commit 40631362b31d78d0eea4c3c45b514bda931b0c76 Author: Dasemu Date: Tue Aug 25 15:32:15 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ba33c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Clojure/ClojureScript build +.cpcache/ +.clj-kondo/.cache/ +.shadow-cljs/ +target/ +out/ +.nrepl-port +.lsp/.cache/ + +# Build output +static/js/ +static/out/ +static/mobile/js/ +static/db-worker-node.js +static/db-worker-node-assets.json +static/electron.js +dist/ +cli/_build/ +cli/**/*.install +resources/.cache/ + +# OCaml/Dune +cli/_opam/ + +# Env / secrets +.env +.env.* +*.pem +*.key + +# OS / editor +.DS_Store +*.log +.vscode/ +.idea/ + +# Python sidecar +sidecar/.venv/ +sidecar/__pycache__/ +**/__pycache__/ + +# Mobile build artifacts +android/app/build/ +android/.gradle/ +ios/App/Pods/ +ios/App/build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..09bcad6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Repository Guidelines + +## Build, Test, and Development Commands +- `bb dev:lint-and-test` runs linters and unit tests. +- `bb dev:test -v ` runs a single unit test (example: `bb dev:test -v logseq.some-test/foo`). +- App E2E tests live in `clj-e2e/`; run from that directory with `bb test` (or `bb -f clj-e2e/bb.edn test` from repo root). +- CLI E2E tests live in `cli-e2e/`; run with `bb -f cli-e2e/bb.edn test --skip-build` (or `bb -f cli-e2e/bb.edn build` first when needed). +- If a request says only “e2e”, clarify whether it targets `clj-e2e/` or `cli-e2e/` before planning changes. + +## Error handling and compatibility +- When modifying code, first consider removing compatibility layers rather than extending them. +- Prefer fail-fast over fallback. +- Do not add backward compatibility unless explicitly requested. +- Do not introduce default values to mask invalid state. +- Do not silently recover from programmer errors. +- Keep one clear code path whenever possible. +- Internal code may assume well-formed inputs from controlled callers. + +## Coding Style & Naming Conventions +- ClojureScript keywords are defined via `logseq.common.defkeywords/defkeyword`; use existing keywords and add new ones in the shared definitions. +- Follow existing namespace and file layout; keep related workers and RTC code in their dedicated directories. +- Prefer concise, imperative commit subjects aligned with existing history (examples: `fix: download`, `enhance(rtc): ...`). +- Clojure map keyword name should prefer `-` instead of `_`, e.g. `:user-id` instead of `:user_id`. +- For i18n work, use `.i18n-lint.toml` as the source of truth for lint scope and exceptions. Inside that scope, shipped UI text must use helpers from `frontend.context.i18n`; console text is exempt. Keep out-of-scope developer-only `(Dev)` labels inline in code/config, not in translation dictionaries. +- Reuse `src/resources/dicts/en.edn` keys only on exact semantic owner + textual role match. Follow `docs/i18n-key-naming.md` for new or renamed keys. Add English source text in `en.edn`; add non-English entries only when providing real translations; keep complete sentences whole; use placeholders for plain dynamic text; run `bb lang:validate-translations`, `bb lang:lint-hardcoded`, and `bb lang:format-dicts` as needed. + +## Testing Guidelines +- Unit tests live in `src/test/` and should be runnable via `bb dev:lint-and-test`. +- Name tests after their namespaces; use `-v` to target a specific test case. +- For CLJS tests, avoid recompiling when source files have not changed since the last successful `cljs:test`; run against the existing compiled test artifact instead. +- Run lint/tests before submitting PRs; keep changes green. + +## *IMPORTANT*: Always respect directory-specific AGENTS.md based on file path +- when editing code in a specific directory, you must recursively read `AGENTS.md` files up the directory tree, and `AGENTS.md` in subdirectories takes precedence over the root-level one + +## Commit & Pull Request Guidelines +- Commit subjects are short and imperative; optional scope prefixes appear (e.g., `fix:` or `enhance(rtc):`). +- PRs should describe the behavior change, link relevant issues, and note any test coverage added or skipped. +- Never include the word "Codex" or any agent name in commit subjects, commit bodies, trailers, branch names, PR titles, or PR descriptions. +- PR titles must describe the code change only. +- Before creating or updating commits or PRs, verify that the generated metadata does not contain "Codex" or any agent name in any casing. + +## Agent-Specific Notes +- Use repo-local skills discovered under `.agents/skills/`; load the matching `SKILL.md` before editing files or proposing changes. +- **i18n (mandatory)**: Always load `.agents/skills/logseq-i18n/SKILL.md` before any change that adds, edits, or removes user-facing UI text, regardless of whether other skills also apply. +- Review notes live in `prompts/review.md`; check them when preparing changes. +- Runtime, sync, CLI, rendering, search, and Graph View engineering guidance is consolidated in `docs/agent-guide/implemented/architecture/2026-08-24-logseq-runtime-and-engineering-guide.md`; current source and tests remain authoritative. +- For db-sync D1 schema changes, add or update a Cloudflare worker SQL migration under `deps/db-sync/worker/migrations/`; do not rely on ad hoc runtime-only schema migration code. +- New properties should be added to `logseq.db.frontend.property/built-in-properties`. +- Avoid creating new class or property unless you have to. +- Avoid shadow var, e.g. `bytes` should be named as `payload`. +- Avoid using `js/Buffer` in browser related code. +- Git commits should include a concise summary. +- When deleting properties in db migration, those properties should be added to `logseq.db-sync.tx-sanitize/migration-deleted-attrs` +- Use spec-dev-tool to manage agent decision documents; run `spec-dev-tool --help` and follow its AGENT WORKFLOW. +- When asked to find simplifications, run `spec-dev-tool guide find-simplifications` from inside this Git worktree and follow the emitted workflow. diff --git a/CODEBASE_OVERVIEW.md b/CODEBASE_OVERVIEW.md new file mode 100644 index 0000000..148b43f --- /dev/null +++ b/CODEBASE_OVERVIEW.md @@ -0,0 +1,88 @@ +# Logseq Codebase Overview + +This document helps you understand more about how Logseq works. To contribute, read the [README](https://github.com/logseq/logseq) first. + +## Tech Stack + +### Clojure/ClojureScript + +Nowadays compile-to-js are common practice. With the advent of web assembly, you can use almost any language to write browser apps. The Logseq app mostly uses Clojure. + +Simply put, Clojure is a dynamic typing functional programming language, with Lisp's syntax, running on the JVM. ClojureScript is just Clojure compiling to JavaScript. + +Clojure is easy to learn, you can pick it up pretty quickly following the [official guide](https://clojure.org/guides/learn/syntax). + +Logseq chose ClojureScript not only because of all the [benefits](https://clojure.org/about/rationale) of the language itself but also because of its awesome ecosystem, such as the [DataScript](https://github.com/tonsky/datascript) library. More on that later. + +### Build Tools + +Shadow-cljs is a tool that helps compiling the ClojureScript code to JavaScript. In addition, it supports more handy features like live reload, code splitting, REPL, etc. + +For other tasks like bundling static resources and building the desktop app, which is not covered by shadow-cljs, Logseq uses the good old [Gulp](https://gulpjs.com). + +### React & Rum + +[React](https://reactjs.org/) is a library for building data-driven UI declaratively. Comparing to the imperative ways (such as DOM manipulation or using jQuery), it's simpler and easier to code correctly. + +[Rum](https://github.com/tonsky/rum) is a React wrapper in ClojureScript. More than just providing the familiar React APIs, Rum adds many Clojure flavors to React, especially on the state management part. As a result, if you have experience with React, read Rum's [README](https://github.com/tonsky/rum) before diving into the code. + +### DataScript + +[DataScript](https://github.com/tonsky/datascript) is an in-memory database that implements the [Datalog](https://en.wikipedia.org/wiki/Datalog) logic programming language. Datalog is very different from and much more expressive than the more common SQL and NoSQL query languages. Many users have implemented interesting features on top of Logseq just by utilizing the rich query language. Get started with Datalog with this [tutorial](http://www.learndatalogtoday.org/) + +## Important Directories and Files + +This is overview of this repository's most important directories and files. + +- Config files are located at the root directory. `package.json` contains the JavaScript dependencies while `deps.edn` contains their ClojureScript counterparts. `shadow-cljs.edn` and `gulpfile.js` contain all the build scripts. + +- `resources/` and `public` contain all the static assets + +- `src/` is where most of the code is located. + + - `src/electron/` contains code specific to the Electron desktop app. + + - `src/test/` contains all the cljs tests. + + - `src/resources/` is a directory and Clojure(Script) resource classpath and includes language translations. + + - `src/main/frontend/` contains code that powers the Logseq editor. Directories and files inside are organized by features or functions. Some notable directories: + - `src/main/frontend/components/` contains all the UI components. + - `src/main/frontend/handler/` contains system component like code. + - `src/main/frontend/worker/` contains code for the separate worker asset. + - `src/main/frontend/common/` contains common code shared by the worker asset and the frontend. + - `src/main/logseq/` contains the api used by plugins. + - `src/main/mobile/` contains code for new mobile app. + - `src/dev-cljs/` contains some development utilities. + +- `deps/` contains ClojureScript dependencies or libraries used by the frontend. + - `deps/graph-parser/` is a library that parses a Logseq graph and saves it to a database. + +- `scripts` - Dev scripts +- `clj-e2e/` - end to end clj frontend tests +- `android/` - Android app +- `ios/` - iOS app + +## Data Flow + +### Application State + +Most of Logseq's application state is divided into two parts. Document-related state (all your pages, blocks, and contents) is stored in DataScript. UI-related state (such as the current editing block) is kept in Clojure's [atom](https://clojure.org/reference/atoms). We then use Rum's reactive component to subscribe to these states. React efficiently re-renders after state changes. + +### When the App Starts + +Logseq loads files from your computer or the cloud, depending on your usage. The files are then parsed (and might be decrypted) and stored in DataScript. Other UI-related states are initialized. React components render for the first time. Event handlers are registered. + +### When you Type Something in the Document + +It's the typical flow of an event-driven GUI application. Various handlers (which are just functions) are listening for events like drag and drop, edit, format, and so on. When you start typing, the handler for editing blocks is called. It does three things: + +- Save your work to the disk or the cloud, so you won't lose them in case of an emergent power off. +- Update the UI state. +- Run transactions to update the DataScript database. Since other parts of the app may use data that are affected by the change, we need to rebuild the database query cache. + +After the change changes, React will dutifully refresh the screen. + +## Architecture + +Logseq has undergone a heavy refactoring, results in a much more robust and clear architecture. Read [this article](https://docs.logseq.com/#/page/The%20Refactoring%20Of%20Logseq) written by the main contributor to the refactoring for a detailed tour. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d621f9b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,140 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering a safe and welcoming environment, we as +the Logseq team pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. Logseq is not merely a note-taking tool, but can potentially become a community for collaboration and learning. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Logseq team is responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Logseq team has the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + + + +## Scope + +This Code of Conduct applies to all Logseq communication channels - online or in person, +and it also applies when an individual is representing the project or its community in +public spaces. Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the moderators on the [Forum](https://discuss.logseq.com), [Discord](https://discord.gg/KpN4eHY) channel, Tienson Qin (@tiensonqin) on GitHub, or the official [Twitter](https://twitter.com/logseq). We will work with you to resolve the issue promptly. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The Logseq team +will maintain confidentiality with regard to the reporter of an incident. +Enforcement may result in an indefinite ban from all official Logseq communication +channels, or other actions as deemed appropriate by the Logseq team. + +Logseq maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Enforcement Guidelines + +Logseq team and community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1e508e8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,173 @@ +# Contributing to Logseq + +Thanks for your interest! :heart: :man_dancing: :woman_dancing: We would love +for you to contribute to Logseq and help make it even better than it is today! + +As a contributor, here is an overview of things to learn about and ways to get involved: + +- [Code of Conduct](#coc) +- [How can I help?](#how-can-i-help) +- [Question or Problem?](#question) +- [Issues and Bugs](#issue) +- [Feature Requests](#feature) +- [Submit an Issue](#submit-issue) +- [Submit a Pull Request](#submit-pr) + +## Code of Conduct + +Help us keep Logseq open and inclusive. +Please read and follow our [Code of Conduct][coc]. + +## How can I help? + +There are many ways you can help. Here are some ways to help without coding: + +- You can help others on [Discord][discord] or [Reddit](https://www.reddit.com/r/logseq). +- You can [contribute to the official docs](https://github.com/logseq/docs/blob/master/CONTRIBUTING.md). +- You can confirm bugs on the [issue tracker][issue-tracker] and mention reproducible steps. It helps the core team to get more reports so we can fix the highest priority bugs. +- You can contribute [translations][translations] with a [pull request](#submit-pr). + +For ways to help with coding, read the next section. + +### Code Contributions + +For contributors who want to help with coding, we have a list of [good first +issues](https://github.com/logseq/db-test/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) +to help you get started. These are issues that are beginner-friendly and do not +require advanced knowledge of the codebase. We encourage new contributors to +start with these issues and gradually work their way up to more challenging +tasks. Another way to help with coding is by extending Logseq with +[plugins](https://docs.logseq.com/#/page/Plugins) and submitting them to the [marketplace](https://github.com/logseq/marketplace) so that the +whole community can benefit. + +## Got a Question or a Problem? + +Please do not open issues for general support questions or feature requests as we want to keep GitHub issues for bug reports. +Instead, we recommend using [Logseq forum][forum] to ask support-related questions. + +The Logseq forum is a much better place to ask questions since: + +- there are more people willing to help on the forum +- questions and answers stay available for public viewing so your question/answer might help someone else +- The forum's voting system assures that the best answers are prominently visible. + +To save your and our time, we will systematically close all issues that are requests for general support and redirect people to the forum. + +If you would like to chat about the question in real-time, you can reach out via [our Discord server][discord]. + +## Found a Bug? + +If you find a bug, you can help us by [submitting an issue](#submit-issue) to our [GitHub Repository][github]. +Even better, you can [submit a Pull Request](#submit-pr) with a fix. + +## Missing a Feature? + +You can *request* a new feature by [Creating a thread][feature-request] in our forum. +If you would like to *implement* a new feature, please open an issue and outline your proposal so that it can be discussed. + +## Submit an Issue + +Before you submit an issue, please search the [issue tracker][issue-tracker]. An issue for your problem might already exist and the discussion might inform you of workarounds readily available. + +To submit an issue, [fill out the bug report template][new-issue]. Please file a +single issue per problem and do not enumerate multiple bugs in the same issue. + +The template will ask you to include the following with each issue: + +- Version of Logseq +- Your operating system +- List of extensions that you have installed. Attempt to recreate the issue after disabling all extensions. +- Reproducible steps (1... 2... 3...) that cause the issue +- What you expected to see, versus what you actually saw +- Images, animations, or a link to a video showing the issue occurring +- A code snippet that demonstrates the issue or a link to a code repository the developers can easily pull down to recreate the issue locally + - **Note:** Because the developers need to copy and paste the code snippet, including a code snippet as a media file (i.e. .gif) is not sufficient. +- Errors from the Dev Tools Console (open from the menu: View > Toggle Developer Tools or press CTRL + Shift + i) + +## Submit a Pull Request (PR) + +Before working on your pull request, please check the following: + +1. Search [GitHub][search-pr] for related PRs that may affect your submission. + +2. Be sure that an issue describes the problem you're fixing or the feature +behavior and design you'd like to add. + +3. Please sign our [Contributor License Agreement (CLA)](#cla). We cannot accept +code without a signed CLA. + +After doing the above, you are ready to work on your PR! To create a PR, fork +this repository and then create a branch for the fix. If contributing to the [database version](/README.md#-database-version), create a branch from master. If contributing to the file (text-only) version e.g. any version up to `0.10.X`, create a branch from [version/file](https://github.com/logseq/logseq/tree/version/file/). +Once you push your code to +your fork, you'll be able to open a PR to the Logseq repository. For more info, +you can follow this [GitHub +guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork). +For more github PR guides, see [these +guides](https://docs.github.com/en/pull-requests). + +### PR Guidelines + +When submitting a Pull Request (PR) or expecting a subsequent review, please follow these guidelines: + +1. The PR is ready for review. If you have work you know how to do, then please keep your changes local until they are ready. If you need help with your PR, feel free to submit with questions. + +2. The PR checks which include tests and [lint checks](https://github.com/logseq/logseq/blob/master/docs/dev-practices.md#linting) are passing. + +3. The PR has no merge conflicts. + +4. The PR has [test(s)](https://github.com/logseq/logseq/blob/master/docs/dev-practices.md#testing) for features or enhancements. Tests for bug fixes are also appreciated as they help prevent regressions. + +5. The PR has a descriptive title that a user can understand. We use these titles to generate changelogs for the user. Most titles use one of these prefixes to categorize the PR e.g. `PREFIX: DESCRIPTION ...`: + * `chore` - Misc changes that aren't dev, feat or fix + * `dev` - Developer related changes + * `enhance` - Enhancements i.e. changes to existing features + * `feat` or `feature` - New features + * `fix` - Bug fixes + * `test` - Test only changes + +6. The PR having "allow edits from maintainers" enabled would be appreciated. Helps us help your contribution. + +7. The PR avoids the following changes that are not helpful to the core team: + * Unrelated refactoring or heavy refactoring + * Code or doc formatting changes including whitespace changes + * Dependency updates e.g. in `package.json` + * Changes that contain multiple unverified resources. This is risky for our users and is a lot of work to verify. A change with one resource that can be verified is acceptable. + +### PR Additional Links + +* To run Logseq locally, see [this doc](https://github.com/logseq/logseq/blob/master/docs/develop-logseq.md) or [this doc for Windows](https://github.com/logseq/logseq/blob/master/docs/develop-logseq-on-windows.md). +* To contribute to translations, please read our [translation contribution guidelines][translations]. +* See [our development practices doc](https://github.com/logseq/logseq/blob/master/docs/dev-practices.md) to learn how we develop. +* See [the overview doc](CODEBASE_OVERVIEW.md) to get an overview of the codebase. + +### Sign the CLA + +Please sign our Contributor License Agreement (CLA) before sending pull requests. For any code +changes to be accepted, the CLA must be signed. It's a quick process, we promise! + +- For individuals, we have a [simple click-through form][individual-cla]. +- For corporations, please contact us. + +If you have more than one GitHub account or multiple email addresses associated with a single GitHub account, you must sign the CLA using the primary email address of the GitHub account used to author Git commits and send pull requests. + +The following documents can help you sort out issues with GitHub accounts and multiple email addresses: + +- +- +- +- + +## Thank You + +Your contributions to open source, large or small, make great projects like this possible. Thank you for taking the time to contribute. + +[coc]: https://github.com/logseq/logseq/blob/master/CODE_OF_CONDUCT.md "Logseq Code Of Conduct" +[translations]: https://github.com/logseq/logseq/blob/master/docs/contributing-to-translations.md "contributing to translations" +[github]: https://github.com/logseq/logseq "Logseq Repo" +[discord]: https://discord.gg/KpN4eHY "Logseq Discord Server" +[individual-cla]: https://cla-assistant.io/logseq/logseq "Individual CLA" +[feature-request]: https://discuss.logseq.com/c/feedback/feature-requests/ "Submit Feature Request" +[forum]: https://discuss.logseq.com "Logseq Forum" +[search-pr]: https://github.com/logseq/logseq/pulls "Search open PRs" +[new-issue]: https://github.com/logseq/db-test/issues/new?assignees=&labels=&template=bug_report.yaml "Submit a New issue" +[issue-tracker]: https://github.com/logseq/db-test/issues "Logseq Issue Tracker" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a60c9fe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# NOTE: please keep it in sync with .github pipelines +# NOTE: during testing make sure to change the branch below +# NOTE: before running the build-docker GH action edit +# build-docker.yml and change the release channel from :latest to :testing + +# Builder image +FROM clojure:temurin-11-tools-deps-1.11.1.1208-bullseye-slim as builder + +ARG DEBIAN_FRONTEND=noninteractive + +# Install reqs +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + apt-transport-https \ + gpg \ + build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev + +# install NodeJS & pnpm +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \ + apt-get update && apt-get install -y nodejs && \ + corepack enable && corepack prepare pnpm@10.33.0 --activate + +WORKDIR /data + +# build Logseq static resources +RUN git clone -b master https://github.com/logseq/logseq.git . + +RUN pnpm install --config.network-timeout=240000 + +RUN pnpm release + +# Web App Runner image +FROM nginx:1.24.0-alpine3.17 + +COPY --from=builder /data/static /usr/share/nginx/html diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0394f2b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,697 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it with any of the below libraries (or a modified version of that library), containing parts covered by the terms of any of the below libraries, the licensors of this Program grant you additional permission to convey the resulting work. + +* cider/cider-nrepl +* clojure-complete +* com.cemerick/friend +* compojure +* environ +* hiccup +* medley +* org.clojure/clojure +* org.clojure/clojurescript +* org.clojure/core.async +* org.clojure/core.cache +* org.clojure/core.incubator +* org.clojure/core.logic +* org.clojure/core.match +* org.clojure/core.memoize +* org.clojure/data.csv +* org.clojure/data.json +* org.clojure/data.priority-map +* org.clojure/java.classpath +* org.clojure/java.jdbc +* org.clojure/math.numeric-tower +* org.clojure/tools.analyzer +* org.clojure/tools.analyzer.jvm +* org.clojure/tools.logging +* org.clojure/tools.macro +* org.clojure/tools.namespace +* org.clojure/tools.nrepl +* org.clojure/tools.reader +* org.tcrawley/dynapath +* refactor-nrepl +* slingshot diff --git a/README.md b/README.md new file mode 100644 index 0000000..662bad1 --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ + +

+ + +

+ +

Logseq

+ +

+ A privacy-first, open-source platform for knowledge management and collaboration +

+ + +

+ +

+ + Download Logseq +

+ + +

+ + forum + + chat on Discord + + follow on Twitter +

+ + +

+ + + + + + + + + + latest release version +

+ +## Table of Contents + + * [🚀 Database Version](#-database-version) + * [🤔 Why Logseq?](#-why-logseq) + * [👀 How can I use it?](#-how-can-i-use-it) + * [📚 Learn more](#-learn-more) + * [🫶 Support Logseq Development](#-support-logseq-development) + * [💡 Feature requests](#-feature-requests) + * [🔌 Plugin API](#-plugin-api) + * [🌟 Contributing to Logseq](#-contributing-to-logseq) + * [🛠️ Setting Up a Development Environment](#️-setting-up-a-development-environment) + * [✨ Inspiration](#-inspiration) +* [🙏 Thank You](#-thank-you) + +## 🚀 Database Version + +The Database version (DB version) of Logseq introduces DB graphs. [See this page](https://github.com/logseq/docs/blob/master/db-version.md) to get an overview of the main features for DB graphs. If you are an existing user, [see changes with the DB version](https://github.com/logseq/docs/blob/master/db-version-changes.md). The DB version has its own new mobile app (on iOS, with Android coming soon)! To participate in the mobile app alpha, [please complete this brief form](https://forms.gle/nfefJv51jUuULbFB9). The DB version also has a new sync approach, RTC (Real Time Collaboration)! You can use it to sync graphs between multiple devices or collaborate with others. To participate in the RTC alpha, [please fill out this form](https://forms.gle/YSyF4WfKPSDuwyjH6). + +The DB version is in beta status while the new mobile app and RTC is in alpha. This means that **data loss is possible** so we recommend [automated backups](https://github.com/logseq/docs/blob/master/db-version.md#automated-backup) or [regular SQLite DB backups](https://github.com/logseq/docs/blob/master/db-version.md#graph-export). We recommend you create a dedicated test graph and choose one project that’s not crucial for you. + +To get started with the DB version: +* To try the latest web version, go to https://app.logseq.com/. +* To try the latest desktop version, go to https://github.com/logseq/logseq/releases/tag/nightly and download the artifact for your operating system. +* To try the latest by building from the source code + * Use `test/db` for stable releases. Fewer bugs and slower updates. Update frequency: days or weeks. + * Use `master` for the latest updates as they are developed. Expect more bugs and faster changes. Update frequency: hours or days. +* To report bugs, please file them at https://github.com/logseq/db-test/issues. +* For feature or enhancement requests, please file them on Discord on the `#db-feedback` channel. +* For discussions: + * General ==> see the `#db-chat` channel in Discord. + * Sync and RTC ==> see the `#sync-test` channel in Discord. + * Mobile ==> see the `#mobile-test` channel in Discord. + +## 🤔 Why Logseq? + +[Logseq](https://logseq.com) is a **knowledge management** and **collaboration** platform. It focuses on **privacy**, **longevity**, and [**user control**](https://www.gnu.org/philosophy/free-sw.en.html). Logseq offers a range of **powerful tools** for **knowledge management**, **collaboration**, **PDF annotation**, and **task management** with support for multiple file formats, including **Markdown** and **Org-mode**, and **various features** for organizing and structuring your notes. + +In addition to its core features, Logseq has a growing ecosystem of **plugins** and **themes** that enable a wide range of workflows and **customization** options. **Mobile apps** are also available, providing access to most of the features of the desktop application. Whether you're a student, a professional, or anyone who values a clear and organized approach to managing your ideas and notes, Logseq is an excellent choice for anyone looking to improve their productivity and streamline their workflow. + +![logseq-demo](https://user-images.githubusercontent.com/25513724/221387376-4dc419c2-0d0a-460c-a920-2d211e78b456.gif) + + + Download Logseq + +## 👀 How can I use it? + +To start using Logseq, follow these simple steps: + +1. [Download](https://github.com/logseq/logseq/releases/latest) the latest version of Logseq +2. Install Logseq on your device and launch the application +3. Start writing ✍️ + +That's it! You can now enjoy the benefits of using Logseq to streamline your workflow, manage your projects, and stay on top of your goals. Have fun! 🎉 + +**Linux users**: Use the automated installer script for the best experience: + + ```bash + # Download and run the installer + curl -fsSL https://raw.githubusercontent.com/logseq/logseq/master/scripts/install-linux.sh | bash + + # Or install a specific version + curl -fsSL https://raw.githubusercontent.com/logseq/logseq/master/scripts/install-linux.sh | bash -s -- 0.10.14 + + # For user-specific installation (no root required) + curl -fsSL https://raw.githubusercontent.com/logseq/logseq/master/scripts/install-linux.sh | bash -s -- --user + ``` + +## 📚 Learn more + +* Website: [logseq.com](https://logseq.com) +* Documentation: [docs.logseq.com](https://docs.logseq.com) + * FAQ page: [Logseq Docs: FAQ](https://docs.logseq.com/#/page/faq) +* Blog: [blog.logseq.com](https://blog.logseq.com) + * Please visit our [About page](https://blog.logseq.com/about) for the latest updates. +* Forum: [discuss.logseq.com](https://discuss.logseq.com) - Where we answer questions, discuss workflows, and share tips + * FAQ forum section: [Logseq Forum: FAQ](https://discuss.logseq.com/c/faq/6) +* [Awesome Logseq](https://github.com/logseq/awesome-logseq) - Awesome Logseq extensions and resources created by the community <3 +* Twitter: [@Logseq](https://twitter.com/logseq) +* Discord: [https://discord.com/invite/KpN4eHY](https://discord.com/invite/KpN4eHY) + * [中文 Discord](https://discord.gg/xYqcrXWymg) + +## 🫶 Support Logseq Development + +If you find Logseq useful and want to help us keep the project growing, please consider supporting our contributors on [Open Collective](https://opencollective.com/logseq). Your support shows our contributors that their efforts are appreciated and motivates them to continue their excellent work. Every contribution, no matter how small, helps us keep improving Logseq. + +## 💡 Feature requests + +We value your input on improving Logseq and making it more useful for you. If you have any ideas or feature requests, please share them in the [Logseq Forum: Feature +Requests](https://discuss.logseq.com/new-topic?category=feature-requests) section. + +Your feedback helps us understand our users' needs and prioritize the features that matter most to you. We appreciate your time and effort in sharing your thoughts with us. + +We appreciate your support, and we look forward to hearing your ideas! + +## 🔌 Plugin API + +Logseq provides a plugin API that enables developers to create custom plugins and extend the functionality of Logseq. The plugin API documentation is available at [plugins-doc.logseq.com](https://plugins-doc.logseq.com/), where you can find everything needed to get started with plugin development. + +We value your feedback and suggestions on how to improve our documentation. Please do not hesitate to contact us with any comments or questions. Your input helps us to provide a better experience for our users and developers. + +Thank you for using Logseq, and we look forward to seeing what you create with our plugin API! + +## 🌟 Contributing to Logseq + +To start contributing to Logseq, please read [CONTRIBUTING.md](CONTRIBUTING.md). +There are ways to contribute [with code](https://github.com/logseq/logseq/blob/master/CONTRIBUTING.md#code-contributions) and [without code](https://github.com/logseq/logseq/blob/master/CONTRIBUTING.md#-how-can-i-help). We welcome all +contributions, big or small, and we appreciate your time and effort in helping +us improve Logseq. We look forward to your contributions 🚀 + +### 🛠️ Setting Up a Development Environment + +If you want to set up a development environment for the Logseq web or desktop app, please refer to the [Develop Logseq](docs/develop-logseq.md) guide for macOS/Linux users and the [Develop Logseq on Windows](docs/develop-logseq-on-windows.md) guide for Windows users. + +In addition to these guides, you can also find other helpful resources in the [docs/](docs/) folder, such as the [Guide for Contributing to Translations](docs/contributing-to-translations.md), the [Docker Web App Guide](docs/docker-web-app-guide.md) and the [mobile development guide](docs/develop-logseq-on-mobile.md) + +### 🧰 Logseq CLI (Node) + +Logseq CLI documentation is maintained in `docs/cli/logseq-cli.md`. + +## ✨ Inspiration + +Logseq is inspired by several unique tools and projects, including [Roam Research](https://roamresearch.com/), [Org Mode](https://orgmode.org/), [TiddlyWiki](https://tiddlywiki.com/), [Workflowy](https://workflowy.com/), and [Cuekeeper](https://github.com/talex5/cuekeeper). + +We owe a huge debt of gratitude to the developers and creators of these projects, and we hope that Logseq can continue to build on their innovative ideas and make them accessible to a broader audience. + +Thank you to all those who inspire us, and we look forward to seeing what the Logseq community will create with this tool! + +Logseq is also made possible by the following projects: + +* [Clojure & ClojureScript](https://clojure.org/) - A dynamic, functional, general-purpose programming language +* [DataScript](https://github.com/tonsky/datascript) - An immutable database and Datalog query-engine for Clojure, +ClojureScript and JS +* [OCaml](https://ocaml.org/) & [Angstrom](https://github.com/inhabitedtype/angstrom), for the document parser [mldoc](https://github.com/logseq/mldoc) +* [isomorphic-git](https://isomorphic-git.org/) - A pure JavaScript implementation of Git for NodeJS and web browsers +* [SCI](https://github.com/borkdude/sci) - A Small Clojure Interpreter + +# 🙏 Thank You + +We want to express our sincere gratitude to our [Open Collective](https://opencollective.com/logseq) **sponsors**, **backers**, and **contributors**. Your support and contributions allow us to continue developing and improving Logseq. Thank you for being a part of our community and helping us make Logseq the best it can be! + +## 💎 Sponsors + +

+ [Become a sponsor] +

+

+ + +

+ +## 🌟 Contributors + +

+ + +

+ + +

+ + +

+ + +

+ Logseq - Your joyful, private digital garden | Product Hunt +

diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..4230ebe --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,99 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Sentry Config File +sentry.properties diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..6cf1012 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,79 @@ +apply plugin: 'com.android.application' + +apply from: 'capacitor.build.gradle' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace "com.logseq.app" + compileSdkVersion rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.logseq.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 88 + versionName "2.0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion rootProject.ext.composeCompilerVersion + } + + kotlinOptions { + jvmTarget = '21' + } +} + +repositories { + flatDir { + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation platform("androidx.compose:compose-bom:$composeBomVersion") + implementation "androidx.activity:activity-compose:$androidxActivityVersion" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.foundation:foundation" + implementation "androidx.compose.material3:material3" + implementation "androidx.compose.material:material-icons-extended" + implementation "androidx.compose.runtime:runtime" + implementation "androidx.compose.animation:animation" + implementation "androidx.navigation:navigation-compose:$androidxNavigationVersion" + implementation "com.google.android.material:material:$materialVersion" + implementation project(':capacitor-android') + implementation 'androidx.documentfile:documentfile:1.0.1' + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch (Exception e) { + logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..4699097 --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,36 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-community-safe-area') + implementation project(':capacitor-action-sheet') + implementation project(':capacitor-app') + implementation project(':capacitor-camera') + implementation project(':capacitor-clipboard') + implementation project(':capacitor-device') + implementation project(':capacitor-dialog') + implementation project(':capacitor-filesystem') + implementation project(':capacitor-haptics') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-network') + implementation project(':capacitor-share') + implementation project(':capacitor-splash-screen') + implementation project(':capacitor-status-bar') + implementation project(':capgo-capacitor-navigation-bar') + implementation project(':send-intent') + implementation project(':jcesarmobile-ssl-skip') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b095f8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json new file mode 100644 index 0000000..d49d192 --- /dev/null +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -0,0 +1,74 @@ +[ + { + "pkg": "@aparajita/capacitor-secure-storage", + "classpath": "com.aparajita.capacitor.securestorage.SecureStorage" + }, + { + "pkg": "@capacitor-community/safe-area", + "classpath": "com.getcapacitor.community.safearea.SafeAreaPlugin" + }, + { + "pkg": "@capacitor/action-sheet", + "classpath": "com.capacitorjs.plugins.actionsheet.ActionSheetPlugin" + }, + { + "pkg": "@capacitor/app", + "classpath": "com.capacitorjs.plugins.app.AppPlugin" + }, + { + "pkg": "@capacitor/camera", + "classpath": "com.capacitorjs.plugins.camera.CameraPlugin" + }, + { + "pkg": "@capacitor/clipboard", + "classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin" + }, + { + "pkg": "@capacitor/device", + "classpath": "com.capacitorjs.plugins.device.DevicePlugin" + }, + { + "pkg": "@capacitor/dialog", + "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" + }, + { + "pkg": "@capacitor/filesystem", + "classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" + }, + { + "pkg": "@capacitor/haptics", + "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" + }, + { + "pkg": "@capacitor/keyboard", + "classpath": "com.capacitorjs.plugins.keyboard.KeyboardPlugin" + }, + { + "pkg": "@capacitor/network", + "classpath": "com.capacitorjs.plugins.network.NetworkPlugin" + }, + { + "pkg": "@capacitor/share", + "classpath": "com.capacitorjs.plugins.share.SharePlugin" + }, + { + "pkg": "@capacitor/splash-screen", + "classpath": "com.capacitorjs.plugins.splashscreen.SplashScreenPlugin" + }, + { + "pkg": "@capacitor/status-bar", + "classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin" + }, + { + "pkg": "@capgo/capacitor-navigation-bar", + "classpath": "ee.forgr.capacitor_navigation_bar.CapgoNavigationBarPlugin" + }, + { + "pkg": "send-intent", + "classpath": "de.mindlib.sendIntent.SendIntent" + }, + { + "pkg": "@jcesarmobile/ssl-skip", + "classpath": "com.jcesarmobile.sslskip.SslSkipPlugin" + } +] diff --git a/android/app/src/main/java/com/logseq/app/ComposeHost.kt b/android/app/src/main/java/com/logseq/app/ComposeHost.kt new file mode 100644 index 0000000..5f79d15 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/ComposeHost.kt @@ -0,0 +1,375 @@ +package com.logseq.app + +import android.graphics.Color +import android.app.Activity +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val ROOT_ROUTE = "web/{encodedPath}" +private const val DEBUG_NAV_STACK_PREFIX = "[DEBUG-navstack]" + +/** + * Hosts the existing WebView inside Compose and drives Compose Navigation + * so we get back gestures/animations while delegating actual routing to the JS layer. + */ +object ComposeHost { + private val navEvents = MutableSharedFlow(extraBufferCapacity = 64) + val navigationEvents: SharedFlow = navEvents + + @JvmStatic + fun applyNavigation(state: NavigationRenderState) { + Log.d( + "NavStack", + "$DEBUG_NAV_STACK_PREFIX compose.applyNavigation stack=${state.activeStackId} previous=${state.previousStackId} " + + "type=${state.navigationType} path=${state.path} paths=${state.paths} switched=${state.stackSwitched}" + ) + if (!navEvents.tryEmit(state)) { + Log.w( + "ComposeHost", + "Dropped navigation state: stack=${state.activeStackId} type=${state.navigationType} path=${state.path}" + ) + } + } + + fun renderWithSystemInsets( + activity: Activity, + webView: WebView + ) { + WebViewSnapshotManager.registerWindow(activity.window) + val root = activity.findViewById(android.R.id.content) + + // WebView already created by BridgeActivity; just reparent it into Compose. + (webView.parent as? ViewGroup)?.removeView(webView) + + val composeView = ComposeView(activity).apply { + tag = "compose-host-webview" + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ComposeNavigationHost( + webView = webView + ) + } + } + + if (root.findViewWithTag("compose-host-webview") == null) { + root.addView( + composeView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + } +} + +private fun encodePath(path: String): String = + Uri.encode(if (path.isBlank()) "/" else path) + +private fun routeFor(path: String): String = + "web/${encodePath(path)}" + +private fun decodePath(encodedPath: String?): String = + Uri.decode(encodedPath?.takeIf { it.isNotBlank() } ?: encodePath("/")) + +private fun NavHostController.currentPath(): String? = + currentBackStackEntry?.arguments?.getString("encodedPath")?.let(::decodePath) + +private fun NavHostController.rebuildBackStack(paths: List) { + val normalizedPaths = paths.ifEmpty { listOf("/") } + val rootRoute = routeFor(normalizedPaths.first()) + navigate(rootRoute) { + popUpTo(ROOT_ROUTE) { + inclusive = true + } + launchSingleTop = true + } + normalizedPaths.drop(1).forEach { path -> + navigate(routeFor(path)) { + launchSingleTop = true + } + } +} + +private fun shouldAnimateNavigation(navigationType: String): Boolean = + when (navigationType.trim().lowercase()) { + "push", + "pop", + "back", + "forward", + "replace" -> true + else -> false + } + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun ComposeNavigationHost( + webView: WebView +) { + val navController = rememberNavController() + + DisposableEffect(navController) { + navController.enableOnBackPressed(false) + Log.d("NavStack", "$DEBUG_NAV_STACK_PREFIX compose.navController.backHandling disabled") + onDispose { + navController.enableOnBackPressed(true) + } + } + + // Track the last navigation type so we can change slide direction. + val lastNavTypeState = remember { mutableStateOf("push") } + + HandleNavigationEvents( + navController = navController, + webView = webView + ) { type -> + lastNavTypeState.value = type + } + + // You can comment this out if you want to rely purely on JS for back. + + NavHost( + navController = navController, + startDestination = ROOT_ROUTE, + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars.asPaddingValues()) + ) { + composable( + route = ROOT_ROUTE, + arguments = listOf( + navArgument("encodedPath") { + defaultValue = encodePath("/") + } + ), + // ---- PUSH: A -> B ---- + enterTransition = { + val navType = lastNavTypeState.value + if (!shouldAnimateNavigation(navType)) { + EnterTransition.None + } else if (navType == "pop") { + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 3 }, + animationSpec = tween(220) + ) + fadeIn(animationSpec = tween(180)) + } else { + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(220) + ) + fadeIn(animationSpec = tween(180)) + } + }, + exitTransition = { + val navType = lastNavTypeState.value + if (!shouldAnimateNavigation(navType)) { + ExitTransition.None + } else if (navType == "pop") { + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(160)) + } else { + slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = tween(220) + ) + fadeOut(animationSpec = tween(180)) + } + }, + // ---- POP: B -> A ---- + popEnterTransition = { + if (!shouldAnimateNavigation(lastNavTypeState.value)) { + EnterTransition.None + } else { + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = tween(200) + ) + fadeIn(animationSpec = tween(160)) + } + }, + popExitTransition = { + if (!shouldAnimateNavigation(lastNavTypeState.value)) { + ExitTransition.None + } else { + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(160)) + } + } + ) { + AndroidView( + factory = { context -> + FrameLayout(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + + val webContainer = FrameLayout(context).apply { + id = R.id.webview_container + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + + val overlayContainer = FrameLayout(context).apply { + id = R.id.webview_overlay_container + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + isClickable = false + isFocusable = false + } + + overlayContainer.setBackgroundColor(Color.TRANSPARENT) + overlayContainer.alpha = 1f + overlayContainer.visibility = android.view.View.GONE + + addView(webContainer) + addView(overlayContainer) + + WebViewSnapshotManager.registerOverlay(overlayContainer) + + (webView.parent as? ViewGroup)?.removeView(webView) + webContainer.addView( + webView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + }, + modifier = Modifier.fillMaxSize(), + update = { root -> + val webContainer = + root.findViewById(R.id.webview_container) + val overlayContainer = + root.findViewById(R.id.webview_overlay_container) + WebViewSnapshotManager.registerOverlay(overlayContainer) + if (webView.parent !== webContainer) { + (webView.parent as? ViewGroup)?.removeView(webView) + webContainer.addView( + webView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + } + ) + } + } +} + +@Composable +private fun HandleNavigationEvents( + navController: NavHostController, + webView: WebView, + onNavType: (String) -> Unit +) { + LaunchedEffect(navController) { + var snapshotVersion = 0 + ComposeHost.navigationEvents.collect { state -> + snapshotVersion += 1 + val currentSnapshotVersion = snapshotVersion + val paths = state.paths.ifEmpty { listOf(state.path) } + val targetPath = paths.lastOrNull() ?: state.path + val route = routeFor(targetPath) + val animateNavigation = shouldAnimateNavigation(state.navigationType) && !state.stackSwitched + Log.d( + "NavStack", + "$DEBUG_NAV_STACK_PREFIX compose.event.before stack=${state.activeStackId} previous=${state.previousStackId} " + + "type=${state.navigationType} path=$targetPath paths=$paths switched=${state.stackSwitched} targetRoute=$route " + + "currentRoute=${navController.currentBackStackEntry?.destination?.route} " + + "currentArgs=${navController.currentBackStackEntry?.arguments} " + + "previousRoute=${navController.previousBackStackEntry?.destination?.route}" + ) + if (animateNavigation) { + WebViewSnapshotManager.showSnapshot("navigation", webView) + } else { + WebViewSnapshotManager.clearSnapshot("navigation") + } + onNavType(state.navigationType) + when { + state.stackSwitched || state.navigationType == "reset" -> { + navController.rebuildBackStack(paths) + } + + state.navigationType == "push" -> { + if (navController.currentPath() != targetPath) { + navController.navigate(route) + } + } + + state.navigationType == "replace" -> { + navController.popBackStack() + navController.navigate(route) { + launchSingleTop = true + } + } + + state.navigationType == "pop" -> { + if (!navController.popBackStack(route, inclusive = false)) { + navController.rebuildBackStack(paths) + } + } + + else -> navController.navigate(route) + } + Log.d( + "NavStack", + "$DEBUG_NAV_STACK_PREFIX compose.event.after stack=${state.activeStackId} type=${state.navigationType} path=$targetPath targetRoute=$route " + + "currentRoute=${navController.currentBackStackEntry?.destination?.route} " + + "currentArgs=${navController.currentBackStackEntry?.arguments} " + + "previousRoute=${navController.previousBackStackEntry?.destination?.route}" + ) + + if (animateNavigation) { + launch { + delay(260) + if (currentSnapshotVersion == snapshotVersion) { + WebViewSnapshotManager.clearSnapshot("navigation") + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/FileUtil.java b/android/app/src/main/java/com/logseq/app/FileUtil.java new file mode 100644 index 0000000..52df482 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/FileUtil.java @@ -0,0 +1,214 @@ +package com.logseq.app; + +import android.annotation.TargetApi; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.util.Log; + +import java.io.File; + +// The following refs +// https://stackoverflow.com/questions/29713587/how-to-get-the-real-path-with-action-open-document-tree-intent +// https://gist.github.com/asifmujteba/d89ba9074bc941de1eaa#file-asfurihelper +// with bug fixes and patches. +public class FileUtil { + @TargetApi(Build.VERSION_CODES.KITKAT) + public static String getPath(final Context context, final Uri uri) { + + final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + + // DocumentProvider + if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + // NOTE: It's not a good idea to use storage root as Graph root. + String remain = ""; + if (split.length == 2) { + remain = split[1]; + } + + if ("primary".equalsIgnoreCase(type)) { + return Environment.getExternalStorageDirectory() + "/" + remain; + } else if ("home".equalsIgnoreCase(type)) { + return Environment.getExternalStorageDirectory() + "/Documents/" + remain; + } else if ("downloads".equalsIgnoreCase(type)) { + return Environment.getExternalStorageDirectory() + "/Download/" + remain; // No 's' here + } + + File dir = null; + File[] mdirs = context.getExternalMediaDirs(); + for (File mdir : mdirs) { + String extPath = mdir.getAbsolutePath(); + + if (extPath.contains("/" + type + "/")) { + dir = new File(extPath.substring(0, extPath.indexOf("/Android")) + "/" + remain); + if (dir.exists()) { + return dir.getAbsolutePath(); + } + } + } + // FIXME: The following attempt cannot handle same directory name on different devices! + // attempt 1 + dir = new File("/storage/" + type + "/" + remain); + if (dir.exists()) { + return dir.getAbsolutePath(); + } + // attempt 3 + dir = new File("/mnt/media_rw/" + type + "/" + remain); + if (dir.exists()) { + return dir.getAbsolutePath(); + } + // attempt 3 + dir = new File("/mnt/" + type + "/" + remain); + if (dir.exists()) { + return dir.getAbsolutePath(); + } + + // TODO: other cases + } else if (isDownloadsDocument(uri)) { + final String id = DocumentsContract.getDocumentId(uri); + if (!TextUtils.isEmpty(id)) { + if (id.startsWith("raw:")) { + return id.replaceFirst("raw:", ""); + } + try { + final Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); + return getDataColumn(context, contentUri, null, null); + } catch (NumberFormatException e) { + return null; + } + } + } else if (isMediaDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + Uri contentUri = null; + if ("image".equals(type)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if ("video".equals(type)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else if ("audio".equals(type)) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + final String selection = "_id=?"; + final String[] selectionArgs = new String[]{ + split[1] + }; + + return getDataColumn(context, contentUri, selection, selectionArgs); + } else if (isTermuxDocument(uri)) { + String docId = DocumentsContract.getDocumentId(uri); + + // Ref: https://github.com/termux/termux-app/blob/master/app/src/main/java/com/termux/app/TermuxInstaller.java + if (docId.startsWith("/")) { + if (docId.contains("/com.termux/files/home/storage/")) { + String remain = docId.replaceFirst("^.*?com\\.termux/files/home/storage/[^/]+/", ""); + if (docId.contains("/storage/external-1")) { // TODO: Support external-2 or more + File[] dirs = context.getExternalFilesDirs(remain); + if (dirs != null && dirs.length >= 2) { + docId = dirs[1].getAbsolutePath(); + } + } else if (docId.contains("/storage/media-1")) { + File[] dirs = context.getExternalMediaDirs(); + if (dirs != null && dirs.length >= 2) { + docId = dirs[1].getAbsolutePath() + "/" + remain; + } + } else if (docId.contains("/storage/downloads")) { + docId = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/" + remain; + } else if (docId.contains("/storage/shared")) { + docId = Environment.getExternalStorageDirectory() + "/" + remain; + } + } + File dir = new File(docId); + if (dir.exists()) { + return dir.getAbsolutePath(); + } + Log.e("Logseq/FileUtil", "Handle termux content url failed: " + docId); + } + // FIXME: Are there any other cases? + } + } else if ("content".equalsIgnoreCase(uri.getScheme())) { + // Return the remote address + if (isGooglePhotosUri(uri)) + return uri.getLastPathSegment(); + + return getDataColumn(context, uri, null, null); + } else if ("file".equalsIgnoreCase(uri.getScheme())) { + return uri.getPath(); + } + + return null; + } + + public static String getDataColumn(Context context, Uri uri, String selection, + String[] selectionArgs) { + + Cursor cursor = null; + final String column = "_data"; + final String[] projection = { + column + }; + + try { + cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, + null); + if (cursor != null && cursor.moveToFirst()) { + final int index = cursor.getColumnIndexOrThrow(column); + return cursor.getString(index); + } + } finally { + if (cursor != null) + cursor.close(); + } + return null; + } + + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + public static boolean isExternalStorageDocument(Uri uri) { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + public static boolean isDownloadsDocument(Uri uri) { + return "com.android.providers.downloads.documents".equals(uri.getAuthority()); + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + public static boolean isMediaDocument(Uri uri) { + return "com.android.providers.media.documents".equals(uri.getAuthority()); + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + public static boolean isGooglePhotosUri(Uri uri) { + return "com.google.android.apps.photos.content".equals(uri.getAuthority()); + } + + public static boolean isTermuxDocument(Uri uri) { + return "com.termux.documents".equals(uri.getAuthority()); + } +} diff --git a/android/app/src/main/java/com/logseq/app/FolderPicker.java b/android/app/src/main/java/com/logseq/app/FolderPicker.java new file mode 100644 index 0000000..e89b71c --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/FolderPicker.java @@ -0,0 +1,91 @@ +package com.logseq.app; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.os.Build; +import android.provider.DocumentsContract; +import android.provider.Settings; +import android.util.Log; + +import androidx.activity.result.ActivityResult; +import androidx.core.content.FileProvider; +import androidx.documentfile.provider.DocumentFile; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.annotation.ActivityCallback; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; + +import java.io.File; + + +@CapacitorPlugin(name = "FolderPicker") +public class FolderPicker extends Plugin { + public static boolean FileAccessAllowed() + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return true; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return (Environment.isExternalStorageManager()); + } + return false; + } + + @PluginMethod() + public void pickFolder(PluginCall call) { + if (FileAccessAllowed()) { + Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + i.addCategory(Intent.CATEGORY_DEFAULT); + startActivityForResult(call, i, "folderPickerResult"); + } else { + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + Uri uri = Uri.fromParts("package", this.getContext().getPackageName(), null); + intent.setData(uri); + startActivityForResult(call, intent, 20); + } + } + + @PluginMethod() + public void openFile(PluginCall call) { + Uri uri = Uri.parse(call.getString("uri")); + File file = new File(uri.getPath()); + + // Get URI and MIME type of file + String appId = getAppId(); + uri = FileProvider.getUriForFile(getActivity(), appId + ".fileprovider", file); + String mime = getContext().getContentResolver().getType(uri); + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mime); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + getContext().startActivity(intent); + } + + @ActivityCallback + private void folderPickerResult(PluginCall call, ActivityResult result) { + if (call == null) { + return; + } + JSObject ret = new JSObject(); + Context context = this.getContext(); + Uri treeUri = result.getData().getData(); + Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, + DocumentsContract.getTreeDocumentId(treeUri)); + Log.i("Logseq/FolderPicker", "Got uri " + docUri); + String path = FileUtil.getPath(context, docUri); + Log.i("Logseq/FolderPicker", "Convert to path " + FileUtil.getPath(context, docUri)); + if (path == null || path.isEmpty()) { + call.reject("Cannot support this directory type: " + docUri); + } else { + Uri folderUri = Uri.fromFile(new File(path)); + ret.put("path", folderUri.toString()); + call.resolve(ret); + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/FsWatcher.java b/android/app/src/main/java/com/logseq/app/FsWatcher.java new file mode 100644 index 0000000..0f896dd --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/FsWatcher.java @@ -0,0 +1,307 @@ +package com.logseq.app; + +import android.system.ErrnoException; +import android.system.Os; +import android.system.StructStat; +import android.util.Log; +import android.os.FileObserver; + +import android.net.Uri; + +import java.io.*; + +import java.net.URI; +import java.text.Normalizer; +import java.util.HashMap; +import java.util.Map; +import java.util.Stack; +import java.util.regex.Pattern; + +import java.io.File; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.PluginCall; + +@CapacitorPlugin(name = "FsWatcher") +public class FsWatcher extends Plugin { + private String mPath; + private PollingFsWatcher mWatcher; + private Thread mThread; + + @Override + public void load() { + Log.i("FsWatcher", "Android fs-watcher loaded!"); + } + + @PluginMethod() + public void watch(PluginCall call) { + String pathParam = call.getString("path"); + // check file:// or no scheme uris + Uri u = Uri.parse(pathParam); + Log.i("FsWatcher", "watching " + u); + // TODO: handle context:// uri + if (u.getScheme() == null || u.getScheme().equals("file")) { + File pathObj; + try { + pathObj = new File(u.getPath()); + } catch (Exception e) { + call.reject("invalid watch path: " + pathParam); + return; + } + + mPath = pathObj.getAbsolutePath(); + + if (mWatcher != null) { + call.reject("already watching"); + return; + } + + mWatcher = new PollingFsWatcher(mPath); + mThread = new Thread(mWatcher); + mThread.start(); + + call.resolve(); + } else { + call.reject(u.getScheme() + " scheme not supported"); + } + } + + @PluginMethod() + public void unwatch(PluginCall call) { + Log.i("FsWatcher", "unwatch all..."); + + if (mWatcher != null) { + mThread.interrupt(); + mWatcher = null; + } + + call.resolve(); + } + + // add, change, unlink events + public void onObserverEvent(int event, String path, SimpleFileMetadata metadata) { + JSObject obj = new JSObject(); + String content = null; + File f = new File(path); + + boolean shouldRead = false; + if (Pattern.matches("(?i)[^.].*?\\.(md|org|css|edn|js|markdown|excalidraw)$", f.getName())) { + shouldRead = true; + } + + Uri dir = Uri.fromFile(new File(mPath)); + Uri fpath = Uri.fromFile(f); + String relpath = null; + + if (fpath.getPath().startsWith(dir.getPath())) { + relpath = fpath.getPath().substring(dir.getPath().length()); + if (relpath.startsWith("/")) { + relpath = relpath.substring(1); + } + } else { + Log.e("FsWatcher", "file path not under watch path"); + return; + } + + + obj.put("path", Normalizer.normalize(relpath, Normalizer.Form.NFC)); + obj.put("dir", Uri.fromFile(new File(mPath))); // Uri is for Android. URI is for RFC compatible + JSObject stat; + + switch (event) { + case FileObserver.MODIFY: + obj.put("event", "change"); + stat = new JSObject(); + stat.put("mtime", metadata.mtime); + stat.put("ctime", metadata.ctime); + stat.put("size", metadata.size); + obj.put("stat", stat); + if (shouldRead) { + try { + content = getFileContents(f); + } catch (IOException e) { + Log.e("FsWatcher", "error reading modified file"); + e.printStackTrace(); + } + } + + Log.i("FsWatcher", "prepare event " + obj); + obj.put("content", content); + break; + case FileObserver.CREATE: + obj.put("event", "add"); + stat = new JSObject(); + stat.put("mtime", metadata.mtime); + stat.put("ctime", metadata.ctime); + stat.put("size", metadata.size); + obj.put("stat", stat); + if (shouldRead) { + try { + content = getFileContents(f); + } catch (IOException e) { + Log.e("FsWatcher", "error reading new file"); + e.printStackTrace(); + } + } + obj.put("content", content); + break; + case FileObserver.DELETE: + if (f.exists()) { + Log.i("FsWatcher", "abandon delete notification due to file exists"); + return; + } else { + obj.put("event", "unlink"); + } + Log.i("FsWatcher", "prepare event " + obj); + break; + default: + // unreachable? + obj.put("event", "unknown"); + break; + } + + notifyListeners("watcher", obj); + } + + public static String getFileContents(final File file) throws IOException { + InputStream inputStream = new FileInputStream(file); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + byte[] buffer = new byte[1024]; + int length; + + while ((length = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, length); + } + + inputStream.close(); + return outputStream.toString("utf-8"); + } + + public class SimpleFileMetadata { + public long mtime; + public long ctime; + public long size; + public long ino; + + public SimpleFileMetadata(File file) throws ErrnoException { + StructStat stat = Os.stat(file.getPath()); + mtime = stat.st_mtime; + ctime = stat.st_ctime; + size = stat.st_size; + ino = stat.st_ino; + } + + public boolean equals(SimpleFileMetadata other) { + return mtime == other.mtime && ctime == other.ctime && size == other.size && ino == other.ino; + } + } + + + public class PollingFsWatcher implements Runnable { + private String mPath; + private Map metaDb; + + public PollingFsWatcher(String path) { + metaDb = new HashMap(); + + File dir = new File(path); + try { + mPath = dir.getCanonicalPath(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public void run() { + this.tick(false); // skip initial notification + + while (!Thread.currentThread().isInterrupted()) { + try { + this.tick(true); + Thread.sleep(2000); // The same as iOS fswatcher, 2s interval + } catch (InterruptedException e) { + // e.printStackTrace(); + Log.i("FsWatcher", "interrupted, unwatch"); + break; + } + } + + } + + private void tick(boolean shouldNotify) { + Map newMetaDb = new HashMap(); + + Stack paths = new Stack(); + paths.push(mPath); + while (!paths.isEmpty()) { + String dir = paths.pop(); + File curr = new File(dir); + + File[] files = curr.listFiles(); + if (files != null) { + for (File file : files) { + String filename = file.getName(); + if (file.isDirectory()) { + if (!filename.startsWith(".") && !filename.equals("bak") && !filename.equals("version-files") && !filename.equals("node_modules")) { + paths.push(file.getAbsolutePath()); + } + } else if (file.isFile() && !filename.equals("graphs-txid.edn") && !filename.equals("broken-config.edn")) { + try { + SimpleFileMetadata metadata = new SimpleFileMetadata(file); + newMetaDb.put(file.getAbsolutePath(), metadata); + } catch (ErrnoException e) { + } + } + } + } + } + + if (shouldNotify) { + this.updateMetaDb(newMetaDb); + } else { + this.metaDb = newMetaDb; + } + } + + private void updateMetaDb(Map newMetaDb) { + for (Map.Entry entry : newMetaDb.entrySet()) { + String path = entry.getKey(); + SimpleFileMetadata newMeta = entry.getValue(); + SimpleFileMetadata oldMeta = metaDb.remove(path); + if (oldMeta == null) { + // new file + onObserverEvent(FileObserver.CREATE, path, newMeta); + Log.d("FsWatcher", "create " + path); + } else if (!oldMeta.equals(newMeta)) { + // file changed + onObserverEvent(FileObserver.MODIFY, path, newMeta); + Log.d("FsWatcher", "changed " + path); + } + } + for (String path : metaDb.keySet()) { + // file deleted + Thread timer = new Thread() { + @Override + public void run() { + try { + // delay 500ms then send, enough for most syncing net disks + Thread.sleep(500); + onObserverEvent(FileObserver.DELETE, path, null); + Log.d("FsWatcher", "deleted " + path); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }; + timer.start(); + } + + this.metaDb = newMetaDb; + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt b/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt new file mode 100644 index 0000000..38ec0d6 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt @@ -0,0 +1,663 @@ +package com.logseq.app + +import android.content.Context +import android.os.Build +import android.graphics.Color +import android.text.Editable +import android.text.TextWatcher +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp // New Import for DP units +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +// NOTE: NativeUiUtils and MaterialIconResolver are assumed to be defined elsewhere in your project +// and are necessary for this code to compile. + +@CapacitorPlugin(name = "LiquidTabsPlugin") +class LiquidTabsPlugin : Plugin() { + private var bottomNav: ComposeView? = null + private var searchContainer: LinearLayout? = null + private var searchInput: EditText? = null + private var resultsContainer: LinearLayout? = null + private var closeButton: ImageButton? = null + private var originalBottomPadding: Int? = null + + private var tabsState by mutableStateOf>(emptyList()) + private var currentTabId by mutableStateOf(null) + + // Define a standard horizontal padding for consistency + private val HORIZONTAL_PADDING_DP = 16f + private val VERTICAL_PADDING_DP = 12f + private val RESULT_ROW_VERTICAL_PADDING_DP = 10f + + // 💡 NEW: Define padding for the Tab Bar edges (makes it compact and adds left/right space) + private val TAB_BAR_HORIZONTAL_PADDING = 12.dp + private val ACCENT_COLOR_HEX = "#6097c7" + + private fun tabIconResId(tabId: String): Int? = when (tabId) { + "flashcards" -> R.drawable.ic_tab_flashcards + else -> null + } + + @PluginMethod + fun configureTabs(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + val tabs = parseTabs(call.getArray("tabs")) + + activity.runOnUiThread { + tabsState = tabs + val activeId = currentTabId?.takeIf { id -> tabs.any { it.id == id } } + ?: tabs.firstOrNull()?.id + currentTabId = activeId + ensureNav() + currentTabId?.let { id -> + tabsState.find { it.id == id }?.let { tab -> + handleSelection(tab, reselected = false, notify = false) + } + } ?: hideSearchUi() + adjustWebViewPadding() + call.resolve() + } + } + + @PluginMethod + fun selectTab(call: PluginCall) { + val id = call.getString("id") ?: run { + call.reject("Missing id") + return + } + val tab = tabsState.find { it.id == id } + if (tab == null) { + call.resolve() + return + } + val nav = bottomNav + if (nav == null) { + call.resolve() + return + } + nav.post { + val reselected = currentTabId == tab.id + handleSelection(tab, reselected, notify = false) + call.resolve() + } + } + + @PluginMethod + fun updateNativeSearchResults(call: PluginCall) { + val results = parseResults(call.getArray("results")) + activity?.runOnUiThread { + ensureSearchUi() + val container = resultsContainer ?: return@runOnUiThread + container.removeAllViews() + results.forEach { result -> + container.addView(makeResultRow(result)) + } + call.resolve() + } ?: call.resolve() + } + + /** + * FIX: Allows the web view to explicitly show the search UI again, + * typically after backing out of an opened search result item. + */ + @PluginMethod + fun showSearchUiNative(call: PluginCall) { + activity?.runOnUiThread { + showSearchUi() + // Ensure padding is correct when search UI is manually shown + adjustWebViewPadding() + call.resolve() + } ?: call.resolve() + } + + fun handleNativeBackPressed(): Boolean { + val container = searchContainer ?: return false + if (container.visibility != View.VISIBLE) return false + + val input = searchInput ?: return false + val imeVisible = ViewCompat.getRootWindowInsets(input) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true + val hasFocus = input.hasFocus() + + if (imeVisible || hasFocus) { + moveFocusAwayFromSearchInput(input) + hideIme(input) + return true + } + + dismissSearchUiFromBack() + return true + } + + + private fun ensureNav(): ComposeView { + val activity = activity ?: throw IllegalStateException("No activity") + val root = NativeUiUtils.contentRoot(activity) + val nav = bottomNav ?: ComposeView(activity).also { view -> + view.id = R.id.liquid_tabs_bottom_nav // Assuming R.id.liquid_tabs_bottom_nav is defined + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + view.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ) + view.setBackgroundColor(LogseqTheme.current().background) + bottomNav = view + root.addView(view) + setupImeBehaviorForNav(view) + } + + nav.setContent { + BottomNavBar( + tabs = tabsState, + currentId = currentTabId, + onSelect = { tab -> + val reselected = tab.id == currentTabId + handleSelection(tab, reselected, notify = true) + } + ) + } + + nav.doOnNextLayout { adjustWebViewPadding() } + return nav + } + + private fun handleSelection(tab: TabSpec, reselected: Boolean, notify: Boolean) { + currentTabId = tab.id + if (tab.role == "search") { + showSearchUi() + } else { + clearSearchUi() + hideSearchUi() + } + + if (notify) { + notifyListeners("tabSelected", JSObject().put("id", tab.id).put("reselected", reselected)) + } + + adjustWebViewPadding() + } + + private fun adjustWebViewPadding() { + val webView = bridge.webView ?: return + val nav = bottomNav ?: return + if (originalBottomPadding == null) { + originalBottomPadding = webView.paddingBottom + } + nav.post { + val padding = originalBottomPadding ?: 0 + val h = nav.height + val newPadding = if (searchContainer?.visibility == View.VISIBLE) { + padding + } else { + padding + h + } + webView.setPadding(webView.paddingLeft, webView.paddingTop, webView.paddingRight, newPadding) + } + } + + private fun setupImeBehaviorForNav(nav: View) { + ViewCompat.setOnApplyWindowInsetsListener(nav) { v, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + + val extra = if (imeVisible) { + imeInsets.bottom + } else { + 0 + } + v.translationY = extra.toFloat() + insets + } + + ViewCompat.requestApplyInsets(nav) + } + + private fun ensureSearchUi() { + if (searchContainer != null) return + showSearchUi() + } + + private fun showSearchUi() { + val activity = activity ?: return + val root = NativeUiUtils.contentRoot(activity) + val theme = LogseqTheme.current() + val labelColor = if (theme.isDark) theme.tint else Color.BLACK + val secondaryLabelColor = + if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY + + // Calculate status bar height for safe area padding + val insets = ViewCompat.getRootWindowInsets(root) + val statusBarHeight = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0 + + val container = searchContainer ?: LinearLayout(activity).also { layout -> + layout.orientation = LinearLayout.VERTICAL + layout.setBackgroundColor(theme.background) + + val lp = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Set bottom margin to clear the bottom navigation bar + lp.setMargins(0, 0, 0, bottomNav?.height ?: NativeUiUtils.dp(activity, 56f)) + + // Remove elevation/shadow + layout.elevation = 0f + + // Apply status bar height as top padding for safe area + layout.setPadding(0, statusBarHeight, 0, 0) + + root.addView(layout, lp) + searchContainer = layout + } + + // Re-apply top padding in case insets were not available on first run + container.setPadding(0, statusBarHeight, 0, 0) + + // Search Input Setup + if (searchInput == null) { + // Container for input and close button + val searchRow = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL // Center items vertically + } + + val input = EditText(activity).apply { + hint = "Search" + setSingleLine(true) + imeOptions = EditorInfo.IME_ACTION_SEARCH + setTextColor(labelColor) + setHintTextColor(secondaryLabelColor) + // Remove EditText default background/border for a flat look + setBackgroundColor(Color.TRANSPARENT) + + // Fine-tune padding inside the EditText for text alignment + setPadding( + NativeUiUtils.dp(activity, 0f), + NativeUiUtils.dp(activity, 10f), + NativeUiUtils.dp(activity, 0f), + NativeUiUtils.dp(activity, 10f) + ) + + // Layout params to make EditText take most of the horizontal space + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f) + + setOnEditorActionListener { _, actionId, event -> + val isEnter = + actionId == EditorInfo.IME_ACTION_SEARCH || + actionId == EditorInfo.IME_ACTION_DONE || + actionId == EditorInfo.IME_ACTION_GO || + (event?.keyCode == KeyEvent.KEYCODE_ENTER) + if (isEnter) { + notifyListeners("keyboardHackKey", JSObject().put("key", "enter")) + true + } else { + false + } + } + + setOnKeyListener { _, keyCode, event -> + when (keyCode) { + KeyEvent.KEYCODE_ENTER -> { + if (event.action == KeyEvent.ACTION_DOWN) { + notifyListeners("keyboardHackKey", JSObject().put("key", "enter")) + } + true + } + KeyEvent.KEYCODE_DEL -> { + if (text.isNullOrEmpty() && event.action == KeyEvent.ACTION_DOWN) { + notifyListeners("keyboardHackKey", JSObject().put("key", "backspace")) + true + } else { + false + } + } + else -> false + } + } + addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + // Toggle close button visibility based on text + val hasText = !s.isNullOrEmpty() + closeButton?.visibility = if (hasText) View.VISIBLE else View.GONE + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + notifyListeners("searchChanged", JSObject().put("query", s?.toString() ?: "")) + } + }) + } + + // Close Button + val button = ImageButton(activity).apply { + setImageDrawable(AppCompatResources.getDrawable(activity, R.drawable.ic_search_clear)) + background = null + scaleType = ImageView.ScaleType.CENTER_INSIDE + contentDescription = "Clear search" + alpha = if (theme.isDark) 0.92f else 0.8f + setPadding( + NativeUiUtils.dp(activity, 4f), + NativeUiUtils.dp(activity, 4f), + NativeUiUtils.dp(activity, 4f), + NativeUiUtils.dp(activity, 4f) + ) + minimumWidth = NativeUiUtils.dp(activity, 28f) + minimumHeight = NativeUiUtils.dp(activity, 28f) + visibility = View.GONE // Initially hidden + + setOnClickListener { + input.setText("") // Clear the EditText + // TextWatcher will handle notifying the web view and hiding the button + } + // Set layout params for the button + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + marginStart = NativeUiUtils.dp(activity, 8f) + } + } + + // 1. Add EditText + searchRow.addView(input) + // 2. Add Close Button + searchRow.addView(button) + + // inputContainer (was the old search container wrapper) + val inputContainer = LinearLayout(activity).apply { + // Add horizontal padding for the search box container + setPadding( + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), + NativeUiUtils.dp(activity, VERTICAL_PADDING_DP), + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), + NativeUiUtils.dp(activity, VERTICAL_PADDING_DP) + ) + orientation = LinearLayout.VERTICAL + // Add the new searchRow (input + button) + addView(searchRow, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + + // Add a divider below the search box (optional visual polish) + val divider = View(activity).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f)) + setBackgroundColor( + if (theme.isDark) Color.argb(40, 245, 247, 250) else Color.parseColor("#E0E0E0") + ) + } + addView(divider, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f))) + } + + // Insert the inputContainer into the main searchContainer + container.addView(inputContainer, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + searchInput = input + closeButton = button + } + + // Search Results Setup + if (resultsContainer == null) { + val scroll = ScrollView(activity) + val inner = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + // Apply horizontal padding for the list of results + setPadding( + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Left + NativeUiUtils.dp(activity, 0f), // Top + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Right + NativeUiUtils.dp(activity, 12f) // Bottom + ) + } + scroll.addView(inner, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + // The ScrollView should take up the rest of the vertical space + container.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + resultsContainer = inner + } + + container.visibility = View.VISIBLE + adjustWebViewPadding() + } + + private fun clearSearchUi() { + searchInput?.setText("") + resultsContainer?.removeAllViews() + } + + private fun hideSearchUi() { + dismissActiveSearchInput() + searchContainer?.visibility = View.GONE + adjustWebViewPadding() + } + + private fun dismissSearchUiFromBack() { + hideSearchUi() + } + + private fun dismissActiveSearchInput() { + searchInput?.let { input -> + moveFocusAwayFromSearchInput(input) + hideIme(input) + } + } + + private fun moveFocusAwayFromSearchInput(input: EditText) { + val container = searchContainer + if (container != null) { + container.isFocusableInTouchMode = true + container.isFocusable = true + container.requestFocus() + } + input.clearFocus() + activity?.currentFocus?.clearFocus() + } + + private fun hideIme(target: View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + target.windowInsetsController?.hide(WindowInsets.Type.ime()) + } + val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + val windowToken = target.windowToken + ?: activity?.currentFocus?.windowToken + ?: activity?.window?.decorView?.windowToken + ?: searchContainer?.windowToken + if (windowToken != null) { + imm?.hideSoftInputFromWindow(windowToken, 0) + } + target.post { + val postedToken = activity?.window?.decorView?.windowToken ?: target.windowToken + if (postedToken != null) { + imm?.hideSoftInputFromWindow(postedToken, 0) + } + } + } + + private fun makeResultRow(result: SearchResult): View { + val activity = activity ?: throw IllegalStateException("No activity") + val theme = LogseqTheme.current() + val labelColor = if (theme.isDark) theme.tint else Color.BLACK + val secondaryLabelColor = + if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY + return LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + + // Apply vertical padding for the row item, using RESULT_ROW_VERTICAL_PADDING_DP (10f) + // for both top and bottom to ensure they are equal. + setPadding(0, + NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP), // TOP: 10f + 0, + NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP) // BOTTOM: 10f + ) + + val subtitleText = result.subtitle + if (subtitleText != null && + !subtitleText.isNullOrBlank() && + subtitleText.lowercase() != "null") { + + val sub = TextView(activity).apply { + text = subtitleText + setTextColor(secondaryLabelColor) + textSize = 13f + } + addView(sub) + } + + val titleView = TextView(activity).apply { + text = result.title + setTextColor(labelColor) + textSize = 15f + } + addView(titleView) + + setOnClickListener { + hideSearchUi() + notifyListeners("openSearchResultBlock", JSObject().put("id", result.id)) + } + } + } + + @Composable + private fun BottomNavBar( + tabs: List, + currentId: String?, + onSelect: (TabSpec) -> Unit + ) { + val theme by LogseqTheme.colors.collectAsState() + val container = ComposeColor(theme.background) + val unselected = if (theme.isDark) ComposeColor(theme.tint).copy(alpha = 0.78f) else ComposeColor.Black.copy(alpha = 0.65f) + Box( + modifier = Modifier + .fillMaxWidth() + .background(container) + .padding(horizontal = TAB_BAR_HORIZONTAL_PADDING) + ) { + NavigationBar( + modifier = Modifier.fillMaxWidth(), + containerColor = container + ) { + tabs.forEach { tab -> + val selected = tab.id == currentId + val icon = remember(tab.systemImage, tab.id) { + MaterialIconResolver.resolve(tab.systemImage) ?: MaterialIconResolver.resolve(tab.id) + } + val iconResId = remember(tab.id) { tabIconResId(tab.id) } + val accent = ComposeColor(NativeUiUtils.parseColor(ACCENT_COLOR_HEX, Color.parseColor(ACCENT_COLOR_HEX))) + + NavigationBarItem( + selected = selected, + onClick = { onSelect(tab) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = accent, + selectedTextColor = accent, + unselectedIconColor = unselected, + unselectedTextColor = unselected, + indicatorColor = accent.copy(alpha = 0.12f) + ), + icon = { + if (iconResId != null) { + Icon( + painter = painterResource(id = iconResId), + contentDescription = tab.title + ) + } else { + Icon( + imageVector = icon ?: Icons.Filled.Circle, + contentDescription = tab.title + ) + } + }, + // Slightly reduce the default Material3 gap between icon and label. + label = { Text(tab.title, modifier = Modifier.offset(y = (-4).dp)) } + ) + } + } + } + } + + private fun parseTabs(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val id = obj.optString("id", "") + if (id.isBlank()) continue + val title = obj.optString("title", id) + val systemImage = obj.optString("systemImage", "") + val role = obj.optString("role", "normal") + result.add(TabSpec(id, title, systemImage, role)) + } + return result + } + + private fun parseResults(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val id = obj.optString("id", "") + if (id.isBlank()) continue + val title = obj.optString("title", "") + val subtitle = if (obj.has("subtitle") && !obj.isNull("subtitle")) obj.optString("subtitle") else null + result.add(SearchResult(id, title, subtitle)) + } + return result + } +} + +data class TabSpec( + val id: String, + val title: String, + val systemImage: String, + val role: String +) + +data class SearchResult( + val id: String, + val title: String, + val subtitle: String? +) diff --git a/android/app/src/main/java/com/logseq/app/LogseqTheme.kt b/android/app/src/main/java/com/logseq/app/LogseqTheme.kt new file mode 100644 index 0000000..ef83e00 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/LogseqTheme.kt @@ -0,0 +1,44 @@ +package com.logseq.app + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class LogseqThemeColors( + val background: Int, + val tint: Int, + val isDark: Boolean, +) + +object LogseqTheme { + private val _colors = MutableStateFlow(compute(isDark = false)) + val colors: StateFlow = _colors.asStateFlow() + + fun update(context: Context) { + _colors.value = compute(context) + } + + fun current(): LogseqThemeColors = _colors.value + + fun isDark(context: Context): Boolean { + val mask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return mask == Configuration.UI_MODE_NIGHT_YES + } + + private fun compute(context: Context): LogseqThemeColors = compute(isDark(context)) + + private fun compute(isDark: Boolean): LogseqThemeColors { + val background = + if (isDark) Color.parseColor("#002B36") else Color.parseColor("#FCFCFC") + val tint = + if (isDark) Color.parseColor("#F5F7FA") else Color.parseColor("#000000") + return LogseqThemeColors( + background = background, + tint = tint, + isDark = isDark, + ) + } +} diff --git a/android/app/src/main/java/com/logseq/app/MainActivity.java b/android/app/src/main/java/com/logseq/app/MainActivity.java new file mode 100644 index 0000000..10f3e39 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/MainActivity.java @@ -0,0 +1,345 @@ +package com.logseq.app; + +import android.content.Intent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.os.Bundle; +import android.os.Build; +import android.webkit.WebView; +import androidx.activity.OnBackPressedCallback; +import androidx.activity.BackEventCompat; +import androidx.activity.EdgeToEdge; +import androidx.core.content.ContextCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import com.getcapacitor.BridgeActivity; +import com.getcapacitor.PluginHandle; +import com.getcapacitor.Plugin; +import android.util.Log; +import android.view.View; +import android.window.OnBackInvokedCallback; +import android.window.OnBackInvokedDispatcher; + +import java.lang.reflect.Field; +import java.util.Timer; +import java.util.TimerTask; + +public class MainActivity extends BridgeActivity { + private static final String NAV_STACK_DEBUG_PREFIX = "[DEBUG-navstack]"; + private final NavigationCoordinator navigationCoordinator = new NavigationCoordinator(); + private BroadcastReceiver routeChangeReceiver; + private OnBackInvokedCallback overlayBackInvokedCallback; + + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(FolderPicker.class); + registerPlugin(UILocal.class); + registerPlugin(NativeTopBarPlugin.class); + registerPlugin(NativeBottomSheetPlugin.class); + registerPlugin(NativeEditorToolbarPlugin.class); + registerPlugin(NativeSelectionActionBarPlugin.class); + registerPlugin(LiquidTabsPlugin.class); + registerPlugin(Utils.class); + + super.onCreate(savedInstanceState); + // @capacitor/app installs its own OnBackPressedCallback during plugin load (inside + // super.onCreate above). Its callback consumes the first system/edge back press by + // calling webView.goBack(), which both makes the WebView flash and prevents our + // MainActivity-level handler (below) from ever running. We route back through JS + // instead, so disable that plugin's back handler defensively even if the config + // entry "App.disableBackButtonHandler" hasn't been synced yet. + disableCapacitorAppBackHandler(); + + EdgeToEdge.enable(this); + WebView webView = getBridge().getWebView(); + webView.setOverScrollMode(WebView.OVER_SCROLL_NEVER); + webView.getSettings().setUseWideViewPort(true); + webView.getSettings().setLoadWithOverviewMode(true); + + applyLogseqTheme(); + + // Let Compose host the WebView with system bar padding for safe areas. + // Android back is still delegated to JS from the Activity back dispatcher. + ComposeHost.INSTANCE.renderWithSystemInsets(this, webView); + WebViewKeyboardRecovery.install(this, webView); + + // On API 33+ (Android 13+), predictive back routes through the platform-level + // OnBackInvokedDispatcher. AndroidX's OnBackPressedDispatcher bridges into it at + // PRIORITY_DEFAULT, so plugin / Compose / WebView callbacks registered through + // AndroidX can pre-empt ours. Register OUR callback directly on the platform + // dispatcher at PRIORITY_OVERLAY to guarantee the first edge-back gesture is + // delivered to handleNativeBack(). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + overlayBackInvokedCallback = () -> { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackInvoked source=window-overlay"); + handleNativeBack(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_OVERLAY, + overlayBackInvokedCallback + ); + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackInvoked.registered priority=OVERLAY"); + } + + // Fallback for API < 33 and as defense in depth. Predictive-back lifecycle methods + // log so we can see whether the system is starting/cancelling vs committing. + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackStarted(BackEventCompat backEvent) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackStarted progress=" + backEvent.getProgress() + " edge=" + backEvent.getSwipeEdge()); + } + + @Override + public void handleOnBackProgressed(BackEventCompat backEvent) { + // Intentionally not logging every progress tick; uncomment for tracing. + // Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackProgressed progress=" + backEvent.getProgress()); + } + + @Override + public void handleOnBackCancelled() { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackCancelled"); + } + + @Override + public void handleOnBackPressed() { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.onBackPressed source=androidx-dispatcher"); + handleNativeBack(); + } + }); + + routeChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!UILocal.ACTION_ROUTE_CHANGED.equals(intent.getAction())) return; + String stack = intent.getStringExtra("stack"); + String navigationType = intent.getStringExtra("navigationType"); + String path = intent.getStringExtra("path"); + Log.d( + "NavStack", + NAV_STACK_DEBUG_PREFIX + " activity.routeChange.received stack=" + stack + + " type=" + navigationType + + " path=" + path + + " before=" + navigationCoordinator.debugState() + ); + NavigationRenderState renderState = navigationCoordinator.onRouteChange(stack, navigationType, path); + if (renderState.getApplyToCompose()) { + ComposeHost.applyNavigation(renderState); + } else { + Log.d( + "NavStack", + NAV_STACK_DEBUG_PREFIX + " activity.routeChange.skippedCompose stack=" + + renderState.getActiveStackId() + + " type=" + renderState.getNavigationType() + + " path=" + renderState.getPath() + ); + } + Log.d( + "NavStack", + NAV_STACK_DEBUG_PREFIX + " activity.routeChange.applied after=" + + navigationCoordinator.debugState() + + " renderStack=" + renderState.getActiveStackId() + + " renderPaths=" + renderState.getPaths() + + " stackSwitched=" + renderState.getStackSwitched() + + " applyToCompose=" + renderState.getApplyToCompose() + ); + } + }; + IntentFilter filter = new IntentFilter(UILocal.ACTION_ROUTE_CHANGED); + ContextCompat.registerReceiver(this, routeChangeReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); + new Timer().schedule(new TimerTask() { + @Override + public void run() { + bridge.eval("window.dispatchEvent(new Event('sendIntentReceived'))", s -> {}); + } + }, 5000); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + applyLogseqTheme(); + dispatchSystemThemeToWeb(); + } + + private void applyLogseqTheme() { + LogseqTheme.INSTANCE.update(this); + LogseqThemeColors colors = LogseqTheme.INSTANCE.current(); + + int bg = colors.getBackground(); + boolean isDark = colors.isDark(); + + View content = findViewById(android.R.id.content); + if (content != null) { + content.setBackgroundColor(bg); + } + + WebView webView = getBridge() != null ? getBridge().getWebView() : null; + if (webView != null) { + webView.setBackgroundColor(bg); + } + + getWindow().getDecorView().setBackgroundColor(bg); + getWindow().setStatusBarColor(bg); + getWindow().setNavigationBarColor(bg); + + WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView()); + controller.setAppearanceLightStatusBars(!isDark); + controller.setAppearanceLightNavigationBars(!isDark); + + WebViewSnapshotManager.INSTANCE.setSnapshotBackgroundColor(bg); + } + + public void applyLogseqThemeNow() { + applyLogseqTheme(); + } + + private void dispatchSystemThemeToWeb() { + try { + if (bridge == null) return; + boolean isDark = LogseqTheme.INSTANCE.isDark(this); + String js = "window.dispatchEvent(new CustomEvent('logseq:native-system-theme-changed', { detail: { isDark: " + + (isDark ? "true" : "false") + + " } }));"; + bridge.eval(js, null); + } catch (Exception e) { + // ignore + } + } + + @Override + public void onPause() { + overridePendingTransition(0, R.anim.byebye); + super.onPause(); + } + + private void handleNativeBack() { + Log.d("onBackPressed", "Debug"); + Log.d( + "NavStack", + NAV_STACK_DEBUG_PREFIX + " activity.nativeBack source=system-or-edge before=" + + navigationCoordinator.debugState() + ); + + if (consumeNativeOverlayBack()) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.nativeBack consumed=native-overlay"); + return; + } + + WebView webView = getBridge().getWebView(); + if (webView != null) { + NavigationRenderState nativePopState = navigationCoordinator.prepareNativePop(); + if (nativePopState != null) { + Log.d( + "NavStack", + NAV_STACK_DEBUG_PREFIX + " activity.nativeBack consumed=native-stack-pop target=" + + nativePopState.getPath() + + " paths=" + nativePopState.getPaths() + ); + ComposeHost.applyNavigation(nativePopState); + } + // Send "native back" into JS. JS will call your UILocal/route-change, + // which flows into ComposeHost.applyNavigation(...) and animates. + sendJsBack(webView); + } else { + // Fallback if for some reason there is no webview + finish(); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + String action = intent.getAction(); + String type = intent.getType(); + if (Intent.ACTION_SEND.equals(action) && type != null) { + bridge.getActivity().setIntent(intent); + bridge.eval("window.dispatchEvent(new Event('sendIntentReceived'))", s -> {}); + } + } + + @Override + public void onDestroy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && overlayBackInvokedCallback != null) { + try { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(overlayBackInvokedCallback); + } catch (Exception ignored) { + } + overlayBackInvokedCallback = null; + } + if (routeChangeReceiver != null) { + unregisterReceiver(routeChangeReceiver); + routeChangeReceiver = null; + } + WebViewKeyboardRecovery.uninstall(this); + super.onDestroy(); + } + + private void sendJsBack(WebView webView) { + if (webView == null) return; + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.sendJsBack dispatching onNativePop"); + webView.post(() -> webView.evaluateJavascript( + "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();", + null + )); + } + + private boolean consumeNativeOverlayBack() { + try { + if (getBridge() == null) { + return false; + } + + PluginHandle handle = getBridge().getPlugin("LiquidTabsPlugin"); + if (handle == null) { + return false; + } + + Plugin instance = handle.getInstance(); + if (!(instance instanceof LiquidTabsPlugin)) { + return false; + } + + return ((LiquidTabsPlugin) instance).handleNativeBackPressed(); + } catch (Exception e) { + Log.d( + "NavStack", + NAV_STACK_DEBUG_PREFIX + " activity.nativeOverlayBack error=" + + e.getClass().getSimpleName() + ":" + e.getMessage() + ); + return false; + } + } + + private void disableCapacitorAppBackHandler() { + try { + if (getBridge() == null) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack bridge=null skip"); + return; + } + PluginHandle handle = getBridge().getPlugin("App"); + if (handle == null) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack pluginHandle=null skip"); + return; + } + Plugin instance = handle.getInstance(); + if (instance == null) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack pluginInstance=null skip"); + return; + } + Field field = instance.getClass().getDeclaredField("onBackPressedCallback"); + field.setAccessible(true); + Object callback = field.get(instance); + if (callback instanceof OnBackPressedCallback) { + ((OnBackPressedCallback) callback).setEnabled(false); + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack disabled"); + } else { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack unexpectedType=" + (callback == null ? "null" : callback.getClass().getName())); + } + } catch (NoSuchFieldException e) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack noField=" + e.getMessage()); + } catch (Exception e) { + Log.d("NavStack", NAV_STACK_DEBUG_PREFIX + " activity.appPluginBack error=" + e.getClass().getSimpleName() + ":" + e.getMessage()); + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt b/android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt new file mode 100644 index 0000000..673ed07 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt @@ -0,0 +1,92 @@ +package com.logseq.app + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Comment +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.AddCircle +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material.icons.outlined.DataArray +import androidx.compose.material.icons.outlined.KeyboardCommandKey +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ArrowForward +import androidx.compose.material.icons.outlined.AttachFile +import androidx.compose.material.icons.outlined.BookmarkAdd +import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Equalizer +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.GraphicEq +import androidx.compose.material.icons.outlined.KeyboardHide +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.LocalOffer +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Redo +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material.icons.outlined.Undo +import androidx.compose.ui.graphics.vector.ImageVector + +object MaterialIconResolver { + fun resolve(name: String?): ImageVector? { + val key = name + ?.trim() + ?.lowercase() + ?.replace("_", "-") + ?.replace("\\s+".toRegex(), "-") + ?.replace(".", "-") + ?: return null + + return when (key) { + "chevron-backward", "arrow-left", "back" -> Icons.Outlined.ArrowBack + "arrow-right" -> Icons.Outlined.ArrowForward + "arrow-uturn-backward" -> Icons.Outlined.Undo + "arrow-uturn-forward" -> Icons.Outlined.Redo + "calendar" -> Icons.Outlined.CalendarToday + "waveform", "audio", "equalizer" -> Icons.Outlined.GraphicEq + "ellipsis" -> Icons.Outlined.MoreVert + "star-fill" -> Icons.Filled.Star + "star" -> Icons.Outlined.StarBorder + "circle-fill" -> Icons.Filled.Circle + "plus", "add" -> Icons.Outlined.Add + "paperplane", "send" -> Icons.Filled.Send + "todo", "checkmark-square" -> Icons.Outlined.CheckBox + "number", "tag" -> Icons.Outlined.LocalOffer + "parentheses" -> Icons.Outlined.DataArray + "command", "slash" -> Icons.Outlined.KeyboardCommandKey + "camera" -> Icons.Outlined.CameraAlt + "keyboard-chevron-compact-down", "keyboard-hide" -> Icons.Outlined.KeyboardHide + "doc-on-doc", "copy" -> Icons.Outlined.ContentCopy + "text-bubble", "comment" -> Icons.AutoMirrored.Outlined.Comment + "trash", "delete" -> Icons.Outlined.Delete + "r-square", "bookmark-ref" -> Icons.Outlined.BookmarkAdd + "link" -> Icons.Outlined.Link + "xmark", "close" -> Icons.Filled.Close + "house", "home" -> Icons.Filled.Home + "app-background-dotted" -> Icons.Outlined.Dashboard + "tray", "add" -> Icons.Outlined.AddCircle + "square-stack-3d-down-right", "layers" -> Icons.Filled.Layers + "magnifyingglass", "search" -> Icons.Outlined.Search + "go-to", "goto" -> Icons.Outlined.Explore + "bookmark" -> Icons.Filled.Bookmarks + "sync" -> Icons.Outlined.Equalizer + "cloud", "cloud-upload", "icloud-and-arrow-up" -> Icons.Filled.CloudUpload + "paperclip" -> Icons.Outlined.AttachFile + "face-smiling" -> Icons.Outlined.AddReaction + else -> null + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt new file mode 100644 index 0000000..745a5bd --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt @@ -0,0 +1,268 @@ +package com.logseq.app + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog + +@CapacitorPlugin(name = "NativeBottomSheetPlugin") +class NativeBottomSheetPlugin : Plugin() { + private val snapshotTag = "bottom-sheet" + private val mainHandler = Handler(Looper.getMainLooper()) + private var dialog: BottomSheetDialog? = null + private var pendingPresentation: (() -> Unit)? = null + private var awaitingContentReady = false + private var previousParent: ViewGroup? = null + private var previousIndex: Int = -1 + private var previousLayoutParams: ViewGroup.LayoutParams? = null + private var placeholder: View? = null + private var container: FrameLayout? = null + private var pendingRestoreWebView: View? = null + private var dismissalFallback: Runnable? = null + + @PluginMethod + fun present(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + val webView = bridge.webView ?: run { + call.reject("No webview") + return + } + + activity.runOnUiThread { + WebViewSnapshotManager.registerWindow(activity.window) + if (dialog != null || pendingPresentation != null || awaitingContentReady) { + call.resolve() + return@runOnUiThread + } + + val ctx = activity + container = FrameLayout(ctx) + container!!.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + val cornerRadius = NativeUiUtils.dp(ctx, 16f).toFloat() + val roundedBackground = GradientDrawable().apply { + setColor(LogseqTheme.current().background) + cornerRadii = floatArrayOf( + cornerRadius, cornerRadius, // 左上角 + cornerRadius, cornerRadius, // 右上角 + 0f, 0f, // 右下角 + 0f, 0f // 左下角 + ) + } + container!!.background = roundedBackground + container!!.clipToOutline = true + + val sheet = BottomSheetDialog(ctx) + sheet.setContentView(container!!) + + sheet.setOnShowListener { + val bottomSheet = sheet.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.setBackgroundColor(Color.TRANSPARENT) + } + + WebViewSnapshotManager.showSnapshot(snapshotTag, webView) + + val behavior = sheet.behavior + val defaultHeight = call.getInt("defaultHeight", null) + val allowFullHeight = call.getBoolean("allowFullHeight") ?: true + if (defaultHeight != null) { + val peek = NativeUiUtils.dp(ctx, defaultHeight.toFloat()) + behavior.peekHeight = peek + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } else { + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + behavior.skipCollapsed = !allowFullHeight + + sheet.setOnDismissListener { + awaitingContentReady = true + + // Forcefully detach WebView before restoring + try { + (webView.parent as? ViewGroup)?.removeView(webView) + container?.removeAllViews() + } catch (_: Exception) {} + + // Delay restoration slightly to let Android clean up window surfaces + mainHandler.post { + restoreWebView(webView) + webView.alpha = 0f + pendingRestoreWebView = webView + awaitingContentReady = true + scheduleDismissalFallback() + } + + notifyListeners("state", JSObject().put("dismissing", true)) + dialog = null + container = null + } + + pendingPresentation = { + detachWebView(webView, ctx) + container!!.addView( + webView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + sheet.show() + notifyListeners("state", JSObject().put("presented", true)) + dialog = sheet + call.resolve() + } + notifyListeners("state", JSObject().put("presenting", true)) + } + } + + @PluginMethod + fun dismiss(call: PluginCall) { + activity?.runOnUiThread { + if (pendingPresentation != null) { + pendingPresentation = null + container = null + WebViewSnapshotManager.clearSnapshot(snapshotTag) + notifyListeners("state", JSObject().put("dismissing", true)) + notifyListeners( + "state", + JSObject() + .put("presented", false) + .put("dismissing", false) + ) + call.resolve() + return@runOnUiThread + } + + if (dialog == null) { + pendingRestoreWebView?.let { finishDismissal(it) } + ?: WebViewSnapshotManager.clearSnapshot(snapshotTag) + call.resolve() + return@runOnUiThread + } + + dialog?.dismiss() + call.resolve() + } ?: call.resolve() + } + + @PluginMethod + fun contentReady(call: PluginCall) { + activity?.runOnUiThread { + pendingPresentation?.let { presentation -> + pendingPresentation = null + presentation() + call.resolve() + return@runOnUiThread + } + + val webView = pendingRestoreWebView + if (!awaitingContentReady || webView == null) { + call.resolve() + return@runOnUiThread + } + + finishDismissal(webView) + call.resolve() + } ?: call.resolve() + } + + private fun detachWebView(webView: View, ctx: android.content.Context) { + val parent = webView.parent as? ViewGroup ?: return + previousParent = parent + previousIndex = parent.indexOfChild(webView) + previousLayoutParams = webView.layoutParams + + parent.removeView(webView) + placeholder = View(ctx).apply { + setBackgroundColor(LogseqTheme.current().background) + layoutParams = previousLayoutParams ?: ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + parent.addView(placeholder, previousIndex) + } + + private fun restoreWebView(webView: View) { + val parent = previousParent ?: return + val lp = previousLayoutParams ?: ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Fully detach from any container + (webView.parent as? ViewGroup)?.removeView(webView) + placeholder?.let { parent.removeView(it) } + placeholder = null + + // Reattach WebView + if (previousIndex in 0..parent.childCount) { + parent.addView(webView, previousIndex, lp) + } else { + parent.addView(webView, lp) + } + + // ✅ Force WebView to recreate its SurfaceView and redraw + webView.visibility = View.INVISIBLE + webView.post { + webView.visibility = View.VISIBLE + webView.requestLayout() + webView.invalidate() + webView.dispatchWindowVisibilityChanged(View.VISIBLE) + } + + previousParent = null + previousLayoutParams = null + previousIndex = -1 + container = null + } + + private fun finishDismissal(webView: View) { + dismissalFallback?.let(mainHandler::removeCallbacks) + dismissalFallback = null + awaitingContentReady = false + pendingRestoreWebView = null + + webView.alpha = 1f + webView.requestLayout() + webView.invalidate() + + WebViewSnapshotManager.clearSnapshot(snapshotTag) + notifyListeners( + "state", + JSObject() + .put("presented", false) + .put("dismissing", false) + ) + } + + private fun scheduleDismissalFallback() { + dismissalFallback?.let(mainHandler::removeCallbacks) + + val runnable = Runnable { + val webView = pendingRestoreWebView + if (awaitingContentReady && webView != null) { + finishDismissal(webView) + } + } + + dismissalFallback = runnable + mainHandler.postDelayed(runnable, 2_000) + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt new file mode 100644 index 0000000..b6305e5 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt @@ -0,0 +1,381 @@ +package com.logseq.app + +import android.graphics.Color +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "NativeEditorToolbarPlugin") +class NativeEditorToolbarPlugin : Plugin() { + private var toolbarView: EditorToolbarView? = null + + @PluginMethod + fun present(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + + val actions = parseActions(call.getArray("actions")) + val trailing = call.getObject("trailingAction")?.let { EditorAction.from(it) } + val tintHex = call.getString("tintColor") + val bgHex = call.getString("backgroundColor") + + activity.runOnUiThread { + if (actions.isEmpty() && trailing == null) { + dismissInternal() + call.resolve() + return@runOnUiThread + } + + val view = toolbarView ?: EditorToolbarView(activity).also { v -> + v.onAction = { id -> + notifyListeners("action", JSObject().put("id", id)) + } + toolbarView = v + } + + view.bind(actions, trailing, tintHex, bgHex) + + val root = NativeUiUtils.contentRoot(activity) + val margin = NativeUiUtils.dp(activity, 12f) + if (view.parent !== root) { + NativeUiUtils.detachView(view) + val lp = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ).apply { + setMargins(margin, margin, margin, margin) + } + root.addView(view, lp) + } + view.bindToKeyboardTop(root, margin) + + call.resolve() + } + } + + @PluginMethod + fun dismiss(call: PluginCall) { + activity?.runOnUiThread { + dismissInternal() + call.resolve() + } ?: call.resolve() + } + + private fun dismissInternal() { + val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return + toolbarView?.let { + it.clearKeyboardTopBinding() + root.removeView(it) + } + toolbarView = null + } + + private fun parseActions(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + EditorAction.from(obj)?.let { result.add(it) } + } + return result + } +} + +data class EditorAction( + val id: String, + val title: String, + val systemIcon: String? +) { + companion object { + fun from(obj: org.json.JSONObject): EditorAction? { + val id = obj.optString("id", "") + if (id.isBlank()) return null + val title = obj.optString("title", id) + val icon = if (obj.has("systemIcon") && !obj.isNull("systemIcon")) { + obj.optString("systemIcon") + } else { + null + } + return EditorAction(id, title, icon) + } + } +} + +private class EditorToolbarView(context: android.content.Context) : FrameLayout(context) { + var onAction: ((String) -> Unit)? = null + + private val showAfterImeSettles = Runnable { + pendingShowAfterImeSettles = null + if (imeVisible && visibility != VISIBLE) { + visibility = VISIBLE + } + } + private var pendingShowAfterImeSettles: Runnable? = null + private var imeVisible: Boolean = false + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + private var actions: List = emptyList() + private var trailing: EditorAction? = null + private var tint: Int = defaultTint() + private var backgroundColor: Int = defaultBackground() + + init { + visibility = GONE + addView(composeView) + } + + fun bindToKeyboardTop(root: FrameLayout, margin: Int) { + val decorView = root.rootView ?: root + updateBottomMargin(margin) + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + applyRootInsets(root, decorView, insets, margin) + insets + } + root.post { + ViewCompat.getRootWindowInsets(decorView)?.let { applyRootInsets(root, decorView, it, margin) } + ?: ViewCompat.getRootWindowInsets(root)?.let { applyRootInsets(root, decorView, it, margin) } + ViewCompat.requestApplyInsets(this) + } + } + + fun clearKeyboardTopBinding() { + cancelPendingShow() + imeVisible = false + visibility = GONE + ViewCompat.setOnApplyWindowInsetsListener(this, null) + } + + private fun applyRootInsets( + root: FrameLayout, + decorView: View, + insets: WindowInsetsCompat, + margin: Int + ) { + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + updateVisibilityForIme(imeVisible) + val coveredBySystemUi = if (imeVisible) { + bottomOverlap(root, decorView, imeInsets.bottom) + } else { + bottomOverlap(root, decorView, navInsets.bottom) + } + updateBottomMargin(coveredBySystemUi + margin) + } + + private fun updateVisibilityForIme(imeVisible: Boolean) { + this.imeVisible = imeVisible + if (!imeVisible) { + cancelPendingShow() + if (visibility != GONE) { + visibility = GONE + } + return + } + + if (visibility == VISIBLE) { + return + } + + cancelPendingShow() + pendingShowAfterImeSettles = showAfterImeSettles + postDelayed(showAfterImeSettles, TOOLBAR_SHOW_DELAY_MS) + } + + private fun cancelPendingShow() { + pendingShowAfterImeSettles?.let { + removeCallbacks(it) + pendingShowAfterImeSettles = null + } + } + + companion object { + private const val TOOLBAR_SHOW_DELAY_MS = 160L + } + + private fun bottomOverlap(root: View, decorView: View, bottomInset: Int): Int { + if (bottomInset <= 0 || root.height <= 0) return 0 + + val decorHeight = decorView.height.takeIf { it > 0 } ?: root.rootView.height + if (decorHeight <= 0) return bottomInset + + val rootLocation = IntArray(2) + val decorLocation = IntArray(2) + root.getLocationInWindow(rootLocation) + decorView.getLocationInWindow(decorLocation) + + val rootBottomInWindow = rootLocation[1] + root.height + val decorBottomInWindow = decorLocation[1] + decorHeight + val insetTopInWindow = decorBottomInWindow - bottomInset + + return (rootBottomInWindow - insetTopInWindow).coerceAtLeast(0) + } + + private fun updateBottomMargin(bottom: Int) { + val lp = layoutParams as? LayoutParams ?: return + if (lp.bottomMargin == bottom) return + lp.bottomMargin = bottom + layoutParams = lp + } + + fun bind( + actions: List, + trailing: EditorAction?, + tintHex: String?, + bgHex: String? + ) { + this.actions = actions + this.trailing = trailing + tint = NativeUiUtils.parseColor(tintHex, defaultTint()) + backgroundColor = NativeUiUtils.parseColor(bgHex, defaultBackground()) + render() + } + + private fun defaultTint(): Int = + if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK + + private fun defaultBackground(): Int = LogseqTheme.current().background + + private fun render() { + val onActionFn = onAction + val actionsSnapshot = actions + val trailingSnapshot = trailing + val tintColor = tint + val bgColor = backgroundColor + + composeView.setContent { + EditorToolbar( + actions = actionsSnapshot, + trailing = trailingSnapshot, + tint = ComposeColor(tintColor), + background = ComposeColor(bgColor), + onAction = { id -> onActionFn?.invoke(id) } + ) + } + } +} + +@Composable +private fun EditorToolbar( + actions: List, + trailing: EditorAction?, + tint: ComposeColor, + background: ComposeColor, + onAction: (String) -> Unit +) { + val borderColor = ComposeColor.Gray.copy( + alpha = if (background.luminance() < 0.5f) 0.08f else 0.2f + ) + + Surface( + color = background, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, borderColor), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + actions.forEach { action -> + ToolbarButton(action, tint, onAction) + } + } + + trailing?.let { trailingAction -> + if (actions.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + } + ToolbarButton(trailingAction, tint, onAction) + } + } + } +} + +@Composable +private fun ToolbarButton( + action: EditorAction, + tint: ComposeColor, + onAction: (String) -> Unit +) { + val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) } + val contentTint = remember(tint) { tint.copy(alpha = 0.8f) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minWidth = 44.dp) + .clickable { onAction(action.id) } + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = action.title.ifBlank { action.id }, + tint = contentTint, + modifier = Modifier + .defaultMinSize(minWidth = 20.dp) + .padding(end = 2.dp) + ) + } else { + Text( + text = action.title, + color = contentTint, + fontSize = 14.sp + ) + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt new file mode 100644 index 0000000..23838f9 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt @@ -0,0 +1,280 @@ +package com.logseq.app + +import android.graphics.Color +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import kotlin.math.max + +@CapacitorPlugin(name = "NativeSelectionActionBarPlugin") +class NativeSelectionActionBarPlugin : Plugin() { + private var barView: SelectionActionBarView? = null + + @PluginMethod + fun present(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + + val actionsArray = call.getArray("actions") + val actions = parseActions(actionsArray) + val tintHex = call.getString("tintColor") + val bgHex = call.getString("backgroundColor") + + activity.runOnUiThread { + if (actions.isEmpty()) { + dismissInternal() + call.resolve() + return@runOnUiThread + } + + val root = NativeUiUtils.contentRoot(activity) + val view = barView ?: SelectionActionBarView(activity).also { v -> + v.onAction = { id -> + notifyListeners("action", JSObject().put("id", id)) + } + barView = v + } + + view.bind(actions, tintHex, bgHex) + + if (view.parent !== root) { + NativeUiUtils.detachView(view) + + val lp = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ).apply { + val margin = NativeUiUtils.dp(activity, 12f) + val bottomOffset = computeBottomOffset(activity, root) + // top / left / right: margin + // bottom: margin + bottom nav height + system/IME inset + setMargins(margin, margin, margin, margin + bottomOffset) + } + + root.addView(view, lp) + } + + call.resolve() + } + } + + @PluginMethod + fun dismiss(call: PluginCall) { + activity?.runOnUiThread { + dismissInternal() + call.resolve() + } ?: call.resolve() + } + + private fun dismissInternal() { + val activity = activity ?: return + val root = NativeUiUtils.contentRoot(activity) + + barView?.let { root.removeView(it) } + barView = null + } + + private fun parseActions(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + SelectionAction.from(obj)?.let { result.add(it) } + } + return result + } + + /** + * Compute how far we must lift the bar from the bottom so that it + * sits above: + * - the BottomNavigationView created by LiquidTabsPlugin + * - system nav / gesture bar + * - IME (when showing) + */ + private fun computeBottomOffset(activity: android.app.Activity, root: ViewGroup): Int { + val insets = ViewCompat.getRootWindowInsets(root) + val systemBarsBottom = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + val imeBottom = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 + + // Find the bottom nav created by LiquidTabsPlugin (must have this ID set there) + val bottomNav = activity.findViewById(R.id.liquid_tabs_bottom_nav) + // Fallback height if nav not measured yet + val navHeight = bottomNav?.height ?: NativeUiUtils.dp(activity, 56f) + + val insetBottom = max(systemBarsBottom, imeBottom) + return navHeight + insetBottom + } +} + +data class SelectionAction( + val id: String, + val title: String, + val systemIcon: String? +) { + companion object { + fun from(obj: org.json.JSONObject): SelectionAction? { + val id = obj.optString("id", "") + if (id.isBlank()) return null + val title = obj.optString("title", id) + val icon = obj.optString("systemIcon", null) + return SelectionAction(id, title, icon) + } + } +} + +private class SelectionActionBarView(context: android.content.Context) : FrameLayout(context) { + var onAction: ((String) -> Unit)? = null + private val composeView: ComposeView + + init { + composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + addView(composeView) + } + + fun bind(actions: List, tintHex: String?, bgHex: String?) { + val defaultTint = if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK + val tint = ComposeColor(NativeUiUtils.parseColor(tintHex, defaultTint)) + val backgroundColor = ComposeColor(NativeUiUtils.parseColor(bgHex, LogseqTheme.current().background)) + val onActionFn = onAction + + composeView.setContent { + SelectionActionBar(actions, tint, backgroundColor) { id -> + onActionFn?.invoke(id) + } + } + composeView.doOnNextLayout { requestLayout() } + } +} + +@Composable +private fun SelectionActionBar( + actions: List, + tint: ComposeColor, + background: ComposeColor, + onAction: (String) -> Unit +) { + val (mainActions, trailingAction) = remember(actions) { + val primary = if (actions.size > 1) actions.dropLast(1) else emptyList() + Pair(primary, actions.lastOrNull()) + } + val scrollState = rememberScrollState() + + Surface( + color = background, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, tint.copy(alpha = 0.05f)) + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (mainActions.isNotEmpty()) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(scrollState), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + mainActions.forEach { action -> + SelectionActionButton(action, tint, onAction) + } + } + } + + trailingAction?.let { action -> + if (mainActions.isNotEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + Divider( + modifier = Modifier + .height(28.dp) + .width(1.dp), + color = tint.copy(alpha = 0.15f) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + SelectionActionButton(action, tint, onAction) + } + } + } +} + +@Composable +private fun SelectionActionButton( + action: SelectionAction, + tint: ComposeColor, + onAction: (String) -> Unit +) { + val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) } + val contentTint = remember(tint) { tint.copy(alpha = 0.8f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .defaultMinSize(minWidth = 48.dp) + .clickable { onAction(action.id) } + .padding(horizontal = 4.dp, vertical = 8.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = action.title.ifBlank { action.id }, + tint = contentTint, + modifier = Modifier.size(22.dp) + ) + } + Text( + text = action.title, + color = contentTint, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt new file mode 100644 index 0000000..c9a543e --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt @@ -0,0 +1,300 @@ +package com.logseq.app + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.doOnNextLayout +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "NativeTopBarPlugin") +class NativeTopBarPlugin : Plugin() { + private var topBarView: NativeTopBarView? = null + + @PluginMethod + fun configure(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + + activity.runOnUiThread { + val hidden = call.getBoolean("hidden") ?: false + val title = call.getString("title") ?: "" + val leftButtons = parseButtons(call.getArray("leftButtons")) + val rightButtons = parseButtons(call.getArray("rightButtons")) + val titleClickable = call.getBoolean("titleClickable") ?: false + val tintHex = call.getString("tintColor") + val tintColorOverride = + tintHex?.takeIf { it.isNotBlank() } + ?.let { NativeUiUtils.parseColor(it, LogseqTheme.current().tint) } + + if (hidden) { + removeBar() + call.resolve() + return@runOnUiThread + } + + val bar = topBarView ?: NativeTopBarView(activity).also { view -> + view.onTap = { id -> + notifyListeners("buttonTapped", JSObject().put("id", id)) + } + attachBar(view) + topBarView = view + } + + bar.bind(title, titleClickable, leftButtons, rightButtons, tintColorOverride) + call.resolve() + } + } + + private fun attachBar(bar: NativeTopBarView) { + val root = NativeUiUtils.contentRoot(activity) + if (bar.parent !== root) { + NativeUiUtils.detachView(bar) + val lp = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + root.addView(bar, lp) + } + } + + private fun removeBar() { + val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return + topBarView?.let { view -> + root.removeView(view) + } + topBarView = null + } + + private fun parseButtons(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val id = obj.optString("id", "") + if (id.isBlank()) continue + val systemIcon = obj.optString("systemIcon", "") + val title = obj.optString("title", id) + val tintHex = obj.optString("tintColor", obj.optString("color", "")) + val iconSize = if (id == "sync") { + "small" + } else { + "medium" + } + + val size = obj.optString("size", iconSize) + result.add( + ButtonSpec( + id = id, + title = if (title.isNotBlank()) title else id, + systemIcon = systemIcon.takeIf { it.isNotBlank() }, + tint = tintHex, + size = size + ) + ) + } + return result + } +} + +data class ButtonSpec( + val id: String, + val title: String, + val systemIcon: String?, + val tint: String?, + val size: String +) + +private class NativeTopBarView(context: android.content.Context) : FrameLayout(context) { + var onTap: ((String) -> Unit)? = null + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + init { + addView(composeView) + } + + fun bind( + title: String, + titleClickable: Boolean, + leftButtons: List, + rightButtons: List, + tintColorOverride: Int? + ) { + val onTapFn = onTap + composeView.setContent { + TopBarContent( + title = title, + titleClickable = titleClickable, + leftButtons = leftButtons, + rightButtons = rightButtons, + tintOverride = tintColorOverride, + onTap = { id -> onTapFn?.invoke(id) } + ) + } + doOnNextLayout { + requestLayout() + } + } +} + +@Composable +private fun TopBarContent( + title: String, + titleClickable: Boolean, + leftButtons: List, + rightButtons: List, + tintOverride: Int?, + onTap: (String) -> Unit +) { + val theme by LogseqTheme.colors.collectAsState() + val background = ComposeColor(theme.background) + val tint = tintOverride?.let { ComposeColor(it) } ?: ComposeColor(theme.tint) + val contentTint = tint.copy(alpha = 0.8f) + + Surface( + color = background, + shadowElevation = 4.dp + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 42.dp) + .padding(start = 12.dp, end = 8.dp, top = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + leftButtons.forEachIndexed { index, button -> + if (index > 0) { + Spacer(modifier = Modifier.width(10.dp)) + } + TopBarButton(button, contentTint, onTap) + } + } + + Text( + text = title, + color = contentTint, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 2.dp) + .let { mod -> + if (titleClickable) { + mod.clickable { onTap("title") } + } else { + mod + } + } + ) + + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + rightButtons.forEachIndexed { index, button -> + if (index > 0) { + Spacer(modifier = Modifier.width(6.dp)) + } + TopBarButton(button, contentTint, onTap) + } + } + } + +// HorizontalDivider( +// thickness = 1.dp, +// color = contentTint.copy(alpha = 0.28f) +// ) + } + } +} + +@Composable +private fun TopBarButton( + spec: ButtonSpec, + fallbackTint: ComposeColor, + onTap: (String) -> Unit +) { + val icon = remember(spec.systemIcon) { MaterialIconResolver.resolve(spec.systemIcon) } + val baseTint = remember(spec.tint, fallbackTint) { + spec.tint?.let { ComposeColor(NativeUiUtils.parseColor(it, fallbackTint.toArgb())) } + ?: fallbackTint + } + val tint = remember(baseTint) { baseTint.copy(alpha = 0.8f) } + val fontSize = when (spec.size.lowercase()) { + "small" -> 13.sp + "large" -> 17.sp + else -> 15.sp + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onTap(spec.id) } + .padding(horizontal = 8.dp, vertical = 1.dp) + ) { + if (icon != null) { + val iconSize = when (spec.size.lowercase()) { + "small" -> 12.dp + else -> 22.dp + } + + Icon( + imageVector = icon, + contentDescription = spec.title.ifBlank { spec.id }, + tint = tint, + modifier = Modifier.size(iconSize) + ) + } else { + Text( + text = spec.title, + color = tint, + fontSize = fontSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeUiUtils.kt b/android/app/src/main/java/com/logseq/app/NativeUiUtils.kt new file mode 100644 index 0000000..559f288 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeUiUtils.kt @@ -0,0 +1,31 @@ +package com.logseq.app + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout + +object NativeUiUtils { + fun dp(context: Context, value: Float): Int = + (value * context.resources.displayMetrics.density).toInt() + + fun parseColor(hex: String?, defaultColor: Int): Int { + if (hex.isNullOrBlank()) return defaultColor + return try { + Color.parseColor(hex) + } catch (_: IllegalArgumentException) { + defaultColor + } + } + + fun contentRoot(activity: Activity): FrameLayout = + activity.findViewById(android.R.id.content) + + fun detachView(view: View?): ViewGroup? { + val parent = view?.parent as? ViewGroup ?: return null + parent.removeView(view) + return parent + } +} diff --git a/android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt b/android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt new file mode 100644 index 0000000..20bfed2 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt @@ -0,0 +1,203 @@ +package com.logseq.app + +import android.util.Log + +data class NavigationRenderState( + val previousStackId: String, + val activeStackId: String, + val paths: List, + val navigationType: String, + val path: String, + val stackSwitched: Boolean, + val applyToCompose: Boolean = true +) + +private data class PendingNativePop( + val stackId: String, + val targetPath: String +) + +class NavigationCoordinator { + companion object { + private const val TAG = "NavStack" + private const val DEBUG_PREFIX = "[DEBUG-navstack]" + } + + private val primaryStack = "home" + private val stackPaths: MutableMap> = mutableMapOf( + primaryStack to mutableListOf("/") + ) + private val pendingNativePops: ArrayDeque = ArrayDeque() + var activeStackId: String = primaryStack + private set + + private fun rootPath(stack: String): String = + if (stack == primaryStack) "/" else "/__stack__/$stack" + + private fun normalizedPath(raw: String?): String = + raw?.takeIf { it.isNotBlank() } ?: "/" + + fun debugState(): String = + "active=$activeStackId stacks=" + stackPaths.entries.joinToString( + prefix = "{", + postfix = "}" + ) { (stack, paths) -> "$stack=${paths.joinToString(prefix = "[", postfix = "]")}" } + + private fun logDebug(message: String) { + try { + Log.d(TAG, message) + } catch (_: RuntimeException) { + // Local JVM unit tests do not provide android.util.Log. + } + } + + private fun snapshot( + previousStackId: String, + navigationType: String, + path: String, + stackSwitched: Boolean, + applyToCompose: Boolean = true + ): NavigationRenderState { + val paths = stackPaths[activeStackId]?.toList() ?: listOf(rootPath(activeStackId)) + return NavigationRenderState( + previousStackId = previousStackId, + activeStackId = activeStackId, + paths = paths, + navigationType = navigationType, + path = path, + stackSwitched = stackSwitched, + applyToCompose = applyToCompose + ) + } + + private fun consumePendingNativePop(stackId: String, path: String): Boolean { + val pending = pendingNativePops.firstOrNull() ?: return false + if (pending.stackId != stackId || pending.targetPath != path) { + return false + } + pendingNativePops.removeFirst() + logDebug( + "$DEBUG_PREFIX coordinator.consumePendingNativePop stack=$stackId path=$path remaining=${pendingNativePops.size} ${debugState()}" + ) + return true + } + + fun onRouteChange(stack: String?, navigationType: String?, path: String?): NavigationRenderState { + val stackId = stack?.takeIf { it.isNotBlank() } ?: primaryStack + val navType = navigationType?.lowercase() ?: "push" + val resolvedPath = normalizedPath(path) + val previousStackId = activeStackId + + logDebug( + "$DEBUG_PREFIX coordinator.onRouteChange.before stack=$stackId type=$navType path=$resolvedPath ${debugState()}" + ) + + if (navType == "pop" && consumePendingNativePop(stackId, resolvedPath)) { + activeStackId = stackId + return snapshot(previousStackId, navType, resolvedPath, stackSwitched = false, applyToCompose = false) + } + + val paths = stackPaths.getOrPut(stackId) { mutableListOf(rootPath(stackId)) } + + if (stackId != activeStackId) { + val newPaths = paths.toMutableList() + when { + resolvedPath == rootPath(stackId) -> { + newPaths.clear() + newPaths.add(resolvedPath) + } + + navType == "reset" -> { + newPaths.clear() + newPaths.add(resolvedPath) + } + + stackId == primaryStack && resolvedPath == "/" -> { + newPaths.clear() + newPaths.add("/") + } + + newPaths.isEmpty() -> newPaths.add(resolvedPath) + + navType == "push" && newPaths.last() != resolvedPath -> newPaths.add(resolvedPath) + + newPaths.last() != resolvedPath -> newPaths[newPaths.lastIndex] = resolvedPath + } + + activeStackId = stackId + stackPaths[stackId] = newPaths + + logDebug( + "$DEBUG_PREFIX coordinator.onRouteChange.after stack=$stackId type=$navType path=$resolvedPath ${debugState()} canPop=${canPop()} stackSwitched=true" + ) + + return snapshot(previousStackId, navType, resolvedPath, stackSwitched = true) + } + + when (navType) { + "reset" -> { + paths.clear() + paths.add(resolvedPath) + } + + "replace" -> { + if (paths.isEmpty()) { + paths.add(resolvedPath) + } else { + paths[paths.lastIndex] = resolvedPath + } + } + + "pop" -> { + if (paths.size > 1) { + paths.removeAt(paths.lastIndex) + } + } + + else -> { // push (default) + if (paths.isEmpty()) { + paths.add(resolvedPath) + } else if (paths.last() != resolvedPath) { + paths.add(resolvedPath) + } + } + } + + if (navType != "push" && resolvedPath == rootPath(stackId)) { + paths.clear() + paths.add(resolvedPath) + } + + activeStackId = stackId + stackPaths[stackId] = paths + + logDebug( + "$DEBUG_PREFIX coordinator.onRouteChange.after stack=$stackId type=$navType path=$resolvedPath ${debugState()} canPop=${canPop()}" + ) + + return snapshot(previousStackId, navType, resolvedPath, stackSwitched = false) + } + + fun canPop(): Boolean { + val paths = stackPaths[activeStackId] ?: return false + return paths.size > 1 + } + + fun prepareNativePop(): NavigationRenderState? { + val paths = stackPaths[activeStackId] ?: return null + if (paths.size <= 1) return null + paths.removeAt(paths.lastIndex) + val target = paths.lastOrNull() ?: return null + pendingNativePops.addLast(PendingNativePop(activeStackId, target)) + logDebug( + "$DEBUG_PREFIX coordinator.prepareNativePop stack=$activeStackId target=$target pending=${pendingNativePops.size} ${debugState()} canPop=${canPop()}" + ) + return snapshot( + previousStackId = activeStackId, + navigationType = "pop", + path = target, + stackSwitched = false, + applyToCompose = true + ) + } +} diff --git a/android/app/src/main/java/com/logseq/app/UILocal.kt b/android/app/src/main/java/com/logseq/app/UILocal.kt new file mode 100644 index 0000000..6026154 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/UILocal.kt @@ -0,0 +1,146 @@ +package com.logseq.app + +import android.app.DatePickerDialog +import android.content.Intent +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +@CapacitorPlugin(name = "UILocal") +class UILocal : Plugin() { + private var toast: Toast? = null + companion object { + const val ACTION_ROUTE_CHANGED = "com.logseq.app.ROUTE_DID_CHANGE" + private const val DEBUG_PREFIX = "[DEBUG-navstack]" + } + + @PluginMethod + fun showDatePicker(call: PluginCall) { + val defaultDate = call.getString("defaultDate") + val calendar = Calendar.getInstance() + + if (!defaultDate.isNullOrEmpty()) { + try { + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val date = sdf.parse(defaultDate) + if (date != null) { + calendar.time = date + } + } catch (e: Exception) { + call.reject("Invalid default date format. Use YYYY-MM-DD", e) + return + } + } + + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + + // create date picker dialog + val datePickerDialog = DatePickerDialog( + activity, + null, + year, month, day + ) + + datePickerDialog.datePicker.init(year, month, day) { _, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> + // format selected date + val selectedDate = Calendar.getInstance().apply { + set(selectedYear, selectedMonth, selectedDay) + } + + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val formattedDate = sdf.format(selectedDate.time) + + // return to js + val res = JSObject() + res.put("value", formattedDate) + call.resolve(res) + + // close dialog + datePickerDialog.dismiss() + } + + datePickerDialog.setOnShowListener { + val parent = datePickerDialog.getButton(DatePickerDialog.BUTTON_POSITIVE).parent.parent + if (parent is ViewGroup) { + parent.visibility = View.GONE + } + } + + if (call.getBoolean("restrictFuture", false) == true) { + datePickerDialog.datePicker.maxDate = System.currentTimeMillis() + } + + try { + datePickerDialog.show() + } catch (e: Exception) { + call.reject("Error showing date picker", e) + } + } + + @PluginMethod + fun alert(call: PluginCall) { + val message = call.getString("title") ?: call.getString("message") + val duration = if ((call.getDouble("duration") ?: 0.0) > 3.5) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + val ctx = context ?: run { + call.reject("No context") + return + } + if (message.isNullOrBlank()) { + call.reject("title or message is required") + return + } + toast?.cancel() + toast = Toast.makeText(ctx, message, duration).also { it.show() } + call.resolve() + } + + @PluginMethod + fun hideAlert(call: PluginCall) { + toast?.cancel() + toast = null + call.resolve() + } + + @PluginMethod + fun routeDidChange(call: PluginCall) { + val navigationType = call.getString("navigationType") ?: "push" + val push = call.getBoolean("push") ?: (navigationType == "push") + val path = call.getString("path") ?: "/" + val stack = call.getString("stack") ?: "home" + + Log.d( + "NavStack", + "$DEBUG_PREFIX uiLocal.routeDidChange stack=$stack type=$navigationType push=$push path=$path" + ) + + + val ctx = context + if (ctx != null) { + val intent = Intent(ACTION_ROUTE_CHANGED).apply { + putExtra("navigationType", navigationType) + putExtra("push", push) + putExtra("stack", stack) + putExtra("path", path) + } + ctx.sendBroadcast(intent) + } + + call.resolve() + } + + @PluginMethod + fun transcribeAudio2Text(call: PluginCall) { + call.reject("transcription not supported on Android") + } +} diff --git a/android/app/src/main/java/com/logseq/app/Utils.kt b/android/app/src/main/java/com/logseq/app/Utils.kt new file mode 100644 index 0000000..e46d81c --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/Utils.kt @@ -0,0 +1,38 @@ +package com.logseq.app + +import androidx.appcompat.app.AppCompatDelegate +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "Utils") +class Utils : Plugin() { + @PluginMethod + fun setInterfaceStyle(call: PluginCall) { + val mode = (call.getString("mode") ?: "system").lowercase() + val system = call.getBoolean("system") ?: (mode == "system") + + val nightMode = + if (system || mode == "system") { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } else if (mode == "dark") { + AppCompatDelegate.MODE_NIGHT_YES + } else { + AppCompatDelegate.MODE_NIGHT_NO + } + + val activity = activity + if (activity == null) { + call.reject("No activity") + return + } + + activity.runOnUiThread { + AppCompatDelegate.setDefaultNightMode(nightMode) + (activity as? MainActivity)?.applyLogseqThemeNow() + call.resolve() + } + } +} + diff --git a/android/app/src/main/java/com/logseq/app/WebViewKeyboardRecovery.kt b/android/app/src/main/java/com/logseq/app/WebViewKeyboardRecovery.kt new file mode 100644 index 0000000..e545507 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/WebViewKeyboardRecovery.kt @@ -0,0 +1,122 @@ +package com.logseq.app + +import android.app.Activity +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import java.util.WeakHashMap + +/** + * Defensive recovery for Android WebView/IME interactions. + * + * In this app the Capacitor WebView is re-parented into a Compose host. On some + * Android/WebView combinations, focusing an input while the IME is shown can + * leave the native WebView host chain panned/resized after the IME hides. The + * web content itself may remain scrolled, which is valid; this class only resets + * native view/container state that should always return to full-screen. + */ +object WebViewKeyboardRecovery { + private val listeners = WeakHashMap() + + @JvmStatic + fun install(activity: Activity, webView: WebView) { + if (listeners.containsKey(activity)) return + + val content = activity.findViewById(android.R.id.content) ?: return + val root = content.rootView ?: content + val keyboardThreshold = NativeUiUtils.dp(activity, 120f) + var wasKeyboardVisible = isKeyboardVisible(root, keyboardThreshold) + + val listener = ViewTreeObserver.OnGlobalLayoutListener { + val keyboardVisible = isKeyboardVisible(root, keyboardThreshold) + if (wasKeyboardVisible && !keyboardVisible) { + recoverAfterKeyboardHidden(activity, content, webView) + } + wasKeyboardVisible = keyboardVisible + } + + listeners[activity] = listener + content.viewTreeObserver.addOnGlobalLayoutListener(listener) + } + + @JvmStatic + fun uninstall(activity: Activity) { + val listener = listeners.remove(activity) ?: return + val content = activity.findViewById(android.R.id.content) ?: return + if (content.viewTreeObserver.isAlive) { + content.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + + private fun isKeyboardVisible(root: View, thresholdPx: Int): Boolean { + if (root.height <= 0) return false + + val visibleFrame = Rect() + root.getWindowVisibleDisplayFrame(visibleFrame) + val coveredHeight = root.height - visibleFrame.bottom + return coveredHeight > thresholdPx + } + + private fun recoverAfterKeyboardHidden( + activity: Activity, + content: FrameLayout, + webView: WebView + ) { + longArrayOf(0L, 80L, 240L).forEach { delay -> + content.postDelayed({ + resetNativeHostState(content, webView) + ViewCompat.requestApplyInsets(activity.window.decorView) + ViewCompat.requestApplyInsets(content) + ViewCompat.requestApplyInsets(webView) + }, delay) + } + } + + private fun resetNativeHostState(content: FrameLayout, webView: WebView) { + content.translationY = 0f + content.scrollTo(0, 0) + + var current: View? = webView + while (current != null) { + current.translationY = 0f + + if (current !== webView && current is ViewGroup) { + current.scrollTo(0, 0) + } + + resetLayoutParamsIfWebViewHost(current, webView) + current.requestLayout() + current.invalidate() + + if (current === content) break + current = current.parent as? View + } + } + + private fun resetLayoutParamsIfWebViewHost(view: View, webView: WebView) { + val shouldFillParent = view === webView || + view.id == R.id.webview_container || + view.tag == "compose-host-webview" + + if (!shouldFillParent) return + + val lp = view.layoutParams ?: return + var changed = false + if (lp.width != ViewGroup.LayoutParams.MATCH_PARENT) { + lp.width = ViewGroup.LayoutParams.MATCH_PARENT + changed = true + } + if (lp.height != ViewGroup.LayoutParams.MATCH_PARENT) { + lp.height = ViewGroup.LayoutParams.MATCH_PARENT + changed = true + } + if (changed) { + view.layoutParams = lp + } + } +} + diff --git a/android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt b/android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt new file mode 100644 index 0000000..ea0038a --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt @@ -0,0 +1,187 @@ +package com.logseq.app + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View +import android.view.Window +import android.widget.FrameLayout +import android.widget.ImageView +import java.lang.ref.WeakReference + +/** + * Utility to capture lightweight WebView snapshots and show them in the overlay container. + * Used to keep the background stable while the shared WebView is being reparented + * (navigation transitions, bottom sheet presentation/dismiss). + */ +object WebViewSnapshotManager { + private var overlayRef: WeakReference? = null + private var windowRef: WeakReference? = null + private val snapshotRefs: MutableMap> = mutableMapOf() + private val containerRefs: MutableMap> = mutableMapOf() + private var snapshotBackgroundColor: Int = LogseqTheme.current().background + private val mainHandler = Handler(Looper.getMainLooper()) + + fun setSnapshotBackgroundColor(color: Int) { + snapshotBackgroundColor = color + overlayRef?.get()?.setBackgroundColor(Color.TRANSPARENT) + } + + fun registerWindow(window: Window?) { + windowRef = window?.let { WeakReference(it) } + } + + fun registerOverlay(overlay: FrameLayout?) { + overlayRef = overlay?.let { WeakReference(it) } + } + + fun showSnapshot(tag: String, webView: View): View? { + val overlay = ensureOverlay(webView) ?: return null + clearSnapshot(tag) + overlay.isClickable = true + overlay.visibility = View.VISIBLE + + val snapshotView = makeSnapshotView(webView) + + overlay.addView( + snapshotView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + overlay.bringChildToFront(snapshotView) + + snapshotRefs[tag] = WeakReference(snapshotView) + containerRefs[tag] = WeakReference(overlay) + + return snapshotView + } + + fun clearSnapshot(tag: String) { + val view = snapshotRefs.remove(tag)?.get() + val container = containerRefs.remove(tag)?.get() + if (view != null && container != null) { + container.removeView(view) + if (container.childCount == 0) { + container.isClickable = false + container.visibility = View.GONE + } + } + } + + fun clearAll() { + snapshotRefs.keys.toList().forEach { clearSnapshot(it) } + } + + private fun ensureOverlay(webView: View): FrameLayout? { + overlayRef?.get()?.let { return it } + val root = webView.rootView + val overlay = root.findViewById(R.id.webview_overlay_container) + if (overlay != null) { + overlayRef = WeakReference(overlay) + } + return overlay + } + + private fun makeSnapshotView(webView: View): View { + val width = webView.width + val height = webView.height + if (width <= 0 || height <= 0) { + return View(webView.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + } + + // Limit bitmap size to avoid OOM (e.g., max 4096x4096 or 16MB) + val MAX_BITMAP_DIMENSION = 4096 + val MAX_BITMAP_SIZE = 16 * 1024 * 1024 // 16MB + val safeWidth = width.coerceAtMost(MAX_BITMAP_DIMENSION) + val safeHeight = height.coerceAtMost(MAX_BITMAP_DIMENSION) + val estimatedSize = safeWidth * safeHeight * 4 + if (estimatedSize > MAX_BITMAP_SIZE) { + // Fallback: show plain color view if too large + return View(webView.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + } + + val bitmap = try { + Bitmap.createBitmap(safeWidth, safeHeight, Bitmap.Config.ARGB_8888) + } catch (e: OutOfMemoryError) { + // Fallback: show plain color view if OOM + return View(webView.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + } + // Fast fallback: capture via View#draw (can be imperfect for WebView on some devices). + try { + val canvas = Canvas(bitmap) + canvas.drawColor(snapshotBackgroundColor) + // If we had to scale down, scale the canvas + if (safeWidth != width || safeHeight != height) { + val scaleX = safeWidth.toFloat() / width + val scaleY = safeHeight.toFloat() / height + canvas.scale(scaleX, scaleY) + } + webView.draw(canvas) + } catch (_: Exception) { + // Keep bitmap with background color only. + } + + val imageView = ImageView(webView.context).apply { + setImageBitmap(bitmap) + scaleType = ImageView.ScaleType.FIT_XY + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + + // Higher-fidelity capture: PixelCopy from the Window buffer. + val window = windowRef?.get() + if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + val loc = IntArray(2) + webView.getLocationInWindow(loc) + val rect = Rect(loc[0], loc[1], loc[0] + width, loc[1] + height) + + PixelCopy.request(window, rect, bitmap, { result -> + if (result == PixelCopy.SUCCESS) { + imageView.invalidate() + } + }, mainHandler) + } catch (_: Exception) { + // Ignore; fallback bitmap already set. + } + } + + return imageView + } +} diff --git a/android/app/src/main/res/anim/byebye.xml b/android/app/src/main/res/anim/byebye.xml new file mode 100644 index 0000000..0c45f42 --- /dev/null +++ b/android/app/src/main/res/anim/byebye.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..ee0775d Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-ldpi/splash.png b/android/app/src/main/res/drawable-land-ldpi/splash.png new file mode 100644 index 0000000..505b433 Binary files /dev/null and b/android/app/src/main/res/drawable-land-ldpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..b0c51e0 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-night-hdpi/splash.png b/android/app/src/main/res/drawable-land-night-hdpi/splash.png new file mode 100644 index 0000000..ee0775d Binary files /dev/null and b/android/app/src/main/res/drawable-land-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-night-ldpi/splash.png b/android/app/src/main/res/drawable-land-night-ldpi/splash.png new file mode 100644 index 0000000..505b433 Binary files /dev/null and b/android/app/src/main/res/drawable-land-night-ldpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-night-mdpi/splash.png b/android/app/src/main/res/drawable-land-night-mdpi/splash.png new file mode 100644 index 0000000..b0c51e0 Binary files /dev/null and b/android/app/src/main/res/drawable-land-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-night-xhdpi/splash.png b/android/app/src/main/res/drawable-land-night-xhdpi/splash.png new file mode 100644 index 0000000..657e33f Binary files /dev/null and b/android/app/src/main/res/drawable-land-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png new file mode 100644 index 0000000..925ad4b Binary files /dev/null and b/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png new file mode 100644 index 0000000..0ac8581 Binary files /dev/null and b/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..657e33f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..925ad4b Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..0ac8581 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night/splash.png b/android/app/src/main/res/drawable-night/splash.png new file mode 100644 index 0000000..505b433 Binary files /dev/null and b/android/app/src/main/res/drawable-night/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..3f6a50a Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-ldpi/splash.png b/android/app/src/main/res/drawable-port-ldpi/splash.png new file mode 100644 index 0000000..be35972 Binary files /dev/null and b/android/app/src/main/res/drawable-port-ldpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..9821bd2 Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-night-hdpi/splash.png b/android/app/src/main/res/drawable-port-night-hdpi/splash.png new file mode 100644 index 0000000..3f6a50a Binary files /dev/null and b/android/app/src/main/res/drawable-port-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-night-ldpi/splash.png b/android/app/src/main/res/drawable-port-night-ldpi/splash.png new file mode 100644 index 0000000..be35972 Binary files /dev/null and b/android/app/src/main/res/drawable-port-night-ldpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-night-mdpi/splash.png b/android/app/src/main/res/drawable-port-night-mdpi/splash.png new file mode 100644 index 0000000..9821bd2 Binary files /dev/null and b/android/app/src/main/res/drawable-port-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-night-xhdpi/splash.png b/android/app/src/main/res/drawable-port-night-xhdpi/splash.png new file mode 100644 index 0000000..5a7badd Binary files /dev/null and b/android/app/src/main/res/drawable-port-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png new file mode 100644 index 0000000..20ea9dc Binary files /dev/null and b/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png new file mode 100644 index 0000000..bedfd18 Binary files /dev/null and b/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..5a7badd Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..20ea9dc Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..bedfd18 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_search_clear.xml b/android/app/src/main/res/drawable/ic_search_clear.xml new file mode 100644 index 0000000..cfe4506 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_search_clear.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_tab_flashcards.xml b/android/app/src/main/res/drawable/ic_tab_flashcards.xml new file mode 100644 index 0000000..16822f1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_flashcards.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..9821bd2 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/drawable/splash_centered.xml b/android/app/src/main/res/drawable/splash_centered.xml new file mode 100644 index 0000000..a7b0ab8 --- /dev/null +++ b/android/app/src/main/res/drawable/splash_centered.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..0aa82aa --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..0aa82aa --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..dbb186e Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..4a6cd84 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4a6cd84 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..fe16401 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..7073bb7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000..c159bea Binary files /dev/null and b/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png new file mode 100644 index 0000000..7bdde2a Binary files /dev/null and b/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7bdde2a Binary files /dev/null and b/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000..429442b Binary files /dev/null and b/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..0db78ff Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..2a74148 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2a74148 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..7643cce Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5fc932f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2dcf24e Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..0736e68 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0736e68 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..3012190 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..66e1a36 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..c8c3116 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..8d7c2f2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8d7c2f2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..47c73e8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c21b469 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..fc96a60 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..5beeac1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5beeac1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..d1e80f6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2bc5702 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..052c0a7 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #0f262e + + #037DBA + #6097c7 + #0f262e + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/ids.xml b/android/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..14b2b64 --- /dev/null +++ b/android/app/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f0ac725 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Logseq + Logseq + com.logseq.app + com.logseq.app + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..39e1540 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/config.xml b/android/app/src/main/res/xml/config.xml new file mode 100644 index 0000000..1b1b0e0 --- /dev/null +++ b/android/app/src/main/res/xml/config.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..d11f6e5 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,16 @@ + + + + logseq.local + + + + + + + + + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..07275d9 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,55 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + ext { + kotlin_version = '2.0.21' + composeCompilerVersion = '2.0.1' + androidxActivityVersion = '1.9.2' + } + repositories { + google() + jcenter() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$composeCompilerVersion" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + jcenter() + mavenCentral() + } +} + +// Force compileSdk 36 for all subprojects to match dependency requirements +subprojects { + afterEvaluate { project -> + if (project.hasProperty('android')) { + project.android { + def currentSdk = compileSdkVersion + def currentSdkInt = currentSdk instanceof String ? + currentSdk.replace('android-', '') as int : + currentSdk as int + if (currentSdkInt < 36) { + compileSdkVersion 36 + } + } + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..c460134 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,57 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.3.1_@capacitor+core@8.3.1/node_modules/@capacitor/android/capacitor') + +include ':aparajita-capacitor-secure-storage' +project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') + +include ':capacitor-community-safe-area' +project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.3.1/node_modules/@capacitor-community/safe-area/android') + +include ':capacitor-action-sheet' +project(':capacitor-action-sheet').projectDir = new File('../node_modules/.pnpm/@capacitor+action-sheet@8.1.1_@capacitor+core@8.3.1/node_modules/@capacitor/action-sheet/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.1/node_modules/@capacitor/app/android') + +include ':capacitor-camera' +project(':capacitor-camera').projectDir = new File('../node_modules/.pnpm/@capacitor+camera@8.2.0_@capacitor+core@8.3.1/node_modules/@capacitor/camera/android') + +include ':capacitor-clipboard' +project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.3.1/node_modules/@capacitor/clipboard/android') + +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@8.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/device/android') + +include ':capacitor-dialog' +project(':capacitor-dialog').projectDir = new File('../node_modules/.pnpm/@capacitor+dialog@8.0.1_@capacitor+core@8.3.1/node_modules/@capacitor/dialog/android') + +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.1/node_modules/@capacitor/filesystem/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/haptics/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.3.1/node_modules/@capacitor/keyboard/android') + +include ':capacitor-network' +project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.3.1/node_modules/@capacitor/network/android') + +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.3.1/node_modules/@capacitor/share/android') + +include ':capacitor-splash-screen' +project(':capacitor-splash-screen').projectDir = new File('../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.3.1/node_modules/@capacitor/splash-screen/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@capacitor+status-bar@8.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/status-bar/android') + +include ':capgo-capacitor-navigation-bar' +project(':capgo-capacitor-navigation-bar').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-navigation-bar@8.1.5_@capacitor+core@8.3.1/node_modules/@capgo/capacitor-navigation-bar/android') + +include ':send-intent' +project(':send-intent').projectDir = new File('../node_modules/.pnpm/send-intent@7.0.0_@capacitor+core@8.3.1/node_modules/send-intent/android') + +include ':jcesarmobile-ssl-skip' +project(':jcesarmobile-ssl-skip').projectDir = new File('../node_modules/.pnpm/@jcesarmobile+ssl-skip@0.9.0/node_modules/@jcesarmobile/ssl-skip/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..75264e2 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,26 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx4096m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Keep Capacitor 8 plugins on AndroidX core versions that still match AGP 8.7 / compileSdk 35. +androidxCoreVersion=1.15.0 +androidxCoreKTXVersion=1.15.0 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..9b141f1 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,6 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' + diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..aef6d3f --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,21 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxCoreKTXVersion = '1.17.0' + androidxNavigationVersion = '2.9.6' + androidxFragmentVersion = '1.8.9' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + materialVersion = '1.12.0' + composeBomVersion = '2024.09.02' + composeCompilerVersion = '1.7.0' +} diff --git a/assets/icon-background.png b/assets/icon-background.png new file mode 100644 index 0000000..8617110 Binary files /dev/null and b/assets/icon-background.png differ diff --git a/assets/icon-foreground.png b/assets/icon-foreground.png new file mode 100644 index 0000000..8617110 Binary files /dev/null and b/assets/icon-foreground.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8617110 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/splash-dark.png b/assets/splash-dark.png new file mode 100644 index 0000000..48776cc Binary files /dev/null and b/assets/splash-dark.png differ diff --git a/assets/splash.png b/assets/splash.png new file mode 100644 index 0000000..48776cc Binary files /dev/null and b/assets/splash.png differ diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..5065aff --- /dev/null +++ b/bb.edn @@ -0,0 +1,274 @@ +{:paths ["scripts/src" "src/main" "src/resources"] + :deps + {metosin/malli + {:git/url "https://github.com/metosin/malli" + :sha "52ea58a36ff5172b38dfc526ca638afa7226a4a0"} + borkdude/rewrite-edn {:mvn/version "0.5.9"} + logseq/bb-tasks + #_{:local/root "../bb-tasks"} + {:git/url "https://github.com/logseq/bb-tasks" + :git/sha "7cc218286c03f093996539b8195de56a0ec5df6a"} + logseq/graph-parser + {:local/root "deps/graph-parser"} + org.clj-commons/digest + {:mvn/version "1.4.100"} + cheshire/cheshire + {:mvn/version "6.2.0"}} + :pods + {clj-kondo/clj-kondo {:version "2026.04.15"} + org.babashka/fswatcher {:version "0.0.7"} + org.babashka/go-sqlite3 {:version "0.3.13"}} + :tasks + {dev:desktop-watch + logseq.tasks.dev.desktop/watch + + libs:generate-cljs-sdk + {:doc "Generate CLJS wrappers based on the JS SDK declarations" + :requires ([logseq.libs.sdk-generator :as sdk-gen]) + :task (sdk-gen/run! (sdk-gen/parse-args *command-line-args*))} + + dev:open-dev-electron-app + logseq.tasks.dev.desktop/open-dev-electron-app + + -dev:electron-start + {:depends [dev:desktop-watch dev:open-dev-electron-app]} + + dev:electron-start + {:doc "Start electron dev by watching assets and opening dev app" + ;; Parallel execution - https://book.babashka.org/#parallel + :task (run '-dev:electron-start {:parallel true})} + + dev:app-watch + logseq.tasks.dev.mobile/app-watch + + dev:mobile-https-cert + {:doc "Generate local HTTPS certificate for mobile Shadow CLJS development" + :task logseq.tasks.dev.mobile/ensure-dev-ssl-cert-task} + + -dev:build-publishing-frontend + logseq.tasks.dev/build-publishing-frontend + + dev:publishing-backend + logseq.tasks.dev/publishing-backend + + -dev:publishing-release + {:depends [-dev:build-publishing-frontend] + :doc "Build release publishing spa app given graph and output dirs" + :task (run 'dev:publishing-backend)} + + -dev:watch-publishing-frontend + logseq.tasks.dev/watch-publishing-frontend + + -dev:watch-publishing-backend + logseq.tasks.dev/watch-publishing-backend + + -dev:publishing-dev + {:depends [-dev:watch-publishing-frontend -dev:watch-publishing-backend] + :doc "Watch dev publishing spa app given graph and output dirs"} + + dev:publishing + {:doc "Builds full publishing app given graph and output dirs. Append --dev to watch frontend" + :task (if ((set *command-line-args*) "--dev") + (run '-dev:publishing-dev {:parallel true}) + (run '-dev:publishing-release))} + + dev:query + {:doc "Query a DB graph's datascript db" + :requires ([babashka.fs :as fs]) + :task (apply shell {:dir "deps/db" :extra-env {"ORIGINAL_PWD" (fs/cwd)}} + "pnpm exec nbb-logseq script/query.cljs" *command-line-args*)} + + dev:transact + {:doc "Transact against a DB graph's datascript db" + :requires ([babashka.fs :as fs]) + :task (apply shell {:dir "deps/outliner" :extra-env {"ORIGINAL_PWD" (fs/cwd)}} + "pnpm exec nbb-logseq script/transact.cljs" *command-line-args*)} + + dev:create + {:doc "Create a DB graph given a sqlite.build EDN file" + :requires ([babashka.fs :as fs]) + :task (apply shell {:dir "deps/db" :extra-env {"ORIGINAL_PWD" (fs/cwd)}} + "pnpm exec nbb-logseq -cp src:../outliner/src:script script/create_graph.cljs" *command-line-args*)} + + dev:create-clojure-log-graph + {:doc "Import local clojure-log chats into a DB graph" + :task (apply shell {:dir "deps/outliner"} + "pnpm exec nbb-logseq ../../scripts/src/logseq/tasks/db_graph/create_graph_with_clojure_irc_history.cljs" + *command-line-args*)} + + dev:import + {:doc "Import a file graph to db graph" + :requires ([babashka.fs :as fs]) + :task (apply shell {:dir "deps/graph-parser" :extra-env {"ORIGINAL_PWD" (fs/cwd)}} + "pnpm exec nbb-logseq -cp src:../outliner/src script/db_import.cljs" *command-line-args*)} + + dev:import-many + {:doc "Import multiple file graphs to db graphs" + :task logseq.tasks.dev/db-import-many} + + dev:datoms + {:doc "Write db's datoms to a file" + :requires ([babashka.fs :as fs]) + :task (apply shell {:dir "deps/db" :extra-env {"ORIGINAL_PWD" (fs/cwd)}} + "pnpm exec nbb-logseq script/dump_datoms.cljs" + *command-line-args*)} + + dev:diff-datoms + logseq.tasks.dev/diff-datoms + + dev:cap-run-ios + logseq.tasks.dev.mobile/cap-run-ios + + -dev:ios-app + {:depends [dev:app-watch dev:cap-run-ios]} + + dev:ios-app + {:doc "iOS development environment" + :task (run '-dev:ios-app {:parallel true})} + + release:ios-app + logseq.tasks.dev.mobile/run-ios-release + + dev:cap-run-android + logseq.tasks.dev.mobile/cap-run-android + + -dev:android-app + {:depends [dev:app-watch dev:cap-run-android]} + + dev:android-app + {:doc "Android development environment" + :task (run '-dev:android-app {:parallel true})} + + release:android-app + logseq.tasks.dev.mobile/run-android-release + + dev:validate-local-storage + logseq.tasks.spec/validate-local-storage + + dev:validate-plugins-edn + logseq.tasks.malli/validate-plugins-edn + + dev:validate-repo-config-edn + logseq.tasks.malli/validate-repo-config-edn + + dev:validate-global-config-edn + logseq.tasks.malli/validate-global-config-edn + + dev:validate-ast + logseq.tasks.malli/validate-ast + + dev:test + logseq.tasks.dev/test + + dev:run-test-namespaces + logseq.tasks.dev/run-test-namespaces + + dev:test-no-worker + logseq.tasks.dev/test-no-worker + + dev:lint-and-test + logseq.tasks.dev/lint-and-test + + dev:lint-test-deps + logseq.tasks.dev.lint-test-deps/run + + dev:e2e-basic-test + logseq.tasks.dev/e2e-basic-test + + dev:e2e-rtc-extra-test + {:doc "Run e2e rtc extra tests. HTTP server should be available at localhost:3001" + :task (clojure {:dir "clj-e2e"} "-X:dev-run-rtc-extra-test")} + + dev:e2e-rtc-extra-part2-test + {:doc "Run e2e rtc extra part2 tests. HTTP server should be available at localhost:3001" + :task (clojure {:dir "clj-e2e"} "-X:dev-run-rtc-extra-part2-test")} + + dev:db-sync-test + {:doc "Run db-sync server unit tests" + :task (do (shell {:dir "deps/db-sync"} "clojure -M:cljs compile db-sync-test") + (shell {:dir "deps/db-sync"} "node worker/dist/worker-test.js"))} + + dev:db-sync-start + {:doc "Start db-sync local server + client watch processes in foreground" + :task logseq.tasks.dev.db-sync/start} + + dev:gen-malli-kondo-config + logseq.tasks.dev/gen-malli-kondo-config + + dev:db-worker-node + {:doc "Compile and start db-worker-node (pass-through args forwarded to node)" + :task (do + (shell "clojure" "-M:cljs" "compile" "db-worker-node") + (apply shell "node" "./static/db-worker-node.js" *command-line-args*))} + + dev:cli-e2e + {:doc "Run shell-first CLI end-to-end tests" + :task (apply shell {:shutdown nil} "bb" "-f" "cli-e2e/bb.edn" "test" *command-line-args*)} + + dev:cli-e2e-cleanup + {:doc "Run shell-first CLI end-to-end cleanup" + :task (apply shell {:shutdown nil} "bb" "-f" "cli-e2e/bb.edn" "cleanup" *command-line-args*)} + + dev:cli-e2e-sync + {:doc "Run shell-first CLI sync end-to-end tests" + :task (apply shell {:shutdown nil} "bb" "-f" "cli-e2e/bb.edn" "test-sync" *command-line-args*)} + + lint:dev + logseq.tasks.dev.lint/dev + + lint:kondo-git-changes + logseq.tasks.dev.lint/kondo-git-changes + + 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:worker-and-frontend-separate + logseq.tasks.dev.lint/worker-and-frontend-separate + + nbb:watch + logseq.bb-tasks.nbb.watch/watch + + nbb:portal-watch + logseq.bb-tasks.nbb.watch/portal-watch + + lang:list + logseq.tasks.lang/list-langs + + lang:missing + logseq.tasks.lang/list-missing + + lang:pseudo + logseq.tasks.lang/list-pseudo + + lang:format-dicts + logseq.tasks.lang/format-dicts + + lang:validate-translations + logseq.tasks.lang/validate-translations + + lang:lint-hardcoded + logseq.tasks.lang/lint-hardcoded + + ai:check-common-errors + logseq.tasks.common-errors/check-common-errors} + + :tasks/config + {:large-vars + ;; TODO: Get to a smaller max-lines-count + {:max-lines-count 100 + ;; TODO: Address vars tagged with cleanup-todo. These + ;; are left mostly because they are not high priority + ;; or not well understood + :metadata-exceptions #{:large-vars/cleanup-todo}} + + :ns-docstrings + {:paths ["src/main"] + ;; Ignore namespaces that won't be helpful to document initially + ;; e.g. frontend.components.onboarding -> "Onboarding fns" + :ignore-regex "^(frontend.components|frontend.extensions|frontend.modules|frontend.mobile|logseq.sdk)"}}} diff --git a/bin/logseq-i18n-lint b/bin/logseq-i18n-lint new file mode 100755 index 0000000..9b29170 --- /dev/null +++ b/bin/logseq-i18n-lint @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# logseq-i18n-lint launcher +# Detects the current OS/arch and runs the prebuilt binary in the same directory. +# All arguments are forwarded to the binary. +# +# Supported platforms: +# Linux x86_64 -> logseq-i18n-lint-x86_64-linux +# Linux aarch64 -> logseq-i18n-lint-aarch64-linux +# macOS x86_64 -> logseq-i18n-lint-x86_64-macos +# macOS arm64 -> logseq-i18n-lint-aarch64-macos +# Windows x86_64 -> logseq-i18n-lint-x86_64-windows.exe (via Git Bash / MSYS2) +# Windows aarch64 -> logseq-i18n-lint-aarch64-windows.exe + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Detect OS ──────────────────────────────────────────────────────────────── + +OS="$(uname -s)" +case "${OS}" in + Linux*) platform="linux" ;; + Darwin*) platform="macos" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + platform="windows" ;; + *) + echo "error: unsupported OS: ${OS}" >&2 + exit 1 + ;; +esac + +# ── Detect architecture ─────────────────────────────────────────────────────── + +ARCH="$(uname -m)" +case "${ARCH}" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) + echo "error: unsupported architecture: ${ARCH}" >&2 + exit 1 + ;; +esac + +# ── Resolve binary path ──────────────────────────────────────────────────────── + +if [[ "${platform}" == "windows" ]]; then + bin="${SCRIPT_DIR}/logseq-i18n-lint-${arch}-${platform}.exe" +else + bin="${SCRIPT_DIR}/logseq-i18n-lint-${arch}-${platform}" +fi + +if [[ ! -f "${bin}" ]]; then + echo "error: binary not found: ${bin}" >&2 + echo " Download it from: https://github.com/logseq/logseq-i18n-lint/releases/latest" >&2 + exit 1 +fi + +if [[ ! -x "${bin}" ]]; then + chmod +x "${bin}" +fi + +# ── Run ─────────────────────────────────────────────────────────────────────── + +exec "${bin}" "$@" diff --git a/bin/logseq-i18n-lint-aarch64-linux b/bin/logseq-i18n-lint-aarch64-linux new file mode 100755 index 0000000..5b0e06f Binary files /dev/null and b/bin/logseq-i18n-lint-aarch64-linux differ diff --git a/bin/logseq-i18n-lint-aarch64-macos b/bin/logseq-i18n-lint-aarch64-macos new file mode 100755 index 0000000..72a018d Binary files /dev/null and b/bin/logseq-i18n-lint-aarch64-macos differ diff --git a/bin/logseq-i18n-lint-aarch64-windows.exe b/bin/logseq-i18n-lint-aarch64-windows.exe new file mode 100644 index 0000000..81594fc Binary files /dev/null and b/bin/logseq-i18n-lint-aarch64-windows.exe differ diff --git a/bin/logseq-i18n-lint-x86_64-linux b/bin/logseq-i18n-lint-x86_64-linux new file mode 100755 index 0000000..d411a7c Binary files /dev/null and b/bin/logseq-i18n-lint-x86_64-linux differ diff --git a/bin/logseq-i18n-lint-x86_64-macos b/bin/logseq-i18n-lint-x86_64-macos new file mode 100755 index 0000000..5958695 Binary files /dev/null and b/bin/logseq-i18n-lint-x86_64-macos differ diff --git a/bin/logseq-i18n-lint-x86_64-windows.exe b/bin/logseq-i18n-lint-x86_64-windows.exe new file mode 100644 index 0000000..9daaa59 Binary files /dev/null and b/bin/logseq-i18n-lint-x86_64-windows.exe differ diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..13bc682 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,70 @@ +import { CapacitorConfig } from '@capacitor/cli' +import { KeyboardResize } from '@capacitor/keyboard' +import * as fs from 'fs' + +const version = fs.readFileSync('static/package.json', 'utf8').match(/"version": "(.*?)"/)?.at(1) ?? '0.0.0' + +const config: CapacitorConfig = { + appId: 'com.logseq.app', + appName: 'Logseq', + webDir: 'static/mobile', + loggingBehavior: 'debug', + server: { + androidScheme: 'http', + }, + plugins: { + App: { + // Logseq routes back presses through MainActivity -> JS (window.LogseqNative.onNativePop). + // Disable @capacitor/app's built-in OnBackPressedCallback so it doesn't intercept the + // first edge-back gesture by calling webView.goBack() (which causes a flash and swallows + // the event before our handler runs). See android/app/.../MainActivity.java. + disableBackButtonHandler: true, + }, + + StatusBar: { + overlaysWebView: true, + style: 'Light', + backgroundColor: '#ffffffff', + }, + + SplashScreen: { + launchShowDuration: 500, + launchAutoHide: false, + androidScaleType: 'CENTER_CROP', + splashImmersive: false, + backgroundColor: '#002b36' + }, + + Keyboard: { + resize: KeyboardResize.None, + }, + + SafeArea: { + enabled: true, + customColorsForSystemBars: true, + statusBarColor: '#000000', + statusBarContent: 'light', + navigationBarColor: '#000000', + navigationBarContent: 'light', + offset: 0 + } + }, + android: { + appendUserAgent: `Logseq/${version} (Android)`, + }, + ios: { + scheme: 'Logseq', + appendUserAgent: `Logseq/${version} (iOS)`, + } +} + +if (process.env.LOGSEQ_APP_SERVER_URL) { + Object.assign(config, { + server: { + url: process.env.LOGSEQ_APP_SERVER_URL, + cleartext: true + } + }) +} + +export = config; diff --git a/cli-e2e/AGENTS.md b/cli-e2e/AGENTS.md new file mode 100644 index 0000000..e872a40 --- /dev/null +++ b/cli-e2e/AGENTS.md @@ -0,0 +1,38 @@ +# cli-e2e + +Shell-first end-to-end tests for logseq CLI. + +## Test cli-e2e itself +- Run internal cli-e2e harness unit tests: `bb unit-test` + +## cleanup first +- Execute cleanup: terminate stale db-worker-node processes, terminate stale db-sync listeners on port `18080`, and remove cli-e2e temp roots: `bb cleanup` +- Preview cleanup actions only (no kill/delete): `bb cleanup --dry-run` + +## Run non-sync suite +- List declared non-sync case ids: `bb list-cases` +- Run non-sync cases with build preflight unless `--skip-build` is provided: `bb test` + - `bb test --help` for options + - Increase case-level parallelism with `--jobs N` (default: `4`), for example: `bb test --skip-build --jobs 4` + - Parallelism is case-scoped only; each case still runs setup/main/cleanup sequentially in the existing ephemeral shell model + +## Run sync suite +- List declared sync case ids: `bb list-sync-cases` +- Run sync cases with build preflight by default: `bb test-sync` + - Add `--skip-build` only when you intentionally want to reuse existing artifacts + - `bb test-sync --help` for options + - Increase case-level parallelism with `--jobs N` (default: `4`), for example: `bb test-sync --jobs 4` + - Parallelism is case-scoped only; each sync case still runs its own setup/main/cleanup sequentially + - The local db-sync server is shared per suite and starts once before cases begin, then stops once after all cases complete + - Each sync case gets its own isolated temp root, data directories, home directory, auth copy, and generated config files + - Configure sync E2EE password: `--e2ee-password ` (default: `11111`) + - Run only sync MVP case: `bb test-sync --case sync-upload-download-mvp` + +### Sync suite prerequisites +- CLI auth file must exist at `~/logseq/auth.json` (generated by `logseq login`). +- Sync suite starts/stops one shared local db-sync server per suite run. +- Required build artifact for local db-sync server: + - `deps/db-sync/worker/dist/node-adapter.js` +- If artifact is missing, build it before running sync suite: + - `pnpm --dir deps/db-sync build:node-adapter` + diff --git a/cli-e2e/README.md b/cli-e2e/README.md new file mode 100755 index 0000000..47dc3e3 --- /dev/null +++ b/cli-e2e/README.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/cli-e2e/bb.edn b/cli-e2e/bb.edn new file mode 100644 index 0000000..8011c61 --- /dev/null +++ b/cli-e2e/bb.edn @@ -0,0 +1,47 @@ +{:paths ["src" "test"] + :deps {cheshire/cheshire {:mvn/version "5.12.0"}} + :tasks + {:requires ([babashka.cli :as cli] + [logseq.cli.e2e.main :as main] + [logseq.cli.e2e.test-runner :as test-runner]) + :init (def cli-opts + (cli/parse-opts + *command-line-args* + {:alias {:i :include + :h :help} + :spec {:jobs {:default 4}} + :coerce {:include [] + :help :boolean + :dry-run :boolean + :skip-build :boolean + :verbose :boolean + :timings :boolean + :jobs :long}})) + + unit-test + {:doc "Run internal cli-e2e harness unit tests" + :task (test-runner/run! cli-opts)} + + build + {:doc "Compile required CLI artifacts and verify expected outputs exist" + :task (main/build! cli-opts)} + + list-cases + {:doc "List declared non-sync cli-e2e case ids" + :task (main/list-cases! cli-opts)} + + list-sync-cases + {:doc "List declared sync cli-e2e case ids" + :task (main/list-sync-cases! cli-opts)} + + test + {:doc "Run non-sync cli-e2e cases with build preflight unless --skip-build is provided" + :task (main/test! cli-opts)} + + test-sync + {:doc "Run sync cli-e2e cases with build preflight unless --skip-build is provided; use --jobs for case-level parallelism" + :task (main/test-sync! cli-opts)} + + cleanup + {:doc "Terminate cli-e2e db-worker-node processes, terminate stale db-sync listeners, and remove cli-e2e temp roots" + :task (main/cleanup! cli-opts)}}} diff --git a/cli-e2e/scripts/agent_bridge_demo.sh b/cli-e2e/scripts/agent_bridge_demo.sh new file mode 100755 index 0000000..a7d9064 --- /dev/null +++ b/cli-e2e/scripts/agent_bridge_demo.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: agent_bridge_demo.sh [options] + +Creates a fresh Logseq graph, starts `logseq agent bridge`, writes a routable +task after the bridge listener is ready, and verifies that a fake Codex master +session received the task dispatch. + +Options: + --cli PATH Path to static/logseq-cli.js. Default: /static/logseq-cli.js + --root-dir DIR Logseq CLI root dir. Default: a new temp dir + --graph NAME Graph name. Default: agent-bridge-demo- + --repo-root DIR Repository root. Default: inferred from this script + --timeout-sec N Wait timeout for bridge events. Default: 45 + -h, --help Show this help +USAGE +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +cli_path="" +root_dir="" +graph="agent-bridge-demo-$(date +%s)" +timeout_sec=45 +agent_name="AgentBridgeDemo" +task_title="AgentBridge demo task: mark this block in review" +expected_session="thread-agent-bridge-demo" +bridge_pid="" +graph_created=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --cli) + cli_path="$2" + shift 2 + ;; + --root-dir) + root_dir="$2" + shift 2 + ;; + --graph) + graph="$2" + shift 2 + ;; + --repo-root) + repo_root="$2" + shift 2 + ;; + --timeout-sec) + timeout_sec="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cli_path="${cli_path:-$repo_root/static/logseq-cli.js}" +root_dir="${root_dir:-$(mktemp -d "${TMPDIR:-/tmp}/logseq-agent-bridge-demo.XXXXXX")}" +config_path="$root_dir/cli.edn" +work_dir="$root_dir/agent-bridge-demo" +fake_bin="$work_dir/fake-bin" +bridge_log="$work_dir/agent-bridge.log" +bridge_err="$work_dir/agent-bridge.err" +codex_log="$work_dir/codex-invocations.jsonl" + +cleanup() { + local status=$? + if [[ -n "${bridge_pid:-}" ]] && kill -0 "$bridge_pid" 2>/dev/null; then + kill "$bridge_pid" 2>/dev/null || true + wait "$bridge_pid" 2>/dev/null || true + fi + if [[ "$graph_created" -eq 1 ]]; then + node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output json server stop --graph "$graph" >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT INT TERM + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required command is missing: $1" >&2 + exit 2 + fi +} + +json_result() { + python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["result"])' +} + +json_result_first() { + python3 -c 'import json,sys; r=json.load(sys.stdin)["data"]["result"]; print(r[0] if isinstance(r, list) else r)' +} + +run_cli_json() { + node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output json "$@" +} + +wait_for_file_text() { + local path="$1" + local text="$2" + local deadline=$((SECONDS + timeout_sec)) + while (( SECONDS < deadline )); do + if [[ -f "$path" ]] && grep -Fq "$text" "$path"; then + return 0 + fi + if [[ -n "${bridge_pid:-}" ]] && ! kill -0 "$bridge_pid" 2>/dev/null; then + echo "agent bridge exited before '$text'" >&2 + [[ -f "$bridge_log" ]] && cat "$bridge_log" >&2 + [[ -f "$bridge_err" ]] && cat "$bridge_err" >&2 + exit 1 + fi + sleep 0.2 + done + echo "Timed out waiting for '$text' in $path" >&2 + [[ -f "$bridge_log" ]] && cat "$bridge_log" >&2 + [[ -f "$bridge_err" ]] && cat "$bridge_err" >&2 + exit 1 +} + +query_task_status() { + run_cli_json query --graph "$graph" --query "[:find ?status-ident . :where [$1 :logseq.property/status ?status] [?status :db/ident ?status-ident]]" | json_result +} + +query_agent_session() { + local payload + payload="$(run_cli_json query --graph "$graph" --query "[:find ?session . :where [$1 :logseq.property.agent/session-id ?session]]")" + python3 - "$root_dir" "$config_path" "$graph" "$cli_path" "$payload" <<'PY' +import json +import subprocess +import sys + +payload = json.loads(sys.argv[5]) +value = payload.get("data", {}).get("result") +if isinstance(value, int): + root_dir, config_path, graph, cli_path = sys.argv[1:5] + query = f"[:find ?title . :where [{value} :block/title ?title]]" + resolved = subprocess.check_output( + [ + "node", + cli_path, + "--root-dir", + root_dir, + "--config", + config_path, + "--output", + "json", + "query", + "--graph", + graph, + "--query", + query, + ], + text=True, + ) + value = json.loads(resolved)["data"]["result"] +print("" if value is None else value) +PY +} + +write_fake_codex() { + mkdir -p "$fake_bin" + cat > "$fake_bin/codex" <<'FAKE_CODEX' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -eq 1 && "$1" == "--version" ]]; then + echo "codex-cli 0.0.0-agent-bridge-demo" + exit 0 +fi + +if [[ "$#" -ge 6 && "$1" == "--sandbox" && "$2" == "danger-full-access" && "$3" == "exec" && "$4" == "--json" && "$5" == "--skip-git-repo-check" ]]; then + prompt="$6" + python3 - "$CODEX_FAKE_LOG" "$prompt" "$@" <<'PY' +import json +import pathlib +import sys + +log_path = pathlib.Path(sys.argv[1]) +prompt = sys.argv[2] +args = sys.argv[3:] +log_path.parent.mkdir(parents=True, exist_ok=True) +with log_path.open("a", encoding="utf8") as f: + f.write(json.dumps({"event": "start", "args": args, "prompt": prompt}, ensure_ascii=False) + "\n") +PY + printf '{"type":"thread.started","thread_id":"thread-agent-bridge-demo"}\n' + exit 0 +fi + +if [[ "$#" -ge 8 && "$1" == "--sandbox" && "$2" == "danger-full-access" && "$3" == "exec" && "$4" == "resume" && "$5" == "--json" && "$6" == "--skip-git-repo-check" ]]; then + session_id="$7" + prompt="$8" + python3 - "$CODEX_FAKE_LOG" "$session_id" "$prompt" "$@" <<'PY' +import json +import pathlib +import sys + +log_path = pathlib.Path(sys.argv[1]) +session_id = sys.argv[2] +prompt = sys.argv[3] +args = sys.argv[4:] +log_path.parent.mkdir(parents=True, exist_ok=True) +with log_path.open("a", encoding="utf8") as f: + f.write(json.dumps({"event": "resume", "session": session_id, "args": args, "prompt": prompt}, ensure_ascii=False) + "\n") +PY + block_uuid="$(python3 - "$prompt" <<'PY' +import re +import sys + +match = re.search(r"^Block UUID:\s*([0-9a-fA-F-]+)\s*$", sys.argv[1], re.MULTILINE) +if not match: + raise SystemExit("Block UUID not found in AgentBridge prompt") +print(match.group(1)) +PY +)" + node "$DEMO_CLI" --root-dir "$DEMO_ROOT_DIR" --config "$DEMO_CONFIG" --output json upsert task --graph "$DEMO_GRAPH" --uuid "$block_uuid" --status in-review >/dev/null + node "$DEMO_CLI" --root-dir "$DEMO_ROOT_DIR" --config "$DEMO_CONFIG" --output json upsert block --graph "$DEMO_GRAPH" --uuid "$block_uuid" --update-properties '{"Agent Session ID" "thread-agent-bridge-demo"}' >/dev/null + printf '{"type":"thread.started","thread_id":"%s"}\n' "$session_id" + exit 0 +fi + +echo "unexpected codex args: $*" >&2 +exit 2 +FAKE_CODEX + chmod +x "$fake_bin/codex" +} + +require_command node +require_command python3 + +mkdir -p "$work_dir" +printf '{:output-format :json :agent-name "%s"}\n' "$agent_name" > "$config_path" +write_fake_codex + +echo "creating graph: $graph" +echo "root dir: $root_dir" +run_cli_json graph create --graph "$graph" >/dev/null +graph_created=1 + +echo "starting agent bridge" +PATH="$fake_bin:$PATH" \ +CODEX_FAKE_LOG="$codex_log" \ +DEMO_CLI="$cli_path" \ +DEMO_ROOT_DIR="$root_dir" \ +DEMO_CONFIG="$config_path" \ +DEMO_GRAPH="$graph" \ + node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output human agent bridge --graph "$graph" >"$bridge_log" 2>"$bridge_err" & +bridge_pid=$! + +wait_for_file_text "$bridge_log" "listening graph changes" + +task_id="$(run_cli_json upsert task --graph "$graph" --target-page AgentBridgeDemo --content "$task_title" --status todo | json_result_first)" +run_cli_json upsert block --graph "$graph" --id "$task_id" --update-properties "{\"Assignee\" \"$agent_name\"}" >/dev/null + +deadline=$((SECONDS + timeout_sec)) +task_status="" +agent_session="" +while (( SECONDS < deadline )); do + task_status="$(query_task_status "$task_id")" + agent_session="$(query_agent_session "$task_id")" + if [[ "$task_status" == "logseq.property/status.in-review" && "$agent_session" == "$expected_session" ]]; then + break + fi + if [[ -n "${bridge_pid:-}" ]] && ! kill -0 "$bridge_pid" 2>/dev/null; then + echo "agent bridge exited before verification completed" >&2 + cat "$bridge_log" >&2 + cat "$bridge_err" >&2 + exit 1 + fi + sleep 0.5 +done + +if [[ "$task_status" != "logseq.property/status.in-review" ]]; then + echo "Expected task status in-review, got: ${task_status:-}" >&2 + cat "$bridge_log" >&2 + cat "$bridge_err" >&2 + exit 1 +fi + +if [[ "$agent_session" != "$expected_session" ]]; then + echo "Expected agent-session-id $expected_session, got: ${agent_session:-}" >&2 + cat "$bridge_log" >&2 + cat "$bridge_err" >&2 + exit 1 +fi + +python3 - "$codex_log" "$task_title" "$graph" <<'PY' +import json +import pathlib +import sys + +log_path = pathlib.Path(sys.argv[1]) +task_title = sys.argv[2] +graph = sys.argv[3] +lines = [json.loads(line) for line in log_path.read_text(encoding="utf8").splitlines() if line.strip()] +resume_lines = [line for line in lines if line.get("event") == "resume"] +if len(resume_lines) != 1: + raise SystemExit(f"expected one Codex resume invocation, got {len(resume_lines)}") +prompt = resume_lines[0]["prompt"] +if task_title not in prompt: + raise SystemExit("task title missing from Codex prompt") +if f"Graph: {graph}" not in prompt: + raise SystemExit("graph missing from Codex prompt") +if "Block UUID:" not in prompt: + raise SystemExit("block uuid missing from Codex prompt") +if "Request kind: task" not in prompt: + raise SystemExit("request kind missing from Codex prompt") +PY + +echo "task status: in-review" +echo "agent-session-id: $agent_session" +echo "agent bridge demo completed" diff --git a/cli-e2e/scripts/agent_bridge_e2e.py b/cli-e2e/scripts/agent_bridge_e2e.py new file mode 100644 index 0000000..5413b06 --- /dev/null +++ b/cli-e2e/scripts/agent_bridge_e2e.py @@ -0,0 +1,685 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import pathlib +import subprocess +import time + + +TASK_TITLE = "Test AgentBridge task routing and completion status" +EXPECTED_SESSION = "thread-e2e-agent-bridge" +PARALLEL_TASK_TITLES = [ + "测试 agent bridge 并行执行任务 1", + "测试 agent bridge 并行执行任务 2", +] + + +def write_fake_codex(fake_bin): + fake_codex = fake_bin / "codex" + fake_codex.parent.mkdir(parents=True, exist_ok=True) + fake_codex.write_text( + """#!/usr/bin/env python3 +import json +import os +import pathlib +import re +import sys +import time + +args = sys.argv[1:] +if args == ["--version"]: + print("codex-cli 0.0.0-e2e") + sys.exit(0) + +if args[:5] == ["--sandbox", "danger-full-access", "exec", "--json", "--skip-git-repo-check"]: + prompt = args[5] if len(args) > 5 else "" + block_uuid_match = re.search(r"^Block UUID: (.+)$", prompt, re.MULTILINE) + block_uuid = block_uuid_match.group(1) if block_uuid_match else None + log_path = pathlib.Path(os.environ["CODEX_FAKE_LOG"]) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf8") as f: + f.write(json.dumps( + {"event": "start", "time": time.time(), "args": args, "prompt": prompt, "block_uuid": block_uuid}, + ensure_ascii=False) + "\\n") + delay = float(os.environ.get("CODEX_FAKE_DELAY_SECONDS", "0")) + if delay > 0: + time.sleep(delay) + if os.environ.get("CODEX_FAKE_SESSION_BY_UUID") == "1" and block_uuid: + session_id = "thread-e2e-agent-bridge-" + re.sub(r"[^A-Za-z0-9]", "", block_uuid)[-12:] + else: + session_id = "thread-e2e-agent-bridge" + with log_path.open("a", encoding="utf8") as f: + f.write(json.dumps( + {"event": "session", "time": time.time(), "session": session_id, "block_uuid": block_uuid}, + ensure_ascii=False) + "\\n") + print(json.dumps({"type": "thread.started", "thread_id": session_id}), flush=True) + sys.exit(0) + +if args[:6] == ["--sandbox", "danger-full-access", "exec", "resume", "--json", "--skip-git-repo-check"]: + session_id = args[6] if len(args) > 6 else "" + prompt = args[7] if len(args) > 7 else "" + comment_uuid_match = re.search(r"^Comment UUID: (.+)$", prompt, re.MULTILINE) + comment_uuid = comment_uuid_match.group(1) if comment_uuid_match else None + block_uuid_match = re.search(r"^Block UUID: (.+)$", prompt, re.MULTILINE) + block_uuid = block_uuid_match.group(1) if block_uuid_match else None + log_path = pathlib.Path(os.environ["CODEX_FAKE_LOG"]) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf8") as f: + f.write(json.dumps( + {"event": "resume", "time": time.time(), "args": args, "prompt": prompt, "comment_uuid": comment_uuid, "block_uuid": block_uuid, "session": session_id}, + ensure_ascii=False) + "\\n") + delay = float(os.environ.get("CODEX_FAKE_DELAY_SECONDS", "0")) + if delay > 0: + time.sleep(delay) + with log_path.open("a", encoding="utf8") as f: + f.write(json.dumps( + {"event": "resume-complete", "time": time.time(), "comment_uuid": comment_uuid, "block_uuid": block_uuid, "session": session_id}, + ensure_ascii=False) + "\\n") + print(json.dumps({"type": "thread.started", "thread_id": session_id}), flush=True) + sys.exit(0) + +print("unexpected codex args: " + repr(args), file=sys.stderr) +sys.exit(2) +""", + encoding="utf8", + ) + fake_codex.chmod(0o755) + + +def run_json(cli, repo_root, root_dir, config, graph, query): + result = subprocess.run( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "json", + "query", + "--graph", + graph, + "--query", + query, + ], + cwd=repo_root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout).get("data", {}).get("result") + + +def run_cli(cli, repo_root, root_dir, config, graph, extra_args): + result = subprocess.run( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "json", + ] + + extra_args, + cwd=repo_root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise SystemExit( + "CLI command failed: {}\nstdout:\n{}\nstderr:\n{}".format( + " ".join(extra_args), result.stdout, result.stderr + ) + ) + return json.loads(result.stdout) if result.stdout.strip() else None + + +def run_cli_with_env(cli, repo_root, root_dir, config, extra_args, env): + result = subprocess.run( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "json", + ] + + extra_args, + cwd=repo_root, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result + + +def deref_session_value(cli, repo_root, root_dir, config, graph, value): + if not isinstance(value, int): + return value + return run_json( + cli, + repo_root, + root_dir, + config, + graph, + f"[:find ?title . :where [{value} :block/title ?title]]", + ) + + +def read_text(path): + return path.read_text(encoding="utf8", errors="replace") + + +def wait_for_log(path, text, process): + deadline = time.time() + 30 + while time.time() < deadline: + if path.exists() and text in read_text(path): + return + if process.poll() is not None: + raise SystemExit( + "agent bridge exited before {}\nstdout:\n{}".format( + text, read_text(path) + ) + ) + time.sleep(0.2) + raise SystemExit("agent bridge did not log {!r}\nstdout:\n{}".format(text, read_text(path))) + + +def find_task_id(cli, repo_root, root_dir, config, graph, title): + task_id = run_json( + cli, + repo_root, + root_dir, + config, + graph, + '[:find ?e . :where [?e :block/title "{}"]]'.format(title), + ) + if task_id is None: + raise SystemExit("task block was not found: {}".format(title)) + return task_id + + +def find_task_uuid(cli, repo_root, root_dir, config, graph, title): + task_uuid = run_json( + cli, + repo_root, + root_dir, + config, + graph, + '[:find ?uuid . :where [?e :block/title "{}"] [?e :block/uuid ?uuid]]'.format(title), + ) + if task_uuid is None: + raise SystemExit("task block uuid was not found: {}".format(title)) + return task_uuid + + +def create_task(cli, repo_root, root_dir, config, graph, title): + run_cli( + cli, + repo_root, + root_dir, + config, + graph, + [ + "upsert", + "task", + "--graph", + graph, + "--target-page", + "AgentBridgeE2E", + "--content", + title, + "--status", + "todo", + ], + ) + + +def assign_task(cli, repo_root, root_dir, config, graph, title=TASK_TITLE): + task_id = find_task_id(cli, repo_root, root_dir, config, graph, title) + run_cli( + cli, + repo_root, + root_dir, + config, + graph, + [ + "upsert", + "block", + "--graph", + graph, + "--id", + str(task_id), + "--update-properties", + '{{"Assignee" "{}"}}'.format(os.uname().nodename), + ], + ) + + +def read_codex_events(codex_log): + if not codex_log.exists(): + return [] + return [ + json.loads(line) + for line in codex_log.read_text(encoding="utf8").splitlines() + if line.strip() + ] + + +def session_query_for_title(title): + return ( + '[:find ?session . :where [?e :block/title "{}"] ' + "[?e :logseq.property.agent/session-id ?session]]" + ).format(title) + + +def wait_for_task_sessions(cli, repo_root, root_dir, config, graph, titles, bridge, bridge_log, bridge_err): + deadline = time.time() + 45 + sessions = {} + while time.time() < deadline: + sessions = { + title: deref_session_value( + cli, + repo_root, + root_dir, + config, + graph, + run_json(cli, repo_root, root_dir, config, graph, session_query_for_title(title)), + ) + for title in titles + } + if all(sessions.values()): + return sessions + if bridge.poll() is not None: + raise SystemExit( + "agent bridge exited early with {}\nstdout:\n{}\nstderr:\n{}".format( + bridge.returncode, read_text(bridge_log), read_text(bridge_err) + ) + ) + time.sleep(0.5) + raise SystemExit( + "agent-session-id was not written for every task; last sessions={!r}\nstdout:\n{}\nstderr:\n{}".format( + sessions, read_text(bridge_log), read_text(bridge_err) + ) + ) + + +def wait_for_codex_event(codex_log, event_name, bridge, bridge_log, bridge_err): + deadline = time.time() + 30 + events = [] + while time.time() < deadline: + events = read_codex_events(codex_log) + matches = [event for event in events if event.get("event") == event_name] + if matches: + return matches + if bridge.poll() is not None: + raise SystemExit( + "agent bridge exited early with {}\nstdout:\n{}\nstderr:\n{}".format( + bridge.returncode, read_text(bridge_log), read_text(bridge_err) + ) + ) + time.sleep(0.5) + raise SystemExit("codex event {!r} was not observed; events={!r}".format(event_name, events)) + + +def wait_for_codex_events(codex_log, event_name, count, bridge, bridge_log, bridge_err): + deadline = time.time() + 45 + events = [] + while time.time() < deadline: + events = read_codex_events(codex_log) + matches = [event for event in events if event.get("event") == event_name] + if len(matches) >= count: + return matches + if bridge.poll() is not None: + raise SystemExit( + "agent bridge exited early with {}\nstdout:\n{}\nstderr:\n{}".format( + bridge.returncode, read_text(bridge_log), read_text(bridge_err) + ) + ) + time.sleep(0.5) + raise SystemExit("codex event {!r} count {} was not observed; events={!r}".format(event_name, count, events)) + + +def write_comment_blocks_file(path, task_uuid, hostname): + path.write_text( + """[{{:block/title "Comments" + :block/tags [:logseq.class/Comments] + :logseq.property.comments/blocks [[:block/uuid #uuid "{task_uuid}"]] + :block/children [{{:block/title "[[{hostname}]] please continue from the comment" + :block/tags [:logseq.class/Comment]}}]}}]""".format( + task_uuid=task_uuid, + hostname=hostname, + ), + encoding="utf8", + ) + + +def run_comment_mention_check(cli, repo_root, root_dir, config, graph, tmp_dir): + create_task(cli, repo_root, root_dir, config, graph, TASK_TITLE) + + fake_bin = tmp_dir / "fake-bin" + bridge_log = tmp_dir / "agent-bridge.log" + bridge_err = tmp_dir / "agent-bridge.err" + codex_log = tmp_dir / "codex-invocations.jsonl" + + env = os.environ.copy() + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["CODEX_FAKE_LOG"] = str(codex_log) + + with bridge_log.open("wb") as out, bridge_err.open("wb") as err: + bridge = subprocess.Popen( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "human", + "agent", + "bridge", + "--graph", + graph, + ], + cwd=repo_root, + env=env, + stdout=out, + stderr=err, + ) + + try: + wait_for_log(bridge_log, "listening graph changes", bridge) + assign_task(cli, repo_root, root_dir, config, graph) + start_events = wait_for_codex_event(codex_log, "start", bridge, bridge_log, bridge_err) + session_events = wait_for_codex_event(codex_log, "session", bridge, bridge_log, bridge_err) + session = session_events[0].get("session") + resume_events = wait_for_codex_event(codex_log, "resume", bridge, bridge_log, bridge_err) + task_prompt = resume_events[0].get("prompt", "") + assert resume_events[0].get("session") == session, resume_events[0] + assert "Request kind: task" in task_prompt, task_prompt + assert TASK_TITLE in task_prompt, task_prompt + + task_id = find_task_id(cli, repo_root, root_dir, config, graph, TASK_TITLE) + task_uuid = find_task_uuid(cli, repo_root, root_dir, config, graph, TASK_TITLE) + blocks_file = tmp_dir / "comment-blocks.edn" + write_comment_blocks_file(blocks_file, task_uuid, os.uname().nodename) + run_cli( + cli, + repo_root, + root_dir, + config, + graph, + [ + "upsert", + "block", + "--graph", + graph, + "--target-id", + str(task_id), + "--pos", + "last-child", + "--blocks-file", + str(blocks_file), + ], + ) + + resume_events = wait_for_codex_events(codex_log, "resume", 2, bridge, bridge_log, bridge_err) + comment_event = resume_events[-1] + if comment_event.get("session") != session: + raise SystemExit("comment resumed the wrong master session: {!r}".format(comment_event)) + prompt = comment_event.get("prompt", "") + assert "You are handling a Logseq AgentBridge master dispatch request." in prompt, prompt + assert "Request kind: comment" in prompt, prompt + assert "Comment target context:" in prompt, prompt + assert "Requesting comment:" in prompt, prompt + print("agent bridge dispatched comment request to master " + session) + finally: + if bridge.poll() is None: + bridge.terminate() + try: + bridge.wait(timeout=5) + except subprocess.TimeoutExpired: + bridge.kill() + bridge.wait(timeout=5) + + +def run_parallel_assignment_check(cli, repo_root, root_dir, config, graph, tmp_dir): + for title in PARALLEL_TASK_TITLES: + create_task(cli, repo_root, root_dir, config, graph, title) + + fake_bin = tmp_dir / "fake-bin" + bridge_log = tmp_dir / "agent-bridge.log" + bridge_err = tmp_dir / "agent-bridge.err" + codex_log = tmp_dir / "codex-invocations.jsonl" + + env = os.environ.copy() + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["CODEX_FAKE_LOG"] = str(codex_log) + env["CODEX_FAKE_DELAY_SECONDS"] = "5" + env["CODEX_FAKE_SESSION_BY_UUID"] = "1" + + with bridge_log.open("wb") as out, bridge_err.open("wb") as err: + bridge = subprocess.Popen( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "human", + "agent", + "bridge", + "--graph", + graph, + ], + cwd=repo_root, + env=env, + stdout=out, + stderr=err, + ) + + try: + wait_for_log(bridge_log, "listening graph changes", bridge) + for title in PARALLEL_TASK_TITLES: + assign_task(cli, repo_root, root_dir, config, graph, title) + + events = read_codex_events(codex_log) + start_events = [event for event in events if event.get("event") == "start"] + resume_events = wait_for_codex_events(codex_log, "resume", 2, bridge, bridge_log, bridge_err) + complete_events = wait_for_codex_events(codex_log, "resume-complete", 2, bridge, bridge_log, bridge_err) + if len(start_events) != 1 or len(resume_events) != 2: + raise SystemExit("expected one master start and two master resumes, got: {!r}".format(read_codex_events(codex_log))) + + first_complete_time = min(event["time"] for event in complete_events) + latest_resume_time = max(event["time"] for event in resume_events) + if latest_resume_time >= first_complete_time: + raise SystemExit( + "master dispatch resumes did not overlap; resumes={!r}, completes={!r}".format( + [event["time"] for event in resume_events], + [event["time"] for event in complete_events], + ) + ) + + prompts = "\n".join(event["prompt"] for event in resume_events) + for title in PARALLEL_TASK_TITLES: + assert title in prompts, prompts + print("agent bridge dispatched tasks concurrently to master") + finally: + if bridge.poll() is None: + bridge.terminate() + try: + bridge.wait(timeout=5) + except subprocess.TimeoutExpired: + bridge.kill() + bridge.wait(timeout=5) + + +def run_duplicate_bridge_check(cli, repo_root, root_dir, config, graph, tmp_dir): + fake_bin = tmp_dir / "fake-bin" + bridge_log = tmp_dir / "agent-bridge.log" + bridge_err = tmp_dir / "agent-bridge.err" + codex_log = tmp_dir / "codex-invocations.jsonl" + + env = os.environ.copy() + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["CODEX_FAKE_LOG"] = str(codex_log) + + with bridge_log.open("wb") as out, bridge_err.open("wb") as err: + bridge = subprocess.Popen( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "human", + "agent", + "bridge", + "--graph", + graph, + ], + cwd=repo_root, + env=env, + stdout=out, + stderr=err, + ) + + try: + wait_for_log(bridge_log, "listening graph changes", bridge) + duplicate = subprocess.run( + cli + + [ + "--root-dir", + root_dir, + "--config", + config, + "--output", + "json", + "agent", + "bridge", + "--graph", + graph, + ], + cwd=repo_root, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=15, + ) + output = duplicate.stdout + duplicate.stderr + if duplicate.returncode == 0: + raise SystemExit("duplicate bridge unexpectedly succeeded:\n{}".format(output)) + if "agent-bridge-already-running" not in output and "already running" not in output: + raise SystemExit("duplicate bridge did not report the expected lock error:\n{}".format(output)) + print("agent bridge rejected duplicate process") + finally: + if bridge.poll() is None: + bridge.terminate() + try: + bridge.wait(timeout=5) + except subprocess.TimeoutExpired: + bridge.kill() + bridge.wait(timeout=5) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cli", required=True) + parser.add_argument("--root-dir", required=True) + parser.add_argument("--config", required=True) + parser.add_argument("--graph", required=True) + parser.add_argument("--tmp-dir", required=True) + parser.add_argument("--repo-root", required=True) + parser.add_argument("--prepare-fake-codex-only", action="store_true") + parser.add_argument("--assign-after-start", action="store_true") + parser.add_argument("--parallel-assignment-check", action="store_true") + parser.add_argument("--comment-mention-check", action="store_true") + parser.add_argument("--duplicate-bridge-check", action="store_true") + args = parser.parse_args() + + repo_root = pathlib.Path(args.repo_root) + tmp_dir = pathlib.Path(args.tmp_dir) + fake_bin = tmp_dir / "fake-bin" + bridge_log = tmp_dir / "agent-bridge.log" + bridge_err = tmp_dir / "agent-bridge.err" + codex_log = tmp_dir / "codex-invocations.jsonl" + cli = ["node", args.cli] + + write_fake_codex(fake_bin) + if args.prepare_fake_codex_only: + return + + if args.parallel_assignment_check: + run_parallel_assignment_check(cli, repo_root, args.root_dir, args.config, args.graph, tmp_dir) + return + + if args.comment_mention_check: + run_comment_mention_check(cli, repo_root, args.root_dir, args.config, args.graph, tmp_dir) + return + + if args.duplicate_bridge_check: + run_duplicate_bridge_check(cli, repo_root, args.root_dir, args.config, args.graph, tmp_dir) + return + + env = os.environ.copy() + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["CODEX_FAKE_LOG"] = str(codex_log) + + with bridge_log.open("wb") as out, bridge_err.open("wb") as err: + bridge = subprocess.Popen( + cli + + [ + "--root-dir", + args.root_dir, + "--config", + args.config, + "--output", + "human", + "agent", + "bridge", + "--graph", + args.graph, + ], + cwd=repo_root, + env=env, + stdout=out, + stderr=err, + ) + + try: + if args.assign_after_start: + wait_for_log(bridge_log, "listening graph changes", bridge) + assign_task(cli, repo_root, args.root_dir, args.config, args.graph) + + start_events = [event for event in read_codex_events(codex_log) if event.get("event") == "start"] + resume_events = wait_for_codex_event(codex_log, "resume", bridge, bridge_log, bridge_err) + assert len(start_events) == 1, start_events + prompt = resume_events[0]["prompt"] + assert TASK_TITLE in prompt, prompt + assert "Graph: " + args.graph in prompt, prompt + assert "Block UUID:" in prompt, prompt + assert "Request kind: task" in prompt, prompt + print("agent bridge dispatched task to master " + resume_events[0]["session"]) + finally: + if bridge.poll() is None: + bridge.terminate() + try: + bridge.wait(timeout=5) + except subprocess.TimeoutExpired: + bridge.kill() + bridge.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/scripts/compare_graph_queries.py b/cli-e2e/scripts/compare_graph_queries.py new file mode 100644 index 0000000..b5d95c1 --- /dev/null +++ b/cli-e2e/scripts/compare_graph_queries.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Compare normalized query payloads between two cli graph contexts.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict + +IGNORED_KEYS = { + "db/id", + "db/created-at", + "db/updated-at", + "block/uuid", + "block/updated-at", + "block/created-at", +} + + +def fail(message: str, **context: object) -> None: + payload = {"status": "error", "message": message} + if context: + payload["context"] = context + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(1) + + +def normalize(value: Any) -> Any: + if isinstance(value, dict): + normalized = {} + for key in sorted(value.keys(), key=str): + key_str = str(key) + if key_str in IGNORED_KEYS: + continue + normalized[key_str] = normalize(value[key]) + return normalized + if isinstance(value, list): + normalized_list = [normalize(item) for item in value] + try: + return sorted(normalized_list, key=lambda item: json.dumps(item, sort_keys=True)) + except TypeError: + return normalized_list + return value + + +def run_query(cli_path: Path, config_path: Path, root_dir: Path, graph: str, query: str) -> Dict[str, Any]: + command = [ + "node", + str(cli_path), + "--root-dir", + str(root_dir), + "--config", + str(config_path), + "--output", + "json", + "query", + "--graph", + graph, + "--query", + query, + ] + + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + fail( + "query command failed", + command=command, + exit=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + fail( + "query command did not return valid JSON", + command=command, + stdout=result.stdout, + stderr=result.stderr, + detail=str(error), + ) + + if payload.get("status") != "ok": + fail("query command returned non-ok status", command=command, payload=payload) + + data = payload.get("data") or {} + return { + "payload": payload, + "result": data.get("result"), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Compare normalized query payloads between two cli contexts") + parser.add_argument("--cli", required=True, help="Path to static/logseq-cli.js") + parser.add_argument("--graph", required=True) + parser.add_argument("--query", required=True, action="append") + parser.add_argument("--config-a", required=True) + parser.add_argument("--root-dir-a", required=True) + parser.add_argument("--config-b", required=True) + parser.add_argument("--root-dir-b", required=True) + parser.add_argument("--require-result", action="store_true") + return parser.parse_args() + + + +def main() -> None: + args = parse_args() + + cli_path = Path(args.cli).expanduser().resolve() + if not cli_path.exists(): + fail("cli entry file does not exist", cli=str(cli_path)) + + queries = args.query + left_config = Path(args.config_a).expanduser().resolve() + left_root_dir = Path(args.root_dir_a).expanduser().resolve() + right_config = Path(args.config_b).expanduser().resolve() + right_root_dir = Path(args.root_dir_b).expanduser().resolve() + + normalized_results = {} + + for query in queries: + left = run_query( + cli_path, + left_config, + left_root_dir, + args.graph, + query, + ) + right = run_query( + cli_path, + right_config, + right_root_dir, + args.graph, + query, + ) + + left_result = left["result"] + right_result = right["result"] + + if args.require_result and (left_result is None or right_result is None): + fail( + "query result is empty", + query=query, + left_result=left_result, + right_result=right_result, + ) + + left_normalized = normalize(left_result) + right_normalized = normalize(right_result) + + if left_normalized != right_normalized: + fail( + "normalized query results differ", + query=query, + left_result=left_normalized, + right_result=right_normalized, + left_payload=left["payload"], + right_payload=right["payload"], + ) + + normalized_results[query] = left_normalized + + payload_key = "result" if len(normalized_results) == 1 else "results" + payload_value = next(iter(normalized_results.values())) if len(normalized_results) == 1 else normalized_results + print( + json.dumps( + { + "status": "ok", + payload_key: payload_value, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/scripts/db_sync_server.py b/cli-e2e/scripts/db_sync_server.py new file mode 100644 index 0000000..83e0c38 --- /dev/null +++ b/cli-e2e/scripts/db_sync_server.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Manage local db-sync server process for cli-e2e sync suite.""" + +from __future__ import annotations + +import argparse +import base64 +import ctypes +import json +import os +import signal +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, Optional + +DEFAULT_COGNITO_ISSUER = "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_kAqZcxIeM" +DEFAULT_COGNITO_CLIENT_ID = "1qi1uijg8b6ra70nejvbptis0q" + + +def fail(message: str, **context: object) -> None: + payload = {"status": "error", "message": message} + if context: + payload["context"] = context + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(1) + + +def load_pid(pid_file: Path) -> Optional[int]: + if not pid_file.exists(): + return None + raw = pid_file.read_text(encoding="utf-8").strip() + if not raw: + return None + try: + return int(raw) + except ValueError: + return None + + +def process_running(pid: int) -> bool: + if pid <= 0: + return False + if os.name == "nt": + kernel32 = ctypes.windll.kernel32 + access = 0x00100000 | 0x1000 # SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION + handle = kernel32.OpenProcess(access, False, pid) + if handle: + kernel32.CloseHandle(handle) + return True + return ctypes.get_last_error() == 5 + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + else: + return True + + +def terminate_process(pid: int, force: bool) -> bool: + if pid <= 0: + return True + if os.name == "nt": + cmd = ["taskkill", "/PID", str(pid), "/T"] + if force: + cmd.append("/F") + result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + return result.returncode == 0 or not process_running(pid) + try: + os.kill(pid, signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + return True + except PermissionError: + return False + return True + + +def parse_json_file(path: Path) -> Dict[str, Any]: + if not path.exists(): + fail("sync auth file is missing", auth_path=str(path), hint="Run `logseq login` first.") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + fail("sync auth file is invalid JSON", auth_path=str(path), detail=str(error)) + if not isinstance(payload, dict): + fail("sync auth file must be a JSON object", auth_path=str(path)) + return payload + + +def decode_jwt_claims(token: str) -> Optional[Dict[str, Any]]: + if not isinstance(token, str): + return None + parts = token.split(".") + if len(parts) != 3: + return None + payload = parts[1] + padded = payload + "=" * ((4 - (len(payload) % 4)) % 4) + try: + decoded = base64.urlsafe_b64decode(padded.encode("utf-8")).decode("utf-8") + claims = json.loads(decoded) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + return None + return claims if isinstance(claims, dict) else None + + +def auth_cognito_from_auth_file(auth_path: Path) -> Dict[str, str]: + payload = parse_json_file(auth_path) + token = payload.get("id-token") or payload.get("access-token") + claims = decode_jwt_claims(token) if isinstance(token, str) else None + + issuer = claims.get("iss") if isinstance(claims, dict) else None + client_id = None + if isinstance(claims, dict): + client_id = claims.get("aud") or claims.get("client_id") + + return { + "issuer": issuer if isinstance(issuer, str) and issuer else "", + "client_id": client_id if isinstance(client_id, str) and client_id else "", + } + + +def wait_health(base_url: str, timeout_s: float, interval_s: float) -> bool: + deadline = time.time() + timeout_s + url = base_url.rstrip("/") + "/health" + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + while time.time() < deadline: + try: + with opener.open(url, timeout=2) as response: + if response.status == 200: + return True + except (urllib.error.URLError, TimeoutError, socket.timeout): + pass + time.sleep(interval_s) + return False + + +def add_node_path(env: Dict[str, str], path: Path) -> None: + existing = env.get("NODE_PATH") + next_path = str(path) + env["NODE_PATH"] = next_path if not existing else next_path + os.pathsep + existing + + +def start_server(args: argparse.Namespace) -> None: + repo_root = Path(args.repo_root).expanduser().resolve() + entry = repo_root / "deps" / "db-sync" / "worker" / "dist" / "node-adapter.js" + if not entry.exists(): + fail("db-sync node adapter build artifact is missing", entry=str(entry), hint="Run: yarn --cwd deps/db-sync build:node-adapter") + + pid_file = Path(args.pid_file).expanduser().resolve() + log_file = Path(args.log_file).expanduser().resolve() + data_dir = Path(args.data_dir).expanduser().resolve() + + pid_file.parent.mkdir(parents=True, exist_ok=True) + log_file.parent.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + + existing_pid = load_pid(pid_file) + if existing_pid and process_running(existing_pid): + fail("db-sync server already running", pid=existing_pid, pid_file=str(pid_file)) + + auth_path = Path(args.auth_path).expanduser().resolve() if args.auth_path else None + auth_derived = auth_cognito_from_auth_file(auth_path) if auth_path else {"issuer": "", "client_id": ""} + + issuer = args.cognito_issuer or auth_derived.get("issuer") or DEFAULT_COGNITO_ISSUER + client_id = args.cognito_client_id or auth_derived.get("client_id") or DEFAULT_COGNITO_CLIENT_ID + jwks_url = args.cognito_jwks_url or f"{issuer}/.well-known/jwks.json" + + env = os.environ.copy() + add_node_path(env, repo_root / "deps" / "db-sync" / "node_modules") + env.update( + { + "DB_SYNC_PORT": str(args.port), + "DB_SYNC_DATA_DIR": str(data_dir), + "COGNITO_ISSUER": issuer, + "COGNITO_CLIENT_ID": client_id, + "COGNITO_JWKS_URL": jwks_url, + # CLI e2e sync suite should remain runnable without outbound internet. + "DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS": "true", + } + ) + + with log_file.open("a", encoding="utf-8") as stream: + stream.write(f"\n=== cli-e2e db-sync start {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n") + stream.flush() + process = subprocess.Popen( + ["node", str(entry)], + stdout=stream, + stderr=stream, + cwd=str(repo_root), + env=env, + start_new_session=True, + ) + + base_url = f"http://{args.host}:{args.port}" + if not wait_health(base_url, args.startup_timeout_s, args.poll_interval_s): + terminate_process(process.pid, force=False) + fail( + "db-sync server failed health check before timeout", + base_url=base_url, + pid=process.pid, + log_file=str(log_file), + ) + + pid_file.write_text(f"{process.pid}\n", encoding="utf-8") + print( + json.dumps( + { + "status": "ok", + "pid": process.pid, + "pid_file": str(pid_file), + "log_file": str(log_file), + "base_url": base_url, + "data_dir": str(data_dir), + "cognito_issuer": issuer, + "cognito_client_id": client_id, + "auth_path": str(auth_path) if auth_path else None, + } + ) + ) + + +def stop_server(args: argparse.Namespace) -> None: + pid_file = Path(args.pid_file).expanduser().resolve() + pid = load_pid(pid_file) + + if pid is None: + print(json.dumps({"status": "ok", "stopped": False, "reason": "pid-file-missing-or-empty", "pid_file": str(pid_file)})) + return + + if not process_running(pid): + try: + pid_file.unlink(missing_ok=True) + except OSError: + pass + print(json.dumps({"status": "ok", "stopped": False, "reason": "process-not-running", "pid": pid, "pid_file": str(pid_file)})) + return + + if not terminate_process(pid, force=False): + fail("db-sync server stop failed", pid=pid, signal="SIGTERM", pid_file=str(pid_file)) + deadline = time.time() + args.shutdown_timeout_s + while time.time() < deadline: + if not process_running(pid): + pid_file.unlink(missing_ok=True) + print(json.dumps({"status": "ok", "stopped": True, "signal": "SIGTERM", "pid": pid, "pid_file": str(pid_file)})) + return + time.sleep(args.poll_interval_s) + + if not terminate_process(pid, force=True): + fail("db-sync server force stop failed", pid=pid, signal="SIGKILL", pid_file=str(pid_file)) + pid_file.unlink(missing_ok=True) + print(json.dumps({"status": "ok", "stopped": True, "signal": "SIGKILL", "pid": pid, "pid_file": str(pid_file)})) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage db-sync local server for cli-e2e sync tests") + subparsers = parser.add_subparsers(dest="command", required=True) + + start = subparsers.add_parser("start", help="Start server and wait for /health") + start.add_argument("--repo-root", required=True) + start.add_argument("--pid-file", required=True) + start.add_argument("--log-file", required=True) + start.add_argument("--data-dir", required=True) + start.add_argument("--host", default="127.0.0.1") + start.add_argument("--port", type=int, default=8080) + start.add_argument("--startup-timeout-s", type=float, default=25.0) + start.add_argument("--poll-interval-s", type=float, default=0.5) + start.add_argument("--auth-path", default="~/logseq/auth.json") + start.add_argument("--cognito-issuer") + start.add_argument("--cognito-client-id") + start.add_argument("--cognito-jwks-url") + + stop = subparsers.add_parser("stop", help="Stop server if running") + stop.add_argument("--pid-file", required=True) + stop.add_argument("--shutdown-timeout-s", type=float, default=10.0) + stop.add_argument("--poll-interval-s", type=float, default=0.25) + + return parser + + +def main() -> None: + args = build_parser().parse_args() + if args.command == "start": + start_server(args) + elif args.command == "stop": + stop_server(args) + else: + fail("unknown command", command=args.command) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/scripts/prepare_sync_config.py b/cli-e2e/scripts/prepare_sync_config.py new file mode 100644 index 0000000..afb9b2d --- /dev/null +++ b/cli-e2e/scripts/prepare_sync_config.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Prepare per-case CLI config for sync e2e tests.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +DEFAULT_OAUTH_TOKEN_ENDPOINT = "https://logseq-prod.auth.us-east-1.amazoncognito.com/oauth2/token" + + +def fail(message: str, **context: object) -> None: + payload = {"status": "error", "message": message} + if context: + payload["context"] = context + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(1) + + +def read_auth(auth_path: Path) -> dict: + if not auth_path.exists(): + fail("sync auth file is missing", auth_path=str(auth_path), hint="Run `logseq login` first.") + try: + payload = json.loads(auth_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + fail("sync auth file is invalid JSON", auth_path=str(auth_path), detail=str(error)) + + has_token = any(payload.get(key) for key in ("refresh-token", "id-token", "access-token")) + if not has_token: + fail( + "sync auth file does not contain usable tokens", + auth_path=str(auth_path), + required_any_of=["refresh-token", "id-token", "access-token"], + ) + return payload + + +def write_config(output_path: Path, http_base: str, ws_url: str, oauth_token_endpoint: str) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + payload = "\n".join( + [ + "{", + " :output-format :json", + f' :http-base "{http_base}"', + f' :ws-url "{ws_url}"', + f' :oauth-token-endpoint "{oauth_token_endpoint}"', + "}", + "", + ] + ) + output_path.write_text(payload, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prepare cli.edn for sync e2e") + parser.add_argument("--output", required=True) + parser.add_argument("--auth-path", default="~/logseq/auth.json") + parser.add_argument("--http-base", required=True) + parser.add_argument("--ws-url", required=True) + parser.add_argument("--oauth-token-endpoint", default=DEFAULT_OAUTH_TOKEN_ENDPOINT) + args = parser.parse_args() + + auth_path = Path(args.auth_path).expanduser().resolve() + _auth = read_auth(auth_path) + + output_path = Path(args.output).expanduser().resolve() + write_config(output_path, args.http_base, args.ws_url, args.oauth_token_endpoint) + + print( + json.dumps( + { + "status": "ok", + "auth_path": str(auth_path), + "config_path": str(output_path), + "http_base": args.http_base, + "ws_url": args.ws_url, + "oauth_token_endpoint": args.oauth_token_endpoint, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/scripts/random_bidirectional_block_ops.py b/cli-e2e/scripts/random_bidirectional_block_ops.py new file mode 100644 index 0000000..d68c1ef --- /dev/null +++ b/cli-e2e/scripts/random_bidirectional_block_ops.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""Run randomized bidirectional block operations on two synced graph peers.""" + +from __future__ import annotations + +import argparse +import json +import random +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List + + +class CliCommandError(RuntimeError): + """Raised when a CLI command does not complete successfully.""" + + def __init__(self, message: str, *, context: Dict[str, Any]) -> None: + super().__init__(message) + self.context = context + + +@dataclass(frozen=True) +class ClientContext: + name: str + config: Path + root_dir: Path + + +def fail(message: str, **context: object) -> None: + payload = {"status": "error", "message": message} + if context: + payload["context"] = context + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(1) + + +def run_cli_json( + *, + cli_path: Path, + graph: str, + client: ClientContext, + args: List[str], +) -> Dict[str, Any]: + command = [ + "node", + str(cli_path), + "--root-dir", + str(client.root_dir), + "--config", + str(client.config), + "--output", + "json", + *args, + "--graph", + graph, + ] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + raise CliCommandError( + "cli command exited with non-zero status", + context={ + "client": client.name, + "command": command, + "exit": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + }, + ) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise CliCommandError( + "cli command did not return valid json", + context={ + "client": client.name, + "command": command, + "stdout": result.stdout, + "stderr": result.stderr, + "detail": str(error), + }, + ) from error + + if payload.get("status") != "ok": + raise CliCommandError( + "cli command returned non-ok status", + context={ + "client": client.name, + "command": command, + "payload": payload, + }, + ) + return payload + + +def page_block_ids( + *, + cli_path: Path, + graph: str, + client: ClientContext, + page_title: str, +) -> List[int]: + query = ( + "[:find [?e ...] " + ":where " + f"[?p :block/title {json.dumps(page_title)}] " + "[?e :block/page ?p] " + "[?e :block/uuid]]" + ) + payload = run_cli_json( + cli_path=cli_path, + graph=graph, + client=client, + args=["query", "--query", query], + ) + result = (payload.get("data") or {}).get("result") + if not isinstance(result, list): + return [] + output: List[int] = [] + for item in result: + try: + output.append(int(item)) + except (TypeError, ValueError): + continue + return output + + +def upsert_page( + *, + cli_path: Path, + graph: str, + client: ClientContext, + page_title: str, +) -> None: + run_cli_json( + cli_path=cli_path, + graph=graph, + client=client, + args=["upsert", "page", "--page", page_title], + ) + + +def create_block( + *, + cli_path: Path, + graph: str, + client: ClientContext, + page_title: str, + content: str, + ids: List[int], + rng: random.Random, +) -> None: + if ids and rng.random() < 0.6: + target_id = str(rng.choice(ids)) + run_cli_json( + cli_path=cli_path, + graph=graph, + client=client, + args=[ + "upsert", + "block", + "--target-id", + target_id, + "--pos", + "first-child", + "--content", + content, + ], + ) + return + + run_cli_json( + cli_path=cli_path, + graph=graph, + client=client, + args=[ + "upsert", + "block", + "--target-page", + page_title, + "--content", + content, + ], + ) + + +def move_block( + *, + cli_path: Path, + graph: str, + client: ClientContext, + page_title: str, + ids: List[int], + rng: random.Random, +) -> bool: + if not ids: + return False + source_id = str(rng.choice(ids)) + run_cli_json( + cli_path=cli_path, + graph=graph, + client=client, + args=[ + "upsert", + "block", + "--id", + source_id, + "--target-page", + page_title, + "--pos", + "last-child", + ], + ) + return True + + +def delete_block( + *, + cli_path: Path, + graph: str, + client: ClientContext, + ids: List[int], + rng: random.Random, +) -> bool: + if len(ids) <= 2: + return False + source_id = str(rng.choice(ids)) + run_cli_json( + cli_path=cli_path, + graph=graph, + client=client, + args=["remove", "block", "--id", source_id], + ) + return True + + +def feasible(operation: str, ids: List[int]) -> bool: + if operation == "create": + return True + if operation == "move": + return len(ids) >= 1 + if operation == "delete": + return len(ids) > 2 + return False + + +def choose_operation(op_counts: Dict[str, int], ids: List[int], rng: random.Random) -> str: + for required in ("create", "move", "delete"): + if op_counts.get(required, 0) == 0 and feasible(required, ids): + return required + + candidates = ["create", "create", "move"] + if feasible("delete", ids): + candidates.append("delete") + if feasible("move", ids): + candidates.append("move") + return rng.choice(candidates) + + +def apply_operation( + *, + cli_path: Path, + graph: str, + client: ClientContext, + page_title: str, + op_counts: Dict[str, int], + round_index: int, + rng: random.Random, +) -> None: + last_error: Dict[str, Any] | None = None + for attempt in range(1, 7): + ids = page_block_ids( + cli_path=cli_path, + graph=graph, + client=client, + page_title=page_title, + ) + operation = choose_operation(op_counts, ids, rng) + content = f"{client.name}-rnd-{round_index:03d}-{rng.randint(100000, 999999)}" + + try: + if operation == "create": + create_block( + cli_path=cli_path, + graph=graph, + client=client, + page_title=page_title, + content=content, + ids=ids, + rng=rng, + ) + op_counts["create"] = op_counts.get("create", 0) + 1 + return + + if operation == "move": + moved = move_block( + cli_path=cli_path, + graph=graph, + client=client, + page_title=page_title, + ids=ids, + rng=rng, + ) + if moved: + op_counts["move"] = op_counts.get("move", 0) + 1 + return + continue + + if operation == "delete": + deleted = delete_block( + cli_path=cli_path, + graph=graph, + client=client, + ids=ids, + rng=rng, + ) + if deleted: + op_counts["delete"] = op_counts.get("delete", 0) + 1 + return + continue + except CliCommandError as error: + last_error = { + "attempt": attempt, + "operation": operation, + "context": error.context, + } + continue + + fail( + "failed to apply random operation after retries", + client=client.name, + round_index=round_index, + last_error=last_error, + ) + + +def ensure_non_empty_page( + *, + cli_path: Path, + graph: str, + client: ClientContext, + page_title: str, + rng: random.Random, +) -> None: + ids = page_block_ids( + cli_path=cli_path, + graph=graph, + client=client, + page_title=page_title, + ) + if ids: + return + content = f"{client.name}-reseed-{rng.randint(100000, 999999)}" + create_block( + cli_path=cli_path, + graph=graph, + client=client, + page_title=page_title, + content=content, + ids=[], + rng=rng, + ) + + +PROFILE_DEFAULT_ROUNDS = { + "default": 40, + "high-stress": 100, +} + + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run randomized bidirectional block operations on two synced graph clients" + ) + parser.add_argument("--cli", required=True, help="Path to static/logseq-cli.js") + parser.add_argument("--graph", required=True) + parser.add_argument("--config-a", required=True) + parser.add_argument("--root-dir-a", required=True) + parser.add_argument("--config-b", required=True) + parser.add_argument("--root-dir-b", required=True) + parser.add_argument("--page", required=True) + parser.add_argument( + "--profile", + choices=sorted(PROFILE_DEFAULT_ROUNDS.keys()), + default="default", + help="Execution profile controlling default stress level", + ) + parser.add_argument("--rounds-per-client", type=int, default=None) + parser.add_argument("--seed", type=int, default=424242) + args = parser.parse_args() + if args.rounds_per_client is None: + args.rounds_per_client = PROFILE_DEFAULT_ROUNDS[args.profile] + return args + + +def main() -> None: + args = parse_args() + cli_path = Path(args.cli).expanduser().resolve() + if not cli_path.exists(): + fail("cli path does not exist", cli=str(cli_path)) + + rng = random.Random(args.seed) + client_a = ClientContext( + name="a", + config=Path(args.config_a).expanduser().resolve(), + root_dir=Path(args.root_dir_a).expanduser().resolve(), + ) + client_b = ClientContext( + name="b", + config=Path(args.config_b).expanduser().resolve(), + root_dir=Path(args.root_dir_b).expanduser().resolve(), + ) + clients = [client_a, client_b] + + for client in clients: + upsert_page( + cli_path=cli_path, + graph=args.graph, + client=client, + page_title=args.page, + ) + + # Seed both peers so move/delete have available targets from the beginning. + for seed_round in range(3): + for client in clients: + create_block( + cli_path=cli_path, + graph=args.graph, + client=client, + page_title=args.page, + content=f"{client.name}-seed-{seed_round}-{rng.randint(100000, 999999)}", + ids=[], + rng=rng, + ) + + op_stats: Dict[str, Dict[str, int]] = { + client.name: {"create": 0, "move": 0, "delete": 0} for client in clients + } + + for round_index in range(args.rounds_per_client): + for client in clients: + apply_operation( + cli_path=cli_path, + graph=args.graph, + client=client, + page_title=args.page, + op_counts=op_stats[client.name], + round_index=round_index, + rng=rng, + ) + + for client in clients: + ensure_non_empty_page( + cli_path=cli_path, + graph=args.graph, + client=client, + page_title=args.page, + rng=rng, + ) + + print( + json.dumps( + { + "status": "ok", + "graph": args.graph, + "page": args.page, + "rounds_per_client": args.rounds_per_client, + "seed": args.seed, + "stats": op_stats, + "total_operations": args.rounds_per_client * len(clients), + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/scripts/sync_asset_download.py b/cli-e2e/scripts/sync_asset_download.py new file mode 100644 index 0000000..57d8736 --- /dev/null +++ b/cli-e2e/scripts/sync_asset_download.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Exercise `logseq sync asset download` in an isolated sync e2e graph.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict, Iterable, List + + +def fail(message: str, **context: object) -> None: + payload = {"status": "error", "message": message} + if context: + payload["context"] = context + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(1) + + +def cli_base(cli: Path, root_dir: Path, config: Path) -> List[str]: + return [ + "node", + str(cli), + "--root-dir", + str(root_dir), + "--config", + str(config), + "--output", + "json", + ] + + +def run_cli_json(command: List[str], *, allow_error: bool = False) -> Dict[str, Any]: + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0 and not allow_error: + fail( + "cli command failed", + command=command, + exit=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + fail( + "cli command did not return valid JSON", + command=command, + exit=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + detail=str(error), + ) + + if payload.get("status") != "ok" and not allow_error: + fail("cli command returned non-ok status", command=command, payload=payload) + + return payload + + +def contains_key(value: Any, key: str) -> bool: + if isinstance(value, dict): + return key in value or any(contains_key(child, key) for child in value.values()) + if isinstance(value, list): + return any(contains_key(child, key) for child in value) + return False + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(8192), b""): + digest.update(chunk) + return digest.hexdigest() + + +def wait_for_checksum(path: Path, checksum: str, timeout_s: float = 60.0) -> None: + deadline = time.time() + timeout_s + last_state = "missing" + while time.time() < deadline: + if path.exists(): + current = sha256_file(path) + if current == checksum: + return + last_state = f"checksum:{current}" + time.sleep(1.0) + fail("asset file did not reach expected checksum", path=str(path), expected=checksum, last_state=last_state) + + +def get_asset(cli: Path, root_dir: Path, config: Path, graph: str, title: str) -> Dict[str, Any]: + query = ( + '[:find (pull ?e [:db/id :block/uuid ' + ':logseq.property.asset/type :logseq.property.asset/checksum ' + ':logseq.property.asset/remote-metadata]) . ' + f':where [?e :block/title "{title}"]]' + ) + payload = run_cli_json( + cli_base(cli, root_dir, config) + + ["query", "--graph", graph, "--query", query] + ) + asset = (payload.get("data") or {}).get("result") + if not isinstance(asset, dict): + fail("asset query did not return an entity", payload=payload) + required = [ + "db/id", + "block/uuid", + "logseq.property.asset/type", + "logseq.property.asset/checksum", + "logseq.property.asset/remote-metadata", + ] + missing = [key for key in required if not asset.get(key)] + if missing: + fail("asset entity is missing required fields", asset=asset, missing=missing) + return asset + + +def sync_asset_download_by_id(cli: Path, root_dir: Path, config: Path, graph: str, asset_id: Any) -> Dict[str, Any]: + return run_cli_json( + cli_base(cli, root_dir, config) + + ["sync", "asset", "download", "--graph", graph, "--id", str(asset_id)] + ) + + +def sync_asset_download_by_uuid(cli: Path, root_dir: Path, config: Path, graph: str, asset_uuid: str) -> Dict[str, Any]: + return run_cli_json( + cli_base(cli, root_dir, config) + + ["sync", "asset", "download", "--graph", graph, "--uuid", asset_uuid] + ) + + +def assert_data(payload: Dict[str, Any], expected: Dict[str, Any]) -> None: + data = payload.get("data") or {} + for key, value in expected.items(): + if data.get(key) != value: + fail("unexpected sync asset download data", expected=expected, actual=data, payload=payload) + if contains_key(payload, "local-path"): + fail("sync asset download output leaked local-path", payload=payload) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run sync asset download e2e assertions") + parser.add_argument("--cli", required=True) + parser.add_argument("--graph", required=True) + parser.add_argument("--asset-title", required=True) + parser.add_argument("--config-b", required=True) + parser.add_argument("--root-dir-b", required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + cli = Path(args.cli).expanduser().resolve() + root_b = Path(args.root_dir_b).expanduser().resolve() + config_b = Path(args.config_b).expanduser().resolve() + + asset = get_asset(cli, root_b, config_b, args.graph, args.asset_title) + asset_id = asset["db/id"] + asset_uuid = asset["block/uuid"] + asset_type = asset["logseq.property.asset/type"] + checksum = asset["logseq.property.asset/checksum"] + asset_path = root_b / "graphs" / args.graph / "assets" / f"{asset_uuid}.{asset_type}" + + if asset_path.exists(): + asset_path.unlink() + + requested = sync_asset_download_by_uuid(cli, root_b, config_b, args.graph, asset_uuid) + assert_data( + requested, + { + "asset-id": asset_id, + "asset-uuid": asset_uuid, + "asset-type": asset_type, + "download-requested?": True, + "checksum-status": "missing", + }, + ) + wait_for_checksum(asset_path, checksum) + + skipped = sync_asset_download_by_id(cli, root_b, config_b, args.graph, asset_id) + assert_data( + skipped, + { + "asset-id": asset_id, + "asset-uuid": asset_uuid, + "asset-type": asset_type, + "download-requested?": False, + "checksum-status": "match", + "skipped-reason": "already-downloaded", + }, + ) + + asset_path.write_text("corrupted local asset", encoding="utf-8") + mismatched = sync_asset_download_by_id(cli, root_b, config_b, args.graph, asset_id) + assert_data( + mismatched, + { + "asset-id": asset_id, + "asset-uuid": asset_uuid, + "asset-type": asset_type, + "download-requested?": True, + "checksum-status": "mismatch", + }, + ) + hint = (mismatched.get("data") or {}).get("hint") or "" + if "checksum" not in hint: + fail("checksum mismatch hint is missing", payload=mismatched) + wait_for_checksum(asset_path, checksum) + + run_cli_json(cli_base(cli, root_b, config_b) + ["sync", "stop", "--graph", args.graph]) + inactive = run_cli_json( + cli_base(cli, root_b, config_b) + + ["sync", "asset", "download", "--graph", args.graph, "--id", str(asset_id)], + allow_error=True, + ) + if inactive.get("status") != "error": + fail("inactive sync command unexpectedly succeeded", payload=inactive) + error = inactive.get("error") or {} + if error.get("code") != "sync-not-started": + fail("inactive sync command returned wrong error", payload=inactive) + if "sync start" not in (error.get("hint") or ""): + fail("inactive sync command did not include start hint", payload=inactive) + + print( + json.dumps( + { + "status": "ok", + "data": { + "asset-id": asset_id, + "asset-uuid": asset_uuid, + "asset-type": asset_type, + "download-requested?": True, + "checksum-status": "mismatch", + "pending-local": 0, + "pending-asset": 0, + "pending-server": 0, + "last-error": None, + }, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/scripts/wait_sync_status.py b/cli-e2e/scripts/wait_sync_status.py new file mode 100644 index 0000000..0c28041 --- /dev/null +++ b/cli-e2e/scripts/wait_sync_status.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Poll `logseq sync status` until queues settle and tx converges.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict + + +def fail(message: str, **context: object) -> None: + payload = {"status": "error", "message": message} + if context: + payload["context"] = context + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(1) + + +def parse_int(value: Any) -> int: + if value is None: + return 0 + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + try: + return int(str(value)) + except (TypeError, ValueError): + return 0 + + +def status_command(args: argparse.Namespace) -> list[str]: + existing = getattr(args, "status_command", None) + if existing is not None: + return existing + + command = [ + "node", + str(Path(args.cli).expanduser().resolve()), + "--root-dir", + str(Path(args.root_dir).expanduser().resolve()), + "--config", + str(Path(args.config).expanduser().resolve()), + "--output", + "json", + "sync", + "status", + "--graph", + args.graph, + ] + args.status_command = command + return command + + + +def run_status(args: argparse.Namespace) -> Dict[str, Any]: + command = status_command(args) + + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + fail( + "sync status command failed", + command=command, + exit=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + fail( + "sync status command did not return valid JSON", + command=command, + stdout=result.stdout, + stderr=result.stderr, + detail=str(error), + ) + + if payload.get("status") != "ok": + fail("sync status returned non-ok status", payload=payload) + + return payload + + +def pending_counts(status_payload: Dict[str, Any]) -> Dict[str, int]: + data = status_payload.get("data") or {} + return { + "pending-local": parse_int(data.get("pending-local")), + "pending-asset": parse_int(data.get("pending-asset")), + "pending-server": parse_int(data.get("pending-server")), + } + + +def all_settled(counts: Dict[str, int]) -> bool: + required_keys = ("pending-local", "pending-asset", "pending-server") + return all(counts.get(key, 0) == 0 for key in required_keys) + + +def parse_required_int(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, str) and value.strip() == "": + return None + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + try: + return int(str(value)) + except (TypeError, ValueError): + return None + + +def tx_sync_status(status_payload: Dict[str, Any]) -> Dict[str, Any]: + data = status_payload.get("data") or {} + local_tx = parse_required_int(data.get("local-tx")) + remote_tx = parse_required_int(data.get("remote-tx")) + synced = ( + local_tx is not None + and remote_tx is not None + and local_tx == remote_tx + ) + return { + "local-tx": local_tx, + "remote-tx": remote_tx, + "synced": synced, + } + + +def tx_deltas( + tx_status: Dict[str, Any], + baseline_tx: Dict[str, Any], +) -> Dict[str, int | None]: + local_tx = tx_status.get("local-tx") + remote_tx = tx_status.get("remote-tx") + baseline_local_tx = baseline_tx.get("local-tx") + baseline_remote_tx = baseline_tx.get("remote-tx") + + local_tx_delta = ( + None + if local_tx is None or baseline_local_tx is None + else local_tx - baseline_local_tx + ) + remote_tx_delta = ( + None + if remote_tx is None or baseline_remote_tx is None + else remote_tx - baseline_remote_tx + ) + + return { + "local-tx-delta": local_tx_delta, + "remote-tx-delta": remote_tx_delta, + } + + +def min_tx_delta_reached( + tx_status: Dict[str, Any], + baseline_tx: Dict[str, Any], + min_tx_delta: int, +) -> bool: + if min_tx_delta <= 0: + return True + + deltas = tx_deltas(tx_status, baseline_tx) + local_tx_delta = deltas.get("local-tx-delta") + remote_tx_delta = deltas.get("remote-tx-delta") + if local_tx_delta is None or remote_tx_delta is None: + return False + + return local_tx_delta >= min_tx_delta and remote_tx_delta >= min_tx_delta + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Wait for sync status to settle" + ) + parser.add_argument( + "--cli", + required=True, + help="Path to static/logseq-cli.js", + ) + parser.add_argument("--root-dir", required=True) + parser.add_argument("--config", required=True) + parser.add_argument("--graph", required=True) + parser.add_argument("--timeout-s", type=float, default=120.0) + parser.add_argument("--interval-s", type=float, default=1.0) + parser.add_argument( + "--min-tx-delta", + type=int, + default=0, + help=( + "Require synced tx delta to be >= this value: " + "(new-tx - old-tx) >= min-tx-delta " + "(default: 0)" + ), + ) + parser.add_argument( + "--baseline-tx", + type=int, + default=None, + help=( + "Optional explicit baseline tx used as old-tx for both local " + "and remote; if omitted, first observed tx values " + "are used" + ), + ) + args = parser.parse_args() + + if args.min_tx_delta < 0: + invalid_min_tx_delta = args.min_tx_delta + fail( + "min-tx-delta must be non-negative", + min_tx_delta=invalid_min_tx_delta, + ) + + started = time.time() + deadline = started + args.timeout_s + last_payload: Dict[str, Any] | None = None + baseline_tx: Dict[str, Any] | None = ( + { + "local-tx": args.baseline_tx, + "remote-tx": args.baseline_tx, + } + if args.baseline_tx is not None + else None + ) + + while time.time() < deadline: + payload = run_status(args) + last_payload = payload + data = payload.get("data") or {} + last_error = data.get("last-error") + + if last_error is not None: + fail("sync status reports last-error", payload=payload) + + counts = pending_counts(payload) + tx_status = tx_sync_status(payload) + if baseline_tx is None: + baseline_tx = { + "local-tx": tx_status.get("local-tx"), + "remote-tx": tx_status.get("remote-tx"), + } + + deltas = tx_deltas(tx_status, baseline_tx) + if ( + all_settled(counts) + and tx_status["synced"] + and min_tx_delta_reached( + tx_status, + baseline_tx, + args.min_tx_delta, + ) + ): + print( + json.dumps( + { + "status": "ok", + "elapsed_s": round(time.time() - started, 3), + "counts": counts, + "tx": { + "local-tx": tx_status["local-tx"], + "remote-tx": tx_status["remote-tx"], + }, + "tx-delta": deltas, + "baseline-tx": baseline_tx, + "min_tx_delta": args.min_tx_delta, + "payload": payload, + } + ) + ) + return + + time.sleep(max(args.interval_s, 0.0)) + + last_tx_status = tx_sync_status(last_payload or {}) + last_baseline_tx = baseline_tx or {"local-tx": None, "remote-tx": None} + fail( + ( + "sync status polling timed out before queues settled, tx synced, " + "and min tx delta reached" + ), + timeout_s=args.timeout_s, + min_tx_delta=args.min_tx_delta, + baseline_tx=last_baseline_tx, + last_payload=last_payload, + last_counts=pending_counts(last_payload or {}), + last_tx=last_tx_status, + last_tx_delta=tx_deltas( + last_tx_status, + last_baseline_tx, + ), + ) + + +if __name__ == "__main__": + main() diff --git a/cli-e2e/spec/non_sync_cases.edn b/cli-e2e/spec/non_sync_cases.edn new file mode 100644 index 0000000..fbaba59 --- /dev/null +++ b/cli-e2e/spec/non_sync_cases.edn @@ -0,0 +1,1421 @@ +{:templates + {:non-sync/graph-json-env + {:setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"], + :cleanup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"]}, + :non-sync/graph-json-secondary-env + {:extends :non-sync/graph-json-env, + :vars + {:secondary-graph "graph-two", :secondary-graph-arg "graph-two"}, + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{secondary-graph-arg}} >/dev/null"], + :cleanup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{secondary-graph-arg}}"]}, + :non-sync/source-graph-json-env + {:extends :non-sync/graph-json-secondary-env, + :vars + {:secondary-graph "source-graph", + :secondary-graph-arg "source-graph"}}, + :non-sync/example-command + {:tags [:example], + :covers {:options {:global ["--output"]}}, + :expect {:exit 0}, + :vars {:example-output "json"}, + :cmds + ["{{cli}} --output {{example-output}} example {{example-selector}}"]}, + :non-sync/example-human-command + {:extends :non-sync/example-command, + :vars {:example-output "human"}}, + :non-sync/example-json-command + {:extends :non-sync/example-command, + :expect {:stdout-json-paths {[:status] "ok"}}}, + :non-sync/example-edn-command + {:extends :non-sync/example-command, + :vars {:example-output "edn"}, + :expect {:stdout-edn-paths {[:status] :ok}}}, + :non-sync/completion-command + {:tags [:completion], + :covers {:commands ["completion"]}, + :expect {:exit 0}, + :cmds ["{{cli}} completion {{completion-selector}}"]}}, + :cases + [{:id "global-help", + :cmds ["{{cli}} --help"], + :expect + {:exit 0, + :stdout-contains + ["Usage: logseq [options]" "graph create" "completion"]}, + :covers {:options {:global ["--help"]}}, + :tags [:global :smoke]} + {:id "global-version", + :cmds ["{{cli}} --version"], + :expect {:exit 0, :stdout-contains ["Build time:" "Revision:"]}, + :covers {:options {:global ["--version"]}}, + :tags [:global :smoke]} + {:id "verbose-graph-list-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json --verbose graph list"], + :expect + {:exit 0, + :stderr-contains [":cli/parsed-options"], + :stdout-json-paths {[:status] "ok"}}, + :covers + {:commands ["graph list"], + :options + {:global ["--config" "--root-dir" "--output" "--verbose"]}}, + :tags [:global :graph]} + {:id "graph-create-and-info-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph info --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok", [:data :graph] "{{graph}}"}}, + :covers + {:commands ["graph create" "graph info"], + :options {:global ["--config" "--graph" "--root-dir" "--output"]}}, + :cleanup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"], + :tags [:graph :smoke]} + {:id "graph-list-human", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human graph list"], + :expect {:exit 0, :stdout-contains ["{{graph}}"]}, + :covers + {:commands ["graph list"], + :options {:global ["--config" "--root-dir" "--output"]}}, + :tags [:graph], + :extends :non-sync/graph-json-env} + {:id "graph-list-edn", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output edn graph list"], + :expect + {:exit 0, + :stdout-edn-paths {[:status] :ok, [:data :graphs] ["{{graph}}"]}}, + :covers + {:commands ["graph list"], + :options {:global ["--config" "--root-dir" "--output"]}}, + :tags [:graph], + :extends :non-sync/graph-json-env} + {:tags [:graph], + :extends :non-sync/graph-json-secondary-env, + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :message] "switched to {{secondary-graph}}"}}, + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph switch --graph {{secondary-graph-arg}}"], + :id "graph-switch-json", + :covers + {:commands ["graph switch"], + :options {:global ["--config" "--graph" "--root-dir" "--output"]}}} + {:id "graph-validate-fix-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph validate --graph {{graph-arg}} --fix"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok", [:data :result :errors] nil}}, + :covers + {:commands ["graph validate"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--fix"]}}, + :tags [:graph], + :extends :non-sync/graph-json-env} + {:id "graph-export-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page ExportHome >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph export --graph {{graph-arg}} --type edn --file {{export-path-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["{{export-path}}"]}, + :covers + {:commands ["graph export"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--file"]}}, + :tags [:graph], + :extends :non-sync/graph-json-env} + {:id "graph-export-edn", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page ExportOptionsHome >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph export --graph {{graph-arg}} --type edn --file {{export-path-arg}} --edn-options '{:export-type :graph-human}' --pretty-print"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["{{export-path}}"]}, + :covers + {:commands ["graph export"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--file" "--edn-options" "--pretty-print"]}}, + :tags [:graph], + :extends :non-sync/graph-json-env} + {:tags [:graph], + :extends :non-sync/source-graph-json-env, + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["Imported edn from" "{{export-path}}"]}, + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{secondary-graph-arg}} --page ImportSeed >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph export --graph {{secondary-graph-arg}} --type edn --file {{export-path-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}}"], + :id "graph-import-json", + :covers + {:commands ["graph import"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"]}}} + {:tags [:graph], + :extends :non-sync/graph-json-env, + :expect {:exit 0, :stdout-json-paths {[:status] "ok"}}, + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page BackupSeed >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph backup create --graph {{graph-arg}} --name nightly >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph backup list --graph {{graph-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph backup restore --graph {{graph-arg}} --src \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph backup list --graph {{graph-arg}} | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"backups\"][0][\"name\"])')\" --dst {{restore-graph-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph backup remove --graph {{graph-arg}} --src \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph backup list --graph {{graph-arg}} | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"backups\"][0][\"name\"])')\""], + :id "graph-backup-lifecycle-json", + :covers + {:commands + ["graph backup create" + "graph backup list" + "graph backup restore" + "graph backup remove"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--name" "--src" "--dst"]}}, + :vars + {:restore-graph "backup-restore", + :restore-graph-arg "backup-restore"}, + :cleanup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{restore-graph-arg}}"]} + {:id "page-upsert-and-list-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list page --graph {{graph-arg}} --fields title,id --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :items 0 :block/title] "Home"}}, + :covers + {:commands ["upsert page" "list page"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--page"], + :list ["--fields" "--limit" "--sort" "--order"]}}, + :tags [:upsert :list :smoke], + :extends :non-sync/graph-json-env} + {:id "task-upsert-and-list-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --page TaskHome --status todo --priority high --scheduled '2026-02-10T08:00:00.000Z' --deadline '2026-02-12T18:00:00.000Z' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list task --graph {{graph-arg}} --status todo --priority high --content task --fields id,title,status,priority,scheduled,deadline --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :items 0 :block/title] "TaskHome", + [:data :items 0 :logseq.property/status] + "logseq.property/status.todo", + [:data :items 0 :logseq.property/priority] + "logseq.property/priority.high", + [:data :items 0 :logseq.property/scheduled] 1770710400000, + [:data :items 0 :logseq.property/deadline] 1770919200000}}, + :covers + {:commands ["upsert task" "list task"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert + ["--page" "--status" "--priority" "--scheduled" "--deadline"], + :list + ["--status" + "--priority" + "--content" + "--fields" + "--limit" + "--sort" + "--order"]}}, + :tags [:upsert :list :smoke], + :extends :non-sync/graph-json-env} + {:id "task-upsert-clear-properties-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --page TaskHome --status todo --priority high --scheduled '2026-02-10T08:00:00.000Z' --deadline '2026-02-12T18:00:00.000Z' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --page TaskHome --no-status --no-priority --no-scheduled --no-deadline >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list task --graph {{graph-arg}} --content taskhome --fields id,title,status,priority,scheduled,deadline --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :items 0 :block/title] "TaskHome"}, + :stdout-not-contains + ["logseq.property/priority.high" + "2026-02-10T08:00:00.000Z" + "2026-02-12T18:00:00.000Z"]}, + :covers + {:commands ["upsert task" "list task"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert + ["--page" + "--no-status" + "--no-priority" + "--no-scheduled" + "--no-deadline"], + :list ["--content" "--fields" "--limit" "--sort" "--order"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "task-upsert-set-clear-conflict-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --page TaskHome --status todo --no-status"], + :expect + {:exit 1, + :stdout-contains + ["invalid-options" + "--status and --no-status are mutually exclusive"]}, + :covers + {:commands ["upsert task"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--page" "--status" "--no-status"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "page-upsert-invalid-update-properties-atomic-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name AtomicTag >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page AtomicPage --update-tags '[\"AtomicTag\"]' --update-properties '{:missing-prop \"value\"}'"], + :expect + {:exit 1, + :stdout-json-paths + {[:status] "error", + [:error :code] "property-not-found", + [:error :option] "--update-properties", + [:error :phase] "resolve-options"}, + :stdout-contains ["missing-prop"]}, + :covers + {:commands ["upsert page"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--page" "--update-tags" "--update-properties"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "node-list-by-tags-properties-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name NodeTag >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name node-prop >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page NodeHome --update-tags '[\"NodeTag\"]' --update-properties '{:node-prop \"v\"}' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list node --graph {{graph-arg}} --tags NodeTag --properties node-prop --fields id,title,type --sort updated-at --order desc --limit 20"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :items 0 :block/title] "NodeHome", + [:data :items 0 :node/type] "page"}}, + :covers + {:commands + ["upsert tag" "upsert property" "upsert page" "list node"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--name" "--page" "--update-tags" "--update-properties"], + :list + ["--tags" + "--properties" + "--fields" + "--limit" + "--sort" + "--order"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "node-list-renders-block-ref-labels-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name RefNodeTag >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page RefNodePage >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content \"Task [[$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?uuid . :where [?e :block/title \"RefNodePage\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')]]\" --update-tags '[\"RefNodeTag\"]' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list node --graph {{graph-arg}} --tags RefNodeTag --fields id,title,type --sort updated-at --order desc --limit 20"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["Task [[RefNodePage]]"]}, + :covers + {:commands ["upsert tag" "upsert page" "upsert block" "list node"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--name" "--page" "--target-page" "--content" "--update-tags"], + :list ["--tags" "--fields" "--limit" "--sort" "--order"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "asset-upsert-and-list-json", + :setup ["printf 'asset-content' > {{export-path-arg}}.png"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --path {{export-path-arg}}.png --content 'Asset One' --target-page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list asset --graph {{graph-arg}} --fields id,title,asset-type,size --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :items 0 :block/title] "Asset One", + [:data :items 0 :logseq.property.asset/type] "png", + [:data :items 0 :logseq.property.asset/size] 13}}, + :covers + {:commands ["upsert asset" "list asset"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--path" "--content" "--target-page"], + :list ["--fields" "--limit" "--sort" "--order"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "asset-upsert-update-json", + :setup + ["printf 'asset-content' > {{export-path-arg}}.png" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --path {{export-path-arg}}.png --content 'Asset Original' --target-page Home >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Asset Original\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --content 'Asset Updated' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list asset --graph {{graph-arg}} --fields id,title --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :items 0 :block/title] "Asset Updated"}}, + :covers + {:commands ["upsert asset" "list asset"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--id" "--content"], + :list ["--fields" "--limit" "--sort" "--order"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "block-upsert-and-show-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json show --graph {{graph-arg}} --page Home --level 2 --linked-references false"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :root :block/children 0 :block/title] "Alpha block"}}, + :covers + {:commands ["upsert block" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--target-page" "--content"], + :show ["--page" "--level" "--linked-references"]}}, + :tags [:upsert :show :smoke], + :extends :non-sync/graph-json-env} + {:id "block-upsert-page-link-appears-in-linked-references-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page bar_page >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page 'Jul 1st, 2026' --pos last-child --content 'Foo links to [[bar_page]]' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json show --graph {{graph-arg}} --page bar_page --linked-references true --level 2"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :linked-references :count] 1}}, + :covers + {:commands ["upsert page" "upsert block" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--page" "--target-page" "--pos" "--content"], + :show ["--page" "--linked-references" "--level"]}}, + :tags [:upsert :show], + :extends :non-sync/graph-json-env} + {:id "show-page-default-content-json", + :setup + ["printf '%s' '{:pages-and-blocks [{:page {:block/title \"Foo\"} :blocks [{:block/title \"Foo normal content\"}]}]}' > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "FOO_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Foo\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Hierarchy Bar Page\\\" :block/parent {:db/id $FOO_ID}} :blocks [{:block/title \\\"Bar normal content\\\"}]}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "BAR_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Hierarchy Bar Page\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Hierarchy Baz Page\\\" :block/parent {:db/id $BAR_ID}}}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json show --graph {{graph-arg}} --page Foo --linked-references false"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :root :block/title] "Foo", + [:data :root :block/children 0 :block/title] "Foo normal content"}, + :stdout-not-contains ["Hierarchy Bar Page" "Hierarchy Baz Page"]}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--page" "--linked-references"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "show-page-hierarchy-json", + :setup + ["printf '%s' '{:pages-and-blocks [{:page {:block/title \"Foo\"} :blocks [{:block/title \"Foo normal content\"}]}]}' > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "FOO_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Foo\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Hierarchy Bar Page\\\" :block/parent {:db/id $FOO_ID}} :blocks [{:block/title \\\"Bar normal content\\\"}]}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "BAR_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Hierarchy Bar Page\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Hierarchy Baz Page\\\" :block/parent {:db/id $BAR_ID}}}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json show --graph {{graph-arg}} --page Foo --page-hierarchy true --level 3 --linked-references false"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :root :block/title] "Foo", + [:data :root :block/children 0 :block/title] "Hierarchy Bar Page", + [:data :root :block/children 0 :block/children 0 :block/title] "Hierarchy Baz Page"}, + :stdout-not-contains ["Foo normal content" "Bar normal content"]}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--page" "--page-hierarchy" "--level" "--linked-references"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "show-library-page-human", + :setup + ["LIB_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Library\"] [?e :logseq.property/built-in? true]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Project Alpha\\\" :block/parent {:db/id $LIB_ID}} :blocks [{:block/title \\\"Project page content\\\"}]}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "PROJECT_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Project Alpha\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Nested Alpha\\\" :block/parent {:db/id $PROJECT_ID}}}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Library --level 3 --linked-references false"], + :expect + {:exit 0, + :stdout-contains ["Library" "Project Alpha" "Nested Alpha"], + :stdout-not-contains ["Project page content"]}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--page" "--level" "--linked-references"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "show-library-page-json", + :setup + ["LIB_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Library\"] [?e :logseq.property/built-in? true]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Project Alpha\\\" :block/parent {:db/id $LIB_ID}} :blocks [{:block/title \\\"Project page content\\\"}]}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "PROJECT_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Project Alpha\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; printf '%s' \"{:pages-and-blocks [{:page {:block/title \\\"Nested Alpha\\\" :block/parent {:db/id $PROJECT_ID}}}]}\" > {{export-path-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json show --graph {{graph-arg}} --page Library --level 3 --linked-references false"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :root :block/title] "Library", + [:data :root :block/children 0 :block/title] "Project Alpha", + [:data :root :block/children 0 :block/children 0 :block/title] "Nested Alpha"}, + :stdout-not-contains ["Project page content"]}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--page" "--level" "--linked-references"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "tag-upsert-and-list-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name TagOne >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list tag --graph {{graph-arg}} --with-properties --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :items 0 :block/title] "TagOne"}}, + :covers + {:commands ["upsert tag" "list tag"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--name"], + :list ["--with-properties" "--sort" "--order" "--limit"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "tag-upsert-schema-properties-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name SchemaOwner --type default --cardinality one >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name SchemaTag --add-properties '[\"SchemaOwner\"]' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find [?title ...] :where [?tag :block/title \"SchemaTag\"] [?tag :logseq.property.class/properties ?prop] [?prop :block/title ?title]]' | grep -q SchemaOwner"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name SchemaTag --remove-properties '[\"SchemaOwner\"]' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find [?title ...] :where [?tag :block/title \"SchemaTag\"] [?tag :logseq.property.class/properties ?prop] [?prop :block/title ?title]]'"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-not-contains ["SchemaOwner"]}, + :covers + {:commands ["upsert property" "upsert tag" "query"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--name" "--type" "--cardinality" "--add-properties" "--remove-properties"], + :query ["--query"]}}, + :tags [:upsert :query], + :extends :non-sync/graph-json-env} + {:id "property-upsert-and-list-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name score --type number --cardinality one >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list property --graph {{graph-arg}} --with-type --sort updated-at --order desc --limit 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :items 0 :block/title] "score", + [:data :items 0 :logseq.property/type] "number", + [:data :items 0 :db/cardinality] "db.cardinality/one"}}, + :covers + {:commands ["upsert property" "list property"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--name" "--type" "--cardinality"], + :list ["--with-type" "--sort" "--order" "--limit"]}}, + :tags [:upsert :list], + :extends :non-sync/graph-json-env} + {:id "list-tag-expand-offset-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name TagA >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name TagB >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list tag --graph {{graph-arg}} --expand --with-extends --sort updated-at --order desc --offset 0 --limit 20"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["TagA" "TagB"]}, + :covers + {:commands ["list tag"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :list + ["--expand" + "--with-extends" + "--offset" + "--sort" + "--order" + "--limit"]}}, + :tags [:list], + :extends :non-sync/graph-json-env} + {:id "list-property-classes-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name alpha --type number --cardinality one >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name beta --type number --cardinality one >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list property --graph {{graph-arg}} --with-classes --sort updated-at --order desc --offset 0 --limit 20"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["alpha" "beta"]}, + :covers + {:commands ["list property"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :list + ["--with-classes" "--sort" "--order" "--offset" "--limit"]}}, + :tags [:list], + :extends :non-sync/graph-json-env} + {:id "list-page-journal-only-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list page --graph {{graph-arg}} --journal-only"], + :expect + {:exit 0, :stdout-json-paths {[:status] "ok", [:data :items] []}}, + :covers + {:commands ["list page"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :list ["--journal-only"]}}, + :tags [:list], + :extends :non-sync/graph-json-env} + {:id "search-block-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SearchBlockTarget >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} search block --content blocktarget --output json --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["SearchBlockTarget"]}, + :covers + {:commands ["search block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :search ["--content"]}}, + :tags [:search :smoke], + :extends :non-sync/graph-json-env} + {:id "search-block-renders-block-ref-labels-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SearchRefPage >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content \"foo [[$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?uuid . :where [?e :block/title \"SearchRefPage\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')]]\" >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json search block --graph {{graph-arg}} --content foo"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["foo [[SearchRefPage]]"]}, + :covers + {:commands ["upsert page" "upsert block" "search block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--page" "--target-page" "--content"], + :search ["--content"]}}, + :tags [:upsert :search], + :extends :non-sync/graph-json-env} + {:id "search-page-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SearchPageTarget >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json search page --content target --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["SearchPageTarget"]}, + :covers + {:commands ["search page"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :search ["--content"]}}, + :tags [:search], + :extends :non-sync/graph-json-env} + {:id "search-property-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name SearchOwner --type default >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json search property --content owner --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["SearchOwner"]}, + :covers + {:commands ["search property"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :search ["--content"]}}, + :tags [:search], + :extends :non-sync/graph-json-env} + {:id "search-tag-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name SearchTagTarget >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json search tag --content target --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-contains ["SearchTagTarget"]}, + :covers + {:commands ["search tag"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :search ["--content"]}}, + :tags [:search], + :extends :non-sync/graph-json-env} + {:id "block-upsert-blocks-file-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Anchor block' >/dev/null" + "printf '[{:block/title \"Inserted from file\"}]' > {{export-path-arg}}"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human upsert block --graph {{graph-arg}} --target-id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Anchor block\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --blocks-file {{export-path-arg}} --pos sibling >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect {:exit 0, :stdout-contains ["Inserted from file"]}, + :covers + {:commands ["upsert block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--blocks-file" "--target-id" "--pos"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "block-upsert-blocks-file-inline-property-name-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name 'Correct Answer' --type default --cardinality one --public true >/dev/null" + "printf '%s' '[{:block/title \"Question block\" \"Correct Answer\" \"answer\"}]' > {{export-path-arg}}"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --blocks-file {{export-path-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect + {:exit 0, + :stdout-contains ["Question block" "Correct Answer:" "answer"]}, + :covers + {:commands ["upsert block" "upsert property" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--blocks-file" "--target-page" "--name" "--type" "--cardinality" "--public"], + :show ["--page"]}}, + :tags [:upsert :show], + :extends :non-sync/graph-json-env} + {:id "block-upsert-blocks-inline-property-missing-does-not-create-target-page-json", + :cmds + ["set +e; out=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page NewPage --blocks '[{:block/title \"Q\" \"Missing Prop\" \"x\"}]' 2>&1)\"; code=$?; set -e; printf '%s\n' \"$out\"; test \"$code\" -ne 0; printf '%s' \"$out\" | python3 -c 'import sys,json; data=json.load(sys.stdin); assert data[\"status\"] == \"error\"; assert data[\"error\"][\"code\"] == \"property-not-found\"'; page=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"NewPage\"]]')\"; printf '%s\n' \"$page\"; printf '%s' \"$page\" | python3 -c 'import sys,json; data=json.load(sys.stdin); assert data[\"data\"][\"result\"] is None'"], + :expect + {:exit 0, + :stdout-contains ["property-not-found"]}, + :covers + {:commands ["upsert block" "query"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--blocks" "--target-page"], + :query ["--query"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "block-upsert-blocks-inline-properties-per-block-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name 'Inline Note' --type default --cardinality one --public true >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --blocks '[{:block/title \"First inline\" \"Inline Note\" \"First note\"} {:block/title \"Second inline\" \"Inline Note\" \"Second note\"}]' >/dev/null" + "first_id=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"First inline\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; second_id=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Second inline\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; first_show=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --id \"$first_id\")\"; second_show=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --id \"$second_id\")\"; printf '%s\n---\n%s\n' \"$first_show\" \"$second_show\"; printf '%s' \"$first_show\" | grep 'Inline Note: First note' >/dev/null; ! printf '%s' \"$first_show\" | grep 'Second note' >/dev/null; printf '%s' \"$second_show\" | grep 'Inline Note: Second note' >/dev/null; ! printf '%s' \"$second_show\" | grep 'First note' >/dev/null"], + :expect + {:exit 0, + :stdout-contains ["First inline" "Inline Note: First note" "Second inline" "Inline Note: Second note"]}, + :covers + {:commands ["upsert block" "upsert property" "query" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--blocks" "--target-page" "--name" "--type" "--cardinality" "--public"], + :query ["--query"], + :show ["--id"]}}, + :tags [:upsert :show], + :extends :non-sync/graph-json-env} + {:id "block-upsert-blocks-inline-property-numeric-id-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name 'Numeric Prop' --type default --cardinality one --public true >/dev/null"], + :cmds + ["prop_id=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Numeric Prop\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; {{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --blocks \"[{:block/title \\\"Inline numeric\\\" $prop_id \\\"inline\\\"}]\" >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect + {:exit 0, + :stdout-contains ["Inline numeric" "Numeric Prop: inline"]}, + :covers + {:commands ["upsert block" "upsert property" "query" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--blocks" "--target-page" "--name" "--type" "--cardinality" "--public"], + :query ["--query"], + :show ["--page"]}}, + :tags [:upsert :show], + :extends :non-sync/graph-json-env} + {:id "block-upsert-target-uuid-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Anchor block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human upsert block --graph {{graph-arg}} --target-uuid \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?uuid . :where [?e :block/title \"Anchor block\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --content 'Inserted by uuid' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect {:exit 0, :stdout-contains ["Inserted by uuid"]}, + :covers + {:commands ["upsert block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--target-uuid"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "block-upsert-update-id-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human upsert block --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Alpha block\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --content 'Updated by id' --update-tags '[:logseq.class/Quote-block]' --update-properties '{:logseq.property/publishing-public? true}' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect {:exit 0, :stdout-contains ["Updated by id"]}, + :covers + {:commands ["upsert block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--id" "--update-tags" "--update-properties"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "block-upsert-update-id-custom-many-property-json", + :vars {:prop-name "Reproducible steps"}, + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name '{{prop-name}}' --type default --cardinality many --public true >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Alpha block\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --update-properties '{\"{{prop-name}}\" [\"Step 1\" \"Step 2\" \"Step 3\"]}' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Alpha block\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\""], + :expect + {:exit 0, + :stdout-contains ["{{prop-name}}" "Step 1" "Step 2" "Step 3"]}, + :covers + {:commands ["upsert block" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert + ["--id" "--target-page" "--content" "--update-properties"], + :show ["--id"]}}, + :tags [:upsert :show]} + {:id "show-property-many-ref-default-renders-nested-block-ref-human", + :setup + ["printf '%s' '{:properties {:user.property/reproducible-steps {:block/title \"Reproducible steps\" :logseq.property/type :default :db/cardinality :db.cardinality/many :db/valueType :db.type/ref :logseq.property/public? true}} :pages-and-blocks [{:page {:block/title \"Targets\"} :blocks [{:block/title \"Nested target\" :block/uuid #uuid \"11111111-1111-1111-1111-111111111111\" :build/keep-uuid? true} {:block/title \"Step [[11111111-1111-1111-1111-111111111111]]\" :block/uuid #uuid \"22222222-2222-2222-2222-222222222222\" :build/keep-uuid? true} {:block/title \"Verify output\" :block/uuid #uuid \"33333333-3333-3333-3333-333333333333\" :build/keep-uuid? true}]} {:page {:block/title \"Home\"} :blocks [{:block/title \"Alpha block\" :build/properties {:user.property/reproducible-steps #{[:block/uuid #uuid \"22222222-2222-2222-2222-222222222222\"] [:block/uuid #uuid \"33333333-3333-3333-3333-333333333333\"]}}}]}]}' > {{export-path-arg}}"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect + {:exit 0, + :stdout-contains ["Reproducible steps:" "Step [[Nested target]]" "Verify output"], + :stdout-not-contains ["Step [[11111111-1111-1111-1111-111111111111]]"]}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--page"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "block-upsert-update-uuid-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' --update-tags '[:logseq.class/Quote-block]' --update-properties '{:logseq.property/publishing-public? true}' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human upsert block --graph {{graph-arg}} --uuid \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?uuid . :where [?e :block/title \"Alpha block\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --content 'Updated by uuid' --remove-tags '[:logseq.class/Quote-block]' --remove-properties '[:logseq.property/publishing-public?]' >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"], + :expect {:exit 0, :stdout-contains ["Updated by uuid"]}, + :covers + {:commands ["upsert block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--uuid" "--remove-tags" "--remove-properties"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "property-upsert-update-id-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name score --type number --cardinality one >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list property --graph {{graph-arg}} --fields title,id --limit 200 --sort updated-at --order desc | python3 -c 'import sys,json; print(next(item[\"db/id\"] for item in json.load(sys.stdin)[\"data\"][\"items\"] if item[\"block/title\"] == \"score\"))')\" --hide true --public false"], + :expect {:exit 0, :stdout-json-paths {[:status] "ok"}}, + :covers + {:commands ["upsert property"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :upsert ["--id" "--hide" "--public"]}}, + :tags [:upsert], + :extends :non-sync/graph-json-env} + {:id "query-custom-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?title . :where [?b :block/title ?title] [(= ?title \"Alpha block\")]]'"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :result] "Alpha block"}}, + :covers + {:commands ["query"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :query ["--query"]}}, + :tags [:query], + :extends :non-sync/graph-json-env} + {:id "query-named-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --name recent-updated --inputs '[1]'"], + :expect {:exit 0, :stdout-json-paths {[:status] "ok"}}, + :covers + {:commands ["query"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :query ["--name" "--inputs"]}}, + :tags [:query], + :extends :non-sync/graph-json-env} + {:id "query-list-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query list --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :queries 0 :name] "list-priority"}}, + :covers + {:commands ["query list"], + :options {:global ["--config" "--graph" "--root-dir" "--output"]}}, + :tags [:query], + :extends :non-sync/graph-json-env} + {:id "example-show-human", + :expect + {:exit 0, + :stdout-contains + ["Selector: show" "Matched commands:" "show" "Examples:"]}, + :covers {:commands ["example show"]}, + :tags [:example], + :extends :non-sync/example-human-command, + :vars {:example-selector "show"}} + {:id "example-upsert-page-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "upsert page", + [:data :matched-commands 0] "upsert page"}}, + :covers {:commands ["example upsert page"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "upsert page"}} + {:id "example-upsert-prefix-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "upsert", + [:data :matched-commands 0] "upsert block"}}, + :covers {:commands ["example upsert"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "upsert"}} + {:id "example-list-prefix-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "list", + [:data :matched-commands 0] "list page"}}, + :covers {:commands ["example list"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "list"}} + {:id "example-list-page-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "list page", + [:data :matched-commands] ["list page"]}}, + :covers {:commands ["example list page"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "list page"}} + {:id "example-query-prefix-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "query", [:data :matched-commands 0] "query"}}, + :covers {:commands ["example query"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "query"}} + {:id "example-query-list-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "query list", + [:data :matched-commands] ["query list"]}}, + :covers {:commands ["example query list"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "query list"}} + {:id "example-remove-prefix-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "remove", + [:data :matched-commands 0] "remove block"}}, + :covers {:commands ["example remove"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "remove"}} + {:id "example-remove-page-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "remove page", + [:data :matched-commands] ["remove page"]}}, + :covers {:commands ["example remove page"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "remove page"}} + {:id "example-search-prefix-json", + :expect + {:exit 0, + :stdout-json-paths + {[:data :selector] "search", + [:data :matched-commands 0] "search block"}}, + :covers {:commands ["example search"]}, + :tags [:example], + :extends :non-sync/example-json-command, + :vars {:example-selector "search"}} + {:id "example-search-block-edn", + :expect + {:exit 0, + :stdout-edn-paths + {[:data :selector] "search block", + [:data :matched-commands] ["search block"]}}, + :covers {:commands ["example search block"]}, + :tags [:example], + :extends :non-sync/example-edn-command, + :vars {:example-selector "search block"}} + {:id "show-linked-block-id-human", + :setup + ["printf '%s' '{:pages-and-blocks [{:page {:block/title \"Targets\"} :blocks [{:block/title \"Target B\" :block/uuid #uuid \"11111111-1111-1111-1111-111111111111\" :build/keep-uuid? true :build/children [{:block/title \"Target child\"}]}]} {:page {:block/title \"Home\"} :blocks [{:block/title \"Source A\" :block/uuid #uuid \"22222222-2222-2222-2222-222222222222\" :build/keep-uuid? true :block/link [:block/uuid #uuid \"11111111-1111-1111-1111-111111111111\"]}]}]}' > {{export-path-arg}}"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output human show --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output json query --query '[:find ?e . :where [?e :block/title \"Source A\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --linked-references false"], + :expect + {:exit 0, + :stdout-contains ["→" "Target B" "Target child"], + :stdout-not-contains ["Source A"]}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--id" "--linked-references"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "show-linked-block-id-json", + :setup + ["printf '%s' '{:pages-and-blocks [{:page {:block/title \"Targets\"} :blocks [{:block/title \"Target B\" :block/uuid #uuid \"11111111-1111-1111-1111-111111111111\" :build/keep-uuid? true :build/children [{:block/title \"Target child\"}]}]} {:page {:block/title \"Home\"} :blocks [{:block/title \"Source A\" :block/uuid #uuid \"22222222-2222-2222-2222-222222222222\" :build/keep-uuid? true :block/link [:block/uuid #uuid \"11111111-1111-1111-1111-111111111111\"]}]}]}' > {{export-path-arg}}"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph import --graph {{graph-arg}} --type edn --input {{export-path-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output json show --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output json query --query '[:find ?e . :where [?e :block/title \"Source A\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --linked-references false"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok" + [:data :root :block/title] "Target B" + [:data :root :block/children 0 :block/title] "Target child"}}, + :covers + {:commands ["graph import" "show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :graph ["--type" "--input"], + :show ["--id" "--linked-references"]}}, + :tags [:graph :show], + :extends :non-sync/graph-json-env} + {:id "show-id-human", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output human show --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output human query --query '[:find ?e . :where [?e :block/title \"Alpha block\"]]')\""], + :expect {:exit 0, :stdout-contains ["Alpha block"]}, + :covers + {:commands ["show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :show ["--id"]}}, + :tags [:show], + :extends :non-sync/graph-json-env} + {:id "show-uuid-human", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output human show --uuid \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output json query --query '[:find ?uuid . :where [?e :block/title \"Alpha block\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\""], + :expect {:exit 0, :stdout-contains ["Alpha block"]}, + :covers + {:commands ["show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :show ["--uuid"]}}, + :tags [:show], + :extends :non-sync/graph-json-env} + {:id "show-stdin-id-human", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output human query --query '[:find [?e ...] :in $ ?q :where [?e :block/title ?title] [(clojure.string/includes? ?title ?q)]]' --inputs '[\"Alpha\"]' | {{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --graph {{graph-arg}} --output human show --id"], + :expect {:exit 0, :stdout-contains ["Alpha block"]}, + :covers + {:commands ["show"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :show ["stdin:--id"]}}, + :tags [:show :pipe], + :extends :non-sync/graph-json-env} + {:id "debug-pull-id-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json debug pull --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Home\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\""], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :entity :block/title] "Home"}}, + :covers + {:commands ["debug pull"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :debug ["--id"]}}, + :tags [:debug], + :extends :non-sync/graph-json-env} + {:id "debug-pull-uuid-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json debug pull --graph {{graph-arg}} --uuid \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?uuid . :where [?e :block/title \"Home\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\""], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :entity :block/title] "Home"}}, + :covers + {:commands ["debug pull"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :debug ["--uuid"]}}, + :tags [:debug], + :extends :non-sync/graph-json-env} + {:id "debug-pull-ident-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json debug pull --graph {{graph-arg}} --ident :logseq.class/Tag"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :entity :db/ident] "logseq.class/Tag"}}, + :covers + {:commands ["debug pull"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :debug ["--ident"]}}, + :tags [:debug], + :extends :non-sync/graph-json-env} + {:id "remove-page-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json remove page --graph {{graph-arg}} --page Home"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok", [:data :result] true}}, + :covers + {:commands ["remove page"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :remove ["--page"]}}, + :tags [:remove], + :extends :non-sync/graph-json-env} + {:id "graph-remove-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph remove --graph {{graph-arg}}" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph list"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok"}, + :stdout-not-contains ["{{graph}}"]}, + :covers + {:commands ["graph remove" "graph list"], + :options {:global ["--config" "--graph" "--root-dir" "--output"]}}, + :tags [:graph :remove]} + {:id "remove-block-uuid-human", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --content 'Alpha block' >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human remove block --graph {{graph-arg}} --uuid \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?uuid . :where [?e :block/title \"Alpha block\"] [?e :block/uuid ?uuid]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home --linked-references false"], + :expect {:exit 0, :stdout-not-contains ["Alpha block"]}, + :covers + {:commands ["remove block"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :remove ["--uuid"]}}, + :tags [:remove], + :extends :non-sync/graph-json-env} + {:id "remove-tag-name-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert tag --graph {{graph-arg}} --name TagOne >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json remove tag --graph {{graph-arg}} --name TagOne >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list tag --graph {{graph-arg}} --fields title,id"], + :expect {:exit 0, :stdout-not-contains ["TagOne"]}, + :covers + {:commands ["remove tag"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :remove ["--name"]}}, + :tags [:remove], + :extends :non-sync/graph-json-env} + {:id "remove-property-id-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name score --type number --cardinality one >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json remove property --graph {{graph-arg}} --id \"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list property --graph {{graph-arg}} --fields title,id --limit 200 --sort updated-at --order desc | python3 -c 'import sys,json; print(next(item[\"db/id\"] for item in json.load(sys.stdin)[\"data\"][\"items\"] if item[\"block/title\"] == \"score\"))')\" >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json list property --graph {{graph-arg}} --fields title,id"], + :expect {:exit 0, :stdout-not-contains ["score"]}, + :covers + {:commands ["remove property"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :remove ["--id"]}}, + :tags [:remove], + :extends :non-sync/graph-json-env} + {:id "server-cleanup-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server cleanup"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :mismatched] 0, + [:data :eligible] 0, + [:data :skipped-owner] 0}}, + :covers + {:commands ["server cleanup"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :server []}}, + :tags [:server], + :extends :non-sync/graph-json-env} + {:id "server-list-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server start --graph {{graph-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server list"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :servers 0 :graph] "{{graph}}"}}, + :covers + {:commands ["server list"], + :options {:global ["--config" "--root-dir" "--output"]}}, + :tags [:server], + :extends :non-sync/graph-json-env} + {:id "server-restart-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server restart --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :repo] "logseq_db_{{graph}}", [:data :owned] true}}, + :covers + {:commands ["server restart"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :server ["--graph"]}}, + :tags [:server], + :extends :non-sync/graph-json-env} + {:id "server-stop-json", + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server start --graph {{graph-arg}} >/dev/null"], + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths {[:status] "ok", [:data :repo] "logseq_db_{{graph}}"}}, + :covers + {:commands ["server stop"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :server ["--graph"]}}, + :tags [:server]} + {:id "server-start-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server start --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", [:data :repo] "logseq_db_{{graph}}", [:data :owned] true}}, + :covers + {:commands ["server start"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"], + :server ["--graph"]}}, + :tags [:server], + :extends :non-sync/graph-json-env} + {:id "agent-bridge-workflows", + :setup + ["mkdir -p '{{tmp-dir}}/assigned-root' && printf '{:output-format :json}\\n' > '{{tmp-dir}}/assigned-root/cli.edn' && {{cli}} --root-dir '{{tmp-dir}}/assigned-root' --config '{{tmp-dir}}/assigned-root/cli.edn' --output json graph create --graph cli-e2e-agent-bridge-assigned >/dev/null && {{cli}} --root-dir '{{tmp-dir}}/assigned-root' --config '{{tmp-dir}}/assigned-root/cli.edn' --output json upsert task --graph cli-e2e-agent-bridge-assigned --target-page AgentBridgeE2E --content 'Test AgentBridge task routing and completion status' --status todo >/dev/null" + "mkdir -p '{{tmp-dir}}/parallel-root' && printf '{:output-format :json}\\n' > '{{tmp-dir}}/parallel-root/cli.edn' && {{cli}} --root-dir '{{tmp-dir}}/parallel-root' --config '{{tmp-dir}}/parallel-root/cli.edn' --output json graph create --graph cli-e2e-agent-bridge-parallel >/dev/null" + "mkdir -p '{{tmp-dir}}/duplicate-root' && printf '{:output-format :json}\\n' > '{{tmp-dir}}/duplicate-root/cli.edn' && {{cli}} --root-dir '{{tmp-dir}}/duplicate-root' --config '{{tmp-dir}}/duplicate-root/cli.edn' --output json graph create --graph cli-e2e-agent-bridge-duplicate >/dev/null" + "mkdir -p '{{tmp-dir}}/comment-root' && printf '{:output-format :json}\\n' > '{{tmp-dir}}/comment-root/cli.edn' && {{cli}} --root-dir '{{tmp-dir}}/comment-root' --config '{{tmp-dir}}/comment-root/cli.edn' --output json graph create --graph cli-e2e-agent-bridge-comment >/dev/null"], + :cmds + ["python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/assigned-root' --config '{{tmp-dir}}/assigned-root/cli.edn' --graph cli-e2e-agent-bridge-assigned --tmp-dir '{{tmp-dir}}/assigned-work' --repo-root '{{repo-root}}' --assign-after-start" + "python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/parallel-root' --config '{{tmp-dir}}/parallel-root/cli.edn' --graph cli-e2e-agent-bridge-parallel --tmp-dir '{{tmp-dir}}/parallel-work' --repo-root '{{repo-root}}' --parallel-assignment-check" + "python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/duplicate-root' --config '{{tmp-dir}}/duplicate-root/cli.edn' --graph cli-e2e-agent-bridge-duplicate --tmp-dir '{{tmp-dir}}/duplicate-work' --repo-root '{{repo-root}}' --duplicate-bridge-check" + "python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/comment-root' --config '{{tmp-dir}}/comment-root/cli.edn' --graph cli-e2e-agent-bridge-comment --tmp-dir '{{tmp-dir}}/comment-work' --repo-root '{{repo-root}}' --comment-mention-check" + "bash '{{repo-root}}/cli-e2e/scripts/agent_bridge_demo.sh' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/demo-root' --graph cli-e2e-agent-bridge-demo --repo-root '{{repo-root}}'"], + :expect + {:exit 0, + :stdout-contains ["agent bridge"]}, + :covers + {:commands ["agent bridge"], + :options + {:global ["--config" "--graph" "--root-dir" "--output"]}}, + :cleanup + ["{{cli}} --root-dir '{{tmp-dir}}/assigned-root' --config '{{tmp-dir}}/assigned-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-assigned" + "{{cli}} --root-dir '{{tmp-dir}}/parallel-root' --config '{{tmp-dir}}/parallel-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-parallel" + "{{cli}} --root-dir '{{tmp-dir}}/duplicate-root' --config '{{tmp-dir}}/duplicate-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-duplicate" + "{{cli}} --root-dir '{{tmp-dir}}/comment-root' --config '{{tmp-dir}}/comment-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-comment"], + :tags [:agent]} + {:id "skill-show-human", + :cmds ["{{cli}} skill show"], + :expect + {:exit 0, :stdout-contains ["name: logseq-cli" "# Logseq CLI"]}, + :covers {:commands ["skill show"], :options {:skill []}}, + :tags [:skill :smoke]} + {:id "skill-show-json-still-raw-markdown", + :cmds ["{{cli}} --output json skill show"], + :expect + {:exit 0, + :stdout-contains ["name: logseq-cli" "# Logseq CLI"], + :stdout-not-contains ["\"status\"" "\"data\""]}, + :covers {:commands ["skill show"], :options {:global ["--output"]}}, + :tags [:skill]} + ;; Repo root already contains tracked `.agents/skills/logseq-cli/SKILL.md`, + ;; so this local-install case must run from an isolated cwd and HOME. + {:id "skill-install-local", + :setup + ["rm -rf ./tmp/cli-e2e-skill-install-local ./tmp/cli-e2e-skill-install-local-home" + "mkdir -p ./tmp/cli-e2e-skill-install-local ./tmp/cli-e2e-skill-install-local-home"], + :cmds + ["cd ./tmp/cli-e2e-skill-install-local && HOME=\"$(pwd)/../cli-e2e-skill-install-local-home\" {{cli}} skill install" + "python3 -c 'import pathlib; p=pathlib.Path(\"./tmp/cli-e2e-skill-install-local/.agents/skills/logseq-cli/SKILL.md\"); print(\"installed\" if p.exists() else \"missing\")'"], + :expect {:exit 0, :stdout-contains ["installed"]}, + :covers {:commands ["skill install"], :options {:skill []}}, + :cleanup ["rm -rf ./tmp/cli-e2e-skill-install-local ./tmp/cli-e2e-skill-install-local-home"], + :tags [:skill]} + {:id "skill-install-global-preserves-other-skills", + :setup + ["rm -rf ./tmp/cli-e2e-skill-home" + "mkdir -p ./tmp/cli-e2e-skill-home/.agents/skills/existing-skill" + "python3 -c 'import pathlib; pathlib.Path(\"./tmp/cli-e2e-skill-home/.agents/skills/existing-skill/SKILL.md\").write_text(\"keep\", encoding=\"utf8\")'"], + :cmds + ["HOME=\"$(pwd)/tmp/cli-e2e-skill-home\" {{cli}} skill install --global >/dev/null" + "python3 -c 'import pathlib; home=pathlib.Path(\"./tmp/cli-e2e-skill-home\"); existing=(home / \".agents/skills/existing-skill/SKILL.md\").read_text(encoding=\"utf8\"); installed=(home / \".agents/skills/logseq-cli/SKILL.md\").exists(); print(\"ok\" if existing==\"keep\" and installed else \"bad\")'"], + :expect {:exit 0, :stdout-contains ["ok"]}, + :covers + {:commands ["skill install"], :options {:skill ["--global"]}}, + :cleanup ["rm -rf ./tmp/cli-e2e-skill-home"], + :tags [:skill]} + {:id "completion-zsh", + :expect + {:exit 0, + :stdout-contains + ["#compdef logseq" "Auto-generated by `logseq completion zsh`"]}, + :covers {:options {:completion ["zsh"]}}, + :tags [:smoke], + :extends :non-sync/completion-command, + :vars {:completion-selector "zsh"}} + {:id "completion-bash-flag", + :expect + {:exit 0, + :stdout-contains + ["Auto-generated by `logseq completion bash`" + "_logseq_json_names_bash"]}, + :covers {:options {:completion ["--shell" "bash"]}}, + :extends :non-sync/completion-command, + :vars {:completion-selector "--shell bash"}} + {:id "completion-invalid-shell", + :expect {:exit 1, :stdout-contains ["unsupported shell: fish"]}, + :extends :non-sync/completion-command, + :vars {:completion-selector "fish"}} + {:id "doctor-dev-script-json", + :cmds + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json doctor --dev-script"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :status] "ok", + [:data :checks 0 :id] "db-worker-script"}, + :stdout-contains ["static/db-worker-node.js"]}, + :covers + {:commands ["doctor"], + :options + {:global ["--config" "--root-dir" "--output"], + :doctor ["--dev-script"]}}, + :tags [:doctor :smoke]}]} diff --git a/cli-e2e/spec/non_sync_inventory.edn b/cli-e2e/spec/non_sync_inventory.edn new file mode 100644 index 0000000..4db28c7 --- /dev/null +++ b/cli-e2e/spec/non_sync_inventory.edn @@ -0,0 +1,174 @@ +{:excluded-command-prefixes ["sync" "login" "logout"] + :scopes + {:global + {:options ["--help" + "--version" + "--config" + "--graph" + "--root-dir" + "--output" + "--verbose"]} + + :graph + {:commands ["graph list" + "graph create" + "graph switch" + "graph remove" + "graph validate" + "graph info" + "graph export" + "graph import" + "graph backup list" + "graph backup create" + "graph backup restore" + "graph backup remove"] + :options ["--fix" + "--type" + "--file" + "--edn-options" + "--pretty-print" + "--input" + "--name" + "--src" + "--dst"]} + + :list + {:commands ["list page" + "list tag" + "list property" + "list task" + "list node" + "list asset"] + :options ["--expand" + "--fields" + "--limit" + "--offset" + "--sort" + "--order" + "--status" + "--priority" + "--content" + "--tags" + "--properties" + "--journal-only" + "--with-properties" + "--with-extends" + "--with-classes" + "--with-type"]} + + :upsert + {:commands ["upsert block" + "upsert page" + "upsert task" + "upsert asset" + "upsert tag" + "upsert property"] + :options ["--id" + "--uuid" + "--target-id" + "--target-uuid" + "--target-page" + "--pos" + "--content" + "--path" + "--blocks-file" + "--status" + "--priority" + "--scheduled" + "--deadline" + "--no-status" + "--no-priority" + "--no-scheduled" + "--no-deadline" + "--update-tags" + "--update-properties" + "--remove-tags" + "--remove-properties" + "--add-properties" + "--remove-properties" + "--page" + "--name" + "--type" + "--cardinality" + "--hide" + "--public"]} + + :remove + {:commands ["remove block" + "remove page" + "remove tag" + "remove property"] + :options ["--id" + "--uuid" + "--name"]} + + :query + {:commands ["query" + "query list"] + :options ["--query" + "--name" + "--inputs"]} + + :search + {:commands ["search block" + "search page" + "search property" + "search tag"] + :options ["--content"]} + + :show + {:commands ["show"] + :options ["--id" + "--uuid" + "--page" + "--page-hierarchy" + "--linked-references" + "--level" + "stdin:--id"]} + + :debug + {:commands ["debug pull"] + :options ["--id" + "--uuid" + "--ident"]} + + :example + {:commands ["example list" + "example list page" + "example upsert" + "example upsert page" + "example remove" + "example remove page" + "example query" + "example query list" + "example search" + "example search block" + "example show"] + :options []} + + :server + {:commands ["server list" + "server cleanup" + "server start" + "server stop" + "server restart"] + :options ["--graph"]} + + :doctor + {:commands ["doctor"] + :options ["--dev-script"]} + + :agent + {:commands ["agent bridge"] + :options []} + + :completion + {:commands ["completion"] + :options ["bash" + "zsh" + "--shell"]} + + :skill + {:commands ["skill show" + "skill install"] + :options ["--global"]}}} diff --git a/cli-e2e/spec/sync_cases.edn b/cli-e2e/spec/sync_cases.edn new file mode 100644 index 0000000..5e87217 --- /dev/null +++ b/cli-e2e/spec/sync_cases.edn @@ -0,0 +1,229 @@ +{:templates + {:sync/base + {:cleanup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" + "{{cli}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json server stop --graph {{graph-arg}}"], + :tags [:sync], + :vars + {:sync-port "18080", + :sync-http-base "http://127.0.0.1:18080", + :sync-ws-url "ws://127.0.0.1:18080/sync/%s", + :home-dir "{{tmp-dir}}/home", + :auth-path "{{tmp-dir}}/home/logseq/auth.json", + :cli-home "HOME='{{tmp-dir}}/home' {{cli}}"}, + :setup + ["mkdir -p '{{tmp-dir}}/graphs-b'" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}"]}, + :sync/create-enable-base + {:cleanup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" + "{{cli}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json server stop --graph {{graph-arg}}"], + :tags [:sync], + :vars + {:sync-port "18080", + :sync-http-base "http://127.0.0.1:18080", + :sync-ws-url "ws://127.0.0.1:18080/sync/%s", + :home-dir "{{tmp-dir}}/home", + :auth-path "{{tmp-dir}}/home/logseq/auth.json", + :cli-home "HOME='{{tmp-dir}}/home' {{cli}}"}, + :setup + ["mkdir -p '{{tmp-dir}}/graphs-b'"]}, + :sync/common + {:extends :sync/base, + :cmds + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}}" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 120 --interval-s 1" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync download --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :pending-local] 0, + [:data :pending-asset] 0, + [:data :pending-server] 0, + [:data :last-error] nil}}, + :covers + {:commands ["sync upload" "sync download" "sync status"], + :options {:global ["--config" "--graph" "--root-dir" "--output"]}}}} + :cases + [{:tags [:happy-path :bootstrap :create-enable-sync :a-to-b], + :extends :sync/create-enable-base, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} --enable-sync --e2ee-password {{e2ee-password-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 120 --interval-s 1" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncCreateEnableHome >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncCreateEnableHome --content '{{marker-content}}' >/dev/null" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 120 --interval-s 1" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync download --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1"], + :cmds + ["python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{marker-content}}\")] ]' --require-result" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :expect + {:exit 0, + :stdout-json-paths + {[:status] "ok", + [:data :pending-local] 0, + [:data :pending-asset] 0, + [:data :pending-server] 0, + [:data :last-error] nil}}, + :covers + {:commands ["graph create" "sync download" "sync status"], + :options {:global ["--config" "--graph" "--root-dir" "--output"], + :graph-create ["--enable-sync" "--e2ee-password"]}}, + :id "sync-graph-create-enable-sync-a-to-b", + :graph "sync-e2e-create-enable-sync-a-to-b", + :vars {:marker-content "sync-create-enable-sync-a-to-b-marker"}} + {:tags [:happy-path :bootstrap :a-to-b], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncBootstrapHome >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncBootstrapHome --content '{{marker-content}}' >/dev/null"], + :cmds + ["python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{marker-content}}\")] ]' --require-result" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :id "sync-bootstrap-upload-download-a-to-b", + :graph "sync-e2e-bootstrap-a-to-b", + :vars {:marker-content "sync-happy-bootstrap-a-to-b-marker"}} + {:tags [:happy-path :incremental :a-to-b], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncIncrementalHome >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncIncrementalHome --content '{{seed-marker}}' >/dev/null"], + :cmds + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncIncrementalHome --content '{{incremental-marker}}' >/dev/null" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1" + "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{incremental-marker}}\")] ]' --require-result" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :id "sync-incremental-update-a-to-b", + :graph "sync-e2e-incremental-a-to-b", + :vars + {:seed-marker "sync-happy-incremental-seed-marker", + :incremental-marker "sync-happy-incremental-update-marker"}} + {:tags [:happy-path :bidirectional :roundtrip], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncRoundtripAHome >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncRoundtripAHome --content '{{marker-a}}' >/dev/null"], + :cmds + ["{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json upsert page --graph {{graph-arg}} --page SyncRoundtripBHome >/dev/null" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json upsert block --graph {{graph-arg}} --target-page SyncRoundtripBHome --content '{{marker-b}}' >/dev/null" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 120 --interval-s 1" + "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{marker-b}}\")] ]' --require-result" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync status --graph {{graph-arg}}"], + :id "sync-bidirectional-roundtrip", + :graph "sync-e2e-bidirectional-roundtrip", + :vars + {:marker-a "sync-happy-roundtrip-a-seed-marker", + :marker-b "sync-happy-roundtrip-b-origin-marker"}} + {:tags [:happy-path :multi-batch :a-to-b], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchHome >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchHome --content '{{seed-marker}}' >/dev/null"], + :cmds + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchOne >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchOne --content '{{batch-marker-1}}' >/dev/null" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchTwo >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchTwo --content '{{batch-marker-2}}' >/dev/null" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchThree >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchThree --content '{{batch-marker-3}}' >/dev/null" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" + "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-1}}\")] ]' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-2}}\")] ]' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-3}}\")] ]' --require-result" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :id "sync-multi-batch-operations", + :graph "sync-e2e-multi-batch-operations", + :vars + {:seed-marker "sync-happy-multi-batch-seed-marker", + :batch-marker-3 "sync-happy-multi-batch-marker-3", + :batch-marker-1 "sync-happy-multi-batch-marker-1", + :batch-marker-2 "sync-happy-multi-batch-marker-2"}} + {:tags [:negative :upload :duplicate], + :extends :sync/base, + :setup + ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}} >/dev/null"], + :cmds + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}}"], + :expect + {:exit 1, + :stdout-json-paths + {[:status] "error", + [:error :code] "graph-already-exists", + [:error :data :graph-name] "{{graph}}"}, + :stdout-contains ["delete it before uploading again"]}, + :id "sync-upload-rejects-duplicate-remote-graph", + :graph "sync-e2e-upload-rejects-duplicate-remote-graph"} + {:tags [:happy-path :steady-state :status], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncSteadyStateHome >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncSteadyStateHome --content '{{marker-content}}' >/dev/null"], + :cmds + ["python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{marker-content}}\")] ]' --require-result" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :id "sync-status-steady-state", + :graph "sync-e2e-status-steady-state", + :vars {:marker-content "sync-happy-steady-state-marker"}} + {:tags [:stress :bidirectional :random :block-ops], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page '{{random-page}}' >/dev/null"], + :cmds + ["python3 '{{repo-root}}/cli-e2e/scripts/random_bidirectional_block_ops.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --page '{{random-page}}' --profile default --rounds-per-client {{rounds-per-client}} --seed {{random-seed}}" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 180 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 180 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" + "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find (pull ?b [:block/uuid :block/title :block/order {:block/parent [:block/uuid]}]) :where [?p :block/title \"{{random-page}}\"] [?b :block/page ?p] [?b :block/uuid]]' --require-result" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :id "sync-random-bidirectional-block-ops", + :graph "sync-e2e-random-bidirectional-block-ops", + :vars + {:random-seed "424242", + :random-page "SyncRandomOpsHome", + :rounds-per-client "40"}} + {:tags [:happy-path :asset-download :a-to-b], + :extends :sync/common, + :setup + ["printf '{{asset-payload}}' > '{{tmp-dir}}/sync-asset-download.txt'" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --path '{{tmp-dir}}/sync-asset-download.txt' --content '{{asset-title}}' --target-page Home >/dev/null"], + :cmds + ["HOME='{{tmp-dir}}/home' python3 '{{repo-root}}/cli-e2e/scripts/sync_asset_download.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --asset-title '{{asset-title}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b'"], + :id "sync-asset-download-a-to-b", + :graph "sync-e2e-asset-download-a-to-b", + :vars + {:asset-title "Sync Asset Download Asset" + :asset-payload "sync-asset-download-payload"}, + :covers + {:commands ["upsert asset" "query" "sync asset download"], + :options {:sync ["--id" "--uuid"]}}} + {:tags [:stress :offline :bidirectional :random :block-ops], + :extends :sync/common, + :setup + ["{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page '{{random-page}}' >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page '{{random-page}}' --content '{{seed-marker}}' >/dev/null"], + :cmds + ["python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{seed-marker}}\")] ]' --require-result" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync stop --graph {{graph-arg}}" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync stop --graph {{graph-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/random_bidirectional_block_ops.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --page '{{random-page}}' --profile high-stress --rounds-per-client {{rounds-per-client}} --seed {{random-seed}}" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 240 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 240 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" + "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --root-dir-a '{{root-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b' --query '[:find (pull ?b [:block/uuid :block/title :block/order {:block/parent [:block/uuid]}]) :where [?p :block/title \"{{random-page}}\"] [?b :block/page ?p] [?b :block/uuid]]' --require-result" + "{{cli-home}} --root-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], + :id "sync-offline-random-bidirectional-block-ops", + :graph "sync-e2e-offline-random-bidirectional-block-ops", + :vars + {:seed-marker "sync-offline-random-seed-marker", + :random-seed "989898", + :random-page "SyncOfflineRandomOpsHome", + :rounds-per-client "40"}}]} diff --git a/cli-e2e/spec/sync_inventory.edn b/cli-e2e/spec/sync_inventory.edn new file mode 100644 index 0000000..d9bf095 --- /dev/null +++ b/cli-e2e/spec/sync_inventory.edn @@ -0,0 +1,14 @@ +{:excluded-command-prefixes ["login" "logout"] + :scopes + {:global + {:options ["--config" + "--graph" + "--root-dir" + "--output"]} + + :sync + {:commands ["sync upload" + "sync download" + "sync asset download" + "sync status"] + :options []}}} diff --git a/cli-e2e/src/logseq/cli/e2e/cleanup.clj b/cli-e2e/src/logseq/cli/e2e/cleanup.clj new file mode 100644 index 0000000..906f69d --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/cleanup.clj @@ -0,0 +1,184 @@ +(ns logseq.cli.e2e.cleanup + (:require [babashka.fs :as fs] + [clojure.java.shell :as java-shell] + [clojure.string :as string])) + +(def ^:private cli-e2e-temp-prefix "logseq-cli-e2e-") +(def ^:private db-sync-default-port 18080) + +(defn- parse-ps-line + [line] + (when-let [[_ pid-str command] (re-matches #"\s*(\d+)\s+(.*)" (or line ""))] + {:pid (Long/parseLong pid-str) + :command command})) + +(defn- cli-e2e-db-worker-command? + [command] + (and (re-find #"db-worker-node(?:\.js)?\b" command) + (re-find #"logseq-cli-e2e-" command))) + +(defn list-cli-e2e-db-worker-pids + ([] + (list-cli-e2e-db-worker-pids {})) + ([{:keys [shell-fn] + :or {shell-fn java-shell/sh}}] + (let [{:keys [exit out err]} (shell-fn "ps" "-ax" "-o" "pid=" "-o" "command=")] + (when-not (zero? exit) + (throw (ex-info "Unable to scan processes" + {:exit exit + :err err}))) + (->> (string/split-lines (or out "")) + (keep parse-ps-line) + (filter (fn [{:keys [command]}] + (cli-e2e-db-worker-command? command))) + (mapv :pid))))) + +(defn- pid-alive? + [pid] + (zero? (:exit (java-shell/sh "kill" "-0" (str pid))))) + +(defn- kill-pid! + [pid] + (try + (if-not (pid-alive? pid) + :not-found + (do + (java-shell/sh "kill" "-TERM" (str pid)) + (Thread/sleep 100) + (if-not (pid-alive? pid) + :killed + (do + (java-shell/sh "kill" "-KILL" (str pid)) + (Thread/sleep 50) + (if (pid-alive? pid) + :failed + :killed))))) + (catch Exception _ + :failed))) + +(defn cleanup-db-worker-processes! + ([] + (cleanup-db-worker-processes! {})) + ([{:keys [dry-run list-pids-fn kill-pid-fn]}] + (let [list-pids-fn (or list-pids-fn list-cli-e2e-db-worker-pids) + kill-pid-fn (or kill-pid-fn kill-pid!) + found-pids (vec (list-pids-fn))] + (if dry-run + {:dry-run? true + :found-pids found-pids + :would-kill-pids found-pids + :killed-pids [] + :failed-pids []} + (let [{:keys [killed-pids failed-pids]} + (reduce (fn [acc pid] + (if (= :failed (kill-pid-fn pid)) + (update acc :failed-pids conj pid) + (update acc :killed-pids conj pid))) + {:killed-pids [] + :failed-pids []} + found-pids)] + {:dry-run? false + :found-pids found-pids + :would-kill-pids [] + :killed-pids killed-pids + :failed-pids failed-pids}))))) + +(defn- parse-long-safe + [value] + (try + (Long/parseLong value) + (catch Exception _ + nil))) + +(defn list-cli-e2e-db-sync-port-pids + ([] + (list-cli-e2e-db-sync-port-pids {})) + ([{:keys [shell-fn port] + :or {shell-fn java-shell/sh + port db-sync-default-port}}] + (let [{:keys [exit out err]} (shell-fn "lsof" "-nP" (str "-iTCP:" port) "-sTCP:LISTEN") + out-lines (->> (string/split-lines (or out "")) + (map string/trim) + (remove string/blank?) + vec)] + (when (and (not (zero? exit)) + (or (not (string/blank? err)) + (seq out-lines))) + (throw (ex-info "Unable to scan db-sync server port listeners" + {:exit exit + :err err + :port port}))) + (->> out-lines + (filter #(re-find (re-pattern (str ":" port "\\b")) %)) + (keep (fn [line] + (some-> (string/split line #"\s+") + (nth 1 nil) + parse-long-safe))) + distinct + vec)))) + +(defn cleanup-db-sync-port-processes! + ([] + (cleanup-db-sync-port-processes! {})) + ([{:keys [dry-run list-pids-fn kill-pid-fn] + :as _opts}] + (let [list-pids-fn (or list-pids-fn list-cli-e2e-db-sync-port-pids) + kill-pid-fn (or kill-pid-fn kill-pid!) + found-pids (vec (list-pids-fn))] + (if dry-run + {:dry-run? true + :found-pids found-pids + :would-kill-pids found-pids + :killed-pids [] + :failed-pids []} + (let [{:keys [killed-pids failed-pids]} + (reduce (fn [acc pid] + (if (= :failed (kill-pid-fn pid)) + (update acc :failed-pids conj pid) + (update acc :killed-pids conj pid))) + {:killed-pids [] + :failed-pids []} + found-pids)] + {:dry-run? false + :found-pids found-pids + :would-kill-pids [] + :killed-pids killed-pids + :failed-pids failed-pids}))))) + +(defn- list-cli-e2e-temp-roots + [tmp-root] + (->> (fs/list-dir tmp-root) + (filter fs/directory?) + (filter #(string/starts-with? (fs/file-name %) cli-e2e-temp-prefix)) + (mapv str))) + +(defn cleanup-temp-roots! + ([] + (cleanup-temp-roots! {})) + ([{:keys [dry-run tmp-root list-dirs-fn delete-dir-fn] + :or {tmp-root (System/getProperty "java.io.tmpdir") + delete-dir-fn fs/delete-tree}}] + (let [list-dirs-fn (or list-dirs-fn + #(list-cli-e2e-temp-roots tmp-root)) + found-dirs (vec (list-dirs-fn))] + (if dry-run + {:dry-run? true + :found-dirs found-dirs + :would-remove-dirs found-dirs + :removed-dirs [] + :failed-dirs []} + (let [{:keys [removed-dirs failed-dirs]} + (reduce (fn [acc dir] + (try + (delete-dir-fn dir) + (update acc :removed-dirs conj dir) + (catch Exception _ + (update acc :failed-dirs conj dir)))) + {:removed-dirs [] + :failed-dirs []} + found-dirs)] + {:dry-run? false + :found-dirs found-dirs + :would-remove-dirs [] + :removed-dirs removed-dirs + :failed-dirs failed-dirs}))))) diff --git a/cli-e2e/src/logseq/cli/e2e/coverage.clj b/cli-e2e/src/logseq/cli/e2e/coverage.clj new file mode 100644 index 0000000..2b5e13f --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/coverage.clj @@ -0,0 +1,120 @@ +(ns logseq.cli.e2e.coverage + (:require [clojure.set :as set] + [clojure.string :as string])) + +(defn- normalize-commands + [commands] + (->> commands + (map str) + (remove string/blank?) + distinct + sort + vec)) + +(defn- normalize-options + [options] + (->> options + (map str) + (remove string/blank?) + distinct + sort + vec)) + +(defn- excluded-command? + [excluded-prefixes command] + (let [head (first (string/split (str command) #" "))] + (contains? (set excluded-prefixes) head))) + +(defn validate-inventory! + [{:keys [excluded-command-prefixes scopes] :as inventory}] + (let [invalid-commands (->> scopes + vals + (mapcat :commands) + (filter #(excluded-command? excluded-command-prefixes %)) + normalize-commands) + invalid-scopes (->> scopes + (keep (fn [[scope {:keys [commands]}]] + (when (some #(excluded-command? excluded-command-prefixes %) commands) + scope))) + sort + vec)] + (when (seq invalid-commands) + (throw (ex-info "Excluded commands cannot appear in cli-e2e inventory" + {:invalid-commands invalid-commands + :invalid-scopes invalid-scopes + :inventory inventory}))) + inventory)) + +(defn validate-cases! + [{:keys [excluded-command-prefixes] :as inventory} cases] + (let [invalid-cases (->> cases + (filter (fn [{:keys [covers]}] + (some #(excluded-command? excluded-command-prefixes %) + (get covers :commands [])))) + (mapv :id))] + (when (seq invalid-cases) + (throw (ex-info "Excluded commands cannot be covered by cli-e2e cases" + {:invalid-case-ids invalid-cases + :inventory inventory}))) + cases)) + +(defn command->scope + [{:keys [scopes]}] + (into {} + (mapcat (fn [[scope {:keys [commands]}]] + (map (fn [command] + [command scope]) + commands))) + scopes)) + +(defn required-commands + [{:keys [scopes]}] + (->> scopes + vals + (mapcat :commands) + normalize-commands)) + +(defn covered-commands + [cases] + (->> cases + (mapcat #(get-in % [:covers :commands])) + normalize-commands)) + +(defn required-options-by-scope + [{:keys [scopes]}] + (into {} + (map (fn [[scope {:keys [options]}]] + [scope (normalize-options options)])) + scopes)) + +(defn covered-options-by-scope + [cases] + (reduce (fn [acc {:keys [covers]}] + (reduce-kv (fn [acc' scope options] + (update acc' scope (fnil into #{}) options)) + acc + (get covers :options {}))) + {} + cases)) + +(defn coverage-report + [inventory cases] + (validate-inventory! inventory) + (validate-cases! inventory cases) + (let [required-commands* (set (required-commands inventory)) + covered-commands* (set (covered-commands cases)) + covered-options* (covered-options-by-scope cases) + required-options* (required-options-by-scope inventory)] + {:missing-commands (->> (set/difference required-commands* covered-commands*) + normalize-commands) + :missing-options (into {} + (map (fn [[scope options]] + [scope (->> (set/difference (set options) + (get covered-options* scope #{})) + normalize-options)])) + required-options*)})) + +(defn complete? + [{:keys [missing-commands missing-options]}] + (and (empty? missing-commands) + (every? empty? (vals missing-options)))) diff --git a/cli-e2e/src/logseq/cli/e2e/main.clj b/cli-e2e/src/logseq/cli/e2e/main.clj new file mode 100644 index 0000000..f4c438d --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/main.clj @@ -0,0 +1,468 @@ +(ns logseq.cli.e2e.main + (:require [clojure.set :as set] + [logseq.cli.e2e.cleanup :as cleanup] + [logseq.cli.e2e.coverage :as coverage] + [logseq.cli.e2e.manifests :as manifests] + [logseq.cli.e2e.preflight :as preflight] + [logseq.cli.e2e.report :as report] + [logseq.cli.e2e.runner :as runner] + [logseq.cli.e2e.shell :as shell] + [logseq.cli.e2e.sync-fixture :as sync-fixture]) + (:import (java.util.concurrent Executors LinkedBlockingQueue TimeUnit))) + +(defn select-cases + [cases {:keys [case include]}] + (let [cases (remove :skip cases)] + (cond + case + (filterv #(= case (:id %)) cases) + + (seq include) + (let [include-tags (set (map keyword include))] + (filterv (fn [{:keys [tags]}] + (not-empty (set/intersection include-tags (set tags)))) + cases)) + + :else + (vec cases)))) + +(def default-suite :non-sync) +(def default-jobs 1) +(def default-cli-jobs 4) + +(defn- suite-from-opts + [opts] + (or (:suite opts) default-suite)) + +(defn- elapsed-ms + [started-at] + (long (/ (- (System/nanoTime) started-at) 1000000))) + +(defn- format-duration + [started-at] + (format "%.2fs" (/ (double (- (System/nanoTime) started-at)) 1000000000.0))) + +(defn- positive-jobs + [jobs] + (let [jobs (or jobs default-jobs)] + (when-not (and (integer? jobs) (pos? jobs)) + (throw (ex-info "--jobs must be a positive integer" + {:jobs jobs}))) + jobs)) + +(defn run-selected-cases! + [selected-cases run-case run-command {:keys [on-case-start on-case-success on-case-failure detailed-log? timings? jobs]}] + (let [total (count selected-cases) + _ (positive-jobs jobs)] + (reduce (fn [acc [idx case]] + (let [index (inc idx) + started-at (System/nanoTime)] + (when on-case-start + (on-case-start {:index index + :total total + :case case})) + (try + (let [result (run-case case {:run-command run-command + :detailed-log? detailed-log? + :timings? timings?}) + payload {:index index + :total total + :case case + :result result + :elapsed-ms (elapsed-ms started-at)}] + (when on-case-success + (on-case-success payload)) + (conj acc result)) + (catch Exception error + (when on-case-failure + (on-case-failure {:index index + :total total + :case case + :error error + :elapsed-ms (elapsed-ms started-at)})) + (throw error))))) + [] + (map-indexed vector selected-cases)))) + +(defn run-selected-cases-in-parallel! + [selected-cases run-case run-command {:keys [on-case-start on-case-success on-case-failure detailed-log? timings? jobs]}] + (let [total (count selected-cases) + jobs (positive-jobs jobs) + executor (Executors/newFixedThreadPool jobs) + completions (LinkedBlockingQueue.)] + (try + (doseq [[idx case] (map-indexed vector selected-cases)] + (let [index (inc idx)] + (when on-case-start + (on-case-start {:index index + :total total + :case case})) + (.submit executor + ^Runnable + (fn [] + (let [started-at (System/nanoTime)] + (.put completions + (try + (let [result (run-case case {:run-command run-command + :detailed-log? detailed-log? + :timings? timings?})] + {:index index + :total total + :case case + :result result + :elapsed-ms (elapsed-ms started-at)}) + (catch Exception error + {:index index + :total total + :case case + :error error + :elapsed-ms (elapsed-ms started-at)})))))))) + (loop [remaining total + results [] + failure nil] + (if (zero? remaining) + (do + (when failure + (throw failure)) + (->> results + (sort-by :index) + (mapv :result))) + (let [payload (.take completions)] + (if-let [error (:error payload)] + (do + (when on-case-failure + (on-case-failure payload)) + (recur (dec remaining) results (or failure error))) + (do + (when on-case-success + (on-case-success payload)) + (recur (dec remaining) (conj results payload) failure)))))) + (finally + (.shutdown executor) + (.awaitTermination executor 1 TimeUnit/MINUTES))))) + +(defn run! + [{:keys [inventory cases skip-build run-command jobs] + :as opts}] + (let [run-command (or run-command shell/run!) + run-case (or (:run-case opts) runner/run-case!) + suite (suite-from-opts opts) + sync-suite? (= suite :sync) + jobs (positive-jobs jobs) + targeted-run? (or (:case opts) (seq (:include opts))) + on-preflight-start (:on-preflight-start opts) + on-preflight-complete (:on-preflight-complete opts) + on-cases-ready (:on-cases-ready opts)] + (when on-preflight-start + (on-preflight-start {:skip-build skip-build})) + (let [preflight-result (preflight/run! {:skip-build skip-build + :run-command run-command}) + inventory (or inventory (manifests/load-inventory suite)) + cases (or cases (manifests/load-cases suite))] + (when on-preflight-complete + (on-preflight-complete preflight-result)) + (let [selected-cases (select-cases cases opts) + coverage-result (when-not targeted-run? + (coverage/coverage-report inventory selected-cases))] + (when (and coverage-result + (not (coverage/complete? coverage-result))) + (throw (ex-info "Missing coverage" + {:coverage coverage-result + :message (report/format-missing-coverage coverage-result)}))) + (when on-cases-ready + (on-cases-ready {:total (count selected-cases) + :targeted-run? targeted-run?})) + (let [suite-context (when sync-suite? + (sync-fixture/before-suite! {:run-command run-command})) + sync-context (if suite-context + (assoc suite-context :e2ee-password (:e2ee-password opts)) + suite-context) + run-case* (if sync-suite? + (fn [case case-opts] + (run-case (sync-fixture/prepare-case case sync-context) + case-opts)) + run-case)] + (try + {:status :ok + :cases selected-cases + :coverage coverage-result + :results ((if (> jobs 1) + run-selected-cases-in-parallel! + run-selected-cases!) + selected-cases + run-case* + run-command + (assoc opts :jobs jobs))} + (finally + (when suite-context + (sync-fixture/after-suite! suite-context {:run-command run-command}))))))))) +(defn build! + [opts] + (preflight/run! opts)) + +(defn list-cases! + [opts] + (doseq [{:keys [id]} (manifests/load-cases (suite-from-opts opts))] + (println id))) + +(defn list-sync-cases! + [opts] + (list-cases! (assoc opts :suite :sync))) + +(defn- print-cleanup-help! + [] + (println "Usage: bb -f cli-e2e/bb.edn cleanup [options]") + (println) + (println "Options:") + (println " -h, --help Show this help and exit") + (println " --dry-run Scan and report only; do not kill/delete") + (println) + (println "Cleanups performed:") + (println " - Terminate cli-e2e db-worker-node processes") + (println " - Terminate db-sync server listeners on port 18080") + (println " - Remove cli-e2e temp roots") + (flush)) + +(defn cleanup! + [opts] + (if (:help opts) + (do + (print-cleanup-help!) + {:status :help}) + (let [dry-run? (boolean (:dry-run opts)) + cleanup-opts (cond-> {} + dry-run? (assoc :dry-run true)) + processes (cleanup/cleanup-db-worker-processes! cleanup-opts) + db-sync-port-processes (cleanup/cleanup-db-sync-port-processes! cleanup-opts) + temp-roots (cleanup/cleanup-temp-roots! cleanup-opts)] + (println "==> Running cli-e2e cleanup") + (if dry-run? + (do + (println (format "[dry-run] db-worker-node processes: found %d, would kill %d" + (count (:found-pids processes)) + (count (:would-kill-pids processes)))) + (println (format "[dry-run] db-sync server processes (port 18080): found %d, would kill %d" + (count (:found-pids db-sync-port-processes)) + (count (:would-kill-pids db-sync-port-processes)))) + (println (format "[dry-run] temp roots: found %d, would remove %d" + (count (:found-dirs temp-roots)) + (count (:would-remove-dirs temp-roots))))) + (do + (println (format "db-worker-node processes: found %d, killed %d, failed %d" + (count (:found-pids processes)) + (count (:killed-pids processes)) + (count (:failed-pids processes)))) + (println (format "db-sync server processes (port 18080): found %d, killed %d, failed %d" + (count (:found-pids db-sync-port-processes)) + (count (:killed-pids db-sync-port-processes)) + (count (:failed-pids db-sync-port-processes)))) + (println (format "temp roots: found %d, removed %d, failed %d" + (count (:found-dirs temp-roots)) + (count (:removed-dirs temp-roots)) + (count (:failed-dirs temp-roots)))))) + (flush) + {:status :ok + :dry-run? dry-run? + :processes processes + :db-sync-port-processes db-sync-port-processes + :temp-roots temp-roots}))) + +(defn- print-failure-details! + [error] + (let [data (ex-data error)] + (println (str " reason: " (.getMessage error))) + (when-let [cmd (:cmd data)] + (println (str " cmd: " cmd))) + (when-let [stream (:stream data)] + (println (str " stream: " stream))) + (when-let [snippet (:snippet data)] + (println (str " snippet: " snippet))) + (flush))) + +(defn- format-step-label + [{:keys [phase step-index step-total]}] + (let [phase-name (name (or phase :command))] + (format "%s %d/%d" phase-name (or step-index 1) (or step-total 1)))) + +(defn- print-case-timings! + [timings] + (when (seq timings) + (println " step timings:") + (doseq [{:keys [elapsed-ms status cmd] :as timing} timings] + (println (format " - [%-12s] %6dms (%s) $ %s" + (format-step-label timing) + elapsed-ms + (name (or status :ok)) + cmd))) + (flush))) + +(defn- print-slow-steps! + [all-step-timings] + (when (seq all-step-timings) + (println "Slow steps (top 10):") + (doseq [{:keys [case-id elapsed-ms cmd] :as timing} + (->> all-step-timings + (sort-by :elapsed-ms >) + (take 10))] + (println (format " - %-45s [%-12s] %6dms $ %s" + case-id + (format-step-label timing) + elapsed-ms + cmd))) + (flush))) + +(defn- progress-prefix + [{:keys [parallel? index total]} symbol] + (if parallel? + (str symbol " ") + (format "[%d/%d] %s " index total symbol))) + +(defn- print-test-help! + [command-name suite] + (let [sync-suite? (= suite :sync)] + (println (str "Usage: bb -f cli-e2e/bb.edn " command-name " [options]")) + (println) + (println "Options:") + (println " -h, --help Show this help and exit") + (println " --skip-build Skip build preflight steps") + (println " -i, --include TAG Run only cases with matching tag (repeatable)") + (println " --case ID Run a single case by id") + (println (format " --jobs N %s (Default: %d)" + (if sync-suite? + "Run up to N sync cases in parallel" + "Run up to N non-sync cases in parallel") + default-cli-jobs)) + (when sync-suite? + (println " --e2ee-password VALUE E2EE password for sync commands (Default: 11111)")) + (println " --verbose Enable verbose output") + (println " --timings Print per-step timings and slow-step summary") + (println) + (println "Examples:") + (if sync-suite? + (println (str " bb -f cli-e2e/bb.edn " command-name)) + (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build"))) + (println (str " bb -f cli-e2e/bb.edn " command-name + (if sync-suite? + " --jobs 4" + " --skip-build --jobs 4"))) + (println (str " bb -f cli-e2e/bb.edn " command-name " -i smoke")) + (if sync-suite? + (println (str " bb -f cli-e2e/bb.edn " command-name " --case sync-upload-download-mvp")) + (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --case global-help"))) + (when sync-suite? + (println (str " bb -f cli-e2e/bb.edn " command-name " --e2ee-password 'my-secret'"))) + (flush))) + +(defn- test-suite! + [opts {:keys [suite command-name] + :or {suite default-suite + command-name "test"}}] + (let [suite (or suite default-suite) + opts (assoc opts :suite suite)] + (if (:help opts) + (do + (print-test-help! command-name suite) + {:status :help}) + (let [started-at (System/nanoTime) + passed (atom 0) + failed (atom 0) + total-count (atom 0) + timings? (boolean (:timings opts)) + all-step-timings (atom []) + detailed-case-log? (some? (:case opts)) + parallel? (> (positive-jobs (:jobs opts)) 1) + base-run-command (or (:run-command opts) shell/run!) + run-command (if detailed-case-log? + (fn [{:keys [cmd phase step-index step-total] :as command-opts}] + (let [prefix (case phase + :setup (format " [setup %d/%d]" step-index step-total) + :main (format " [main %d/%d]" (or step-index 1) (or step-total 1)) + :cleanup (format " [cleanup %d/%d]" step-index step-total) + " [command]")] + (println (str prefix " $ " cmd)) + (flush) + (base-run-command (assoc command-opts :stream-output? true)))) + base-run-command)] + (println "==> Running cli-e2e cases") + (when detailed-case-log? + (println (format "==> Detailed case logging enabled (--case %s)" (:case opts)))) + (when timings? + (println "==> Step timing enabled (--timings)")) + (flush) + (try + (run! (assoc opts + :run-command run-command + :detailed-log? detailed-case-log? + :timings? timings? + :on-preflight-start (fn [_] + (println "==> Build preflight: running...") + (flush)) + :on-preflight-complete (fn [{:keys [status]}] + (println (case status + :skipped "==> Build preflight: skipped (--skip-build)" + "==> Build preflight: completed")) + (flush)) + :on-cases-ready (fn [{:keys [total]}] + (reset! total-count total) + (println (format "==> Prepared %d case(s), starting execution" total)) + (flush)) + :on-case-start (fn [{:keys [index total case]}] + (println (str (progress-prefix {:parallel? parallel? + :index index + :total total} + "▶") + (:id case))) + (flush)) + :on-case-success (fn [{:keys [index total result elapsed-ms]}] + (swap! passed inc) + (println (format "%s%s (%dms)" + (progress-prefix {:parallel? parallel? + :index index + :total total} + "✓") + (:id result) + elapsed-ms)) + (when timings? + (let [case-timings (vec (:timings result))] + (swap! all-step-timings into + (map #(assoc % :case-id (:id result)) case-timings)) + (print-case-timings! case-timings))) + (flush)) + :on-case-failure (fn [{:keys [index total case error elapsed-ms]}] + (swap! failed inc) + (println (format "%s%s (%dms)" + (progress-prefix {:parallel? parallel? + :index index + :total total} + "✗") + (:id case) + elapsed-ms)) + (print-failure-details! error) + (when timings? + (let [case-timings (vec (:timings (ex-data error)))] + (swap! all-step-timings into + (map #(assoc % :case-id (:id case)) case-timings)) + (print-case-timings! case-timings)))))) + (println (format "Summary: %d passed, %d failed" @passed @failed)) + (println (str "Selected cases: " @total-count)) + (println (str "Duration: " (format-duration started-at))) + (when timings? + (print-slow-steps! @all-step-timings)) + (catch Exception error + (let [failed-count (max 1 @failed)] + (println (format "Summary: %d passed, %d failed" @passed failed-count)) + (println (str "Selected cases: " (max @total-count failed-count))) + (println (str "Duration: " (format-duration started-at))) + (when timings? + (print-slow-steps! @all-step-timings))) + (throw error))))))) + +(defn test! + [opts] + (test-suite! opts {:suite :non-sync + :command-name "test"})) + +(defn test-sync! + [opts] + (test-suite! opts {:suite :sync + :command-name "test-sync"})) diff --git a/cli-e2e/src/logseq/cli/e2e/manifests.clj b/cli-e2e/src/logseq/cli/e2e/manifests.clj new file mode 100644 index 0000000..3032052 --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/manifests.clj @@ -0,0 +1,237 @@ +(ns logseq.cli.e2e.manifests + (:require [clojure.edn :as edn] + [logseq.cli.e2e.paths :as paths])) + +(def suite->manifest-files + {:non-sync {:inventory "non_sync_inventory.edn" + :cases "non_sync_cases.edn"} + :sync {:inventory "sync_inventory.edn" + :cases "sync_cases.edn"}}) + +(def default-suite :non-sync) + +(def ^:private append-merge-keys + #{:setup :cmds :cleanup :tags}) + +(def ^:private deep-merge-keys + #{:vars :covers :expect}) + +(defn read-edn-file + [path] + (edn/read-string (slurp path))) + +(defn- normalize-suite + [suite] + (let [suite' (cond + (nil? suite) default-suite + (keyword? suite) suite + (string? suite) (keyword suite) + :else suite)] + (when-not (contains? suite->manifest-files suite') + (throw (ex-info "Unknown cli-e2e suite" + {:suite suite + :known-suites (sort (keys suite->manifest-files))}))) + suite')) + +(defn- manifest-file + [suite kind] + (get-in suite->manifest-files [(normalize-suite suite) kind])) + +(defn load-inventory + ([] + (load-inventory nil)) + ([suite] + (read-edn-file (paths/spec-path (manifest-file suite :inventory))))) + +(defn- normalize-extends + [extends] + (let [extends' (cond + (nil? extends) [] + (keyword? extends) [extends] + (vector? extends) extends + :else + (throw (ex-info "Invalid :extends value in cli-e2e manifest" + {:extends extends + :expected "keyword | vector | nil"}))) + invalid-entries (remove keyword? extends')] + (when (seq invalid-entries) + (throw (ex-info "Invalid :extends entries in cli-e2e manifest" + {:extends extends + :invalid-entries (vec invalid-entries) + :expected "keyword | vector | nil"}))) + extends')) + +(defn- parse-manifest + [manifest-data] + (if-not (map? manifest-data) + (throw (ex-info "Invalid cli-e2e manifest format" + {:manifest-type (type manifest-data) + :expected "{:templates {...} :cases [...]}"})) + (let [templates (or (:templates manifest-data) {}) + cases (:cases manifest-data)] + (when-not (map? templates) + (throw (ex-info "Invalid cli-e2e manifest :templates format" + {:templates templates + :expected "map"}))) + (when-not (vector? cases) + (throw (ex-info "Invalid cli-e2e manifest :cases format" + {:cases cases + :expected "vector"}))) + (doseq [case cases] + (when-not (map? case) + (throw (ex-info "Invalid cli-e2e case format" + {:case case + :expected "map"})))) + {:templates templates + :cases cases}))) + +(defn- reachable-template-ids + [templates roots] + (loop [stack (vec roots) + visited #{}] + (if-let [template-id (peek stack)] + (if (contains? visited template-id) + (recur (pop stack) visited) + (let [template (get templates template-id) + parent-ids (if template + (normalize-extends (:extends template)) + [])] + (recur (into (pop stack) parent-ids) + (conj visited template-id)))) + visited))) + +(defn- lint-manifest! + [{:keys [templates cases]}] + (let [template-ids (set (keys templates)) + template-refs (mapcat (fn [[template-id template]] + (map (fn [target] + {:type :invalid-extends + :source-type :template + :source template-id + :target target}) + (normalize-extends (:extends template)))) + templates) + case-refs (mapcat (fn [[index case]] + (map (fn [target] + {:type :invalid-extends + :source-type :case + :source (or (:id case) + (str "case#" (inc index))) + :target target}) + (normalize-extends (:extends case)))) + (map-indexed vector cases)) + invalid-extends-issues (->> (concat template-refs case-refs) + (filter (fn [{:keys [target]}] + (not (contains? template-ids target))))) + duplicate-id-issues (->> cases + (keep :id) + frequencies + (filter (fn [[_ count]] (> count 1))) + (sort-by first) + (map (fn [[case-id count]] + {:type :duplicate-case-id + :id case-id + :count count}))) + roots (->> cases + (mapcat #(normalize-extends (:extends %))) + distinct) + used-template-ids (reachable-template-ids templates roots) + unused-template-issues (->> (keys templates) + (remove used-template-ids) + sort + (map (fn [template-id] + {:type :unused-template + :template template-id}))) + issues (vec (concat invalid-extends-issues + duplicate-id-issues + unused-template-issues))] + (when (seq issues) + (throw (ex-info "cli-e2e manifest lint failed" + {:issues issues}))))) + +(defn- as-seq + [value] + (cond + (nil? value) [] + (sequential? value) value + :else [value])) + +(defn- deep-merge-maps + [left right] + (merge-with (fn [left-val right-val] + (if (and (map? left-val) + (map? right-val)) + (deep-merge-maps left-val right-val) + right-val)) + (or left {}) + (or right {}))) + +(defn- merge-entry + [parent child] + (let [all-keys (set (concat (keys parent) (keys child)))] + (reduce (fn [acc key] + (let [parent-val (get parent key) + child-val (get child key)] + (assoc acc + key + (cond + (contains? append-merge-keys key) + (if (contains? child key) + (vec (concat (as-seq parent-val) + (as-seq child-val))) + (vec (as-seq parent-val))) + + (contains? deep-merge-keys key) + (if (contains? child key) + (deep-merge-maps parent-val child-val) + parent-val) + + (contains? child key) + child-val + + :else + parent-val)))) + {} + all-keys))) + +(defn- resolve-template + [templates template-id stack] + (when (some #{template-id} stack) + (throw (ex-info "Circular template inheritance detected in cli-e2e manifest" + {:template template-id + :cycle (conj (vec stack) template-id)}))) + (let [template (get templates template-id)] + (when-not template + (throw (ex-info "Unknown template in cli-e2e manifest" + {:template template-id + :known-templates (sort (keys templates))}))) + (let [parent-ids (normalize-extends (:extends template)) + parent-values (map #(resolve-template templates % (conj stack template-id)) + parent-ids)] + (reduce merge-entry + {} + (concat parent-values + [(dissoc template :extends)]))))) + +(defn- expand-manifest-cases + [{:keys [templates cases]}] + (mapv (fn [case] + (let [parent-ids (normalize-extends (:extends case)) + parent-values (map #(resolve-template templates % []) + parent-ids)] + (reduce merge-entry + {} + (concat parent-values + [(dissoc case :extends)])))) + cases)) + +(defn load-cases + ([] + (load-cases nil)) + ([suite] + (let [manifest-data (-> (manifest-file suite :cases) + paths/spec-path + read-edn-file + parse-manifest)] + (lint-manifest! manifest-data) + (expand-manifest-cases manifest-data)))) diff --git a/cli-e2e/src/logseq/cli/e2e/paths.clj b/cli-e2e/src/logseq/cli/e2e/paths.clj new file mode 100644 index 0000000..b48a217 --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/paths.clj @@ -0,0 +1,49 @@ +(ns logseq.cli.e2e.paths + (:require [babashka.fs :as fs])) + +(defn- ancestors + [path] + (take-while some? (iterate fs/parent path))) + +(defn- find-parent-named + [path name] + (some (fn [candidate] + (when (= name (fs/file-name candidate)) + candidate)) + (ancestors (fs/canonicalize path)))) + +(def ^:private cli-e2e-root-path + (or (some-> *file* + (find-parent-named "cli-e2e") + str) + (throw (ex-info "Unable to locate cli-e2e root" + {:file *file*})))) + +(defn cli-e2e-root + [] + cli-e2e-root-path) + +(defn repo-root + [] + (str (fs/parent cli-e2e-root-path))) + +(defn repo-path + [& segments] + (str (apply fs/path (repo-root) segments))) + +(defn cli-e2e-path + [& segments] + (str (apply fs/path (cli-e2e-root) segments))) + +(defn spec-path + [filename] + (cli-e2e-path "spec" filename)) + +(defn required-artifacts + [] + [(repo-path "cli" "_build" "default" "dist" "logseq-cli.js") + (repo-path "static" "logseq-cli.js") + (repo-path "static" "db-worker-node.js") + (repo-path "dist" "db-worker-node.js") + (repo-path "dist" "db-worker-node-assets.json") + (repo-path "deps" "db-sync" "worker" "dist" "node-adapter.js")]) diff --git a/cli-e2e/src/logseq/cli/e2e/preflight.clj b/cli-e2e/src/logseq/cli/e2e/preflight.clj new file mode 100644 index 0000000..872ee94 --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/preflight.clj @@ -0,0 +1,37 @@ +(ns logseq.cli.e2e.preflight + (:require [babashka.fs :as fs] + [logseq.cli.e2e.paths :as paths] + [logseq.cli.e2e.shell :as shell])) + +(def build-plan + [{:cmd "pnpm cli:release"} + {:cmd "pnpm db-worker-node:compile:bundle"} + {:cmd "pnpm --dir deps/db-sync build:node-adapter"}]) + +(defn missing-artifacts + ([] + (missing-artifacts (paths/required-artifacts) fs/exists?)) + ([artifacts file-exists?] + (->> artifacts + (remove file-exists?) + vec))) + +(defn run! + [{:keys [skip-build run-command file-exists?] + :or {run-command shell/run! + file-exists? fs/exists?}}] + (if skip-build + {:status :skipped + :commands [] + :missing-artifacts []} + (let [artifacts (paths/required-artifacts)] + (doseq [{:keys [cmd]} build-plan] + (run-command {:cmd cmd + :dir (paths/repo-root)})) + (let [missing-after-build (missing-artifacts artifacts file-exists?)] + (when (seq missing-after-build) + (throw (ex-info "Build preflight completed but required artifacts are missing" + {:missing-artifacts missing-after-build}))) + {:status :ok + :commands build-plan + :missing-artifacts []})))) diff --git a/cli-e2e/src/logseq/cli/e2e/report.clj b/cli-e2e/src/logseq/cli/e2e/report.clj new file mode 100644 index 0000000..33c217e --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/report.clj @@ -0,0 +1,18 @@ +(ns logseq.cli.e2e.report + (:require [clojure.string :as string])) + +(defn format-missing-coverage + [{:keys [missing-commands missing-options]}] + (str + "Missing coverage\n" + (when (seq missing-commands) + (str "Commands:\n" + (string/join "\n" (map #(str "- " %) missing-commands)) + "\n")) + (string/join + "\n" + (keep (fn [[scope options]] + (when (seq options) + (str (name scope) " options:\n" + (string/join "\n" (map #(str "- " %) options))))) + missing-options)))) diff --git a/cli-e2e/src/logseq/cli/e2e/runner.clj b/cli-e2e/src/logseq/cli/e2e/runner.clj new file mode 100644 index 0000000..42c619b --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/runner.clj @@ -0,0 +1,280 @@ +(ns logseq.cli.e2e.runner + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.edn :as edn] + [clojure.string :as string] + [logseq.cli.e2e.paths :as paths] + [logseq.cli.e2e.shell :as shell])) + +(defn shell-escape + [value] + (let [text (str value)] + (if (re-find #"[^\w@%+=:,./-]" text) + (str "'" (string/replace text #"'" "'\"'\"'") "'") + text))) + +(def template-pattern #"\{\{([^}]+)\}\}") +(def ^:private e2e-env {"CLI_E2E_TEST" "1" + "NO_COLOR" "1"}) + +(defn- render-string + [template context] + (string/replace template + template-pattern + (fn [[_ key]] + (let [lookup-key (keyword (string/trim key))] + (when-not (contains? context lookup-key) + (throw (ex-info "Missing case template value" + {:template template + :key lookup-key + :context context}))) + (str (get context lookup-key)))))) + +(defn- render-value + [value context] + (cond + (string? value) (render-string value context) + (map? value) (into {} + (map (fn [[k v]] + [k (render-value v context)])) + value) + (vector? value) (mapv #(render-value % context) value) + (seq? value) (doall (map #(render-value % context) value)) + :else value)) + +(defn render-case + [case context] + (render-value case context)) + +(defn- ensure-contains! + [id cmd output snippets stream] + (doseq [snippet (or snippets [])] + (let [normalize-pathish-text (fn [text] + (-> (str text) + (string/replace "\\\\" "\\") + (string/replace "\\" "/") + (string/replace "\r" ""))) + matches? (or (string/includes? output snippet) + (string/includes? (normalize-pathish-text output) + (normalize-pathish-text snippet)))] + (when-not matches? + (throw (ex-info (str stream " did not contain expected text") + {:id id + :cmd cmd + :snippet snippet + :output output + :stream stream})))))) + +(defn- ensure-not-contains! + [id cmd output snippets stream] + (doseq [snippet (or snippets [])] + (when (string/includes? output snippet) + (throw (ex-info (str stream " contained forbidden text") + {:id id + :cmd cmd + :snippet snippet + :output output + :stream stream}))))) + +(defn- ensure-equals! + [id cmd actual expected label] + (when (and (some? expected) + (not= expected actual)) + (throw (ex-info (str label " did not match") + {:id id + :cmd cmd + :expected expected + :actual actual})))) + +(defn- ensure-json-paths! + [id cmd output expected-paths] + (when expected-paths + (let [payload (json/parse-string output true)] + (doseq [[path expected] expected-paths] + (let [actual (get-in payload path)] + (when-not (= expected actual) + (throw (ex-info "stdout JSON path did not match" + {:id id + :cmd cmd + :path path + :expected expected + :actual actual + :payload payload})))))))) + +(defn- ensure-edn-paths! + [id cmd output expected-paths] + (when expected-paths + (let [payload (edn/read-string output)] + (doseq [[path expected] expected-paths] + (let [actual (get-in payload path)] + (when-not (= expected actual) + (throw (ex-info "stdout EDN path did not match" + {:id id + :cmd cmd + :path path + :expected expected + :actual actual + :payload payload})))))))) + +(defn assert-result! + [{:keys [id expect]} {:keys [cmd exit out err] :as result}] + (when-let [expected-exit (:exit expect)] + (when-not (= expected-exit exit) + (throw (ex-info "Unexpected exit code" + {:id id + :cmd cmd + :expected expected-exit + :actual exit + :result result})))) + (ensure-equals! id cmd out (:stdout-equals expect) "stdout") + (ensure-equals! id cmd err (:stderr-equals expect) "stderr") + (ensure-contains! id cmd out (:stdout-contains expect) "stdout") + (ensure-contains! id cmd err (:stderr-contains expect) "stderr") + (ensure-not-contains! id cmd out (:stdout-not-contains expect) "stdout") + (ensure-not-contains! id cmd err (:stderr-not-contains expect) "stderr") + (ensure-json-paths! id cmd out (:stdout-json-paths expect)) + (ensure-edn-paths! id cmd out (:stdout-edn-paths expect)) + nil) + +(defn- default-context + [case context] + (let [tmp-dir (or (:tmp-dir context) + (str (fs/create-temp-dir {:prefix (str "logseq-cli-e2e-" (:id case) "-")}))) + root-dir (or (:root-dir context) + tmp-dir) + config-path (or (:config-path context) + (str (fs/path root-dir "cli.edn"))) + export-path (or (:export-path context) + (str (fs/path tmp-dir "graph-export.edn"))) + graph (or (:graph context) + (:graph case) + (str "cli-e2e-" (:id case)))] + (fs/create-dirs (fs/path root-dir "graphs")) + (spit config-path "{:output-format :json}\n") + {:tmp-dir tmp-dir + :tmp-dir-arg (shell-escape tmp-dir) + :root-dir root-dir + :root-dir-arg (shell-escape root-dir) + :config-path config-path + :config-path-arg (shell-escape config-path) + :export-path export-path + :export-path-arg (shell-escape export-path) + :repo-root (paths/repo-root) + :repo-root-arg (shell-escape (paths/repo-root)) + :cli (str "node " (shell-escape (paths/repo-path "static" "logseq-cli.js"))) + :graph graph + :graph-arg (shell-escape graph)})) + +(defn- case-context + [case {:keys [context]}] + (merge (default-context case context) + context + (:vars case))) + +(defn- run-command! + [command context {:keys [run-command stdin allow-failure phase step-index step-total case-id]}] + (run-command {:cmd (render-string command context) + :dir (paths/repo-root) + :env e2e-env + :stdin (some-> stdin (render-string context)) + :phase phase + :step-index step-index + :step-total step-total + :case-id case-id + :throw? (not allow-failure)})) + +(defn- elapsed-ms + [started-at] + (long (/ (- (System/nanoTime) started-at) 1000000))) + +(defn- run-step! + [timings command context {:keys [timings? phase step-index step-total] + :as run-opts}] + (if-not timings? + (run-command! command context run-opts) + (let [started-at (System/nanoTime)] + (try + (let [result (run-command! command context run-opts)] + (swap! timings conj {:phase phase + :step-index step-index + :step-total step-total + :cmd (:cmd result) + :elapsed-ms (elapsed-ms started-at) + :status :ok}) + result) + (catch Exception error + (swap! timings conj {:phase phase + :step-index step-index + :step-total step-total + :cmd (or (:cmd (ex-data error)) + (render-string command context)) + :elapsed-ms (elapsed-ms started-at) + :status :failed}) + (throw error)))))) + +(defn run-case! + [case {:keys [run-command detailed-log? timings?] + :or {run-command shell/run!} + :as opts}] + (let [context (case-context case opts) + rendered (render-case case context) + case-id (:id rendered) + timings? (boolean timings?) + timings (atom []) + cleanup-commands (vec (:cleanup rendered)) + setup-commands (vec (:setup rendered)) + main-commands (vec (:cmds rendered)) + cleanup! (fn [] + (doseq [[idx command] (map-indexed vector cleanup-commands)] + (try + (run-step! timings command context {:run-command run-command + :timings? timings? + :allow-failure true + :phase (when (or detailed-log? timings?) :cleanup) + :step-index (inc idx) + :step-total (count cleanup-commands) + :case-id case-id}) + (catch Exception _ + nil))))] + (try + (doseq [[idx command] (map-indexed vector setup-commands)] + (run-step! timings command context {:run-command run-command + :timings? timings? + :phase (when (or detailed-log? timings?) :setup) + :step-index (inc idx) + :step-total (count setup-commands) + :case-id case-id})) + (let [main-total (count main-commands) + _ (when (zero? main-total) + (throw (ex-info "Missing case commands" + {:id case-id + :case rendered}))) + result (reduce (fn [_ [idx command]] + (let [last-step? (= idx (dec main-total))] + (run-step! timings command context {:run-command run-command + :timings? timings? + :stdin (when last-step? (:stdin rendered)) + :allow-failure last-step? + :phase (when (or detailed-log? timings?) :main) + :step-index (inc idx) + :step-total main-total + :case-id case-id}))) + nil + (map-indexed vector main-commands))] + (assert-result! rendered result) + (cleanup!) + (cond-> {:id case-id + :status :ok + :cmd (:cmd result) + :result result + :context context} + timings? (assoc :timings @timings))) + (catch Exception error + (cleanup!) + (if timings? + (throw (ex-info (.getMessage error) + (assoc (or (ex-data error) {}) + :timings @timings + :case-id case-id) + error)) + (throw error)))))) diff --git a/cli-e2e/src/logseq/cli/e2e/shell.clj b/cli-e2e/src/logseq/cli/e2e/shell.clj new file mode 100644 index 0000000..4cbc052 --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/shell.clj @@ -0,0 +1,67 @@ +(ns logseq.cli.e2e.shell + (:require [babashka.process :as process]) + (:import [java.io ByteArrayOutputStream OutputStream])) + +(defn- utf8-string + [^ByteArrayOutputStream payload] + (.toString payload "UTF-8")) + +(defn- capture-stream + [target stream-output?] + (let [payload (ByteArrayOutputStream.)] + {:payload payload + :stream (if stream-output? + (proxy [OutputStream] [] + (write + ([byte] + (.write payload byte) + (.write target byte) + (.flush target)) + ([bytes offset length] + (.write payload bytes offset length) + (.write target bytes offset length) + (.flush target))) + (flush [] + (.flush target)) + (close [] + (.flush target))) + payload)})) + +(defn- default-executor + [cmd {:keys [dir extra-env in stream-output?]}] + (let [{out-payload :payload + out-stream :stream} (capture-stream System/out stream-output?) + {err-payload :payload + err-stream :stream} (capture-stream System/err stream-output?) + result @(process/process {:continue true + :dir dir + :extra-env extra-env + :in in + :out out-stream + :err err-stream} + "bash" + "-c" + cmd)] + {:exit (:exit result 0) + :out (utf8-string out-payload) + :err (utf8-string err-payload)})) + +(defn run! + [{:keys [cmd dir env stdin executor throw? stream-output?] + :or {throw? true}}] + (let [runner (or executor default-executor) + result (runner cmd {:dir dir + :extra-env env + :in stdin + :stream-output? stream-output?}) + exit (:exit result 0) + payload {:cmd cmd + :dir dir + :exit exit + :out (:out result "") + :err (:err result "")}] + (when (and throw? + (not (zero? exit))) + (throw (ex-info "Shell command failed" + payload))) + payload)) diff --git a/cli-e2e/src/logseq/cli/e2e/sync_fixture.clj b/cli-e2e/src/logseq/cli/e2e/sync_fixture.clj new file mode 100644 index 0000000..3c0deff --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/sync_fixture.clj @@ -0,0 +1,140 @@ +(ns logseq.cli.e2e.sync-fixture + (:require [babashka.fs :as fs] + [clojure.string :as string] + [logseq.cli.e2e.paths :as paths] + [logseq.cli.e2e.runner :as runner] + [logseq.cli.e2e.shell :as shell])) + +(def default-sync-port "18080") +(def default-e2ee-password "11111") +(def default-user-keys-graph "sync-e2e-user-keys-bootstrap") + +(def ^:private heavy-setup-patterns + [#"^mkdir -p '\{\{tmp-dir\}\}/home/logseq'$" + #"^cp ~/logseq/auth\.json\b" + #"prepare_sync_config\.py" + #"db_sync_server\.py'? start"]) + +(def ^:private heavy-cleanup-patterns + [#"db_sync_server\.py'? stop"]) + +(defn- shell-quote + [value] + (runner/shell-escape value)) + +(defn- heavy-command? + [command patterns] + (boolean (some #(re-find % command) patterns))) + +(defn- case-local-setup-prefix + [] + ["mkdir -p '{{tmp-dir}}/home/logseq'" + "cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'" + "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"]) + +(def ^:private case-local-resource-markers + ["{{cli-home}}" + "{{config-path}}" + "{{config-path-arg}}" + "{{tmp-dir}}/cli-b.edn" + "{{auth-path}}" + "{{home-dir}}"]) + +(defn- requires-case-local-resources? + [command] + (boolean (some #(string/includes? command %) case-local-resource-markers))) + +(defn- suite-user-keys-bootstrap-command + [suite-tmp-dir] + (let [lock-dir (shell-quote (str (fs/path suite-tmp-dir "user-rsa-keys.lock"))) + done-file (shell-quote (str (fs/path suite-tmp-dir "user-rsa-keys.ready")))] + (format (str "LOCK_DIR=%s; DONE_FILE=%s; " + "if [ -f \"$DONE_FILE\" ]; then exit 0; fi; " + "while ! mkdir \"$LOCK_DIR\" 2>/dev/null; do [ -f \"$DONE_FILE\" ] && exit 0; sleep 0.1; done; " + "trap 'rmdir \"$LOCK_DIR\" 2>/dev/null || true' EXIT; " + "if [ ! -f \"$DONE_FILE\" ]; then " + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{user-keys-graph-arg}} >/dev/null 2>/dev/null || true; " + "if {{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{user-keys-graph-arg}} --e2ee-password {{e2ee-password-arg}} --upload-keys >/dev/null; then " + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{user-keys-graph-arg}} >/dev/null 2>/dev/null || true; " + "touch \"$DONE_FILE\"; " + "else exit 1; fi; " + "fi") + lock-dir + done-file))) + +(defn before-suite! + [{:keys [run-command sync-port] + :or {run-command shell/run! + sync-port default-sync-port}}] + (let [sync-port (str sync-port) + suite-tmp-dir (str (fs/create-temp-dir {:prefix "logseq-cli-e2e-sync-suite-"})) + db-sync-pid-file (str (fs/path suite-tmp-dir "db-sync-server.pid")) + db-sync-log-file (str (fs/path suite-tmp-dir "db-sync-server.log")) + db-sync-root-dir (str (fs/path suite-tmp-dir "db-sync-server-data")) + sync-http-base (str "http://127.0.0.1:" sync-port) + sync-ws-url (str "ws://127.0.0.1:" sync-port "/sync/%s") + auth-path (str (fs/path (System/getProperty "user.home") "logseq" "auth.json")) + start-db-sync-cmd (format "python3 %s start --repo-root %s --pid-file %s --log-file %s --data-dir %s --port %s --startup-timeout-s 60 --auth-path %s" + (shell-quote (paths/repo-path "cli-e2e" "scripts" "db_sync_server.py")) + (shell-quote (paths/repo-root)) + (shell-quote db-sync-pid-file) + (shell-quote db-sync-log-file) + (shell-quote db-sync-root-dir) + sync-port + (shell-quote auth-path))] + (run-command {:cmd start-db-sync-cmd + :dir (paths/repo-root)}) + {:suite-tmp-dir suite-tmp-dir + :db-sync-pid-file db-sync-pid-file + :db-sync-log-file db-sync-log-file + :db-sync-root-dir db-sync-root-dir + :sync-port sync-port + :sync-http-base sync-http-base + :sync-ws-url sync-ws-url})) + +(defn prepare-case + [case {:keys [suite-tmp-dir sync-port sync-http-base sync-ws-url e2ee-password]}] + (let [e2ee-password (or e2ee-password default-e2ee-password) + user-keys-graph (or (get-in case [:vars :user-keys-graph]) + default-user-keys-graph) + setup-commands (vec (:setup case)) + insertion-point? (fn [command] + (or (heavy-command? command heavy-setup-patterns) + (requires-case-local-resources? command))) + leading-setup (->> setup-commands + (take-while #(not (insertion-point? %))) + vec) + trailing-setup (->> setup-commands + (drop (count leading-setup)) + (remove #(heavy-command? % heavy-setup-patterns)) + vec) + bootstrap-setup (when suite-tmp-dir + [(suite-user-keys-bootstrap-command suite-tmp-dir)]) + cleanup' (->> (:cleanup case) + (remove #(heavy-command? % heavy-cleanup-patterns)) + vec)] + (-> case + (update :vars merge {:sync-port sync-port + :sync-http-base sync-http-base + :sync-ws-url sync-ws-url + :e2ee-password e2ee-password + :e2ee-password-arg (shell-quote e2ee-password) + :user-keys-graph-arg (shell-quote user-keys-graph)}) + (assoc :setup (vec (concat leading-setup + (case-local-setup-prefix) + bootstrap-setup + trailing-setup))) + (assoc :cleanup cleanup')))) + +(defn after-suite! + [{:keys [db-sync-pid-file]} + {:keys [run-command] + :or {run-command shell/run!}}] + (when (and (string? db-sync-pid-file) + (not (string/blank? db-sync-pid-file))) + (run-command {:cmd (format "python3 %s stop --pid-file %s" + (shell-quote (paths/repo-path "cli-e2e" "scripts" "db_sync_server.py")) + (shell-quote db-sync-pid-file)) + :dir (paths/repo-root) + :throw? false}))) diff --git a/cli-e2e/src/logseq/cli/e2e/test_runner.clj b/cli-e2e/src/logseq/cli/e2e/test_runner.clj new file mode 100644 index 0000000..447015b --- /dev/null +++ b/cli-e2e/src/logseq/cli/e2e/test_runner.clj @@ -0,0 +1,23 @@ +(ns logseq.cli.e2e.test-runner + (:require [clojure.test :as test])) + +(def test-namespaces + '[logseq.cli.e2e.coverage-test + logseq.cli.e2e.preflight-test + logseq.cli.e2e.paths-test + logseq.cli.e2e.shell-test + logseq.cli.e2e.runner-test + logseq.cli.e2e.cleanup-test + logseq.cli.e2e.manifests-test + logseq.cli.e2e.sync-fixture-test + logseq.cli.e2e.main-test]) + +(defn run! + [_opts] + (doseq [ns-sym test-namespaces] + (require ns-sym)) + (let [{:keys [fail error]} (apply test/run-tests test-namespaces)] + (when (pos? (+ fail error)) + (throw (ex-info "cli-e2e unit tests failed" + {:fail fail + :error error}))))) diff --git a/cli-e2e/test/logseq/cli/e2e/cleanup_test.clj b/cli-e2e/test/logseq/cli/e2e/cleanup_test.clj new file mode 100644 index 0000000..2f61fa4 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/cleanup_test.clj @@ -0,0 +1,149 @@ +(ns logseq.cli.e2e.cleanup-test + (:require [babashka.fs :as fs] + [clojure.test :refer [deftest is testing]] + [logseq.cli.e2e.cleanup :as cleanup])) + +(deftest list-cli-e2e-db-worker-pids-filters-processes + (let [shell-fn (fn [& _] + {:exit 0 + :out (str " 101 node /repo/dist/db-worker-node.js --repo graph-a --root-dir /tmp/logseq-cli-e2e-graph-a-123/graphs --owner-source cli\n" + " 202 node /repo/dist/db-worker-node.js --repo production --root-dir /tmp/production-graphs --owner-source cli\n" + " 303 node /repo/static/logseq-cli.js graph list\n" + " 404 /usr/bin/python3 background.py\n" + " 505 node /repo/static/db-worker-node.js --repo graph-b --root-dir /private/tmp/logseq-cli-e2e-graph-b-999/graphs --owner-source cli\n") + :err ""})] + (is (= [101 505] + (cleanup/list-cli-e2e-db-worker-pids {:shell-fn shell-fn})))) + + (testing "throws when ps invocation fails" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Unable to scan processes" + (cleanup/list-cli-e2e-db-worker-pids {:shell-fn (fn [& _] + {:exit 1 + :out "" + :err "permission denied"})}))))) + +(deftest cleanup-db-worker-processes-separates-killed-and-failed + (let [killed (atom []) + result (cleanup/cleanup-db-worker-processes! {:list-pids-fn (fn [] [11 22 33]) + :kill-pid-fn (fn [pid] + (swap! killed conj pid) + (if (= pid 22) + :failed + :killed))})] + (is (= [11 22 33] (:found-pids result))) + (is (= [11 33] (:killed-pids result))) + (is (= [22] (:failed-pids result))) + (is (= [11 22 33] @killed)))) + +(deftest cleanup-db-worker-processes-dry-run-does-not-kill + (let [killed (atom []) + result (cleanup/cleanup-db-worker-processes! {:dry-run true + :list-pids-fn (fn [] [11 22]) + :kill-pid-fn (fn [pid] + (swap! killed conj pid) + :killed)})] + (is (= [11 22] (:found-pids result))) + (is (true? (:dry-run? result))) + (is (= [11 22] (:would-kill-pids result))) + (is (= [] (:killed-pids result))) + (is (= [] (:failed-pids result))) + (is (= [] @killed)))) + +(deftest list-cli-e2e-db-sync-port-pids-filters-port-18080 + (let [shell-fn (fn [& _] + {:exit 0 + :out (str "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "node 111 me 13u IPv6 0x0 0t0 TCP *:18080 (LISTEN)\n" + "python 222 me 13u IPv4 0x0 0t0 TCP 127.0.0.1:18080 (LISTEN)\n" + "node 333 me 13u IPv6 0x0 0t0 TCP *:18081 (LISTEN)\n") + :err ""})] + (is (= [111 222] + (cleanup/list-cli-e2e-db-sync-port-pids {:shell-fn shell-fn})))) + + (testing "returns empty when no listener exists" + (is (= [] + (cleanup/list-cli-e2e-db-sync-port-pids {:shell-fn (fn [& _] + {:exit 1 + :out "" + :err ""})})))) + + (testing "throws when lsof invocation fails" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Unable to scan db-sync server port listeners" + (cleanup/list-cli-e2e-db-sync-port-pids {:shell-fn (fn [& _] + {:exit 1 + :out "" + :err "permission denied"})}))))) + +(deftest cleanup-db-sync-port-processes-separates-killed-and-failed + (let [killed (atom []) + result (cleanup/cleanup-db-sync-port-processes! {:list-pids-fn (fn [] [44 55]) + :kill-pid-fn (fn [pid] + (swap! killed conj pid) + (if (= pid 55) + :failed + :killed))})] + (is (= [44 55] (:found-pids result))) + (is (= [44] (:killed-pids result))) + (is (= [55] (:failed-pids result))) + (is (= [44 55] @killed)))) + +(deftest cleanup-db-sync-port-processes-dry-run-does-not-kill + (let [killed (atom []) + result (cleanup/cleanup-db-sync-port-processes! {:dry-run true + :list-pids-fn (fn [] [44]) + :kill-pid-fn (fn [pid] + (swap! killed conj pid) + :killed)})] + (is (= [44] (:found-pids result))) + (is (true? (:dry-run? result))) + (is (= [44] (:would-kill-pids result))) + (is (= [] (:killed-pids result))) + (is (= [] (:failed-pids result))) + (is (= [] @killed)))) + +(deftest cleanup-temp-roots-removes-only-cli-e2e-temp-roots + (let [tmp-root (fs/create-temp-dir {:prefix "cleanup-e2e-test-"}) + matching-root (fs/path tmp-root "logseq-cli-e2e-case-a-123") + another-matching-root (fs/path tmp-root "logseq-cli-e2e-case-b-456") + non-matching-root (fs/path tmp-root "other-case")] + (try + (fs/create-dirs (fs/path matching-root "graphs")) + (fs/create-dirs (fs/path matching-root "home" "logseq")) + (fs/create-dirs (fs/path another-matching-root "graphs")) + (fs/create-dirs (fs/path non-matching-root "graphs")) + + (let [result (cleanup/cleanup-temp-roots! {:tmp-root (str tmp-root)}) + expected-dirs (sort [(str matching-root) + (str another-matching-root)])] + (is (= expected-dirs + (sort (:found-dirs result)))) + (is (= expected-dirs + (sort (:removed-dirs result)))) + (is (empty? (:failed-dirs result))) + (is (false? (fs/exists? matching-root))) + (is (false? (fs/exists? another-matching-root))) + (is (true? (fs/exists? non-matching-root)))) + (finally + (fs/delete-tree tmp-root))))) + +(deftest cleanup-temp-roots-dry-run-does-not-delete + (let [deleted (atom []) + result (cleanup/cleanup-temp-roots! {:dry-run true + :list-dirs-fn (fn [] ["/tmp/logseq-cli-e2e-a" + "/tmp/logseq-cli-e2e-b"]) + :delete-dir-fn (fn [dir] + (swap! deleted conj dir))})] + (is (= ["/tmp/logseq-cli-e2e-a" + "/tmp/logseq-cli-e2e-b"] + (:found-dirs result))) + (is (true? (:dry-run? result))) + (is (= ["/tmp/logseq-cli-e2e-a" + "/tmp/logseq-cli-e2e-b"] + (:would-remove-dirs result))) + (is (= [] (:removed-dirs result))) + (is (= [] (:failed-dirs result))) + (is (= [] @deleted)))) diff --git a/cli-e2e/test/logseq/cli/e2e/compare_graph_queries_test.py b/cli-e2e/test/logseq/cli/e2e/compare_graph_queries_test.py new file mode 100644 index 0000000..cbea011 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/compare_graph_queries_test.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + + +MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "compare_graph_queries.py" +spec = importlib.util.spec_from_file_location("compare_graph_queries", MODULE_PATH) +compare_graph_queries = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(compare_graph_queries) + + +def test_parse_args_supports_repeated_queries(monkeypatch) -> None: + monkeypatch.setattr( + compare_graph_queries.sys, + "argv", + [ + "compare_graph_queries.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--root-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--root-dir-b", + "/tmp/b", + "--query", + "[:find ?x]", + "--query", + "[:find ?y]", + ], + ) + + args = compare_graph_queries.parse_args() + + assert args.root_dir_a == "/tmp/a" + assert args.root_dir_b == "/tmp/b" + assert args.query == ["[:find ?x]", "[:find ?y]"] + + +def test_main_batches_multiple_queries_in_one_process(tmp_path: Path) -> None: + left_queries = [] + right_queries = [] + printed = [] + cli_path = tmp_path / "logseq-cli.js" + cli_path.write_text("// mock cli\n") + + def fake_run_query(cli_path, config_path, root_dir, graph, query): + record = { + "cli_path": str(cli_path), + "config_path": str(config_path), + "root_dir": str(root_dir), + "graph": graph, + "query": query, + } + if str(config_path).endswith("a.edn"): + left_queries.append(record) + else: + right_queries.append(record) + return { + "payload": {"status": "ok"}, + "result": [{"title": query}], + } + + compare_graph_queries.run_query = fake_run_query + compare_graph_queries.parse_args = lambda: SimpleNamespace( + cli=str(cli_path), + graph="demo", + query=["[:find ?x]", "[:find ?y]"], + config_a="/tmp/a.edn", + root_dir_a="/tmp/a", + config_b="/tmp/b.edn", + root_dir_b="/tmp/b", + require_result=False, + ) + compare_graph_queries.print = lambda value, **kwargs: printed.append(json.loads(value)) + + compare_graph_queries.main() + + assert [item["query"] for item in left_queries] == ["[:find ?x]", "[:find ?y]"] + assert [item["query"] for item in right_queries] == ["[:find ?x]", "[:find ?y]"] + assert printed == [ + { + "status": "ok", + "results": { + "[:find ?x]": [{"title": "[:find ?x]"}], + "[:find ?y]": [{"title": "[:find ?y]"}], + }, + } + ] diff --git a/cli-e2e/test/logseq/cli/e2e/coverage_test.clj b/cli-e2e/test/logseq/cli/e2e/coverage_test.clj new file mode 100644 index 0000000..883a27e --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/coverage_test.clj @@ -0,0 +1,58 @@ +(ns logseq.cli.e2e.coverage-test + (:require [clojure.test :refer [deftest is testing]] + [logseq.cli.e2e.coverage :as coverage] + [logseq.cli.e2e.manifests :as manifests])) + +(def sample-inventory + {:excluded-command-prefixes ["sync" "login" "logout"] + :scopes {:global {:options ["--help" "--version"]} + :graph {:commands ["graph list" "graph create"] + :options ["--file" "--type"]}}}) + +(deftest reports-missing-commands-and-options + (let [cases [{:id "global-help" + :covers {:options {:global ["--help"]}}} + {:id "graph-list" + :covers {:commands ["graph list"] + :options {:graph ["--file"]}}}] + report (coverage/coverage-report sample-inventory cases)] + (is (= ["graph create"] (:missing-commands report))) + (is (= ["--version"] (get-in report [:missing-options :global]))) + (is (= ["--type"] (get-in report [:missing-options :graph]))))) + +(deftest rejects-excluded-commands-in-inventory + (let [inventory (assoc-in sample-inventory [:scopes :bad :commands] ["login"])] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Excluded commands" + (coverage/validate-inventory! inventory))))) + +(deftest rejects-excluded-commands-in-case-covers + (let [cases [{:id "bad-login" + :covers {:commands ["logout"]}}]] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Excluded commands" + (coverage/validate-cases! sample-inventory cases))))) + +(deftest complete-coverage-is-recognized + (let [cases [{:id "global" + :covers {:options {:global ["--help" "--version"]}}} + {:id "graph-create" + :covers {:commands ["graph create"] + :options {:graph ["--type"]}}} + {:id "graph-list" + :covers {:commands ["graph list"] + :options {:graph ["--file"]}}}] + report (coverage/coverage-report sample-inventory cases)] + (is (coverage/complete? report)))) + +(deftest sync-suite-manifests-cover-mvp-commands + (let [inventory (manifests/load-inventory :sync) + cases (manifests/load-cases :sync) + covered-commands (set (mapcat #(get-in % [:covers :commands]) cases)) + report (coverage/coverage-report inventory cases)] + (is (contains? covered-commands "sync upload")) + (is (contains? covered-commands "sync download")) + (is (contains? covered-commands "sync status")) + (is (coverage/complete? report)))) diff --git a/cli-e2e/test/logseq/cli/e2e/main_test.clj b/cli-e2e/test/logseq/cli/e2e/main_test.clj new file mode 100644 index 0000000..72dd629 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/main_test.clj @@ -0,0 +1,798 @@ +(ns logseq.cli.e2e.main-test + (:require [babashka.cli :as cli] + [clojure.string :as string] + [clojure.test :refer [deftest is]] + [logseq.cli.e2e.cleanup :as cleanup] + [logseq.cli.e2e.main :as main] + [logseq.cli.e2e.manifests :as manifests] + [logseq.cli.e2e.sync-fixture :as sync-fixture])) + +(def sample-cases + [{:id "global-help" + :cmds ["node static/logseq-cli.js --help"] + :covers {:options {:global ["--help"]}} + :tags [:global :smoke]} + {:id "graph-create" + :cmds ["node static/logseq-cli.js graph create --graph demo"] + :covers {:commands ["graph create"] + :options {:graph ["--type"]}} + :tags [:graph]} + {:id "graph-list" + :cmds ["node static/logseq-cli.js graph list"] + :covers {:commands ["graph list"] + :options {:graph ["--file"]}} + :tags [:graph :smoke]}]) + +(def complete-inventory + {:excluded-command-prefixes ["sync" "login" "logout"] + :scopes {:global {:options ["--help"]} + :graph {:commands ["graph create" "graph list"] + :options ["--type" "--file"]}}}) + +(def cli-parse-config + {:alias {:i :include + :h :help} + :spec {:jobs {:default 4}} + :coerce {:include [] + :help :boolean + :dry-run :boolean + :skip-build :boolean + :verbose :boolean + :timings :boolean + :jobs :long}}) + +(deftest cli-opts-parses-jobs-as-integer + (is (= 3 + (:jobs (cli/parse-opts ["--jobs" "3"] cli-parse-config))))) + +(deftest cli-opts-defaults-jobs-to-four + (is (= 4 + (:jobs (cli/parse-opts [] cli-parse-config))))) + +(deftest cli-opts-rejects-non-integer-jobs + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Coerce failure" + (cli/parse-opts ["--jobs" "nope"] cli-parse-config)))) + +(deftest run-rejects-jobs-less-than-one + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"--jobs must be a positive integer" + (main/run! {:inventory complete-inventory + :cases sample-cases + :skip-build true + :jobs 0 + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})))) + +(deftest run-non-sync-uses-parallel-runner-when-jobs-greater-than-one + (let [parallel-call (atom nil) + serial-called? (atom false)] + (with-redefs [main/run-selected-cases-in-parallel! (fn [selected-cases run-case run-command opts] + (reset! parallel-call {:case-ids (mapv :id selected-cases) + :run-case run-case + :run-command run-command + :jobs (:jobs opts)}) + [{:id "global-help" :status :ok} + {:id "graph-list" :status :ok}]) + main/run-selected-cases! (fn [& _] + (reset! serial-called? true) + (throw (ex-info "serial runner should not be used" {})))] + (let [result (main/run! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :jobs 2 + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))) + (is (= ["global-help" "graph-list"] (:case-ids @parallel-call))) + (is (= 2 (:jobs @parallel-call))) + (is (false? @serial-called?)))))) + +(deftest run-sync-suite-uses-parallel-runner-when-jobs-greater-than-one + (let [parallel-call (atom nil) + serial-called? (atom false) + sync-inventory {:excluded-command-prefixes ["login" "logout"] + :scopes {:sync {:commands ["sync upload" "sync status"] + :options []}}} + sync-cases [{:id "sync-upload" + :cmds ["node static/logseq-cli.js sync upload"] + :covers {:commands ["sync upload"]}} + {:id "sync-status" + :cmds ["node static/logseq-cli.js sync status"] + :covers {:commands ["sync status"]}}]] + (with-redefs [main/run-selected-cases-in-parallel! (fn [selected-cases run-case run-command opts] + (reset! parallel-call {:case-ids (mapv :id selected-cases) + :run-case run-case + :run-command run-command + :jobs (:jobs opts)}) + (mapv (fn [case] + {:id (:id case) + :status :ok}) + selected-cases)) + main/run-selected-cases! (fn [& _] + (reset! serial-called? true) + (throw (ex-info "serial runner should not be used" {})))] + (let [result (main/run! {:suite :sync + :inventory sync-inventory + :cases sync-cases + :skip-build true + :jobs 4 + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))) + (is (= ["sync-upload" "sync-status"] (:case-ids @parallel-call))) + (is (= 4 (:jobs @parallel-call))) + (is (false? @serial-called?)))))) + +(deftest run-jobs-one-keeps-serial-runner + (let [serial-call (atom nil) + parallel-called? (atom false)] + (with-redefs [main/run-selected-cases! (fn [selected-cases run-case run-command opts] + (reset! serial-call {:case-ids (mapv :id selected-cases) + :run-case run-case + :run-command run-command + :jobs (:jobs opts)}) + (mapv (fn [case] + {:id (:id case) + :status :ok}) + selected-cases)) + main/run-selected-cases-in-parallel! (fn [& _] + (reset! parallel-called? true) + (throw (ex-info "parallel runner should not be used" {})))] + (let [result (main/run! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :jobs 1 + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))) + (is (= ["global-help" "graph-list"] (:case-ids @serial-call))) + (is (= 1 (:jobs @serial-call))) + (is (false? @parallel-called?)))))) + +(deftest parallel-runner-collects-completions-before-rethrowing-failure + (let [started (atom []) + finished (atom []) + started-latch (java.util.concurrent.CountDownLatch. 2) + release-success (promise) + cases [{:id "global-help"} + {:id "graph-list"}] + error (try + (main/run-selected-cases-in-parallel! + cases + (fn [case _opts] + (swap! started conj (:id case)) + (.countDown started-latch) + (.await started-latch) + (if (= "graph-list" (:id case)) + (do + (swap! finished conj [:failed (:id case)]) + (deliver release-success true) + (throw (ex-info "boom" {:id (:id case)}))) + (do + @release-success + (swap! finished conj [:ok (:id case)]) + {:id (:id case) + :status :ok}))) + (fn [_] + {:exit 0 + :out "" + :err ""}) + {:jobs 2}) + nil + (catch Exception ex + ex))] + (is (instance? Exception error)) + (is (= #{"global-help" "graph-list"} + (set @started))) + (is (= #{[:ok "global-help"] + [:failed "graph-list"]} + (set @finished))))) + +(deftest parallel-runner-elapsed-ms-starts-when-case-begins-running + (let [events (atom [])] + (main/run-selected-cases-in-parallel! + [{:id "slow-1"} + {:id "slow-2"} + {:id "fast-after-queue"}] + (fn [test-case _opts] + (clojure.core/case (:id test-case) + "slow-1" (Thread/sleep 180) + "slow-2" (Thread/sleep 180) + "fast-after-queue" (Thread/sleep 10)) + {:id (:id test-case) + :status :ok}) + (fn [_] + {:exit 0 + :out "" + :err ""}) + {:jobs 2 + :on-case-success (fn [payload] + (swap! events conj [(:id (:result payload)) (:elapsed-ms payload)]))}) + (let [elapsed-map (into {} @events)] + (is (< (get elapsed-map "fast-after-queue" 1000) 120) + "queued case should measure only its own execution time, not time spent waiting in the pool") + (is (>= (get elapsed-map "slow-1" 0) 150)) + (is (>= (get elapsed-map "slow-2" 0) 150))))) + +(deftest select-cases-supports-case-id + (is (= ["graph-create"] + (mapv :id (main/select-cases sample-cases {:case "graph-create"}))))) + +(deftest select-cases-supports-include-tags + (is (= ["global-help" "graph-list"] + (mapv :id (main/select-cases sample-cases {:include ["smoke"]}))))) + +(deftest select-cases-omits-skipped-cases + (let [cases (conj sample-cases + {:id "stale-fallback" + :cmds ["node static/logseq-cli.js debug pull --id 1"] + :skip "Requires a removed current graph fallback" + :covers {:commands ["debug pull"]} + :tags [:debug :smoke]})] + (is (= ["global-help" "graph-create" "graph-list"] + (mapv :id (main/select-cases cases {})))) + (is (= ["global-help" "graph-list"] + (mapv :id (main/select-cases cases {:include ["smoke"]})))) + (is (= [] + (mapv :id (main/select-cases cases {:case "stale-fallback"})))))) + +(deftest run-fails-on-missing-coverage-before-case-execution + (let [ran? (atom false)] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Missing coverage" + (main/run! {:inventory complete-inventory + :cases [{:id "global-help" + :cmds ["node static/logseq-cli.js --help"] + :covers {:options {:global ["--help"]}}}] + :skip-build true + :run-command (fn [_] + (reset! ran? true) + {:exit 0 + :out "" + :err ""})}))) + (is (false? @ran?)))) + +(deftest run-succeeds-when-coverage-is-complete + (let [result (main/run! {:inventory complete-inventory + :cases [{:id "global-help" + :cmds ["node static/logseq-cli.js --help"] + :covers {:options {:global ["--help"]}}} + {:id "graph-create" + :cmds ["node static/logseq-cli.js graph create --type markdown"] + :covers {:commands ["graph create"] + :options {:graph ["--type"]}}} + {:id "graph-list" + :cmds ["node static/logseq-cli.js graph list --file demo.edn"] + :covers {:commands ["graph list"] + :options {:graph ["--file"]}}}] + :skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""})})] + (is (= :ok (:status result))) + (is (= 3 (count (:cases result)))))) + +(deftest targeted-run-skips-full-coverage-check + (let [executed (atom [])] + (let [result (main/run! {:inventory complete-inventory + :cases [{:id "global-help" + :cmds ["node static/logseq-cli.js --help"] + :covers {:options {:global ["--help"]}}} + {:id "graph-create" + :cmds ["node static/logseq-cli.js graph create --graph demo"] + :covers {:commands ["graph create"] + :options {:graph ["--type"]}}}] + :case "global-help" + :skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + (swap! executed conj (:id case)) + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))) + (is (= ["global-help"] @executed))))) + +(deftest run-invokes-case-progress-hooks + (let [events (atom [])] + (main/run! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok}) + :on-case-start (fn [{:keys [index total case]}] + (swap! events conj [:start index total (:id case)])) + :on-case-success (fn [{:keys [index total result]}] + (swap! events conj [:ok index total (:id result)]))}) + (is (= [[:start 1 2 "global-help"] + [:ok 1 2 "global-help"] + [:start 2 2 "graph-list"] + [:ok 2 2 "graph-list"]] + @events)))) + +(deftest run-loads-non-sync-suite-by-default + (let [suite-calls (atom [])] + (with-redefs [manifests/load-inventory (fn [suite] + (swap! suite-calls conj [:inventory suite]) + complete-inventory) + manifests/load-cases (fn [suite] + (swap! suite-calls conj [:cases suite]) + sample-cases)] + (let [result (main/run! {:skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result)))) + (is (= [[:inventory :non-sync] + [:cases :non-sync]] + @suite-calls))))) + +(deftest run-loads-sync-suite-when-explicit + (let [suite-calls (atom []) + sync-inventory {:excluded-command-prefixes ["login" "logout"] + :scopes {:sync {:commands ["sync upload"] + :options []}}} + sync-cases [{:id "sync-upload" + :cmds ["node static/logseq-cli.js sync upload"] + :covers {:commands ["sync upload"]}}]] + (with-redefs [manifests/load-inventory (fn [suite] + (swap! suite-calls conj [:inventory suite]) + sync-inventory) + manifests/load-cases (fn [suite] + (swap! suite-calls conj [:cases suite]) + sync-cases)] + (let [result (main/run! {:suite :sync + :skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result)))) + (is (= [[:inventory :sync] + [:cases :sync]] + @suite-calls))))) + +(deftest run-sync-suite-uses-suite-fixture-once-with-parallel-runner + (let [before-called (atom 0) + after-called (atom 0) + prepared-case-ids (atom []) + run-case-seen (atom []) + parallel-call (atom nil) + sync-inventory {:excluded-command-prefixes ["login" "logout"] + :scopes {:sync {:commands ["sync upload" "sync status"] + :options []}}} + sync-cases [{:id "sync-upload" + :cmds ["node static/logseq-cli.js sync upload"] + :covers {:commands ["sync upload"]}} + {:id "sync-status" + :cmds ["node static/logseq-cli.js sync status"] + :covers {:commands ["sync status"]}}]] + (with-redefs [sync-fixture/before-suite! (fn [_] + (swap! before-called inc) + {:suite :sync}) + sync-fixture/prepare-case (fn [case _suite-context] + (swap! prepared-case-ids conj (:id case)) + (assoc case :prepared? true)) + sync-fixture/after-suite! (fn [_ _] + (swap! after-called inc)) + main/run-selected-cases-in-parallel! (fn [selected-cases run-case run-command opts] + (reset! parallel-call {:case-ids (mapv :id selected-cases) + :jobs (:jobs opts)}) + (mapv (fn [case] + (run-case case {:run-command run-command})) + selected-cases))] + (let [result (main/run! {:suite :sync + :inventory sync-inventory + :cases sync-cases + :skip-build true + :jobs 2 + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + (swap! run-case-seen conj [(:id case) (:prepared? case)]) + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))))) + (is (= 1 @before-called)) + (is (= 1 @after-called)) + (is (= ["sync-upload" "sync-status"] (:case-ids @parallel-call))) + (is (= 2 (:jobs @parallel-call))) + (is (= ["sync-upload" "sync-status"] @prepared-case-ids)) + (is (= [["sync-upload" true] + ["sync-status" true]] + @run-case-seen)))) + +(deftest list-cases-defaults-to-non-sync + (let [selected-suite (atom nil) + output (with-out-str + (with-redefs [manifests/load-cases (fn [suite] + (reset! selected-suite suite) + [{:id "non-sync-case"}])] + (main/list-cases! {})))] + (is (= :non-sync @selected-suite)) + (is (string/includes? output "non-sync-case")))) + +(deftest list-sync-cases-uses-sync-suite + (let [selected-suite (atom nil) + output (with-out-str + (with-redefs [manifests/load-cases (fn [suite] + (reset! selected-suite suite) + [{:id "sync-case"}])] + (main/list-sync-cases! {})))] + (is (= :sync @selected-suite)) + (is (string/includes? output "sync-case")))) + +(deftest test-prints-progress-and-summary + (let [output (with-out-str + (main/test! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok + :cmd (last (:cmds case))})}))] + (is (string/includes? output "==> Running cli-e2e cases")) + (is (string/includes? output "==> Build preflight: running...")) + (is (string/includes? output "==> Build preflight: skipped (--skip-build)")) + (is (string/includes? output "==> Prepared 2 case(s), starting execution")) + (is (string/includes? output "[1/2] ▶ global-help")) + (is (string/includes? output "[1/2] ✓ global-help")) + (is (string/includes? output "[2/2] ▶ graph-list")) + (is (string/includes? output "[2/2] ✓ graph-list")) + (is (string/includes? output "Summary: 2 passed, 0 failed")))) + +(deftest test-parallel-output-omits-meaningless-index-prefixes + (let [output (with-out-str + (main/test! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :jobs 2 + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + (Thread/sleep (if (= "global-help" (:id case)) 25 5)) + {:id (:id case) + :status :ok})}))] + (is (string/includes? output "▶ global-help")) + (is (string/includes? output "✓ global-help")) + (is (string/includes? output "✓ graph-list")) + (is (not (string/includes? output "[1/2]"))) + (is (not (string/includes? output "[2/2]"))))) + +(deftest test-timings-prints-step-details-and-slow-summary + (let [output (with-out-str + (main/test! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :timings true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + (if (= "global-help" (:id case)) + {:id "global-help" + :status :ok + :timings [{:phase :setup + :step-index 1 + :step-total 1 + :elapsed-ms 12 + :status :ok + :cmd "setup-global"} + {:phase :main + :step-index 1 + :step-total 1 + :elapsed-ms 55 + :status :ok + :cmd "main-global"}]} + {:id "graph-list" + :status :ok + :timings [{:phase :main + :step-index 1 + :step-total 1 + :elapsed-ms 210 + :status :ok + :cmd "main-graph-list"}]}))}))] + (is (string/includes? output "==> Step timing enabled (--timings)")) + (is (string/includes? output "step timings:")) + (is (string/includes? output "Slow steps (top 10):")) + (is (string/includes? output "main-graph-list")))) + +(deftest test-without-timings-keeps-output-concise + (let [output (with-out-str + (main/test! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok + :timings [{:phase :main + :step-index 1 + :step-total 1 + :elapsed-ms 88 + :status :ok + :cmd "hidden-step"}]})}))] + (is (not (string/includes? output "==> Step timing enabled (--timings)"))) + (is (not (string/includes? output "step timings:"))) + (is (not (string/includes? output "Slow steps (top 10):"))))) + +(deftest test-sync-timings-prints-step-details-and-slow-summary + (let [sync-inventory {:excluded-command-prefixes ["login" "logout"] + :scopes {:sync {:commands ["sync status"] + :options []}}} + sync-cases [{:id "sync-status-case" + :cmds ["node static/logseq-cli.js sync status"] + :covers {:commands ["sync status"]}}] + output (with-out-str + (main/test-sync! {:inventory sync-inventory + :cases sync-cases + :skip-build true + :timings true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok + :timings [{:phase :setup + :step-index 1 + :step-total 1 + :elapsed-ms 10 + :status :ok + :cmd "sync-setup"} + {:phase :main + :step-index 1 + :step-total 1 + :elapsed-ms 150 + :status :ok + :cmd "sync-main"} + {:phase :cleanup + :step-index 1 + :step-total 1 + :elapsed-ms 20 + :status :ok + :cmd "sync-cleanup"}]})}))] + (is (string/includes? output "==> Running cli-e2e cases")) + (is (string/includes? output "==> Step timing enabled (--timings)")) + (is (string/includes? output "step timings:")) + (is (string/includes? output "Slow steps (top 10):")) + (is (string/includes? output "sync-main")))) + +(deftest test-help-prints-usage-and-skips-execution + (let [ran? (atom false) + result (atom nil) + output (with-out-str + (reset! result + (main/test! {:help true + :run-command (fn [_] + (reset! ran? true) + {:exit 0 + :out "" + :err ""}) + :run-case (fn [_ _] + (reset! ran? true) + {:id "unexpected" + :status :ok})}))) ] + (is (= :help (:status @result))) + (is (false? @ran?)) + (is (string/includes? output "Usage: bb -f cli-e2e/bb.edn test [options]")) + (is (string/includes? output "--skip-build")) + (is (not (string/includes? output "--force-build"))) + (is (string/includes? output "--include TAG")) + (is (string/includes? output "--case ID")) + (is (string/includes? output "--jobs N")) + (is (string/includes? output "Run up to N non-sync cases in parallel")) + (is (string/includes? output "--skip-build --jobs 4")) + (is (string/includes? output "Default: 4")) + (is (string/includes? output "--timings")) + (is (not (string/includes? output "--e2ee-password"))))) + +(deftest test-sync-help-prints-usage-and-skips-execution + (let [ran? (atom false) + result (atom nil) + output (with-out-str + (reset! result + (main/test-sync! {:help true + :run-command (fn [_] + (reset! ran? true) + {:exit 0 + :out "" + :err ""}) + :run-case (fn [_ _] + (reset! ran? true) + {:id "unexpected" + :status :ok})})))] + (is (= :help (:status @result))) + (is (false? @ran?)) + (is (string/includes? output "Usage: bb -f cli-e2e/bb.edn test-sync [options]")) + (is (string/includes? output "--skip-build")) + (is (not (string/includes? output "--force-build"))) + (is (string/includes? output "--include TAG")) + (is (string/includes? output "--case ID")) + (is (string/includes? output "--jobs N")) + (is (string/includes? output "Run up to N sync cases in parallel")) + (is (string/includes? output "--jobs 4")) + (is (string/includes? output "bb -f cli-e2e/bb.edn test-sync")) + (is (string/includes? output "--case sync-upload-download-mvp")) + (is (not (string/includes? output "--skip-build --case sync-upload-download-mvp"))) + (is (string/includes? output "Default: 4")) + (is (string/includes? output "--timings")) + (is (string/includes? output "--e2ee-password VALUE")) + (is (string/includes? output "Default: 11111")))) + +(deftest run-does-not-pass-force-build-to-preflight + (let [preflight-call (atom nil)] + (with-redefs [logseq.cli.e2e.preflight/run! (fn [opts] + (reset! preflight-call opts) + {:status :ok + :commands [] + :missing-artifacts []})] + (let [result (main/run! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build false + :force-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))) + (is (not (contains? @preflight-call :force-build))))))) + +(deftest test-single-case-enables-detailed-command-logging + (let [command-opts (atom nil) + output (with-out-str + (main/test! {:inventory complete-inventory + :cases [{:id "global-help" + :cmds ["node static/logseq-cli.js --help"] + :covers {:options {:global ["--help"]}}}] + :case "global-help" + :skip-build true + :run-command (fn [{:keys [cmd] :as opts}] + (reset! command-opts opts) + {:cmd cmd + :exit 0 + :out "ok" + :err ""}) + :run-case (fn [case {:keys [run-command]}] + (let [result (run-command {:cmd (first (:cmds case)) + :phase :main + :step-index 1 + :step-total 1})] + {:id (:id case) + :status :ok + :cmd (:cmd result)}) )}))] + (is (string/includes? output "==> Detailed case logging enabled (--case global-help)")) + (is (string/includes? output " [main 1/1] $ node static/logseq-cli.js --help")) + (is (true? (:stream-output? @command-opts))))) + +(deftest cleanup-help-prints-usage + (let [output (with-out-str (main/cleanup! {:help true}))] + (is (string/includes? output "Usage: bb -f cli-e2e/bb.edn cleanup")) + (is (string/includes? output "Terminate cli-e2e db-worker-node processes")) + (is (string/includes? output "Terminate db-sync server listeners on port 18080")) + (is (string/includes? output "Remove cli-e2e temp roots")) + (is (string/includes? output "--dry-run")))) + +(deftest cleanup-prints-summary-and-returns-status + (with-redefs [cleanup/cleanup-db-worker-processes! (fn [_] {:found-pids [101 202] + :killed-pids [101] + :failed-pids [202]}) + cleanup/cleanup-db-sync-port-processes! (fn [_] {:found-pids [303] + :killed-pids [303] + :failed-pids []}) + cleanup/cleanup-temp-roots! (fn [_] {:found-dirs ["/tmp/logseq-cli-e2e-a" + "/tmp/logseq-cli-e2e-b"] + :removed-dirs ["/tmp/logseq-cli-e2e-a"] + :failed-dirs ["/tmp/logseq-cli-e2e-b"]})] + (let [result (atom nil) + output (with-out-str + (reset! result (main/cleanup! {})))] + (is (= :ok (:status @result))) + (is (= [101] (get-in @result [:processes :killed-pids]))) + (is (= [303] (get-in @result [:db-sync-port-processes :killed-pids]))) + (is (= ["/tmp/logseq-cli-e2e-a"] (get-in @result [:temp-roots :removed-dirs]))) + (is (string/includes? output "db-worker-node processes: found 2, killed 1, failed 1")) + (is (string/includes? output "db-sync server processes (port 18080): found 1, killed 1, failed 0")) + (is (string/includes? output "temp roots: found 2, removed 1, failed 1"))))) + +(deftest cleanup-dry-run-prints-summary-and-passes-option + (let [process-opts (atom nil) + db-sync-opts (atom nil) + dir-opts (atom nil)] + (with-redefs [cleanup/cleanup-db-worker-processes! (fn [opts] + (reset! process-opts opts) + {:dry-run? true + :found-pids [101 202] + :would-kill-pids [101 202] + :killed-pids [] + :failed-pids []}) + cleanup/cleanup-db-sync-port-processes! (fn [opts] + (reset! db-sync-opts opts) + {:dry-run? true + :found-pids [303] + :would-kill-pids [303] + :killed-pids [] + :failed-pids []}) + cleanup/cleanup-temp-roots! (fn [opts] + (reset! dir-opts opts) + {:dry-run? true + :found-dirs ["/tmp/logseq-cli-e2e-a"] + :would-remove-dirs ["/tmp/logseq-cli-e2e-a"] + :removed-dirs [] + :failed-dirs []})] + (let [result (atom nil) + output (with-out-str + (reset! result (main/cleanup! {:dry-run true})))] + (is (= {:dry-run true} @process-opts)) + (is (= {:dry-run true} @db-sync-opts)) + (is (= {:dry-run true} @dir-opts)) + (is (= :ok (:status @result))) + (is (true? (get-in @result [:processes :dry-run?]))) + (is (true? (get-in @result [:db-sync-port-processes :dry-run?]))) + (is (true? (get-in @result [:temp-roots :dry-run?]))) + (is (string/includes? output "[dry-run] db-worker-node processes: found 2, would kill 2")) + (is (string/includes? output "[dry-run] db-sync server processes (port 18080): found 1, would kill 1")) + (is (string/includes? output "[dry-run] temp roots: found 1, would remove 1")))))) diff --git a/cli-e2e/test/logseq/cli/e2e/manifests_test.clj b/cli-e2e/test/logseq/cli/e2e/manifests_test.clj new file mode 100644 index 0000000..70f5352 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/manifests_test.clj @@ -0,0 +1,225 @@ +(ns logseq.cli.e2e.manifests-test + (:require [clojure.test :refer [deftest is testing]] + [logseq.cli.e2e.manifests :as manifests])) + +(deftest load-cases-requires-new-map-format + (with-redefs [manifests/read-edn-file (fn [_] + [{:id "legacy-a"} + {:id "legacy-b"}])] + (let [error (try + (manifests/load-cases :non-sync) + nil + (catch clojure.lang.ExceptionInfo error + error))] + (is (some? error)) + (is (re-find #"Invalid cli-e2e manifest format" (.getMessage error))) + (is (= "{:templates {...} :cases [...]}" + (:expected (ex-data error))))))) + +(deftest load-cases-merges-templates-and-cases-by-rules + (with-redefs [manifests/read-edn-file + (fn [_] + {:templates + {:base {:setup ["setup-a"] + :cmds ["cmd-a"] + :cleanup ["cleanup-a"] + :tags [:base] + :vars {:nested {:left 1} + :only-base true} + :covers {:commands ["base-command"] + :options {:global ["--base"]}} + :expect {:stdout-json-paths {[:status] "ok" + [:data :base] 1}} + :graph "base-graph"} + :addon {:setup ["setup-b"] + :cmds ["cmd-b"] + :cleanup ["cleanup-b"] + :tags [:addon] + :vars {:nested {:right 2}} + :covers {:options {:graph ["--addon"]}} + :expect {:stdout-json-paths {[:data :addon] 2}} + :graph "addon-graph"} + :shared {:setup ["setup-shared"] + :cmds ["cmd-shared"] + :cleanup ["cleanup-shared"] + :tags [:shared] + :vars {:nested {:shared 9}} + :graph "shared-graph"}} + :cases + [{:id "single-parent" + :extends :base + :cmds ["cmd-case"] + :expect {:stdout-json-paths {[:data :case] 3}} + :graph "single-parent-graph"} + {:id "multi-parent" + :extends [:base :addon] + :setup ["setup-case"] + :cmds ["cmd-case"] + :cleanup ["cleanup-case"] + :tags [:case] + :vars {:nested {:leaf 3} + :only-case true} + :covers {:commands ["case-command"] + :options {:graph ["--case"]}} + :expect {:stdout-json-paths {[:data :case] 3}} + :graph "case-graph"} + {:id "negative-case" + :extends :shared + :expect {:exit 1 + :stdout-json-paths {[:error :code] "boom"}} + :graph "negative-graph"}]})] + (let [[single-parent multi-parent negative-case] (manifests/load-cases :sync)] + (testing "supports :extends keyword" + (is (= "single-parent" (:id single-parent))) + (is (= ["cmd-a" "cmd-case"] (:cmds single-parent))) + (is (= "single-parent-graph" (:graph single-parent)))) + (testing "append merge keys" + (is (= ["setup-a" "setup-b" "setup-case"] (:setup multi-parent))) + (is (= ["cmd-a" "cmd-b" "cmd-case"] (:cmds multi-parent))) + (is (= ["cleanup-a" "cleanup-b" "cleanup-case"] (:cleanup multi-parent))) + (is (= [:base :addon :case] (:tags multi-parent)))) + (testing "deep merge keys" + (is (= {:nested {:left 1 :right 2 :leaf 3} + :only-base true + :only-case true} + (:vars multi-parent))) + (is (= {:commands ["case-command"] + :options {:global ["--base"] + :graph ["--case"]}} + (:covers multi-parent))) + (is (= {[:status] "ok" + [:data :base] 1 + [:data :addon] 2 + [:data :case] 3} + (get-in multi-parent [:expect :stdout-json-paths])))) + (testing "child expect overrides inherited maps when parent omits them" + (is (= {:exit 1 + :stdout-json-paths {[:error :code] "boom"}} + (:expect negative-case)))) + (testing "base templates can omit happy-path defaults" + (is (= ["setup-shared"] (:setup negative-case))) + (is (= ["cmd-shared"] (:cmds negative-case))) + (is (= ["cleanup-shared"] (:cleanup negative-case)))) + (testing "scalar keys are overridden by child" + (is (= "case-graph" (:graph multi-parent))))))) + +(deftest load-cases-detects-circular-template-inheritance-with-cycle-path + (with-redefs [manifests/read-edn-file (fn [_] + {:templates + {:a {:extends :b + :setup ["a"]} + :b {:extends :a + :setup ["b"]}} + :cases [{:id "cycle" :extends :a}]})] + (let [error (try + (manifests/load-cases :sync) + nil + (catch clojure.lang.ExceptionInfo error + error))] + (is (some? error)) + (is (re-find #"Circular template inheritance" (.getMessage error))) + (is (= [:a :b :a] (:cycle (ex-data error))))))) + +(deftest load-cases-validates-extends-entries + (with-redefs [manifests/read-edn-file (fn [_] + {:templates {:base {:cmds ["base"]}} + :cases [{:id "invalid-extends" + :extends [:base "bad"] + :cmds ["case"]}]})] + (let [error (try + (manifests/load-cases :non-sync) + nil + (catch clojure.lang.ExceptionInfo error + error))] + (is (some? error)) + (is (re-find #"Invalid :extends entries" (.getMessage error))) + (is (= ["bad"] (:invalid-entries (ex-data error))))))) + +(deftest load-cases-lint-detects-invalid-extends-references + (with-redefs [manifests/read-edn-file (fn [_] + {:templates {:base {:cmds ["base"]} + :unused {:extends :missing-template + :cmds ["unused"]}} + :cases [{:id "valid" :extends :base :cmds ["case"]} + {:id "invalid" :extends :also-missing :cmds ["case"]}]})] + (let [error (try + (manifests/load-cases :sync) + nil + (catch clojure.lang.ExceptionInfo error + error)) + issues (:issues (ex-data error))] + (is (some? error)) + (is (re-find #"manifest lint failed" (.getMessage error))) + (is (= #{:also-missing :missing-template} + (set (map :target (filter #(= :invalid-extends (:type %)) issues)))))))) + +(deftest load-cases-lint-detects-duplicate-case-ids + (with-redefs [manifests/read-edn-file (fn [_] + {:templates {:base {:cmds ["base"]}} + :cases [{:id "dup" :extends :base :cmds ["case-a"]} + {:id "dup" :extends :base :cmds ["case-b"]}]})] + (let [error (try + (manifests/load-cases :non-sync) + nil + (catch clojure.lang.ExceptionInfo error + error)) + duplicate-issues (filter #(= :duplicate-case-id (:type %)) + (:issues (ex-data error)))] + (is (some? error)) + (is (= [{:type :duplicate-case-id :id "dup" :count 2}] + (vec duplicate-issues)))))) + +(deftest load-cases-lint-detects-unused-templates + (with-redefs [manifests/read-edn-file (fn [_] + {:templates {:base {:cmds ["base"]} + :unused {:cmds ["unused"]}} + :cases [{:id "only" :extends :base :cmds ["case"]}]})] + (let [error (try + (manifests/load-cases :sync) + nil + (catch clojure.lang.ExceptionInfo error + error)) + unused-issues (filter #(= :unused-template (:type %)) + (:issues (ex-data error)))] + (is (some? error)) + (is (= [{:type :unused-template :template :unused}] + (vec unused-issues)))))) + +(deftest sync-multi-batch-operations-uses-state-driven-waits-instead-of-fixed-sleeps + (let [cases (manifests/load-cases :sync) + multi-batch (some #(when (= "sync-multi-batch-operations" (:id %)) %) cases) + commands (:cmds multi-batch) + upload-commands (filter #(re-find #"sync upload --graph" %) commands) + wait-commands (filter #(re-find #"wait_sync_status\.py" %) commands)] + (is (some? multi-batch)) + (is (not-any? #(= "sleep 1" %) commands)) + (is (= 1 (count upload-commands))) + (is (= 5 (count wait-commands))) + (is (= 3 (count (filter #(re-find #"--root-dir '\{\{tmp-dir\}\}/graphs-b'.+--timeout-s 30 --interval-s 1" %) wait-commands)))))) + +(deftest sync-manifest-includes-duplicate-upload-negative-case + (let [cases (manifests/load-cases :sync) + duplicate-upload (some #(when (= "sync-upload-rejects-duplicate-remote-graph" (:id %)) %) cases) + json-paths (get-in duplicate-upload [:expect :stdout-json-paths]) + setup-commands (:setup duplicate-upload) + main-commands (:cmds duplicate-upload)] + (is (some? duplicate-upload)) + (is (= 1 (get-in duplicate-upload [:expect :exit]))) + (is (= "graph-already-exists" + (get-in duplicate-upload [:expect :stdout-json-paths [:error :code]]))) + (is (= ["delete it before uploading again"] + (get-in duplicate-upload [:expect :stdout-contains]))) + (is (= 1 (count (filter #(re-find #"sync upload --graph" %) setup-commands)))) + (is (= 1 (count (filter #(re-find #"sync upload --graph" %) main-commands)))) + (is (false? (contains? json-paths [:data :pending-local]))) + (is (false? (contains? json-paths [:data :pending-asset]))) + (is (false? (contains? json-paths [:data :pending-server]))) + (is (false? (contains? json-paths [:data :last-error]))))) + +(deftest sync-status-steady-state-does-not-repeat-identical-b-side-steady-state-waits + (let [cases (manifests/load-cases :sync) + steady-state (some #(when (= "sync-status-steady-state" (:id %)) %) cases) + steady-waits (filter #(re-find #"wait_sync_status\.py.+--root-dir '\{\{tmp-dir\}\}/graphs-b'.+--timeout-s 30 --interval-s 1" %) (:cmds steady-state))] + (is (some? steady-state)) + (is (= 1 (count steady-waits))) + (is (= 1 (count (filter #(re-find #"sync status --graph" %) (:cmds steady-state))))))) diff --git a/cli-e2e/test/logseq/cli/e2e/paths_test.clj b/cli-e2e/test/logseq/cli/e2e/paths_test.clj new file mode 100644 index 0000000..9fc784a --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/paths_test.clj @@ -0,0 +1,18 @@ +(ns logseq.cli.e2e.paths-test + (:require [clojure.test :refer [deftest is]] + [logseq.cli.e2e.paths :as paths])) + +(deftest repo-root-does-not-depend-on-runtime-file-binding + (let [expected (paths/repo-root)] + (binding [*file* nil] + (is (= expected + (paths/repo-root)))))) + +(deftest required-artifacts-include-cli-runtime-contract + (is (= [(paths/repo-path "cli" "_build" "default" "dist" "logseq-cli.js") + (paths/repo-path "static" "logseq-cli.js") + (paths/repo-path "static" "db-worker-node.js") + (paths/repo-path "dist" "db-worker-node.js") + (paths/repo-path "dist" "db-worker-node-assets.json") + (paths/repo-path "deps" "db-sync" "worker" "dist" "node-adapter.js")] + (paths/required-artifacts)))) diff --git a/cli-e2e/test/logseq/cli/e2e/preflight_test.clj b/cli-e2e/test/logseq/cli/e2e/preflight_test.clj new file mode 100644 index 0000000..268204a --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/preflight_test.clj @@ -0,0 +1,98 @@ +(ns logseq.cli.e2e.preflight-test + (:require [clojure.test :refer [deftest is testing]] + [logseq.cli.e2e.preflight :as preflight])) + +(def required-artifacts + ["/repo/cli/_build/default/dist/logseq-cli.js" + "/repo/static/logseq-cli.js" + "/repo/static/db-worker-node.js" + "/repo/dist/db-worker-node.js" + "/repo/dist/db-worker-node-assets.json" + "/repo/deps/db-sync/worker/dist/node-adapter.js"]) + +(defn- with-required-artifacts + [f] + (with-redefs [logseq.cli.e2e.paths/repo-root (constantly "/repo") + logseq.cli.e2e.paths/required-artifacts (fn [] required-artifacts)] + (f))) + +(deftest build-plan-matches-required-commands + (is (= ["pnpm cli:release" + "pnpm db-worker-node:compile:bundle" + "pnpm --dir deps/db-sync build:node-adapter"] + (mapv :cmd preflight/build-plan))) + (is (not-any? #(re-find #"clojure -M:cljs compile logseq-cli" (:cmd %)) + preflight/build-plan))) + +(deftest missing-artifacts-returns-unreadable-paths + (let [artifacts ["/repo/static/logseq-cli.js" + "/repo/static/db-worker-node.js" + "/repo/dist/db-worker-node.js"] + present? #{"/repo/static/logseq-cli.js"}] + (is (= ["/repo/static/db-worker-node.js" + "/repo/dist/db-worker-node.js"] + (preflight/missing-artifacts artifacts present?))))) + +(deftest skip-build-avoids-running-shell-commands + (let [called? (atom false) + result (preflight/run! {:skip-build true + :run-command (fn [_] + (reset! called? true)) + :file-exists? (constantly false)})] + (is (= :skipped (:status result))) + (is (false? @called?)))) + +(deftest build-runs-commands-even-when-artifacts-are-ready + (let [calls (atom [])] + (with-required-artifacts + #(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + {:cmd cmd + :exit 0 + :out "" + :err ""}) + :file-exists? (set required-artifacts)})] + (is (= :ok (:status result))) + (is (= ["pnpm cli:release" + "pnpm db-worker-node:compile:bundle" + "pnpm --dir deps/db-sync build:node-adapter"] + @calls)))))) + +(deftest build-runs-commands-when-artifacts-are-partially-present + (let [calls (atom []) + existing (atom (disj (set required-artifacts) + "/repo/dist/db-worker-node-assets.json"))] + (with-required-artifacts + #(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + (reset! existing (set required-artifacts)) + {:cmd cmd + :exit 0 + :out "" + :err ""}) + :file-exists? (fn [path] + (contains? @existing path))})] + (is (= :ok (:status result))) + (is (= ["pnpm cli:release" + "pnpm db-worker-node:compile:bundle" + "pnpm --dir deps/db-sync build:node-adapter"] + @calls)))))) + +(deftest build-runs-commands-when-artifacts-are-absent + (let [calls (atom []) + existing (atom #{})] + (with-required-artifacts + #(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + (reset! existing (set required-artifacts)) + {:cmd cmd + :exit 0 + :out "" + :err ""}) + :file-exists? (fn [path] + (contains? @existing path))})] + (is (= :ok (:status result))) + (is (= ["pnpm cli:release" + "pnpm db-worker-node:compile:bundle" + "pnpm --dir deps/db-sync build:node-adapter"] + @calls)))))) diff --git a/cli-e2e/test/logseq/cli/e2e/random_bidirectional_block_ops_test.py b/cli-e2e/test/logseq/cli/e2e/random_bidirectional_block_ops_test.py new file mode 100644 index 0000000..ec457c6 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/random_bidirectional_block_ops_test.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + + +MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "random_bidirectional_block_ops.py" +spec = importlib.util.spec_from_file_location("random_bidirectional_block_ops", MODULE_PATH) +random_bidirectional_block_ops = importlib.util.module_from_spec(spec) +assert spec.loader is not None +sys.modules[spec.name] = random_bidirectional_block_ops +spec.loader.exec_module(random_bidirectional_block_ops) + + +def test_default_profile_is_faster_than_high_stress(monkeypatch) -> None: + monkeypatch.setattr( + random_bidirectional_block_ops.sys, + "argv", + [ + "random_bidirectional_block_ops.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--root-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--root-dir-b", + "/tmp/b", + "--page", + "Home", + ], + ) + default_args = random_bidirectional_block_ops.parse_args() + + monkeypatch.setattr( + random_bidirectional_block_ops.sys, + "argv", + [ + "random_bidirectional_block_ops.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--root-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--root-dir-b", + "/tmp/b", + "--page", + "Home", + "--profile", + "high-stress", + ], + ) + high_stress_args = random_bidirectional_block_ops.parse_args() + + assert default_args.root_dir_a == "/tmp/a" + assert default_args.root_dir_b == "/tmp/b" + assert default_args.profile == "default" + assert high_stress_args.profile == "high-stress" + assert default_args.rounds_per_client < high_stress_args.rounds_per_client + + +def test_explicit_rounds_override_profile_default(monkeypatch) -> None: + monkeypatch.setattr( + random_bidirectional_block_ops.sys, + "argv", + [ + "random_bidirectional_block_ops.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--root-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--root-dir-b", + "/tmp/b", + "--page", + "Home", + "--profile", + "high-stress", + "--rounds-per-client", + "12", + ], + ) + + args = random_bidirectional_block_ops.parse_args() + + assert args.profile == "high-stress" + assert args.rounds_per_client == 12 diff --git a/cli-e2e/test/logseq/cli/e2e/runner_test.clj b/cli-e2e/test/logseq/cli/e2e/runner_test.clj new file mode 100644 index 0000000..a2a1ae1 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/runner_test.clj @@ -0,0 +1,152 @@ +(ns logseq.cli.e2e.runner-test + (:require [clojure.test :refer [deftest is]] + [logseq.cli.e2e.runner :as runner])) + +(deftest render-case-expands-template-values-recursively + (let [rendered (runner/render-case + {:id "graph-create" + :setup ["{{cli}} --graph {{graph-arg}}"] + :cmds ["{{cli}} graph info --graph {{graph-arg}}"] + :expect {:stdout-json-paths {[:data :graph] "{{graph}}" + [:status] "ok"}}} + {:cli "node /tmp/logseq-cli.js" + :graph "demo" + :graph-arg "'demo'"})] + (is (= ["node /tmp/logseq-cli.js --graph 'demo'"] (:setup rendered))) + (is (= ["node /tmp/logseq-cli.js graph info --graph 'demo'"] (:cmds rendered))) + (is (= "demo" (get-in rendered [:expect :stdout-json-paths [:data :graph]]))))) + +(deftest run-case-executes-setup-before-main-command + (let [calls (atom []) + result (runner/run-case! + {:id "graph-info" + :setup ["setup one" "setup two"] + :cmds ["main command one" "main command two"] + :expect {:exit 0}} + {:context {} + :run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + {:cmd cmd + :exit 0 + :out "" + :err ""})})] + (is (= ["setup one" "setup two" "main command one" "main command two"] @calls)) + (is (= "graph-info" (:id result))) + (is (= "main command two" (get-in result [:result :cmd]))))) + +(deftest run-case-includes-command-phase-metadata-when-detailed + (let [calls (atom [])] + (runner/run-case! + {:id "graph-info" + :setup ["setup one" "setup two"] + :cmds ["main command one" "main command two"] + :cleanup ["cleanup one"] + :expect {:exit 0}} + {:context {} + :detailed-log? true + :run-command (fn [{:keys [cmd phase step-index step-total case-id throw?] :as opts}] + (swap! calls conj (select-keys opts [:cmd :phase :step-index :step-total :case-id :throw?])) + {:cmd cmd + :exit 0 + :out "" + :err ""})}) + (is (= [{:cmd "setup one" :phase :setup :step-index 1 :step-total 2 :case-id "graph-info" :throw? true} + {:cmd "setup two" :phase :setup :step-index 2 :step-total 2 :case-id "graph-info" :throw? true} + {:cmd "main command one" :phase :main :step-index 1 :step-total 2 :case-id "graph-info" :throw? true} + {:cmd "main command two" :phase :main :step-index 2 :step-total 2 :case-id "graph-info" :throw? false} + {:cmd "cleanup one" :phase :cleanup :step-index 1 :step-total 1 :case-id "graph-info" :throw? false}] + @calls)))) + +(deftest run-case-collects-step-timings-when-enabled + (let [result (runner/run-case! + {:id "graph-info" + :setup ["setup one"] + :cmds ["main command"] + :cleanup ["cleanup one"] + :expect {:exit 0}} + {:context {} + :timings? true + :run-command (fn [{:keys [cmd]}] + {:cmd cmd + :exit 0 + :out "" + :err ""})})] + (is (= 3 (count (:timings result)))) + (is (= [:setup :main :cleanup] + (mapv :phase (:timings result)))))) + +(deftest run-case-attaches-timings-to-error-when-enabled + (let [error (try + (runner/run-case! + {:id "graph-info" + :setup ["setup one"] + :cmds ["main command"] + :cleanup ["cleanup one"] + :expect {:exit 0}} + {:context {} + :timings? true + :run-command (fn [{:keys [cmd]}] + (when (= cmd "main command") + (throw (ex-info "boom" {:cmd cmd}))) + {:cmd cmd + :exit 0 + :out "" + :err ""})}) + nil + (catch clojure.lang.ExceptionInfo error + error)) + timings (:timings (ex-data error))] + (is (= "graph-info" (:case-id (ex-data error)))) + (is (= [:setup :main :cleanup] + (mapv :phase timings))) + (is (= :failed (:status (second timings)))))) + +(deftest run-case-validates-json-paths-and-nonzero-exit + (let [result (runner/run-case! + {:id "invalid-shell" + :cmds ["node static/logseq-cli.js completion fish"] + :expect {:exit 1 + :stdout-json-paths {[:status] "error" + [:error :code] "invalid-options"}}} + {:context {} + :run-command (fn [{:keys [cmd]}] + {:cmd cmd + :exit 1 + :out "{\"status\":\"error\",\"error\":{\"code\":\"invalid-options\"}}" + :err ""})})] + (is (= 1 (get-in result [:result :exit]))))) + +(deftest assert-result-validates-edn-paths + (is (nil? (runner/assert-result! + {:id "graph-list-edn" + :expect {:exit 0 + :stdout-edn-paths {[:status] :ok + [:data :graphs] ["demo"]}}} + {:cmd "node cli.js graph list" + :exit 0 + :out "{:status :ok, :data {:graphs [\"demo\"]}}" + :err ""})))) + +(deftest assert-result-validates-stdout-not-contains + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"stdout contained forbidden text" + (runner/assert-result! + {:id "remove-block" + :expect {:exit 0 + :stdout-not-contains ["Alpha block"]}} + {:cmd "node cli.js show --page Home" + :exit 0 + :out "Home\n- Alpha block" + :err ""})))) + +(deftest assert-result-normalizes-json-escaped-windows-paths-for-contains + (is (nil? (runner/assert-result! + {:id "doctor-dev-script-json" + :expect {:exit 0 + :stdout-contains ["static/db-worker-node.js" + "C:\\Users\\demo\\tmp\\graph-export.edn"]}} + {:cmd "node cli.js doctor --dev-script" + :exit 0 + :out "{\"status\":\"ok\",\"data\":{\"path\":\"C:\\\\Users\\\\demo\\\\tmp\\\\graph-export.edn\",\"message\":\"Found readable file: C:\\\\Users\\\\demo\\\\project\\\\static\\\\db-worker-node.js\"}}" + :err ""})))) diff --git a/cli-e2e/test/logseq/cli/e2e/shell_test.clj b/cli-e2e/test/logseq/cli/e2e/shell_test.clj new file mode 100644 index 0000000..9d696d1 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/shell_test.clj @@ -0,0 +1,54 @@ +(ns logseq.cli.e2e.shell-test + (:require [babashka.process :as process] + [clojure.test :refer [deftest is testing]] + [logseq.cli.e2e.shell :as shell])) + +(deftest preserves-the-exact-command-string + (let [captured (atom nil) + command "node static/logseq-cli.js --graph 'Graph With Space' --help" + result (shell/run! {:cmd command + :dir "/repo" + :executor (fn [cmd opts] + (reset! captured {:cmd cmd + :opts opts}) + {:exit 0 + :out "ok" + :err ""})})] + (is (= command (:cmd result))) + (is (= command (:cmd @captured))) + (is (= "/repo" (get-in @captured [:opts :dir]))))) + +(deftest shell-failures-include-command-context + (let [command "node static/logseq-cli.js completion fish"] + (try + (shell/run! {:cmd command + :executor (fn [_ _] + {:exit 2 + :out "" + :err "unsupported shell"})}) + (is false "Expected shell/run! to throw") + (catch clojure.lang.ExceptionInfo ex + (is (= command (:cmd (ex-data ex)))) + (is (= 2 (:exit (ex-data ex)))) + (is (= "unsupported shell" (:err (ex-data ex)))))))) + +(deftest allow-failure-returns-result-without-throwing + (let [command "node static/logseq-cli.js graph info --graph missing" + result (shell/run! {:cmd command + :throw? false + :executor (fn [_ _] + {:exit 1 + :out "failed" + :err "missing"})})] + (is (= command (:cmd result))) + (is (= 1 (:exit result))) + (is (= "failed" (:out result))) + (is (= "missing" (:err result))))) + +(deftest default-executor-uses-resolvable-shell-command + (let [captured (atom nil)] + (with-redefs [process/process (fn [_opts & args] + (reset! captured args) + (future {:exit 0}))] + (shell/run! {:cmd "echo ok"})) + (is (= ["bash" "-c" "echo ok"] @captured)))) diff --git a/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj b/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj new file mode 100644 index 0000000..87d7cb2 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj @@ -0,0 +1,184 @@ +(ns logseq.cli.e2e.sync-fixture-test + (:require [babashka.fs :as fs] + [clojure.java.shell :as shell] + [clojure.string :as string] + [clojure.test :refer [deftest is testing]] + [logseq.cli.e2e.sync-fixture :as sync-fixture])) + +(defn- free-port [] + (with-open [socket (java.net.ServerSocket. 0)] + (.getLocalPort socket))) + +(deftest prepare-sync-config-writes-oauth-token-endpoint + (let [tmp-dir (fs/create-temp-dir {:prefix "logseq-cli-sync-config-test-"}) + auth-path (fs/path tmp-dir "auth.json") + config-path (fs/path tmp-dir "cli.edn") + script (fs/path "cli-e2e" "scripts" "prepare_sync_config.py") + result (do + (spit (str auth-path) "{\"refresh-token\":\"refresh-token\"}\n") + (shell/sh "python3" (str script) + "--output" (str config-path) + "--auth-path" (str auth-path) + "--http-base" "http://127.0.0.1:18080" + "--ws-url" "ws://127.0.0.1:18080/sync/%s"))] + (try + (is (= 0 (:exit result)) (:err result)) + (let [config (slurp (str config-path))] + (is (string/includes? config ":http-base \"http://127.0.0.1:18080\"")) + (is (string/includes? config ":ws-url \"ws://127.0.0.1:18080/sync/%s\"")) + (is (string/includes? config ":oauth-token-endpoint ")) + (is (string/includes? config "https://logseq-prod.auth.us-east-1.amazoncognito.com/oauth2/token"))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest prepare-case-injects-case-local-sync-resources + (let [suite-context {:sync-port "18080" + :sync-http-base "http://127.0.0.1:18080" + :sync-ws-url "ws://127.0.0.1:18080/sync/%s"} + input-case {:id "sync-case" + :vars {:existing true} + :setup ["mkdir -p '{{tmp-dir}}/graphs-b'" + "mkdir -p '{{tmp-dir}}/home/logseq'" + "cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'" + "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + "python3 '{{repo-root}}/cli-e2e/scripts/db_sync_server.py' start --port 18080" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"] + :cleanup ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" + "python3 '{{repo-root}}/cli-e2e/scripts/db_sync_server.py' stop --pid-file '{{tmp-dir}}/db-sync-server.pid'"]} + prepared (sync-fixture/prepare-case input-case suite-context)] + (is (= "sync-case" (:id prepared))) + (is (= ["mkdir -p '{{tmp-dir}}/graphs-b'" + "mkdir -p '{{tmp-dir}}/home/logseq'" + "cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'" + "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"] + (:setup prepared))) + (is (= ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"] + (:cleanup prepared))) + (is (= "http://127.0.0.1:18080" (get-in prepared [:vars :sync-http-base]))) + (is (= "ws://127.0.0.1:18080/sync/%s" (get-in prepared [:vars :sync-ws-url]))) + (is (= "18080" (get-in prepared [:vars :sync-port]))) + (is (= "11111" (get-in prepared [:vars :e2ee-password]))) + (is (= "11111" (get-in prepared [:vars :e2ee-password-arg]))) + (is (not (contains? (:vars prepared) :suite-auth-path))) + (is (not (contains? (:vars prepared) :suite-config-path))) + (is (not-any? #(string/includes? % "suite-auth-path") (:setup prepared))) + (is (not-any? #(string/includes? % "suite-config-path") (:setup prepared))) + (is (not-any? #(string/includes? % "db_sync_server.py' start") (:setup prepared))) + (is (not-any? #(string/includes? % "db_sync_server.py' stop") (:cleanup prepared))))) + +(deftest prepare-case-places-case-local-auth-before-cli-sync-commands + (let [suite-context {:suite-tmp-dir "/tmp/sync-suite" + :sync-port "18080" + :sync-http-base "http://127.0.0.1:18080" + :sync-ws-url "ws://127.0.0.1:18080/sync/%s"} + prepared (sync-fixture/prepare-case + {:id "sync-case" + :setup ["mkdir -p '{{tmp-dir}}/graphs-b'" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}"] + :cleanup []} + suite-context) + setup (:setup prepared) + bootstrap-cmd (nth setup 5)] + (is (= "mkdir -p '{{tmp-dir}}/graphs-b'" (first setup))) + (is (= "mkdir -p '{{tmp-dir}}/home/logseq'" (second setup))) + (is (= "cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'" (nth setup 2))) + (is (= "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + (nth setup 3))) + (is (= "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'" + (nth setup 4))) + (is (string/includes? bootstrap-cmd "sync ensure-keys")) + (is (string/includes? bootstrap-cmd "--upload-keys")) + (is (string/includes? bootstrap-cmd "/tmp/sync-suite/user-rsa-keys.lock")) + (is (string/includes? bootstrap-cmd "/tmp/sync-suite/user-rsa-keys.ready")) + (is (= "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + (nth setup 6))) + (is (= "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" + (nth setup 7))) + (is (not-any? #(and (string/includes? % "sync ensure-keys") + (not= % bootstrap-cmd) + (string/includes? % "--upload-keys")) + setup)))) + +(deftest prepare-case-accepts-custom-e2ee-password + (let [suite-context {:sync-port "18080" + :sync-http-base "http://127.0.0.1:18080" + :sync-ws-url "ws://127.0.0.1:18080/sync/%s" + :e2ee-password "pass word"} + prepared (sync-fixture/prepare-case {:id "sync-case" + :setup [] + :cleanup []} + suite-context)] + (is (= "pass word" (get-in prepared [:vars :e2ee-password]))) + (is (= "'pass word'" (get-in prepared [:vars :e2ee-password-arg]))))) + +(deftest before-and-after-suite-only-manage-shared-sync-server-lifecycle + (let [calls (atom []) + run-command (fn [opts] + (swap! calls conj opts) + {:exit 0 + :out "" + :err ""}) + suite-context (sync-fixture/before-suite! {:run-command run-command})] + (testing "before-suite only starts the shared server" + (is (= 1 (count @calls))) + (is (string/includes? (:cmd (first @calls)) "db_sync_server.py")) + (is (string/includes? (:cmd (first @calls)) " start ")) + (is (string/includes? (:cmd (first @calls)) "--data-dir")) + (is (not (string/includes? (:cmd (first @calls)) "--root-dir"))) + (is (string/includes? (:cmd (first @calls)) "--port 18080")) + (is (string/includes? (:cmd (first @calls)) "--startup-timeout-s 60")) + (is (not (string/includes? (:cmd (first @calls)) "prepare_sync_config.py"))) + (is (not (contains? suite-context :suite-auth-path))) + (is (not (contains? suite-context :suite-config-path)))) + (sync-fixture/after-suite! suite-context {:run-command run-command}) + (testing "after-suite only stops the shared server once" + (is (= 2 (count @calls))) + (is (string/includes? (:cmd (last @calls)) "db_sync_server.py")) + (is (string/includes? (:cmd (last @calls)) " stop ")) + (is (false? (:throw? (last @calls))))))) + +(deftest db-sync-server-start-adds-db-sync-node-modules-to-node-path + (let [tmp-dir (fs/create-temp-dir {:prefix "logseq-cli-db-sync-server-test-"}) + repo-root (fs/path tmp-dir "repo") + entry (fs/path repo-root "deps" "db-sync" "worker" "dist" "node-adapter.js") + node-modules (fs/path repo-root "deps" "db-sync" "node_modules") + pid-file (fs/path tmp-dir "server.pid") + log-file (fs/path tmp-dir "server.log") + data-dir (fs/path tmp-dir "server-data") + node-path-file (fs/path tmp-dir "node-path.txt") + script (fs/path "cli-e2e" "scripts" "db_sync_server.py") + port (free-port)] + (try + (fs/create-dirs (fs/parent entry)) + (fs/create-dirs node-modules) + (spit (str entry) + (str "const fs = require('node:fs');\n" + "const http = require('node:http');\n" + "fs.writeFileSync(" (pr-str (str node-path-file)) ", process.env.NODE_PATH || '', 'utf8');\n" + "http.createServer((req, res) => {\n" + " if (req.url === '/health') { res.writeHead(200); res.end('ok'); return; }\n" + " res.writeHead(404); res.end('not found');\n" + "}).listen(process.env.DB_SYNC_PORT, '127.0.0.1');\n")) + (let [start (shell/sh "python3" (str script) + "start" + "--repo-root" (str repo-root) + "--pid-file" (str pid-file) + "--log-file" (str log-file) + "--data-dir" (str data-dir) + "--port" (str port) + "--startup-timeout-s" "5" + "--auth-path" "")] + (try + (is (= 0 (:exit start)) (:err start)) + (is (string/includes? (slurp (str node-path-file)) (str node-modules))) + (finally + (shell/sh "python3" (str script) + "stop" + "--pid-file" (str pid-file) + "--shutdown-timeout-s" "2")))) + (finally + (fs/delete-tree tmp-dir))))) diff --git a/cli-e2e/test/logseq/cli/e2e/wait_sync_status_test.py b/cli-e2e/test/logseq/cli/e2e/wait_sync_status_test.py new file mode 100644 index 0000000..ba9df4b --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/wait_sync_status_test.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + + +MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "wait_sync_status.py" +spec = importlib.util.spec_from_file_location("wait_sync_status", MODULE_PATH) +wait_sync_status = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(wait_sync_status) + + +def test_resolved_status_command_is_built_once_and_reused() -> None: + args = SimpleNamespace( + cli="~/repo/static/logseq-cli.js", + root_dir="~/tmp/root", + config="~/tmp/cli.edn", + graph="demo", + ) + + command = wait_sync_status.status_command(args) + + assert command[0] == "node" + assert command[2] == "--root-dir" + assert command[-2:] == ["--graph", "demo"] + assert Path(command[1]).is_absolute() + assert Path(command[3]).is_absolute() + assert Path(command[5]).is_absolute() + assert wait_sync_status.status_command(args) is command + + +def test_run_status_uses_precomputed_command() -> None: + command = ["node", "/abs/cli.js", "--graph", "demo"] + args = SimpleNamespace(status_command=command) + calls = [] + + def fake_run(cmd, capture_output, text): + calls.append(cmd) + return SimpleNamespace( + returncode=0, + stdout='{"status":"ok","data":{"pending-local":0,"pending-asset":0,"pending-server":0}}', + stderr="", + ) + + original_run = wait_sync_status.subprocess.run + wait_sync_status.subprocess.run = fake_run + try: + payload = wait_sync_status.run_status(args) + finally: + wait_sync_status.subprocess.run = original_run + + assert payload["status"] == "ok" + assert calls == [command] diff --git a/cli/.ocamlformat b/cli/.ocamlformat new file mode 100644 index 0000000..de9ec72 --- /dev/null +++ b/cli/.ocamlformat @@ -0,0 +1,2 @@ +version = 0.29.0 +profile = default diff --git a/cli/AGENTS.md b/cli/AGENTS.md new file mode 100644 index 0000000..66c6453 --- /dev/null +++ b/cli/AGENTS.md @@ -0,0 +1,10 @@ +- Do not modify any files under `spec/` during development unless explicitly asked to modify the `.mli` files under `spec/`. +- Do not modify any dune file during development unless explicitly asked. +- If development is blocked because the `.mli` definitions under `spec/` are unclear or unreasonable, stop development immediately and report the specific spec issue, suggested changes, and rationale. +- This repository implements only the CLI portion. It does not include the db-worker-node server, and should use the existing cljs version of the db-worker-node server. +- MUST not use magic methods to cast types. +- Platform-specific code must only appear under `lib/platform/`. +- Only keep test cases related to business logic. +- Do not keep temporary tests added during refactoring, and do not keep test cases that assert internal implementation details. +- Before adding a new external JavaScript function, first check whether a corresponding implementation already exists in `melange.js` or `melange.node`. Avoid adding new externals whenever possible. +- MUST not disable compiler warnings diff --git a/cli/bin/dune b/cli/bin/dune new file mode 100644 index 0000000..9ccaa72 --- /dev/null +++ b/cli/bin/dune @@ -0,0 +1,24 @@ +(melange.emit + (target logseq_cli_melange) + (modules main) + (module_systems esm) + (preprocess + (pps melange.ppx)) + (libraries logseq_cli_platform_node logseq_cli)) + +(melange.emit + (target playground) + (modules playground) + (module_systems esm) + (preprocess + (pps melange.ppx)) + (libraries + logseq_cli_platform_node + logseq_cli + melange-edn-melange)) + +(install + (section bin) + (package logseq-cli) + (files + (logseq_cli_melange/bin/main.js as logseq-cli.js))) diff --git a/cli/bin/main.ml b/cli/bin/main.ml new file mode 100644 index 0000000..c81a320 --- /dev/null +++ b/cli/bin/main.ml @@ -0,0 +1,58 @@ +open Cli_effect.Infix + +let cli_argv () = + let process_argv = Cli_platform.argv () in + let len = Array.length process_argv in + let args = if len <= 2 then [||] else Array.sub process_argv 2 (len - 2) in + Vec.of_array args + +let cli_env () = + Cli_unix.environment () |> Vec.of_array + |> Vec.filter_map (fun item -> + match String.index_opt item '=' with + | None -> None + | Some index -> + let key = String.sub item 0 index in + let value = + String.sub item (index + 1) (String.length item - index - 1) + in + Some (key, value)) + +let cli_input () = + { + Cli.argv = cli_argv (); + env = cli_env (); + cwd = Sys.getcwd (); + stdin = None; + } + +let finalize_error err = Cli.final_effect (Cli.make_error_state err) + +let run app input = + let raw = Cli.make_raw_argv_state app input in + match Cli.parse_args app raw with + | Error err -> finalize_error err + | Ok parsed -> ( + Cli.resolve_config app parsed >>= function + | Error err -> finalize_error err + | Ok resolved -> ( + Cli.build_action app resolved >>= function + | Error err -> finalize_error err + | Ok built -> ( + Cli.execute_action app built >>= function + | Error err -> finalize_error err + | Ok executed -> Cli.final_effect executed))) + +let () = + let complete code = if code <> 0 then exit code in + let fail exn = + prerr_endline (Printexc.to_string exn); + exit 1 + in + let task = + try + let app = Cli.make_app_context () in + run app (cli_input ()) + with exn -> Cli_effect.error exn + in + Cli_effect.on_any task complete fail diff --git a/cli/bin/playground.ml b/cli/bin/playground.ml new file mode 100644 index 0000000..b8a67a8 --- /dev/null +++ b/cli/bin/playground.ml @@ -0,0 +1,6 @@ +Melange_edn_melange.of_edn_string {|{:aaa 11}|} +|> Melange_edn_melange.to_edn_string |> print_string +;; + +Melange_edn_melange.of_edn_string {|{:aaa 11}|} +|> Melange_edn_melange.to_json_string |> print_string diff --git a/cli/dune b/cli/dune new file mode 100644 index 0000000..e69de29 diff --git a/cli/dune-project b/cli/dune-project new file mode 100644 index 0000000..6eaaff9 --- /dev/null +++ b/cli/dune-project @@ -0,0 +1,36 @@ +(lang dune 3.23) + +(using melange 1.0) + +(name logseq-cli) + +(generate_opam_files true) + +(source + (github username/reponame)) + +(authors "Author Name ") + +(maintainers "Maintainer Name ") + +(license LICENSE) + +(documentation https://url/to/documentation) + +(package + (name logseq-cli) + (synopsis "A short synopsis") + (description "A longer description") + (depends + (ocaml (>= 5.1.1)) + melange + melange-fetch + melange-transit-melange + melange-edn-melange + melange-fest + rrbvec + humanize) + (tags + ("add topics" "to describe" your project))) + +; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html diff --git a/cli/lib/add.ml b/cli/lib/add.ml new file mode 100644 index 0000000..5194151 --- /dev/null +++ b/cli/lib/add.ml @@ -0,0 +1,1144 @@ +type opts = { + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page_name : string option; + pos : Block.position option; + status : string option; + tags_edn : string option; + properties_edn : string option; + content : string option; + blocks_edn : string option; + blocks_file : Cli_primitive.path option; +} + +type action = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page_name : string option; + pos : Block.position; + status : Cli_primitive.keyword option; + tags : Selector.tag Rrbvec.t; + properties : Property.assignment Rrbvec.t; + blocks : Block.t Rrbvec.t; +} + +let kw value = Edn_util.keyword value +let vector_vec values = Edn_util.vector_vec values +let vector values = vector_vec values +let list_vec values = Edn_util.list_vec values +let list values = list_vec values +let sym value = Edn_util.symbol value +let normalized_lookup_name value = String.lowercase_ascii (String.trim value) + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let edn_value_of_string ~label text = + try Ok (Melange_edn_melange.of_edn_string text) + with Melange_edn_melange.Parse_error _ -> + Error (Error.invalid_options ("invalid " ^ label ^ " edn")) + +let strip_tag_prefix value = + let value = String.trim value in + let rec loop index = + if index < String.length value && value.[index] = '#' then loop (index + 1) + else index + in + let start = loop 0 in + String.sub value start (String.length value - start) |> String.trim + +let tag_of_value value = + match + ( Edn_util.as_int64 value, + Edn_util.as_uuid value, + Edn_util.as_keyword_t value, + Edn_util.as_string value ) + with + | Some id, _, _, _ -> Some (Selector.Tag_id id) + | _, Some uuid, _, _ -> Some (Tag_uuid uuid) + | _, _, Some ident, _ -> Some (Tag_ident ident) + | _, _, _, Some value -> + let value = strip_tag_prefix value in + if value = "" then None + else if Cli_primitive.is_uuid_string value then Some (Tag_uuid value) + else if value.[0] = ':' then Some (Tag_ident (Edn_util.keyword_t value)) + else Some (Tag_name value) + | _ -> None + +let parse_tags_option = function + | None -> Ok Vec.empty + | Some text -> + Error.bind (edn_value_of_string ~label:"tags" text) (fun value -> + match Edn_util.as_vector value with + | Some values when Vec.is_empty values -> + Error (Error.invalid_options "tags must be a non-empty vector") + | Some values -> + let rec loop acc values = + match Vec.pop_front values with + | None -> Ok acc + | Some (value, rest) -> ( + match tag_of_value value with + | Some tag -> loop (Vec.push_back acc tag) rest + | None -> + Error + (Error.invalid_options + "tags must be strings, keywords, uuids, or ids")) + in + loop Vec.empty values + | None -> Error (Error.invalid_options "tags must be a vector")) + +let parse_tags_vector_option = parse_tags_option + +let normalize_property_name value = + let value = String.trim value in + if value = "" then None else Some (Edn_util.keyword_t value) + +let property_key_of_value value = + match + ( Edn_util.as_int64 value, + Edn_util.as_keyword_t value, + Edn_util.as_string value ) + with + | Some id, _, _ -> Some (Property.Key_id id) + | _, Some ident, _ -> Some (Key_ident ident) + | _, _, Some value -> + Option.map + (fun ident -> Property.Key_ident ident) + (normalize_property_name value) + | _ -> None + +let parse_properties_option ?(allow_non_built_in = false) = function + | None -> Ok Vec.empty + | Some text -> + let _ = allow_non_built_in in + Error.bind (edn_value_of_string ~label:"properties" text) (fun value -> + match Edn_util.as_map value with + | Some fields when Vec.is_empty fields -> + Error (Error.invalid_options "properties must be a non-empty map") + | Some fields -> + let rec loop acc fields = + match Vec.pop_front fields with + | None -> Ok acc + | Some ((key, value), rest) -> ( + match property_key_of_value key with + | Some key -> + loop (Vec.push_back acc { Property.key; value }) rest + | None -> + Error + (Error.invalid_options + ("invalid property key: " + ^ Melange_edn_melange.to_edn_string key))) + in + loop Vec.empty fields + | None -> Error (Error.invalid_options "properties must be a map")) + +let parse_properties_vector_option ?(allow_non_built_in = false) = function + | None -> Ok Vec.empty + | Some text -> + let _ = allow_non_built_in in + Error.bind (edn_value_of_string ~label:"properties" text) (fun value -> + match Edn_util.as_vector value with + | Some values when Vec.is_empty values -> + Error + (Error.invalid_options "properties must be a non-empty vector") + | Some values -> + let rec loop acc values = + match Vec.pop_front values with + | None -> Ok acc + | Some (value, rest) -> ( + match property_key_of_value value with + | Some key -> loop (Vec.push_back acc key) rest + | None -> + Error + (Error.invalid_options + ("invalid property key: " + ^ Melange_edn_melange.to_edn_string value))) + in + loop Vec.empty values + | None -> Error (Error.invalid_options "properties must be a vector")) + +let normalize_status value = + match String.lowercase_ascii (String.trim value) with + | "" -> None + | "todo" | "later" -> Some (Edn_util.keyword_t "logseq.property/status.todo") + | "doing" | "now" | "in-progress" | "in progress" | "inprogress" -> + Some (Edn_util.keyword_t "logseq.property/status.doing") + | "done" -> Some (Edn_util.keyword_t "logseq.property/status.done") + | "wait" | "waiting" | "backlog" -> + Some (Edn_util.keyword_t "logseq.property/status.backlog") + | "canceled" | "cancelled" -> + Some (Edn_util.keyword_t "logseq.property/status.canceled") + | "in-review" | "in_review" | "inreview" -> + Some (Edn_util.keyword_t "logseq.property/status.in-review") + | value when String.length value > 0 && value.[0] = ':' -> + Some (Edn_util.keyword_t value) + | _ -> None + +let invalid_options (opts : opts) = + let target_selectors = + Vec.filter Option.is_some + (Vec.of_array + [| + Option.map Int64.to_string opts.target_id; + opts.target_uuid; + Option.map String.trim opts.target_page_name; + |]) + in + let nonempty = function + | Some value -> String.trim value <> "" + | None -> false + in + let has_blocks = + nonempty opts.blocks_edn || Option.is_some opts.blocks_file + in + let has_metadata = nonempty opts.tags_edn || nonempty opts.properties_edn in + let invalid_target_uuid = + match opts.target_uuid with + | Some uuid -> not (Cli_primitive.is_uuid_string uuid) + | None -> false + in + match (opts.pos, opts.target_page_name, target_selectors) with + | _, _, _ when invalid_target_uuid -> + Some "Option target-uuid must be a valid UUID string" + | Some Block.Sibling, Some page, _ when String.trim page <> "" -> + Some "--pos sibling is only valid for block targets" + | Some Block.Sibling, _, selectors when Vec.is_empty selectors -> + Some "--pos sibling is only valid for block targets" + | _, _, _ when Vec.length target_selectors > 1 -> + Some + "only one of --target-id, --target-uuid, or --target-page-name is \ + allowed" + | _, _, _ when has_blocks && has_metadata -> + Some + "tags and properties cannot be combined with --blocks or --blocks-file" + | _ -> None + +module Node_crypto = struct + external random_uuid : unit -> string = "randomUUID" [@@mel.module "crypto"] +end + +let generate_uuid = Node_crypto.random_uuid +let unique = Uuid_refs_types.unique_preserve_order + +let extract_page_refs title = + title |> Uuid_refs_types.extract_wiki_refs + |> Vec.filter_map (fun ref_title -> + let ref_title = String.trim ref_title in + if ref_title = "" || Cli_primitive.is_uuid_string ref_title then None + else Some ref_title) + |> unique + +let rec block_of_value raw = + match Edn_util.as_map raw with + | Some fields -> + let find key = + Vec.find_map + (fun (k, v) -> + match Edn_util.as_string_like k with + | Some k when k = key -> Some v + | _ -> None) + fields + in + let title = + match + ( Option.bind (find "block/title") Edn_util.as_string, + Option.bind (find "block/content") Edn_util.as_string ) + with + | Some value, _ | _, Some value -> Some value + | _ -> None + in + let uuid = Option.bind (find "block/uuid") Edn_util.as_string_like in + let tags = + Option.value + (Option.map + (Vec.filter_map tag_of_value) + (Option.bind (find "block/tags") Edn_util.as_seq)) + ~default:Vec.empty + in + let children = + Option.value + (Option.map (Vec.map block_of_value) + (Option.bind (find "block/children") Edn_util.as_seq)) + ~default:Vec.empty + in + { (Block.make ?uuid ?title ~children ()) with tags; raw } + | None -> ( + match Edn_util.as_string raw with + | Some title -> Block.make ~title () + | None -> { (Block.make ()) with raw }) + +let ensure_block_uuid block = + match block.Block.uuid with + | Some _ -> block + | None -> { block with uuid = Some (generate_uuid ()) } + +let rec ensure_block_uuids block = + let children = Vec.map ensure_block_uuids block.Block.children in + ensure_block_uuid { block with children } + +let parse_blocks_edn ~label text = + Error.bind (edn_value_of_string ~label text) (function value -> + (match Edn_util.as_vector value with + | Some values -> Ok (Vec.map block_of_value values) + | None -> + Error (Error.make Error.Invalid_blocks "blocks must be a vector"))) + +let read_file path = Cli_unix.read_text_file path + +let read_blocks (opts : opts) args = + match + (opts.blocks_edn, opts.blocks_file, Option.map String.trim opts.content) + with + | Some text, _, _ -> parse_blocks_edn ~label:"blocks" text + | None, Some path, _ -> parse_blocks_edn ~label:"blocks" (read_file path) + | None, None, Some content when content <> "" -> + Ok (Vec.singleton (Block.make ~title:content ())) + | None, None, _ when not (Vec.is_empty args) -> + Ok (Vec.singleton (Block.make ~title:(Vec.string_concat " " args) ())) + | _ -> Error (Error.make Error.Missing_content "content is required") + +let build_add_block_action (opts : opts) args repo = + match invalid_options opts with + | Some message -> Error (Error.invalid_options message) + | None -> + Error.bind (read_blocks opts args) (fun blocks -> + Error.bind (parse_tags_option opts.tags_edn) (fun tags -> + Error.bind (parse_properties_option opts.properties_edn) + (fun properties -> + let pos = Option.value opts.pos ~default:Block.Last_child in + let status = Option.bind opts.status normalize_status in + match (Option.map String.trim opts.status, status) with + | Some status_text, None when status_text <> "" -> + Error + (Error.invalid_options + ("invalid status: " ^ status_text)) + | _ -> + let blocks = Vec.map ensure_block_uuids blocks in + Ok + { + repo; + graph = Cli_config.repo_to_graph repo; + target_id = opts.target_id; + target_uuid = opts.target_uuid; + target_page_name = opts.target_page_name; + pos; + status; + tags; + properties; + blocks; + }))) + +let page_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |]) + +let tag_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/tags", vector_vec (Vec.of_array [| kw "db/ident" |])); + |]); + kw "logseq.property/public?"; + kw "logseq.property/built-in?"; + |]) + +let property_entity_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/type"; + kw "db/cardinality"; + kw "logseq.property/public?"; + |]) + +let page_query selector = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (vector_vec + (Vec.of_array + [| + list_vec (Vec.of_array [| sym "pull"; sym "?e"; selector |]); + sym "..."; + |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.singleton + (Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "?name" |])))) + () + +let tag_query selector = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (vector_vec + (Vec.of_array + [| + list_vec (Vec.of_array [| sym "pull"; sym "?e"; selector |]); + sym "..."; + |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "?name" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/tags"; sym "?t" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| sym "?t"; kw "db/ident"; kw "logseq.class/Tag" |])); + |]) + () + +let property_query selector = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (vector_vec + (Vec.of_array + [| + list_vec (Vec.of_array [| sym "pull"; sym "?e"; selector |]); + sym "..."; + |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "?name" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/tags"; sym "?t" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| sym "?t"; kw "db/ident"; kw "logseq.class/Property" |])); + |]) + () + +let first_entity = function + | value -> ( + match + (Edn_util.as_vector value, Edn_util.as_list value, Edn_util.as_map value) + with + | Some values, _, _ when not (Vec.is_empty values) -> + Some (Vec.peek_front values) + | _, Some values, _ when not (Vec.is_empty values) -> + Some (Vec.peek_front values) + | _, _, Some _ -> Some value + | _ -> None) + +let uuid_of_entity value = + Option.bind (Edn_util.get value "block/uuid") Edn_util.as_string_like + +let id_of_entity value = Edn_util.get_int64 value "db/id" + +let ident_of_entity value = + Option.bind (Edn_util.get value "db/ident") Edn_util.as_keyword_t + +let recycled_entity value = + Option.is_some (Edn_util.get value "logseq.property/deleted-at") + +let page_not_found () = Error.make Error.Page_not_found "page not found" +let recycled_page_error () = Error.make Error.Recycled_page "page is recycled" + +let pull_entity config repo selector lookup = + Transport.thread_api_pull config ~repo + ~selector:(Edn_util.expect_vector_t "add pull selector" selector) + ~lookup + +let apply_outliner_ops config repo ops = + Transport.thread_api_apply_outliner_ops config ~repo + ~ops:(Edn_util.vector_t_vec ops) + ~options:(Edn_util.map_t_vec Vec.empty) + +let create_page config repo name = + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "create-page"; + Edn_util.vector_vec + (Vec.of_array + [| Edn_util.string name; Edn_util.map_vec Vec.empty |]); + |]) + in + apply_outliner_ops config repo (Vec.singleton op) + +let pull_pages_by_name config repo name selector = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (page_query selector); + Edn_util.string (normalized_lookup_name name); + |])) + +let pull_tag_by_name config repo name selector = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (tag_query selector); + Edn_util.string (normalized_lookup_name name); + |])) + +let list_tags config repo = + Transport.thread_api_cli_list_tags config ~repo + ~options:(Edn_util.map_t_vec Vec.empty) + +let tag_name_matches name entity = + let expected = normalized_lookup_name name in + let matches value = String.equal (normalized_lookup_name value) expected in + match + ( Edn_util.get_string entity "block/title", + Edn_util.get_string entity "block/name" ) + with + | Some title, _ when matches title -> true + | _, Some name when matches name -> true + | _ -> false + +let find_tag_by_name config repo name = + let open Cli_effect in + bind (list_tags config repo) (fun value -> + match Edn_util.as_seq value with + | Some tags -> pure (Vec.find_opt (tag_name_matches name) tags) + | None -> pure None) + +let pull_property_by_name config repo name selector = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (property_query selector); + Edn_util.string (normalized_lookup_name name); + |])) + +let pull_created_page config repo name create_result = + let uuid_value = + match + (Edn_util.as_vector create_result, Edn_util.as_list create_result) + with + | Some values, _ -> Vec.nth_opt values 1 + | _, Some values -> Vec.nth_opt values 1 + | _ -> None + in + match uuid_value with + | Some uuid_value -> ( + match Edn_util.as_string_like uuid_value with + | Some uuid -> + pull_entity config repo page_selector + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + | _ -> + pull_entity config repo page_selector + (vector_vec + (Vec.of_array + [| + kw "block/name"; + Edn_util.string (normalized_lookup_name name); + |]))) + | _ -> + pull_entity config repo page_selector + (vector_vec + (Vec.of_array + [| + kw "block/name"; Edn_util.string (normalized_lookup_name name); + |])) + +let ensure_page_entity config repo page_name = + let open Cli_effect in + bind (pull_pages_by_name config repo page_name page_selector) (fun result -> + match first_entity result with + | Some entity when recycled_entity entity -> + pure (Error (recycled_page_error ())) + | Some entity -> pure (Ok entity) + | None -> + bind (create_page config repo page_name) (fun create_result -> + bind (pull_created_page config repo page_name create_result) + (fun page -> + match uuid_of_entity page with + | Some _ -> pure (Ok page) + | None -> pure (Error (page_not_found ()))))) + +let ensure_page config repo page_name = + let open Cli_effect in + bind (ensure_page_entity config repo page_name) (function + | Ok entity -> ( + match uuid_of_entity entity with + | Some uuid -> pure (Ok uuid) + | None -> pure (Error (page_not_found ()))) + | Error err -> pure (Error err)) + +let resolve_add_target config (action : action) = + let open Cli_effect in + match (action.target_id, action.target_uuid, action.target_page_name) with + | Some id, _, _ -> + bind + (pull_entity config action.repo + (vector_vec + (Vec.of_array [| kw "db/id"; kw "block/uuid"; kw "block/title" |])) + (Edn_util.int64 id)) + (fun block -> + match uuid_of_entity block with + | Some uuid -> pure (Ok uuid) + | None -> + pure + (Error + (Error.make Error.Target_not_found "target block not found"))) + | None, Some uuid, _ -> + bind + (pull_entity config action.repo + (vector_vec + (Vec.of_array [| kw "db/id"; kw "block/uuid"; kw "block/title" |])) + (vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]))) + (fun block -> + match uuid_of_entity block with + | Some uuid -> pure (Ok uuid) + | None -> + pure + (Error + (Error.make Error.Target_not_found "target block not found"))) + | None, None, Some page_name -> ensure_page config action.repo page_name + | None, None, None -> + pure + (Error + (Error.make Error.Missing_target "target page or block is required")) + +let flatten_blocks blocks = + let rec flatten_one parent_uuid block = + let parent = + match parent_uuid with + | Some uuid -> Some (Selector.Block_uuid uuid) + | None -> block.Block.parent + in + let children = block.children in + let block = { block with parent; children = Vec.empty } in + let child_parent_uuid = block.Block.uuid in + Vec.push_front + (Vec.concat_map (flatten_one child_parent_uuid) children) + block + in + Vec.concat_map (flatten_one None) blocks + +let insert_opts = function + | Block.Last_child -> + Edn_util.map_vec + (Vec.of_array + [| + (kw "sibling?", Edn_util.bool false); + (kw "bottom?", Edn_util.bool true); + (kw "keep-uuid?", Edn_util.bool true); + (kw "outliner-op", kw "insert-blocks"); + |]) + | First_child -> + Edn_util.map_vec + (Vec.of_array + [| + (kw "sibling?", Edn_util.bool false); + (kw "keep-uuid?", Edn_util.bool true); + (kw "outliner-op", kw "insert-blocks"); + |]) + | Sibling -> + Edn_util.map_vec + (Vec.of_array + [| + (kw "sibling?", Edn_util.bool true); + (kw "keep-uuid?", Edn_util.bool true); + (kw "outliner-op", kw "insert-blocks"); + |]) + +let collect_uuids_from_value value = + let rec loop acc value = + match Edn_util.as_map value with + | Some fields -> + let acc = + match + Option.bind + (Edn_util.get value "block/uuid") + Edn_util.as_string_like + with + | Some uuid -> Vec.push_back acc uuid + | _ -> acc + in + Vec.fold_left (fun acc (k, v) -> loop (loop acc k) v) acc fields + | None -> ( + match Edn_util.as_seq value with + | Some values -> Vec.fold_left loop acc values + | None -> acc) + in + loop Vec.empty value + +let collect_action_block_uuids blocks = + blocks |> flatten_blocks + |> Vec.filter_map (fun block -> block.Block.uuid) + |> Uuid_refs_types.unique_preserve_order + +let result_ids ids = + Edn_util.map_vec + (Vec.of_array + [| + ( kw "result", + Edn_util.vector_vec (ids |> Vec.map (fun id -> Edn_util.int64 id)) ); + |]) + +let lookup_of_tag = function + | Selector.Tag_id id -> Edn_util.int64 id + | Tag_name name -> Edn_util.string (normalized_lookup_name name) + | Tag_ident ident -> Edn_util.any ident + | Tag_uuid uuid -> + vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]) + +let tag_entity value = + match Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq with + | Some tags -> + Vec.exists + (fun tag -> + match + Option.bind (Edn_util.get tag "db/ident") Edn_util.as_string_like + with + | Some "logseq.class/Tag" -> true + | _ -> false) + tags + | None -> false + +let resolve_tag_entity invoke_config repo tag = + let open Cli_effect in + match tag with + | Selector.Tag_name name -> + bind (find_tag_by_name invoke_config repo name) (function + | Some entity when Option.is_some (id_of_entity entity) -> + pure (Entity.of_value entity) + | _ -> failwith "tag not found") + | _ -> + bind + (pull_entity invoke_config repo tag_selector (lookup_of_tag tag)) + (fun entity -> + match first_entity entity with + | Some entity + when Option.is_some (id_of_entity entity) && tag_entity entity -> + pure (Entity.of_value entity) + | Some _ -> failwith "tag not found" + | None -> failwith "tag not found") + +let resolve_tags config repo tags = + if Vec.is_empty tags then Cli_effect.pure Vec.empty + else + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> failwith err.message + | Ok invoke_config -> + all (Vec.map (resolve_tag_entity invoke_config repo) tags)) + +let lookup_property_entity invoke_config repo = function + | Property.Key_id id -> + pull_entity invoke_config repo property_entity_selector + (Edn_util.int64 id) + | Key_ident ident -> + pull_entity invoke_config repo property_entity_selector + (vector_vec (Vec.of_array [| kw "db/ident"; Edn_util.any ident |])) + | Key_name name -> + let open Cli_effect in + bind + (pull_property_by_name invoke_config repo name property_entity_selector) + (fun result -> + match first_entity result with + | Some entity when Option.is_some (id_of_entity entity) -> pure entity + | _ -> + let ident = Edn_util.keyword_t name in + pull_entity invoke_config repo property_entity_selector + (vector_vec + (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]))) + +let resolve_property_assignment invoke_config repo assignment = + let open Cli_effect in + bind (lookup_property_entity invoke_config repo assignment.Property.key) + (fun entity -> + match + ( id_of_entity entity, + ident_of_entity entity, + Edn_util.get entity "logseq.property/type" ) + with + | Some _, Some ident, Some _ -> + pure { assignment with Property.key = Property.Key_ident ident } + | _ -> failwith "property not found") + +let resolve_properties config repo properties = + if Vec.is_empty properties then Cli_effect.pure Vec.empty + else + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> failwith err.message + | Ok invoke_config -> + all + (Vec.map + (resolve_property_assignment invoke_config repo) + properties)) + +let resolve_created_ids config repo blocks insert_result = + let open Cli_effect in + let uuids = + let uuids = collect_uuids_from_value insert_result in + if Vec.is_empty uuids then collect_action_block_uuids blocks + else Uuid_refs_types.unique_preserve_order uuids + in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (uuid, rest) -> + bind + (pull_entity config repo + (vector_vec (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]))) + (fun entity -> + match id_of_entity entity with + | Some id -> loop (Vec.push_back acc id) rest + | None -> + pure + (Error + (Error.make Error.Add_id_resolution_failed + "unable to resolve created ids"))) + in + loop Vec.empty uuids + +let target_not_found_error () = + Error.make Error.Target_not_found "target block not found" + +let resolve_created_ids_or_target_error config repo target_uuid blocks + insert_result = + let open Cli_effect in + bind (resolve_created_ids config repo blocks insert_result) (function + | Ok ids -> pure (Ok ids) + | Error err when err.Error.code = Error.Add_id_resolution_failed -> + bind + (pull_entity config repo + (vector_vec (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid target_uuid |]))) + (fun target -> + if Option.is_some (id_of_entity target) then pure (Error err) + else pure (Error (target_not_found_error ()))) + | Error err -> pure (Error err)) + +let property_key_to_value = function + | Property.Key_ident ident -> Edn_util.any ident + | Key_id id -> Edn_util.int64 id + | Key_name name -> Edn_util.string name + +let metadata_ops block_uuids status tags properties = + let uuid_values = + Edn_util.vector_vec (block_uuids |> Vec.map (fun uuid -> Edn_util.uuid uuid)) + in + let status_ops = + match (status, block_uuids) with + | Some status, uuids when not (Vec.is_empty uuids) -> + Vec.singleton + (Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + kw "logseq.property/status"; + Edn_util.any status; + Edn_util.map_vec Vec.empty; + |]); + |])) + | _ -> Vec.empty + in + let tag_ops = + tags + |> Vec.filter_map (fun tag -> tag.Entity.id) + |> Uuid_refs_types.unique_preserve_order + |> Vec.map (fun tag_id -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + kw "block/tags"; + Edn_util.int64 tag_id; + Edn_util.map_vec Vec.empty; + |]); + |])) + in + let property_ops = + Vec.map + (fun assignment -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + property_key_to_value assignment.Property.key; + assignment.value; + Edn_util.map_vec Vec.empty; + |]); + |])) + properties + in + if Vec.is_empty block_uuids then Vec.empty + else Vec.append status_ops (Vec.append tag_ops property_ops) + +let page_ref_value entity fallback_title = + match uuid_of_entity entity with + | None -> None + | Some uuid -> + let fields = + Vec.of_array + [| + (kw "block/uuid", Edn_util.uuid uuid); + ( kw "block/title", + Edn_util.string + (Option.value + (Edn_util.get_string entity "block/title") + ~default:fallback_title) ); + |] + in + let fields = + match Edn_util.get_string entity "block/name" with + | Some name -> + Vec.push_front fields (kw "block/name", Edn_util.string name) + | None -> fields + in + Some (Edn_util.map_vec fields) + +let resolve_title_page_refs invoke_config repo title = + let open Cli_effect in + let page_names = extract_page_refs title in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (page_name, rest) -> + bind (ensure_page_entity invoke_config repo page_name) (function + | Error err -> pure (Error err) + | Ok entity -> ( + match page_ref_value entity page_name with + | Some ref_value -> loop (Vec.push_back acc ref_value) rest + | None -> pure (Error (page_not_found ())))) + in + loop Vec.empty page_names + +let rec resolve_block_title_page_refs invoke_config repo block = + let open Cli_effect in + let title_refs = + match block.Block.title with + | Some title -> resolve_title_page_refs invoke_config repo title + | None -> pure (Ok Vec.empty) + in + bind title_refs (function + | Error err -> pure (Error err) + | Ok refs -> + let rec resolve_children acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (child, rest) -> + bind (resolve_block_title_page_refs invoke_config repo child) + (function + | Error err -> pure (Error err) + | Ok child_refs -> + resolve_children (Vec.append acc child_refs) rest) + in + bind (resolve_children Vec.empty block.Block.children) (function + | Error err -> pure (Error err) + | Ok child_refs -> + let own_refs = + match (block.Block.uuid, refs) with + | Some uuid, refs when not (Vec.is_empty refs) -> + Vec.singleton (uuid, refs) + | _ -> Vec.empty + in + pure (Ok (Vec.append own_refs child_refs)))) + +let resolve_blocks_title_page_refs invoke_config repo blocks = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (block, rest) -> + bind (resolve_block_title_page_refs invoke_config repo block) (function + | Error err -> pure (Error err) + | Ok refs -> loop (Vec.append acc refs) rest) + in + loop Vec.empty blocks + +let execute_add_block action config mode = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok invoke_config -> + bind (resolve_add_target invoke_config action) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok target_uuid -> + bind (resolve_tags config action.repo action.tags) (fun tags -> + bind (resolve_properties config action.repo action.properties) + (fun properties -> + bind + (resolve_blocks_title_page_refs invoke_config + action.repo action.blocks) (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_block + mode err) + | Ok refs_by_uuid -> + let all_blocks = Block.flatten action.blocks in + let block_uuids = + Vec.filter_map + (fun block -> block.Block.uuid) + all_blocks + |> Uuid_refs_types.unique_preserve_order + in + let block_for_insert block = + { + block with + Block.parent = None; + children = Vec.empty; + } + in + let block_value_for_insert block = + let value = + Edn_util.any + (Block.to_value (block_for_insert block)) + in + match block.Block.uuid with + | Some uuid -> ( + match Vec.assoc_opt uuid refs_by_uuid with + | Some refs -> + Edn_util.assoc "block/refs" + (Edn_util.vector_vec refs) value + | None -> value) + | None -> value + in + let insert_blocks target_uuid pos blocks = + let insert_op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "insert-blocks"; + Edn_util.vector_vec + (Vec.of_array + [| + Edn_util.vector_vec + (blocks + |> Vec.map (fun block -> + block_value_for_insert block) + ); + Edn_util.uuid target_uuid; + insert_opts pos; + |]); + |]) + in + apply_outliner_ops invoke_config action.repo + (Vec.singleton insert_op) + in + let rec insert_tree target_uuid pos blocks = + if Vec.is_empty blocks then pure Edn_util.nil + else + bind (insert_blocks target_uuid pos blocks) + (fun insert_result -> + let rec insert_children acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (block, rest) -> ( + match block.Block.uuid with + | None -> insert_children acc rest + | Some uuid -> + bind + (insert_tree uuid + Block.Last_child + block.Block.children) + (fun child_result -> + let acc = + if Edn_util.is_null acc then + child_result + else acc + in + insert_children acc rest)) + in + insert_children insert_result blocks) + in + bind + (insert_tree target_uuid action.pos action.blocks) + (fun insert_result -> + let metadata_ops = + metadata_ops block_uuids action.status tags + properties + in + bind + (if Vec.is_empty metadata_ops then + pure Edn_util.nil + else + apply_outliner_ops invoke_config + action.repo metadata_ops) + (fun _metadata_result -> + bind + (resolve_created_ids_or_target_error + invoke_config action.repo target_uuid + all_blocks insert_result) (function + | Error err -> + pure + (Cli_result.error + ~command:Command_id.Upsert_block + mode err) + | Ok ids -> + pure + (Cli_result.ok + ~command:Command_id.Upsert_block + mode + (Raw (result_ids ids))))))))))) diff --git a/cli/lib/agent.ml b/cli/lib/agent.ml new file mode 100644 index 0000000..8d3ead3 --- /dev/null +++ b/cli/lib/agent.ml @@ -0,0 +1,2132 @@ +type parsed = Parsed_bridge + +type action = + | Agent_bridge of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + +type routable_reason = + | Missing_stable_uuid + | Missing_task_tag + | Not_todo + | Assignee_mismatch + | Already_routed + +type routable_decision = Routable | Not_routable of routable_reason +type template_kind = Task | Comment + +type prompt_template = { + kind : template_kind; + body : string; + required_vars : string Rrbvec.t; + allowed_vars : string Rrbvec.t; +} + +type bridge_result = { + mode : Cli_primitive.keyword; + graph : Cli_primitive.graph; + agent_name : string; + routed : Melange_edn_melange.any Rrbvec.t; +} + +type routed_task = { block : Melange_edn_melange.any; session : string option } +type prompt_templates = { task : string; comment : string } +type inherited_session = { parent_block_uuid : string; session_id : string } + +let routed_blocks : (string, unit) Hashtbl.t = Hashtbl.create 32 + +let claim_routing_block uuid = + if Hashtbl.mem routed_blocks uuid then false + else ( + Hashtbl.add routed_blocks uuid (); + true) + +let default_master_prompt = + String.concat "\n" + [ + "# AgentBridge Master Agent"; + ""; + "## Role"; + "You are the graph-scoped master agent for Logseq AgentBridge."; + "Classify incoming work and decide how to dispatch subagents."; + ""; + "## Graph Safety"; + "Do not operate outside the target graph."; + "Only the master agent may write task results back into the target graph."; + "Subagents may read graph context but must not write graph content."; + "When dispatching a subagent for a task, write the subagent Codex \ + session id to the task block's `:logseq.property.agent/session-id` \ + property."; + ] + +let graph_scope_line = "Do not operate outside the target graph." +let task_result_line = "Write task results back into the graph." + +let task_finish_reaction_line = + "When the task or subagent finishes, remove the `eyes` reaction from the \ + task block whether it succeeded or failed." + +let comment_completion_line = "Complete the request from the mentioned comment." + +let graph_report_lines = + Vec.of_array + [| + "If the target graph is sync-enabled, make sure it is synced after \ + writing back to the graph."; + "Keep the report short when possible."; + "Report blockers only if there is a blocker."; + "Report root cause and Steps to verify only for bug fixes."; + |] + +let comment_reply_instruction_lines = + Vec.of_array + [| + "Reply instructions:"; + "For a short reply, append a comment after the requesting comment."; + "For a long reply, write a normal block tree after the comments area and \ + append a comment that references that tree."; + "When referencing result blocks in DB graphs, reference result blocks \ + with [[block-uuid]], not ((block-uuid))."; + "If the request is blocked or fails, make that clear in the reply."; + |] + +let default_task_prompt_template = + Vec.string_concat "\n" + ( Vec.of_array + [| + "You are handling a Logseq AgentBridge task."; + ""; + "Graph: {{graph}}"; + "Block UUID: {{block-uuid}}"; + "AgentBridge name: {{agent-name}}"; + ""; + graph_scope_line; + task_result_line; + task_finish_reaction_line; + |] + |> fun lines -> + Vec.append lines graph_report_lines |> fun lines -> + Vec.append_array lines [| ""; "Task block tree:"; "{{task-block-tree}}" |] + ) + +let default_comment_prompt_template = + Vec.string_concat "\n" + ( Vec.of_array + [| + "You are handling a Logseq AgentBridge comment request."; + ""; + "Graph: {{graph}}"; + "Comment UUID: {{comment-uuid}}"; + "AgentBridge name: {{agent-name}}"; + ""; + graph_scope_line; + comment_completion_line; + |] + |> fun lines -> + Vec.append lines graph_report_lines |> fun lines -> + Vec.append_array lines + [| + ""; + "Comment target context:"; + "{{comment-target-context}}"; + ""; + "Comment thread context:"; + "{{comment-thread-context}}"; + ""; + "Requesting comment:"; + "{{requesting-comment}}"; + ""; + |] + |> fun lines -> Vec.append lines comment_reply_instruction_lines ) + +let trim_non_empty value = + let value = String.trim value in + if value = "" then None else Some value + +let strip_keyword_prefix value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let value_key_matches key value = + let key = strip_keyword_prefix key in + match Edn_util.as_string_like value with + | Some k -> strip_keyword_prefix k = key + | None -> false + +let map_contains key value = + match Edn_util.as_map value with + | Some fields -> Vec.exists (fun (k, _) -> value_key_matches key k) fields + | None -> false + +let resolve_agent_name config hostname = + let configured = + Option.bind config.Cli_config.raw_file_config (fun value -> + Edn_util.get_string value "agent-name") + in + let configured_present = + match config.raw_file_config with + | Some value -> map_contains "agent-name" value + | None -> false + in + match (configured_present, Option.bind configured trim_non_empty) with + | true, Some agent_name -> Ok agent_name + | true, None -> + Error + (Error.make Error.Agent_name_invalid + "agent-name in cli.edn must be a non-empty string") + | false, _ -> ( + match Option.bind hostname trim_non_empty with + | Some agent_name -> Ok agent_name + | None -> ( + match trim_non_empty (Cli_platform.hostname ()) with + | Some agent_name -> Ok agent_name + | None -> + Error + (Error.make Error.Agent_name_invalid + "agent-name cannot be resolved from cli.edn or hostname"))) + +let value_list = function + | Some value -> Option.value (Edn_util.as_seq value) ~default:Vec.empty + | _ -> Vec.empty + +let ident_value value = + match Edn_util.as_string_like value with + | Some ident -> Some (strip_keyword_prefix ident) + | None -> + if Option.is_some (Edn_util.as_map value) then + Option.map strip_keyword_prefix (Edn_util.get_string value "db/ident") + else None + +let has_task_tag raw = + value_list (Edn_util.get raw "block/tags") + |> Vec.exists (fun tag -> ident_value tag = Some "logseq.class/Task") + +let status_ident raw = + match Edn_util.get raw "logseq.property/status" with + | Some value -> ident_value value + | None -> None + +let assignee_values raw = + value_list (Edn_util.get raw "logseq.property/assignee") + |> Vec.filter_map (fun value -> + Option.bind (Edn_util.get_string value "block/title") trim_non_empty) + +let routable_task_decision entity ~agent_name = + let raw = entity.Entity.raw in + if Option.is_none (Edn_util.get raw "block/uuid") then + Not_routable Missing_stable_uuid + else if not (has_task_tag raw) then Not_routable Missing_task_tag + else if status_ident raw <> Some "logseq.property/status.todo" then + Not_routable Not_todo + else if not (Vec.mem agent_name (assignee_values raw)) then + Not_routable Assignee_mismatch + else if Option.is_some (Edn_util.get raw "logseq.property.agent/session-id") + then Not_routable Already_routed + else Routable + +let is_var_char = function + | 'A' .. 'Z' | 'a' .. 'z' | '0' .. '9' | '-' -> true + | _ -> false + +let valid_var_name value = + String.length value > 0 && String.for_all is_var_char value + +let unique values = + let rec loop seen remaining = + match Vec.pop_front remaining with + | None -> seen + | Some (value, rest) when Vec.mem value seen -> loop seen rest + | Some (value, rest) -> loop (Vec.push_back seen value) rest + in + loop Vec.empty values + +let template_vars body = + let len = String.length body in + let rec loop acc index = + if index + 3 >= len then unique acc + else if body.[index] = '{' && body.[index + 1] = '{' then + match String.index_from_opt body (index + 2) '}' with + | Some close when close + 1 < len && body.[close + 1] = '}' -> + let name = String.sub body (index + 2) (close - index - 2) in + let quoted = + index > 0 + && body.[index - 1] = '\'' + && close + 2 < len + && body.[close + 2] = '\'' + in + let acc = + if (not quoted) && valid_var_name name then Vec.push_back acc name + else acc + in + loop acc (close + 2) + | _ -> loop acc (index + 2) + else loop acc (index + 1) + in + loop Vec.empty 0 + +let list_diff xs ys = Vec.filter (fun x -> not (Vec.mem x ys)) xs + +let validate_prompt_template template = + if String.trim template.body = "" then + Error + (Error.make Error.Missing_template_code_block + "agent bridge prompt template code block is missing") + else + let vars = template_vars template.body in + let unknown_vars = list_diff vars template.allowed_vars in + if not (Vec.is_empty unknown_vars) then + Error + (Error.make Error.Unknown_template_vars + "agent bridge prompt template has unknown variables") + else + let missing_vars = list_diff template.required_vars vars in + if not (Vec.is_empty missing_vars) then + Error + (Error.make Error.Missing_template_vars + "agent bridge prompt template is missing required variables") + else Ok () + +let raw_config_string config key = + Option.bind config.Cli_config.raw_file_config (fun value -> + Edn_util.get_string value key) + +let raw_config_bool config key = + Option.bind config.Cli_config.raw_file_config (fun value -> + Edn_util.get_bool value key) + +let codex_bin config = + Option.value (raw_config_string config "codex-bin") ~default:"codex" + +let agent_bridge_process_once config = + Option.value + (raw_config_bool config "agent-bridge-process-once?") + ~default:false + +let codex_command_prefix config = + Vec.of_array [| codex_bin config; "--sandbox"; "danger-full-access"; "exec" |] + +let build_codex_command config prompt = + Vec.append_array + (codex_command_prefix config) + [| "--json"; "--skip-git-repo-check"; prompt |] + +let build_codex_resume_command config session_id prompt = + Vec.append_array + (codex_command_prefix config) + [| "resume"; "--json"; "--skip-git-repo-check"; session_id; prompt |] + +let shell_env () = Cli_unix.environment () + +let run_command_capture command = + match Vec.pop_front command with + | None -> { Cli_unix.status = 1; stdout = ""; stderr = "missing command" } + | Some (bin, args) -> Cli_unix.run_process_capture bin args (shell_env ()) + +let start_command_capture_session_line command = + match Vec.pop_front command with + | None -> { Cli_unix.status = 1; stdout = ""; stderr = "missing command" } + | Some (bin, args) -> + Cli_unix.start_process_capture_session_line bin args (shell_env ()) + +let start_command_detached command = + match Vec.peek_front_opt command with + | None -> Error (Error.make Error.Codex_start_failed "missing command") + | Some bin -> ( + try + ignore + (Cli_unix.create_process_env bin (Vec.to_array command) (shell_env ()) + 0 1 2); + Ok () + with + | Cli_unix.Cli_unix_error (_, op, detail) -> + Error + (Error.make Error.Codex_start_failed + ("failed to start codex " ^ op ^ ": " ^ detail)) + | exn -> + Error + (Error.make Error.Codex_start_failed + ("failed to start codex: " ^ Printexc.to_string exn))) + +let codex_available config = + let result = + run_command_capture (Vec.of_array [| codex_bin config; "--version" |]) + in + result.Cli_unix.status = 0 + +let parse_codex_session_id stdout = + Vec.split_on_char '\n' stdout + |> Vec.find_map (fun line -> + try + match Json_util.object_of_json_string line with + | None -> None + | Some object_ -> ( + match + Vec.of_array + [| "session-id"; "session_id"; "thread-id"; "thread_id" |] + |> Vec.find_map (Json_util.string_field object_) + with + | Some session_id -> Some session_id + | None -> + Vec.of_array + [| + ("session", "id"); + ("session", "session-id"); + ("session", "session_id"); + ("thread", "id"); + ("thread", "thread-id"); + ("thread", "thread_id"); + |] + |> Vec.find_map (fun (object_field, nested_field) -> + Json_util.nested_string_field object_ object_field + nested_field)) + with _ -> None) + +let start_codex _config command = + let result = start_command_capture_session_line command in + if result.Cli_unix.status <> 0 then + Error + (Error.make Error.Codex_start_failed + ("codex exited before startup completed: " ^ result.Cli_unix.stderr)) + else + match parse_codex_session_id result.Cli_unix.stdout with + | Some session when String.trim session <> "" -> Ok session + | _ -> + Error + (Error.make Error.Codex_session_id_missing + "codex exited before reporting a session id") + +let ensure_master_session config master_prompt = + start_codex config (build_codex_command config master_prompt) + +let resume_master_session config master_session prompt = + match + start_command_detached + (build_codex_resume_command config master_session prompt) + with + | Ok () -> Ok master_session + | Error err -> Error err + +let block_uuid value = Edn_util.get_string value "block/uuid" + +let block_title value = + Option.value (Edn_util.get_string value "block/title") ~default:"" + +let project_dir_line config = + match config.Cli_config.project_dir with + | Some value when String.trim value <> "" -> + Vec.singleton ("Project directory: " ^ value) + | _ -> Vec.empty + +let inherited_task_session_lines = function + | None -> Vec.empty + | Some inherited -> + Vec.of_array + [| + ""; + "Inherited parent task UUID: " ^ inherited.parent_block_uuid; + "Inherited subagent session id: " ^ inherited.session_id; + "Continue this child task in the inherited subagent session instead \ + of launching a new subagent."; + |] + +let build_master_task_dispatch_prompt config ~graph ~agent_name + ?inherited_session ?tree_text block = + let uuid = Option.value (block_uuid block) ~default:"" in + Vec.string_concat "\n" + ( Vec.of_array + [| + "You are handling a Logseq AgentBridge master dispatch request."; + ""; + "Request kind: task"; + "Graph: " ^ graph; + "AgentBridge name: " ^ agent_name; + |] + |> fun lines -> + Vec.append lines (project_dir_line config) |> fun lines -> + Vec.append_array lines + [| + ""; + graph_scope_line; + "Only the master agent may write task results back into the target \ + graph."; + "Subagents may read graph context but must not write graph content."; + "Route this request according to the master prompt policy."; + "After launching the subagent with `codex exec`, write that subagent \ + session id to the task block's `:logseq.property.agent/session-id` \ + property."; + ""; + "Write task results back into the graph."; + task_finish_reaction_line; + |] + |> fun lines -> + Vec.append lines graph_report_lines |> fun lines -> + Vec.append_array lines [| ""; "Block UUID: " ^ uuid; "" |] |> fun lines -> + Vec.append lines (inherited_task_session_lines inherited_session) + |> fun lines -> + Vec.append_array lines + [| + ""; + "Task block tree:"; + Option.value tree_text ~default:(block_title block); + |] ) + +let dispatch_task_to_master config ~graph ~agent_name ~master_session + ?inherited_session ?tree_text block = + let prompt = + build_master_task_dispatch_prompt config ~graph ~agent_name + ?inherited_session ?tree_text block + in + resume_master_session config master_session prompt + +let build_master_comment_dispatch_prompt config ~graph ~agent_name + ~comment_block ~target_tree_texts ~comments_area_tree_text + ~comment_tree_text = + let uuid = Option.value (block_uuid comment_block) ~default:"" in + Vec.string_concat "\n" + ( Vec.of_array + [| + "You are handling a Logseq AgentBridge master dispatch request."; + ""; + "Request kind: comment"; + "Graph: " ^ graph; + "AgentBridge name: " ^ agent_name; + |] + |> fun lines -> + Vec.append lines (project_dir_line config) |> fun lines -> + Vec.append_array lines [| ""; graph_scope_line; comment_completion_line |] + |> fun lines -> + Vec.append lines graph_report_lines |> fun lines -> + Vec.push_back lines "" |> fun lines -> + Vec.append lines comment_reply_instruction_lines |> fun lines -> + Vec.append_array lines + [| + ""; + "Comment UUID: " ^ uuid; + ""; + "Comment target context:"; + Vec.string_concat "\n" + (Vec.filter + (fun value -> String.trim value <> "") + target_tree_texts); + ""; + "Comment thread context:"; + comments_area_tree_text; + ""; + "Requesting comment:"; + comment_tree_text; + |] ) + +let dispatch_comment_to_master config ~graph ~agent_name ~master_session + ~target_tree_texts ~comments_area_tree_text ~comment_tree_text comment_block + = + let prompt = + build_master_comment_dispatch_prompt config ~graph ~agent_name + ~comment_block ~target_tree_texts ~comments_area_tree_text + ~comment_tree_text + in + resume_master_session config master_session prompt + +let command_id _ = Command_id.Agent_bridge +let validate_parsed _ = Ok () + +let build ?registry:_ config _globals = function + | Parsed_bridge -> ( + match config.Cli_config.repo with + | Some repo -> + Ok (Agent_bridge { repo; graph = Cli_config.repo_to_graph repo }) + | None -> Error (Error.missing_repo "repo is required for agent bridge")) + +let kw value = Edn_util.keyword value +let sym value = Edn_util.symbol value +let vector_vec values = Edn_util.vector_vec values +let list_vec values = Edn_util.list_vec values +let where_v values = Cli_primitive.V (Edn_util.vector_t_vec values) +let where_l values = Cli_primitive.L (Edn_util.list_t_vec values) + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let query_call query args = + Edn_util.vector_vec (Vec.push_front args (query_value query)) + +let empty_rules = Edn_util.vector_vec Vec.empty +let agent_bridge_registry_page = "AgentBridge" +let master_prompt_wrapper_title = "AgentBridge master prompt" +let task_prompt_template_title = "Task prompt template" +let comment_prompt_template_title = "Comment prompt template" + +let page_name_sanity_lc value = + value |> String.trim |> String.lowercase_ascii |> String.to_seq + |> Seq.filter (function ' ' | '\t' | '\n' | '\r' -> false | _ -> true) + |> String.of_seq + +let registry_page_name () = page_name_sanity_lc agent_bridge_registry_page +let agent_page_name agent_name = page_name_sanity_lc agent_name + +let live_entity value = + Option.is_some (Edn_util.get value "db/id") + && Option.is_none (Edn_util.get value "logseq.property/deleted-at") + +let first_live_entity values = Vec.find_opt live_entity values + +let order_key value = + match Edn_util.get value "block/order" with + | Some value -> ( + match (Edn_util.as_string value, Edn_util.as_int value) with + | Some text, _ -> text + | _, Some order -> string_of_int order + | _ -> "") + | None -> "" + +let child_blocks block = + let values = + match + (Edn_util.get block "block/children", Edn_util.get block "block/_parent") + with + | Some value, _ | None, Some value -> + Option.value (Edn_util.as_seq value) ~default:Vec.empty + | None, None -> Vec.empty + in + values |> Vec.filter live_entity + |> Vec.sort (fun a b -> String.compare (order_key a) (order_key b)) + +let rec block_title_tree block = + let title = + Option.value (Edn_util.get_string block "block/title") ~default:"" + in + Vec.push_front (Vec.concat_map block_title_tree (child_blocks block)) title + +let code_block_tag tag = ident_value tag = Some "logseq.class/Code-block" + +let block_has_code_tag block = + value_list (Edn_util.get block "block/tags") |> Vec.exists code_block_tag + +let extract_code_blocks text = + let len = String.length text in + let rec loop acc index = + if index + 3 > len then acc + else if index + 3 <= len && String.sub text index 3 = "```" then + match String.index_from_opt text (index + 3) '\n' with + | None -> loop acc (index + 3) + | Some body_start -> ( + let rec find_close i = + if i + 3 > len then None + else if String.sub text i 3 = "```" then Some i + else find_close (i + 1) + in + match find_close (body_start + 1) with + | None -> loop acc (body_start + 1) + | Some close -> + let body = + String.sub text (body_start + 1) (close - body_start - 1) + in + loop (Vec.push_back acc body) (close + 3)) + else loop acc (index + 1) + in + loop Vec.empty 0 + +let task_prompt_template body = + { + kind = Task; + body; + required_vars = + Vec.of_array [| "graph"; "block-uuid"; "agent-name"; "task-block-tree" |]; + allowed_vars = + Vec.of_array [| "graph"; "block-uuid"; "agent-name"; "task-block-tree" |]; + } + +let comment_prompt_template body = + { + kind = Comment; + body; + required_vars = + Vec.of_array + [| + "graph"; + "comment-uuid"; + "agent-name"; + "comment-target-context"; + "comment-thread-context"; + "requesting-comment"; + |]; + allowed_vars = + Vec.of_array + [| + "graph"; + "comment-uuid"; + "agent-name"; + "comment-target-context"; + "comment-thread-context"; + "requesting-comment"; + |]; + } + +let prompt_template_for_kind = function + | Task -> task_prompt_template + | Comment -> comment_prompt_template + +let prompt_template_title = function + | Task -> task_prompt_template_title + | Comment -> comment_prompt_template_title + +let default_prompt_template = function + | Task -> default_task_prompt_template + | Comment -> default_comment_prompt_template + +let prompt_template_from_block kind block = + let templates = + block_title_tree block |> Vec.concat_map extract_code_blocks + in + let renderable = + templates + |> Vec.filter (fun body -> + validate_prompt_template ((prompt_template_for_kind kind) body) = Ok ()) + in + if Vec.length renderable = 1 then Ok (Vec.peek_front renderable) + else + match templates with + | templates when Vec.length templates = 1 -> ( + let body = Vec.peek_front templates in + match + validate_prompt_template ((prompt_template_for_kind kind) body) + with + | Ok () -> Ok body + | Error err -> Error err) + | _ -> + Error + (Error.make Error.Agent_prompt_template_invalid + "agent bridge prompt template must contain one code block") + +let blocks_by_title blocks title = + Vec.find_opt + (fun block -> Edn_util.get_string block "block/title" = Some title) + blocks + +let agent_bridge_registry_page_query = + query_call + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?p"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |]); + |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?page-name"; + |]) + ~where: + (Vec.singleton + (where_v + (Vec.of_array [| sym "?p"; kw "block/name"; sym "?page-name" |]))) + ()) + (Vec.singleton (Edn_util.string (registry_page_name ()))) + +let registered_agent_query agent_name = + query_call + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?p"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |]); + |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?agent-page-name"; + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| sym "?p"; kw "block/name"; sym "?agent-page-name" |]); + |]) + ()) + (Vec.singleton (Edn_util.string (agent_page_name agent_name))) + +let agent_master_prompt_blocks_query page_id = + query_call + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?b"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + kw "block/order"; + kw "logseq.property/deleted-at"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/_parent", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + kw "block/order"; + kw + "logseq.property/deleted-at"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw + "block/name"; + kw + "block/title"; + |]) ); + |]); + |]) ); + |]); + |]); + |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?page-id"; + |]) + ~where: + (Vec.singleton + (where_v + (Vec.of_array [| sym "?b"; kw "block/parent"; sym "?page-id" |]))) + ()) + (Vec.singleton (Edn_util.int64 page_id)) + +let prompt_template_blocks_query page_id = + query_call + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?b"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + kw "block/order"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/_parent", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + kw "block/order"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/_parent", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw + "block/uuid"; + kw + "block/title"; + kw + "block/order"; + |]) ); + |]); + |]) ); + |]); + |]); + |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?page-id"; + |]) + ~where: + (Vec.singleton + (where_v + (Vec.of_array [| sym "?b"; kw "block/parent"; sym "?page-id" |]))) + ()) + (Vec.singleton (Edn_util.int64 page_id)) + +let task_ancestor_session_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + kw "logseq.property.agent/session-id"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/parent", vector_vec (Vec.of_array [| kw "db/id" |])); + |]); + |]) + +let routable_task_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property/status", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property/assignee", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/title"; + kw "block/name"; + kw "db/ident"; + |]) ); + |]); + kw "logseq.property.agent/session-id"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/parent", vector_vec (Vec.of_array [| kw "db/id" |])); + |]); + |]) + +let routable_task_query agent_name = + query_call + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| sym "pull"; sym "?e"; routable_task_selector |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?agent-name"; + Melange_edn_melange.symbol "%"; + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| sym "?e"; kw "block/tags"; kw "logseq.class/Task" |]); + where_v + (Vec.of_array + [| sym "?e"; kw "logseq.property/status"; sym "?status" |]); + where_v + (Vec.of_array + [| + sym "?status"; + kw "db/ident"; + kw "logseq.property/status.todo"; + |]); + where_v + (Vec.of_array + [| + sym "?assignee-property"; + kw "block/name"; + Edn_util.string "assignee"; + |]); + where_v + (Vec.of_array + [| + sym "?assignee-property"; + kw "db/ident"; + sym "?assignee-attr"; + |]); + where_v + (Vec.of_array + [| sym "?e"; sym "?assignee-attr"; sym "?assignee-ref" |]); + where_v + (Vec.of_array + [| + sym "?assignee-ref"; kw "block/title"; sym "?agent-name"; + |]); + |]) + ()) + (Vec.of_array [| Edn_util.string agent_name; empty_rules |]) + +let comment_block_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/refs", + vector_vec + (Vec.of_array + [| kw "db/id"; kw "block/title"; kw "block/name" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/parent", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array + [| kw "db/ident"; kw "block/title" |]) ); + |]); + |]) ); + |]); + sym "*"; + |]) + +let comment_target_block_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property/assignee", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/title"; + kw "block/name"; + kw "db/ident"; + |]) ); + |]); + kw "logseq.property.agent/session-id"; + sym "*"; + |]) + +let comments_area_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property.comments/blocks", + comment_target_block_selector ); + |]); + |]) + +let reaction_query target_uuid emoji_id = + query_call + (Cli_primitive.make_datascript_query + ~find:(Vec.of_array [| sym "?r"; sym "." |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?target-uuid"; + Melange_edn_melange.symbol "?emoji-id"; + Melange_edn_melange.symbol "%"; + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| sym "?target"; kw "block/uuid"; sym "?target-uuid" |]); + where_v + (Vec.of_array + [| + sym "?r"; + kw "logseq.property.reaction/target"; + sym "?target"; + |]); + where_v + (Vec.of_array + [| + sym "?r"; + kw "logseq.property.reaction/emoji-id"; + sym "?emoji-id"; + |]); + where_l + (Vec.of_array + [| + sym "missing?"; + sym "$"; + sym "?r"; + kw "logseq.property/created-by-ref"; + |]); + |]) + ()) + (Vec.of_array + [| Edn_util.uuid target_uuid; Edn_util.string emoji_id; empty_rules |]) + +let task_status_query block_uuid = + query_call + (Cli_primitive.make_datascript_query + ~find:(Vec.of_array [| sym "?status-ident"; sym "." |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?block-uuid"; + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| sym "?block"; kw "block/uuid"; sym "?block-uuid" |]); + where_v + (Vec.of_array + [| + sym "?block"; kw "logseq.property/status"; sym "?status"; + |]); + where_v + (Vec.of_array + [| sym "?status"; kw "db/ident"; sym "?status-ident" |]); + |]) + ()) + (Vec.singleton (Edn_util.uuid block_uuid)) + +let values_of_query_result value = + match (Edn_util.as_vector value, Edn_util.as_list value) with + | Some values, _ | _, Some values -> values + | _ -> Vec.empty + +let unquote_transit_value = function + | Melange_edn_melange.Any + (Melange_edn_melange.Tagged (("transit/quote" | "'"), value)) -> + value + | value -> value + +let keyword_string value = value |> unquote_transit_value |> Edn_util.as_keyword + +let bridge_result_value (result : bridge_result) = + Edn_util.map_vec + (Vec.of_array + [| + (kw "mode", Edn_util.any result.mode); + ( kw "graph", + Edn_util.string (Cli_primitive.string_of_graph result.graph) ); + (kw "agent-name", Edn_util.string result.agent_name); + (kw "routed", Edn_util.vector_vec result.routed); + |]) + +let routed_task_value task = + Edn_util.map_vec + (Vec.of_array + [| + (kw "block", task.block); + ( kw "session", + match task.session with + | Some session -> Edn_util.string session + | None -> Edn_util.nil ); + |]) + +let list_routable_tasks invoke_config repo agent_name = + let open Cli_effect in + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "routable task query" + (routable_task_query agent_name))) + (fun result -> + let routed = + result |> values_of_query_result + |> Vec.filter (fun value -> + routable_task_decision (Entity.of_value value) ~agent_name + = Routable) + in + pure routed) + +let apply_outliner_ops invoke_config repo ops = + Transport.thread_api_apply_outliner_ops invoke_config ~repo + ~ops:(Edn_util.vector_t_vec ops) + ~options:(Edn_util.map_t_vec Vec.empty) + +let pull_registry_page invoke_config repo = + let open Cli_effect in + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "agent registry page query" + agent_bridge_registry_page_query)) + (fun pages -> pure (first_live_entity (values_of_query_result pages))) + +let pull_agent_page invoke_config repo agent_name = + let open Cli_effect in + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "registered agent query" + (registered_agent_query agent_name))) + (fun pages -> pure (first_live_entity (values_of_query_result pages))) + +let create_page invoke_config repo title = + apply_outliner_ops invoke_config repo + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + kw "create-page"; + vector_vec + (Vec.of_array + [| Edn_util.string title; Edn_util.map_vec Vec.empty |]); + |]); + |]) + +let ensure_registry_page invoke_config repo = + let open Cli_effect in + bind (pull_registry_page invoke_config repo) (function + | Some page -> pure page + | None -> + bind (create_page invoke_config repo agent_bridge_registry_page) + (fun _ -> + bind (pull_registry_page invoke_config repo) (function + | Some page -> pure page + | None -> + error + (Failure "agent bridge registry page not found after create")))) + +let register_agent_bridge invoke_config repo agent_name = + let open Cli_effect in + bind (ensure_registry_page invoke_config repo) (fun _ -> + bind (pull_agent_page invoke_config repo agent_name) (function + | Some _ -> pure () + | None -> + bind (create_page invoke_config repo agent_name) (fun _ -> + bind (pull_agent_page invoke_config repo agent_name) (function + | Some _ -> pure () + | None -> + error + (Failure + "agent bridge agent page not found after create"))))) + +let master_prompt_from_block block = + match Edn_util.get_string block "block/title" with + | Some title when title = master_prompt_wrapper_title -> + let prompts = + child_blocks block + |> Vec.filter block_has_code_tag + |> Vec.filter_map (fun child -> + Option.bind (Edn_util.get_string child "block/title") trim_non_empty) + in + if Vec.length prompts = 1 then Ok (Vec.peek_front prompts) + else if Vec.is_empty prompts then + Error + (Error.make Error.Agent_master_prompt_invalid + "agent bridge master prompt code block is missing") + else + Error + (Error.make Error.Agent_master_prompt_invalid + "agent bridge master prompt must contain one code block") + | _ -> + Error + (Error.make Error.Agent_master_prompt_invalid + "agent bridge master prompt wrapper is invalid") + +let hex_digest_prefix byte_count seed = + String.sub (Sha256.hex seed) 0 (byte_count * 2) + +let insert_default_master_prompt invoke_config repo agent_page_uuid = + let nonce = + Printf.sprintf "%.17g" (Time.time_to_epoch_seconds_float (Time.now ())) + in + let block_uuid = + "00000000-0000-4000-8000-" + ^ hex_digest_prefix 6 (agent_page_uuid ^ ":" ^ nonce) + in + let code_uuid = + "00000000-0000-4000-8001-" ^ hex_digest_prefix 6 (block_uuid ^ ":" ^ nonce) + in + let code_block = + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/uuid", Edn_util.uuid code_uuid); + (kw "block/title", Edn_util.string default_master_prompt); + ( kw "block/tags", + Edn_util.set_vec (Vec.of_array [| kw "logseq.class/Code-block" |]) + ); + (kw "logseq.property.node/display-type", kw "code"); + (kw "logseq.property.code/lang", Edn_util.string "markdown"); + |]) + in + let wrapper_block = + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/uuid", Edn_util.uuid block_uuid); + (kw "block/title", Edn_util.string master_prompt_wrapper_title); + |]) + in + apply_outliner_ops invoke_config repo + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + kw "insert-blocks"; + vector_vec + (Vec.of_array + [| + vector_vec (Vec.of_array [| wrapper_block |]); + Edn_util.uuid agent_page_uuid; + Edn_util.map_vec + (Vec.of_array + [| + (kw "outliner-op", kw "insert-blocks"); + (kw "sibling?", Edn_util.bool false); + (kw "bottom?", Edn_util.bool false); + (kw "keep-uuid?", Edn_util.bool true); + |]); + |]); + |]); + vector_vec + (Vec.of_array + [| + kw "insert-blocks"; + vector_vec + (Vec.of_array + [| + vector_vec (Vec.of_array [| code_block |]); + Edn_util.uuid block_uuid; + Edn_util.map_vec + (Vec.of_array + [| + (kw "outliner-op", kw "insert-blocks"); + (kw "sibling?", Edn_util.bool false); + (kw "bottom?", Edn_util.bool false); + (kw "keep-uuid?", Edn_util.bool true); + |]); + |]); + |]); + |]) + +let insert_default_master_prompt_code invoke_config repo wrapper_uuid = + let nonce = + Printf.sprintf "%.17g" (Time.time_to_epoch_seconds_float (Time.now ())) + in + let code_uuid = + "00000000-0000-4000-8001-" ^ hex_digest_prefix 6 (wrapper_uuid ^ ":" ^ nonce) + in + let code_block = + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/uuid", Edn_util.uuid code_uuid); + (kw "block/title", Edn_util.string default_master_prompt); + ( kw "block/tags", + Edn_util.set_vec (Vec.of_array [| kw "logseq.class/Code-block" |]) + ); + (kw "logseq.property.node/display-type", kw "code"); + (kw "logseq.property.code/lang", Edn_util.string "markdown"); + |]) + in + apply_outliner_ops invoke_config repo + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + kw "insert-blocks"; + vector_vec + (Vec.of_array + [| + vector_vec (Vec.of_array [| code_block |]); + Edn_util.uuid wrapper_uuid; + Edn_util.map_vec + (Vec.of_array + [| + (kw "outliner-op", kw "insert-blocks"); + (kw "sibling?", Edn_util.bool false); + (kw "bottom?", Edn_util.bool false); + (kw "keep-uuid?", Edn_util.bool true); + |]); + |]); + |]); + |]) + +let repairable_missing_master_prompt_code block = + Edn_util.get_string block "block/title" = Some master_prompt_wrapper_title + && Option.is_some (Edn_util.get_string block "block/uuid") + && child_blocks block |> Vec.filter block_has_code_tag |> Vec.is_empty + +let ensure_agent_master_prompt invoke_config repo agent_name = + let open Cli_effect in + bind (pull_agent_page invoke_config repo agent_name) (function + | None -> error (Failure "agent bridge agent page not found") + | Some page -> ( + match + ( Edn_util.get_int64 page "db/id", + Edn_util.get_string page "block/uuid" ) + with + | None, _ -> error (Failure "agent bridge agent page id not found") + | _, None -> error (Failure "agent bridge agent page uuid not found") + | Some page_id, Some page_uuid -> + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "agent master prompt query" + (agent_master_prompt_blocks_query page_id))) + (fun blocks -> + match + values_of_query_result blocks + |> Vec.sort (fun a b -> + String.compare (order_key a) (order_key b)) + |> first_live_entity + with + | Some block -> ( + match master_prompt_from_block block with + | Ok prompt -> pure prompt + | Error err -> + if repairable_missing_master_prompt_code block then + match Edn_util.get_string block "block/uuid" with + | Some wrapper_uuid -> + bind + (insert_default_master_prompt_code invoke_config + repo wrapper_uuid) (fun _ -> + pure default_master_prompt) + | None -> error (Failure err.Error.message) + else error (Failure err.Error.message)) + | None -> + bind + (insert_default_master_prompt invoke_config repo page_uuid) + (fun _ -> pure default_master_prompt)))) + +let ensure_prompt_templates invoke_config repo = + let open Cli_effect in + bind (ensure_registry_page invoke_config repo) (fun page -> + match + (Edn_util.get_int64 page "db/id", Edn_util.get_string page "block/uuid") + with + | None, _ -> error (Failure "agent bridge registry page id not found") + | _, None -> error (Failure "agent bridge registry page uuid not found") + | Some page_id, _ -> + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "prompt template query" + (prompt_template_blocks_query page_id))) + (fun blocks -> + let blocks = values_of_query_result blocks in + let template_or_default kind = + match blocks_by_title blocks (prompt_template_title kind) with + | Some block -> ( + match prompt_template_from_block kind block with + | Ok template -> template + | Error _ -> default_prompt_template kind) + | None -> default_prompt_template kind + in + pure + { + task = template_or_default Task; + comment = template_or_default Comment; + })) + +let ensure_reaction invoke_config repo target_uuid emoji_id = + let open Cli_effect in + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "reaction query" + (reaction_query target_uuid emoji_id))) + (fun existing -> + if not (Edn_util.is_null existing) then pure () + else + bind + (apply_outliner_ops invoke_config repo + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + kw "toggle-reaction"; + vector_vec + (Vec.of_array + [| + Edn_util.uuid target_uuid; + Edn_util.string emoji_id; + Edn_util.nil; + |]); + |]); + |])) + (fun _ -> pure ())) + +let show_task_tree config repo graph block = + let open Cli_effect in + match block_uuid block with + | None -> pure (block_title block) + | Some uuid -> + let action : Show.action = + { + repo; + graph; + target = Show.By_uuid uuid; + multi_id = false; + linked_references = false; + ref_id_footer = false; + page_hierarchy = false; + level = Some 100; + } + in + let config = + { + config with + Cli_config.output_format = Some (Output.Mode.Packed Output.Mode.Human); + } + in + bind (Show.execute action config) (fun result -> + match + Option.bind (Cli_result.data_value result) Edn_util.as_string + with + | Some text when String.trim text <> "" -> pure text + | _ -> pure (block_title block)) + +let show_block_tree = show_task_tree + +let string_contains ~needle value = + let needle_len = String.length needle in + let value_len = String.length value in + needle_len = 0 + || + let rec loop index = + index + needle_len <= value_len + && (String.sub value index needle_len = needle || loop (index + 1)) + in + loop 0 + +let tag_ident_matches ident tag = + ident_value tag = Some (strip_keyword_prefix ident) + +let comment_tag tag = tag_ident_matches "logseq.class/Comment" tag +let comments_area_tag tag = tag_ident_matches "logseq.class/Comments" tag + +let has_tag predicate block = + value_list (Edn_util.get block "block/tags") |> Vec.exists predicate + +let ref_titles block = + value_list (Edn_util.get block "block/refs") + |> Vec.filter_map (fun ref -> + Option.bind (Edn_util.get_string ref "block/title") trim_non_empty) + +let comment_block_matches block agent_name = + Option.is_some (block_uuid block) + && has_tag comment_tag block + && + let title = block_title block in + string_contains ~needle:("[[" ^ agent_name ^ "]]") title + || Vec.mem agent_name (ref_titles block) + +let comments_area_block block = has_tag comments_area_tag block + +let parent_block block = + match Edn_util.get block "block/parent" with + | Some parent when Option.is_some (Edn_util.as_map parent) -> Some parent + | _ -> None + +let pull_comment_block invoke_config repo block_id = + Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "comment block selector" comment_block_selector) + ~lookup:(Edn_util.int64 block_id) + +let pull_comments_area invoke_config repo comment_block = + let open Cli_effect in + match + Option.bind (parent_block comment_block) (fun parent -> + Edn_util.get_int64 parent "db/id") + with + | None -> error (Failure "comment block parent is missing") + | Some parent_id -> + Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "comments area selector" + comments_area_selector) + ~lookup:(Edn_util.int64 parent_id) + +let comments_area_target_blocks comments_area = + value_list (Edn_util.get comments_area "logseq.property.comments/blocks") + +let process_comment invoke_config repo graph agent_name config master_session + comment_block = + let open Cli_effect in + let ( let* ) = bind in + let* comments_area = pull_comments_area invoke_config repo comment_block in + if not (comments_area_block comments_area) then + error (Failure "comment parent is not a comments area") + else + let target_blocks = comments_area_target_blocks comments_area in + let* target_tree_texts = + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (block, rest) -> + let* text = show_block_tree config repo graph block in + loop (Vec.push_back acc text) rest + in + loop Vec.empty target_blocks + in + let* comments_area_tree_text = + show_block_tree config repo graph comments_area + in + let* comment_tree_text = show_block_tree config repo graph comment_block in + match + dispatch_comment_to_master config + ~graph:(Cli_primitive.string_of_graph graph) + ~agent_name ~master_session ~target_tree_texts ~comments_area_tree_text + ~comment_tree_text comment_block + with + | Error err -> error (Failure err.Error.message) + | Ok session -> + let* () = + match block_uuid comment_block with + | None -> pure () + | Some uuid -> ensure_reaction invoke_config repo uuid "eyes" + in + pure { block = comment_block; session = Some session } + +let route_comment_candidate invoke_config repo graph agent_name config + master_session block_id = + let open Cli_effect in + let ( let* ) = bind in + let* comment_block = pull_comment_block invoke_config repo block_id in + if comment_block_matches comment_block agent_name then + let* routed = + process_comment invoke_config repo graph agent_name config master_session + comment_block + in + pure (Some routed) + else pure None + +let comment_candidate_ids payload = + value_list (Edn_util.get payload "comment-route-candidate-ids") + |> Vec.filter_map Edn_util.as_int64 + +let route_comment_candidates invoke_config repo graph agent_name config + master_session candidate_ids = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (block_id, rest) -> + bind + (route_comment_candidate invoke_config repo graph agent_name config + master_session block_id) (function + | Some routed -> loop (Vec.push_back acc routed) rest + | None -> loop acc rest) + in + loop Vec.empty candidate_ids + +let parent_block_id block = + match Edn_util.get block "block/parent" with + | Some parent -> ( + match (Edn_util.as_map parent, Edn_util.as_int64 parent) with + | Some _, _ -> Edn_util.get_int64 parent "db/id" + | _, Some id -> Some id + | _ -> None) + | None -> None + +let nearest_ancestor_task_session invoke_config repo block = + let open Cli_effect in + let rec loop = function + | None -> pure None + | Some parent_id -> + bind + (Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "task ancestor selector" + task_ancestor_session_selector) + ~lookup:(Edn_util.int64 parent_id)) + (fun parent -> + match + ( has_task_tag parent, + Option.bind + (Edn_util.get_string parent "logseq.property.agent/session-id") + trim_non_empty, + Edn_util.get_string parent "block/uuid" ) + with + | true, Some session_id, Some parent_block_uuid -> + pure (Some { parent_block_uuid; session_id }) + | _ -> loop (parent_block_id parent)) + in + loop (parent_block_id block) + +let mark_task_started invoke_config repo block = + let open Cli_effect in + match block_uuid block with + | None -> pure () + | Some uuid -> + bind (ensure_reaction invoke_config repo uuid "eyes") (fun () -> + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.expect_vector_t "task status query" + (task_status_query uuid))) + (fun status -> + match keyword_string status with + | Some "logseq.property/status.todo" -> + bind + (apply_outliner_ops invoke_config repo + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + vector_vec + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| Edn_util.uuid uuid |]); + kw "logseq.property/status"; + kw "logseq.property/status.doing"; + Edn_util.map_vec Vec.empty; + |]); + |]); + |])) + (fun _ -> pure ()) + | _ -> pure ())) + +let process_task invoke_config repo graph agent_name config master_session block + = + let open Cli_effect in + match block_uuid block with + | Some uuid when not (claim_routing_block uuid) -> + pure { block; session = None } + | _ -> + bind (show_task_tree config repo graph block) (fun tree_text -> + bind (nearest_ancestor_task_session invoke_config repo block) + (fun inherited_session -> + match + dispatch_task_to_master config + ~graph:(Cli_primitive.string_of_graph graph) + ~agent_name ~master_session ?inherited_session ~tree_text + block + with + | Error err -> error (Failure err.Error.message) + | Ok session -> + bind (mark_task_started invoke_config repo block) (fun () -> + pure { block; session = Some session }))) + +let process_tasks invoke_config repo graph agent_name config master_session + tasks = + Cli_effect.all + (Vec.map + (process_task invoke_config repo graph agent_name config master_session) + tasks) + +let bridge_log_line message = Time.rfc3339_millis (Time.now ()) ^ " " ^ message + +let emit_bridge_log : type a. a Output.Mode.t -> string -> unit = + fun mode message -> + match mode with + | Output.Mode.Human -> Cli_unix.write_stdout (bridge_log_line message ^ "\n") + | Output.Mode.Json | Output.Mode.Edn -> () + +let command_preview command = + let shell_safe value = + let rec loop index = + if index >= String.length value then true + else + match value.[index] with + | 'A' .. 'Z' + | 'a' .. 'z' + | '0' .. '9' + | '.' | '_' | ':' | '/' | '=' | '-' -> + loop (index + 1) + | _ -> false + in + value <> "" && loop 0 + in + let quote value = + if shell_safe value then value + else "'" ^ Vec.string_concat "'\"'\"'" (Vec.split_on_char '\'' value) ^ "'" + in + Vec.string_concat " " (Vec.map quote command) + +let is_uri_component_unescaped = function + | 'A' .. 'Z' + | 'a' .. 'z' + | '0' .. '9' + | '-' | '_' | '.' | '!' | '~' | '*' | '\'' | '(' | ')' -> + true + | _ -> false + +let encode_uri_component value = + let hex = "0123456789ABCDEF" in + let buffer = Buffer.create (String.length value) in + String.iter + (fun c -> + if is_uri_component_unescaped c then Buffer.add_char buffer c + else + let code = Char.code c in + Buffer.add_char buffer '%'; + Buffer.add_char buffer hex.[code lsr 4]; + Buffer.add_char buffer hex.[code land 0x0F]) + value; + Buffer.contents buffer + +let bridge_lock_name graph agent_name = + let graph = Cli_primitive.string_of_graph graph in + encode_uri_component graph ^ "--" ^ encode_uri_component agent_name ^ ".lock" + +let bridge_lock_dir config graph agent_name = + Filename.concat + (Filename.concat config.Cli_config.root_dir "agent-bridge-locks") + (bridge_lock_name graph agent_name) + +let bridge_lock_owner_path lock_dir = Filename.concat lock_dir "owner.edn" + +let bridge_lock_owner graph agent_name = + let graph = Cli_primitive.string_of_graph graph in + Melange_edn_melange.to_edn_string + (Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "pid", Edn_util.int (Cli_unix.getpid ())); + (Edn_util.keyword "graph", Edn_util.string graph); + (Edn_util.keyword "agent", Edn_util.string agent_name); + ( Edn_util.keyword "started-at", + Edn_util.string + (Printf.sprintf "%.3f" + (Time.time_to_epoch_seconds_float (Time.now ()))) ); + |])) + ^ "\n" + +let bridge_lock_error graph agent_name = + let graph = Cli_primitive.string_of_graph graph in + Error.make Error.Agent_bridge_already_running + ("agent bridge is already running for graph '" ^ graph + ^ "' and AgentBridge name '" ^ agent_name ^ "'") + +let bridge_lock_failure message = + Error.make Error.Agent_bridge_lock_failed message + +let bridge_lock_owner_pid lock_dir = + try + let owner = + Cli_unix.read_text_file (bridge_lock_owner_path lock_dir) + |> Melange_edn_melange.of_edn_string + in + Edn_util.get_int owner "pid" + with _ -> None + +let stale_bridge_lock lock_dir = + match bridge_lock_owner_pid lock_dir with + | Some pid -> pid <= 0 || not (Cli_unix.process_running pid) + | None -> true + +let release_bridge_lock lock_dir = + try Cli_unix.remove_tree lock_dir with _ -> () + +let acquire_bridge_lock config graph agent_name = + let lock_dir = bridge_lock_dir config graph agent_name in + let rec loop retried = + try + Cli_unix.mkdir_p (Filename.dirname lock_dir); + match Cli_unix.mkdir_exclusive lock_dir 0o755 with + | Cli_unix.Created -> ( + try + Cli_unix.write_text_file + (bridge_lock_owner_path lock_dir) + (bridge_lock_owner graph agent_name); + Ok lock_dir + with exn -> + release_bridge_lock lock_dir; + Error + (bridge_lock_failure + ("failed to write agent bridge lock owner: " + ^ Printexc.to_string exn))) + | Cli_unix.Already_exists -> + if (not retried) && stale_bridge_lock lock_dir then ( + release_bridge_lock lock_dir; + loop true) + else Error (bridge_lock_error graph agent_name) + with exn -> + Error + (bridge_lock_failure + ("failed to acquire agent bridge lock: " ^ Printexc.to_string exn)) + in + loop false + +let with_bridge_lock config (graph : Cli_primitive.graph) agent_name mode body = + let open Cli_effect in + match acquire_bridge_lock config graph agent_name with + | Error err -> + pure (Output_mode.error ~command:Command_id.Agent_bridge mode err) + | Ok lock_dir -> + finally (body ()) (fun () -> + release_bridge_lock lock_dir; + pure ()) + +let route_current_tasks invoke_config repo (graph : Cli_primitive.graph) + agent_name config master_session = + let open Cli_effect in + let ( let* ) = bind in + let* tasks = list_routable_tasks invoke_config repo agent_name in + process_tasks invoke_config repo graph agent_name config master_session tasks + +let execute_bridge_once repo (graph : Cli_primitive.graph) agent_name config + mode = + let open Cli_effect in + let ( let* ) = bind in + let* server = + Server_runtime.ensure_server config repo ~create_empty_db:false + in + match server with + | Error err -> + pure (Output_mode.error ~command:Command_id.Agent_bridge mode err) + | Ok invoke_config -> ( + let* () = register_agent_bridge invoke_config repo agent_name in + let* master_prompt = + ensure_agent_master_prompt invoke_config repo agent_name + in + let* _prompt_templates = ensure_prompt_templates invoke_config repo in + match ensure_master_session config master_prompt with + | Error err -> + pure (Output_mode.error ~command:Command_id.Agent_bridge mode err) + | Ok master_session -> + let* routed_tasks = + route_current_tasks invoke_config repo graph agent_name config + master_session + in + let result = + { + mode = Edn_util.keyword_t "processed-once"; + graph; + agent_name; + routed = Vec.map routed_task_value routed_tasks; + } + in + pure + (Cli_result.ok ~command:Command_id.Agent_bridge mode + (Raw (bridge_result_value result)))) + +let sync_db_changes_event_type = Edn_util.keyword_t "sync-db-changes" + +let wait_forever () = + let task, _resolver = Cli_effect.wait () in + task + +let execute_bridge_forever repo (graph : Cli_primitive.graph) agent_name config + mode = + let open Cli_effect in + let ( let* ) = bind in + emit_bridge_log mode "checking the environment ..."; + emit_bridge_log mode ("using graph: " ^ Cli_primitive.string_of_graph graph); + emit_bridge_log mode ("using agent name: " ^ agent_name); + emit_bridge_log mode "checking codex cli ..."; + let* server = + Server_runtime.ensure_server config repo ~create_empty_db:false + in + match server with + | Error err -> + pure (Output_mode.error ~command:Command_id.Agent_bridge mode err) + | Ok invoke_config -> ( + emit_bridge_log mode "registering agent bridge ..."; + let* () = register_agent_bridge invoke_config repo agent_name in + emit_bridge_log mode "checking master prompt ..."; + let* master_prompt = + ensure_agent_master_prompt invoke_config repo agent_name + in + emit_bridge_log mode "checking prompt templates ..."; + let* _prompt_templates = ensure_prompt_templates invoke_config repo in + emit_bridge_log mode + ("Codex master command prepared: " + ^ command_preview (build_codex_command config master_prompt)); + match ensure_master_session config master_prompt with + | Error err -> + pure (Output_mode.error ~command:Command_id.Agent_bridge mode err) + | Ok master_session -> + emit_bridge_log mode + ("Codex master session started: " ^ master_session); + let process_graph_changes event_type payload = + if event_type = sync_db_changes_event_type then ( + emit_bridge_log mode "got graph changes: sync-db-changes"; + let candidate_ids = comment_candidate_ids payload in + let* _routed_comments = + route_comment_candidates invoke_config repo graph agent_name + config master_session candidate_ids + in + let* _routed_tasks = + route_current_tasks invoke_config repo graph agent_name config + master_session + in + pure ()) + else pure () + in + let* _subscription = + Transport.connect_events invoke_config process_graph_changes + in + emit_bridge_log mode "listening graph changes ..."; + let* _initial_routed_tasks = + route_current_tasks invoke_config repo graph agent_name config + master_session + in + wait_forever ()) + +let execute_with_mode (Agent_bridge { repo; graph }) config mode = + let open Cli_effect in + match resolve_agent_name config None with + | Error err -> + pure (Output_mode.error ~command:Command_id.Agent_bridge mode err) + | Ok agent_name -> + if not (codex_available config) then + pure + (Output_mode.error ~command:Command_id.Agent_bridge mode + (Error.make Error.Codex_not_found + "codex executable is not available")) + else + with_bridge_lock config graph agent_name mode (fun () -> + if agent_bridge_process_once config then + execute_bridge_once repo graph agent_name config mode + else execute_bridge_forever repo graph agent_name config mode) + +let metadata () = + Vec.singleton + { + Command_registry.id = Command_id.Agent_bridge; + path = Command_id.to_path Command_id.Agent_bridge; + doc = "Run task agent bridge"; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Hidden; + requires_graph = Command_id.requires_graph Command_id.Agent_bridge; + requires_auth = Command_id.requires_auth Command_id.Agent_bridge; + write_command = Command_id.is_write Command_id.Agent_bridge; + human_table_headers_order = Vec.empty; + } + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/auth_command.ml b/cli/lib/auth_command.ml new file mode 100644 index 0000000..ad5c585 --- /dev/null +++ b/cli/lib/auth_command.ml @@ -0,0 +1,94 @@ +open Cli_effect.Infix + +type parsed = Parsed_login | Parsed_logout +type action = Login | Logout + +let command_id = function + | Parsed_login -> Command_id.Login + | Parsed_logout -> Logout + +let validate_parsed _ = Ok () + +let build ?registry:_ _ _ = function + | Parsed_login -> Ok Login + | Parsed_logout -> Ok Logout + +let login_value (result : Auth_state.login_result) = + let fields = + Vec.of_array + [| + (Edn_util.keyword "auth-path", Edn_util.string result.auth_path); + (Edn_util.keyword "authorize-url", Edn_util.string result.authorize_url); + (Edn_util.keyword "opened", Edn_util.bool result.opened); + ( Edn_util.keyword "updated-at", + Edn_util.int64 (Time.time_to_epoch_ms result.updated_at) ); + |] + in + let fields = + match result.email with + | Some email -> + Vec.push_back fields (Edn_util.keyword "email", Edn_util.string email) + | None -> fields + in + let fields = + match result.sub with + | Some sub -> + Vec.push_back fields (Edn_util.keyword "sub", Edn_util.string sub) + | None -> fields + in + Edn_util.map_vec fields + +let logout_value (result : Auth_state.logout_result) = + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "auth-path", Edn_util.string result.auth_path); + (Edn_util.keyword "deleted", Edn_util.bool result.deleted); + (Edn_util.keyword "logout-url", Edn_util.string result.logout_url); + (Edn_util.keyword "opened", Edn_util.bool result.opened); + ( Edn_util.keyword "logout-completed", + Edn_util.bool result.logout_completed ); + |]) + +let execute_with_mode action config mode = + match action with + | Login -> ( + Auth_state.login config >>= function + | Ok result -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Login mode + (Raw (login_value result))) + | Error err -> + Cli_effect.pure (Output_mode.error ~command:Command_id.Login mode err) + ) + | Logout -> ( + Auth_state.logout config >>= function + | Ok result -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Logout mode + (Raw (logout_value result))) + | Error err -> + Cli_effect.pure + (Output_mode.error ~command:Command_id.Logout mode err)) + +let meta id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Authentication; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array [| meta Command_id.Login "Login"; meta Logout "Logout" |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/auth_state.ml b/cli/lib/auth_state.ml new file mode 100644 index 0000000..9cba59c --- /dev/null +++ b/cli/lib/auth_state.ml @@ -0,0 +1,766 @@ +open Cli_effect.Infix + +type auth_data = { + provider : string; + id_token : string option; + access_token : string option; + refresh_token : string option; + expires_at : Js.Date.t option; + sub : string option; + email : Cli_primitive.email option; + updated_at : Js.Date.t; +} + +type login_result = { + auth_path : Cli_primitive.path; + authorize_url : Cli_primitive.url; + opened : bool; + email : Cli_primitive.email option; + sub : string option; + updated_at : Js.Date.t; +} + +type logout_result = { + auth_path : Cli_primitive.path; + deleted : bool; + logout_url : Cli_primitive.url; + opened : bool; + logout_completed : bool; +} + +let default_auth_path () = + Filename.concat + (Filename.concat + (Sys.getenv_opt "HOME" |> Option.value ~default:".") + "logseq") + "auth.json" + +let auth_path config = + Option.value config.Cli_config.auth_path ~default:(default_auth_path ()) + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || Cli_unix.file_exists path then + () + else ( + mkdir_p (Filename.dirname path); + Cli_unix.mkdir path 0o755) + +let read_file = Cli_unix.read_text_file + +let http_error_message status body = + if String.trim body = "" then + "http request failed (" ^ string_of_int status ^ ")" + else + "http request failed (" ^ string_of_int status ^ ")\nhttp response: " ^ body + +let http_request ~(method_ : Fetch.requestMethod) ~url ~headers ~body + ~timeout_span = + Cli_platform.HTTP.request ?timeout_span method_ url ~headers ~body + >>= fun (response, body) -> + let status = Fetch.Response.status response in + if status >= 200 && status <= 299 then Cli_effect.pure body + else Cli_effect.error (Failure (http_error_message status body)) + +let auth_json data = + let object_ = Js.Dict.empty () in + let set_string key value = Js.Dict.set object_ key (Js.Json.string value) in + let set_int key value = + Js.Dict.set object_ key (Js.Json.number (Int64.to_float value)) + in + let set_opt_string key = Option.iter (set_string key) in + let set_opt_time key = + Option.iter (fun value -> set_int key (Time.time_to_epoch_ms value)) + in + set_string "provider" data.provider; + set_int "updated-at" (Time.time_to_epoch_ms data.updated_at); + set_opt_string "id-token" data.id_token; + set_opt_string "access-token" data.access_token; + set_opt_string "refresh-token" data.refresh_token; + set_opt_time "expires-at" data.expires_at; + set_opt_string "sub" data.sub; + set_opt_string "email" data.email; + Js.Json.stringify (Js.Json.object_ object_) ^ "\n" + +let first_string_field object_ keys = + keys |> Vec.find_map (Json_util.string_field object_) + +let first_int64_field object_ keys = + keys |> Vec.find_map (Json_util.int64_field object_) + +let parse_auth_json text = + let object_ = Json_util.object_of_json_string text in + let id_token = + Option.bind object_ (fun object_ -> + first_string_field object_ (Vec.of_array [| "id-token"; "id_token" |])) + in + let access_token = + Option.bind object_ (fun object_ -> + first_string_field object_ + (Vec.of_array [| "access-token"; "access_token" |])) + in + let refresh_token = + Option.bind object_ (fun object_ -> + first_string_field object_ + (Vec.of_array [| "refresh-token"; "refresh_token" |])) + in + let provider = + Option.bind object_ (fun object_ -> + first_string_field object_ (Vec.singleton "provider")) + in + match (provider, id_token, access_token, refresh_token) with + | None, None, None, None -> + Error (Error.make Error.Invalid_auth_file "invalid auth file") + | _, _, _, _ -> + let provider = Option.value provider ~default:"cognito" in + Ok + { + provider; + id_token; + access_token; + refresh_token; + expires_at = + Option.bind object_ (fun object_ -> + Option.map Time.time_of_epoch_ms + (first_int64_field object_ + (Vec.of_array [| "expires-at"; "expires_at" |]))); + sub = + Option.bind object_ (fun object_ -> + first_string_field object_ (Vec.singleton "sub")); + email = + Option.bind object_ (fun object_ -> + first_string_field object_ (Vec.singleton "email")); + updated_at = + Option.value + (Option.bind object_ (fun object_ -> + Option.map Time.time_of_epoch_ms + (first_int64_field object_ + (Vec.of_array [| "updated-at"; "updated_at" |])))) + ~default:Time.epoch; + } + +let jwt_payload token = + let parts = Vec.split_on_char '.' token in + if Vec.length parts = 3 then + Ok (Cli_platform.Crypto.base64url_decode (Vec.nth parts 1)) + else Error (Error.make Error.Invalid_auth_token "invalid auth token") + +let claims_of_id_token id_token = + Error.bind (jwt_payload id_token) (fun payload -> + let object_ = Json_util.object_of_json_string payload in + Ok + ( Option.bind object_ (fun object_ -> + first_string_field object_ (Vec.singleton "sub")), + Option.bind object_ (fun object_ -> + first_string_field object_ (Vec.singleton "email")), + Option.bind object_ (fun object_ -> + Option.map + (fun exp -> Time.time_of_epoch_ms (Int64.mul exp 1_000L)) + (first_int64_field object_ (Vec.singleton "exp"))) )) + +let auth_path_context path = + Edn_util.map_vec + (Vec.of_array [| (Edn_util.keyword "auth-path", Edn_util.string path) |]) + +let read_auth_file config = + let path = auth_path config in + Cli_effect.pure + (if not (Cli_unix.file_exists path) then Ok None + else + try Error.map (fun data -> Some data) (parse_auth_json (read_file path)) + with exn -> + Error + (Error.make ~context:(auth_path_context path) Error.Invalid_auth_file + (Printexc.to_string exn))) + +let write_auth_file config data = + let path = auth_path config in + Cli_effect.pure + (try + mkdir_p (Filename.dirname path); + Cli_unix.write_text_file path (auth_json data); + (try Cli_unix.chmod path 0o600 with Cli_unix.Cli_unix_error _ -> ()); + Ok data + with exn -> + Error + (Error.make ~context:(auth_path_context path) + Error.Auth_file_write_failed (Printexc.to_string exn))) + +let delete_auth_file config = + let path = auth_path config in + Cli_effect.pure + (try + if Cli_unix.file_exists path then Cli_unix.remove_tree path; + Ok () + with exn -> + Error + (Error.make ~context:(auth_path_context path) + Error.Auth_file_delete_failed (Printexc.to_string exn))) + +let expired_auth auth = + match auth.expires_at with + | Some expires_at -> Time.compare_time expires_at (Time.now ()) <= 0 + | None -> true + +let missing_auth config message = + Error.make ~hint:"Run `logseq login` first." + ~context:(auth_path_context (auth_path config)) + Error.Missing_auth message + +let config_auth config = + if + Option.is_some config.Cli_config.id_token + || Option.is_some config.access_token + || Option.is_some config.refresh_token + then + Some + { + provider = "cognito"; + id_token = config.id_token; + access_token = config.access_token; + refresh_token = config.refresh_token; + expires_at = None; + sub = None; + email = None; + updated_at = Time.now (); + } + else None + +let raw_config_string config keys = + match config.Cli_config.raw_file_config with + | None -> None + | Some value -> Vec.find_map (Edn_util.get_string value) keys + +let default_oauth_domain = "logseq-prod.auth.us-east-1.amazoncognito.com" +let default_oauth_client_id = "69cs1lgme7p8kbgld8n5kseii6" +let default_api_http_base = "https://api.logseq.io" + +let normalize_base_url value = + let value = String.trim value in + if value <> "" && value.[String.length value - 1] = '/' then + String.sub value 0 (String.length value - 1) + else value + +let oauth_domain_base config = + match + raw_config_string config (Vec.of_array [| "oauth-domain"; "domain" |]) + with + | Some domain when String.trim domain <> "" -> "https://" ^ String.trim domain + | _ -> "https://" ^ default_oauth_domain + +let raw_http_base config = raw_config_string config (Vec.singleton "http-base") + +let configured_http_base config = + match raw_http_base config with + | Some base when String.trim base <> "" -> Some base + | _ -> ( + match config.Cli_config.http_base with + | Some base + when String.trim base <> "" + && normalize_base_url base <> default_api_http_base -> + Some base + | _ -> None) + +let oauth_endpoint_base config = + Option.value (configured_http_base config) ~default:(oauth_domain_base config) + +let token_endpoint config = + match + raw_config_string config + (Vec.of_array [| "oauth-token-endpoint"; "token-endpoint" |]) + with + | Some endpoint when String.trim endpoint <> "" -> Some endpoint + | _ -> Some (normalize_base_url (oauth_endpoint_base config) ^ "/oauth2/token") + +let logout_endpoint config = + match + raw_config_string config + (Vec.of_array [| "oauth-logout-endpoint"; "logout-endpoint" |]) + with + | Some endpoint when String.trim endpoint <> "" -> Some endpoint + | _ -> Some (normalize_base_url (oauth_endpoint_base config) ^ "/logout") + +let oauth_client_id config = + match + raw_config_string config (Vec.of_array [| "oauth-client-id"; "client-id" |]) + with + | Some client_id when String.trim client_id <> "" -> Some client_id + | _ -> Some default_oauth_client_id + +let authorize_endpoint config = + match + raw_config_string config + (Vec.of_array [| "oauth-authorize-endpoint"; "authorize-endpoint" |]) + with + | Some endpoint when String.trim endpoint <> "" -> Some endpoint + | _ -> + Some + (normalize_base_url (oauth_endpoint_base config) ^ "/oauth2/authorize") + +let oauth_scope config = + Option.value + (raw_config_string config (Vec.of_array [| "oauth-scope"; "scope" |])) + ~default:"email openid phone" + +let raw_config_bool config keys ~default = + match config.Cli_config.raw_file_config with + | None -> default + | Some value -> ( + match Vec.find_map (Edn_util.get value) keys with + | Some value when Option.is_some (Edn_util.as_bool value) -> + Option.get (Edn_util.as_bool value) + | Some value when Option.is_some (Edn_util.as_string value) -> + let value = Option.get (Edn_util.as_string value) in + String.lowercase_ascii (String.trim value) = "true" + | _ -> default) + +let replace_char ~needle ~replacement value = + value |> Vec.split_on_char needle |> Vec.string_concat replacement + +let form_encode value = + value |> Js.Global.encodeURIComponent + |> replace_char ~needle:'!' ~replacement:"%21" + |> replace_char ~needle:'\'' ~replacement:"%27" + |> replace_char ~needle:'(' ~replacement:"%28" + |> replace_char ~needle:')' ~replacement:"%29" + |> replace_char ~needle:'*' ~replacement:"%2A" + +let form_body fields = + fields + |> Vec.map (fun (key, value) -> form_encode key ^ "=" ^ form_encode value) + |> Vec.string_concat "&" + +let query_string fields = + fields + |> Vec.map (fun (key, value) -> form_encode key ^ "=" ^ form_encode value) + |> Vec.string_concat "&" + +let random_base64url size = Cli_platform.Crypto.random_base64url size +let pkce_challenge verifier = Sha256.base64url verifier + +let raw_or_random config keys size = + match raw_config_string config keys with + | Some value when String.trim value <> "" -> value + | _ -> random_base64url size + +let refreshed_auth_of_body current body = + match Json_util.object_of_json_string body with + | None -> + Error + (Error.make Error.Missing_id_token + "auth token response missing id_token") + | Some object_ -> ( + match + first_string_field object_ (Vec.of_array [| "id_token"; "id-token" |]) + with + | None -> + Error + (Error.make Error.Missing_id_token + "auth token response missing id_token") + | Some id_token -> + Error.bind (claims_of_id_token id_token) + (fun (sub, email, expires_at) -> + Ok + { + provider = "cognito"; + id_token = Some id_token; + access_token = + first_string_field object_ + (Vec.of_array [| "access_token"; "access-token" |]); + refresh_token = + (match + first_string_field object_ + (Vec.of_array [| "refresh_token"; "refresh-token" |]) + with + | Some _ as token -> token + | None -> current.refresh_token); + expires_at; + sub; + email; + updated_at = Time.now (); + })) + +let refresh_auth config data = + match data.refresh_token with + | None -> + Cli_effect.pure (Error (missing_auth config "missing refresh token")) + | Some refresh_token -> ( + match token_endpoint config with + | None -> + Cli_effect.pure + (Error + (Error.make ~hint:"Run `logseq login` first." + Error.Auth_refresh_failed + "auth token endpoint is not configured")) + | Some url -> + let fields = + Vec.of_array + [| + ("grant_type", "refresh_token"); ("refresh_token", refresh_token); + |] + in + let fields = + match oauth_client_id config with + | Some client_id -> Vec.push_back fields ("client_id", client_id) + | None -> fields + in + Cli_effect.catch + (Cli_effect.map + (fun body -> refreshed_auth_of_body data body) + (http_request ~method_:Fetch.Post ~url + ~headers: + (Vec.of_array + [| + ("Content-Type", "application/x-www-form-urlencoded"); + ("Accept", "application/json"); + |]) + ~body:(form_body fields) + ~timeout_span:(Some config.timeout_span))) + (fun exn -> + Cli_effect.pure + (Error + (Error.make ~hint:"Run `logseq login` first." + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "auth-path", + Edn_util.string (auth_path config) ); + ( Edn_util.keyword "error", + Edn_util.string (Printexc.to_string exn) ); + |])) + Error.Auth_refresh_failed "auth refresh failed")))) + +let auth_code_exchange config ~code ~redirect_uri ~code_verifier = + match token_endpoint config with + | None -> + Cli_effect.pure + (Error + (Error.make ~hint:"Run `logseq login` first." + Error.Auth_code_exchange_failed + "auth token endpoint is not configured")) + | Some url -> + let fields = + Vec.of_array + [| + ("grant_type", "authorization_code"); + ("code", code); + ("redirect_uri", redirect_uri); + ("code_verifier", code_verifier); + |] + in + let fields = + match oauth_client_id config with + | Some client_id -> Vec.push_back fields ("client_id", client_id) + | None -> fields + in + Cli_effect.catch + (Cli_effect.map + (fun body -> + refreshed_auth_of_body + { + provider = "cognito"; + id_token = None; + access_token = None; + refresh_token = None; + expires_at = None; + sub = None; + email = None; + updated_at = Time.now (); + } + body) + (http_request ~method_:Fetch.Post ~url + ~headers: + (Vec.of_array + [| + ("Content-Type", "application/x-www-form-urlencoded"); + ("Accept", "application/json"); + |]) + ~body:(form_body fields) ~timeout_span:(Some config.timeout_span))) + (fun exn -> + Cli_effect.pure + (Error + (Error.make ~hint:"Run `logseq login` first." + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "auth-path", + Edn_util.string (auth_path config) ); + ( Edn_util.keyword "error", + Edn_util.string (Printexc.to_string exn) ); + |])) + Error.Auth_code_exchange_failed + "authorization code exchange failed"))) + +let redirect_path = "/auth/callback" +let callback_host = "localhost" +let callback_port = 8765 + +let split_once c value = + match String.index_opt value c with + | None -> (value, "") + | Some idx -> + ( String.sub value 0 idx, + String.sub value (idx + 1) (String.length value - idx - 1) ) + +let url_decode value = + try + value + |> replace_char ~needle:'+' ~replacement:" " + |> Js.Global.decodeURIComponent + with _ -> value + +let query_params query = + Vec.split_on_char '&' query + |> Vec.filter_map (fun part -> + if part = "" then None + else + let key, value = split_once '=' part in + Some (url_decode key, url_decode value)) + +let login_callback_response status body : Cli_platform.login_callback_response = + { status; body } + +let callback_uri path = + "http://" ^ callback_host ^ ":" ^ string_of_int callback_port ^ path + +let callback_result_of_target ~state = function + | None -> + ( login_callback_response 400 "Invalid request", + Error + (Error.make Error.Invalid_callback_request + "invalid login callback request") ) + | Some target -> ( + let path, query = split_once '?' target in + let params = query_params query in + let param key = Vec.assoc_opt key params in + match (path, param "error", param "state", param "code") with + | path, _, _, _ when path <> redirect_path -> + ( login_callback_response 404 "Not found", + Error + (Error.make Error.Login_callback_not_found + "login callback path not found") ) + | _, Some oauth_error, _, _ -> + ( login_callback_response 400 + "Login failed. You can return to the CLI.", + Error + (Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "oauth-error", + Edn_util.string oauth_error ); + |])) + Error.Login_callback_error + "login callback returned oauth error") ) + | _, _, Some callback_state, _ when callback_state <> state -> + ( login_callback_response 400 + "Login failed due to state mismatch. Return to the CLI and retry.", + Error + (Error.make Error.Invalid_callback_state + "login callback state mismatch") ) + | _, _, _, Some code when String.trim code <> "" -> + ( login_callback_response 200 + "Login successful. You can return to the CLI.", + Ok code ) + | _ -> + ( login_callback_response 400 + "Login failed because the callback did not include a code.", + Error + (Error.make Error.Missing_callback_code + "missing authorization code") )) + +let open_browser config url = + if + not + (raw_config_bool config + (Vec.of_array [| "open-browser"; "open-browser?" |]) + ~default:true) + then Ok false + else if Cli_unix.open_url url then Ok true + else Error (Error.make Error.Browser_open_failed "failed to open browser") + +let resolve_auth config = + match config_auth config with + | Some auth -> Cli_effect.pure (Ok auth) + | None -> ( + read_auth_file config >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok None -> Cli_effect.pure (Error (missing_auth config "missing auth")) + | Ok (Some auth) -> + if expired_auth auth then + refresh_auth config auth >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok refreshed -> write_auth_file config refreshed + else Cli_effect.pure (Ok auth)) + +let login config = + match authorize_endpoint config with + | None -> + Cli_effect.pure + (Error + (Error.make Error.Login_not_configured + "oauth authorize endpoint is not configured")) + | Some authorize_endpoint -> ( + match oauth_client_id config with + | None -> + Cli_effect.pure + (Error + (Error.make Error.Login_not_configured + "oauth client id is not configured")) + | Some client_id -> ( + let state = + raw_or_random config (Vec.of_array [| "oauth-state"; "state" |]) 24 + in + let code_verifier = + raw_or_random config + (Vec.of_array [| "oauth-code-verifier"; "code-verifier" |]) + 48 + in + let code_challenge = + match + raw_config_string config + (Vec.of_array [| "oauth-code-challenge"; "code-challenge" |]) + with + | Some value when String.trim value <> "" -> value + | _ -> pkce_challenge code_verifier + in + let redirect_uri = callback_uri redirect_path in + let authorize_url = + authorize_endpoint ^ "?" + ^ query_string + (Vec.of_array + [| + ("response_type", "code"); + ("client_id", client_id); + ("scope", oauth_scope config); + ("redirect_uri", redirect_uri); + ("state", state); + ("code_challenge", code_challenge); + ("code_challenge_method", "S256"); + |]) + in + let finish_login opened code = + auth_code_exchange config ~code ~redirect_uri ~code_verifier + >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok auth -> ( + write_auth_file config auth >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok data -> + Cli_effect.pure + (Ok + { + auth_path = auth_path config; + authorize_url; + opened; + email = data.email; + sub = data.sub; + updated_at = data.updated_at; + })) + in + let opened_ref = ref false in + let browser_open_error = ref None in + let on_listen () = + match open_browser config authorize_url with + | Ok opened -> + opened_ref := opened; + Cli_effect.pure (Ok ()) + | Error err -> + browser_open_error := Some err; + Cli_effect.pure (Error err.Error.message) + in + let handle_request request = + callback_result_of_target ~state request.Cli_platform.target + in + Cli_platform.login_callback_server ~host:callback_host + ~port:callback_port ~timeout_span:config.login_timeout_span + ~on_listen ~handle_request + >>= function + | Ok (Ok code) -> finish_login !opened_ref code + | Ok (Error err) -> Cli_effect.pure (Error err) + | Error Cli_platform.Login_callback_timeout -> + Cli_effect.pure + (Error + (Error.make Error.Login_timeout "login callback timed out")) + | Error (Cli_platform.Login_callback_server_aborted message) -> ( + match !browser_open_error with + | Some err -> Cli_effect.pure (Error err) + | None -> + Cli_effect.pure + (Error + (Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "error", + Edn_util.string message ); + |])) + Error.Login_callback_server_start_failed + "failed to start login callback server"))) + | Error (Cli_platform.Login_callback_server_start_failed message) -> + Cli_effect.pure + (Error + (Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "error", + Edn_util.string message ); + |])) + Error.Login_callback_server_start_failed + "failed to start login callback server")))) + +let logout config = + let path = auth_path config in + let existed = Cli_unix.file_exists path in + match logout_endpoint config with + | None -> + Cli_effect.pure + (Error + (Error.make Error.Logout_not_configured + "oauth logout endpoint is not configured")) + | Some logout_endpoint -> ( + match oauth_client_id config with + | None -> + Cli_effect.pure + (Error + (Error.make Error.Logout_not_configured + "oauth client id is not configured")) + | Some client_id -> ( + let redirect_uri = callback_uri redirect_path in + let state = + raw_or_random config + (Vec.of_array [| "oauth-logout-state"; "logout-state" |]) + 24 + in + let logout_url = + logout_endpoint ^ "?" + ^ query_string + (Vec.of_array + [| + ("response_type", "code"); + ("client_id", client_id); + ("redirect_uri", redirect_uri); + ("state", state); + ("scope", oauth_scope config); + |]) + in + let result opened = + { + auth_path = path; + deleted = existed; + logout_url; + opened; + logout_completed = true; + } + in + delete_auth_file config >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok () -> ( + match open_browser config logout_url with + | Ok opened -> Cli_effect.pure (Ok (result opened)) + | Error err -> Cli_effect.pure (Error err)))) diff --git a/cli/lib/block.ml b/cli/lib/block.ml new file mode 100644 index 0000000..5ea1a46 --- /dev/null +++ b/cli/lib/block.ml @@ -0,0 +1,119 @@ +type position = First_child | Last_child | Sibling + +type t = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + title : string option; + name : string option; + order : int option; + parent : Selector.block option; + page : Selector.page option; + tags : Selector.tag Rrbvec.t; + properties : Property.assignment Rrbvec.t; + children : t Rrbvec.t; + raw : Melange_edn_melange.any; +} + +type tree = { root : t } + +let position_of_string = function + | "first-child" | "first_child" -> Some First_child + | "last-child" | "last_child" -> Some Last_child + | "sibling" -> Some Sibling + | _ -> None + +let make ?uuid ?title ?(children = Vec.empty) () = + { + id = None; + uuid; + title; + name = None; + order = None; + parent = None; + page = None; + tags = Vec.empty; + properties = Vec.empty; + children; + raw = Edn_util.nil; + } + +let of_value raw = + { + (make ()) with + raw; + id = Edn_util.get_int64 raw "db/id"; + uuid = Edn_util.get_string raw "block/uuid"; + title = Edn_util.get_string raw "block/title"; + } + +let tag_to_value = function + | Selector.Tag_id id -> Edn_util.int64 id + | Tag_name name -> Edn_util.string name + | Tag_ident ident -> Edn_util.any ident + | Tag_uuid uuid -> Edn_util.uuid uuid + +let property_key_to_value = function + | Property.Key_ident ident -> Edn_util.any ident + | Key_id id -> Edn_util.int64 id + | Key_name name -> Edn_util.string name + +let parent_to_value = function + | Selector.Block_id id -> Edn_util.int64 id + | Block_uuid uuid -> + Edn_util.vector_vec + (Vec.of_array [| Edn_util.keyword "block/uuid"; Edn_util.uuid uuid |]) + +let rec to_value t = + let fields = + Vec.singleton + ( Edn_util.keyword "block/title", + Edn_util.string (Option.value t.title ~default:"") ) + in + let fields = + if Vec.is_empty t.children then fields + else + Vec.push_back fields + ( Edn_util.keyword "block/children", + Edn_util.vector_vec + (t.children |> Vec.map (fun child -> Edn_util.any (to_value child))) + ) + in + let fields = + match t.uuid with + | Some uuid -> + Vec.push_back fields (Edn_util.keyword "block/uuid", Edn_util.uuid uuid) + | None -> fields + in + let fields = + match t.id with + | Some id -> + Vec.push_back fields (Edn_util.keyword "db/id", Edn_util.int64 id) + | None -> fields + in + let fields = + match t.parent with + | Some parent -> + Vec.push_back fields + (Edn_util.keyword "block/parent", parent_to_value parent) + | None -> fields + in + let fields = + if Vec.is_empty t.tags then fields + else + Vec.push_back fields + ( Edn_util.keyword "block/tags", + Edn_util.set_vec (t.tags |> Vec.map tag_to_value) ) + in + let fields = + Vec.fold_left + (fun fields assignment -> + Vec.push_back fields + (property_key_to_value assignment.Property.key, assignment.value)) + fields t.properties + in + Edn_util.map_t_vec fields + +let rec flatten xs = + Vec.append xs (Vec.concat_map (fun t -> flatten t.children) xs) + +let label t = match t.title with Some _ as x -> x | None -> t.name diff --git a/cli/lib/cli.ml b/cli/lib/cli.ml new file mode 100644 index 0000000..ebd5826 --- /dev/null +++ b/cli/lib/cli.ml @@ -0,0 +1,469 @@ +type app_context = { + registry : Command_registry.t; + defaults : Cli_config.defaults; +} + +type run_input = { + argv : string Rrbvec.t; + env : (string * string) Rrbvec.t; + cwd : Cli_primitive.path; + stdin : string option; +} + +let command_metadata () = + Vec.fold_left Vec.append Vec.empty + (Vec.of_array + [| + Graph.metadata (); + List_command.metadata (); + Upsert.metadata (); + Remove.metadata (); + Search.metadata (); + Query.metadata (); + Show.metadata (); + Server_command.metadata (); + Sync.metadata (); + Completion.metadata (); + Skill.metadata (); + Example.metadata (); + Doctor.metadata (); + Debug.metadata (); + Agent.metadata (); + Auth_command.metadata (); + |]) + +let command_registry () = Command_registry.make (command_metadata ()) + +let make_app ?version:_ () = + { registry = command_registry (); defaults = Cli_config.defaults () } + +let make_app_context = make_app +let env_lookup env key = Vec.assoc_opt key env +let is_option token = String.length token > 0 && token.[0] = '-' +let option_value key options = + Vec.find_map + (fun (candidate, value) -> if candidate = key then value else None) + options +let option_present key options = Vec.mem_assoc key options + +let normalize_key = function + | "-g" -> Some "graph" + | "-o" -> Some "output" + | "-c" -> Some "content" + | "-e" -> Some "expand" + | "-f" -> Some "fields" + | "-h" -> Some "help" + | "-s" -> Some "sort" + | "-t" -> Some "type" + | "-v" -> Some "verbose" + | token when String.length token > 2 && String.sub token 0 2 = "--" -> + Some (String.sub token 2 (String.length token - 2)) + | _ -> None + +let split_equals_option token = + match String.index_opt token '=' with + | None -> None + | Some index -> + let key = String.sub token 0 index in + let value = + String.sub token (index + 1) (String.length token - index - 1) + in + Option.map (fun key -> (key, value)) (normalize_key key) + +let boolean_option = function + | "version" | "help" | "verbose" | "profile" | "enable-sync" | "expand" + | "include-built-in" | "include-journal" | "journal-only" | "include-hidden" + | "with-properties" | "with-extends" | "with-classes" | "with-type" + | "page-hierarchy" | "linked-references" | "ref-id-footer" | "progress" + | "upload-keys" -> + true + | _ -> false + +let boolean_literal value = + match String.lowercase_ascii (String.trim value) with + | "true" | "false" -> true + | _ -> false + +let parse_tokens argv = + let rec loop options positional remaining = + match Vec.pop_front remaining with + | None -> (options, positional) + | Some ("--", rest) -> (options, Vec.append positional rest) + | Some (token, rest) -> ( + match split_equals_option token with + | Some (key, value) -> + loop (Vec.push_back options (key, Some value)) positional rest + | None -> ( + match normalize_key token with + | Some key when boolean_option key -> ( + match Vec.pop_front rest with + | Some (value, tail) when boolean_literal value -> + loop + (Vec.push_back options (key, Some value)) + positional tail + | _ -> + loop + (Vec.push_back options (key, Some "true")) + positional rest) + | Some key -> ( + match Vec.pop_front rest with + | Some (value, tail) when not (is_option value) -> + loop + (Vec.push_back options (key, Some value)) + positional tail + | _ -> loop (Vec.push_back options (key, None)) positional rest) + | None -> loop options (Vec.push_back positional token) rest)) + in + loop Vec.empty Vec.empty argv + +let stdin_required_for_show_id argv = + let options, positional = parse_tokens argv in + Vec.length positional = 1 + && Vec.peek_front positional = "show" + && option_present "id" options + && Option.is_none (option_value "id" options) + +let read_stdin_all () = Cli_unix.read_stdin_all () + +let output_mode_of_options options = + Option.bind (option_value "output" options) Output_mode.of_string + |> Option.value ~default:Output_mode.default + +let output_mode_of_globals globals = + Option.value globals.Global_opts.output_format ~default:Output_mode.default + +let output_mode_of_config config = Output_mode.for_config config + +let result_text_for_mode result (Output.Mode.Packed mode) = + match mode with + | Output.Mode.Human -> ( + match result.Cli_result.status with + | Ok -> ( + match result.data with + | Some (Cli_result.Message message) -> message + | _ -> "") + | Error -> ( + match result.error with + | Some err -> Format_types.format_error err result.command + | None -> "")) + | Output.Mode.Json -> Format_types.to_json result + | Output.Mode.Edn -> Format_types.to_edn result + +type pending_output = { + stdout : string; + stderr : string Rrbvec.t; + exit_code : int; +} + +let pending_output : pending_output option ref = ref None + +let set_pending_output ?(stderr = Vec.empty) ~exit_code stdout = + pending_output := Some { stdout; stderr; exit_code } + +let take_pending_output () = + let output = !pending_output in + pending_output := None; + output + +let set_pending_error_output ?command mode err = + let (Output.Mode.Packed concrete_mode) = mode in + let result = Cli_result.error ?command concrete_mode err in + set_pending_output ~exit_code:1 (result_text_for_mode result mode) + +let set_pending_message_output ?command mode message = + let (Output.Mode.Packed concrete_mode) = mode in + let result = + Cli_result.ok ?command concrete_mode (Cli_result.Message message) + in + set_pending_output ~exit_code:0 (result_text_for_mode result mode) + +let is_prefix prefix path = + let rec loop index = + index >= Vec.length prefix + || (Vec.nth prefix index = Vec.nth path index && loop (index + 1)) + in + Vec.length prefix <= Vec.length path && loop 0 + +let group_help_path registry path = + if Vec.is_empty path then true + else if Option.is_some (Command_registry.find_by_path path registry) then + false + else + Vec.exists + (fun command -> + is_prefix path command.Command_registry.path + && Vec.length command.Command_registry.path > Vec.length path) + registry.Command_registry.commands + +let help_group registry options positional = + if option_present "help" options then Some positional + else if group_help_path registry positional then Some positional + else None + +let profile_status exit_code = + Edn_util.keyword_t (if exit_code = 0 then "ok" else "error") + +let command_name = function + | Some command -> Command_id.to_string command + | None -> "unknown" + +let profile_lines config result exit_code = + match config.Cli_config.profile_session with + | None -> Vec.empty + | Some session -> + Profile_types.report session + ~command:(command_name result.Cli_result.command) + ~status:(profile_status exit_code) + |> Profile_types.render_lines + +let human_table_headers_order registry command = + match command with + | None -> Vec.empty + | Some command -> + registry.Command_registry.commands + |> Vec.find_opt (fun meta -> meta.Command_registry.id = command) + |> Option.map (fun (meta : Command_registry.command_meta) -> + meta.human_table_headers_order) + |> Option.value ~default:Vec.empty + +let with_registry_metadata app result = + Cli_result.with_human_table_headers_order + (human_table_headers_order app.registry result.Cli_result.command) + result + +let verbose_line config result = + if not config.Cli_config.verbose then Vec.empty + else + Vec.singleton + (Melange_edn_melange.to_edn_string + (Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "level", Edn_util.keyword "debug"); + ( Edn_util.keyword "message", + Edn_util.keyword "cli/parsed-options" ); + ( Edn_util.keyword "command", + Edn_util.string (command_name result.Cli_result.command) ); + (Edn_util.keyword "root-dir", Edn_util.string config.root_dir); + |]))) + +type raw_argv +type parsed_argv +type resolved_config +type built_action +type executed_action +type error +type final +type not_final + +type ('phase, 'final) state = + | Raw_argv_state : run_input -> (raw_argv, not_final) state + | Parsed_argv_state : + (run_input * Cli_request.t) + -> (parsed_argv, not_final) state + | Resolved_config_state : + (Cli_config.t * Cli_request.t) + -> (resolved_config, not_final) state + | Built_action_state : + (Cli_config.t * Cli_action.t) + -> (built_action, not_final) state + | Executed_action_state : + (Cli_config.t * Cli_result.t) + -> (executed_action, final) state + | Error_state : Error.t -> (error, final) state + +let make_error_state err = Error_state err +let make_raw_argv_state _ input = Raw_argv_state input +let parse_request _ input = Cli_parse.parse ?stdin:input.stdin input.argv + +let parse_args : type marker. + app_context -> + (raw_argv, marker) state -> + (parsed_argv, not_final) state Error.build_result = + fun app (Raw_argv_state input) -> + let input = + match input.stdin with + | Some _ -> input + | None when stdin_required_for_show_id input.argv -> + { input with stdin = Some (read_stdin_all ()) } + | None -> input + in + let options, positional = parse_tokens input.argv in + let mode = output_mode_of_options options in + let help = + if option_present "version" options then None + else help_group app.registry options positional + in + match help with + | Some group -> + Command_registry.render_help ~group app.registry + |> set_pending_message_output mode; + Error (Error.unknown_command "") + | None -> ( + match parse_request app input with + | Ok request -> Ok (Parsed_argv_state (input, request)) + | Error err -> + set_pending_error_output mode err; + Error err) + +let resolve_request_config app request input = + Cli_config.resolve ~defaults:app.defaults ~env:(env_lookup input.env) + request.Cli_request.globals + |> Cli_effect.map + (Error.map (fun r -> + { r.Cli_config.config with project_dir = Some input.cwd })) + +let ensure_root_config config = + match Root_dir_types.ensure_root_dir (Some config.Cli_config.root_dir) with + | Ok root_dir -> Ok { config with Cli_config.root_dir } + | Error _ as err -> err + +let graph_exists_error graph = + let graph_name = Cli_primitive.string_of_graph graph in + Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array + [| (Edn_util.keyword "graph", Edn_util.string graph_name) |])) + Error.Graph_exists + ("graph already exists: " ^ graph_name) + +let action_graph_exists config graph = + Vec.exists + (fun existing -> + String.equal + (Cli_primitive.string_of_graph existing) + (Cli_primitive.string_of_graph graph)) + (Server_runtime.list_graphs config) + +let ensure_action_graph_constraints config action = + match Cli_action.graph action with + | Some graph + when Cli_action.requires_missing_graph action + && action_graph_exists config graph -> + Error (graph_exists_error graph) + | _ -> Ok () + +let resolve_config : type marker. + app_context -> + (parsed_argv, marker) state -> + (resolved_config, not_final) state Error.build_result Cli_effect.t = + fun app (Parsed_argv_state (input, request)) -> + Cli_effect.map + (fun result -> + match result with + | Error err -> + set_pending_error_output + ~command:(Cli_request.command_id request) + (output_mode_of_globals request.Cli_request.globals) + err; + Error err + | Ok config -> ( + match ensure_root_config config with + | Error err -> + set_pending_error_output + ~command:(Cli_request.command_id request) + (output_mode_of_config config) + err; + Error err + | Ok config -> + let profile_session = + Profile_types.create_session config.Cli_config.profile + in + Option.iter + (fun session -> + let now = Time.now () in + Profile_types.record_span session + { + stage = "cli.parse-args"; + span_id = 1; + start_time = now; + end_time = now; + elapsed_span = Time.zero_span; + }) + profile_session; + Ok + (Resolved_config_state + ({ config with Cli_config.profile_session }, request)))) + (resolve_request_config app request input) + +let build_request_action app request config = + match request.Cli_request.command with + | Cli_request.Completion parsed -> + Cli_effect.pure + (Error.map + (fun action -> Cli_action.Completion action) + (Completion.build ~registry:app.registry config request.globals + parsed)) + | _ -> Cli_action.build config request + +let build_action : type marker. + app_context -> + (resolved_config, marker) state -> + (built_action, not_final) state Error.build_result Cli_effect.t = + fun app (Resolved_config_state (config, request)) -> + Cli_effect.map + (fun result -> + match result with + | Error err -> + set_pending_error_output + ~command:(Cli_request.command_id request) + (output_mode_of_config config) + err; + Error err + | Ok action -> ( + match ensure_action_graph_constraints config action with + | Error err -> + set_pending_error_output + ~command:(Cli_request.command_id request) + (output_mode_of_config config) + err; + Error err + | Ok () -> Ok (Built_action_state (config, action)))) + (build_request_action app request config) + +let execute_action : type marker. + app_context -> + (built_action, marker) state -> + (executed_action, final) state Error.build_result Cli_effect.t = + fun app (Built_action_state (config, action)) -> + Cli_effect.map + (fun result -> + Ok (Executed_action_state (config, with_registry_metadata app result))) + (Cli_action.execute action config) + +let format_cli_result result config = Format_types.format_result result config + +let format_result _ (Executed_action_state (config, result)) = + format_cli_result result config + +let result_exit_code result = Cli_result.exit_code result +let write_stdout_line output = if output <> "" then print_string (output ^ "\n") + +let final_effect : type phase. (phase, final) state -> int Cli_effect.t = + function + | Executed_action_state (config, result) -> + let output = format_cli_result result config in + let exit_code = result_exit_code result in + write_stdout_line output; + Vec.iter prerr_endline (verbose_line config result); + Vec.iter prerr_endline (profile_lines config result exit_code); + flush stdout; + flush stderr; + Cli_effect.pure exit_code + | Error_state err -> + let output = + match take_pending_output () with + | Some output -> output + | None -> + { + stdout = Format_types.format_error err None; + stderr = Vec.empty; + exit_code = 1; + } + in + write_stdout_line output.stdout; + Vec.iter prerr_endline output.stderr; + flush stdout; + flush stderr; + Cli_effect.pure output.exit_code diff --git a/cli/lib/cli_action.ml b/cli/lib/cli_action.ml new file mode 100644 index 0000000..89fcc8f --- /dev/null +++ b/cli/lib/cli_action.ml @@ -0,0 +1,135 @@ +type t = + | Version + | Graph of Graph.action + | List of List_command.action + | Upsert of Upsert.action + | Remove of Remove.action + | Search of Search.action + | Query of Query.action + | Show of Show.action + | Server of Server_command.action + | Sync of Sync.action + | Auth of Auth_command.action + | Agent of Agent.action + | Doctor of Doctor.action + | Debug of Debug.action + | Skill of Skill.action + | Completion of Completion.action + | Example of Example.action + +let build_time : string = + [%mel.raw + {| +typeof LOGSEQ_CLI_BUILD_TIME !== "undefined" ? LOGSEQ_CLI_BUILD_TIME : "unknown" +|}] + +let revision : string = + [%mel.raw + {| +typeof LOGSEQ_CLI_REVISION !== "undefined" ? LOGSEQ_CLI_REVISION : "dev" +|}] + +let version_output () = "Build time: " ^ build_time ^ "\nRevision: " ^ revision + +let build config request = + let result = + match request.Cli_request.command with + | Cli_request.Version -> Ok Version + | Graph parsed -> + Error.bind (Graph.build config request.globals parsed) (fun action -> + Ok (Graph action)) + | List parsed -> + Error.bind (List_command.build config request.globals parsed) + (fun action -> Ok (List action)) + | Upsert parsed -> + Error.bind (Upsert.build config request.globals parsed) (fun action -> + Ok (Upsert action)) + | Remove parsed -> + Error.bind (Remove.build config request.globals parsed) (fun action -> + Ok (Remove action)) + | Search parsed -> + Error.bind (Search.build config request.globals parsed) (fun action -> + Ok (Search action)) + | Query parsed -> + Error.bind (Query.build config request.globals parsed) (fun action -> + Ok (Query action)) + | Show parsed -> + Error.bind (Show.build config request.globals parsed) (fun action -> + Ok (Show action)) + | Server parsed -> + Error.bind (Server_command.build config request.globals parsed) + (fun action -> Ok (Server action)) + | Sync parsed -> + Error.bind (Sync.build config request.globals parsed) (fun action -> + Ok (Sync action)) + | Debug parsed -> + Error.bind (Debug.build config request.globals parsed) (fun action -> + Ok (Debug action)) + | Doctor parsed -> + Error.bind (Doctor.build config request.globals parsed) (fun action -> + Ok (Doctor action)) + | Skill parsed -> + Error.bind (Skill.build config request.globals parsed) (fun action -> + Ok (Skill action)) + | Completion parsed -> + Error.bind (Completion.build config request.globals parsed) + (fun action -> Ok (Completion action)) + | Example parsed -> + Error.bind (Example.build config request.globals parsed) (fun action -> + Ok (Example action)) + | Auth parsed -> + Error.bind (Auth_command.build config request.globals parsed) + (fun action -> Ok (Auth action)) + | Agent parsed -> + Error.bind (Agent.build config request.globals parsed) (fun action -> + Ok (Agent action)) + in + Cli_effect.pure result + +let output_mode config = + Option.value config.Cli_config.output_format ~default:Output_mode.default + +let execute action config = + let (Output.Mode.Packed mode) = output_mode config in + match action with + | Version -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Version mode + (Message (version_output ()))) + | Graph action -> Graph.execute action config + | List action -> List_command.execute action config + | Upsert action -> Upsert.execute action config + | Remove action -> Remove.execute action config + | Search action -> Search.execute action config + | Query action -> Query.execute action config + | Show action -> Show.execute action config + | Server action -> Server_command.execute action config + | Sync action -> Sync.execute action config + | Debug action -> Debug.execute action config + | Doctor action -> Doctor.execute action config + | Skill action -> Skill.execute action config + | Completion action -> Completion.execute action config + | Example action -> Example.execute action config + | Auth action -> Auth_command.execute action config + | Agent action -> Agent.execute action config + +let requires_missing_graph = function + | Graph + (Graph.Graph_import { opts = { import_type = Graph.Import_sqlite; _ }; _ }) + -> + true + | Sync (Sync.Sync_download { require_missing_graph; _ }) -> + require_missing_graph + | _ -> false + +let repo = function + | Graph a -> Graph.repo a + | Upsert a -> Some (Upsert.repo a) + | Sync (Sync.Sync_download { repo; _ }) -> Some repo + | _ -> None + +let graph = function + | Graph a -> Graph.graph a + | Upsert a -> Some (Upsert.graph a) + | Sync (Sync.Sync_download { graph; _ }) -> Some graph + | _ -> None diff --git a/cli/lib/cli_config.ml b/cli/lib/cli_config.ml new file mode 100644 index 0000000..b2302fe --- /dev/null +++ b/cli/lib/cli_config.ml @@ -0,0 +1,495 @@ +type env = string -> string option + +type defaults = { + timeout_span : float; + login_timeout_span : float; + logout_timeout_span : float; + list_title_max_display_width : int; + root_dir : Cli_primitive.path; + ws_url : Cli_primitive.url; + http_base : Cli_primitive.url; +} + +type t = { + graph : Cli_primitive.graph option; + repo : Cli_primitive.repo option; + root_dir : Cli_primitive.path; + config_path : Cli_primitive.path; + timeout_span : float; + login_timeout_span : float; + logout_timeout_span : float; + list_title_max_display_width : int; + output_format : Output.Mode.packed option; + verbose : bool; + profile : bool; + ws_url : Cli_primitive.url option; + http_base : Cli_primitive.url option; + auth_path : Cli_primitive.path option; + id_token : string option; + access_token : string option; + refresh_token : string option; + base_url : Cli_primitive.url option; + owner_source : Cli_primitive.owner_source; + project_dir : Cli_primitive.path option; + raw_file_config : Melange_edn_melange.any option; + profile_session : Profile_types.session option; +} + +type source = Defaults | Env | File of Cli_primitive.path | Argv +type resolved = { config : t; sources : source Rrbvec.t } + +let default_root_dir () = + Filename.concat (Sys.getenv_opt "HOME" |> Option.value ~default:".") "logseq" + +let default_config_path root_dir = Filename.concat root_dir "cli.edn" + +let defaults () = + { + timeout_span = Time.span_of_ms 10_000L; + login_timeout_span = Time.span_of_ms 300_000L; + logout_timeout_span = Time.span_of_ms 120_000L; + list_title_max_display_width = 40; + root_dir = default_root_dir (); + ws_url = "wss://api.logseq.io/sync/%s"; + http_base = "https://api.logseq.io"; + } + +let db_version_prefix = "logseq_db_" + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let strip_db_version_prefix value = + if starts_with ~prefix:db_version_prefix value then + String.sub value + (String.length db_version_prefix) + (String.length value - String.length db_version_prefix) + else value + +let graph_to_repo graph = + let stripped = + let rec loop value = + if starts_with ~prefix:db_version_prefix value then + loop (strip_db_version_prefix value) + else value + in + loop (Cli_primitive.string_of_graph graph) + in + Cli_primitive.create_repo stripped + +let repo_to_graph repo = + Cli_primitive.create_graph + (strip_db_version_prefix (Cli_primitive.string_of_repo repo)) + +let value_of_edn edn = edn +let edn_of_value = Melange_edn_melange.to_edn_string +let read_file = Cli_unix.read_text_file + +let read_config_file path = + if not (Cli_unix.file_exists path) then Ok None + else + let content = read_file path in + if String.trim content = "" then Ok None + else + try Ok (Some (Melange_edn_melange.of_edn_string content |> value_of_edn)) + with exn -> + Error + (Error.make Error.Invalid_config + ("invalid config file: " ^ path ^ " (" ^ Printexc.to_string exn + ^ ")")) + +let invalid_config message = Error.make Error.Invalid_config message + +let parse_int_value value = + let value = String.trim value in + if value = "" then None + else Option.map Edn_util.int64 (Int64.of_string_opt value) + +let env_string key env value_key fields = + match env key with + | Some value when String.trim value <> "" -> + Vec.push_back fields (Edn_util.keyword value_key, Edn_util.string value) + | _ -> fields + +let env_int key env value_key fields = + match Option.bind (env key) parse_int_value with + | Some value -> Vec.push_back fields (Edn_util.keyword value_key, value) + | None -> fields + +let env_config env = + let fields = + Vec.empty + |> env_string "LOGSEQ_CLI_GRAPH" env "graph" + |> env_string "LOGSEQ_CLI_ROOT_DIR" env "root-dir" + |> env_string "LOGSEQ_CLI_CONFIG" env "config-path" + |> env_int "LOGSEQ_CLI_TIMEOUT_MS" env "timeout-ms" + |> env_int "LOGSEQ_CLI_LOGIN_TIMEOUT_MS" env "login-timeout-ms" + |> env_int "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS" env "logout-timeout-ms" + |> env_string "LOGSEQ_CLI_OUTPUT" env "output-format" + |> env_string "LOGSEQ_CLI_WS_URL" env "ws-url" + |> env_string "LOGSEQ_CLI_HTTP_BASE" env "http-base" + in + Edn_util.map_t_vec fields + +let validate_env_int_value key env = + match env key with + | Some value when String.trim value <> "" -> ( + let value = String.trim value in + match Int64.of_string_opt value with + | Some _ -> Ok () + | None -> + Error + (invalid_config + ("invalid env " ^ key ^ ": " ^ value ^ ". Expected integer"))) + | _ -> Ok () + +let checked_env_config env = + Error.bind (validate_env_int_value "LOGSEQ_CLI_TIMEOUT_MS" env) (fun () -> + Error.bind (validate_env_int_value "LOGSEQ_CLI_LOGIN_TIMEOUT_MS" env) + (fun () -> + Error.bind (validate_env_int_value "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS" env) + (fun () -> Ok (env_config env)))) + +let sanitize_file_config value = + match Edn_util.as_map value with + | Some fields -> + Edn_util.map_vec + (fields + |> Vec.filter (fun (key, _) -> + match Edn_util.as_string_like key with + | Some ("auth-token" | "retries" | "e2ee-password") -> false + | _ -> true)) + | None -> value + +let assoc_opt key value fields = + match value with + | None -> fields + | Some value -> + Vec.push_front + (Vec.remove_assoc (Edn_util.keyword key) fields) + (Edn_util.keyword key, Edn_util.string value) + +let map_fields value = Option.value (Edn_util.as_map value) ~default:Vec.empty + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || Cli_unix.file_exists path then + () + else ( + mkdir_p (Filename.dirname path); + Cli_unix.mkdir path 0o755) + +let write_config_file path value = + mkdir_p (Filename.dirname path); + Cli_unix.write_text_file path (edn_of_value value ^ "\n") + +let pick_graph config globals = + match globals.Global_opts.graph with Some _ as g -> g | None -> config.graph + +let value_string key value = Edn_util.get_string value key +let value_int key value = Edn_util.get_int value key +let value_int64 key value = Edn_util.get_int64 value key + +let normalize_output_string value = + let value = String.trim value in + if value <> "" && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let value_output key value = + Option.bind (value_string key value) (fun value -> + Output.Mode.of_string (normalize_output_string value)) + +let edn_value_text value = Melange_edn_melange.to_edn_string value + +let validate_int64_config_value ~source key value = + match Edn_util.get value key with + | None -> Ok () + | Some raw -> ( + match Edn_util.as_int64 raw with + | Some _ -> Ok () + | None -> + Error + (invalid_config + ("invalid " ^ source ^ " config " ^ key ^ ": " + ^ edn_value_text raw ^ ". Expected integer"))) + +let validate_int_config_value ~source key value = + match Edn_util.get value key with + | None -> Ok () + | Some raw -> ( + match Edn_util.as_int raw with + | Some _ -> Ok () + | None -> + Error + (invalid_config + ("invalid " ^ source ^ " config " ^ key ^ ": " + ^ edn_value_text raw ^ ". Expected integer"))) + +let validate_output_config_value ~source key value = + match Edn_util.get value key with + | None -> Ok () + | Some raw -> ( + match Edn_util.as_string_like raw with + | Some output + when Option.is_some + (Output.Mode.of_string (normalize_output_string output)) -> + Ok () + | _ -> + Error + (invalid_config + ("invalid " ^ source ^ " config " ^ key ^ ": " + ^ edn_value_text raw ^ ". Expected one of human, json, edn"))) + +let validate_config_values ~source value = + Error.bind (validate_int64_config_value ~source "timeout-ms" value) (fun () -> + Error.bind (validate_int64_config_value ~source "login-timeout-ms" value) + (fun () -> + Error.bind + (validate_int64_config_value ~source "logout-timeout-ms" value) + (fun () -> + Error.bind + (validate_int_config_value ~source + "list-title-max-display-width" value) (fun () -> + Error.bind + (validate_output_config_value ~source "output-format" value) + (fun () -> + validate_output_config_value ~source "output" value))))) + +let first_some values = Vec.find_map (fun value -> value) values + +let positive_or_default value default = + match value with Some value when value > 0 -> value | _ -> default + +let span_option_of_ms value = Option.map Time.span_of_ms value + +let resolve ~(defaults : defaults) ~env (globals : Global_opts.t) = + match checked_env_config env with + | Error _ as err -> Cli_effect.pure err + | Ok env_config -> ( + let env_config = Edn_util.any env_config in + let initial_root_dir = + first_some + (Vec.of_array + [| + globals.root_dir; + value_string "root-dir" env_config; + Some defaults.root_dir; + |]) + |> Option.value ~default:defaults.root_dir + in + let config_path = + first_some + (Vec.of_array + [| + globals.config_path; + value_string "config-path" env_config; + Some (default_config_path initial_root_dir); + |]) + |> Option.value ~default:(default_config_path initial_root_dir) + in + match read_config_file config_path with + | Error _ as err -> Cli_effect.pure err + | Ok raw_file_config -> ( + let raw_file_config = + Option.map sanitize_file_config raw_file_config + in + match + Error.bind (validate_config_values ~source:"env" env_config) + (fun () -> + match raw_file_config with + | None -> Ok () + | Some file_config -> + validate_config_values ~source:"file" file_config) + with + | Error _ as err -> Cli_effect.pure err + | Ok () -> + let base_url = env "LOGSEQ_CLI_BASE_URL" in + let file_graph = + Option.bind raw_file_config (value_string "graph") + |> Option.map Cli_primitive.create_graph + in + let env_graph = + value_string "graph" env_config + |> Option.map Cli_primitive.create_graph + in + let root_dir = + first_some + (Vec.of_array + [| + globals.root_dir; + value_string "root-dir" env_config; + Option.bind raw_file_config (value_string "root-dir"); + Some initial_root_dir; + |]) + |> Option.value ~default:initial_root_dir + in + let graph = + first_some + (Vec.of_array [| globals.graph; env_graph; file_graph |]) + in + let repo = Option.map graph_to_repo graph in + let file_ws_url = + Option.bind raw_file_config (fun value -> + Edn_util.get_string value "ws-url") + in + let file_http_base = + Option.bind raw_file_config (fun value -> + Edn_util.get_string value "http-base") + in + let file_auth_path = + Option.bind raw_file_config (fun value -> + Edn_util.get_string value "auth-path") + in + let file_id_token = + Option.bind raw_file_config (fun value -> + Edn_util.get_string value "id-token") + in + let file_access_token = + Option.bind raw_file_config (fun value -> + Edn_util.get_string value "access-token") + in + let file_refresh_token = + Option.bind raw_file_config (fun value -> + Edn_util.get_string value "refresh-token") + in + let env_ws_url = Edn_util.get_string env_config "ws-url" in + let env_http_base = Edn_util.get_string env_config "http-base" in + let output_format = + first_some + (Vec.of_array + [| + globals.output_format; + value_output "output-format" env_config; + Option.bind raw_file_config + (value_output "output-format"); + Option.bind raw_file_config (value_output "output"); + |]) + in + let timeout_span = + first_some + (Vec.of_array + [| + globals.timeout_span; + span_option_of_ms (value_int64 "timeout-ms" env_config); + span_option_of_ms + (Option.bind raw_file_config (value_int64 "timeout-ms")); + Some defaults.timeout_span; + |]) + |> Option.value ~default:defaults.timeout_span + in + let login_timeout_span = + first_some + (Vec.of_array + [| + span_option_of_ms + (value_int64 "login-timeout-ms" env_config); + span_option_of_ms + (Option.bind raw_file_config + (value_int64 "login-timeout-ms")); + Some defaults.login_timeout_span; + |]) + |> Option.value ~default:defaults.login_timeout_span + in + let logout_timeout_span = + first_some + (Vec.of_array + [| + span_option_of_ms + (value_int64 "logout-timeout-ms" env_config); + span_option_of_ms + (Option.bind raw_file_config + (value_int64 "logout-timeout-ms")); + Some defaults.logout_timeout_span; + |]) + |> Option.value ~default:defaults.logout_timeout_span + in + let list_title_max_display_width = + positive_or_default + (Option.bind raw_file_config + (value_int "list-title-max-display-width")) + defaults.list_title_max_display_width + in + let config = + { + graph; + repo; + root_dir; + config_path; + timeout_span; + login_timeout_span; + logout_timeout_span; + list_title_max_display_width; + output_format; + verbose = globals.verbose; + profile = globals.profile; + ws_url = + Some + (Option.value env_ws_url + ~default: + (Option.value file_ws_url ~default:defaults.ws_url)); + http_base = + Some + (Option.value env_http_base + ~default: + (Option.value file_http_base + ~default:defaults.http_base)); + auth_path = file_auth_path; + id_token = file_id_token; + access_token = file_access_token; + refresh_token = file_refresh_token; + base_url; + owner_source = Cli_primitive.Cli; + project_dir = None; + raw_file_config; + profile_session = None; + } + in + Cli_effect.pure + (Ok + { + config; + sources = + Vec.of_array [| Defaults; File config_path; Env; Argv |]; + }))) + +let update_config config patch = + let original = + match config.raw_file_config with + | Some value -> value + | None -> Edn_util.map_vec Vec.empty + in + let fields = map_fields original in + let fields = + fields + |> assoc_opt "ws-url" (Edn_util.get_string patch "ws-url") + |> assoc_opt "http-base" (Edn_util.get_string patch "http-base") + |> assoc_opt "graph" (Edn_util.get_string patch "graph") + in + let fields = + match Edn_util.get patch "ws-url" with + | Some value when Edn_util.is_null value -> + Vec.remove_assoc (Edn_util.keyword "ws-url") fields + | _ -> fields + in + let fields = + match Edn_util.get patch "http-base" with + | Some value when Edn_util.is_null value -> + Vec.remove_assoc (Edn_util.keyword "http-base") fields + | _ -> fields + in + let fields = + match Edn_util.get patch "graph" with + | Some value when Edn_util.is_null value -> + Vec.remove_assoc (Edn_util.keyword "graph") fields + | _ -> fields + in + let value = Edn_util.map_t_vec (fields |> Vec.rev) in + try + write_config_file config.config_path (Edn_util.any value); + Cli_effect.pure (Ok value) + with exn -> + Cli_effect.pure + (Error + (Error.make Error.Config_write_failed + ("failed to write config file: " ^ Printexc.to_string exn))) diff --git a/cli/lib/cli_parse.ml b/cli/lib/cli_parse.ml new file mode 100644 index 0000000..9b52911 --- /dev/null +++ b/cli/lib/cli_parse.ml @@ -0,0 +1,1150 @@ +let is_option token = String.length token > 0 && token.[0] = '-' +let option_value key options = + Vec.find_map + (fun (candidate, value) -> if candidate = key then value else None) + options +let option_present key options = Vec.mem_assoc key options + +let normalize_key = function + | "-g" -> Some "graph" + | "-o" -> Some "output" + | "-c" -> Some "content" + | "-e" -> Some "edn-options" + | "-f" -> Some "fields" + | "-h" -> Some "help" + | "-p" -> Some "pretty-print" + | "-s" -> Some "sort" + | "-t" -> Some "type" + | "-v" -> Some "verbose" + | token when String.length token > 2 && String.sub token 0 2 = "--" -> + Some (String.sub token 2 (String.length token - 2)) + | _ -> None + +let split_equals_option token = + match String.index_opt token '=' with + | None -> None + | Some index -> + let key = String.sub token 0 index in + let value = + String.sub token (index + 1) (String.length token - index - 1) + in + Option.map (fun key -> (key, value)) (normalize_key key) + +let boolean_option = function + | "version" | "help" | "verbose" | "profile" | "enable-sync" | "expand" + | "fix" + | "include-built-in" | "include-journal" | "journal-only" | "include-hidden" + | "with-properties" | "with-extends" | "with-classes" | "with-type" + | "page-hierarchy" | "linked-references" | "ref-id-footer" | "progress" + | "upload-keys" | "pretty-print" -> + true + | _ -> false + +let boolean_literal value = + match String.lowercase_ascii (String.trim value) with + | "true" | "false" -> true + | _ -> false + +let parse_tokens argv = + let rec loop options positional remaining = + match Vec.pop_front remaining with + | None -> (options, positional) + | Some ("--", rest) -> (options, Vec.append positional rest) + | Some (token, rest) -> ( + match split_equals_option token with + | Some (key, value) -> + loop (Vec.push_back options (key, Some value)) positional rest + | None -> ( + match normalize_key token with + | Some key when boolean_option key -> ( + match Vec.pop_front rest with + | Some (value, tail) when boolean_literal value -> + loop + (Vec.push_back options (key, Some value)) + positional tail + | _ -> + loop + (Vec.push_back options (key, Some "true")) + positional rest) + | Some key -> ( + match Vec.pop_front rest with + | Some (value, tail) when not (is_option value) -> + loop + (Vec.push_back options (key, Some value)) + positional tail + | _ -> loop (Vec.push_back options (key, None)) positional rest) + | None -> loop options (Vec.push_back positional token) rest)) + in + loop Vec.empty Vec.empty argv + +let globals_of_options options = + Global_opts.create + ?graph: + (Option.map Cli_primitive.create_graph (option_value "graph" options)) + ?root_dir:(option_value "root-dir" options) + ?config_path:(option_value "config" options) + ?timeout_span: + (Option.map Time.span_of_ms + (Option.bind (option_value "timeout-ms" options) Int64.of_string_opt)) + ?output_format: + (Option.bind (option_value "output" options) Output.Mode.of_string) + ~verbose:(option_present "verbose" options) + ~profile:(option_present "profile" options) + () + +let parse_csv value = + Vec.split_on_char ',' value + |> Vec.map String.trim + |> Vec.filter (fun value -> value <> "") + +let int_option key options = + Option.bind (option_value key options) int_of_string_opt + +let int64_option key options = + Option.bind (option_value key options) Int64.of_string_opt + +let time_option key options = + Option.bind (option_value key options) Time.parse_time + +let bool_option_value key options = + match option_value key options with + | Some value -> Some (String.lowercase_ascii (String.trim value) = "true") + | None -> if option_present key options then Some true else None + + +let common_list_opts options = + { + List_command.fields = Option.map parse_csv (option_value "fields" options); + limit = int_option "limit" options; + offset = int_option "offset" options; + sort = option_value "sort" options; + order = + Option.bind (option_value "order" options) List_command.order_of_string; + } + +let optional_flag key options = bool_option_value key options + +let graph_export_type options = + Option.bind + (Option.map String.lowercase_ascii (option_value "type" options)) + Graph.normalize_export_type + +let graph_import_type options = + Option.bind + (Option.map String.lowercase_ascii (option_value "type" options)) + Graph.normalize_import_type + +let global_options = + Vec.of_array + [| + "graph"; + "root-dir"; + "config"; + "timeout-ms"; + "output"; + "verbose"; + "profile"; + "help"; + "version"; + |] + +let common_list_options = + Vec.of_array [| "fields"; "limit"; "offset"; "sort"; "order" |] + +let option_names values = Vec.of_array values +let path1 path a = Vec.length path = 1 && Vec.nth path 0 = a + +let path2 path a b = + Vec.length path = 2 && Vec.nth path 0 = a && Vec.nth path 1 = b + +let path3 path a b c = + Vec.length path = 3 + && Vec.nth path 0 = a + && Vec.nth path 1 = b + && Vec.nth path 2 = c + +let path2_any path a choices = + Vec.length path = 2 && Vec.nth path 0 = a && Vec.mem (Vec.nth path 1) choices + +let path3_any path a b choices = + Vec.length path = 3 + && Vec.nth path 0 = a + && Vec.nth path 1 = b + && Vec.mem (Vec.nth path 2) choices + +let allowed_options_for_path path = + if + path2_any path "graph" + (option_names [| "list"; "switch"; "remove"; "info" |]) + || path3 path "graph" "backup" "list" + then Vec.empty + else if path2 path "graph" "create" then + option_names [| "enable-sync"; "e2ee-password" |] + else if path2 path "graph" "validate" then option_names [| "fix" |] + else if path3 path "graph" "backup" "create" then option_names [| "name" |] + else if path3 path "graph" "backup" "restore" then + option_names [| "src"; "dst" |] + else if path3 path "graph" "backup" "remove" then option_names [| "src" |] + else if path2 path "graph" "export" then + option_names [| "type"; "file"; "edn-options"; "pretty-print" |] + else if path2 path "graph" "import" then option_names [| "type"; "input" |] + else if path2 path "list" "page" then + Vec.append common_list_options + (option_names + [| + "expand"; + "include-built-in"; + "include-journal"; + "journal-only"; + "include-hidden"; + "updated-after"; + "created-after"; + |]) + else if path2 path "list" "tag" then + Vec.append common_list_options + (option_names + [| "expand"; "include-built-in"; "with-properties"; "with-extends" |]) + else if path2 path "list" "property" then + Vec.append common_list_options + (option_names + [| "expand"; "include-built-in"; "with-classes"; "with-type" |]) + else if path2 path "list" "task" then + Vec.append common_list_options + (option_names [| "status"; "priority"; "content" |]) + else if path2 path "list" "node" then + Vec.append common_list_options (option_names [| "tags"; "properties" |]) + else if path2 path "list" "asset" then common_list_options + else if path2 path "remove" "block" then option_names [| "id"; "uuid" |] + else if path2 path "remove" "page" then option_names [| "id"; "page" |] + else if path2_any path "remove" (option_names [| "tag"; "property" |]) then + option_names [| "id"; "name" |] + else if path2 path "upsert" "block" then + option_names + [| + "id"; + "uuid"; + "target-id"; + "target-uuid"; + "target-page"; + "pos"; + "content"; + "blocks"; + "blocks-file"; + "update-tags"; + "update-properties"; + "remove-tags"; + "remove-properties"; + |] + else if path2 path "upsert" "page" then + option_names + [| + "id"; + "page"; + "restore"; + "update-tags"; + "update-properties"; + "remove-tags"; + "remove-properties"; + |] + else if path2 path "upsert" "asset" then + option_names + [| + "id"; + "uuid"; + "path"; + "target-id"; + "target-uuid"; + "target-page"; + "pos"; + "content"; + |] + else if path2 path "upsert" "task" then + option_names + [| + "id"; + "uuid"; + "page"; + "content"; + "target-id"; + "target-uuid"; + "target-page"; + "pos"; + "status"; + "priority"; + "scheduled"; + "deadline"; + "no-status"; + "no-priority"; + "no-scheduled"; + "no-deadline"; + |] + else if path2 path "upsert" "tag" then + option_names [| "id"; "name"; "add-properties"; "remove-properties" |] + else if path2 path "upsert" "property" then + option_names [| "id"; "name"; "type"; "cardinality"; "hide"; "public" |] + else if + path2_any path "search" + (option_names [| "block"; "page"; "property"; "tag" |]) + then option_names [| "content" |] + else if path2 path "query" "list" then Vec.empty + else if path1 path "query" then option_names [| "query"; "name"; "inputs" |] + else if path1 path "show" then + option_names + [| + "id"; + "uuid"; + "page"; + "page-hierarchy"; + "linked-references"; + "ref-id-footer"; + "level"; + |] + else if + path2_any path "server" + (option_names [| "list"; "cleanup"; "start"; "stop"; "restart" |]) + then Vec.empty + else if + path2_any path "sync" (option_names [| "status"; "stop"; "remote-graphs" |]) + || path3_any path "sync" "config" (option_names [| "get"; "unset" |]) + then Vec.empty + else if path2_any path "sync" (option_names [| "start"; "upload" |]) then + option_names [| "e2ee-password" |] + else if path2 path "sync" "download" then + option_names [| "progress"; "e2ee-password" |] + else if path3 path "sync" "asset" "download" then + option_names [| "id"; "uuid" |] + else if path2 path "sync" "ensure-keys" then + option_names [| "e2ee-password"; "upload-keys" |] + else if path2 path "sync" "grant-access" then + option_names [| "graph-id"; "email" |] + else if path3 path "sync" "config" "set" then Vec.empty + else if path2 path "debug" "pull" then + option_names [| "id"; "uuid"; "ident" |] + else if path1 path "doctor" then option_names [| "dev-script" |] + else if + path1 path "login" || path1 path "logout" || path2 path "skill" "show" + || path1 path "example" + then Vec.empty + else if path1 path "completion" then option_names [| "shell" |] + else if path2 path "skill" "install" then option_names [| "global" |] + else Vec.empty + +let validate_known_options path options = + let allowed = + Vec.sort_uniq String.compare + (Vec.append global_options (allowed_options_for_path path)) + in + match Vec.find_opt (fun (key, _) -> not (Vec.mem key allowed)) options with + | Some (key, _) -> Error (Error.invalid_options ("Unknown option: :" ^ key)) + | None -> Ok () + +let invalid_value key value message = + Error.invalid_options + ("Invalid value for option :" ^ key ^ ": " ^ value ^ ". " ^ message) + +let validate_integer_option key options = + match Vec.assoc_opt key options with + | Some (Some value) when Int64.of_string_opt value = None -> + Error (invalid_value key value "Expected integer") + | _ -> Ok () + +let validate_integer_options keys options = + Vec.fold_left + (fun result key -> + Error.bind result (fun () -> validate_integer_option key options)) + (Ok ()) keys + +let validate_boolean_option_values options = + match + Vec.find_opt + (fun (key, value) -> + boolean_option key + && + match value with + | Some value -> not (boolean_literal value) + | None -> false) + options + with + | Some (key, Some value) -> + Error (invalid_value key value "Expected true or false") + | _ -> Ok () + +let validate_member_option key values options = + match option_value key options with + | Some value when not (Vec.mem value values) -> + Error + (invalid_value key value + ("Available values: " ^ Vec.string_concat ", " values)) + | _ -> Ok () + +let validate_csv_member_option key values options = + match option_value key options with + | Some value -> ( + match + Vec.find_opt (fun field -> not (Vec.mem field values)) (parse_csv value) + with + | Some invalid -> + Error + (invalid_value key invalid + ("Available values: " ^ Vec.string_concat ", " values)) + | None -> Ok ()) + | None -> Ok () + +let validate_time_option key options = + match option_value key options with + | Some value when Option.is_none (Time.parse_time value) -> + Error (invalid_value key value "Expected RFC3339 datetime or epoch ms") + | _ -> Ok () + +let list_page_sort_values = + Vec.of_array [| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |] + +let list_tag_sort_values = + Vec.append_array list_page_sort_values + [| "properties"; "extends"; "description" |] + +let list_property_sort_values = + Vec.append_array list_page_sort_values + [| "classes"; "type"; "cardinality"; "description" |] + +let list_task_sort_values = + Vec.of_array + [| + "id"; + "title"; + "status"; + "priority"; + "scheduled"; + "deadline"; + "updated-at"; + "created-at"; + |] + +let list_node_sort_values = + Vec.of_array + [| + "id"; "title"; "type"; "page-id"; "page-title"; "created-at"; "updated-at"; + |] + +let list_asset_sort_values = + Vec.of_array + [| "id"; "title"; "asset-type"; "size"; "updated-at"; "created-at" |] + +let validate_list_common_values sort_values options = + Error.bind (validate_integer_option "limit" options) (fun () -> + Error.bind (validate_integer_option "offset" options) (fun () -> + Error.bind + (validate_member_option "order" + (Vec.of_array [| "asc"; "desc" |]) + options) + (fun () -> + Error.bind (validate_member_option "sort" sort_values options) + (fun () -> + validate_csv_member_option "fields" sort_values options)))) + +let validate_global_values options = + Error.bind (validate_integer_option "timeout-ms" options) (fun () -> + validate_member_option "output" + (Vec.of_array [| "human"; "json"; "edn" |]) + options) + +let parse_graph_export_edn_options options = + match option_value "edn-options" options with + | None -> Ok None + | Some text -> ( + try + let value = Melange_edn_melange.of_edn_string text in + match Edn_util.as_map value with + | Some _ -> Ok (Some value) + | None -> + Error + (Error.invalid_options + "graph export --edn-options must be an EDN map") + with Melange_edn_melange.Parse_error _ -> + Error + (Error.invalid_options "graph export --edn-options must be an EDN map") + ) + +let validate_non_empty_csv_option key message options = + match Vec.assoc_opt key options with + | Some (Some value) when Vec.is_empty (parse_csv value) -> + Error (Error.invalid_options message) + | Some None -> Error (Error.invalid_options message) + | _ -> Ok () + +let validate_list_node_values options = + Error.bind + (validate_non_empty_csv_option "tags" + "list node --tags must include at least one non-empty value" options) + (fun () -> + validate_non_empty_csv_option "properties" + "list node --properties must include at least one non-empty value" + options) + +let validate_pos_value options = + validate_member_option "pos" + (Vec.of_array [| "first-child"; "last-child"; "sibling" |]) + options + +let validate_selector_integer_values path options = + if + path2_any path "remove" (option_names [| "page"; "tag"; "property" |]) + || path2_any path "upsert" (option_names [| "page"; "tag"; "property" |]) + || path3 path "sync" "asset" "download" + || path2 path "debug" "pull" + then validate_integer_option "id" options + else if path2_any path "upsert" (option_names [| "block"; "asset"; "task" |]) + then validate_integer_options (option_names [| "id"; "target-id" |]) options + else Ok () + +let validate_option_values path options = + Error.bind (validate_selector_integer_values path options) (fun () -> + if path2 path "graph" "export" then Ok () + else if path2 path "list" "page" then + Error.bind (validate_list_common_values list_page_sort_values options) + (fun () -> + Error.bind (validate_time_option "updated-after" options) (fun () -> + validate_time_option "created-after" options)) + else if path2 path "list" "tag" then + validate_list_common_values list_tag_sort_values options + else if path2 path "list" "property" then + validate_list_common_values list_property_sort_values options + else if path2 path "list" "task" then + validate_list_common_values list_task_sort_values options + else if path2 path "list" "node" then + Error.bind (validate_list_node_values options) (fun () -> + validate_list_common_values list_node_sort_values options) + else if path2 path "list" "asset" then + validate_list_common_values list_asset_sort_values options + else if path1 path "show" then validate_integer_option "level" options + else if path1 path "completion" then + validate_member_option "shell" + (Vec.of_array [| "zsh"; "bash" |]) + options + else if path2 path "upsert" "task" then + Error.bind (validate_pos_value options) (fun () -> + Error.bind (validate_time_option "scheduled" options) (fun () -> + validate_time_option "deadline" options)) + else if path2 path "upsert" "block" || path2 path "upsert" "asset" then + validate_pos_value options + else Ok ()) + +let validate_options path options = + Error.bind (validate_known_options path options) (fun () -> + Error.bind (validate_boolean_option_values options) (fun () -> + Error.bind (validate_global_values options) (fun () -> + validate_option_values path options))) + +let sync_config_key_or_error = function + | None -> Ok None + | Some raw -> ( + match + Sync.config_key_of_string (String.lowercase_ascii (String.trim raw)) + with + | Some key -> Ok (Some key) + | None -> Error (Error.invalid_options ("unknown config key: " ^ raw))) + +let parsed_remove_command options = function + | "block" -> + Some + (Cli_request.Remove + (Remove.Parsed_block + { + id_raw = option_value "id" options; + uuid = option_value "uuid" options; + })) + | "page" -> + Some + (Remove + (Parsed_page + { + id = int64_option "id" options; + page = option_value "page" options; + })) + | "tag" -> + Some + (Remove + (Parsed_tag + { + id = int64_option "id" options; + name = option_value "name" options; + })) + | "property" -> + Some + (Remove + (Parsed_property + { + id = int64_option "id" options; + name = option_value "name" options; + })) + | _ -> None + +let content_option_or_args options args = + match option_value "content" options with + | Some _ as content -> content + | None when not (Vec.is_empty args) -> Some (Vec.string_concat " " args) + | None -> None + +let parsed_upsert_command ?(args = Vec.empty) options = function + | "block" -> + Some + (Cli_request.Upsert + (Upsert.Parsed_block + { + id = int64_option "id" options; + uuid = option_value "uuid" options; + target_id = int64_option "target-id" options; + target_uuid = option_value "target-uuid" options; + target_page = option_value "target-page" options; + pos = + Option.bind + (option_value "pos" options) + Block.position_of_string; + content = content_option_or_args options args; + blocks_edn = option_value "blocks" options; + blocks_file = option_value "blocks-file" options; + update_tags_edn = option_value "update-tags" options; + update_properties_edn = option_value "update-properties" options; + remove_tags_edn = option_value "remove-tags" options; + remove_properties_edn = option_value "remove-properties" options; + })) + | "page" -> + Some + (Upsert + (Parsed_page + { + id = int64_option "id" options; + page = option_value "page" options; + restore = option_present "restore" options; + update_tags_edn = option_value "update-tags" options; + update_properties_edn = option_value "update-properties" options; + remove_tags_edn = option_value "remove-tags" options; + remove_properties_edn = option_value "remove-properties" options; + })) + | "asset" -> + Some + (Upsert + (Parsed_asset + { + id = int64_option "id" options; + uuid = option_value "uuid" options; + path = option_value "path" options; + target_id = int64_option "target-id" options; + target_uuid = option_value "target-uuid" options; + target_page = option_value "target-page" options; + pos = + Option.bind + (option_value "pos" options) + Block.position_of_string; + content = option_value "content" options; + })) + | "task" -> + Some + (Upsert + (Parsed_task + { + id = int64_option "id" options; + uuid = option_value "uuid" options; + page = option_value "page" options; + content = option_value "content" options; + target_id = int64_option "target-id" options; + target_uuid = option_value "target-uuid" options; + target_page = option_value "target-page" options; + pos = + Option.bind + (option_value "pos" options) + Block.position_of_string; + status = option_value "status" options; + priority = option_value "priority" options; + scheduled = time_option "scheduled" options; + deadline = time_option "deadline" options; + no_status = option_present "no-status" options; + no_priority = option_present "no-priority" options; + no_scheduled = option_present "no-scheduled" options; + no_deadline = option_present "no-deadline" options; + })) + | "tag" -> + Some + (Upsert + (Parsed_tag + { + id = int64_option "id" options; + name = option_value "name" options; + add_properties_edn = option_value "add-properties" options; + remove_properties_edn = option_value "remove-properties" options; + })) + | "property" -> + Some + (Upsert + (Parsed_property + { + id = int64_option "id" options; + name = option_value "name" options; + kind = + Option.bind + (option_value "type" options) + Property.kind_of_string; + cardinality = + Option.bind + (option_value "cardinality" options) + Property.cardinality_of_string; + hide = bool_option_value "hide" options; + public = bool_option_value "public" options; + })) + | _ -> None + +let parsed_list_command options = function + | "page" -> + Some + (Cli_request.List + (List_command.Parsed_page + { + common = common_list_opts options; + expand = option_present "expand" options; + include_built_in = optional_flag "include-built-in" options; + include_journal = optional_flag "include-journal" options; + journal_only = option_present "journal-only" options; + include_hidden = option_present "include-hidden" options; + updated_after = time_option "updated-after" options; + created_after = time_option "created-after" options; + })) + | "tag" -> + Some + (List + (Parsed_tag + { + common = common_list_opts options; + expand = option_present "expand" options; + include_built_in = optional_flag "include-built-in" options; + with_properties = option_present "with-properties" options; + with_extends = option_present "with-extends" options; + })) + | "property" -> + Some + (List + (Parsed_property + { + common = common_list_opts options; + expand = option_present "expand" options; + include_built_in = optional_flag "include-built-in" options; + with_classes = option_present "with-classes" options; + with_type = true; + })) + | "task" -> + Some + (List + (Parsed_task + { + common = common_list_opts options; + status = option_value "status" options; + priority = option_value "priority" options; + content = option_value "content" options; + })) + | "node" -> + Some + (List + (Parsed_node + { + common = common_list_opts options; + tags = + Option.value + (Option.map parse_csv (option_value "tags" options)) + ~default:Vec.empty; + properties = + Option.value + (Option.map parse_csv (option_value "properties" options)) + ~default:Vec.empty; + })) + | "asset" -> Some (List (Parsed_asset { common = common_list_opts options })) + | _ -> None + +(* normalize_key resolves short aliases globally (-f -> fields, -e -> + edn-options), but a few legacy aliases mean something else under specific + commands. Once the command path is known, rewrite those raw tokens and + re-parse so the alias lands on the right option. *) +let path_alias_overrides = function + | [| "graph"; "validate" |] -> [ ("-f", "--fix") ] + | [| "graph"; "export" |] -> [ ("-f", "--file") ] + | [| "list"; ("page" | "tag" | "property") |] -> [ ("-e", "--expand") ] + | _ -> [] + +(* rewrite a bare alias token, or its -x=value form *) +let apply_alias_override overrides token = + match List.assoc_opt token overrides with + | Some replacement -> replacement + | None -> ( + match String.index_opt token '=' with + | None -> token + | Some index -> ( + match List.assoc_opt (String.sub token 0 index) overrides with + | Some replacement -> + replacement + ^ String.sub token index (String.length token - index) + | None -> token)) + +let parse ?stdin argv = + let options, positional = parse_tokens argv in + let positional_array = Vec.to_array positional in + let options = + match path_alias_overrides positional_array with + | [] -> options + | overrides -> + argv + |> Vec.map (apply_alias_override overrides) + |> parse_tokens |> fst + in + let positional_tail start = + Vec.init + (max 0 (Array.length positional_array - start)) + (fun index -> positional_array.(index + start)) + in + let make_vec path command = + Error.bind (validate_options path options) (fun () -> + let globals = globals_of_options options in + Ok (Cli_request.make ~globals ~path ~command ~raw_args:argv)) + in + let make path command = make_vec (Vec.of_array path) command in + if option_present "version" options then make [||] Cli_request.Version + else if + Array.length positional_array >= 2 + && positional_array.(0) = "upsert" + && positional_array.(1) = "block" + then + match parsed_upsert_command ~args:(positional_tail 2) options "block" with + | Some command -> make [| "upsert"; "block" |] command + | None -> Error (Error.unknown_command "unknown command: upsert block") + else if Array.length positional_array >= 1 && positional_array.(0) = "example" + then + let selector = positional_tail 1 in + make_vec + (Vec.push_front selector "example") + (Example (Example.Parsed_example { selector })) + else + match positional_array with + | [| "graph"; "list" |] -> + make [| "graph"; "list" |] (Graph Graph.Parsed_list) + | [| "graph"; "create" |] -> + make [| "graph"; "create" |] + (Graph + (Parsed_create + { + enable_sync = option_present "enable-sync" options; + e2ee_password = option_value "e2ee-password" options; + })) + | [| "graph"; "switch" |] -> + make [| "graph"; "switch" |] (Graph Parsed_switch) + | [| "graph"; "remove" |] -> + make [| "graph"; "remove" |] (Graph Parsed_remove) + | [| "graph"; "validate" |] -> + make [| "graph"; "validate" |] + (Graph + (Parsed_validate { fix = option_present "fix" options })) + | [| "graph"; "info" |] -> make [| "graph"; "info" |] (Graph Parsed_info) + | [| "graph"; "backup"; "list" |] -> + make [| "graph"; "backup"; "list" |] (Graph Parsed_backup_list) + | [| "graph"; "backup"; "create" |] -> + make + [| "graph"; "backup"; "create" |] + (Graph (Parsed_backup_create { name = option_value "name" options })) + | [| "graph"; "backup"; "restore" |] -> ( + match (option_value "src" options, option_value "dst" options) with + | Some src, Some dst -> + make + [| "graph"; "backup"; "restore" |] + (Graph + (Parsed_backup_restore + { src; dst = Cli_primitive.create_graph dst })) + | None, _ -> + Error + (Error.invalid_options "graph backup restore --src is required") + | _, None -> + Error + (Error.invalid_options "graph backup restore --dst is required")) + | [| "graph"; "backup"; "remove" |] -> ( + match option_value "src" options with + | Some src -> + make + [| "graph"; "backup"; "remove" |] + (Graph (Parsed_backup_remove { src })) + | None -> + Error + (Error.invalid_options "graph backup remove --src is required")) + | [| "graph"; "export" |] -> ( + match graph_export_type options with + | Some export_type -> + Error.bind (parse_graph_export_edn_options options) + (fun edn_options -> + make [| "graph"; "export" |] + (Graph + (Parsed_export + { + export_type; + file = option_value "file" options; + edn_options; + pretty_print = option_present "pretty-print" options; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + }))) + | None -> + Error + (Error.invalid_options "graph export --type must be edn or sqlite") + ) + | [| "graph"; "import" |] -> ( + match (graph_import_type options, option_value "input" options) with + | Some import_type, Some input -> + make [| "graph"; "import" |] + (Graph (Parsed_import { import_type; input })) + | None, _ -> + Error + (Error.invalid_options "graph import --type must be edn or sqlite") + | _, None -> + Error (Error.invalid_options "graph import --input is required")) + | [| "list"; kind |] -> ( + match parsed_list_command options kind with + | Some command -> make [| "list"; kind |] command + | None -> + Error (Error.unknown_command ("unknown command: list " ^ kind))) + | [| "remove"; kind |] -> ( + match parsed_remove_command options kind with + | Some command -> make [| "remove"; kind |] command + | None -> + Error (Error.unknown_command ("unknown command: remove " ^ kind))) + | [| "upsert"; kind |] -> ( + match parsed_upsert_command options kind with + | Some command -> make [| "upsert"; kind |] command + | None -> + Error (Error.unknown_command ("unknown command: upsert " ^ kind))) + | [| "search"; "block" |] -> + make [| "search"; "block" |] + (Search + (Search.Parsed_block + { + content = + Option.value (option_value "content" options) ~default:""; + })) + | [| "search"; "page" |] -> + make [| "search"; "page" |] + (Search + (Search.Parsed_page + { + content = + Option.value (option_value "content" options) ~default:""; + })) + | [| "search"; "property" |] -> + make [| "search"; "property" |] + (Search + (Search.Parsed_property + { + content = + Option.value (option_value "content" options) ~default:""; + })) + | [| "search"; "tag" |] -> + make [| "search"; "tag" |] + (Search + (Search.Parsed_tag + { + content = + Option.value (option_value "content" options) ~default:""; + })) + | [| "query"; "list" |] -> + make [| "query"; "list" |] (Query Query.Parsed_list) + | [| "query" |] -> + make [| "query" |] + (Query + (Query.Parsed_run + { + query_edn = option_value "query" options; + name = option_value "name" options; + inputs_edn = option_value "inputs" options; + })) + | [| "show" |] -> + let stdin_id = + if + option_present "id" options + && Option.is_none (option_value "id" options) + then stdin + else None + in + make [| "show" |] + (Show + (Show.Parsed_show + { + id_raw = option_value "id" options; + uuid = option_value "uuid" options; + page = option_value "page" options; + page_hierarchy = + Option.value + (bool_option_value "page-hierarchy" options) + ~default:false; + linked_references = optional_flag "linked-references" options; + ref_id_footer = optional_flag "ref-id-footer" options; + level = int_option "level" options; + stdin_id; + })) + | [| "server"; "list" |] -> + make [| "server"; "list" |] (Server Server_command.Parsed_list) + | [| "server"; "cleanup" |] -> + make [| "server"; "cleanup" |] (Server Parsed_cleanup) + | [| "server"; "start" |] -> + make [| "server"; "start" |] (Server Parsed_start) + | [| "server"; "stop" |] -> make [| "server"; "stop" |] (Server Parsed_stop) + | [| "server"; "restart" |] -> + make [| "server"; "restart" |] (Server Parsed_restart) + | [| "sync"; "status" |] -> + make [| "sync"; "status" |] (Sync Sync.Parsed_status) + | [| "sync"; "start" |] -> + make [| "sync"; "start" |] + (Sync + (Sync.Parsed_start + { e2ee_password = option_value "e2ee-password" options })) + | [| "sync"; "stop" |] -> make [| "sync"; "stop" |] (Sync Sync.Parsed_stop) + | [| "sync"; "upload" |] -> + make [| "sync"; "upload" |] + (Sync + (Sync.Parsed_upload + { e2ee_password = option_value "e2ee-password" options })) + | [| "sync"; "download" |] -> + make [| "sync"; "download" |] + (Sync + (Sync.Parsed_download + { + progress = + (if option_present "progress" options then Some true + else None); + e2ee_password = option_value "e2ee-password" options; + })) + | [| "sync"; "asset"; "download" |] -> + make + [| "sync"; "asset"; "download" |] + (Sync + (Sync.Parsed_asset_download + { + id = int64_option "id" options; + uuid = option_value "uuid" options; + })) + | [| "sync"; "remote-graphs" |] -> + make [| "sync"; "remote-graphs" |] (Sync Sync.Parsed_remote_graphs) + | [| "sync"; "ensure-keys" |] -> + make + [| "sync"; "ensure-keys" |] + (Sync + (Sync.Parsed_ensure_keys + { + e2ee_password = option_value "e2ee-password" options; + upload_keys = option_present "upload-keys" options; + })) + | [| "sync"; "grant-access" |] -> + make + [| "sync"; "grant-access" |] + (Sync + (Sync.Parsed_grant_access + { + graph_id = option_value "graph-id" options; + email = option_value "email" options; + })) + | [| "sync"; "config"; "get"; key |] -> ( + match sync_config_key_or_error (Some key) with + | Ok key -> + make + [| "sync"; "config"; "get" |] + (Sync (Sync.Parsed_config_get { key })) + | Error err -> Error err) + | [| "sync"; "config"; "get" |] -> + make + [| "sync"; "config"; "get" |] + (Sync (Sync.Parsed_config_get { key = None })) + | [| "sync"; "config"; "set"; key; value |] -> ( + match sync_config_key_or_error (Some key) with + | Ok key -> + make + [| "sync"; "config"; "set" |] + (Sync (Sync.Parsed_config_set { key; value = Some value })) + | Error err -> Error err) + | [| "sync"; "config"; "set"; key |] -> ( + match sync_config_key_or_error (Some key) with + | Ok key -> + make + [| "sync"; "config"; "set" |] + (Sync (Sync.Parsed_config_set { key; value = None })) + | Error err -> Error err) + | [| "sync"; "config"; "set" |] -> + make + [| "sync"; "config"; "set" |] + (Sync (Sync.Parsed_config_set { key = None; value = None })) + | [| "sync"; "config"; "unset"; key |] -> ( + match sync_config_key_or_error (Some key) with + | Ok key -> + make + [| "sync"; "config"; "unset" |] + (Sync (Sync.Parsed_config_unset { key })) + | Error err -> Error err) + | [| "sync"; "config"; "unset" |] -> + make + [| "sync"; "config"; "unset" |] + (Sync (Sync.Parsed_config_unset { key = None })) + | [| "debug"; "pull" |] -> ( + match + Option.map Debug.parse_ident_option (option_value "ident" options) + with + | Some (Error err) -> Error err + | ident -> + make [| "debug"; "pull" |] + (Debug + (Debug.Parsed_pull + { + id = int64_option "id" options; + uuid = option_value "uuid" options; + ident = + Option.bind ident (function + | Ok ident -> Some ident + | Error _ -> None); + }))) + | [| "agent"; "bridge" |] -> + make [| "agent"; "bridge" |] (Agent Agent.Parsed_bridge) + | [| "doctor" |] -> + make [| "doctor" |] + (Doctor + (Doctor.Parsed_doctor + { dev_script = option_present "dev-script" options })) + | [| "login" |] -> make [| "login" |] (Auth Auth_command.Parsed_login) + | [| "logout" |] -> make [| "logout" |] (Auth Auth_command.Parsed_logout) + | [| "skill"; "show" |] -> + make [| "skill"; "show" |] (Skill Skill.Parsed_show) + | [| "skill"; "install" |] -> + make [| "skill"; "install" |] + (Skill + (Skill.Parsed_install { global = option_present "global" options })) + | [| "completion"; shell |] -> ( + match Cli_primitive.shell_of_string shell with + | Some shell -> + make [| "completion" |] + (Completion (Completion.Parsed_completion { shell = Some shell })) + | None -> + Error + (Error.invalid_options + ("unsupported shell: " ^ shell ^ "; expected zsh or bash"))) + | [| "completion" |] -> + let shell = + match option_value "shell" options with + | None -> + Error + (Error.invalid_options + "completion shell is required; expected zsh or bash") + | Some shell -> ( + match Cli_primitive.shell_of_string shell with + | Some shell -> Ok (Some shell) + | None -> + Error + (Error.invalid_options + ("unsupported shell: " ^ shell ^ "; expected zsh or bash")) + ) + in + Error.bind shell (fun shell -> + make [| "completion" |] + (Completion (Completion.Parsed_completion { shell }))) + | [||] -> Error (Error.unknown_command "") + | _ -> + Error + (Error.unknown_command + ("unknown command: " ^ Vec.string_concat " " positional)) diff --git a/cli/lib/cli_primitive.ml b/cli/lib/cli_primitive.ml new file mode 100644 index 0000000..bf859ca --- /dev/null +++ b/cli/lib/cli_primitive.ml @@ -0,0 +1,98 @@ +type keyword = Melange_edn_melange.keyword Melange_edn_melange.t +type graph = string +type repo = string +type db_id = int64 +type uuid = string +type path = string +type url = string +type email = string +type shell = Bash | Zsh +type port = int +type pid = int +type owner_source = Cli | Electron | Unknown | Other of string + +type ds_where_clause = + | V of Melange_edn_melange.vector Melange_edn_melange.t + | L of Melange_edn_melange.list_ Melange_edn_melange.t + +type datascript_query = { + find : Melange_edn_melange.any Rrbvec.t; + in_ : Melange_edn_melange.symbol Melange_edn_melange.t Rrbvec.t option; + where : ds_where_clause Rrbvec.t; +} + +let trim = String.trim +let db_version_prefix = "logseq_db_" + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let create_graph value = String.trim value + +let create_repo value = + let value = String.trim value in + if starts_with ~prefix:db_version_prefix value then value + else db_version_prefix ^ value + +let string_of_graph graph = graph +let string_of_repo repo = repo + +let non_empty s = + let s = trim s in + if s = "" then None else Some s + +let is_uuid_hex = function + | '0' .. '9' | 'a' .. 'f' | 'A' .. 'F' -> true + | _ -> false + +let is_uuid_string s = + let s = String.trim s in + let valid_char idx ch = + match idx with 8 | 13 | 18 | 23 -> ch = '-' | _ -> is_uuid_hex ch + in + let rec loop idx = + idx = String.length s || (valid_char idx s.[idx] && loop (idx + 1)) + in + String.length s = 36 && loop 0 + +let shell_of_string = function + | "bash" -> Some Bash + | "zsh" -> Some Zsh + | _ -> None + +let string_of_owner_source = function + | Cli -> "cli" + | Electron -> "electron" + | Unknown -> "unknown" + | Other s -> s + +let make_datascript_query ~find ?in_ ~where () = + let in_ = + match in_ with + | None -> None + | Some values when Vec.is_empty values -> None + | Some values -> Some values + in + { find; in_; where } + +let datascript_query_to_edn query = + let input_clause = + match query.in_ with + | None -> Vec.empty + | Some inputs -> + Vec.push_front + (inputs |> Vec.map (fun value -> Edn_util.any value)) + (Edn_util.keyword "in") + in + let where_clause = function + | V value -> Edn_util.any value + | L value -> Edn_util.any value + in + Edn_util.vector_t_vec + (Vec.append + (Vec.push_front query.find (Edn_util.keyword "find")) + (Vec.append input_clause + (Vec.push_front + (query.where |> Vec.map where_clause) + (Edn_util.keyword "where")))) diff --git a/cli/lib/cli_request.ml b/cli/lib/cli_request.ml new file mode 100644 index 0000000..d34f8bd --- /dev/null +++ b/cli/lib/cli_request.ml @@ -0,0 +1,48 @@ +type parsed_command = + | Version + | Graph of Graph.parsed + | List of List_command.parsed + | Upsert of Upsert.parsed + | Remove of Remove.parsed + | Search of Search.parsed + | Query of Query.parsed + | Show of Show.parsed + | Server of Server_command.parsed + | Sync of Sync.parsed + | Auth of Auth_command.parsed + | Agent of Agent.parsed + | Doctor of Doctor.parsed + | Debug of Debug.parsed + | Skill of Skill.parsed + | Completion of Completion.parsed + | Example of Example.parsed + +type t = { + globals : Global_opts.t; + path : string Rrbvec.t; + command : parsed_command; + raw_args : string Rrbvec.t; +} + +let make ~globals ~path ~command ~raw_args = + { globals; path; command; raw_args } + +let command_id t = + match t.command with + | Version -> Command_id.Version + | Graph p -> Graph.command_id p + | List p -> List_command.command_id p + | Upsert p -> Upsert.command_id p + | Remove p -> Remove.command_id p + | Search p -> Search.command_id p + | Query p -> Query.command_id p + | Show p -> Show.command_id p + | Server p -> Server_command.command_id p + | Sync p -> Sync.command_id p + | Auth p -> Auth_command.command_id p + | Agent p -> Agent.command_id p + | Doctor p -> Doctor.command_id p + | Debug p -> Debug.command_id p + | Skill p -> Skill.command_id p + | Completion p -> Completion.command_id p + | Example p -> Example.command_id p diff --git a/cli/lib/cli_result.ml b/cli/lib/cli_result.ml new file mode 100644 index 0000000..be86661 --- /dev/null +++ b/cli/lib/cli_result.ml @@ -0,0 +1,426 @@ +type status = Ok | Error + +type ok_data = + | Message of string + | Items of Melange_edn_melange.any Rrbvec.t + | Entity of Melange_edn_melange.any + | Query_result of Melange_edn_melange.any + | Raw of Melange_edn_melange.any + | Empty + +type error_data = Error.t + +type t = { + status : status; + data : ok_data option; + error : error_data option; + command : Command_id.t option; + context : Melange_edn_melange.any option; + output : 'o. 'o Output.Mode.t -> 'o Output.t; + exit_code : int option; + human_table_headers_order : string Rrbvec.t; +} + +let data_value = function + | Message s -> Edn_util.string s + | Items xs -> Edn_util.vector_vec xs + | Entity v | Query_result v | Raw v -> v + | Empty -> Edn_util.nil + +let human_table ?headers ?footer rows : Output.Human_output.t = + Output.Human_output.create ?headers ?footer ~rows () + +let human_message message = human_table (Vec.singleton (Vec.singleton message)) +let empty_human = human_table Vec.empty +let count_footer count = "Count: " ^ Humanize_types.format_count count + +let strip_leading_colon value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let field_label key = + key |> Edn_util.as_string_like + |> Option.value ~default:(Melange_edn_melange.to_edn_string key) + |> strip_leading_colon + +let value_text value = + match + ( Edn_util.is_null value, + Edn_util.as_string_like value, + Edn_util.as_int value, + Edn_util.as_bool value, + Edn_util.as_float value ) + with + | true, _, _, _, _ -> "" + | _, Some value, _, _, _ -> strip_leading_colon value + | _, _, Some value, _, _ -> string_of_int value + | _, _, _, Some value, _ -> string_of_bool value + | _, _, _, _, Some value -> string_of_float value + | _ -> Melange_edn_melange.to_edn_string value + +let has_suffix ~suffix value = + let suffix_len = String.length suffix in + let value_len = String.length value in + value_len >= suffix_len + && String.sub value (value_len - suffix_len) suffix_len = suffix + +let normalized_label label = String.lowercase_ascii label + +let is_created_at_field label = + has_suffix ~suffix:"created-at" (normalized_label label) + +let is_updated_at_field label = + has_suffix ~suffix:"updated-at" (normalized_label label) + +let is_datetime_field label = + is_created_at_field label || is_updated_at_field label + +let field_value_text label value = + let text = value_text value in + match Int64.of_string_opt text with + | Some epoch_ms when is_datetime_field label -> + Humanize_types.relative_datetime + ~then_time:(Time.time_of_epoch_ms epoch_ms) + ~now_time:(Time.now ()) + | _ -> text + +let parse_positive_int64 text = + let text = String.trim text in + if text = "" then None else Int64.of_string_opt text + +let graph_info_timestamp_seconds value = + let timestamp = + match Edn_util.as_int64 value with + | Some value -> Some value + | None -> parse_positive_int64 (value_text value) + in + timestamp + |> Option.map (fun timestamp -> + if Int64.compare timestamp 100_000_000_000L >= 0 then + Int64.div timestamp 1_000L + else timestamp) + +let graph_info_field_value_text label value = + match graph_info_timestamp_seconds value with + | Some then_seconds when is_datetime_field label -> + Humanize_types.datetime + ~now:(Time.time_to_epoch_seconds (Time.now ())) + then_seconds + | _ -> value_text value + +let add_column acc label = + if Vec.mem label acc then acc else Vec.push_back acc label + +let table_columns items = + items + |> Vec.fold_left + (fun acc fields -> + fields + |> Vec.map (fun (key, _) -> field_label key) + |> Vec.fold_left add_column acc) + Vec.empty + +let field_by_label label fields = + fields + |> Vec.find_map (fun (key, value) -> + if field_label key = label then Some value else None) + +let mapped_items_human items = + let mapped = Vec.filter_map Edn_util.as_map items in + if Vec.length mapped = Vec.length items then + let headers = table_columns mapped in + let rows = + mapped + |> Vec.map (fun fields -> + headers + |> Vec.map (fun label -> + fields |> field_by_label label |> Option.map value_text + |> Option.value ~default:"")) + in + human_table ~headers ~footer:(count_footer (Vec.length rows)) rows + else + let seqs = Vec.filter_map Edn_util.as_seq items in + if Vec.length seqs = Vec.length items then + let width = + seqs |> Vec.fold_left (fun acc row -> max acc (Vec.length row)) 0 + in + let headers = + Vec.init width (fun index -> "Column " ^ string_of_int (index + 1)) + in + let rows = + seqs + |> Vec.map (fun row -> + Vec.init width (fun index -> + Vec.nth_opt row index |> Option.map value_text + |> Option.value ~default:"")) + in + human_table ~headers ~footer:(count_footer (Vec.length rows)) rows + else + human_table ~headers:(Vec.singleton "Value") + ~footer:(count_footer (Vec.length items)) + (Vec.map (fun value -> Vec.singleton (value_text value)) items) + +let items_human value = + Option.bind (Edn_util.get value "items") Edn_util.as_seq + |> Option.map mapped_items_human + +let list_like_fields = + Vec.of_array + [| "items"; "queries"; "checks"; "servers"; "backups"; "graphs"; "routed" |] + +let sequence_field_by_label label fields = + fields + |> Vec.find_map (fun (key, value) -> + if field_label key = label then + value |> Edn_util.as_seq |> Option.map mapped_items_human + else None) + +let list_like_sequence_field fields = + list_like_fields + |> Vec.find_map (fun label -> sequence_field_by_label label fields) + +let single_result_sequence_field fields = + match Vec.nth_opt fields 0 with + | Some (key, value) when Vec.length fields = 1 && field_label key = "result" + -> + value |> Edn_util.as_seq |> Option.map mapped_items_human + | _ -> None + +let sequence_field_human fields = + match list_like_sequence_field fields with + | Some table -> Some table + | None -> single_result_sequence_field fields + +let field_rows fields = + Vec.map + (fun (key, value) -> + let label = field_label key in + Vec.of_array [| label; field_value_text label value |]) + fields + +let map_human fields = human_table (field_rows fields) + +let value_human value = + match Edn_util.as_map value with + | Some fields -> ( + match sequence_field_human fields with + | Some table -> table + | None -> map_human fields) + | None -> ( + match Edn_util.as_seq value with + | Some values -> mapped_items_human values + | None -> human_message (value_text value)) + +let graph_list_human value = + match Edn_util.as_map value with + | Some fields -> ( + let current_graph = + match Vec.assoc_opt (Edn_util.keyword "current-graph") fields with + | Some value -> Edn_util.as_string value + | None -> None + in + match Vec.assoc_opt (Edn_util.keyword "graphs") fields with + | Some graphs_value -> ( + match Edn_util.as_vector graphs_value with + | Some graphs -> + let rows = + Vec.map + (fun graph -> + let graph = + Option.value (Edn_util.as_string graph) + ~default:(Melange_edn_melange.to_edn_string graph) + in + let prefix = + if Option.equal String.equal current_graph (Some graph) + then "* " + else if Option.is_some current_graph then " " + else "" + in + Vec.singleton (prefix ^ graph)) + graphs + in + human_table ~footer:(count_footer (Vec.length rows)) rows + | None -> empty_human) + | None -> empty_human) + | None -> empty_human + +let text_list value key = + Option.bind (Edn_util.get value key) Edn_util.as_seq + |> Option.map (Vec.map value_text) + |> Option.value ~default:Vec.empty + +let bullet_lines values = + if Vec.is_empty values then Vec.singleton " - (none)" + else Vec.map (fun value -> " - " ^ value) values + +let example_human value = + let selector = + Edn_util.get_string value "selector" |> Option.value ~default:"-" + in + let matched_commands = text_list value "matched-commands" in + let examples = text_list value "examples" in + let message = Edn_util.get_string value "message" in + let lines = + Vec.append + (match message with + | Some message -> Vec.singleton message + | None -> Vec.empty) + (Vec.of_array [| "Selector: " ^ selector; "Matched commands:" |]) + |> fun lines -> + Vec.append lines (bullet_lines matched_commands) |> fun lines -> + Vec.push_back lines "Examples:" |> fun lines -> + Vec.append lines (bullet_lines examples) + in + human_table (Vec.map (fun line -> Vec.singleton line) lines) + +let contains_substring ~needle text = + let needle_len = String.length needle in + let text_len = String.length text in + let rec loop index = + index + needle_len <= text_len + && (String.sub text index needle_len = needle || loop (index + 1)) + in + loop 0 + +let sensitive_field label = + let label = String.lowercase_ascii label in + Vec.exists + (fun needle -> contains_substring ~needle label) + (Vec.of_array [| "token"; "secret"; "password" |]) + +let graph_info_human value = + match Edn_util.as_map value with + | Some fields -> + let graph_rows = + match Vec.assoc_opt (Edn_util.keyword "graph") fields with + | Some graph -> + Vec.singleton (Vec.of_array [| "graph"; value_text graph |]) + | None -> Vec.empty + in + let kv_rows = + match Vec.assoc_opt (Edn_util.keyword "kv") fields with + | Some kv -> ( + match Edn_util.as_map kv with + | Some kv_fields -> + Vec.map + (fun (key, value) -> + let label = field_label key in + Vec.of_array + [| + label; + (if sensitive_field label then "[REDACTED]" + else graph_info_field_value_text label value); + |]) + kv_fields + | None -> Vec.empty) + | None -> Vec.empty + in + human_table (Vec.append graph_rows kv_rows) + | None -> empty_human + +let remote_graph_columns = + Vec.of_array + [| "graph-name"; "graph-id"; "graph-e2ee?"; "role"; "created-at" |] + +let remote_graph_row graph = + match Edn_util.as_map graph with + | Some fields -> + remote_graph_columns + |> Vec.map (fun label -> + fields |> field_by_label label |> Option.map value_text + |> Option.value ~default:"") + | None -> Vec.of_array [| value_text graph; ""; ""; ""; "" |] + +let remote_graphs_human value = + match Edn_util.as_map value with + | Some fields -> ( + match Vec.assoc_opt (Edn_util.keyword "graphs") fields with + | Some graphs_value -> ( + match Edn_util.as_seq graphs_value with + | Some graphs -> + let rows = Vec.map remote_graph_row graphs in + human_table ~headers:remote_graph_columns + ~footer:(count_footer (Vec.length rows)) + rows + | None -> empty_human) + | None -> empty_human) + | None -> empty_human + +let human_output command data = + match data with + | Message s -> human_message s + | Empty -> empty_human + | Raw value when command = Some Command_id.Graph_list -> + graph_list_human value + | Raw value when command = Some Command_id.Graph_info -> + graph_info_human value + | Raw value when command = Some Command_id.Example -> example_human value + | Raw value when command = Some Command_id.Sync_remote_graphs -> + remote_graphs_human value + | Raw value -> ( + match items_human value with + | Some table -> table + | None -> value_human value) + | Entity value | Query_result value -> value_human value + | Items values -> + human_table ~headers:(Vec.singleton "Value") + ~footer:(count_footer (Vec.length values)) + (Vec.map + (fun value -> + Vec.singleton (Melange_edn_melange.to_edn_string value)) + values) + +let output_for_data : type a. + Command_id.t option -> a Output.Mode.t -> ok_data -> a Output.t = + fun command mode data -> + match mode with + | Output.Mode.Human -> Output.Human (human_output command data) + | Output.Mode.Json -> Output.Json (data_value data) + | Output.Mode.Edn -> Output.Edn (data_value data) + +let ok ?command ?context mode data = + let (_ : _ Output.Mode.t) = mode in + { + status = Ok; + data = Some data; + error = None; + command; + context; + output = (fun mode -> output_for_data command mode data); + exit_code = None; + human_table_headers_order = Vec.empty; + } + +let error ?command ?context mode error = + let (_ : _ Output.Mode.t) = mode in + let output : type a. a Output.Mode.t -> a Output.t = function + | Output.Mode.Human -> Output.Human (human_message error.Error.message) + | Output.Mode.Json -> Output.Json Edn_util.nil + | Output.Mode.Edn -> Output.Edn Edn_util.nil + in + { + status = Error; + data = None; + error = Some error; + command; + context; + output; + exit_code = Some 1; + human_table_headers_order = Vec.empty; + } + +let is_error t = t.status = Error + +let exit_code t = + match t.exit_code with Some c -> c | None -> if is_error t then 1 else 0 + +let data_value t = + match t.data with + | Some Empty | None -> None + | Some data -> Some (data_value data) + +let with_command command t = { t with command = Some command } + +let with_human_table_headers_order human_table_headers_order t = + { t with human_table_headers_order } diff --git a/cli/lib/command_id.ml b/cli/lib/command_id.ml new file mode 100644 index 0000000..7fbdc40 --- /dev/null +++ b/cli/lib/command_id.ml @@ -0,0 +1,177 @@ +type t = + | Version + | Graph_list + | Graph_create + | Graph_switch + | Graph_remove + | Graph_validate + | Graph_info + | Graph_backup_list + | Graph_backup_create + | Graph_backup_restore + | Graph_backup_remove + | Graph_export + | Graph_import + | Server_list + | Server_cleanup + | Server_start + | Server_stop + | Server_restart + | List_page + | List_tag + | List_property + | List_task + | List_node + | List_asset + | Search_block + | Search_page + | Search_property + | Search_tag + | Upsert_block + | Upsert_page + | Upsert_task + | Upsert_asset + | Upsert_tag + | Upsert_property + | Remove_block + | Remove_page + | Remove_tag + | Remove_property + | Query + | Query_list + | Show + | Debug_pull + | Doctor + | Sync_status + | Sync_start + | Sync_stop + | Sync_upload + | Sync_download + | Sync_asset_download + | Sync_remote_graphs + | Sync_ensure_keys + | Sync_grant_access + | Sync_config_get + | Sync_config_set + | Sync_config_unset + | Login + | Logout + | Completion + | Skill_show + | Skill_install + | Example + | Agent_bridge + +let path segments = Vec.of_array segments + +let table = + Vec.of_array + [| + (Version, "version", Vec.empty); + (Graph_list, "graph-list", path [| "graph"; "list" |]); + (Graph_create, "graph-create", path [| "graph"; "create" |]); + (Graph_switch, "graph-switch", path [| "graph"; "switch" |]); + (Graph_remove, "graph-remove", path [| "graph"; "remove" |]); + (Graph_validate, "graph-validate", path [| "graph"; "validate" |]); + (Graph_info, "graph-info", path [| "graph"; "info" |]); + ( Graph_backup_list, + "graph-backup-list", + path [| "graph"; "backup"; "list" |] ); + ( Graph_backup_create, + "graph-backup-create", + path [| "graph"; "backup"; "create" |] ); + ( Graph_backup_restore, + "graph-backup-restore", + path [| "graph"; "backup"; "restore" |] ); + ( Graph_backup_remove, + "graph-backup-remove", + path [| "graph"; "backup"; "remove" |] ); + (Graph_export, "graph-export", path [| "graph"; "export" |]); + (Graph_import, "graph-import", path [| "graph"; "import" |]); + (Server_list, "server-list", path [| "server"; "list" |]); + (Server_cleanup, "server-cleanup", path [| "server"; "cleanup" |]); + (Server_start, "server-start", path [| "server"; "start" |]); + (Server_stop, "server-stop", path [| "server"; "stop" |]); + (Server_restart, "server-restart", path [| "server"; "restart" |]); + (List_page, "list-page", path [| "list"; "page" |]); + (List_tag, "list-tag", path [| "list"; "tag" |]); + (List_property, "list-property", path [| "list"; "property" |]); + (List_task, "list-task", path [| "list"; "task" |]); + (List_node, "list-node", path [| "list"; "node" |]); + (List_asset, "list-asset", path [| "list"; "asset" |]); + (Search_block, "search-block", path [| "search"; "block" |]); + (Search_page, "search-page", path [| "search"; "page" |]); + (Search_property, "search-property", path [| "search"; "property" |]); + (Search_tag, "search-tag", path [| "search"; "tag" |]); + (Upsert_block, "upsert-block", path [| "upsert"; "block" |]); + (Upsert_page, "upsert-page", path [| "upsert"; "page" |]); + (Upsert_task, "upsert-task", path [| "upsert"; "task" |]); + (Upsert_asset, "upsert-asset", path [| "upsert"; "asset" |]); + (Upsert_tag, "upsert-tag", path [| "upsert"; "tag" |]); + (Upsert_property, "upsert-property", path [| "upsert"; "property" |]); + (Remove_block, "remove-block", path [| "remove"; "block" |]); + (Remove_page, "remove-page", path [| "remove"; "page" |]); + (Remove_tag, "remove-tag", path [| "remove"; "tag" |]); + (Remove_property, "remove-property", path [| "remove"; "property" |]); + (Query, "query", path [| "query" |]); + (Query_list, "query-list", path [| "query"; "list" |]); + (Show, "show", path [| "show" |]); + (Debug_pull, "debug-pull", path [| "debug"; "pull" |]); + (Doctor, "doctor", path [| "doctor" |]); + (Sync_status, "sync-status", path [| "sync"; "status" |]); + (Sync_start, "sync-start", path [| "sync"; "start" |]); + (Sync_stop, "sync-stop", path [| "sync"; "stop" |]); + (Sync_upload, "sync-upload", path [| "sync"; "upload" |]); + (Sync_download, "sync-download", path [| "sync"; "download" |]); + ( Sync_asset_download, + "sync-asset-download", + path [| "sync"; "asset"; "download" |] ); + ( Sync_remote_graphs, + "sync-remote-graphs", + path [| "sync"; "remote-graphs" |] ); + (Sync_ensure_keys, "sync-ensure-keys", path [| "sync"; "ensure-keys" |]); + (Sync_grant_access, "sync-grant-access", path [| "sync"; "grant-access" |]); + (Sync_config_get, "sync-config-get", path [| "sync"; "config"; "get" |]); + (Sync_config_set, "sync-config-set", path [| "sync"; "config"; "set" |]); + ( Sync_config_unset, + "sync-config-unset", + path [| "sync"; "config"; "unset" |] ); + (Login, "login", path [| "login" |]); + (Logout, "logout", path [| "logout" |]); + (Completion, "completion", path [| "completion" |]); + (Skill_show, "skill-show", path [| "skill"; "show" |]); + (Skill_install, "skill-install", path [| "skill"; "install" |]); + (Example, "example", path [| "example" |]); + (Agent_bridge, "agent-bridge", path [| "agent"; "bridge" |]); + |] + +let to_string t = + let _, s, _ = Vec.find (fun (id, _, _) -> id = t) table in + s + +let to_path t = + let _, _, p = Vec.find (fun (id, _, _) -> id = t) table in + p + +let is_write = function + | Graph_create | Graph_switch | Graph_remove | Graph_import | Upsert_block + | Upsert_page | Upsert_task | Upsert_asset | Upsert_tag | Upsert_property + | Remove_block | Remove_page | Remove_tag | Remove_property | Sync_start + | Sync_stop | Sync_upload | Sync_download | Sync_asset_download + | Sync_ensure_keys | Sync_grant_access | Sync_config_set | Sync_config_unset + | Login | Logout | Skill_install -> + true + | _ -> false + +let requires_graph = function + | Version | Graph_list | Server_list | Server_cleanup | Completion | Login + | Logout | Skill_show | Skill_install | Example | Sync_remote_graphs -> + false + | _ -> true + +let requires_auth = function + | Sync_status | Sync_start | Sync_stop | Sync_upload | Sync_download + | Sync_asset_download | Sync_remote_graphs | Sync_ensure_keys + | Sync_grant_access | Sync_config_get | Sync_config_set | Sync_config_unset -> + true + | _ -> false diff --git a/cli/lib/command_registry.ml b/cli/lib/command_registry.ml new file mode 100644 index 0000000..c6dfa1e --- /dev/null +++ b/cli/lib/command_registry.ml @@ -0,0 +1,613 @@ +type category = + | Graph_inspect_and_edit + | Graph_management + | Authentication + | Utilities + | Hidden + +type option_arity = Flag | Required_value of string | Optional_value of string + +type option_meta = { + names : string Rrbvec.t; + arity : option_arity; + doc : string; + required : bool; + repeatable : bool; + choices : string Rrbvec.t; + (* value is a comma-separated list; choices complete each list element *) + multi : bool; + default : string option; +} + +type command_meta = { + id : Command_id.t; + path : string Rrbvec.t; + doc : string; + long_doc : string option; + examples : string Rrbvec.t; + options : option_meta Rrbvec.t; + category : category; + requires_graph : bool; + requires_auth : bool; + write_command : bool; + human_table_headers_order : string Rrbvec.t; +} + +type group_meta = { + name : string; + doc : string; + category : category; + children : string Rrbvec.t Rrbvec.t; +} + +type t = { commands : command_meta Rrbvec.t; groups : group_meta Rrbvec.t } + +let append_all values = Array.fold_left Vec.append Vec.empty values + +let option ?(required = false) ?(repeatable = false) ?(choices = Vec.empty) + ?(multi = false) ?default names arity doc = + { names; arity; doc; required; repeatable; choices; multi; default } + +let option_of_array ?required ?repeatable ?choices ?multi ?default names arity + doc = + option ?required ?repeatable ?choices ?multi ?default (Vec.of_array names) + arity doc + +let flag ?required ?repeatable ?choices ?multi ?default name doc = + option ?required ?repeatable ?choices ?multi ?default + (Vec.singleton ("--" ^ name)) + Flag doc + +let value ?required ?repeatable ?choices ?multi ?default name value_name doc = + option ?required ?repeatable ?choices ?multi ?default + (Vec.singleton ("--" ^ name)) + (Required_value value_name) doc + +let optional_value ?required ?repeatable ?choices ?multi ?default name + value_name doc = + option ?required ?repeatable ?choices ?multi ?default + (Vec.singleton ("--" ^ name)) + (Optional_value value_name) doc + +let output_choices = Vec.of_array [| "human"; "json"; "edn" |] +let order_choices = Vec.of_array [| "asc"; "desc" |] +let graph_data_choices = Vec.of_array [| "edn"; "sqlite" |] +let position_choices = Vec.of_array [| "first-child"; "last-child"; "sibling" |] + +let property_type_choices = + Vec.of_array [| "default"; "number"; "date"; "checkbox"; "url" |] + +let cardinality_choices = Vec.of_array [| "one"; "many" |] + +let list_page_sort_choices = + Vec.of_array [| "id"; "title"; "ident"; "uuid"; "created-at"; "updated-at" |] + +let list_tag_sort_choices = + Vec.of_array + [| + "id"; + "title"; + "ident"; + "uuid"; + "properties"; + "extends"; + "description"; + "created-at"; + "updated-at"; + |] + +let list_property_sort_choices = + Vec.of_array + [| + "id"; + "title"; + "ident"; + "uuid"; + "classes"; + "type"; + "cardinality"; + "description"; + "created-at"; + "updated-at"; + |] + +let list_task_sort_choices = + Vec.of_array + [| + "id"; + "title"; + "status"; + "priority"; + "scheduled"; + "deadline"; + "created-at"; + "updated-at"; + |] + +let list_node_sort_choices = + Vec.of_array + [| + "id"; "title"; "type"; "page-id"; "page-title"; "created-at"; "updated-at"; + |] + +let list_asset_sort_choices = + Vec.of_array + [| "id"; "title"; "asset-type"; "size"; "created-at"; "updated-at" |] + +let search_result_header_order = Vec.of_array [| "id"; "ident"; "title" |] + +let human_table_headers_order_for_command = function + | Command_id.List_page -> list_page_sort_choices + | List_tag -> list_tag_sort_choices + | List_property -> list_property_sort_choices + | List_task -> list_task_sort_choices + | List_node -> list_node_sort_choices + | List_asset -> + Vec.of_array + [| + "id"; + "title"; + "size"; + "type"; + "checksum"; + "remote-metadata"; + "created-at"; + "updated-at"; + |] + | Search_block | Search_page | Search_property | Search_tag -> + search_result_header_order + | _ -> Vec.empty + +let global_options = + Vec.of_array + [| + option (Vec.singleton "--config") (Required_value "path") + "Path to cli.edn (default /cli.edn)"; + option_of_array [| "-g"; "--graph" |] (Required_value "graph") + "Graph name"; + option + (Vec.singleton "--root-dir") + (Required_value "path") "Path to CLI root dir (default ~/logseq)"; + option + (Vec.singleton "--timeout-ms") + (Required_value "ms") "Request timeout in ms (default 10000)"; + option_of_array [| "-o"; "--output" |] (Required_value "mode") + ~choices:output_choices ~default:"human" "Output format"; + option_of_array [| "-v"; "--verbose" |] Flag + "Enable verbose debug logging to stderr"; + option + (Vec.singleton "--profile") + Flag "Enable stage timing profile output to stderr"; + option_of_array [| "-h"; "--help" |] Flag "Show command help"; + option (Vec.singleton "--version") Flag "Show CLI version"; + |] + +let common_list_options sort_choices = + Vec.of_array + [| + value "fields" "fields" "Comma-separated fields to include" + ~choices:sort_choices ~multi:true; + value "limit" "n" "Maximum result count"; + value "offset" "n" "Result offset"; + value "sort" "field" "Sort field" ~choices:sort_choices; + value "order" "order" "Sort order" ~choices:order_choices; + |] + +let selector_options = + Vec.of_array + [| value "id" "id" "Entity id"; value "uuid" "uuid" "Entity UUID" |] + +let target_options = + Vec.of_array + [| + value "target-id" "id" "Target block id"; + value "target-uuid" "uuid" "Target block UUID"; + value "target-page" "page" "Target page name"; + value "pos" "position" "Insert position" ~choices:position_choices; + |] + +let property_update_options = + Vec.of_array + [| + value "update-tags" "edn" "Tags to add or update"; + value "update-properties" "edn" "Properties to add or update"; + value "remove-tags" "edn" "Tags to remove"; + value "remove-properties" "edn" "Properties to remove"; + |] + +let content_search_options = + Vec.singleton (value "content" "text" "Content search text") + +let e2ee_password_option = value "e2ee-password" "password" "E2EE password" + +let options_for_command = + let empty = Vec.empty in + function + | Command_id.Version | Graph_list | Graph_switch | Graph_remove | Graph_info + | Graph_backup_list -> + empty + | Graph_create -> + Vec.of_array [| flag "enable-sync" "Enable sync"; e2ee_password_option |] + | Graph_validate -> + Vec.singleton + (option_of_array [| "-f"; "--fix" |] Flag "Fix validation problems") + | Graph_backup_create -> Vec.singleton (value "name" "name" "Backup name") + | Graph_backup_restore -> + Vec.of_array + [| + value ~required:true "src" "backup" "Source backup name"; + value ~required:true "dst" "graph" "Destination graph name"; + |] + | Graph_backup_remove -> + Vec.singleton (value ~required:true "src" "backup" "Source backup name") + | Graph_export -> + Vec.of_array + [| + value "type" "type" "Export type" ~choices:graph_data_choices; + option_of_array [| "-f"; "--file" |] (Required_value "path") + "Output file"; + option_of_array + [| "-e"; "--edn-options" |] + (Required_value "edn") + "EDN map of export options; :export-type overrides the default \ + :graph"; + option_of_array + [| "-p"; "--pretty-print" |] + Flag "Pretty-print the exported EDN file"; + |] + | Graph_import -> + Vec.of_array + [| + value ~required:true "type" "type" "Import type" + ~choices:graph_data_choices; + value ~required:true "input" "path" "Input file"; + |] + | List_page -> + Vec.append_array + (common_list_options list_page_sort_choices) + [| + option_of_array [| "-e"; "--expand" |] Flag "Expand page data"; + optional_value "include-built-in" "bool" "Include built-in pages"; + optional_value "include-journal" "bool" "Include journal pages"; + flag "journal-only" "Only include journal pages"; + flag "include-hidden" "Include hidden pages"; + value "updated-after" "timestamp" + "Only include pages updated after timestamp"; + value "created-after" "timestamp" + "Only include pages created after timestamp"; + |] + | List_tag -> + Vec.append_array + (common_list_options list_tag_sort_choices) + [| + option_of_array [| "-e"; "--expand" |] Flag "Expand tag data"; + optional_value "include-built-in" "bool" "Include built-in tags"; + flag "with-properties" "Include properties"; + flag "with-extends" "Include extends data"; + |] + | List_property -> + Vec.append_array + (common_list_options list_property_sort_choices) + [| + option_of_array [| "-e"; "--expand" |] Flag "Expand property data"; + optional_value "include-built-in" "bool" "Include built-in properties"; + flag "with-classes" "Include classes"; + flag "with-type" "Include property type"; + |] + | List_task -> + Vec.append_array + (common_list_options list_task_sort_choices) + [| + value "status" "status" "Task status"; + value "priority" "priority" "Task priority"; + value "content" "text" "Task content filter"; + |] + | List_node -> + Vec.append_array + (common_list_options list_node_sort_choices) + [| + value "tags" "tags" "Comma-separated tag filters" ~multi:true; + value "properties" "properties" "Comma-separated property filters" + ~multi:true; + |] + | List_asset -> common_list_options list_asset_sort_choices + | Remove_block -> selector_options + | Remove_page -> + Vec.of_array + [| value "id" "id" "Page id"; value "page" "page" "Page name" |] + | Remove_tag | Remove_property -> + Vec.of_array + [| value "id" "id" "Entity id"; value "name" "name" "Entity name" |] + | Upsert_block -> + append_all + [| + selector_options; + target_options; + Vec.of_array + [| + value "content" "text" "Block content"; + value "blocks" "edn" "Blocks EDN"; + value "blocks-file" "path" "Path to blocks EDN file"; + |]; + property_update_options; + |] + | Upsert_page -> + append_all + [| + Vec.of_array + [| + value "id" "id" "Page id"; + value "page" "page" "Page name"; + flag "restore" "Restore recycled page before updating"; + |]; + property_update_options; + |] + | Upsert_asset -> + append_all + [| + selector_options; + Vec.singleton (value "path" "path" "Asset path"); + target_options; + Vec.singleton (value "content" "text" "Asset content"); + |] + | Upsert_task -> + append_all + [| + selector_options; + Vec.of_array + [| + value "page" "page" "Page name"; + value "content" "text" "Task content"; + |]; + target_options; + Vec.of_array + [| + value "status" "status" "Task status"; + value "priority" "priority" "Task priority"; + value "scheduled" "date" "Scheduled date"; + value "deadline" "date" "Deadline date"; + flag "no-status" "Remove status"; + flag "no-priority" "Remove priority"; + flag "no-scheduled" "Remove scheduled date"; + flag "no-deadline" "Remove deadline date"; + |]; + |] + | Upsert_tag -> + Vec.of_array + [| + value "id" "id" "Tag id"; + value "name" "name" "Tag name"; + value "add-properties" "edn" "Tag schema properties to add"; + value "remove-properties" "edn" "Tag schema properties to remove"; + |] + | Upsert_property -> + Vec.of_array + [| + value "id" "id" "Property id"; + value "name" "name" "Property name"; + value "type" "type" "Property type" ~choices:property_type_choices; + value "cardinality" "cardinality" "Property cardinality" + ~choices:cardinality_choices; + optional_value "hide" "bool" "Hide property"; + optional_value "public" "bool" "Make property public"; + |] + | Search_block | Search_page | Search_property | Search_tag -> + content_search_options + | Query_list -> empty + | Query -> + Vec.of_array + [| + value "query" "edn" "Datascript query"; + value "name" "name" "Saved query name"; + value "inputs" "edn" "Query inputs"; + |] + | Show -> + Vec.of_array + [| + optional_value "id" "id" "Entity id"; + value "uuid" "uuid" "Entity UUID"; + value "page" "page" "Page name"; + optional_value "page-hierarchy" "bool" "Show page hierarchy"; + optional_value "linked-references" "bool" "Show linked references"; + optional_value "ref-id-footer" "bool" "Show reference id footer"; + value "level" "n" "Tree depth"; + |] + | Server_list | Server_cleanup | Server_start | Server_stop | Server_restart + | Sync_status | Sync_stop | Sync_remote_graphs | Sync_config_get + | Sync_config_unset | Sync_config_set | Login | Logout | Agent_bridge + | Example | Skill_show -> + empty + | Sync_start | Sync_upload -> Vec.singleton e2ee_password_option + | Sync_download -> + Vec.of_array [| flag "progress" "Stream progress"; e2ee_password_option |] + | Sync_asset_download -> selector_options + | Sync_ensure_keys -> + Vec.of_array + [| e2ee_password_option; flag "upload-keys" "Upload sync keys" |] + | Sync_grant_access -> + Vec.of_array + [| + value "graph-id" "graph-id" "Remote graph id"; + value "email" "email" "Account email"; + |] + | Debug_pull -> + Vec.push_back selector_options (value "ident" "ident" "Entity ident") + | Doctor -> Vec.singleton (flag "dev-script" "Use development script") + | Completion -> + Vec.singleton + (value "shell" "shell" "Completion shell" + ~choices:(Vec.of_array [| "zsh"; "bash" |])) + | Skill_install -> Vec.singleton (flag "global" "Install globally") + +let with_catalog_options command = + let options = + if Vec.is_empty command.options then options_for_command command.id + else command.options + in + let human_table_headers_order = + if Vec.is_empty command.human_table_headers_order then + human_table_headers_order_for_command command.id + else command.human_table_headers_order + in + { command with options; human_table_headers_order } + +let empty = { commands = Vec.empty; groups = Vec.empty } + +let make commands = + { commands = Vec.map with_catalog_options commands; groups = Vec.empty } + +let add command t = + { t with commands = Vec.push_front t.commands (with_catalog_options command) } + +let find_by_path path t = + Vec.find_opt (fun c -> Vec.equal String.equal c.path path) t.commands + +let top_level_group_doc = function + | "agent" -> Some "Agent bridge commands" + | "debug" -> Some "Debug commands" + | "graph" -> Some "Manage graphs" + | "list" -> Some "List graph entities" + | "remove" -> Some "Remove graph entities" + | "search" -> Some "Search graph entities" + | "server" -> Some "Manage db-worker-node servers" + | "skill" -> Some "Manage built-in logseq-cli skill" + | "sync" -> Some "Manage Logseq sync" + | "upsert" -> Some "Create or update graph entities" + | _ -> None + +let is_prefix prefix path = + let rec loop index = + index = Vec.length prefix + || (Vec.nth prefix index = Vec.nth path index && loop (index + 1)) + in + Vec.length prefix <= Vec.length path && loop 0 + +let option_label option = + let names = Vec.string_concat ", " option.names in + match option.arity with + | Flag -> names + | Required_value value_name -> names ^ " <" ^ value_name ^ ">" + | Optional_value value_name -> names ^ " [<" ^ value_name ^ ">]" + +let option_doc option = + let suffixes = + Vec.append + (if Vec.is_empty option.choices then Vec.empty + else Vec.singleton ("choices: " ^ Vec.string_concat ", " option.choices)) + (match option.default with + | None -> Vec.empty + | Some default -> Vec.singleton ("default: " ^ default)) + in + if Vec.is_empty suffixes then option.doc + else option.doc ^ " (" ^ Vec.string_concat "; " suffixes ^ ")" + +let format_rows rows = + let width = + rows |> Vec.fold_left (fun acc (name, _) -> max acc (String.length name)) 0 + in + rows + |> Vec.map (fun (name, doc) -> + let padding = String.make (max 0 (width - String.length name)) ' ' in + if doc = "" then " " ^ name else " " ^ name ^ padding ^ " " ^ doc) + |> Vec.string_concat "\n" + +let format_options options = + options + |> Vec.map (fun option -> (option_label option, option_doc option)) + |> format_rows + +let render_help ?group (t : t) = + let join_words words = Vec.string_concat " " words in + let command_label command = join_words command.path in + let global_options_help = format_options global_options in + let top_level_entries () = + let seen = Hashtbl.create 16 in + let doc_for_top top fallback = + match find_by_path (Vec.singleton top) t with + | Some command -> command.doc + | None -> Option.value (top_level_group_doc top) ~default:fallback + in + t.commands + |> Vec.filter_map (fun command -> + match Vec.nth_opt command.path 0 with + | Some top when not (Hashtbl.mem seen top) -> + Hashtbl.add seen top (); + Some (top, doc_for_top top command.doc) + | _ -> None) + in + let top_level_help () = + Vec.string_concat "\n" + (Vec.of_array + [| + "Usage: logseq [options]"; + ""; + "Commands:"; + format_rows (top_level_entries ()); + ""; + "Available commands:"; + format_rows + (t.commands + |> Vec.filter (fun (command : command_meta) -> + command.category <> Hidden) + |> Vec.map (fun (command : command_meta) -> + (command_label command, command.doc))); + ""; + "Global options:"; + global_options_help; + ""; + "Command options:"; + " See `logseq --help`"; + |]) + in + let group_help group = + let rows = + t.commands + |> Vec.filter (fun command -> is_prefix group command.path) + |> Vec.map (fun command -> (command_label command, command.doc)) + in + Vec.string_concat "\n" + (Vec.of_array + [| + "Usage: logseq " ^ join_words group ^ " [options]"; + ""; + "Subcommands:"; + format_rows rows; + ""; + "Global options:"; + global_options_help; + ""; + "Command options:"; + " See `logseq " ^ join_words group ^ " --help`"; + |]) + in + let command_options_help command = + if Vec.is_empty command.options then " None" + else format_options command.options + in + let command_help command = + let examples = + if Vec.is_empty command.examples then Vec.empty + else + Vec.prepend_array + (command.examples |> Vec.map (fun example -> " " ^ example)) + [| ""; "Examples:" |] + in + Vec.string_concat "\n" + (Vec.prepend_array examples + [| + "Usage: logseq " ^ command_label command ^ " [options]"; + ""; + "Global options:"; + global_options_help; + ""; + "Command options:"; + command_options_help command; + |]) + in + match group with + | None -> top_level_help () + | Some group when Vec.is_empty group -> top_level_help () + | Some group -> ( + match find_by_path group t with + | Some command -> command_help command + | None -> group_help group) diff --git a/cli/lib/common_cli_types.ml b/cli/lib/common_cli_types.ml new file mode 100644 index 0000000..e69de29 diff --git a/cli/lib/completion.ml b/cli/lib/completion.ml new file mode 100644 index 0000000..9748839 --- /dev/null +++ b/cli/lib/completion.ml @@ -0,0 +1,517 @@ +type opts = { shell : Cli_primitive.shell option } +type parsed = Parsed_completion of opts + +type action = + | Completion of { + shell : Cli_primitive.shell; + registry : Command_registry.t option; + } + +let join_words words = Vec.string_concat " " words + +let unique_preserve values = + let seen = Hashtbl.create 16 in + values + |> Vec.filter (fun value -> + if Hashtbl.mem seen value then false + else ( + Hashtbl.add seen value (); + true)) + +let top_command_names registry = + registry.Command_registry.commands + |> Vec.filter_map (fun command -> Vec.nth_opt command.Command_registry.path 0) + |> Vec.sort_uniq String.compare + +let is_prefix prefix path = + let rec loop index = + index = Vec.length prefix + || (Vec.nth prefix index = Vec.nth path index && loop (index + 1)) + in + Vec.length prefix <= Vec.length path && loop 0 + +let commands_under registry prefix = + registry.Command_registry.commands + |> Vec.filter (fun command -> + is_prefix prefix command.Command_registry.path + && Vec.length command.Command_registry.path > Vec.length prefix) + +let direct_children registry prefix = + let prefix_len = Vec.length prefix in + commands_under registry prefix + |> Vec.filter_map (fun command -> + Vec.nth_opt command.Command_registry.path prefix_len) + |> unique_preserve + +let direct_leaf_children registry prefix = + let prefix_len = Vec.length prefix in + commands_under registry prefix + |> Vec.filter (fun command -> + Vec.length command.Command_registry.path = prefix_len + 1) + +let nested_child_groups registry prefix = + let prefix_len = Vec.length prefix in + commands_under registry prefix + |> Vec.filter_map (fun command -> + if Vec.length command.Command_registry.path > prefix_len + 1 then + Vec.nth_opt command.Command_registry.path prefix_len + else None) + |> unique_preserve + +let is_group registry path = not (Vec.is_empty (commands_under registry path)) + +let global_option_names = + Vec.of_array + [| + "--help"; + "-h"; + "--version"; + "--config"; + "--graph"; + "-g"; + "--root-dir"; + "--timeout-ms"; + "--output"; + "-o"; + "--verbose"; + "-v"; + "--profile"; + |] + +let global_value_option_names = + Vec.of_array + [| + "--config"; + "--graph"; + "-g"; + "--root-dir"; + "--timeout-ms"; + "--output"; + "-o"; + |] + +let command_desc_for_path registry path = + Command_registry.find_by_path path registry + |> Option.map (fun (command : Command_registry.command_meta) -> command.doc) + |> Option.value ~default:"" + +let zsh_quote_entry name desc = "'" ^ name ^ ":" ^ desc ^ "'" + +let zsh_escape_desc doc = + let buffer = Buffer.create (String.length doc) in + String.iter + (fun c -> + match c with + | '\'' -> Buffer.add_string buffer "'\\''" + | '[' -> Buffer.add_string buffer "\\[" + | ']' -> Buffer.add_string buffer "\\]" + | c -> Buffer.add_char buffer c) + doc; + Buffer.contents buffer + +let zsh_option_action (option : Command_registry.option_meta) value_name = + if Vec.mem "--graph" option.names then "{_logseq_graphs}" + else if option.multi && not (Vec.is_empty option.choices) then + "{_values -s , " ^ value_name ^ " " ^ Vec.string_concat " " option.choices + ^ "}" + else if not (Vec.is_empty option.choices) then + "(" ^ Vec.string_concat " " option.choices ^ ")" + else if Vec.mem "--root-dir" option.names then "_files -/" + else if value_name = "path" then "_files" + else if value_name = "bool" then "(true false)" + else "" + +let zsh_option_spec (option : Command_registry.option_meta) = + let desc = "[" ^ zsh_escape_desc option.doc ^ "]" in + let value_spec = + match option.arity with + | Command_registry.Flag -> None + | Required_value name -> + Some (":" ^ name ^ ":" ^ zsh_option_action option name) + | Optional_value name -> + Some ("::" ^ name ^ ":" ^ zsh_option_action option name) + in + let name_suffix = if Option.is_some value_spec then "=" else "" in + let arg = Option.value value_spec ~default:"" in + let star = if option.repeatable then "*" else "" in + if Vec.length option.names = 1 then + "'" ^ star ^ Vec.nth option.names 0 ^ name_suffix ^ desc ^ arg ^ "'" + else + let exclusion = + if option.repeatable then "'" ^ star ^ "'" + else "'(" ^ Vec.string_concat " " option.names ^ ")'" + in + let alternatives = + option.names + |> Vec.map (fun name -> name ^ name_suffix) + |> Vec.string_concat "," + in + exclusion ^ "{" ^ alternatives ^ "}'" ^ desc ^ arg ^ "'" + +let global_option_named name = + Command_registry.global_options + |> Vec.find_opt (fun (option : Command_registry.option_meta) -> + Vec.mem name option.names) + +let zsh_global_specs names = + names |> Vec.filter_map global_option_named |> Vec.map zsh_option_spec + +let zsh_spec_lines specs = " " ^ Vec.string_concat " \\\n " specs + +let zsh_leaf_function command = + let func_name = "_logseq_" ^ join_words command.Command_registry.path in + let func_name = String.map (function ' ' -> '_' | c -> c) func_name in + let specs = + Vec.append + (zsh_global_specs (Vec.of_array [| "--help"; "--graph"; "--output" |])) + (Vec.map zsh_option_spec command.Command_registry.options) + in + String.concat "\n" + [ func_name ^ "() {"; " _arguments \\"; zsh_spec_lines specs; "}"; "" ] + +let zsh_group_specs registry path = + let own_option_specs = + match Command_registry.find_by_path path registry with + | Some command -> Vec.map zsh_option_spec command.Command_registry.options + | None -> Vec.empty + in + Vec.append + (Vec.append + (zsh_global_specs (Vec.of_array [| "--help"; "--graph" |])) + own_option_specs) + (Vec.of_array [| "'(-)1:subcommand:->subcmd'"; "'(-)*::args:->args'" |]) + +let zsh_subgroup_function registry parent subgroup = + let prefix = Vec.push_back parent subgroup in + let child_entries = + direct_leaf_children registry prefix + |> Vec.map (fun command -> + " " + ^ zsh_quote_entry + (Vec.nth command.Command_registry.path (Vec.length prefix)) + command.Command_registry.doc) + |> Vec.string_concat "\n" + in + let dispatches = + direct_leaf_children registry prefix + |> Vec.map (fun command -> + let leaf = Vec.nth command.Command_registry.path (Vec.length prefix) in + " " ^ leaf ^ ") _logseq_" + ^ Vec.string_concat "_" command.Command_registry.path + ^ " ;;") + |> Vec.string_concat "\n" + in + String.concat "\n" + [ + "_logseq_" ^ Vec.string_concat "_" prefix ^ "() {"; + " local curcontext=\"$curcontext\" state line"; + " typeset -A opt_args"; + " _arguments -C \\"; + zsh_spec_lines (zsh_group_specs registry prefix); + " case $state in"; + " subcmd)"; + " local -a subcmds"; + " subcmds=("; + child_entries; + " )"; + " _describe 'subcommand' subcmds"; + " ;;"; + " args)"; + " case $line[1] in"; + dispatches; + " esac"; + " ;;"; + " esac"; + "}"; + ""; + ] + +let zsh_group_function registry group = + let prefix = Vec.singleton group in + let leaf_entries = + direct_leaf_children registry prefix + |> Vec.map (fun command -> + " " + ^ zsh_quote_entry + (Vec.nth command.Command_registry.path 1) + command.Command_registry.doc) + in + let subgroup_entries = + nested_child_groups registry prefix + |> Vec.map (fun name -> + " " ^ zsh_quote_entry name (name ^ " commands")) + in + let dispatches = + direct_leaf_children registry prefix + |> Vec.map (fun command -> + let subcmd = Vec.nth command.Command_registry.path 1 in + " " ^ subcmd ^ ") _logseq_" + ^ Vec.string_concat "_" command.Command_registry.path + ^ " ;;") + |> fun leaf_dispatches -> + Vec.append leaf_dispatches + (nested_child_groups registry prefix + |> Vec.map (fun name -> + " " ^ name ^ ") _logseq_" ^ group ^ "_" ^ name ^ " ;;")) + in + let subgroup_functions = + nested_child_groups registry prefix + |> Vec.map (zsh_subgroup_function registry prefix) + |> Vec.string_concat "\n" + in + subgroup_functions + ^ String.concat "\n" + [ + "_logseq_" ^ group ^ "() {"; + " local curcontext=\"$curcontext\" state line"; + " typeset -A opt_args"; + " _arguments -C \\"; + zsh_spec_lines (zsh_group_specs registry prefix); + " case $state in"; + " subcmd)"; + " local -a subcmds"; + " subcmds=("; + Vec.string_concat "\n" (Vec.append leaf_entries subgroup_entries); + " )"; + " _describe 'subcommand' subcmds"; + " ;;"; + " args)"; + " case $line[1] in"; + Vec.string_concat "\n" dispatches; + " esac"; + " ;;"; + " esac"; + "}"; + ""; + ] + +let generate_zsh_completion registry = + let top_entries = + top_command_names registry + |> Vec.map (fun top -> + let desc = + let path = Vec.singleton top in + if is_group registry path then top ^ " commands" + else command_desc_for_path registry path + in + " " ^ zsh_quote_entry top desc) + |> Vec.string_concat "\n" + in + let dispatches = + top_command_names registry + |> Vec.map (fun top -> " " ^ top ^ ") _logseq_" ^ top ^ " ;;") + |> Vec.string_concat "\n" + in + let leaf_functions = + registry.Command_registry.commands + |> Vec.filter (fun command -> + (* commands that are also groups (e.g. `query`) are completed by + their group dispatcher, which includes their options *) + not (is_group registry command.Command_registry.path)) + |> Vec.map zsh_leaf_function |> Vec.string_concat "\n" + in + let group_functions = + top_command_names registry + |> Vec.filter (fun top -> is_group registry (Vec.singleton top)) + |> Vec.map (zsh_group_function registry) + |> Vec.string_concat "\n" + in + String.concat "\n" + [ + "#compdef logseq"; + "# Auto-generated by `logseq completion zsh` - do not edit manually."; + ""; + "_logseq_graphs() {"; + " local -a graphs"; + " graphs=( ${(f)\"$(logseq graph list --output json 2>/dev/null | node \ + -e 'const fs = require(\"fs\"); const input = fs.readFileSync(0, \ + \"utf8\"); if (!input.trim()) process.exit(0); const data = \ + JSON.parse(input); const graphs = data && data.data && \ + data.data.graphs; if (Array.isArray(graphs)) \ + console.log(graphs.join(\"\\n\"));')\"} )"; + " compadd -a graphs"; + "}"; + ""; + "# --- per-command functions ---"; + ""; + leaf_functions; + "# --- group dispatchers ---"; + ""; + group_functions; + "# --- top-level dispatcher ---"; + ""; + "_logseq() {"; + " local curcontext=\"$curcontext\" state line"; + " typeset -A opt_args"; + " _arguments -C \\"; + zsh_spec_lines + (Vec.append + (Vec.map zsh_option_spec Command_registry.global_options) + (Vec.of_array + [| "'(-)1:command:->cmds'"; "'(-)*::args:->args'" |])); + " case $state in"; + " cmds)"; + " local -a cmds"; + " cmds=("; + top_entries; + " )"; + " _describe 'command' cmds"; + " ;;"; + " args)"; + " case $line[1] in"; + dispatches; + " esac"; + " ;;"; + " esac"; + "}"; + ""; + "compdef _logseq logseq"; + ] + +let bash_case_pattern names = Vec.string_concat "|" names + +let bash_subcommand_cases registry = + top_command_names registry + |> Vec.filter_map (fun top -> + let children = direct_children registry (Vec.singleton top) in + if Vec.is_empty children then None + else + Some + (" " ^ top ^ ") COMPREPLY=( $(compgen -W '" + ^ Vec.string_concat " " children + ^ "' -- \"$cur\") ) ;;")) + |> Vec.string_concat "\n" + +let bash_sub_subcommand_cases registry = + top_command_names registry + |> Vec.concat_map (fun top -> + nested_child_groups registry (Vec.singleton top) + |> Vec.map (fun subgroup -> + let leaves = + direct_children registry (Vec.of_array [| top; subgroup |]) + in + " " ^ top ^ ":" ^ subgroup ^ ") COMPREPLY=( $(compgen -W '" + ^ Vec.string_concat " " leaves + ^ "' -- \"$cur\") ) ;;")) + |> Vec.string_concat "\n" + +let generate_bash_completion registry = + let top_commands = Vec.string_concat " " (top_command_names registry) in + String.concat "\n" + [ + "# Auto-generated by `logseq completion bash` - do not edit manually."; + ""; + "_logseq_is_value_opt() {"; + " case \"$1\" in"; + " " ^ bash_case_pattern global_value_option_names ^ ")"; + " return 0 ;;"; + " *) return 1 ;;"; + " esac"; + "}"; + ""; + "_logseq_cmd_and_subcmd() {"; + " local i skip=0"; + " __cmd='' __subcmd='' __subsubcmd=''"; + " for (( i = 1; i < COMP_CWORD; i++ )); do"; + " local w=\"${COMP_WORDS[i]}\""; + " if (( skip )); then skip=0; continue; fi"; + " if [[ \"$w\" == -* ]]; then"; + " _logseq_is_value_opt \"$w\" && skip=1"; + " continue"; + " fi"; + " if [[ -z \"$__cmd\" ]]; then __cmd=\"$w\""; + " elif [[ -z \"$__subcmd\" ]]; then __subcmd=\"$w\""; + " elif [[ -z \"$__subsubcmd\" ]]; then __subsubcmd=\"$w\""; + " fi"; + " done"; + "}"; + ""; + "_logseq_json_names_bash() {"; + " logseq graph list --output json 2>/dev/null | node -e 'const fs = \ + require(\"fs\"); const input = fs.readFileSync(0, \"utf8\"); if \ + (!input.trim()) process.exit(0); const data = JSON.parse(input); const \ + graphs = data && data.data && data.data.graphs; if \ + (Array.isArray(graphs)) console.log(graphs.join(\" \"));'"; + "}"; + ""; + "_logseq_opts_for() {"; + " printf '%s' \"" ^ Vec.string_concat " " global_option_names ^ "\""; + "}"; + ""; + "_logseq() {"; + " local cur prev"; + " cur=\"${COMP_WORDS[COMP_CWORD]}\""; + " prev=\"${COMP_WORDS[COMP_CWORD-1]}\""; + " COMPREPLY=()"; + " local __cmd __subcmd __subsubcmd"; + " _logseq_cmd_and_subcmd"; + " if [[ \"$cur\" == -* ]]; then"; + " COMPREPLY=( $(compgen -W \"$(_logseq_opts_for \"$__cmd\" \ + \"$__subcmd\" \"$__subsubcmd\")\" -- \"$cur\") )"; + " return"; + " fi"; + " if [[ -z \"$__cmd\" ]]; then"; + " COMPREPLY=( $(compgen -W '" ^ top_commands ^ "' -- \"$cur\") )"; + " return"; + " fi"; + " if [[ -z \"$__subcmd\" ]]; then"; + " case \"$__cmd\" in"; + bash_subcommand_cases registry; + " esac"; + " return"; + " fi"; + " if [[ -z \"$__subsubcmd\" ]]; then"; + " case \"$__cmd:$__subcmd\" in"; + bash_sub_subcommand_cases registry; + " esac"; + " return"; + " fi"; + "}"; + ""; + "complete -F _logseq logseq"; + ] + +let generate shell registry = + match shell with + | Cli_primitive.Zsh -> generate_zsh_completion registry + | Bash -> generate_bash_completion registry + +let command_id _ = Command_id.Completion +let validate_parsed _ = Ok () + +let build ?registry _config _ = function + | Parsed_completion { shell = Some shell } -> + Ok (Completion { shell; registry }) + | Parsed_completion { shell = None } -> + Error + (Error.invalid_options + "completion shell is required; expected zsh or bash") + +let execute_with_mode (Completion { shell; registry }) _config mode = + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Completion mode + (Message + (generate shell + (Option.value registry ~default:Command_registry.empty)))) + +let metadata () = + Vec.of_array + [| + { + Command_registry.id = Command_id.Completion; + path = Command_id.to_path Command_id.Completion; + doc = "Generate shell completion script"; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Utilities; + requires_graph = Command_id.requires_graph Command_id.Completion; + requires_auth = Command_id.requires_auth Command_id.Completion; + write_command = Command_id.is_write Command_id.Completion; + human_table_headers_order = Vec.empty; + }; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/db_worker_types.ml b/cli/lib/db_worker_types.ml new file mode 100644 index 0000000..c2adfd4 --- /dev/null +++ b/cli/lib/db_worker_types.ml @@ -0,0 +1,32 @@ +type list_options = Melange_edn_melange.any + +type page_filter = { + expand : bool; + include_hidden : bool; + include_built_in : bool; + include_journal : bool; + journal_only : bool; + created_after : Js.Date.t option; + updated_after : Js.Date.t option; +} + +type node_filter = { + tag_ids : Cli_primitive.db_id Rrbvec.t; + property_idents : Cli_primitive.keyword Rrbvec.t; +} + +let add_raw_field key raw fields = + match Edn_util.get raw key with + | None -> fields + | Some value -> Vec.push_front fields (Edn_util.keyword key, value) + +let minimal_list_item e = + Vec.empty + |> add_raw_field "logseq.property/type" e.Entity.raw + |> add_raw_field "db/cardinality" e.Entity.raw + |> add_raw_field "db/ident" e.Entity.raw + |> add_raw_field "block/updated-at" e.Entity.raw + |> add_raw_field "block/created-at" e.Entity.raw + |> add_raw_field "block/title" e.Entity.raw + |> add_raw_field "db/id" e.Entity.raw + |> fun fields -> Edn_util.map_t_vec fields diff --git a/cli/lib/debug.ml b/cli/lib/debug.ml new file mode 100644 index 0000000..a6d8917 --- /dev/null +++ b/cli/lib/debug.ml @@ -0,0 +1,144 @@ +type selector = + | By_id of Cli_primitive.db_id + | By_uuid of Cli_primitive.uuid + | By_ident of Cli_primitive.keyword + +type opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + ident : Cli_primitive.keyword option; +} + +type parsed = Parsed_pull of opts + +type action = + | Debug_pull of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + lookup : selector; + selector : Melange_edn_melange.any; + } + +let parse_ident_option value = + let value = String.trim value in + try + match Edn_util.as_keyword_t (Melange_edn_melange.of_edn_string value) with + | Some keyword -> Ok keyword + | None -> + Error + (Error.invalid_options + "ident must be a strict EDN keyword (e.g. :logseq.class/Tag)") + with _ -> + Error + (Error.invalid_options + "ident must be a strict EDN keyword (e.g. :logseq.class/Tag)") + +let resolve_selector opts = + let uuid = Option.bind opts.uuid Cli_primitive.non_empty in + let selectors = + Vec.empty + |> (fun acc -> + match opts.id with Some id -> Vec.push_back acc (By_id id) | None -> acc) + |> (fun acc -> + match uuid with + | Some uuid -> Vec.push_back acc (By_uuid uuid) + | None -> acc) + |> (fun acc -> + match opts.ident with + | Some ident -> Vec.push_back acc (By_ident ident) + | None -> acc) + in + match (Vec.length selectors, Vec.nth_opt selectors 0) with + | 0, _ -> + Error + (Error.invalid_options + "exactly one of --id, --uuid, or --ident is required") + | 1, Some (By_uuid uuid) when not (Cli_primitive.is_uuid_string uuid) -> + Error (Error.invalid_options "Option uuid must be a valid UUID string") + | 1, Some selector -> Ok selector + | _ -> + Error + (Error.invalid_options "only one of --id, --uuid, or --ident is allowed") + +let command_id _ = Command_id.Debug_pull + +let validate_parsed (Parsed_pull opts) = + Error.map (fun _ -> ()) (resolve_selector opts) + +let selector_value = function + | By_id id -> Edn_util.int64 id + | By_uuid uuid -> + Edn_util.vector_vec + (Vec.of_array [| Edn_util.keyword "block/uuid"; Edn_util.uuid uuid |]) + | By_ident ident -> + Edn_util.vector_vec + (Vec.of_array [| Edn_util.keyword "db/ident"; Edn_util.any ident |]) + +let default_selector = + Edn_util.vector_vec (Vec.of_array [| Edn_util.symbol "*" |]) + +let build ?registry:_ config _globals (Parsed_pull opts) = + Error.bind (resolve_selector opts) (fun lookup -> + match config.Cli_config.repo with + | Some repo -> + Ok + (Debug_pull + { + repo; + graph = Cli_config.repo_to_graph repo; + lookup; + selector = default_selector; + }) + | None -> Error (Error.missing_repo "repo is required for debug pull")) + +let execute_with_mode (Debug_pull action) config mode = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> + pure (Output_mode.error ~command:Command_id.Debug_pull mode err) + | Ok invoke_config -> + bind + (Transport.thread_api_pull invoke_config ~repo:action.repo + ~selector: + (Edn_util.expect_vector_t "debug pull selector" action.selector) + ~lookup:(selector_value action.lookup)) + (fun entity -> + if Edn_util.is_null entity then + pure + (Output_mode.error ~command:Command_id.Debug_pull mode + (Error.make Error.Entity_not_found "entity not found")) + else + pure + (Cli_result.ok ~command:Command_id.Debug_pull mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "entity", entity); + ( Edn_util.keyword "lookup", + selector_value action.lookup ); + (Edn_util.keyword "selector", action.selector); + |])))))) + +let metadata () = + Vec.of_array + [| + { + Command_registry.id = Command_id.Debug_pull; + path = Command_id.to_path Command_id.Debug_pull; + doc = "Pull raw entity data for debugging"; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Hidden; + requires_graph = Command_id.requires_graph Command_id.Debug_pull; + requires_auth = Command_id.requires_auth Command_id.Debug_pull; + write_command = Command_id.is_write Command_id.Debug_pull; + human_table_headers_order = Vec.empty; + }; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/display_width.ml b/cli/lib/display_width.ml new file mode 100644 index 0000000..7c5c5ff --- /dev/null +++ b/cli/lib/display_width.ml @@ -0,0 +1,56 @@ +let is_combining code = + (code >= 0x0300 && code <= 0x036f) + || (code >= 0x1ab0 && code <= 0x1aff) + || (code >= 0x1dc0 && code <= 0x1dff) + || (code >= 0x20d0 && code <= 0x20ff) + || (code >= 0xfe20 && code <= 0xfe2f) + +let is_wide code = + code >= 0x1100 + && (code <= 0x115f || code = 0x2329 || code = 0x232a + || (code >= 0x2e80 && code <= 0xa4cf && code <> 0x303f) + || (code >= 0xac00 && code <= 0xd7a3) + || (code >= 0xf900 && code <= 0xfaff) + || (code >= 0xfe10 && code <= 0xfe19) + || (code >= 0xfe30 && code <= 0xfe6f) + || (code >= 0xff00 && code <= 0xff60) + || (code >= 0xffe0 && code <= 0xffe6)) + +let char_width code = + if code = 0 || code < 32 || (code >= 0x7f && code < 0xa0) then 0 + else if is_combining code then 0 + else if is_wide code then 2 + else 1 + +external chars_of_string : string -> string array = "from" [@@mel.scope "Array"] + +let unicode_chars text = + Ustring.(of_string text |> to_string) |> chars_of_string + +let code_point ch = + match Js.String.codePointAt ~index:0 ch with Some code -> code | None -> 0 + +let width text = + unicode_chars text + |> Array.fold_left (fun acc ch -> acc + char_width (code_point ch)) 0 + +let take text max_width = + let rec loop chars index acc_width acc = + if index >= Array.length chars then Vec.string_concat "" acc + else + let ch = chars.(index) in + let next_width = acc_width + char_width (code_point ch) in + if next_width > max_width then Vec.string_concat "" acc + else loop chars (index + 1) next_width (Vec.push_back acc ch) + in + if max_width <= 0 then "" else loop (unicode_chars text) 0 0 Vec.empty + +let ellipsis = Cli_platform.Symbols.ellipsis + +let truncate text max_width = + let max_width = max 1 max_width in + if width text <= max_width then text + else + let ellipsis_width = width ellipsis in + if max_width <= ellipsis_width then take ellipsis max_width + else take text (max_width - ellipsis_width) ^ ellipsis diff --git a/cli/lib/doctor.ml b/cli/lib/doctor.ml new file mode 100644 index 0000000..711708e --- /dev/null +++ b/cli/lib/doctor.ml @@ -0,0 +1,270 @@ +type opts = { dev_script : bool } +type parsed = Parsed_doctor of opts +type action = Doctor of { script_path : Cli_primitive.path option } +type check_status = Ok | Warning | Error + +type check = { + id : Cli_primitive.keyword; + status : check_status; + code : Error.code option; + message : string; + path : Cli_primitive.path option; + servers : Melange_edn_melange.any Rrbvec.t; + raw : Melange_edn_melange.any option; +} + +type report = { status : check_status; checks : check Rrbvec.t } + +let command_id _ = Command_id.Doctor +let validate_parsed _ = Stdlib.Ok () + +let status_value = function + | Ok -> Edn_util.keyword "ok" + | Warning -> Edn_util.keyword "warning" + | Error -> Edn_util.keyword "error" + +let check_value (check : check) = + let fields = + Vec.of_array + [| + (Edn_util.keyword "id", Edn_util.any check.id); + (Edn_util.keyword "status", status_value check.status); + (Edn_util.keyword "message", Edn_util.string check.message); + |] + in + let fields = + match check.code with + | Some code -> + Vec.push_back fields + (Edn_util.keyword "code", Edn_util.any (Error.code_to_keyword code)) + | None -> fields + in + let fields = + match check.path with + | Some path -> + Vec.push_back fields (Edn_util.keyword "path", Edn_util.string path) + | None -> fields + in + let fields = + if Vec.is_empty check.servers then fields + else + Vec.push_back fields + (Edn_util.keyword "servers", Edn_util.vector_vec check.servers) + in + let fields = + match check.raw with + | Some raw -> Vec.push_back fields (Edn_util.keyword "raw", raw) + | None -> fields + in + Edn_util.map_vec fields + +let check_db_worker_script (Doctor { script_path }) = + let path = + Option.value script_path + ~default:(Server_runtime.db_worker_runtime_script_path ()) + in + if not (Cli_unix.file_exists path) then + { + id = Edn_util.keyword_t "db-worker-script"; + status = Error; + code = Some Error.Doctor_script_missing; + message = "db-worker script is missing: " ^ path; + path = Some path; + servers = Vec.empty; + raw = None; + } + else if Cli_unix.is_directory path then + { + id = Edn_util.keyword_t "db-worker-script"; + status = Error; + code = Some Error.Doctor_script_unreadable; + message = "db-worker script path is not a file: " ^ path; + path = Some path; + servers = Vec.empty; + raw = None; + } + else + try + ignore (Cli_unix.read_text_file path); + { + id = Edn_util.keyword_t "db-worker-script"; + status = Ok; + code = None; + message = "Found readable file: " ^ path; + path = Some path; + servers = Vec.empty; + raw = None; + } + with exn -> + { + id = Edn_util.keyword_t "db-worker-script"; + status = Error; + code = Some Error.Doctor_script_unreadable; + message = + "db-worker script is not readable: " ^ path ^ " (" + ^ Printexc.to_string exn ^ ")"; + path = Some path; + servers = Vec.empty; + raw = None; + } + +let ensure_dir path = + if Cli_unix.file_exists path then () else Cli_unix.mkdir path 0o755 + +let check_root_dir config = + let path = config.Cli_config.root_dir in + try + ensure_dir path; + let probe = Filename.concat path ".logseq-cli-doctor-check" in + Cli_unix.write_text_file probe "ok"; + Cli_unix.remove_tree probe; + { + id = Edn_util.keyword_t "root-dir"; + status = Ok; + code = None; + message = "Read/write access confirmed: " ^ path; + path = Some path; + servers = Vec.empty; + raw = None; + } + with exn -> + { + id = Edn_util.keyword_t "root-dir"; + status = Error; + code = Some Error.Root_dir_permission; + message = "root-dir check failed: " ^ Printexc.to_string exn; + path = Some path; + servers = Vec.empty; + raw = None; + } + +let server_status_value = function + | Server_runtime.Starting -> Edn_util.keyword "starting" + | Server_runtime.Ready -> Edn_util.keyword "ready" + | Server_runtime.Error -> Edn_util.keyword "error" + | Server_runtime.Unknown -> Edn_util.keyword "unknown" + +let server_value (server : Server_runtime.server) = + Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "repo", + Edn_util.string (Cli_primitive.string_of_repo server.repo) ); + ( Edn_util.keyword "graph", + match server.graph with + | Some graph -> Edn_util.string (Cli_primitive.string_of_graph graph) + | None -> Edn_util.nil ); + (Edn_util.keyword "status", server_status_value server.status); + (Edn_util.keyword "base-url", Edn_util.string server.base_url); + |]) + +let check_running_servers config = + Cli_effect.map + (fun servers -> + let starting = + Vec.filter + (fun (server : Server_runtime.server) -> + server.status = Server_runtime.Starting) + servers + in + if Vec.is_empty starting then + { + id = Edn_util.keyword_t "running-servers"; + status = Ok; + code = None; + message = + (if Vec.is_empty servers then + "No running db-worker servers detected" + else "All running servers are ready"); + path = None; + servers = Vec.map server_value servers; + raw = None; + } + else + { + id = Edn_util.keyword_t "running-servers"; + status = Warning; + code = Some Error.Doctor_server_not_ready; + message = + Humanize_types.format_count_with_noun (Vec.length starting) "server" + ^ " still starting"; + path = None; + servers = Vec.map server_value starting; + raw = None; + }) + (Server_runtime.list_servers config) + +let report_status checks = + if Vec.exists (fun (check : check) -> check.status = Error) checks then Error + else if Vec.exists (fun (check : check) -> check.status = Warning) checks then + Warning + else Ok + +let report_value checks = + let status = report_status checks in + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "status", status_value status); + ( Edn_util.keyword "checks", + Edn_util.vector_vec (checks |> Vec.map check_value) ); + |]) + +let build ?registry:_ _ _ (Parsed_doctor opts) = + let script_path = + if opts.dev_script then Some (Filename.concat "static" "db-worker-node.js") + else None + in + Stdlib.Ok (Doctor { script_path }) + +let execute_with_mode action config mode = + let script_check = check_db_worker_script action in + if script_check.status = Error then + Cli_effect.pure + (Output_mode.error ~command:Command_id.Doctor mode + ~context:(report_value (Vec.singleton script_check)) + (Error.make + ~context:(report_value (Vec.singleton script_check)) + (Option.value script_check.code ~default:Error.Doctor_error) + script_check.message)) + else + let root_check = check_root_dir config in + if root_check.status = Error then + let checks = Vec.of_array [| script_check; root_check |] in + Cli_effect.pure + (Output_mode.error ~command:Command_id.Doctor mode + ~context:(report_value checks) + (Error.make ~context:(report_value checks) + (Option.value root_check.code ~default:Error.Doctor_error) + root_check.message)) + else + Cli_effect.map + (fun server_check -> + let checks = + Vec.of_array [| script_check; root_check; server_check |] + in + Cli_result.ok ~command:Command_id.Doctor mode + (Raw (report_value checks))) + (check_running_servers config) + +let metadata () = + Vec.of_array + [| + { + Command_registry.id = Command_id.Doctor; + path = Command_id.to_path Command_id.Doctor; + doc = "Run CLI diagnostics"; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Utilities; + requires_graph = Command_id.requires_graph Command_id.Doctor; + requires_auth = Command_id.requires_auth Command_id.Doctor; + write_command = Command_id.is_write Command_id.Doctor; + human_table_headers_order = Vec.empty; + }; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/dune b/cli/lib/dune new file mode 100644 index 0000000..184b746 --- /dev/null +++ b/cli/lib/dune @@ -0,0 +1,15 @@ +(library + (name logseq_cli) + (implements logseq_cli_spec) + (modes melange) + (preprocess + (pps melange.ppx)) + (libraries + logseq_cli_platform_spec + humanize + melange-fetch + melange-transit-melange + melange-edn-melange + rrbvec + melange.js + melange.node)) diff --git a/cli/lib/edn_util.ml b/cli/lib/edn_util.ml new file mode 100644 index 0000000..c5f3640 --- /dev/null +++ b/cli/lib/edn_util.ml @@ -0,0 +1,189 @@ +let any = Melange_edn_melange.any +let keyword_t value = Melange_edn_melange.keyword value + +let keyword_to_string + (value : Melange_edn_melange.keyword Melange_edn_melange.t) = + match value with + | Melange_edn_melange.Keyword value -> + Melange_edn_melange.keyword_to_string value + +let keyword_any value = any (keyword_t value) +let list_t values = Melange_edn_melange.list values +let vector_t values = Melange_edn_melange.vector values +let set_t values = Melange_edn_melange.set values +let map_t fields = Melange_edn_melange.map fields +let nil = any Melange_edn_melange.nil +let bool value = any (Melange_edn_melange.bool value) +let int value = any (Melange_edn_melange.int (Int64.of_int value)) +let int64 value = any (Melange_edn_melange.int value) +let float value = any (Melange_edn_melange.float value) +let string value = any (Melange_edn_melange.string value) +let symbol value = any (Melange_edn_melange.symbol value) +let keyword value = any (keyword_t value) +let uuid value = any (Melange_edn_melange.tagged "uuid" (string value)) + +let bytes value = + any + (Melange_edn_melange.tagged "transit/bytes" + (string (Bytes.to_string value))) + +let list values = any (list_t values) +let vector values = any (vector_t values) +let set values = any (set_t values) +let map fields = any (map_t fields) +let list_t_vec values = list_t (Vec.to_list values) +let vector_t_vec values = vector_t (Vec.to_list values) +let set_t_vec values = set_t (Vec.to_list values) +let map_t_vec fields = map_t (Vec.to_list fields) +let list_vec values = any (list_t_vec values) +let vector_vec values = any (vector_t_vec values) +let set_vec values = any (set_t_vec values) +let map_vec fields = any (map_t_vec fields) +let map_t_rev_vec fields = map_t_vec (Vec.rev fields) +let map_rev_vec fields = any (map_t_rev_vec fields) +let vec_of_array = Rrbvec.of_array +let keyword_text value = Melange_edn_melange.keyword_to_string value + +let int64_to_int_opt value = + let int_value = Int64.to_int value in + if Int64.of_int int_value = value then Some int_value else None + +let as_bool = function + | Melange_edn_melange.Any (Melange_edn_melange.Bool value) -> Some value + | _ -> None + +let as_int = function + | Melange_edn_melange.Any (Melange_edn_melange.Int value) -> + int64_to_int_opt value + | _ -> None + +let as_int64 = function + | Melange_edn_melange.Any (Melange_edn_melange.Int value) -> Some value + | _ -> None + +let as_float = function + | Melange_edn_melange.Any (Melange_edn_melange.Float value) -> Some value + | _ -> None + +let raw_string = function + | Melange_edn_melange.Any (Melange_edn_melange.String value) -> Some value + | _ -> None + +let as_string value = + Option.map + (fun value -> Ustring.(of_string value |> to_string)) + (raw_string value) + +let as_symbol = function + | Melange_edn_melange.Any (Melange_edn_melange.Symbol value) -> Some value + | _ -> None + +let as_keyword = function + | Melange_edn_melange.Any (Melange_edn_melange.Keyword value) -> + Some (keyword_text value) + | _ -> None + +let as_keyword_t = function + | Melange_edn_melange.Any (Melange_edn_melange.Keyword value) -> + Some (keyword_t (Melange_edn_melange.keyword_to_string value)) + | _ -> None + +let as_uuid = function + | Melange_edn_melange.Any (Melange_edn_melange.Tagged ("uuid", value)) -> + as_string value + | _ -> None + +let as_bytes = function + | Melange_edn_melange.Any + (Melange_edn_melange.Tagged ("transit/bytes", value)) -> + Option.map Bytes.of_string (raw_string value) + | _ -> None + +let as_list = function + | Melange_edn_melange.Any (Melange_edn_melange.List values) -> + Some (vec_of_array values) + | _ -> None + +let as_vector = function + | Melange_edn_melange.Any (Melange_edn_melange.Vector values) -> + Some (vec_of_array values) + | _ -> None + +let as_vector_t = function + | Melange_edn_melange.Any (Melange_edn_melange.Vector values) -> + Some (vector_t_vec (vec_of_array values)) + | _ -> None + +let as_set = function + | Melange_edn_melange.Any (Melange_edn_melange.Set values) -> + Some (vec_of_array values) + | _ -> None + +let as_map = function + | Melange_edn_melange.Any (Melange_edn_melange.Map fields) -> + Some (vec_of_array fields) + | _ -> None + +let as_map_t = function + | Melange_edn_melange.Any (Melange_edn_melange.Map fields) -> + Some (map_t_vec (vec_of_array fields)) + | _ -> None + +let expect_vector_t label value = + match as_vector_t value with + | Some value -> value + | None -> invalid_arg (label ^ " must be an EDN vector") + +let expect_map_t label value = + match as_map_t value with + | Some value -> value + | None -> invalid_arg (label ^ " must be an EDN map") + +let is_null = function + | Melange_edn_melange.Any Melange_edn_melange.Nil -> true + | _ -> false + +let as_seq value = + match (as_list value, as_vector value, as_set value) with + | Some values, _, _ | _, Some values, _ | _, _, Some values -> Some values + | _ -> None + +let as_string_like value = + match (as_string value, as_keyword value, as_uuid value) with + | Some value, _, _ | _, Some value, _ | _, _, Some value -> Some value + | _ -> None + +let key_matches key field = + match (as_keyword field, as_string field) with + | Some value, _ | _, Some value -> value = key + | _ -> false + +let get value key = + match as_map value with + | Some fields -> + fields + |> Vec.find_map (fun (field, value) -> + if key_matches key field then Some value else None) + | None -> None + +let get_string value key = Option.bind (get value key) as_string_like +let get_int value key = Option.bind (get value key) as_int +let get_int64 value key = Option.bind (get value key) as_int64 +let get_bool value key = Option.bind (get value key) as_bool + +let assoc key value raw = + match as_map raw with + | Some fields -> + map_vec + (Vec.push_front + (Vec.filter (fun (field, _) -> not (key_matches key field)) fields) + (keyword key, value)) + | None -> + map_vec (Vec.of_array [| (keyword key, value); (keyword "_", raw) |]) + +let remove key raw = + match as_map raw with + | Some fields -> + map_vec + (fields |> Vec.filter (fun (field, _) -> not (key_matches key field))) + | None -> raw diff --git a/cli/lib/entity.ml b/cli/lib/entity.ml new file mode 100644 index 0000000..b20e2fa --- /dev/null +++ b/cli/lib/entity.ml @@ -0,0 +1,30 @@ +type kind = Page | Block | Tag | Property | Task | Asset | Unknown + +type t = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + ident : Cli_primitive.keyword option; + name : string option; + title : string option; + kind : kind; + tags : Cli_primitive.keyword Rrbvec.t; + created_at : Js.Date.t option; + updated_at : Js.Date.t option; + deleted_at : Js.Date.t option; + raw : Melange_edn_melange.any; +} + +let of_value raw = + { + id = Edn_util.get_int64 raw "db/id"; + uuid = Edn_util.get_string raw "block/uuid"; + ident = Option.bind (Edn_util.get raw "db/ident") Edn_util.as_keyword_t; + name = Edn_util.get_string raw "name"; + title = Edn_util.get_string raw "block/title"; + kind = Unknown; + tags = Vec.empty; + created_at = None; + updated_at = None; + deleted_at = None; + raw; + } diff --git a/cli/lib/error.ml b/cli/lib/error.ml new file mode 100644 index 0000000..db7c7a3 --- /dev/null +++ b/cli/lib/error.ml @@ -0,0 +1,306 @@ +type code = + | Add_id_resolution_failed + | Agent_bridge_already_running + | Agent_bridge_lock_failed + | Agent_master_prompt_invalid + | Agent_name_invalid + | Agent_prompt_template_invalid + | Ambiguous_page_name + | Ambiguous_property_name + | Ambiguous_tag_name + | Asset_checksum_failed + | Asset_checksum_missing + | Asset_create_failed + | Asset_file_copy_failed + | Asset_file_not_found + | Asset_not_found + | Asset_not_remote + | Asset_tag_not_found + | Asset_type_missing + | Asset_uuid_missing + | Auth_code_exchange_failed + | Auth_file_delete_failed + | Auth_file_write_failed + | Auth_refresh_failed + | Backup_create_failed + | Backup_not_found + | Block_link_cycle + | Block_link_target_not_found + | Block_not_found + | Browser_open_failed + | Codex_not_found + | Codex_session_id_missing + | Codex_start_failed + | Config_write_failed + | Doctor_error + | Doctor_script_missing + | Doctor_script_unreadable + | Doctor_server_not_ready + | E2ee_password_failed + | E2ee_password_not_found + | Entity_not_found + | Exception + | External_asset + | Graph_already_exists + | Graph_db_not_empty + | Graph_exists + | Graph_not_exists + | Graph_not_removed + | Graph_validation_failed + | Invalid_auth_file + | Invalid_auth_token + | Invalid_blocks + | Invalid_callback_request + | Invalid_callback_state + | Invalid_config + | Invalid_options + | Invalid_property_target + | Invalid_query + | Invalid_source + | Invalid_tag_target + | Invalid_target + | Login_callback_error + | Login_callback_not_found + | Login_callback_server_start_failed + | Login_not_configured + | Login_timeout + | Logout_not_configured + | Missing_asset_selector + | Missing_auth + | Missing_callback_code + | Missing_content + | Missing_dst + | Missing_examples + | Missing_graph + | Missing_id_token + | Missing_page_name + | Missing_property_name + | Missing_query + | Missing_query_text + | Missing_repo + | Missing_snapshot + | Missing_source + | Missing_tag_name + | Missing_target + | Missing_template_code_block + | Missing_template_vars + | Not_asset + | Not_implemented + | Page_hierarchy_parent_cycle + | Page_not_found + | Property_built_in + | Property_hidden + | Property_not_found + | Recycled_page + | Remote_graph_not_found + | Root_dir_permission + | Server_cleanup_failed + | Server_not_found + | Server_owned_by_other + | Server_revision_mismatch_after_restart + | Server_script_missing + | Server_start_failed + | Server_start_timeout_orphan + | Server_stop_timeout + | Skill_home_dir_unavailable + | Skill_install_failed + | Skill_show_failed + | Skill_source_not_found + | Source_not_found + | Spec_blocker + | Sync_not_started + | Sync_remote_graphs_failed + | Sync_start_runtime_error + | Sync_start_timeout + | Sync_upload_failed + | Tag_built_in + | Tag_create_not_tag + | Tag_hidden + | Tag_name_conflict + | Tag_not_found + | Tag_rename_conflict + | Target_not_found + | Task_tag_not_found + | Unknown_command + | Unknown_query + | Unknown_template_vars + | Unsupported_input_format + | Unsupported_output_format + | Upsert_id_not_found + | Upsert_id_type_mismatch + +type candidate = { id : Cli_primitive.db_id option; name : string option } + +type t = { + code : code; + message : string; + hint : string option; + candidates : candidate Rrbvec.t; + context : Melange_edn_melange.any option; +} + +type 'a build_result = ('a, t) result + +type source = + | Cli_parse + | Config + | Build_action + | Transport + | Server + | Auth + | Sync + | Db_worker + | Filesystem + | Unknown + +let code_to_string = function + | Add_id_resolution_failed -> "add-id-resolution-failed" + | Agent_bridge_already_running -> "agent-bridge-already-running" + | Agent_bridge_lock_failed -> "agent-bridge-lock-failed" + | Agent_master_prompt_invalid -> "agent-master-prompt-invalid" + | Agent_name_invalid -> "agent-name-invalid" + | Agent_prompt_template_invalid -> "agent-prompt-template-invalid" + | Ambiguous_page_name -> "ambiguous-page-name" + | Ambiguous_property_name -> "ambiguous-property-name" + | Ambiguous_tag_name -> "ambiguous-tag-name" + | Asset_checksum_failed -> "asset-checksum-failed" + | Asset_checksum_missing -> "asset-checksum-missing" + | Asset_create_failed -> "asset-create-failed" + | Asset_file_copy_failed -> "asset-file-copy-failed" + | Asset_file_not_found -> "asset-file-not-found" + | Asset_not_found -> "asset-not-found" + | Asset_not_remote -> "asset-not-remote" + | Asset_tag_not_found -> "asset-tag-not-found" + | Asset_type_missing -> "asset-type-missing" + | Asset_uuid_missing -> "asset-uuid-missing" + | Auth_code_exchange_failed -> "auth-code-exchange-failed" + | Auth_file_delete_failed -> "auth-file-delete-failed" + | Auth_file_write_failed -> "auth-file-write-failed" + | Auth_refresh_failed -> "auth-refresh-failed" + | Backup_create_failed -> "backup-create-failed" + | Backup_not_found -> "backup-not-found" + | Block_link_cycle -> "block-link-cycle" + | Block_link_target_not_found -> "block-link-target-not-found" + | Block_not_found -> "block-not-found" + | Browser_open_failed -> "browser-open-failed" + | Codex_not_found -> "codex-not-found" + | Codex_session_id_missing -> "codex-session-id-missing" + | Codex_start_failed -> "codex-start-failed" + | Config_write_failed -> "config-write-failed" + | Doctor_error -> "doctor-error" + | Doctor_script_missing -> "doctor-script-missing" + | Doctor_script_unreadable -> "doctor-script-unreadable" + | Doctor_server_not_ready -> "doctor-server-not-ready" + | E2ee_password_failed -> "e2ee-password-failed" + | E2ee_password_not_found -> "e2ee-password-not-found" + | Entity_not_found -> "entity-not-found" + | Exception -> "exception" + | External_asset -> "external-asset" + | Graph_already_exists -> "graph-already-exists" + | Graph_db_not_empty -> "graph-db-not-empty" + | Graph_exists -> "graph-exists" + | Graph_not_exists -> "graph-not-exists" + | Graph_not_removed -> "graph-not-removed" + | Graph_validation_failed -> "graph-validation-failed" + | Invalid_auth_file -> "invalid-auth-file" + | Invalid_auth_token -> "invalid-auth-token" + | Invalid_blocks -> "invalid-blocks" + | Invalid_callback_request -> "invalid-callback-request" + | Invalid_callback_state -> "invalid-callback-state" + | Invalid_config -> "invalid-config" + | Invalid_options -> "invalid-options" + | Invalid_property_target -> "invalid-property-target" + | Invalid_query -> "invalid-query" + | Invalid_source -> "invalid-source" + | Invalid_tag_target -> "invalid-tag-target" + | Invalid_target -> "invalid-target" + | Login_callback_error -> "login-callback-error" + | Login_callback_not_found -> "login-callback-not-found" + | Login_callback_server_start_failed -> "login-callback-server-start-failed" + | Login_not_configured -> "login-not-configured" + | Login_timeout -> "login-timeout" + | Logout_not_configured -> "logout-not-configured" + | Missing_asset_selector -> "missing-asset-selector" + | Missing_auth -> "missing-auth" + | Missing_callback_code -> "missing-callback-code" + | Missing_content -> "missing-content" + | Missing_dst -> "missing-dst" + | Missing_examples -> "missing-examples" + | Missing_graph -> "missing-graph" + | Missing_id_token -> "missing-id-token" + | Missing_page_name -> "missing-page-name" + | Missing_property_name -> "missing-property-name" + | Missing_query -> "missing-query" + | Missing_query_text -> "missing-query-text" + | Missing_repo -> "missing-repo" + | Missing_snapshot -> "missing-snapshot" + | Missing_source -> "missing-source" + | Missing_tag_name -> "missing-tag-name" + | Missing_target -> "missing-target" + | Missing_template_code_block -> "missing-template-code-block" + | Missing_template_vars -> "missing-template-vars" + | Not_asset -> "not-asset" + | Not_implemented -> "not-implemented" + | Page_hierarchy_parent_cycle -> "page-hierarchy-parent-cycle" + | Page_not_found -> "page-not-found" + | Property_built_in -> "property-built-in" + | Property_hidden -> "property-hidden" + | Property_not_found -> "property-not-found" + | Recycled_page -> "recycled-page" + | Remote_graph_not_found -> "remote-graph-not-found" + | Root_dir_permission -> "root-dir-permission" + | Server_cleanup_failed -> "server-cleanup-failed" + | Server_not_found -> "server-not-found" + | Server_owned_by_other -> "server-owned-by-other" + | Server_revision_mismatch_after_restart -> + "server-revision-mismatch-after-restart" + | Server_script_missing -> "server-script-missing" + | Server_start_failed -> "server-start-failed" + | Server_start_timeout_orphan -> "server-start-timeout-orphan" + | Server_stop_timeout -> "server-stop-timeout" + | Skill_home_dir_unavailable -> "skill-home-dir-unavailable" + | Skill_install_failed -> "skill-install-failed" + | Skill_show_failed -> "skill-show-failed" + | Skill_source_not_found -> "skill-source-not-found" + | Source_not_found -> "source-not-found" + | Spec_blocker -> "spec-blocker" + | Sync_not_started -> "sync-not-started" + | Sync_remote_graphs_failed -> "sync-remote-graphs-failed" + | Sync_start_runtime_error -> "sync-start-runtime-error" + | Sync_start_timeout -> "sync-start-timeout" + | Sync_upload_failed -> "sync-upload-failed" + | Tag_built_in -> "tag-built-in" + | Tag_create_not_tag -> "tag-create-not-tag" + | Tag_hidden -> "tag-hidden" + | Tag_name_conflict -> "tag-name-conflict" + | Tag_not_found -> "tag-not-found" + | Tag_rename_conflict -> "tag-rename-conflict" + | Target_not_found -> "target-not-found" + | Task_tag_not_found -> "task-tag-not-found" + | Unknown_command -> "unknown-command" + | Unknown_query -> "unknown-query" + | Unknown_template_vars -> "unknown-template-vars" + | Unsupported_input_format -> "unsupported-input-format" + | Unsupported_output_format -> "unsupported-output-format" + | Upsert_id_not_found -> "upsert-id-not-found" + | Upsert_id_type_mismatch -> "upsert-id-type-mismatch" + +let code_to_keyword code = Edn_util.keyword_t (code_to_string code) + +let make ?hint ?(candidates = Vec.empty) ?context code message = + { code; message; hint; candidates; context } + +let invalid_options message = make Invalid_options message + +let missing_graph () = + make ~hint:"Use --graph " Missing_graph "graph name is required" + +let missing_repo message = make Missing_repo message +let missing_target message = make Missing_target message +let unknown_command message = make Unknown_command message + +let exception_error ?context exn = + make ?context Exception (Printexc.to_string exn) + +let map f = function Ok x -> Ok (f x) | Error e -> Error e +let bind x f = match x with Ok v -> f v | Error e -> Error e diff --git a/cli/lib/example.ml b/cli/lib/example.ml new file mode 100644 index 0000000..3d66944 --- /dev/null +++ b/cli/lib/example.ml @@ -0,0 +1,391 @@ +type opts = { selector : string Rrbvec.t } +type parsed = Parsed_example of opts + +type action = { + selector : string; + selector_path : string Rrbvec.t; + matched_commands : string Rrbvec.t; + examples : string Rrbvec.t; + message : string; +} + +type entry = { path : string Rrbvec.t; examples : string Rrbvec.t } + +let entry path examples = + { path = Vec.of_array path; examples = Vec.of_array examples } + +let default_entries = + Vec.of_array + [| + entry [| "graph"; "list" |] [| "logseq graph list" |]; + entry [| "graph"; "create" |] + [| + "logseq graph create --graph my-graph"; + "logseq graph create --graph my-graph --enable-sync"; + "logseq graph create --graph my-graph --enable-sync --e2ee-password \ + \"my-secret\""; + |]; + entry [| "graph"; "switch" |] [| "logseq graph switch --graph my-graph" |]; + entry [| "graph"; "remove" |] [| "logseq graph remove --graph my-graph" |]; + entry [| "graph"; "validate" |] + [| + "logseq graph validate --graph my-graph"; + "logseq graph validate --graph my-graph --fix"; + |]; + entry [| "graph"; "info" |] [| "logseq graph info --graph my-graph" |]; + entry [| "graph"; "export" |] + [| + "logseq graph export --graph my-graph --type edn --file \ + /tmp/my-graph.edn --edn-options '{:export-type :graph \ + :include-timestamps? true}' --pretty-print"; + "logseq graph export --graph my-graph --type sqlite --file \ + /tmp/my-graph.sqlite"; + |]; + entry [| "graph"; "import" |] + [| + "logseq graph import --graph my-graph --type edn --input \ + /tmp/my-graph.edn"; + |]; + entry + [| "graph"; "backup"; "list" |] + [| "logseq graph backup list --graph my-graph" |]; + entry + [| "graph"; "backup"; "create" |] + [| + "logseq graph backup create --graph my-graph"; + "logseq graph backup create --graph my-graph --name nightly"; + |]; + entry + [| "graph"; "backup"; "restore" |] + [| + "logseq graph backup restore --src my-graph-nightly --dst \ + my-graph-restore"; + |]; + entry + [| "graph"; "backup"; "remove" |] + [| "logseq graph backup remove --src my-graph-nightly" |]; + entry [| "list"; "page" |] + [| + "logseq list page --graph my-graph"; + "logseq list page --graph my-graph --journal-only --limit 20"; + "logseq list page --graph my-graph --limit 50 --sort updated-at \ + --order desc"; + |]; + entry [| "list"; "tag" |] + [| + "logseq list tag --graph my-graph --with-properties"; + "logseq list tag --graph my-graph --include-built-in --limit 20 \ + --output json"; + |]; + entry [| "list"; "property" |] + [| + "logseq list property --graph my-graph --with-type"; + "logseq list property --graph my-graph --include-built-in --limit 20 \ + --output json"; + |]; + entry [| "list"; "task" |] + [| + "logseq list task --graph my-graph --status todo --priority high"; + "logseq list task --graph my-graph --content \"release\" --sort \ + updated-at --order desc"; + |]; + entry [| "list"; "node" |] + [| + "logseq list node --graph my-graph --tags project,work"; + "logseq list node --graph my-graph --properties status,priority \ + --sort updated-at --order desc"; + |]; + entry [| "list"; "asset" |] + [| + "logseq list asset --graph my-graph"; + "logseq list asset --graph my-graph --limit 20 --sort updated-at \ + --order desc"; + |]; + entry [| "upsert"; "block" |] + [| + "logseq upsert block --graph my-graph --target-page Home --content \ + \"New block\""; + "logseq upsert block --graph my-graph --id 123 --content \"Updated \ + content\""; + "logseq upsert block --graph my-graph --id 123 --target-page Home"; + "logseq upsert block --graph my-graph --target-page Meeting Notes \ + --content \"AI summary of the discussion\" --update-tags \ + '[\"AI-GENERATED\"]'"; + "logseq upsert block --graph my-graph --blocks '[{:block/title \ + \"A\"} {:block/title \"B\"}]'"; + |]; + entry [| "upsert"; "page" |] + [| + "logseq upsert page --graph my-graph --page Home --update-tags \ + '[\"project\"]'"; + "logseq upsert page --graph my-graph --id 999 --update-properties \ + '{:logseq.property/description \"Example\"}'"; + |]; + entry [| "upsert"; "task" |] + [| + "logseq upsert task --graph my-graph --content \"Ship release\" \ + --target-page Home --status todo --priority high --scheduled \ + \"2026-02-10T08:00:00.000Z\" --deadline \ + \"2026-02-12T18:00:00.000Z\""; + "logseq upsert task --graph my-graph --page Weekly Plan --status \ + doing"; + "logseq upsert task --graph my-graph --id 123 --no-status \ + --no-priority"; + |]; + entry [| "upsert"; "asset" |] + [| + "logseq upsert asset --graph my-graph --path ./assets/logo.png \ + --target-page Home"; + "logseq upsert asset --graph my-graph --id 123 --content \"Updated \ + asset title\""; + |]; + entry [| "upsert"; "tag" |] + [| + "logseq upsert tag --graph my-graph --name project"; + "logseq upsert tag --graph my-graph --id 200 --name Project Renamed"; + "logseq upsert tag --graph my-graph --name project --add-properties \ + '[\"status\" \"owner\"]'"; + |]; + entry [| "upsert"; "property" |] + [| + "logseq upsert property --graph my-graph --name status --type \ + default --cardinality one"; + "logseq upsert property --graph my-graph --id 321 --hide true"; + |]; + entry [| "remove"; "block" |] + [| + "logseq remove block --graph my-graph --id 123"; + "logseq remove block --graph my-graph --id '[123,456]'"; + "logseq remove block --graph my-graph --uuid \ + 7f0f4bb3-2e48-4b46-ae0f-18f52ef0f8be"; + |]; + entry [| "remove"; "page" |] + [| + "logseq remove page --graph my-graph --page Home"; + "logseq remove page --graph my-graph --id 123"; + |]; + entry [| "remove"; "tag" |] + [| "logseq remove tag --graph my-graph --name project" |]; + entry [| "remove"; "property" |] + [| + "logseq remove property --graph my-graph --name owner"; + "logseq remove property --graph my-graph --id 321"; + |]; + entry [| "query" |] + [| + "logseq query --graph my-graph --name recent-updated --inputs '[30]'"; + "logseq query --graph my-graph --name task-search --inputs \ + '[:logseq.property/status.done \"daily\"]'"; + "logseq query --graph my-graph --query '[:find [?e ...] :where [?e \ + :block/name]]'"; + |]; + entry [| "query"; "list" |] + [| + "logseq query list --graph my-graph"; + "logseq query list --graph my-graph --output edn"; + |]; + entry [| "search"; "block" |] + [| "logseq search block --content \"task\" --graph my-graph" |]; + entry [| "search"; "page" |] + [| "logseq search page --content \"home\" --graph my-graph" |]; + entry [| "search"; "property" |] + [| "logseq search property --content \"owner\" --graph my-graph" |]; + entry [| "search"; "tag" |] + [| "logseq search tag --content \"quote\" --graph my-graph" |]; + entry [| "show" |] + [| + "logseq show --graph my-graph --page Home"; + "logseq show --graph my-graph --page Foo --page-hierarchy true"; + "logseq show --graph my-graph --page \"Meeting Notes\" --level 2"; + "logseq show --graph my-graph --id 123 --level 3"; + "logseq show --graph my-graph --id '[123,456,789]'"; + "logseq show --graph my-graph --uuid \ + 11111111-1111-1111-1111-111111111111"; + |]; + entry [| "server"; "list" |] [| "logseq server list" |]; + entry [| "server"; "cleanup" |] [| "logseq server cleanup" |]; + entry [| "server"; "start" |] [| "logseq server start --graph my-graph" |]; + entry [| "server"; "stop" |] [| "logseq server stop --graph my-graph" |]; + entry [| "server"; "restart" |] + [| "logseq server restart --graph my-graph" |]; + entry [| "sync"; "status" |] [| "logseq sync status --graph my-graph" |]; + entry [| "sync"; "start" |] + [| + "logseq sync start --graph my-graph"; + "logseq sync start --graph my-graph --e2ee-password \"my-secret\""; + |]; + entry [| "sync"; "stop" |] [| "logseq sync stop --graph my-graph" |]; + entry [| "sync"; "upload" |] + [| + "logseq sync upload --graph my-graph"; + "logseq sync upload --graph my-graph --e2ee-password \"my-secret\""; + |]; + entry [| "sync"; "download" |] + [| + "logseq sync download --graph my-graph"; + "logseq sync download --graph my-graph --progress"; + "logseq sync download --graph my-graph --e2ee-password \"my-secret\""; + |]; + entry + [| "sync"; "asset"; "download" |] + [| + "logseq sync asset download --graph my-graph --id 123"; + "logseq sync asset download --graph my-graph --uuid "; + |]; + entry [| "sync"; "remote-graphs" |] [| "logseq sync remote-graphs" |]; + entry + [| "sync"; "ensure-keys" |] + [| + "logseq sync ensure-keys"; + "logseq sync ensure-keys --e2ee-password \"my-secret\" --upload-keys"; + |]; + entry + [| "sync"; "grant-access" |] + [| + "logseq sync grant-access --graph my-graph --graph-id \ + 8b6ecdd0-1fab-4a9f-b3fb-3069c5f76e95 --email teammate@example.com"; + |]; + entry + [| "sync"; "config"; "set" |] + [| "logseq sync config set sync-enabled true" |]; + entry + [| "sync"; "config"; "get" |] + [| "logseq sync config get sync-enabled" |]; + entry + [| "sync"; "config"; "unset" |] + [| "logseq sync config unset sync-enabled" |]; + |] + +let example_groups = + Vec.of_array + [| + "graph"; + "list"; + "upsert"; + "remove"; + "query"; + "search"; + "show"; + "server"; + "sync"; + |] + +let label path = Vec.string_concat " " path +let command_id _ = Command_id.Example +let validate_parsed _ = Ok () + +let registry_entries registry = + registry.Command_registry.commands + |> Vec.filter_map (fun (meta : Command_registry.command_meta) -> + match Vec.nth_opt meta.path 0 with + | Some group when Vec.mem group example_groups -> + Some { path = meta.path; examples = meta.examples } + | _ -> None) + +let entries_for registry = + let entries = registry_entries registry in + if Vec.is_empty entries then default_entries else entries + +let matching_entries entries selector = + if Vec.is_empty selector then + Vec.filter (fun entry -> not (Vec.is_empty entry.examples)) entries + else if Vec.length selector = 1 then + let group = Vec.nth selector 0 in + Vec.filter + (fun entry -> + match Vec.nth_opt entry.path 0 with + | Some first -> first = group + | None -> false) + entries + else + Vec.filter (fun entry -> Vec.equal String.equal entry.path selector) entries + +let resolve_selector registry selector = + match selector with + | _ -> + let entries = entries_for registry in + let matches = matching_entries entries selector in + let selector_label = + if Vec.is_empty selector then "all" else label selector + in + if Vec.is_empty matches then + Error + (Error.unknown_command + ("unknown example selector: " ^ selector_label)) + else + let missing = + matches + |> Vec.filter (fun entry -> Vec.is_empty entry.examples) + |> Vec.map (fun entry -> label entry.path) + in + if not (Vec.is_empty missing) then + Error + (Error.make Error.Missing_examples + ("missing examples metadata for: " + ^ Vec.string_concat ", " missing)) + else + let matched_commands = + Vec.map (fun entry -> label entry.path) matches + in + let examples = Vec.concat_map (fun entry -> entry.examples) matches in + let count = Vec.length examples in + Ok + { + selector = selector_label; + selector_path = selector; + matched_commands; + examples; + message = + "Found " + ^ Humanize_types.format_count count + ^ " " + ^ Humanize_types.pluralize_noun count "example" + ^ " for selector " ^ selector_label; + } + +let build ?registry _ _ = function + | Parsed_example { selector } -> + resolve_selector + (Option.value registry ~default:Command_registry.empty) + selector + +let value_of_action action = + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "selector", Edn_util.string action.selector); + ( Edn_util.keyword "matched-commands", + Edn_util.vector_vec + (action.matched_commands + |> Vec.map (fun command -> Edn_util.string command)) ); + ( Edn_util.keyword "examples", + Edn_util.vector_vec + (action.examples + |> Vec.map (fun example -> Edn_util.string example)) ); + (Edn_util.keyword "message", Edn_util.string action.message); + |]) + +let execute_with_mode action _config mode = + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Example mode + (Raw (value_of_action action))) + +let metadata () = + Vec.singleton + { + Command_registry.id = Command_id.Example; + path = Command_id.to_path Command_id.Example; + doc = "Show runnable command examples"; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Utilities; + requires_graph = Command_id.requires_graph Command_id.Example; + requires_auth = Command_id.requires_auth Command_id.Example; + write_command = Command_id.is_write Command_id.Example; + human_table_headers_order = Vec.empty; + } + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/format_types.ml b/cli/lib/format_types.ml new file mode 100644 index 0000000..291a053 --- /dev/null +++ b/cli/lib/format_types.ml @@ -0,0 +1,445 @@ +type formatter = Cli_result.t -> Cli_config.t -> string + +let normalize_json v = v + +let strip_leading_colon value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let unquote_transit_value = function + | Melange_edn_melange.Any + (Melange_edn_melange.Tagged (("transit/quote" | "'"), value)) -> + value + | value -> value + +let rec json_of_value value = + let value = unquote_transit_value value in + match + ( Edn_util.is_null value, + Edn_util.as_bool value, + Edn_util.as_int value, + Edn_util.as_float value, + Edn_util.as_string_like value, + Edn_util.as_bytes value, + Edn_util.as_seq value, + Edn_util.as_map value ) + with + | true, _, _, _, _, _, _, _ -> Js.Json.null + | _, Some b, _, _, _, _, _, _ -> Js.Json.boolean b + | _, _, Some i, _, _, _, _, _ -> Js.Json.number (float_of_int i) + | _, _, _, Some f, _, _, _, _ -> Js.Json.number f + | _, _, _, _, Some s, _, _, _ -> Js.Json.string (strip_leading_colon s) + | _, _, _, _, _, Some b, _, _ -> Js.Json.string (Bytes.to_string b) + | _, _, _, _, _, _, Some xs, _ -> + Js.Json.array (xs |> Vec.map json_of_value |> Vec.to_array) + | _, _, _, _, _, _, _, Some xs -> + let object_ = Js.Dict.empty () in + Vec.iter + (fun (k, v) -> + let key = + match Edn_util.as_string_like k with + | Some s -> strip_leading_colon s + | None -> Melange_edn_melange.to_edn_string k + in + Js.Dict.set object_ key (json_of_value v)) + xs; + Js.Json.object_ object_ + | _ -> Melange_edn_melange.to_json value + +let data_to_value = function + | Cli_result.Message s -> + Edn_util.map_vec + (Vec.of_array [| (Edn_util.keyword "message", Edn_util.string s) |]) + | Items xs -> Edn_util.vector_vec xs + | Entity v | Query_result v | Raw v -> v + | Empty -> Edn_util.nil + +let contains_substring ~needle text = + let needle_len = String.length needle in + let text_len = String.length text in + let rec loop index = + index + needle_len <= text_len + && (String.sub text index needle_len = needle || loop (index + 1)) + in + loop 0 + +let sensitive_field key = + let key = String.lowercase_ascii (strip_leading_colon key) in + Vec.exists + (fun needle -> contains_substring ~needle key) + (Vec.of_array [| "token"; "secret"; "password" |]) + +let rec redact_sensitive_value value = + match Edn_util.as_map value with + | Some fields -> + fields + |> Vec.map (fun (key, value) -> + let value = + match Edn_util.as_string_like key with + | Some key when sensitive_field key -> Edn_util.string "[REDACTED]" + | _ -> redact_sensitive_value value + in + (key, value)) + |> Edn_util.map_vec + | None -> ( + match Edn_util.as_seq value with + | Some values -> + Edn_util.vector_vec (values |> Vec.map redact_sensitive_value) + | None -> value) + +let result_data_value result = + let data = + match result.Cli_result.data with + | Some data -> data_to_value data + | None -> Edn_util.nil + in + match result.Cli_result.command with + | Some Command_id.Graph_info -> redact_sensitive_value data + | _ -> data + +let candidate_to_value (candidate : Error.candidate) = + let fields = + match candidate.name with + | Some name -> Vec.singleton (Edn_util.keyword "name", Edn_util.string name) + | None -> Vec.empty + in + let fields = + match candidate.id with + | Some id -> Vec.push_front fields (Edn_util.keyword "id", Edn_util.int64 id) + | None -> fields + in + Edn_util.map_vec fields + +let error_to_value ?(edn = false) (error : Error.t) = + let code_value = + if edn then Edn_util.any (Error.code_to_keyword error.code) + else Edn_util.string (Error.code_to_string error.code) + in + let fields = + Vec.of_array + [| + (Edn_util.keyword "code", code_value); + (Edn_util.keyword "message", Edn_util.string error.message); + |] + in + let fields = + match error.hint with + | None -> fields + | Some hint -> + Vec.push_back fields (Edn_util.keyword "hint", Edn_util.string hint) + in + let fields = + if Vec.is_empty error.candidates then fields + else + let candidates = error.candidates in + Vec.push_back fields + ( Edn_util.keyword "candidates", + Edn_util.vector_vec (candidates |> Vec.map candidate_to_value) ) + in + let fields = + match error.context with + | None -> fields + | Some context -> ( + match Edn_util.as_map context with + | Some context_fields -> + let reserved = + Vec.of_array [| "code"; "message"; "hint"; "candidates" |] + in + Vec.append fields + (context_fields + |> Vec.filter (fun (key, _) -> + match Edn_util.as_string_like key with + | Some key -> not (Vec.mem (strip_leading_colon key) reserved) + | None -> true)) + | None -> Vec.push_back fields (Edn_util.keyword "context", context)) + in + Edn_util.map_vec fields + +let to_json result = + let object_ = Js.Dict.empty () in + match result.Cli_result.status with + | Ok -> + let data = result_data_value result in + Js.Dict.set object_ "status" (Js.Json.string "ok"); + Js.Dict.set object_ "data" (json_of_value data); + Js.Json.stringify (Js.Json.object_ object_) + | Error -> + let error = + match result.error with + | Some e -> error_to_value e + | None -> Edn_util.map_vec Vec.empty + in + Js.Dict.set object_ "status" (Js.Json.string "error"); + Js.Dict.set object_ "error" (json_of_value error); + Js.Json.stringify (Js.Json.object_ object_) + +let to_edn result = + let status, payload_key, payload = + match result.Cli_result.status with + | Ok -> + let data = result_data_value result in + (Edn_util.keyword "ok", Edn_util.keyword "data", data) + | Error -> + let error = + match result.error with + | Some e -> error_to_value ~edn:true e + | None -> Edn_util.map_vec Vec.empty + in + (Edn_util.keyword "error", Edn_util.keyword "error", error) + in + Melange_edn_melange.to_edn_string + (Edn_util.map_vec + (Vec.of_array + [| (Edn_util.keyword "status", status); (payload_key, payload) |])) + +let render_human table = Output.Human_output.to_string table +let count_footer count = "Count: " ^ Humanize_types.format_count count + +let field_label key = + key |> Edn_util.as_string_like + |> Option.value ~default:(Melange_edn_melange.to_edn_string key) + |> strip_leading_colon + +let value_text value = + let value = unquote_transit_value value in + match + ( Edn_util.is_null value, + Edn_util.as_string_like value, + Edn_util.as_int value, + Edn_util.as_bool value, + Edn_util.as_float value ) + with + | true, _, _, _, _ -> "" + | _, Some value, _, _, _ -> strip_leading_colon value + | _, _, Some value, _, _ -> string_of_int value + | _, _, _, Some value, _ -> string_of_bool value + | _, _, _, _, Some value -> string_of_float value + | _ -> Melange_edn_melange.to_edn_string value + +let query_result_human value = + match Edn_util.get value "result" with + | Some result -> ( + match Edn_util.as_seq result with + | Some values -> values |> Vec.map value_text |> Vec.string_concat "\n" + | None -> value_text result) + | None -> value_text value + +let is_keyword_namespace_char = function + | 'a' .. 'z' | '0' .. '9' | '.' | '-' | '_' -> true + | _ -> false + +let is_keyword_namespace value = + value <> "" && String.for_all is_keyword_namespace_char value + +let human_header_label label = + let label = strip_leading_colon label in + let parts = Vec.split_on_char '/' label in + if Vec.length parts = 2 then + let namespace = Vec.nth parts 0 in + let name = Vec.nth parts 1 in + if is_keyword_namespace namespace && name <> "" then name else label + else label + +let add_column acc label = + if Vec.mem label acc then acc else Vec.push_back acc label + +let discovered_table_columns items = + items + |> Vec.fold_left + (fun acc fields -> + fields + |> Vec.map (fun (key, _) -> field_label key) + |> Vec.fold_left add_column acc) + Vec.empty + +let order_table_columns header_order columns = + let ordered = + header_order + |> Vec.filter_map (fun expected -> + Vec.find_opt + (fun label -> label = expected || human_header_label label = expected) + columns) + in + let ordered = + Vec.fold_left + (fun acc label -> + if Vec.mem label acc then acc else Vec.push_back acc label) + Vec.empty ordered + in + ordered + +let field_by_label label fields = + fields + |> Vec.find_map (fun (key, value) -> + if field_label key = label then Some value else None) + +let is_title_label label = + match String.lowercase_ascii label with + | "title" | "block/title" -> true + | _ -> false + +let title_max_display_width config = + if config.Cli_config.list_title_max_display_width > 0 then + config.list_title_max_display_width + else (Cli_config.defaults ()).list_title_max_display_width + +let truncate_title_cell max_width value = + let first_line = Vec.split_on_char '\n' value |> Vec.peek_front in + Display_width.truncate first_line max_width + +let is_list_command = function + | Command_id.List_page | List_tag | List_property | List_task | List_node + | List_asset -> + true + | _ -> false + +let is_search_command = function + | Command_id.Search_block | Search_page | Search_property | Search_tag -> true + | _ -> false + +let is_list_or_search_command command = + is_list_command command || is_search_command command + +let table_columns ~human_table_headers_order items = + discovered_table_columns items + |> order_table_columns human_table_headers_order + +let should_truncate_title command label = + is_list_command command && is_title_label label + +let is_task_keyword_value_label label = + match human_header_label label with + | "status" | "priority" -> true + | _ -> false + +let list_cell_value_text command label value = + let value = unquote_transit_value value in + match command with + | Command_id.List_task when is_task_keyword_value_label label -> ( + match Edn_util.as_keyword value with + | Some keyword -> human_header_label keyword + | None -> value_text value) + | _ -> value_text value + +let list_search_human ?(human_table_headers_order = Vec.empty) command value + config = + match Option.bind (Edn_util.get value "items") Edn_util.as_seq with + | None -> None + | Some items -> + let mapped = Vec.filter_map Edn_util.as_map items in + if Vec.length mapped <> Vec.length items then None + else + let title_width = title_max_display_width config in + let columns = table_columns ~human_table_headers_order mapped in + let headers = columns in + let rows = + mapped + |> Vec.map (fun fields -> + columns + |> Vec.map (fun label -> + let value = + fields |> field_by_label label + |> Option.map (list_cell_value_text command label) + |> Option.value ~default:"" + in + if should_truncate_title command label then + truncate_title_cell title_width value + else value)) + in + Some + (render_human + (Output.Human_output.create ~headers + ~footer:(count_footer (Vec.length rows)) + ~rows ())) + +let graph_list_human value config = + match Edn_util.as_map value with + | Some fields -> ( + let configured_graph = + config.Cli_config.graph |> Option.map Cli_primitive.string_of_graph + in + let current_graph = + match Vec.assoc_opt (Edn_util.keyword "current-graph") fields with + | Some value -> ( + match Edn_util.as_string value with + | Some _ as graph -> graph + | None -> configured_graph) + | None -> configured_graph + in + match Vec.assoc_opt (Edn_util.keyword "graphs") fields with + | Some graphs_value -> ( + match Edn_util.as_vector graphs_value with + | Some graphs -> + let rows = + Vec.map + (fun graph -> + let graph = + Option.value (Edn_util.as_string graph) + ~default:(Melange_edn_melange.to_edn_string graph) + in + let prefix = + if Option.equal String.equal current_graph (Some graph) + then "* " + else if Option.is_some current_graph then " " + else "" + in + Vec.singleton (prefix ^ graph)) + graphs + in + render_human + (Output.Human_output.create + ~footer:(count_footer (Vec.length rows)) + ~rows ()) + | None -> "") + | None -> "") + | None -> "" + +let to_human ?human_table_headers_order result config = + let human_table_headers_order = + Option.value human_table_headers_order + ~default:result.Cli_result.human_table_headers_order + in + let output = result.Cli_result.output Output.Mode.Human in + match (result.Cli_result.command, result.data, output) with + | Some Command_id.Graph_list, Some (Cli_result.Raw value), Output.Human _ -> + graph_list_human value config + | Some Command_id.Query, Some (Cli_result.Query_result value), Output.Human _ + -> + query_result_human value + | Some command, Some (Cli_result.Raw value), Output.Human _ + when is_list_or_search_command command -> ( + match + list_search_human ~human_table_headers_order command value config + with + | Some output -> output + | None -> ( + match output with + | Output.Human human -> render_human human + | Output.Json _ | Output.Edn _ -> "")) + | _, _, Output.Human human -> render_human human + | _, _, (Output.Json _ | Output.Edn _) -> "" + +let format_error err _ = + "Error (" ^ Error.code_to_string err.Error.code ^ "): " ^ err.message + +let format_result ?human_table_headers_order result config = + match (result.Cli_result.command, result.data) with + | Some Command_id.Skill_show, Some (Cli_result.Message message) -> message + | _ -> ( + match config.Cli_config.output_format with + | Some (Output.Mode.Packed Output.Mode.Json) -> to_json result + | Some (Output.Mode.Packed Output.Mode.Edn) -> to_edn result + | Some (Output.Mode.Packed Output.Mode.Human) | None -> ( + match result.Cli_result.status with + | Ok -> to_human ?human_table_headers_order result config + | Error -> ( + match result.error with + | Some err -> format_error err result.command + | None -> ""))) + +let format_counted_table ~headers ~rows = + render_human + (Output.Human_output.create ~headers + ~footer:("Count: " ^ Humanize_types.format_count (Vec.length rows)) + ~rows ()) diff --git a/cli/lib/global_opts.ml b/cli/lib/global_opts.ml new file mode 100644 index 0000000..8a77c07 --- /dev/null +++ b/cli/lib/global_opts.ml @@ -0,0 +1,21 @@ +type t = { + graph : Cli_primitive.graph option; + root_dir : Cli_primitive.path option; + config_path : Cli_primitive.path option; + timeout_span : float option; + output_format : Output.Mode.packed option; + verbose : bool; + profile : bool; +} + +let create ?graph ?root_dir ?config_path ?timeout_span ?output_format + ?(verbose = false) ?(profile = false) () = + { + graph; + root_dir; + config_path; + timeout_span; + output_format; + verbose; + profile; + } diff --git a/cli/lib/graph.ml b/cli/lib/graph.ml new file mode 100644 index 0000000..ad2c2b7 --- /dev/null +++ b/cli/lib/graph.ml @@ -0,0 +1,1302 @@ +type export_type = Edn | Sqlite +type import_type = Import_edn | Import_sqlite +type create_opts = { enable_sync : bool; e2ee_password : string option } +type validate_opts = { fix : bool } +type backup_create_opts = { name : string option } +type backup_restore_opts = { src : string; dst : Cli_primitive.graph } +type backup_remove_opts = { src : string } + +type export_opts = { + export_type : export_type; + file : Cli_primitive.path option; + edn_options : Melange_edn_melange.any option; + pretty_print : bool; + include_timestamps : bool; + exclude_built_in_pages : bool; + exclude_namespaces : string Rrbvec.t; +} + +type import_opts = { import_type : import_type; input : Cli_primitive.path } + +type parsed = + | Parsed_list + | Parsed_create of create_opts + | Parsed_switch + | Parsed_remove + | Parsed_validate of validate_opts + | Parsed_info + | Parsed_backup_list + | Parsed_backup_create of backup_create_opts + | Parsed_backup_restore of backup_restore_opts + | Parsed_backup_remove of backup_remove_opts + | Parsed_export of export_opts + | Parsed_import of import_opts + +type action = + | Graph_list + | Graph_create of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + opts : create_opts; + } + | Graph_switch of { graph : Cli_primitive.graph; repo : Cli_primitive.repo } + | Graph_remove of { graph : Cli_primitive.graph; repo : Cli_primitive.repo } + | Graph_validate of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + fix : bool; + } + | Graph_info of { graph : Cli_primitive.graph; repo : Cli_primitive.repo } + | Graph_backup_list of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + } + | Graph_backup_create of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + name : string option; + backup_name : string option; + } + | Graph_backup_restore of { + source_repo : Cli_primitive.repo; + source_graph : Cli_primitive.graph; + dst_repo : Cli_primitive.repo; + dst_graph : Cli_primitive.graph; + src : string; + dst : string; + } + | Graph_backup_remove of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + src : string; + } + | Graph_export of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + opts : export_opts; + } + | Graph_import of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + opts : import_opts; + require_missing_graph : bool; + } + +let normalize_export_type = function + | "edn" -> Some Edn + | "sqlite" -> Some Sqlite + | _ -> None + +let normalize_import_type = function + | "edn" -> Some Import_edn + | "sqlite" -> Some Import_sqlite + | _ -> None + +let string_of_import_type = function + | Import_edn -> "edn" + | Import_sqlite -> "sqlite" + +let command_id = function + | Parsed_list -> Command_id.Graph_list + | Parsed_create _ -> Graph_create + | Parsed_switch -> Graph_switch + | Parsed_remove -> Graph_remove + | Parsed_validate _ -> Graph_validate + | Parsed_info -> Graph_info + | Parsed_backup_list -> Graph_backup_list + | Parsed_backup_create _ -> Graph_backup_create + | Parsed_backup_restore _ -> Graph_backup_restore + | Parsed_backup_remove _ -> Graph_backup_remove + | Parsed_export _ -> Graph_export + | Parsed_import _ -> Graph_import + +let validate_parsed = function + | Parsed_create opts + when Option.is_some opts.e2ee_password && not opts.enable_sync -> + Error (Error.invalid_options "--e2ee-password requires --enable-sync") + | Parsed_export opts + when opts.export_type = Sqlite + && (Option.is_some opts.edn_options || opts.pretty_print) -> + Error + (Error.invalid_options + "graph export --type sqlite does not accept --edn-options or \ + --pretty-print") + | Parsed_export opts + when Option.is_some opts.edn_options + && Option.is_none (Option.bind opts.edn_options Edn_util.as_map) -> + Error + (Error.invalid_options "graph export --edn-options must be an EDN map") + | _ -> Ok () + +let utc_timestamp () = + let year, month, day, hour, minute, second = + Time.utc_date_time (Time.now ()) + in + Printf.sprintf "%04d%02d%02dT%02d%02d%02dZ" year month day hour minute second + +let safe_backup_part value = + let buffer = Buffer.create (String.length value) in + String.iter + (function + | '/' | '\\' | ':' -> Buffer.add_char buffer '-' + | c -> Buffer.add_char buffer c) + value; + Buffer.contents buffer + +let build_backup_name repo label = + let graph = + safe_backup_part + (Cli_primitive.string_of_graph (Cli_config.repo_to_graph repo)) + in + let timestamp = utc_timestamp () in + match label with + | Some label when String.trim label <> "" -> + graph ^ "-" ^ safe_backup_part (String.trim label) ^ "-" ^ timestamp + | _ -> graph ^ "-" ^ timestamp + +let tmp_sqlite_counter = ref 0 + +let tmp_sqlite_path dir = + incr tmp_sqlite_counter; + let stamp = + int_of_float (Time.time_to_epoch_seconds_float (Time.now ()) *. 1_000_000.) + in + Filename.concat dir + ("db." ^ string_of_int stamp ^ "." + ^ string_of_int !tmp_sqlite_counter + ^ ".tmp.sqlite") + +let validate_graph_name graph = + let graph_name = graph |> Cli_primitive.string_of_graph |> String.trim in + if graph_name = "" || graph_name = "." || graph_name = ".." then + Error (Error.invalid_options "graph name must not be empty, '.', or '..'") + else Ok (Cli_primitive.create_graph graph_name) + +let graph_and_repo graph = + Error.bind (validate_graph_name graph) (fun graph -> + Ok (graph, Cli_config.graph_to_repo graph)) + +let optional_graph_and_repo graph = + match graph with + | None -> Ok (None, None) + | Some graph -> + Error.bind (graph_and_repo graph) (fun (graph, repo) -> + Ok (Some graph, Some repo)) + +let explicit_graph_and_repo globals = + optional_graph_and_repo globals.Global_opts.graph + +let build ?registry:_ config globals parsed = + Error.bind (validate_parsed parsed) (fun () -> + Error.bind (explicit_graph_and_repo globals) + (fun (explicit_graph, explicit_repo) -> + Error.bind + (optional_graph_and_repo (Cli_config.pick_graph config globals)) + (fun (selected_graph, selected_repo) -> + match parsed with + | Parsed_list -> Ok Graph_list + | Parsed_create opts -> ( + match (explicit_graph, explicit_repo) with + | Some graph, Some repo -> + Ok (Graph_create { graph; repo; opts }) + | _ -> Error (Error.missing_graph ())) + | Parsed_switch -> ( + match (explicit_graph, explicit_repo) with + | Some graph, Some repo -> Ok (Graph_switch { graph; repo }) + | _ -> Error (Error.missing_graph ())) + | Parsed_remove -> ( + match (explicit_graph, explicit_repo) with + | Some graph, Some repo -> Ok (Graph_remove { graph; repo }) + | _ -> Error (Error.missing_graph ())) + | Parsed_validate opts -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> + Ok (Graph_validate { graph; repo; fix = opts.fix }) + | _ -> Error (Error.missing_graph ())) + | Parsed_info -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> Ok (Graph_info { graph; repo }) + | _ -> Error (Error.missing_graph ())) + | Parsed_backup_list -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> + Ok (Graph_backup_list { graph; repo }) + | _ -> + Error + (Error.missing_repo "repo is required for backup list")) + | Parsed_backup_create opts -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> + let name = + Option.bind opts.name (fun value -> + let value = String.trim value in + if value = "" then None else Some value) + in + Ok + (Graph_backup_create + { + graph; + repo; + name; + backup_name = Some (build_backup_name repo name); + }) + | _ -> + Error + (Error.missing_repo "repo is required for backup create") + ) + | Parsed_backup_restore opts -> ( + match (selected_graph, selected_repo) with + | Some source_graph, Some source_repo -> + let src = String.trim opts.src in + let dst = + String.trim (Cli_primitive.string_of_graph opts.dst) + in + if dst = "" then + Error + (Error.make Error.Missing_dst + "destination graph name is required") + else + let dst_graph = Cli_primitive.create_graph dst in + let dst_repo = Cli_config.graph_to_repo dst_graph in + Ok + (Graph_backup_restore + { + source_repo; + source_graph; + dst_repo; + dst_graph; + src; + dst; + }) + | _ -> + Error + (Error.missing_repo + "repo is required for backup restore")) + | Parsed_backup_remove opts -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> + Ok + (Graph_backup_remove + { graph; repo; src = String.trim opts.src }) + | _ -> + Error + (Error.missing_repo "repo is required for backup remove") + ) + | Parsed_export opts -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> ( + match (opts.export_type, opts.file) with + | Edn, None -> + Error + (Error.invalid_options + "graph export --type edn requires --file") + | Sqlite, _ -> Ok (Graph_export { graph; repo; opts }) + | Edn, Some _ -> Ok (Graph_export { graph; repo; opts })) + | _ -> + Error (Error.missing_repo "repo is required for export")) + | Parsed_import opts -> ( + match (selected_graph, selected_repo) with + | Some graph, Some repo -> + Ok + (Graph_import + { + graph; + repo; + opts; + require_missing_graph = + opts.import_type = Import_sqlite; + }) + | _ -> + Error (Error.missing_repo "repo is required for import"))))) + +let graphs_dir config = Filename.concat config.Cli_config.root_dir "graphs" + +let rec ensure_dir path = + if Cli_unix.file_exists path then () + else + let parent = Filename.dirname path in + if parent <> path then ensure_dir parent; + Cli_unix.mkdir path 0o755 + +let rec remove_tree path = + if Cli_unix.file_exists path then + if Cli_unix.is_directory path then ( + Cli_unix.readdir path + |> Array.iter (fun name -> remove_tree (Filename.concat path name)); + Cli_unix.rmdir path) + else Cli_unix.remove_tree path + +let write_file path content = Cli_unix.write_text_file path content + +let graph_path config graph = + Filename.concat (graphs_dir config) + (Graph_dir.encode_graph_dir_name (Cli_primitive.string_of_graph graph)) + +let graph_exists config graph = + Cli_unix.file_exists (graph_path config graph) + && Cli_unix.is_directory (graph_path config graph) + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let contains_substring ~needle text = + let needle_len = String.length needle in + let text_len = String.length text in + let rec loop index = + index + needle_len <= text_len + && (String.sub text index needle_len = needle || loop (index + 1)) + in + loop 0 + +let decode_graph_dir_name = Graph_dir.decode_graph_dir_name + +let legacy_derivation_signal dir_name = + contains_substring ~needle:"++" dir_name + || contains_substring ~needle:"+3A+" dir_name + || contains_substring ~needle:"%" dir_name + +let decode_legacy_graph_dir_name dir_name = + if not (legacy_derivation_signal dir_name) then None + else Graph_dir.decode_legacy_graph_dir_name dir_name + +let ignored_graph_dir name = + name = "Unlinked graphs" || name = "backup" + || starts_with ~prefix:"file-version-" name + +let canonical_dir_name dir_name graph_name = + dir_name = Graph_dir.encode_graph_dir_name graph_name + +let canonical_graph_name graph = + if graph <> "" && not (starts_with ~prefix:"logseq_db_" graph) then Some graph + else None + +let classify_graph_dir graphs_root dir_name = + if ignored_graph_dir dir_name then None + else + let decoded_canonical = + Option.bind (decode_graph_dir_name dir_name) canonical_graph_name + in + match decoded_canonical with + | Some graph_name when canonical_dir_name dir_name graph_name -> + Some + (Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "kind", Edn_util.keyword "canonical"); + (Edn_util.keyword "graph-name", Edn_util.string graph_name); + (Edn_util.keyword "graph-dir", Edn_util.string dir_name); + |])) + | _ -> ( + let legacy_graph_name = + match decoded_canonical with + | Some graph_name -> Some graph_name + | None -> + Option.bind + (decode_legacy_graph_dir_name dir_name) + canonical_graph_name + in + match legacy_graph_name with + | Some graph_name -> + let target_graph_dir = Graph_dir.encode_graph_dir_name graph_name in + Some + (Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "kind", Edn_util.keyword "legacy"); + (Edn_util.keyword "legacy-dir", Edn_util.string dir_name); + (Edn_util.keyword "graph-name", Edn_util.string graph_name); + ( Edn_util.keyword "target-graph-dir", + Edn_util.string target_graph_dir ); + ( Edn_util.keyword "conflict", + Edn_util.bool + (target_graph_dir <> dir_name + && Cli_unix.file_exists + (Filename.concat graphs_root target_graph_dir)) + ); + |])) + | None -> + if legacy_derivation_signal dir_name then + Some + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "kind", + Edn_util.keyword "legacy-undecodable" ); + (Edn_util.keyword "legacy-dir", Edn_util.string dir_name); + ( Edn_util.keyword "reason", + Edn_util.keyword "graph-name-not-derivable" ); + |])) + else None) + +let graph_name_of_canonical_item value = + match Edn_util.as_map value with + | Some fields -> ( + match + ( Vec.assoc_opt (Edn_util.keyword "kind") fields, + Vec.assoc_opt (Edn_util.keyword "graph-name") fields ) + with + | Some kind, Some graph -> ( + match (Edn_util.as_keyword kind, Edn_util.as_string graph) with + | Some "canonical", Some graph -> Some graph + | _ -> None) + | _ -> None) + | None -> None + +let graph_list_value graph_items = + let graphs = Vec.filter_map graph_name_of_canonical_item graph_items in + Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "graphs", + Edn_util.vector_vec + (graphs |> Vec.map (fun graph -> Edn_util.string graph)) ); + (Edn_util.keyword "graph-items", Edn_util.vector_vec graph_items); + |]) + +let list_graph_items config = + let dir = graphs_dir config in + if Cli_unix.file_exists dir then + Cli_unix.readdir dir |> Vec.of_array + |> Vec.filter (fun name -> Cli_unix.is_directory (Filename.concat dir name)) + |> Vec.sort_uniq String.compare + |> Vec.filter_map (classify_graph_dir dir) + else Vec.empty + +let backup_root_path config graph = + Filename.concat (graph_path config graph) "backup" + +let backup_dir_path config graph name = + Filename.concat (backup_root_path config graph) name + +let backup_db_path backup_dir = Filename.concat backup_dir "db.sqlite" +let backup_metadata_path backup_dir = Filename.concat backup_dir "metadata.edn" +let kw name = Edn_util.keyword name +let string value = Edn_util.string value +let bool value = Edn_util.bool value + +let export_format = function + | Edn -> Edn_util.keyword_t "edn" + | Sqlite -> Edn_util.keyword_t "sqlite" + +let import_format = function + | Import_edn -> Edn_util.keyword_t "edn" + | Import_sqlite -> Edn_util.keyword_t "sqlite" + +let default_sqlite_export_path config repo = + let export_root = + Filename.concat (graph_path config (Cli_config.repo_to_graph repo)) "export" + in + ensure_dir export_root; + let timestamp_seconds = + int_of_float (Time.time_to_epoch_seconds_float (Time.now ())) + in + let graph_name = + Cli_config.repo_to_graph repo |> Cli_primitive.string_of_graph + in + Filename.concat export_root + (graph_name ^ "_" ^ string_of_int timestamp_seconds ^ ".sqlite") + +let export_payload opts = + let edn_option_fields = + match opts.edn_options with + | Some value -> Option.value (Edn_util.as_map value) ~default:Vec.empty + | None -> Vec.empty + in + let export_type = + Option.value + (Vec.find_map + (fun (key, value) -> + if Edn_util.key_matches "export-type" key then Some value else None) + edn_option_fields) + ~default:(kw "graph") + in + let graph_options = + edn_option_fields + |> Vec.filter (fun (key, _) -> not (Edn_util.key_matches "export-type" key)) + |> Edn_util.map_vec + in + let fields = ref (Vec.singleton (kw "export-type", export_type)) in + (match graph_options with + | value + when Option.value + (Option.map Vec.is_empty (Edn_util.as_map value)) + ~default:false -> + () + | _ -> fields := Vec.push_back !fields (kw "graph-options", graph_options)); + Edn_util.map_vec !fields + +let write_pretty_edn path data = + try + Cli_unix.write_text_file path + Ustring.(of_string (Pretty_print.pprint_edn data) |> to_string); + Cli_effect.pure (Ok ()) + with exn -> Cli_effect.pure (Error (Error.exception_error exn)) + +let graph_export_message mode _config path = + Cli_result.ok ~command:Command_id.Graph_export mode + (Message ("wrote " ^ path)) + +let graph_import_message mode _config graph opts new_graph = + let graph = Cli_primitive.string_of_graph graph in + let import_type = string_of_import_type opts.import_type in + let message = + (if new_graph then "Created graph " ^ graph ^ "\n" else "") + ^ "Imported " ^ import_type ^ " from " ^ opts.input + in + Cli_result.ok ~command:Command_id.Graph_import mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + (kw "new-graph?", bool new_graph); (kw "message", string message); + |]))) + +let validation_errors value = + match Edn_util.as_map value with + | Some _ -> ( + match Option.bind (Edn_util.get value "errors") Edn_util.as_seq with + | Some errors -> errors + | None -> Vec.empty) + | None -> Vec.empty + +let format_count count noun = Humanize_types.format_count_with_noun count noun + +let graph_validate_result mode _config result = + let errors = validation_errors result in + if Vec.is_empty errors then + Cli_result.ok ~command:Command_id.Graph_validate mode + (Raw (Edn_util.map_vec (Vec.of_array [| (kw "result", result) |]))) + else + let count = Vec.length errors in + Cli_result.error ~command:Command_id.Graph_validate mode + (Error.make Error.Graph_validation_failed + ("Graph invalid. Found " + ^ format_count count "entity" + ^ " with errors:\n" + ^ Melange_edn_melange.to_edn_string (Edn_util.vector_vec errors))) + +let sym name = Edn_util.symbol name +let vector_t_vec values = Edn_util.vector_t_vec values +let list_vec values = Edn_util.list_vec values + +let graph_info_query = + Cli_primitive.( + make_datascript_query + ~find:(Vec.of_array [| sym "?ident"; sym "?value" |]) + ~where: + (Vec.of_array + [| + V + (vector_t_vec + (Vec.of_array [| sym "?e"; kw "db/ident"; sym "?ident" |])); + V + (vector_t_vec + (Vec.of_array + [| + list_vec + (Vec.of_array [| sym "namespace"; sym "?ident" |]); + sym "?ns"; + |])); + V + (vector_t_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| sym "="; string "logseq.kv"; sym "?ns" |]); + |])); + V + (vector_t_vec + (Vec.of_array [| sym "?e"; kw "kv/value"; sym "?value" |])); + |]) + () + |> datascript_query_to_edn) + +let graph_info_key value = + match Edn_util.as_keyword value with + | Some value -> value + | None -> ( + match Edn_util.as_symbol value with + | Some value -> value + | None -> ( + match Edn_util.as_string value with + | Some value -> value + | None -> Melange_edn_melange.to_edn_string value)) + +let graph_info_kv rows = + let row_fields row = + match (Edn_util.as_vector row, Edn_util.as_list row) with + | Some values, _ when Vec.length values = 2 -> + Some + (Edn_util.string (graph_info_key (Vec.nth values 0)), Vec.nth values 1) + | _, Some values when Vec.length values = 2 -> + Some + (Edn_util.string (graph_info_key (Vec.nth values 0)), Vec.nth values 1) + | _ -> None + in + rows |> Vec.filter_map row_fields + +let parse_positive_int64 text = + let text = String.trim text in + if text = "" then None else Int64.of_string_opt text + +let graph_info_timestamp_seconds value = + let timestamp = + match Edn_util.as_int64 value with + | Some value -> Some value + | None -> parse_positive_int64 (Melange_edn_melange.to_edn_string value) + in + timestamp + |> Option.map (fun timestamp -> + if Int64.compare timestamp 100_000_000_000L >= 0 then + Int64.div timestamp 1_000L + else timestamp) + +let graph_info_datetime_value : type a. + a Output.Mode.t -> Melange_edn_melange.any -> Melange_edn_melange.any = + fun mode value -> + match (mode, graph_info_timestamp_seconds value) with + | Output.Mode.Human, Some then_seconds -> + Edn_util.string + (Humanize_types.datetime + ~now:(Time.time_to_epoch_seconds (Time.now ())) + then_seconds) + | _ -> value + +let graph_info_result mode _config graph rows = + let graph = Cli_primitive.string_of_graph graph in + let kv = graph_info_kv rows in + let kv_value key = Vec.assoc_opt (Edn_util.string key) kv in + let graph_created_at = + Option.value + (kv_value "logseq.kv/graph-created-at" + |> Option.map (graph_info_datetime_value mode)) + ~default:Edn_util.nil + in + let fields = + [ + (kw "graph", string graph); + (kw "logseq.kv/graph-created-at", graph_created_at); + ( kw "logseq.kv/schema-version", + Option.value (kv_value "logseq.kv/schema-version") ~default:Edn_util.nil + ); + (kw "kv", Edn_util.map_vec kv); + ] + in + Cli_result.ok ~command:Command_id.Graph_info mode (Raw (Edn_util.map fields)) + +let read_file_opt path = + if not (Cli_unix.file_exists path) then None + else Some (Cli_unix.read_text_file path) + +let strip_leading_colon value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let metadata_source backup_dir = + match read_file_opt (backup_metadata_path backup_dir) with + | Some text -> ( + try + let metadata = Melange_edn_melange.of_edn_string text in + Option.bind (Edn_util.get metadata "source") (fun value -> + Edn_util.as_string_like value) + |> Option.map strip_leading_colon + with _ -> None) + | _ -> None + +let backup_entry root name = + let dir = Filename.concat root name in + let db_path = backup_db_path dir in + if + Cli_unix.file_exists dir && Cli_unix.is_directory dir + && Cli_unix.file_exists db_path + then + let stat = Cli_unix.stat db_path in + let fields = + Vec.of_array + [| + (kw "name", string name); + (kw "created-at", Edn_util.float stat.Cli_unix.st_mtime); + (kw "size-bytes", Edn_util.int stat.Cli_unix.st_size); + |] + in + let fields = + match metadata_source dir with + | Some source -> Vec.push_back fields (kw "source", string source) + | None -> fields + in + Some (Edn_util.map_vec fields) + else None + +let list_backups config graph = + let root = backup_root_path config graph in + if not (Cli_unix.file_exists root) then Vec.empty + else + Cli_unix.readdir root |> Vec.of_array |> Vec.sort String.compare + |> Vec.filter_map (backup_entry root) + +let graph_backup_list_result mode config graph = + Cli_result.ok ~command:Command_id.Graph_backup_list mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + (kw "backups", Edn_util.vector_vec (list_backups config graph)); + |]))) + +let graph_backup_remove_result mode config graph src = + let dir = backup_dir_path config graph src in + if Cli_unix.file_exists dir then ( + remove_tree dir; + Cli_result.ok ~command:Command_id.Graph_backup_remove mode + (Message ("Removed backup " ^ src))) + else + Cli_result.error ~command:Command_id.Graph_backup_remove mode + (Error.make Error.Backup_not_found ("backup not found: " ^ src)) + +let unlink_graph_dir config graph repo = + let graphs_root = graphs_dir config in + let repo_name = Cli_primitive.string_of_repo repo in + let graph_name = Cli_primitive.string_of_graph graph in + let repo_path = Filename.concat graphs_root repo_name in + let graph_dir_name = Graph_dir.encode_graph_dir_name graph_name in + let graph_path = Filename.concat graphs_root graph_dir_name in + let source = + if Cli_unix.file_exists repo_path && Cli_unix.is_directory repo_path then + Some (repo_name, repo_path) + else if Cli_unix.file_exists graph_path && Cli_unix.is_directory graph_path + then Some (graph_dir_name, graph_path) + else None + in + match source with + | None -> None + | Some (dir_name, source_path) -> + let unlinked_root = Filename.concat graphs_root "Unlinked graphs" in + ensure_dir unlinked_root; + let target suffix = + let name = + if suffix = 0 then dir_name else dir_name ^ "-" ^ string_of_int suffix + in + Filename.concat unlinked_root name + in + let rec reserve suffix = + let path = target suffix in + if Cli_unix.file_exists path then reserve (suffix + 1) else path + in + let destination = reserve 0 in + Cli_unix.rename source_path destination; + Some destination + +let reserve_backup_target config graph base_name = + let root = backup_root_path config graph in + ensure_dir root; + let rec loop suffix = + let backup_name = + if suffix = 0 then base_name else base_name ^ "-" ^ string_of_int suffix + in + let dir = backup_dir_path config graph backup_name in + if Cli_unix.file_exists dir then loop (suffix + 1) + else ( + Cli_unix.mkdir dir 0o755; + (backup_name, dir, backup_db_path dir)) + in + loop 0 + +let write_backup_metadata dir ~backup_name ~repo ~db_path = + let repo = Cli_primitive.string_of_repo repo in + let created_at_ms = Time.time_to_epoch_ms (Time.now ()) in + write_file (backup_metadata_path dir) + (Melange_edn_melange.to_edn_string + (Edn_util.map_vec + (Vec.of_array + [| + (kw "schema-version", Edn_util.int 1); + (kw "name", string backup_name); + (kw "repo", string repo); + (kw "source", kw "cli"); + (kw "created-at-ms", Edn_util.int64 created_at_ms); + (kw "db-path", string db_path); + |]))) + +let graph_backup_create_result mode config graph repo name backup_name = + let open Cli_effect in + let base_name = + Option.value backup_name ~default:(build_backup_name repo name) + in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_backup_create mode err) + | Ok invoke_config -> + let backup_name, dir, db_path = + reserve_backup_target config graph base_name + in + let tmp_path = tmp_sqlite_path dir in + let success_result () = + Cli_result.ok ~command:Command_id.Graph_backup_create mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + (kw "backup-name", string backup_name); + (kw "path", string db_path); + (kw "message", string ("Created backup " ^ backup_name)); + |]))) + in + let error_result code message = + Cli_result.error ~command:Command_id.Graph_backup_create mode + (Error.make code message) + in + let backup_effect = + bind + (Transport.thread_api_backup_db_sqlite invoke_config ~repo + ~path:tmp_path) (fun _ -> + if Cli_unix.file_exists tmp_path then ( + Cli_unix.rename tmp_path db_path; + write_backup_metadata dir ~backup_name ~repo ~db_path; + pure (success_result ())) + else ( + remove_tree dir; + pure + (error_result Error.Missing_snapshot + ("snapshot did not create sqlite backup: " ^ tmp_path)))) + in + catch backup_effect (fun exn -> + remove_tree dir; + pure + (Cli_result.error ~command:Command_id.Graph_backup_create mode + (Error.make + ~context:(Edn_util.string (Printexc.to_string exn)) + Error.Backup_create_failed "backup create failed")))) + +let graph_create_data result = + Option.value (Cli_result.data_value result) ~default:Edn_util.nil + +let graph_create_enable_sync_result mode _config graph repo create_result + upload_result start_result = + Cli_result.ok ~command:Command_id.Graph_create mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + (kw "graph", string (Cli_primitive.string_of_graph graph)); + (kw "repo", string (Cli_primitive.string_of_repo repo)); + ( kw "stages", + Edn_util.map_vec + (Vec.of_array + [| + (kw "create", graph_create_data create_result); + (kw "upload", graph_create_data upload_result); + (kw "start", graph_create_data start_result); + |]) ); + |]))) + +let execute_graph_create_invoke mode _graph repo config = + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_create mode err) + | Ok invoke_config -> + bind + (Transport.thread_api_create_or_open_db invoke_config ~repo + ~options:(Edn_util.map_t_vec Vec.empty)) + (fun result -> + pure + (Cli_result.ok ~command:Command_id.Graph_create mode + (Raw + (Edn_util.map_vec + (Vec.of_array [| (kw "result", result) |])))))) + +let execute_graph_create_enable_sync mode graph repo opts config = + let open Cli_effect in + bind (execute_graph_create_invoke mode graph repo config) + (fun create_result -> + if Cli_result.is_error create_result then pure create_result + else + bind + (Sync.execute + (Sync.Sync_upload + { repo; graph; e2ee_password = opts.e2ee_password }) + config) + (fun upload_result -> + if Cli_result.is_error upload_result then + pure + (Cli_result.with_command Command_id.Graph_create upload_result) + else + bind + (Sync.execute + (Sync.Sync_start + { repo; graph; e2ee_password = opts.e2ee_password }) + config) + (fun start_result -> + if Cli_result.is_error start_result then + pure + (Cli_result.with_command Command_id.Graph_create + start_result) + else + pure + (graph_create_enable_sync_result mode config graph repo + create_result upload_result start_result)))) + +let execute_graph_create mode graph repo opts config = + let open Cli_effect in + if opts.enable_sync then + execute_graph_create_enable_sync mode graph repo opts config + else + match config.Cli_config.base_url with + | None -> + ensure_dir (graph_path config graph); + pure + (Cli_result.ok ~command:Command_id.Graph_create mode + (Message + ("Created graph \"" ^ Cli_primitive.string_of_graph graph ^ "\""))) + | Some _ -> execute_graph_create_invoke mode graph repo config + +let execute_graph_export mode _graph repo opts config = + let open Cli_effect in + let output_path = + match (opts.file, opts.export_type) with + | Some path, _ -> path + | None, Sqlite -> default_sqlite_export_path config repo + | None, Edn -> "" + in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_export mode err) + | Ok invoke_config -> ( + match opts.export_type with + | Edn -> + bind + (Transport.thread_api_export_edn invoke_config ~repo + ~options: + (Edn_util.expect_map_t "graph export options" + (export_payload opts))) + (fun exported -> + bind + (if opts.pretty_print then + write_pretty_edn output_path exported + else + Transport.write_output + ~format:(export_format opts.export_type) + ~path:output_path ~data:exported) + (function + | Ok () -> + pure (graph_export_message mode config output_path) + | Error err -> + pure + (Cli_result.error ~command:Command_id.Graph_export + mode err))) + | Sqlite -> + bind + (Transport.thread_api_backup_db_sqlite invoke_config ~repo + ~path:output_path) (fun _ -> + pure (graph_export_message mode config output_path)))) + +let execute_graph_import mode graph repo opts config = + let open Cli_effect in + let new_graph = not (graph_exists config graph) in + bind + (Transport.read_input + ~format:(import_format opts.import_type) + ~path:opts.input) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_import mode err) + | Ok input_data -> + let import_after_stop () = + bind + (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Graph_import mode err) + | Ok invoke_config -> + bind + (match opts.import_type with + | Import_edn -> + Transport.thread_api_import_edn invoke_config ~repo + ~data:input_data + | Import_sqlite -> + Transport.thread_api_import_db_binary invoke_config + ~repo ~data:input_data) + (fun _ -> + bind (Server_runtime.restart_server config repo) (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Graph_import + mode err) + | Ok _ -> + pure + (graph_import_message mode config graph opts + new_graph)))) + in + bind (Server_runtime.stop_server config repo) (function + | Error err when err.Error.code = Error.Server_not_found -> + import_after_stop () + | Error err -> + pure + (Cli_result.error ~command:Command_id.Graph_import mode err) + | Ok _ -> import_after_stop ())) + +let execute_graph_backup_restore mode source_graph dst_graph dst_repo src config + = + let db_path = backup_db_path (backup_dir_path config source_graph src) in + if not (Cli_unix.file_exists db_path) then + Cli_effect.pure + (Cli_result.error ~command:Command_id.Graph_backup_restore mode + (Error.make Error.Backup_not_found ("backup not found: " ^ src))) + else + let opts = { import_type = Import_sqlite; input = db_path } in + Cli_effect.map + (Cli_result.with_command Command_id.Graph_backup_restore) + (execute_graph_import mode dst_graph dst_repo opts config) + +let execute_graph_validate mode repo fix config = + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_validate mode err) + | Ok invoke_config -> + bind + (Transport.thread_api_validate_db invoke_config ~repo + ~options: + (Edn_util.map_t_vec (Vec.of_array [| (kw "fix", bool fix) |]))) + (fun result -> pure (graph_validate_result mode config result))) + +let execute_graph_info mode graph repo config = + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_info mode err) + | Ok invoke_config -> + bind + (Transport.thread_api_q invoke_config ~repo + ~query: + (vector_t_vec (Vec.of_array [| Edn_util.any graph_info_query |]))) + (fun result -> + let rows = + match Edn_util.as_seq result with + | Some rows -> rows + | _ when Edn_util.is_null result -> Vec.empty + | _ -> Vec.singleton result + in + pure (graph_info_result mode config graph rows))) + +let execute_graph_switch mode graph repo config = + let open Cli_effect in + let graph_name = Cli_primitive.string_of_graph graph in + if not (graph_exists config graph) then + pure + (Cli_result.error ~command:Command_id.Graph_switch mode + (Error.make Error.Graph_not_exists "graph not exists")) + else + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_switch mode err) + | Ok _ -> + bind + (Cli_config.update_config config + (Edn_util.map_vec + (Vec.of_array [| (kw "graph", string graph_name) |]))) + (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Graph_switch mode err) + | Ok _ -> + pure + (Cli_result.ok ~command:Command_id.Graph_switch mode + (Message ("switched to " ^ graph_name))))) + +let execute_graph_remove mode graph repo config = + let open Cli_effect in + let graph_name = Cli_primitive.string_of_graph graph in + let removed_graph_result = + Cli_result.ok ~command:Command_id.Graph_remove mode + (Message ("Removed graph \"" ^ graph_name ^ "\"")) + in + if not (graph_exists config graph) then + pure + (Cli_result.error ~command:Command_id.Graph_remove mode + (Error.make Error.Graph_not_exists "graph not exists")) + else + bind (Server_runtime.stop_server config repo) (function + | Error err when err.Error.code = Error.Server_not_found -> ( + match unlink_graph_dir config graph repo with + | Some _ -> pure removed_graph_result + | None -> + pure + (Cli_result.error ~command:Command_id.Graph_remove mode + (Error.make Error.Graph_not_removed "unable to remove graph")) + ) + | Error err -> + pure (Cli_result.error ~command:Command_id.Graph_remove mode err) + | Ok _ -> ( + match unlink_graph_dir config graph repo with + | Some _ -> pure removed_graph_result + | None -> + pure + (Cli_result.error ~command:Command_id.Graph_remove mode + (Error.make Error.Graph_not_removed "unable to remove graph")) + )) + +let execute_with_mode action config mode = + let pure = Cli_effect.pure in + match action with + | Graph_list -> + let graph_items = list_graph_items config in + pure + (Cli_result.ok ~command:Command_id.Graph_list mode + (Raw (graph_list_value graph_items))) + | Graph_create { graph; repo; opts } -> + execute_graph_create mode graph repo opts config + | Graph_switch { graph; repo } -> execute_graph_switch mode graph repo config + | Graph_remove { graph; repo } -> execute_graph_remove mode graph repo config + | Graph_validate { repo; fix; _ } -> + execute_graph_validate mode repo fix config + | Graph_info { graph; repo } -> execute_graph_info mode graph repo config + | Graph_backup_list { graph; _ } -> + pure (graph_backup_list_result mode config graph) + | Graph_backup_create { graph; repo; name; backup_name } -> + graph_backup_create_result mode config graph repo name backup_name + | Graph_backup_restore { source_graph; dst_repo; dst_graph; src; _ } -> + execute_graph_backup_restore mode source_graph dst_graph dst_repo src + config + | Graph_backup_remove { graph; src; _ } -> + pure (graph_backup_remove_result mode config graph src) + | Graph_export { repo; opts; _ } -> + execute_graph_export mode "" repo opts config + | Graph_import { graph; repo; opts; _ } -> + execute_graph_import mode graph repo opts config + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_management; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples:(Vec.singleton "logseq graph list") + Command_id.Graph_list "List graphs"; + meta + ~examples: + (Vec.of_array + [| + "logseq graph create --graph my-graph"; + "logseq graph create --graph my-graph --enable-sync"; + "logseq graph create --graph my-graph --enable-sync \ + --e2ee-password \"my-secret\""; + |]) + Graph_create "Create graph"; + meta + ~examples:(Vec.singleton "logseq graph switch --graph my-graph") + Graph_switch "Switch current graph"; + meta + ~examples:(Vec.singleton "logseq graph remove --graph my-graph") + Graph_remove "Remove graph"; + meta + ~examples: + (Vec.of_array + [| + "logseq graph validate --graph my-graph"; + "logseq graph validate --graph my-graph --fix"; + |]) + Graph_validate "Validate graph"; + meta + ~examples:(Vec.singleton "logseq graph info --graph my-graph") + Graph_info "Graph metadata"; + meta + ~examples: + (Vec.of_array + [| + "logseq graph export --graph my-graph --type edn --file \ + /tmp/my-graph.edn --edn-options '{:export-type :graph \ + :include-timestamps? true}' --pretty-print"; + "logseq graph export --graph my-graph --type sqlite --file \ + /tmp/my-graph.sqlite"; + |]) + Graph_export "Export graph"; + meta + ~examples: + (Vec.of_array + [| + "logseq graph import --graph my-graph --type edn --input \ + /tmp/my-graph.edn"; + |]) + Graph_import "Import graph"; + meta + ~examples:(Vec.singleton "logseq graph backup list --graph my-graph") + Graph_backup_list "List graph backups"; + meta + ~examples: + (Vec.of_array + [| + "logseq graph backup create --graph my-graph"; + "logseq graph backup create --graph my-graph --name nightly"; + |]) + Graph_backup_create "Create graph backup"; + meta + ~examples: + (Vec.of_array + [| + "logseq graph backup restore --src my-graph-nightly --dst \ + my-graph-restore"; + |]) + Graph_backup_restore "Restore graph backup"; + meta + ~examples: + (Vec.singleton "logseq graph backup remove --src my-graph-nightly") + Graph_backup_remove "Remove graph backup"; + |] + +let repo = function + | Graph_list -> None + | Graph_create { repo; _ } + | Graph_switch { repo; _ } + | Graph_remove { repo; _ } + | Graph_validate { repo; _ } + | Graph_info { repo; _ } + | Graph_backup_list { repo; _ } + | Graph_backup_create { repo; _ } + | Graph_backup_remove { repo; _ } + | Graph_export { repo; _ } + | Graph_import { repo; _ } -> + Some repo + | Graph_backup_restore { dst_repo; _ } -> Some dst_repo + +let graph = function + | Graph_list -> None + | Graph_create { graph; _ } + | Graph_switch { graph; _ } + | Graph_remove { graph; _ } + | Graph_validate { graph; _ } + | Graph_info { graph; _ } + | Graph_backup_list { graph; _ } + | Graph_backup_create { graph; _ } + | Graph_backup_remove { graph; _ } + | Graph_export { graph; _ } + | Graph_import { graph; _ } -> + Some graph + | Graph_backup_restore { dst_graph; _ } -> Some dst_graph + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/graph_dir.ml b/cli/lib/graph_dir.ml new file mode 100644 index 0000000..ff5db44 --- /dev/null +++ b/cli/lib/graph_dir.ml @@ -0,0 +1,73 @@ +let replace_all ~needle ~replacement text = + let needle_len = String.length needle in + if needle_len = 0 then invalid_arg "needle must not be empty"; + let text_len = String.length text in + let buffer = Buffer.create text_len in + let rec loop index = + if index >= text_len then Buffer.contents buffer + else if + index + needle_len <= text_len + && String.sub text index needle_len = needle + then ( + Buffer.add_string buffer replacement; + loop (index + needle_len)) + else ( + Buffer.add_char buffer text.[index]; + loop (index + 1)) + in + loop 0 + +let encode_graph_dir_name graph_name = + graph_name |> Js.Global.encodeURIComponent + |> replace_all ~needle:"%20" ~replacement:" " + |> replace_all ~needle:"~" ~replacement:"%7E" + |> replace_all ~needle:"%" ~replacement:"~" + +let graph_dir_name_of_repo repo = + Cli_config.repo_to_graph repo + |> Cli_primitive.string_of_graph |> encode_graph_dir_name + +let contains_substring ~needle text = + let needle_len = String.length needle in + let text_len = String.length text in + let rec loop index = + index + needle_len <= text_len + && (String.sub text index needle_len = needle || loop (index + 1)) + in + loop 0 + +let decode_percent_graph_dir_name dir_name = + try Some (Js.Global.decodeURIComponent dir_name) with _ -> None + +let decode_graph_dir_name dir_name = + if + contains_substring ~needle:"++" dir_name + || contains_substring ~needle:"+3A+" dir_name + then None + else + dir_name + |> replace_all ~needle:"~" ~replacement:"%" + |> decode_percent_graph_dir_name + +let decode_legacy_graph_dir_name dir_name = + if + not + (contains_substring ~needle:"++" dir_name + || contains_substring ~needle:"+3A+" dir_name + || contains_substring ~needle:"%" dir_name) + then None + else + dir_name + |> replace_all ~needle:"+3A+" ~replacement:":" + |> replace_all ~needle:"++" ~replacement:"/" + |> decode_percent_graph_dir_name + |> fun graph_name -> + Option.bind graph_name (fun graph_name -> + if graph_name = "" then None else Some graph_name) + +let canonical_graph_name_of_dir dir_name = + match decode_graph_dir_name dir_name with + | Some graph_name + when graph_name <> "" && encode_graph_dir_name graph_name = dir_name -> + Some graph_name + | _ -> None diff --git a/cli/lib/graph_types.ml b/cli/lib/graph_types.ml new file mode 100644 index 0000000..d27e3c0 --- /dev/null +++ b/cli/lib/graph_types.ml @@ -0,0 +1,11 @@ +type graph_item_kind = Canonical | Legacy | Legacy_undecodable + +type graph_item = { + kind : graph_item_kind; + graph_name : Cli_primitive.graph option; + graph_dir : Cli_primitive.path option; + legacy_dir : Cli_primitive.path option; + target_graph_dir : Cli_primitive.path option; + conflict : bool; + reason : Cli_primitive.keyword option; +} diff --git a/cli/lib/humanize_types.ml b/cli/lib/humanize_types.ml new file mode 100644 index 0000000..aa8203e --- /dev/null +++ b/cli/lib/humanize_types.ml @@ -0,0 +1,13 @@ +let format_count = Humanize.intcomma +let pluralize_noun = Humanize.pluralize_noun + +let format_count_with_noun count noun = + format_count count ^ " " ^ pluralize_noun count noun + +let datetime = Humanize.datetime + +let relative_datetime ~then_time ~now_time = + let now = Time.time_to_epoch_seconds now_time in + let then_time = Time.time_to_epoch_seconds then_time in + Humanize.relative_datetime ~now ~number_format:string_of_int ~max_terms:1 + then_time diff --git a/cli/lib/id_parse.ml b/cli/lib/id_parse.ml new file mode 100644 index 0000000..ca9bbb0 --- /dev/null +++ b/cli/lib/id_parse.ml @@ -0,0 +1,31 @@ +type parsed = { ids : Cli_primitive.db_id Rrbvec.t; multi : bool } + +let valid_id id = Int64.compare id 0L > 0 + +let strip_vector_brackets value = + let value = String.trim value in + let len = String.length value in + if len >= 2 && value.[0] = '[' && value.[len - 1] = ']' then + String.sub value 1 (len - 2) + else value + +let parse_id_string s = + try + let split_ids value = + Vec.split_on_char ',' value + |> Vec.concat_map (fun part -> + Vec.split_on_char ' ' part + |> Vec.concat_map (fun part -> Vec.split_on_char '\t' part)) + in + let ids = + strip_vector_brackets s |> split_ids + |> Vec.filter_map (fun p -> + let p = String.trim p in + if p = "" then None else Some (Int64.of_string p)) + in + if Vec.for_all valid_id ids then Ok { ids; multi = Vec.length ids > 1 } + else Error (Error.invalid_options "invalid id") + with _ -> Error (Error.invalid_options "invalid id") + +let to_single parsed = + if Vec.length parsed.ids = 1 then Vec.nth_opt parsed.ids 0 else None diff --git a/cli/lib/js_bytecode_stubs.c b/cli/lib/js_bytecode_stubs.c new file mode 100644 index 0000000..61708a6 --- /dev/null +++ b/cli/lib/js_bytecode_stubs.c @@ -0,0 +1,37 @@ +#include +#include +#include + +CAMLprim value caml_js_from_string(value string) { return string; } + +CAMLprim value caml_js_to_string(value value) { return value; } + +CAMLprim value caml_js_var(value name) { + caml_failwith("JavaScript global lookup is unavailable outside wasm"); +} + +CAMLprim value caml_js_get(value object, value field) { + caml_failwith("JavaScript property access is unavailable outside wasm"); +} + +CAMLprim value caml_js_fun_call(value fn, value args) { + caml_failwith("JavaScript function call is unavailable outside wasm"); +} + +CAMLprim value caml_js_meth_call(value object, value method_name, value args) { + caml_failwith("JavaScript method call is unavailable outside wasm"); +} + +CAMLprim value caml_js_new(value constructor, value args) { + caml_failwith("JavaScript object construction is unavailable outside wasm"); +} + +CAMLprim value caml_js_object(value fields) { + caml_failwith("JavaScript object construction is unavailable outside wasm"); +} + +CAMLprim value caml_js_wrap_callback(value callback) { return callback; } + +CAMLprim value caml_js_eval_string(value source) { + caml_failwith("JavaScript eval is unavailable outside wasm"); +} diff --git a/cli/lib/json_util.ml b/cli/lib/json_util.ml new file mode 100644 index 0000000..e195902 --- /dev/null +++ b/cli/lib/json_util.ml @@ -0,0 +1,60 @@ +let max_safe_json_int = 9_007_199_254_740_991. + +let is_integral_float value = + Float.is_finite value + && value = floor value + && Float.abs value <= max_safe_json_int + +let rec value_of_json json = + match Js.Json.classify json with + | JSONNull -> Edn_util.nil + | JSONFalse -> Edn_util.bool false + | JSONTrue -> Edn_util.bool true + | JSONString value -> Edn_util.string value + | JSONNumber value -> + if is_integral_float value then Edn_util.int64 (Int64.of_float value) + else Edn_util.float value + | JSONArray values -> + Edn_util.vector_vec (values |> Rrbvec.of_array |> Vec.map value_of_json) + | JSONObject fields -> + fields |> Js.Dict.entries |> Rrbvec.of_array + |> Vec.map (fun (key, value) -> + (Edn_util.string key, value_of_json value)) + |> Edn_util.map_vec + +let value_of_json_string text = Js.Json.parseExn text |> value_of_json +let object_of_json_string text = Js.Json.decodeObject (Js.Json.parseExn text) + +let json_of_string_fields fields = + let object_ = Js.Dict.empty () in + Vec.iter + (fun (key, value) -> Js.Dict.set object_ key (Js.Json.string value)) + fields; + Js.Json.object_ object_ + +let string_of_string_fields fields = + fields |> json_of_string_fields |> Js.Json.stringify + +let field object_ key = Js.Dict.get object_ key + +let string_field object_ key = + Option.bind (field object_ key) Js.Json.decodeString + +let number_field object_ key = + Option.bind (field object_ key) Js.Json.decodeNumber + +let int64_field object_ key = + match number_field object_ key with + | Some value when is_integral_float value -> Some (Int64.of_float value) + | _ -> None + +let object_field object_ key = + match field object_ key with + | Some value -> Js.Json.decodeObject value + | None -> None + +let nested_string_field object_ object_key field_key = + match object_field object_ object_key with + | Some object_ -> + Option.bind (Js.Dict.get object_ field_key) Js.Json.decodeString + | None -> None diff --git a/cli/lib/list_command.ml b/cli/lib/list_command.ml new file mode 100644 index 0000000..e5b8cf9 --- /dev/null +++ b/cli/lib/list_command.ml @@ -0,0 +1,1031 @@ +type order = Asc | Desc + +type common_opts = { + fields : string Rrbvec.t option; + limit : int option; + offset : int option; + sort : string option; + order : order option; +} + +type page_opts = { + common : common_opts; + expand : bool; + include_built_in : bool option; + include_journal : bool option; + journal_only : bool; + include_hidden : bool; + updated_after : Js.Date.t option; + created_after : Js.Date.t option; +} + +type tag_opts = { + common : common_opts; + expand : bool; + include_built_in : bool option; + with_properties : bool; + with_extends : bool; +} + +type property_opts = { + common : common_opts; + expand : bool; + include_built_in : bool option; + with_classes : bool; + with_type : bool; +} + +type task_opts = { + common : common_opts; + status : string option; + priority : string option; + content : string option; +} + +type node_opts = { + common : common_opts; + tags : string Rrbvec.t; + properties : string Rrbvec.t; +} + +type asset_opts = { common : common_opts } + +type parsed = + | Parsed_page of page_opts + | Parsed_tag of tag_opts + | Parsed_property of property_opts + | Parsed_task of task_opts + | Parsed_node of node_opts + | Parsed_asset of asset_opts + +type kind = Page | Tag | Property | Task | Node | Asset + +type action = { + kind : kind; + command : Command_id.t; + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + options : Melange_edn_melange.any; +} + +type list_result = { items : Entity.t Rrbvec.t } + +let order_of_string value = + match String.lowercase_ascii (String.trim value) with + | "asc" -> Some Asc + | "desc" -> Some Desc + | _ -> None + +let string_of_order = function Asc -> "asc" | Desc -> "desc" + +let kind_of_parsed = function + | Parsed_page _ -> Page + | Parsed_tag _ -> Tag + | Parsed_property _ -> Property + | Parsed_task _ -> Task + | Parsed_node _ -> Node + | Parsed_asset _ -> Asset + +let normalize_priority value = + match String.lowercase_ascii (String.trim value) with + | "low" -> Some "logseq.property/priority.low" + | "medium" -> Some "logseq.property/priority.medium" + | "high" -> Some "logseq.property/priority.high" + | "urgent" -> Some "logseq.property/priority.urgent" + | _ -> None + +let invalid_priority_message value = + "Invalid value for option :priority: " ^ value + ^ ". Available values: low, medium, high, urgent" + +let validate_parsed = function + | Parsed_page opts when opts.include_journal = Some true && opts.journal_only + -> + Error + (Error.invalid_options + "include-journal and journal-only are mutually exclusive") + | Parsed_task opts -> ( + match opts.priority with + | Some value when normalize_priority value = None -> + Error (Error.invalid_options (invalid_priority_message value)) + | _ -> Ok ()) + | Parsed_node opts when Vec.is_empty opts.tags && Vec.is_empty opts.properties + -> + Error + (Error.invalid_options + "list node requires at least one of --tags or --properties") + | _ -> Ok () + +let normalize_options p = Error.bind (validate_parsed p) (fun () -> Ok p) +let default_sort_field = "updated-at" + +let field_map fields = + Vec.map (fun (name, keyword) -> (name, Edn_util.keyword_t keyword)) fields + +let page_field_map = + field_map + (Vec.of_array + [| + ("id", "db/id"); + ("ident", "db/ident"); + ("title", "block/title"); + ("uuid", "block/uuid"); + ("created-at", "block/created-at"); + ("updated-at", "block/updated-at"); + |]) + +let tag_field_map = + Vec.append page_field_map + (field_map + (Vec.of_array + [| + ("properties", "logseq.property.class/properties"); + ("extends", "logseq.property.class/extends"); + ("description", "logseq.property/description"); + |])) + +let property_field_map = + Vec.append page_field_map + (field_map + (Vec.of_array + [| + ("classes", "logseq.property/classes"); + ("type", "logseq.property/type"); + ("cardinality", "db/cardinality"); + ("description", "logseq.property/description"); + |])) + +let task_field_map = + field_map + (Vec.of_array + [| + ("id", "db/id"); + ("title", "block/title"); + ("status", "logseq.property/status"); + ("priority", "logseq.property/priority"); + ("scheduled", "logseq.property/scheduled"); + ("deadline", "logseq.property/deadline"); + ("updated-at", "block/updated-at"); + ("created-at", "block/created-at"); + |]) + +let node_field_map = + field_map + (Vec.of_array + [| + ("id", "db/id"); + ("title", "block/title"); + ("type", "node/type"); + ("page-id", "block/page-id"); + ("page-title", "block/page-title"); + ("created-at", "block/created-at"); + ("updated-at", "block/updated-at"); + |]) + +let asset_field_map = + field_map + (Vec.of_array + [| + ("id", "db/id"); + ("title", "block/title"); + ("asset-type", "logseq.property.asset/type"); + ("size", "logseq.property.asset/size"); + ("updated-at", "block/updated-at"); + ("created-at", "block/created-at"); + |]) + +let field_map_of_kind = function + | Page -> page_field_map + | Tag -> tag_field_map + | Property -> property_field_map + | Task -> task_field_map + | Node -> node_field_map + | Asset -> asset_field_map + +let value_rank value = + match value with + | Melange_edn_melange.Any Melange_edn_melange.Nil -> 0 + | Any (Bool _) -> 1 + | Any (Int _ | Bigint _ | Float _ | Decimal _ | Ratio _) -> 2 + | Any (String _ | Symbol _ | Keyword _ | Tagged ("uuid", _)) -> 3 + | Any (Tagged ("transit/bytes", _)) -> 4 + | Any (List _ | Vector _ | Set _) -> 5 + | Any (Map _) -> 6 + | Any (Char _ | Regex _ | Tagged _) -> 7 + +let compare_value a b = + let number value = + match (Edn_util.as_int value, Edn_util.as_float value) with + | Some value, _ -> Some (float_of_int value) + | _, Some value -> Some value + | _ -> None + in + match + ( Edn_util.is_null a, + Edn_util.is_null b, + Edn_util.as_bool a, + Edn_util.as_bool b, + number a, + number b, + Edn_util.as_string_like a, + Edn_util.as_string_like b, + Edn_util.as_bytes a, + Edn_util.as_bytes b ) + with + | true, true, _, _, _, _, _, _, _, _ -> 0 + | _, _, Some a, Some b, _, _, _, _, _, _ -> Bool.compare a b + | _, _, _, _, Some a, Some b, _, _, _, _ -> Float.compare a b + | _, _, _, _, _, _, Some a, Some b, _, _ -> String.compare a b + | _, _, _, _, _, _, _, _, Some a, Some b -> + String.compare (Bytes.to_string a) (Bytes.to_string b) + | _ -> + let rank = Int.compare (value_rank a) (value_rank b) in + if rank <> 0 then rank + else + String.compare + (Melange_edn_melange.to_edn_string a) + (Melange_edn_melange.to_edn_string b) + +let compare_item_by keyword a b = + let value_of key item = + Option.value + (Edn_util.get item (Edn_util.keyword_to_string key)) + ~default:Edn_util.nil + in + let primary = compare_value (value_of keyword a) (value_of keyword b) in + if primary <> 0 then primary + else + compare_value + (value_of (Edn_util.keyword_t "db/id") a) + (value_of (Edn_util.keyword_t "db/id") b) + +let sort_values ~field_map common items = + let sort_field = Option.value common.sort ~default:default_sort_field in + match Vec.assoc_opt sort_field field_map with + | None -> items + | Some keyword -> ( + let sorted = Vec.sort (compare_item_by keyword) items in + match common.order with + | Some Asc -> sorted + | Some Desc | None -> Vec.rev sorted) + +let apply_offset_limit common entities = + let entities = + match common.offset with + | Some n -> entities |> Vec.to_seq |> Seq.drop n |> Vec.of_seq + | None -> entities + in + match common.limit with + | Some n -> entities |> Vec.to_seq |> Seq.take n |> Vec.of_seq + | None -> entities + +let command_id = function + | Parsed_page _ -> Command_id.List_page + | Parsed_tag _ -> List_tag + | Parsed_property _ -> List_property + | Parsed_task _ -> List_task + | Parsed_node _ -> List_node + | Parsed_asset _ -> List_asset + +let value_of_string_list values = + Edn_util.vector_vec (values |> Vec.map (fun value -> Edn_util.string value)) + +let string_list_of_value value = + match Edn_util.as_seq value with + | Some values -> Vec.filter_map Edn_util.as_string_like values + | None -> ( + match Edn_util.as_string_like value with + | Some value -> Vec.singleton value + | None -> Vec.empty) + +let common_of_options options = + { + fields = Option.map string_list_of_value (Edn_util.get options "fields"); + limit = Edn_util.get_int options "limit"; + offset = Edn_util.get_int options "offset"; + sort = Edn_util.get_string options "sort"; + order = Option.bind (Edn_util.get_string options "order") order_of_string; + } + +let select_fields field_map fields item = + let keys = + fields |> Vec.filter_map (fun field -> Vec.assoc_opt field field_map) + in + if Vec.is_empty keys then item + else + Edn_util.map_vec + (keys + |> Vec.filter_map (fun key -> + Option.map + (fun value -> (Edn_util.any key, value)) + (Edn_util.get item (Edn_util.keyword_to_string key)))) + +let apply_fields field_map common items = + match common.fields with + | None -> items + | Some fields -> Vec.map (select_fields field_map fields) items + +let postprocess_items kind options items = + let common = common_of_options options in + let field_map = field_map_of_kind kind in + let sorted = sort_values ~field_map common items in + let limited = + sorted |> Vec.map Entity.of_value |> apply_offset_limit common + |> Vec.map (fun entity -> entity.Entity.raw) + in + apply_fields field_map common limited + +let status_query invoke_config repo = + Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| Edn_util.any Task_status.status_closed_values_query |])) + +let kw value = Edn_util.keyword value +let sym value = Edn_util.symbol value +let vector_vec values = Edn_util.vector_vec values +let list_vec values = Edn_util.list_vec values +let normalized_lookup_name value = String.lowercase_ascii (String.trim value) + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let tag_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/tags", vector_vec (Vec.of_array [| kw "db/ident" |])); + |]); + kw "logseq.property/public?"; + |]) + +let property_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/type"; + kw "db/cardinality"; + kw "logseq.property/public?"; + |]) + +let class_query selector class_ident = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (vector_vec + (Vec.of_array + [| + list_vec (Vec.of_array [| sym "pull"; sym "?e"; selector |]); + sym "..."; + |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "?name" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/tags"; sym "?tag" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?tag"; kw "db/ident"; kw class_ident |])); + |]) + () + +let pull_tag_by_name config repo name = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (class_query tag_selector "logseq.class/Tag"); + Edn_util.string (normalized_lookup_name name); + |])) + +let pull_tag_by_uuid config repo uuid = + Transport.thread_api_pull config ~repo + ~selector:(Edn_util.expect_vector_t "list tag selector" tag_selector) + ~lookup: + (vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + +let pull_property_by_ident config repo ident = + Transport.thread_api_pull config ~repo + ~selector: + (Edn_util.expect_vector_t "list property selector" property_selector) + ~lookup:(vector_vec (Vec.of_array [| kw "db/ident"; kw ident |])) + +let pull_property_by_name config repo name = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value + (class_query property_selector "logseq.class/Property"); + Edn_util.string (normalized_lookup_name name); + |])) + +let pull_property_by_id config repo id = + Transport.thread_api_pull config ~repo + ~selector: + (Edn_util.expect_vector_t "list property selector" property_selector) + ~lookup:(Edn_util.int64 id) + +let pull_asset_tag config repo = + Transport.thread_api_pull config ~repo + ~selector:(Edn_util.vector_t_vec (Vec.of_array [| kw "db/id" |])) + ~lookup: + (vector_vec (Vec.of_array [| kw "db/ident"; kw "logseq.class/Asset" |])) + +let first_entity value = + let map_value value = + match Edn_util.as_map value with Some _ -> Some value | None -> None + in + match + (Edn_util.as_vector value, Edn_util.as_list value, Edn_util.as_map value) + with + | Some values, _, _ when not (Vec.is_empty values) -> ( + let first = Vec.peek_front values in + match map_value first with + | Some _ as result -> result + | None -> ( + match (Edn_util.as_vector first, Edn_util.as_list first) with + | Some nested_values, _ when not (Vec.is_empty nested_values) -> + map_value (Vec.peek_front nested_values) + | _, Some nested_values when not (Vec.is_empty nested_values) -> + map_value (Vec.peek_front nested_values) + | _ -> None)) + | _, Some values, _ when not (Vec.is_empty values) -> ( + let first = Vec.peek_front values in + match map_value first with + | Some _ as result -> result + | None -> ( + match (Edn_util.as_vector first, Edn_util.as_list first) with + | Some nested_values, _ when not (Vec.is_empty nested_values) -> + map_value (Vec.peek_front nested_values) + | _, Some nested_values when not (Vec.is_empty nested_values) -> + map_value (Vec.peek_front nested_values) + | _ -> None)) + | _, _, Some _ -> Some value + | _ -> None + +let id_of_entity value = Edn_util.get_int64 value "db/id" + +let ident_of_entity value = + Option.bind (Edn_util.get value "db/ident") Edn_util.as_keyword_t + +let tag_entity value = + match Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq with + | Some tags -> + Vec.exists + (fun tag -> + match + Option.bind (Edn_util.get tag "db/ident") Edn_util.as_string_like + with + | Some "logseq.class/Tag" -> true + | _ -> false) + tags + | None -> false + +let tag_id_of_result result = + match first_entity result with + | Some entity when tag_entity entity -> ( + match id_of_entity entity with + | Some id -> Ok id + | None -> Error (Error.make Error.Tag_not_found "tag not found")) + | _ -> Error (Error.make Error.Tag_not_found "tag not found") + +let property_entity value = + Option.is_some (Edn_util.get value "logseq.property/type") + +let property_public value = + match Edn_util.get_bool value "logseq.property/public?" with + | Some false -> false + | _ -> true + +let property_ident_of_entity entity = + if property_entity entity && property_public entity then + match ident_of_entity entity with + | Some ident -> Ok ident + | None -> Error (Error.make Error.Property_not_found "property not found") + else Error (Error.make Error.Property_not_found "property not found") + +let property_ident_of_query result = + match first_entity result with + | Some entity -> property_ident_of_entity entity + | None -> Error (Error.make Error.Property_not_found "property not found") + +let keyword_label ident = + let value = Edn_util.keyword_to_string ident in + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let qualified_keyword ident = String.contains (keyword_label ident) '/' + +let parse_tag_selector value = + let value = String.trim value in + match Int64.of_string_opt value with + | Some id -> Selector.Tag_id id + | None -> + if Cli_primitive.is_uuid_string value then Selector.Tag_uuid value + else if String.length value > 0 && value.[0] = ':' then + Selector.Tag_ident (Edn_util.keyword_t value) + else Selector.Tag_name value + +let parse_property_selector value = + let value = String.trim value in + match Int64.of_string_opt value with + | Some id -> Property.Key_id id + | None -> + if String.length value > 0 && value.[0] = ':' then + Property.Key_ident (Edn_util.keyword_t value) + else Key_name value + +let resolve_tag_id invoke_config repo selector = + let open Cli_effect in + match selector with + | Selector.Tag_id id -> pure (Ok id) + | Tag_name name -> + bind (pull_tag_by_name invoke_config repo name) (fun result -> + pure (tag_id_of_result result)) + | Tag_ident ident -> + bind + (pull_tag_by_name invoke_config repo (Edn_util.keyword_to_string ident)) + (fun result -> pure (tag_id_of_result result)) + | Tag_uuid uuid -> + bind (pull_tag_by_uuid invoke_config repo uuid) (fun result -> + pure (tag_id_of_result result)) + +let resolve_tag_ids invoke_config repo selectors = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (selector, rest) -> + bind (resolve_tag_id invoke_config repo selector) (function + | Error err -> pure (Error err) + | Ok id -> loop (Vec.push_back acc id) rest) + in + loop Vec.empty selectors + +let resolve_property_ident invoke_config repo selector = + let open Cli_effect in + match selector with + | Property.Key_ident ident -> + bind + (pull_property_by_ident invoke_config repo + (Edn_util.keyword_to_string ident)) + (fun entity -> + match property_ident_of_entity entity with + | Ok _ as result -> pure result + | Error _ when not (qualified_keyword ident) -> + bind + (pull_property_by_name invoke_config repo (keyword_label ident)) + (fun result -> pure (property_ident_of_query result)) + | Error _ as result -> pure result) + | Key_id id -> + bind (pull_property_by_id invoke_config repo id) (fun entity -> + pure (property_ident_of_entity entity)) + | Key_name name -> + bind (pull_property_by_name invoke_config repo name) (fun result -> + match property_ident_of_query result with + | Ok _ as result -> pure result + | Error _ -> + bind + (pull_property_by_ident invoke_config repo + (Edn_util.keyword_to_string (Edn_util.keyword_t name))) + (fun entity -> pure (property_ident_of_entity entity))) + +let resolve_property_idents invoke_config repo selectors = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (selector, rest) -> + bind (resolve_property_ident invoke_config repo selector) (function + | Error err -> pure (Error err) + | Ok ident -> loop (Vec.push_back acc ident) rest) + in + loop Vec.empty selectors + +let normalize_task_options invoke_config repo options = + let open Cli_effect in + let options = + match Edn_util.get_string options "priority" with + | Some priority_input -> ( + match normalize_priority priority_input with + | Some priority -> + Edn_util.assoc "priority" (Edn_util.keyword priority) options + | None -> options) + | None -> options + in + match Edn_util.get_string options "status" with + | None -> pure (Ok options) + | Some status_input when String.trim status_input = "" -> pure (Ok options) + | Some status_input -> + bind (status_query invoke_config repo) (fun result -> + let values = + match (Edn_util.as_vector result, Edn_util.as_list result) with + | Some values, _ | _, Some values -> values + | _ -> Vec.empty + in + let statuses = Task_status.normalize_available_statuses values in + match Task_status.resolve_status_ident status_input statuses with + | Some ident -> + pure (Ok (Edn_util.assoc "status" (Edn_util.any ident) options)) + | None -> + pure + (Error + (Error.invalid_options + (Task_status.invalid_status_message status_input statuses)))) + +let bool_option options key = + match Edn_util.get_bool options key with + | Some true -> true + | Some false | None -> false + +let fields_include options field = + match (common_of_options options).fields with + | Some fields -> Vec.exists (( = ) field) fields + | None -> false + +let normalize_tag_options options = + if bool_option options "with-properties" || bool_option options "with-extends" + then Edn_util.assoc "expand" (Edn_util.bool true) options + else options + +let normalize_property_options options = + if bool_option options "with-classes" then + Edn_util.assoc "expand" (Edn_util.bool true) options + else options + +let normalize_node_options invoke_config repo options = + let open Cli_effect in + let tag_selectors = + Edn_util.get options "tags" + |> Option.map string_list_of_value + |> Option.value ~default:Vec.empty + |> Vec.map parse_tag_selector + in + let property_selectors = + Edn_util.get options "properties" + |> Option.map string_list_of_value + |> Option.value ~default:Vec.empty + |> Vec.map parse_property_selector + in + bind (resolve_tag_ids invoke_config repo tag_selectors) (function + | Error err -> pure (Error err) + | Ok tag_ids -> + bind (resolve_property_idents invoke_config repo property_selectors) + (function + | Error err -> pure (Error err) + | Ok property_idents -> + let options = + options |> Edn_util.remove "tags" + |> Edn_util.remove "properties" + in + let options = + if Vec.is_empty tag_ids then options + else + Edn_util.assoc "tag-ids" + (Edn_util.vector_vec + (tag_ids |> Vec.map (fun id -> Edn_util.int64 id))) + options + in + let options = + if Vec.is_empty property_idents then options + else + Edn_util.assoc "property-idents" + (Edn_util.vector_vec + (Vec.map + (fun ident -> Edn_util.any ident) + property_idents)) + options + in + pure (Ok options))) + +let normalize_asset_options invoke_config repo options = + let open Cli_effect in + bind (pull_asset_tag invoke_config repo) (fun entity -> + match id_of_entity entity with + | Some id -> + pure + (Ok + (Edn_util.assoc "tag-ids" + (Edn_util.vector_vec (Vec.of_array [| Edn_util.int64 id |])) + options)) + | None -> + pure + (Error (Error.make Error.Asset_tag_not_found "asset tag not found"))) + +let prepare_tag_item options item = + ( ( item |> fun item -> + if bool_option options "with-properties" then item + else Edn_util.remove "logseq.property.class/properties" item ) + |> fun item -> + if bool_option options "with-extends" then item + else Edn_util.remove "logseq.property.class/extends" item ) + |> fun item -> + if fields_include options "description" then item + else Edn_util.remove "logseq.property/description" item + +let prepare_property_item options item = + let with_type = + match Edn_util.get_bool options "with-type" with + | Some value -> value + | None -> true + in + ( ( item |> fun item -> + if bool_option options "with-classes" then item + else Edn_util.remove "logseq.property/classes" item ) + |> fun item -> + if with_type then item else Edn_util.remove "logseq.property/type" item ) + |> fun item -> + if fields_include options "description" then item + else Edn_util.remove "logseq.property/description" item + +let prepare_page_item item = Edn_util.remove "logseq.property/type" item + +let prepare_items kind options items = + match kind with + | Page -> Vec.map prepare_page_item items + | Tag -> Vec.map (prepare_tag_item options) items + | Property -> Vec.map (prepare_property_item options) items + | Task | Node | Asset -> items + +let visible_title_fields = function + | Node -> + Vec.of_array + [| + Edn_util.keyword_t "block/title"; + Edn_util.keyword_t "block/page-title"; + |] + | Page | Tag | Property | Task | Asset -> + Vec.singleton (Edn_util.keyword_t "block/title") + +let normalize_visible_title_fields config repo kind items = + let fields = visible_title_fields kind in + let entities = Vec.map Entity.of_value items in + let uuids = Uuid_refs_types.collect_uuid_refs_from_items entities fields in + if Vec.is_empty uuids then Cli_effect.pure items + else + Cli_effect.map + (fun labels -> + Uuid_refs_types.normalize_item_string_fields entities fields labels + |> Vec.map (fun item -> item.Entity.raw)) + (Uuid_refs_types.fetch_uuid_labels config repo uuids) + +let add_optional key value fields = + match value with + | None -> fields + | Some value -> Vec.push_back fields (Edn_util.keyword key, value) + +let common_options common = + Vec.empty + |> add_optional "fields" (Option.map value_of_string_list common.fields) + |> add_optional "limit" + (Option.map (fun value -> Edn_util.int value) common.limit) + |> add_optional "offset" + (Option.map (fun value -> Edn_util.int value) common.offset) + |> add_optional "sort" + (Option.map (fun value -> Edn_util.string value) common.sort) + |> add_optional "order" + (Option.map + (fun value -> Edn_util.string (string_of_order value)) + common.order) + +let add_bool key enabled fields = + if enabled then Vec.push_back fields (Edn_util.keyword key, Edn_util.bool true) + else fields + +let add_optional_bool key value fields = + add_optional key (Option.map (fun value -> Edn_util.bool value) value) fields + +let options_of_parsed = function + | Parsed_page opts -> + common_options opts.common + |> add_bool "expand" opts.expand + |> add_optional_bool "include-built-in" opts.include_built_in + |> add_optional_bool "include-journal" opts.include_journal + |> add_bool "journal-only" opts.journal_only + |> add_bool "include-hidden" opts.include_hidden + |> add_optional "updated-after" + (Option.map + (fun value -> Edn_util.int64 (Time.time_to_epoch_ms value)) + opts.updated_after) + |> add_optional "created-after" + (Option.map + (fun value -> Edn_util.int64 (Time.time_to_epoch_ms value)) + opts.created_after) + |> Edn_util.map_vec + | Parsed_tag opts -> + common_options opts.common + |> add_bool "expand" opts.expand + |> add_optional_bool "include-built-in" opts.include_built_in + |> add_bool "with-properties" opts.with_properties + |> add_bool "with-extends" opts.with_extends + |> Edn_util.map_vec + | Parsed_property opts -> + common_options opts.common + |> add_bool "expand" opts.expand + |> add_optional_bool "include-built-in" opts.include_built_in + |> add_bool "with-classes" opts.with_classes + |> add_bool "with-type" opts.with_type + |> Edn_util.map_vec + | Parsed_task opts -> + common_options opts.common + |> add_optional "status" + (Option.map (fun value -> Edn_util.string value) opts.status) + |> add_optional "priority" + (Option.map (fun value -> Edn_util.string value) opts.priority) + |> add_optional "content" + (Option.map (fun value -> Edn_util.string value) opts.content) + |> Edn_util.map_vec + | Parsed_node opts -> + common_options opts.common + |> add_optional "tags" + (if Vec.is_empty opts.tags then None + else Some (value_of_string_list opts.tags)) + |> add_optional "properties" + (if Vec.is_empty opts.properties then None + else Some (value_of_string_list opts.properties)) + |> Edn_util.map_vec + | Parsed_asset opts -> Edn_util.map_vec (opts.common |> common_options) + +let build ?registry:_ config _globals parsed = + Error.bind (normalize_options parsed) (fun parsed -> + match config.Cli_config.repo with + | None -> Error (Error.missing_repo "repo is required for list") + | Some repo -> + Ok + { + kind = kind_of_parsed parsed; + command = command_id parsed; + repo; + graph = Cli_config.repo_to_graph repo; + options = options_of_parsed parsed; + }) + +let items_value items = + Edn_util.map_t_vec + (Vec.of_array [| (Edn_util.keyword "items", Edn_util.vector_vec items) |]) + +let execute_with_mode action config mode = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> pure (Output_mode.error ~command:action.command mode err) + | Ok invoke_config -> + let options = + match action.kind with + | Tag -> pure (Ok (normalize_tag_options action.options)) + | Property -> pure (Ok (normalize_property_options action.options)) + | Task -> + normalize_task_options invoke_config action.repo action.options + | Node -> + normalize_node_options invoke_config action.repo action.options + | Asset -> + normalize_asset_options invoke_config action.repo action.options + | _ -> pure (Ok action.options) + in + bind options (function + | Error err -> + pure (Output_mode.error ~command:action.command mode err) + | Ok options -> + let options = + Edn_util.expect_map_t "list command options" options + in + bind + (match action.kind with + | Page -> + Transport.thread_api_cli_list_pages invoke_config + ~repo:action.repo ~options + | Tag -> + Transport.thread_api_cli_list_tags invoke_config + ~repo:action.repo ~options + | Property -> + Transport.thread_api_cli_list_properties invoke_config + ~repo:action.repo ~options + | Task -> + Transport.thread_api_cli_list_tasks invoke_config + ~repo:action.repo ~options + | Node | Asset -> + Transport.thread_api_cli_list_nodes invoke_config + ~repo:action.repo ~options) + (fun value -> + let items = + match + (Edn_util.as_vector value, Edn_util.as_list value) + with + | Some xs, _ | _, Some xs -> xs + | _ when Edn_util.is_null value -> Vec.empty + | _ -> Vec.singleton value + in + let items = prepare_items action.kind action.options items in + let items = + postprocess_items action.kind action.options items + in + bind + (normalize_visible_title_fields config action.repo + action.kind items) (fun items -> + let data = Edn_util.any (items_value items) in + pure + (Cli_result.ok ~command:action.command mode (Raw data)))))) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples: + (Vec.of_array + [| + "logseq list page --graph my-graph"; + "logseq list page --graph my-graph --journal-only --limit 20"; + "logseq list page --graph my-graph --limit 50 --sort updated-at \ + --order desc"; + |]) + Command_id.List_page "List pages"; + meta + ~examples: + (Vec.of_array + [| + "logseq list tag --graph my-graph --with-properties"; + "logseq list tag --graph my-graph --include-built-in --limit 20 \ + --output json"; + |]) + List_tag "List tags"; + meta + ~examples: + (Vec.of_array + [| + "logseq list property --graph my-graph --with-type"; + "logseq list property --graph my-graph --include-built-in \ + --limit 20 --output json"; + |]) + List_property "List properties"; + meta + ~examples: + (Vec.of_array + [| + "logseq list task --graph my-graph --status todo --priority high"; + "logseq list task --graph my-graph --content \"release\" --sort \ + updated-at --order desc"; + |]) + List_task "List tasks"; + meta + ~examples: + (Vec.of_array + [| + "logseq list node --graph my-graph --tags project,work"; + "logseq list node --graph my-graph --properties status,priority \ + --sort updated-at --order desc"; + |]) + List_node "List nodes"; + meta + ~examples: + (Vec.of_array + [| + "logseq list asset --graph my-graph"; + "logseq list asset --graph my-graph --limit 20 --sort \ + updated-at --order desc"; + |]) + List_asset "List assets"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/log_types.ml b/cli/lib/log_types.ml new file mode 100644 index 0000000..2b4470a --- /dev/null +++ b/cli/lib/log_types.ml @@ -0,0 +1,27 @@ +type preview = { preview : string; length : int; truncated : bool } +type level = Debug | Info | Warn | Error + +type record = { + time : Js.Date.t option; + level : level; + logger : string option; + message : string; + exception_ : string option; +} + +let default_preview_limit = 400 +let handler_installed = ref false + +let truncate_preview ?(max_len = default_preview_limit) value = + let limit = max 0 max_len in + let text = + match Edn_util.as_string value with + | Some value -> value + | None -> Melange_edn_melange.to_edn_string value + in + let length = String.length text in + let truncated = length > limit in + let preview = if truncated then String.sub text 0 limit else text in + { preview; length; truncated } + +let install_stderr_handler () = handler_installed := true diff --git a/cli/lib/output.ml b/cli/lib/output.ml new file mode 100644 index 0000000..1cb3dac --- /dev/null +++ b/cli/lib/output.ml @@ -0,0 +1,144 @@ +type human +type json +type edn + +module Mode = struct + type _ t = Human : human t | Json : json t | Edn : edn t + and packed = Packed : _ t -> packed + + let default = Human + + let of_string = function + | "human" -> Some (Packed Human) + | "json" -> Some (Packed Json) + | "edn" -> Some (Packed Edn) + | _ -> None + + let to_string : type a. a t -> string = function + | Human -> "human" + | Json -> "json" + | Edn -> "edn" + + let structured : type a. a t -> bool = function + | Human -> false + | Json | Edn -> true +end + +module Human_output = struct + type t = { + headers : string Rrbvec.t option; + rows : string Rrbvec.t Rrbvec.t; + footer : string option; + } + + let create ?headers ?footer ~rows () = { headers; rows; footer } + + let has_suffix ~suffix value = + let suffix_len = String.length suffix in + let value_len = String.length value in + value_len >= suffix_len + && String.sub value (value_len - suffix_len) suffix_len = suffix + + let is_datetime_header header = + let header = String.lowercase_ascii header in + has_suffix ~suffix:"created-at" header + || has_suffix ~suffix:"updated-at" header + + let display_cell ~now_time ?header value = + let value = if value = "" then "-" else value in + match header with + | Some header when is_datetime_header header -> ( + match Int64.of_string_opt value with + | Some epoch_ms -> + Humanize_types.relative_datetime + ~then_time:(Time.time_of_epoch_ms epoch_ms) + ~now_time + | None -> value) + | _ -> value + + let display_rows t = + let now_time = Time.now () in + let headers = Option.value t.headers ~default:Vec.empty in + Vec.map + (fun row -> + Vec.mapi + (fun index value -> + let header = + match Vec.nth_opt headers index with + | Some header when is_datetime_header header -> Some header + | Some header + when String.lowercase_ascii header = "value" + && Vec.length row >= 2 -> + Vec.nth_opt row 0 + | header -> header + in + display_cell ~now_time ?header value) + row) + t.rows + + let column_widths rows = + rows + |> Vec.fold_left + (fun widths row -> + let width = max (Vec.length widths) (Vec.length row) in + Vec.init width (fun index -> + max + (Option.value (Vec.nth_opt widths index) ~default:0) + (Display_width.width + (Option.value (Vec.nth_opt row index) ~default:"")))) + Vec.empty + + let pad_right width value = + let padding = width - Display_width.width value in + if padding <= 0 then value else value ^ String.make padding ' ' + + let render_row widths row = + let last_index = Vec.length widths - 1 in + widths + |> Vec.mapi (fun index width -> + let value = Option.value (Vec.nth_opt row index) ~default:"" in + if index = last_index then value else pad_right width value) + |> Vec.string_concat " " + + let strip_leading_colon value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + + let is_keyword_namespace_char = function + | 'a' .. 'z' | '0' .. '9' | '.' | '-' | '_' -> true + | _ -> false + + let is_keyword_namespace value = + value <> "" && String.for_all is_keyword_namespace_char value + + let display_header header = + let header = strip_leading_colon header in + let parts = Vec.split_on_char '/' header in + if Vec.length parts = 2 then + let namespace = Vec.nth parts 0 in + let name = Vec.nth parts 1 in + if is_keyword_namespace namespace && name <> "" then name else header + else header + + let lines t = + let rows = display_rows t in + let table_rows = + match t.headers with + | None -> rows + | Some headers when Vec.is_empty headers -> rows + | Some headers -> Vec.push_front rows (Vec.map display_header headers) + in + let widths = column_widths table_rows in + let lines = Vec.map (render_row widths) table_rows in + match t.footer with + | Some footer -> Vec.push_back lines footer + | None -> lines + + let to_string t = Vec.string_concat "\n" (lines t) +end + +type _ t = + | Human : Human_output.t -> human t + | Edn : Melange_edn_melange.any -> edn t + | Json : Melange_edn_melange.any -> json t diff --git a/cli/lib/output_mode.ml b/cli/lib/output_mode.ml new file mode 100644 index 0000000..a31ddf2 --- /dev/null +++ b/cli/lib/output_mode.ml @@ -0,0 +1,18 @@ +type t = Output.Mode.packed + +let all = + [ + Output.Mode.Packed Output.Mode.Human; + Output.Mode.Packed Output.Mode.Json; + Output.Mode.Packed Output.Mode.Edn; + ] + +let default = Output.Mode.Packed Output.Mode.Human +let of_string = Output.Mode.of_string +let to_string (Output.Mode.Packed mode) = Output.Mode.to_string mode +let structured (Output.Mode.Packed mode) = Output.Mode.structured mode +let merge_preference xs = Vec.find_map (fun x -> x) xs +let for_config config = Option.value config.Cli_config.output_format ~default + +let error ?command ?context mode err = + Cli_result.error ?command ?context mode err diff --git a/cli/lib/platform/node/cli_effect.ml b/cli/lib/platform/node/cli_effect.ml new file mode 100644 index 0000000..5facc07 --- /dev/null +++ b/cli/lib/platform/node/cli_effect.ml @@ -0,0 +1,134 @@ +type 'a state = Pending | Resolved of 'a | Rejected of exn + +type 'a t = { + mutable state : 'a state; + mutable callbacks : ('a state -> unit) Rrbvec.t; +} + +type 'a resolver = 'a t + +let pure value = { state = Resolved value; callbacks = Rrbvec.empty } +let error exn = { state = Rejected exn; callbacks = Rrbvec.empty } + +let is_pending task = + match task.state with Pending -> true | Resolved _ | Rejected _ -> false + +let wait () = + let task = { state = Pending; callbacks = Rrbvec.empty } in + (task, task) + +let notify task state = + if is_pending task then ( + task.state <- state; + let callbacks = Rrbvec.rev task.callbacks in + task.callbacks <- Rrbvec.empty; + Rrbvec.iter (fun callback -> callback state) callbacks) + +let wakeup resolver value = notify resolver (Resolved value) +let reject resolver exn = notify resolver (Rejected exn) + +let on_state task callback = + match task.state with + | Pending -> task.callbacks <- Rrbvec.push_front task.callbacks callback + | state -> callback state + +let bind task f = + let result, resolver = wait () in + on_state task (function + | Pending -> () + | Resolved value -> ( + try + let next = f value in + on_state next (function + | Pending -> () + | Resolved value -> wakeup resolver value + | Rejected exn -> reject resolver exn) + with exn -> reject resolver exn) + | Rejected exn -> reject resolver exn); + result + +module Infix = struct + let ( >>= ) = bind +end + +let map f task = bind task (fun value -> pure (f value)) + +let rec map_s f values = + match Rrbvec.pop_front values with + | None -> pure Rrbvec.empty + | Some (value, rest) -> + bind (f value) (fun mapped -> + map + (fun mapped_rest -> Rrbvec.push_front mapped_rest mapped) + (map_s f rest)) + +let both left right = + bind left (fun left_value -> + map (fun right_value -> (left_value, right_value)) right) + +let all tasks = + let result, resolver = wait () in + let pending = ref (Rrbvec.length tasks) in + let values = Array.make (Rrbvec.length tasks) None in + let finish_if_ready () = + if !pending = 0 && is_pending result then + wakeup resolver (values |> Array.map Option.get |> Rrbvec.of_array) + in + Rrbvec.iteri + (fun index task -> + on_state task (function + | Pending -> () + | Resolved value -> + values.(index) <- Some value; + decr pending; + finish_if_ready () + | Rejected exn -> if is_pending result then reject resolver exn)) + tasks; + finish_if_ready (); + result + +let catch value handler = + let result, resolver = wait () in + on_state value (function + | Pending -> () + | Resolved value -> wakeup resolver value + | Rejected exn -> ( + try + let next = handler exn in + on_state next (function + | Pending -> () + | Resolved value -> wakeup resolver value + | Rejected exn -> reject resolver exn) + with exn -> reject resolver exn)); + result + +let finally value f = + let result, resolver = wait () in + let finish state = + let cleanup = try f () with exn -> error exn in + on_state cleanup (function + | Pending -> () + | Resolved () -> ( + match state with + | Pending -> () + | Resolved value -> wakeup resolver value + | Rejected exn -> reject resolver exn) + | Rejected exn -> reject resolver exn) + in + on_state value finish; + result + +let async f = ignore (catch (f ()) (fun _ -> pure ()) : unit t) + +let on_any task on_ok on_error = + on_state task (function + | Pending -> () + | Resolved value -> on_ok value + | Rejected exn -> on_error exn) + +let sleep span = + let task, resolver = wait () in + ignore + (Js.Global.setTimeout ~f:(fun () -> wakeup resolver ()) (int_of_float span) + : Js.Global.timeoutId); + task diff --git a/cli/lib/platform/node/cli_platform.ml b/cli/lib/platform/node/cli_platform.ml new file mode 100644 index 0000000..e04ac03 --- /dev/null +++ b/cli/lib/platform/node/cli_platform.ml @@ -0,0 +1,378 @@ +open Cli_effect.Infix + +let hostname = Cli_unix.gethostname + +module Stdout = struct + type writable + + external stdout : writable = "stdout" [@@mel.module "process"] + + external on_error : + writable -> (_[@mel.as "error"]) -> ((Js.Exn.t -> unit)[@u]) -> unit = "on" + [@@mel.send] + + external error_code : Js.Exn.t -> string option = "code" + [@@mel.get] [@@mel.return { undefined_to_opt }] +end + +let stdout_error_handler_installed = ref false + +let install_stdout_error_handler () = + if not !stdout_error_handler_installed then ( + stdout_error_handler_installed := true; + Stdout.on_error Stdout.stdout (fun[@u] error -> + match Stdout.error_code error with + | Some "EPIPE" -> exit 0 + | _ -> + Js.Exn.raiseError + (Option.value (Js.Exn.message error) ~default:"JavaScript error"))) + +let argv () = + install_stdout_error_handler (); + Node.Process.argv + +type login_callback_request = { target : string option } +type login_callback_response = { status : int; body : string } + +type login_callback_server_error = + | Login_callback_timeout + | Login_callback_server_start_failed of string + | Login_callback_server_aborted of string + +let timeout_ms span = if Float.compare span 0. > 0 then int_of_float span else 0 + +module Timer = struct + let set_timeout f ms = Js.Global.setTimeout ~f ms + let clear_timeout = Js.Global.clearTimeout +end + +module Http_server = struct + type server + type request + type response + + external create_server : ((request -> response -> unit)[@u]) -> server + = "createServer" + [@@mel.module "http"] + + external listen : server -> int -> string -> ((unit -> unit)[@u]) -> unit + = "listen" + [@@mel.send] + + external on_error : + server -> (_[@mel.as "error"]) -> ((Js.Exn.t -> unit)[@u]) -> unit = "on" + [@@mel.send] + + external close : server -> ((unit -> unit)[@u]) -> unit = "close" [@@mel.send] + + external target : request -> string option = "url" + [@@mel.get] [@@mel.return { undefined_to_opt }] + + external write_head : response -> int -> string Js.Dict.t -> unit + = "writeHead" + [@@mel.send] + + external end_ : response -> string -> ((unit -> unit)[@u]) -> unit = "end" + [@@mel.send] + + let text_headers () = + let headers = Js.Dict.empty () in + Js.Dict.set headers "Content-Type" "text/plain; charset=utf-8"; + Js.Dict.set headers "Connection" "close"; + headers + + let close_ignore server = try close server (fun[@u] () -> ()) with _ -> () +end + +let js_error_message exn = + match Js.Exn.message exn with + | Some message when String.trim message <> "" -> message + | _ -> "JavaScript error" + +let login_callback_server ~host ~port ~timeout_span ~on_listen ~handle_request = + let task, resolver = Cli_effect.wait () in + let settled = ref false in + let server_ref = ref None in + let timeout_ref = ref None in + let clear_timer () = + match !timeout_ref with + | None -> () + | Some timeout_handle -> + timeout_ref := None; + Timer.clear_timeout timeout_handle + in + let close_server () = + match !server_ref with + | None -> () + | Some server -> + server_ref := None; + Http_server.close_ignore server + in + let finish result = + if not !settled then ( + settled := true; + clear_timer (); + close_server (); + Cli_effect.wakeup resolver result) + in + let on_request request response = + if !settled then ( + Http_server.write_head response 410 (Http_server.text_headers ()); + Http_server.end_ response "Login callback already handled" (fun[@u] () -> + ())) + else + let response_body, result = + handle_request { target = Http_server.target request } + in + finish (Ok result); + Http_server.write_head response response_body.status + (Http_server.text_headers ()); + Http_server.end_ response response_body.body (fun[@u] () -> + match !server_ref with + | None -> () + | Some server -> Http_server.close_ignore server) + in + let on_listening () = + let timeout = timeout_ms timeout_span in + if timeout > 0 then + timeout_ref := + Some + (Timer.set_timeout + (fun () -> finish (Error Login_callback_timeout)) + timeout); + Cli_effect.on_any (on_listen ()) + (function + | Ok () -> () + | Error message -> + finish (Error (Login_callback_server_aborted message))) + (fun exn -> + finish (Error (Login_callback_server_aborted (Printexc.to_string exn)))) + in + try + let server = + Http_server.create_server (fun[@u] request response -> + on_request request response) + in + server_ref := Some server; + Http_server.on_error server (fun[@u] error -> + finish + (Error (Login_callback_server_start_failed (js_error_message error)))); + Http_server.listen server port host (fun[@u] () -> on_listening ()); + task + with exn -> + finish (Error (Login_callback_server_start_failed (Printexc.to_string exn))); + task + +module Symbols = struct + let ellipsis = Js.String.fromCodePoint 0x2026 + let linked_arrow = Js.String.fromCodePoint 0x2192 ^ " " + let tree_line = Js.String.fromCodePoint 0x2500 + let tree_last = Js.String.fromCodePoint 0x2514 ^ tree_line ^ tree_line ^ " " + let tree_middle = Js.String.fromCodePoint 0x251c ^ tree_line ^ tree_line ^ " " + let tree_pipe = Js.String.fromCodePoint 0x2502 ^ " " +end + +module HTTP = struct + external promise_error_name : Js.Promise.error -> string option = "name" + [@@mel.get] [@@mel.return { undefined_to_opt }] + + external promise_error_message : Js.Promise.error -> string option = "message" + [@@mel.get] [@@mel.return { undefined_to_opt }] + + let promise_to_effect ?(on_settle = fun () -> ()) promise = + let task, resolver = Cli_effect.wait () in + let finish result = + if Cli_effect.is_pending task then Cli_effect.wakeup resolver result + in + let on_ok value = + on_settle (); + finish (Ok value); + Js.Promise.resolve () + in + let on_error error = + let message = + match promise_error_name error with + | Some "AbortError" -> "request timeout" + | _ -> + Option.value + (promise_error_message error) + ~default:"JavaScript promise rejected" + in + on_settle (); + finish (Error message); + Js.Promise.resolve () + in + ignore + (promise |> Js.Promise.then_ on_ok |> Js.Promise.catch on_error + : unit Js.Promise.t); + task >>= function + | Ok value -> Cli_effect.pure value + | Error message -> Cli_effect.error (Failure message) + + let timeout_ms = function + | Some timeout_span when Float.compare timeout_span 0. > 0 -> + int_of_float timeout_span + | _ -> 0 + + let is_body_forbidden = function Fetch.Get | Fetch.Head -> true | _ -> false + + let request_body method_ body = + if is_body_forbidden method_ && body <> "" then + invalid_arg "GET and HEAD requests must not include a body" + else if body = "" then None + else Some (Fetch.BodyInit.make body) + + let timeout controller timeout_span = + match timeout_ms timeout_span with + | 0 -> None + | ms -> + Some + (Timer.set_timeout + (fun () -> Fetch.AbortController.abort controller) + ms) + + let clear_timeout_opt = function + | None -> () + | Some timeout_handle -> Timer.clear_timeout timeout_handle + + let response_text response = + Fetch.Response.text response + |> Js.Promise.then_ (fun body -> Js.Promise.resolve (response, body)) + + let request ?timeout_span method_ url ~headers ~body = + let controller = Fetch.AbortController.make () in + let timeout_handle = timeout controller timeout_span in + let body = request_body method_ body in + let init = + Fetch.RequestInit.make ~method_ + ~headers:(Fetch.HeadersInit.makeWithArray (Rrbvec.to_array headers)) + ?body + ~signal:(Fetch.AbortController.signal controller) + () + in + promise_to_effect + ~on_settle:(fun () -> clear_timeout_opt timeout_handle) + (Fetch.fetchWithInit url init |> Js.Promise.then_ response_text) +end + +module Crypto = struct + module Node_crypto = struct + type hash + + external create_hash : string -> hash = "createHash" [@@mel.module "crypto"] + external update : hash -> string -> string -> hash = "update" [@@mel.send] + external digest : hash -> string -> string = "digest" [@@mel.send] + + external random_bytes : int -> Node.Buffer.t = "randomBytes" + [@@mel.module "crypto"] + end + + let sha256_digest output_encoding text = + let hash = Node_crypto.create_hash "sha256" in + let hash = Node_crypto.update hash text "latin1" in + Node_crypto.digest hash output_encoding + + let sha256_hex text = sha256_digest "hex" text + let sha256_base64url text = sha256_digest "base64url" text + + let random_base64url size = + let text = + Node_crypto.random_bytes size |> Node.Buffer.toString ~encoding:`base64url + in + if String.length text <= size then text else String.sub text 0 size + + let base64url_decode text = + Node.Buffer.fromStringWithEncoding text ~encoding:`base64url + |> Node.Buffer.toString ~encoding:`utf8 +end + +module Events = struct + type subscription = { close : unit -> unit Cli_effect.t } + type readable_stream + type reader + type read_result + type text_decoder + type uint8_array + + external response_body : Fetch.Response.t -> readable_stream option = "body" + [@@mel.get] [@@mel.return { null_to_opt }] + + external get_reader : readable_stream -> reader = "getReader" [@@mel.send] + external read : reader -> read_result Js.Promise.t = "read" [@@mel.send] + external cancel : reader -> unit Js.Promise.t = "cancel" [@@mel.send] + external read_done : read_result -> bool = "done" [@@mel.get] + + external read_value : read_result -> uint8_array option = "value" + [@@mel.get] [@@mel.return { undefined_to_opt }] + + external make_decoder : unit -> text_decoder = "TextDecoder" [@@mel.new] + + external decode : + text_decoder -> uint8_array -> < stream : bool > Js.t -> string = "decode" + [@@mel.send] + + let stream_options = + let options = Js.Obj.empty () in + Js.Obj.assign options [%obj { stream = true }] + + let rec read_loop closed decoder reader on_chunk = + if !closed then Js.Promise.resolve () + else + read reader + |> Js.Promise.then_ (fun result -> + if !closed || read_done result then Js.Promise.resolve () + else ( + result |> read_value + |> Option.iter (fun value -> + on_chunk (decode decoder value stream_options)); + read_loop closed decoder reader on_chunk)) + + let connect ~url ~on_chunk = + let closed = ref false in + let reader_ref = ref None in + let controller = Fetch.AbortController.make () in + let init = + Fetch.RequestInit.make ~method_:Fetch.Get + ~headers: + (Fetch.HeadersInit.makeWithArray + [| ("Accept", "text/event-stream") |]) + ~signal:(Fetch.AbortController.signal controller) + () + in + Cli_effect.bind + (HTTP.promise_to_effect (Fetch.fetchWithInit url init)) + (fun response -> + if not (Fetch.Response.ok response) then + Cli_effect.error + (Js.Exn.raiseError + ("events request failed (" + ^ string_of_int (Fetch.Response.status response) + ^ ")")) + else + let request = + (match response_body response with + | None -> + Fetch.Response.text response + |> Js.Promise.then_ (fun text -> + if not !closed then on_chunk text; + Js.Promise.resolve ()) + | Some body -> + let reader = get_reader body in + reader_ref := Some reader; + read_loop closed (make_decoder ()) reader on_chunk) + |> Js.Promise.catch (fun _ -> Js.Promise.resolve ()) + in + let close () = + Fetch.AbortController.abort controller; + (match !reader_ref with + | None -> () + | Some reader -> + ignore + (cancel reader + |> Js.Promise.catch (fun _ -> Js.Promise.resolve ()) + : unit Js.Promise.t)); + Cli_effect.catch (HTTP.promise_to_effect request) (fun _ -> + Cli_effect.pure ()) + in + Cli_effect.pure { close }) +end diff --git a/cli/lib/platform/node/cli_unix.ml b/cli/lib/platform/node/cli_unix.ml new file mode 100644 index 0000000..7eea347 --- /dev/null +++ b/cli/lib/platform/node/cli_unix.ml @@ -0,0 +1,463 @@ +type error = EAGAIN | EWOULDBLOCK | ETIMEDOUT | ESRCH | EPERM +type access_permission = R_OK | W_OK +type open_flag = O_RDWR +type file_descr = int +type stats = { st_size : int; st_mtime : float } +type process_result = { status : int; stdout : string; stderr : string } +type mkdir_result = Created | Already_exists + +exception Cli_unix_error of error * string * string + +module Fs = struct + type mkdir_options + type rm_options + type stat + + external mkdir_options : mode:int -> unit -> mkdir_options = "" [@@mel.obj] + + external rm_options : recursive:bool -> force:bool -> unit -> rm_options = "" + [@@mel.obj] + + external mkdir_sync : string -> mkdir_options -> unit = "mkdirSync" + [@@mel.module "fs"] + + external stat_sync : string -> stat = "statSync" [@@mel.module "fs"] + + external read_fd_utf8_sync : int -> (_[@mel.as "utf8"]) -> string + = "readFileSync" + [@@mel.module "fs"] + + external copy_file_sync : string -> string -> unit = "copyFileSync" + [@@mel.module "fs"] + + external rm_sync : string -> rm_options -> unit = "rmSync" [@@mel.module "fs"] + external chmod_sync : string -> int -> unit = "chmodSync" [@@mel.module "fs"] + + external access_sync : string -> int -> unit = "accessSync" + [@@mel.module "fs"] + + external open_sync : string -> string -> int = "openSync" [@@mel.module "fs"] + external close_sync : int -> unit = "closeSync" [@@mel.module "fs"] + external mkdtemp_sync : string -> string = "mkdtempSync" [@@mel.module "fs"] + external size : stat -> int = "size" [@@mel.get] + external mtime_ms : stat -> float = "mtimeMs" [@@mel.get] + external is_directory : stat -> bool = "isDirectory" [@@mel.send] +end + +module Os = struct + external hostname : unit -> string = "hostname" [@@mel.module "os"] + external tmpdir : unit -> string = "tmpdir" [@@mel.module "os"] +end + +module Process = struct + type writable + + external pid : int = "pid" [@@mel.module "process"] + external stdout : writable = "stdout" [@@mel.module "process"] + external kill : int -> int -> unit = "kill" [@@mel.module "process"] + external write : writable -> string -> bool = "write" [@@mel.send] +end + +module Child_process = struct + type spawn_options + type spawn_sync_options + type child + type spawn_result + + external spawn_options : + ?detached:bool -> + ?stdio:Js.Json.t array -> + ?env:string Js.Dict.t -> + ?shell:bool -> + unit -> + spawn_options = "" + [@@mel.obj] + + external spawn_sync_options : + ?encoding:string -> + ?env:string Js.Dict.t -> + ?shell:bool -> + unit -> + spawn_sync_options = "" + [@@mel.obj] + + external spawn : string -> string array -> spawn_options -> child = "spawn" + [@@mel.module "child_process"] + + external spawn_sync : + string -> string array -> spawn_sync_options -> spawn_result = "spawnSync" + [@@mel.module "child_process"] + + external pid : child -> int Js.nullable = "pid" [@@mel.get] + external unref : child -> unit = "unref" [@@mel.send] + external status : spawn_result -> int Js.nullable = "status" [@@mel.get] + external stdout : spawn_result -> string Js.nullable = "stdout" [@@mel.get] + external stderr : spawn_result -> string Js.nullable = "stderr" [@@mel.get] + external error : spawn_result -> Js.Exn.t Js.nullable = "error" [@@mel.get] +end + +module Atomics_sleep = struct + type shared_array_buffer + type int32_array + + external make_shared_array_buffer : int -> shared_array_buffer + = "SharedArrayBuffer" + [@@mel.new] + + external make_int32_array : shared_array_buffer -> int32_array = "Int32Array" + [@@mel.new] + + external wait : int32_array -> int -> int -> int -> string = "wait" + [@@mel.scope "Atomics"] + + let buffer = make_int32_array (make_shared_array_buffer 4) + let sleep_ms ms = ignore (wait buffer 0 0 ms : string) +end + +external error_code : Js.Exn.t -> string option = "code" [@@mel.get] + +let js_error_message exn = + match Js.Exn.asJsExn exn with + | Some error -> + Option.value (Js.Exn.message error) ~default:(Printexc.to_string exn) + | None -> Printexc.to_string exn + +let js_error_code exn = + match Js.Exn.asJsExn exn with Some error -> error_code error | None -> None + +let unix_error_of_code = function + | Some "EAGAIN" -> EAGAIN + | Some "EWOULDBLOCK" -> EWOULDBLOCK + | Some "ETIMEDOUT" -> ETIMEDOUT + | Some ("ENOENT" | "ESRCH") -> ESRCH + | Some ("EACCES" | "EPERM") -> EPERM + | _ -> EPERM + +let raise_error ?error op detail exn = + let detail = + let message = js_error_message exn in + if String.trim message = "" then detail else message + in + let error = + Option.value error ~default:(unix_error_of_code (js_error_code exn)) + in + raise (Cli_unix_error (error, op, detail)) + +let run_unit op detail fn = try fn () with exn -> raise_error op detail exn + +let mkdir path perm = + run_unit "mkdir" path (fun () -> + Fs.mkdir_sync path (Fs.mkdir_options ~mode:perm ())) + +let rmdir path = run_unit "rmdir" path (fun () -> Node.Fs.rmdirSync path) + +let mkdir_exclusive path perm = + try + Fs.mkdir_sync path (Fs.mkdir_options ~mode:perm ()); + Created + with exn -> ( + match js_error_code exn with + | Some "EEXIST" -> Already_exists + | _ -> raise_error "mkdir" path exn) + +let file_exists = Node.Fs.existsSync + +let is_directory path = + try Fs.is_directory (Fs.stat_sync path) with _ -> false + +let readdir path = + try Node.Fs.readdirSync path with exn -> raise_error "readdir" path exn + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || file_exists path then () + else ( + mkdir_p (Filename.dirname path); + match mkdir_exclusive path 0o755 with Created | Already_exists -> ()) + +let write_text_file path content = + run_unit "write" path (fun () -> Node.Fs.writeFileAsUtf8Sync path content) + +let read_text_file path = + try Node.Fs.readFileAsUtf8Sync path with exn -> raise_error "read" path exn + +let read_stdin_all () = + try Fs.read_fd_utf8_sync 0 with exn -> raise_error "read" "stdin" exn + +let write_binary_file path content = + run_unit "write" path (fun () -> Node.Fs.writeFileSync path content `latin1) + +let read_binary_file path = + try Node.Fs.readFileSync path `latin1 + with exn -> raise_error "read" path exn + +let copy_file source destination = + run_unit "copy" source (fun () -> Fs.copy_file_sync source destination) + +let remove_tree path = + run_unit "rm" path (fun () -> + Fs.rm_sync path (Fs.rm_options ~recursive:true ~force:true ())) + +let rename source destination = + run_unit "rename" source (fun () -> Node.Fs.renameSync source destination) + +let getpid () = Process.pid + +let process_running pid = + pid > 0 + && + try + Process.kill pid 0; + true + with exn -> ( + match js_error_code exn with Some "ESRCH" -> false | _ -> true) + +let chmod path perm = run_unit "chmod" path (fun () -> Fs.chmod_sync path perm) + +let access path permissions = + let flag = + Rrbvec.fold_left + (fun acc -> function R_OK -> acc lor 4 | W_OK -> acc lor 2) + 0 permissions + in + try Fs.access_sync path flag + with _ -> + if file_exists path then raise (Cli_unix_error (EPERM, "access", path)) + else raise (Cli_unix_error (ESRCH, "access", path)) + +let stat path = + try + let stat = Fs.stat_sync path in + { st_size = Fs.size stat; st_mtime = Fs.mtime_ms stat /. 1000. } + with exn -> raise_error "stat" path exn + +let environment () = + Node.Process.process##env |> Js.Dict.entries + |> Array.map (fun (key, value) -> key ^ "=" ^ value) + +let gethostname () = try Os.hostname () with _ -> "" +let openfile _path _flags _perm = 0 +let close _fd = () + +let command_args argv = + let argv = Rrbvec.of_array argv in + match Rrbvec.pop_front argv with + | Some ("env", rest) -> ( + match Rrbvec.pop_front rest with + | Some (command, args) -> (command, args) + | None -> + raise + (Cli_unix_error (EPERM, "create_process_env", "missing command"))) + | Some (command, args) -> (command, args) + | None -> + raise (Cli_unix_error (EPERM, "create_process_env", "missing command")) + +let copy_env env = + let result = Js.Dict.empty () in + Node.Process.process##env |> Js.Dict.entries + |> Array.iter (fun (key, value) -> Js.Dict.set result key value); + Array.iter + (fun entry -> + match String.index_opt entry '=' with + | None -> () + | Some index -> + Js.Dict.set result (String.sub entry 0 index) + (String.sub entry (index + 1) (String.length entry - index - 1))) + env; + result + +let stdio_ignore = + [| + Js.Json.string "ignore"; Js.Json.string "ignore"; Js.Json.string "ignore"; + |] + +let spawn_detached command args env = + let child = + Child_process.spawn command (Rrbvec.to_array args) + (Child_process.spawn_options ~detached:true ~stdio:stdio_ignore + ~env:(copy_env env) ~shell:false ()) + in + Child_process.unref child; + Child_process.pid child |> Js.Nullable.toOption |> Option.value ~default:0 + +let windows_verbatim_spawn_options env : Child_process.spawn_options = + let make : string Js.Dict.t -> Js.Json.t array -> Child_process.spawn_options + = + [%mel.raw + {| +function (env, stdio) { + return { + detached: true, + stdio: stdio, + env: env, + shell: false, + windowsVerbatimArguments: true + }; +} +|}] + in + make (copy_env env) stdio_ignore + +let spawn_detached_windows_verbatim command args env = + let child = + Child_process.spawn command (Rrbvec.to_array args) + (windows_verbatim_spawn_options env) + in + Child_process.unref child; + Child_process.pid child |> Js.Nullable.toOption |> Option.value ~default:0 + +let create_process_env _prog argv env _stdin _stdout _stderr = + let command, args = command_args argv in + try spawn_detached command args env + with exn -> raise_error "create_process_env" command exn + +let nullable_string_default value = + value |> Js.Nullable.toOption |> Option.value ~default:"" + +let run_process_capture command args env = + let result = + Child_process.spawn_sync command (Rrbvec.to_array args) + (Child_process.spawn_sync_options ~encoding:"utf8" ~env:(copy_env env) + ~shell:false ()) + in + let status = + result |> Child_process.status |> Js.Nullable.toOption + |> Option.value ~default:1 + in + let stderr = + match result |> Child_process.error |> Js.Nullable.toOption with + | Some error -> + Option.value (Js.Exn.message error) + ~default:(nullable_string_default (Child_process.stderr result)) + | None -> nullable_string_default (Child_process.stderr result) + in + { + status; + stdout = nullable_string_default (Child_process.stdout result); + stderr; + } + +let read_text_file_default path = + try Node.Fs.readFileAsUtf8Sync path with _ -> "" + +let contains_substring ~needle haystack = + let needle_len = String.length needle in + let haystack_len = String.length haystack in + let rec loop index = + if needle_len = 0 then true + else if index + needle_len > haystack_len then false + else if String.sub haystack index needle_len = needle then true + else loop (index + 1) + in + loop 0 + +let split_lines text = + let len = String.length text in + let rec loop start index acc = + if index = len then + Rrbvec.push_back acc (String.sub text start (index - start)) + else if text.[index] = '\n' then + let acc = Rrbvec.push_back acc (String.sub text start (index - start)) in + loop (index + 1) (index + 1) acc + else loop start (index + 1) acc + in + loop 0 0 Rrbvec.empty + +let find_session_line text = + text |> split_lines + |> Rrbvec.find_opt (fun line -> + let lower = String.lowercase_ascii line in + contains_substring ~needle:"session" lower + || contains_substring ~needle:"thread" lower) + +let start_process_capture_session_line command args env = + let tmp_dir = + Fs.mkdtemp_sync (Node.Path.join [| Os.tmpdir (); "logseq-cli-session-" |]) + in + let stdout_path = Node.Path.join [| tmp_dir; "stdout.log" |] in + let stderr_path = Node.Path.join [| tmp_dir; "stderr.log" |] in + let stdout_fd = Fs.open_sync stdout_path "a" in + let stderr_fd = Fs.open_sync stderr_path "a" in + let child = + try + Child_process.spawn command (Rrbvec.to_array args) + (Child_process.spawn_options ~detached:true + ~stdio: + [| + Js.Json.string "ignore"; + Js.Json.number (float_of_int stdout_fd); + Js.Json.number (float_of_int stderr_fd); + |] + ~env:(copy_env env) ~shell:false ()) + with exn -> + (try Fs.close_sync stdout_fd with _ -> ()); + (try Fs.close_sync stderr_fd with _ -> ()); + raise_error "spawn" command exn + in + (try Fs.close_sync stdout_fd with _ -> ()); + (try Fs.close_sync stderr_fd with _ -> ()); + let pid = + Child_process.pid child |> Js.Nullable.toOption |> Option.value ~default:0 + in + let deadline = Js.Date.now () +. 30_000. in + let rec loop last_stdout = + let stdout = read_text_file_default stdout_path in + match find_session_line stdout with + | Some line -> + Child_process.unref child; + { + status = 0; + stdout = line; + stderr = read_text_file_default stderr_path; + } + | None when Js.Date.now () >= deadline -> + Child_process.unref child; + { + status = 124; + stdout = (if stdout = "" then last_stdout else stdout); + stderr = + (let stderr = read_text_file_default stderr_path in + if String.trim stderr = "" then "timed out waiting for session id" + else stderr); + } + | None when pid <= 0 || not (process_running pid) -> + { status = 1; stdout; stderr = read_text_file_default stderr_path } + | None -> + Atomics_sleep.sleep_ms 50; + loop stdout + in + loop "" + +let kill pid signal = + try Process.kill pid signal + with _ -> raise (Cli_unix_error (ESRCH, "kill", string_of_int pid)) + +let unquote_double_quoted value = + let len = String.length value in + if len > 1 && value.[0] = '"' && value.[len - 1] = '"' then + String.sub value 1 (len - 2) + else value + +let windows_start_url_command url = + "start \"\" \"" ^ unquote_double_quoted url ^ "\"" + +let open_url url = + try + let platform = Node.Process.process##platform in + let command, args = + match platform with + | "darwin" -> ("open", Rrbvec.of_array [| url |]) + | "linux" -> ("xdg-open", Rrbvec.of_array [| url |]) + | "win32" -> + ( "cmd.exe", + Rrbvec.of_array [| "/d"; "/c"; windows_start_url_command url |] ) + | _ -> raise (Cli_unix_error (EPERM, "open", url)) + in + ignore + ((if platform = "win32" then spawn_detached_windows_verbatim + else spawn_detached) + command args (environment ()) + : int); + true + with _ -> false + +let write_stdout text = ignore (Process.write Process.stdout text : bool) diff --git a/cli/lib/platform/node/dune b/cli/lib/platform/node/dune new file mode 100644 index 0000000..84d4aeb --- /dev/null +++ b/cli/lib/platform/node/dune @@ -0,0 +1,7 @@ +(library + (name logseq_cli_platform_node) + (implements logseq_cli_platform_spec) + (modes melange) + (preprocess + (pps melange.ppx)) + (libraries melange-fetch melange.js melange.node rrbvec)) diff --git a/cli/lib/pretty_print.ml b/cli/lib/pretty_print.ml new file mode 100644 index 0000000..01a99fe --- /dev/null +++ b/cli/lib/pretty_print.ml @@ -0,0 +1,68 @@ +external stringify_json_with_space : + Js.Json.t -> (_[@mel.as {json|null|json}]) -> int -> string = "stringify" +[@@mel.scope "JSON"] + +let margin = 80 +let spaces count = String.make (max 0 count) ' ' +let vec_of_array = Rrbvec.of_array + +let rec flat_value (Melange_edn_melange.Any value as any) = + match value with + | Melange_edn_melange.Map fields -> + vec_of_array fields + |> Vec.map (fun (key, value) -> flat_value key ^ " " ^ flat_value value) + |> Vec.string_concat ", " + |> fun content -> "{" ^ content ^ "}" + | Melange_edn_melange.Vector values -> flat_seq "[" "]" (vec_of_array values) + | Melange_edn_melange.List values -> flat_seq "(" ")" (vec_of_array values) + | Melange_edn_melange.Set values -> flat_seq "#{" "}" (vec_of_array values) + | Melange_edn_melange.Tagged (tag, value) -> + "#" ^ tag ^ " " ^ flat_value value + | _ -> Melange_edn_melange.to_edn_string any + +and flat_seq open_ close values = + values |> Vec.map flat_value |> Vec.string_concat " " |> fun content -> + open_ ^ content ^ close + +let rec format_value indent (Melange_edn_melange.Any value as any) = + let flat = flat_value any in + if indent + String.length flat <= margin then flat + else + match value with + | Melange_edn_melange.Map fields -> format_map indent (vec_of_array fields) + | Melange_edn_melange.Vector values -> + format_seq indent "[" "]" 1 (vec_of_array values) + | Melange_edn_melange.List values -> + format_seq indent "(" ")" 1 (vec_of_array values) + | Melange_edn_melange.Set values -> + format_seq indent "#{" "}" 2 (vec_of_array values) + | _ -> flat + +and format_map indent fields = + if Vec.is_empty fields then "{}" + else + let entry_indent = indent + 1 in + let last_index = Vec.length fields - 1 in + fields + |> Vec.mapi (fun index (key, value) -> + let key = flat_value key in + let value_indent = entry_indent + String.length key + 1 in + let prefix = if index = 0 then "{" else spaces entry_indent in + let suffix = if index = last_index then "" else "," in + prefix ^ key ^ " " ^ format_value value_indent value ^ suffix) + |> Vec.string_concat "\n" + |> fun content -> content ^ "}" + +and format_seq indent open_ close open_width values = + if Vec.is_empty values then open_ ^ close + else + let entry_indent = indent + open_width in + values + |> Vec.mapi (fun index value -> + let prefix = if index = 0 then open_ else spaces entry_indent in + prefix ^ format_value entry_indent value) + |> Vec.string_concat "\n" + |> fun content -> content ^ close + +let pprint_edn value = format_value 0 value ^ "\n" +let pprint_json json = stringify_json_with_space json 2 diff --git a/cli/lib/profile_types.ml b/cli/lib/profile_types.ml new file mode 100644 index 0000000..de762eb --- /dev/null +++ b/cli/lib/profile_types.ml @@ -0,0 +1,213 @@ +type span = { + stage : string; + span_id : int; + start_time : Js.Date.t; + end_time : Js.Date.t; + elapsed_span : float; +} + +type stage_summary = { + stage : string; + count : int; + total_span : float; + avg_span : float; +} + +type session = { + enabled : bool; + start_time : Js.Date.t; + mutable spans : span Rrbvec.t; + mutable next_span_id : int; +} + +type report = { + command : string; + status : Cli_primitive.keyword; + total_span : float; + spans : span Rrbvec.t; + stages : stage_summary Rrbvec.t; +} + +let create_session enabled = + if enabled then + Some + { enabled; start_time = Time.now (); spans = Vec.empty; next_span_id = 0 } + else None + +let next_span_id session = + session.next_span_id <- session.next_span_id + 1; + session.next_span_id + +let record_span (s : session) span = s.spans <- Vec.push_back s.spans span + +let record_timed_span session stage span_id start_time end_time = + let elapsed_span = Time.non_negative_diff ~start_time ~end_time in + record_span session { stage; span_id; start_time; end_time; elapsed_span } + +let time session stage f = + match session with + | None -> f () + | Some session -> ( + let start_time = Time.now () in + let span_id = next_span_id session in + let finish () = + record_timed_span session stage span_id start_time (Time.now ()); + Cli_effect.pure () + in + try Cli_effect.finally (f ()) finish + with exn -> + record_timed_span session stage span_id start_time (Time.now ()); + raise exn) + +let summarize_stages spans = + let table = Hashtbl.create 16 in + let order = ref Vec.empty in + Vec.iter + (fun (span : span) -> + if not (Hashtbl.mem table span.stage) then + order := Vec.push_back !order span.stage; + let count, total_span = + Option.value + (Hashtbl.find_opt table span.stage) + ~default:(0, Time.zero_span) + in + Hashtbl.replace table span.stage + (count + 1, Time.add_span_value total_span span.elapsed_span)) + spans; + Vec.map + (fun stage -> + let count, total_span = Hashtbl.find table stage in + { stage; count; total_span; avg_span = Time.avg_span total_span count }) + !order + +let report (s : session) ~command ~status = + let end_time = Time.now () in + let total_span = Time.non_negative_diff ~start_time:s.start_time ~end_time in + let spans : span Rrbvec.t = s.spans in + let spans = + if Vec.exists (fun (span : span) -> span.stage = "cli.total") spans then + spans + else + Vec.push_back spans + { + stage = "cli.total"; + span_id = 0; + start_time = s.start_time; + end_time; + elapsed_span = total_span; + } + in + { command; status; total_span; spans; stages = summarize_stages spans } + +type stage_node = { + label : string; + elapsed_span : float; + start_time : Js.Date.t; + end_time : Js.Date.t; + mutable children : stage_node Rrbvec.t; +} + +let span_contains outer inner = + Time.compare_time outer.start_time inner.start_time <= 0 + && Time.compare_time outer.end_time inner.end_time >= 0 + +let node_of_span (span : span) = + { + label = span.stage; + elapsed_span = span.elapsed_span; + start_time = span.start_time; + end_time = span.end_time; + children = Vec.empty; + } + +let sort_spans spans = + Vec.sort + (fun (left : span) (right : span) -> + let duration_left = left.elapsed_span in + let duration_right = right.elapsed_span in + match Time.compare_time left.start_time right.start_time with + | 0 -> ( + match Time.compare_span duration_right duration_left with + | 0 -> compare left.span_id right.span_id + | value -> value) + | value -> value) + spans + +let build_stage_tree spans = + let roots = ref Vec.empty in + let stack = ref Vec.empty in + let rec trim_stack span items = + match Vec.pop_front items with + | Some (parent, rest) when not (span_contains parent span) -> + trim_stack span rest + | _ -> items + in + Vec.iter + (fun span -> + let node = node_of_span span in + let current_stack = trim_stack node !stack in + (match Vec.peek_front_opt current_stack with + | Some parent -> parent.children <- Vec.push_back parent.children node + | None -> roots := Vec.push_back !roots node); + stack := Vec.push_front current_stack node) + (sort_spans spans); + !roots + +let status_text status = + let status = Edn_util.keyword_to_string status in + if String.length status > 0 && status.[0] = ':' then + String.sub status 1 (String.length status - 1) + else status + +let rec collect_rows prefix rows = function + | nodes when Vec.is_empty nodes -> rows + | nodes -> + let total = Vec.length nodes in + Vec.mapi (fun idx node -> (idx, node)) nodes + |> Vec.fold_left + (fun rows (idx, node) -> + let last = idx = total - 1 in + let branch = + if last then Cli_platform.Symbols.tree_last + else Cli_platform.Symbols.tree_middle + in + let next_prefix = + prefix ^ if last then " " else Cli_platform.Symbols.tree_pipe + in + let rows = + Vec.push_back rows + (Some node.elapsed_span, prefix ^ branch ^ node.label) + in + collect_rows next_prefix rows node.children) + rows + +let pad_right value width = + let padding = max 0 (width - String.length value) in + value ^ String.make padding ' ' + +let render_lines report = + let rows = + Vec.of_array + [| + ( Some report.total_span, + "command=" ^ report.command ^ " status=" ^ status_text report.status + ); + (None, "stages"); + |] + |> fun rows -> collect_rows "" rows (build_stage_tree report.spans) + in + let duration_text = + Vec.map + (function + | Some span, _ -> Int64.to_string (Time.span_to_ms span) ^ "ms" + | None, _ -> "") + rows + in + let width = + Vec.fold_left + (fun width text -> max width (String.length text)) + 0 duration_text + in + Vec.map2 + (fun duration (_, text) -> pad_right duration width ^ " " ^ text) + duration_text rows diff --git a/cli/lib/property.ml b/cli/lib/property.ml new file mode 100644 index 0000000..1ab7f9b --- /dev/null +++ b/cli/lib/property.ml @@ -0,0 +1,89 @@ +type key = + | Key_ident of Cli_primitive.keyword + | Key_id of Cli_primitive.db_id + | Key_name of string + +type cardinality = One | Many + +type kind = + | Default + | Page + | Class + | Property + | Entity + | Node + | Date + | Url + | Checkbox + | Number + | Template + | Other of Cli_primitive.keyword + +type schema = { + kind : kind option; + cardinality : cardinality option; + hidden : bool option; + public : bool option; +} + +type assignment = { key : key; value : Melange_edn_melange.any } + +type update_plan = { + update_tags : Selector.tag Rrbvec.t; + remove_tags : Selector.tag Rrbvec.t; + update_properties : assignment Rrbvec.t; + remove_properties : key Rrbvec.t; +} + +let empty_update_plan = + { + update_tags = Vec.empty; + remove_tags = Vec.empty; + update_properties = Vec.empty; + remove_properties = Vec.empty; + } + +let parse_key value = + match + ( Edn_util.as_int64 value, + Edn_util.as_keyword_t value, + Edn_util.as_string value ) + with + | Some id, _, _ -> Some (Key_id id) + | _, Some key, _ -> Some (Key_ident key) + | _, _, Some name -> Some (Key_name name) + | _ -> None + +let kind_of_string = function + | "default" -> Some Default + | "page" -> Some Page + | "class" -> Some Class + | "property" -> Some Property + | "entity" -> Some Entity + | "node" -> Some Node + | "date" -> Some Date + | "url" -> Some Url + | "checkbox" -> Some Checkbox + | "number" -> Some Number + | "template" -> Some Template + | "" -> None + | s -> Some (Other (Edn_util.keyword_t s)) + +let string_of_kind = function + | Default -> "default" + | Page -> "page" + | Class -> "class" + | Property -> "property" + | Entity -> "entity" + | Node -> "node" + | Date -> "date" + | Url -> "url" + | Checkbox -> "checkbox" + | Number -> "number" + | Template -> "template" + | Other k -> Edn_util.keyword_to_string k + +let cardinality_of_string = function + | "one" -> Some One + | "many" -> Some Many + | _ -> None diff --git a/cli/lib/query.ml b/cli/lib/query.ml new file mode 100644 index 0000000..3384c60 --- /dev/null +++ b/cli/lib/query.ml @@ -0,0 +1,952 @@ +type input_spec = { + name : string; + optional : bool; + default : Melange_edn_melange.any option; +} + +type source = Built_in | Custom + +type query_entry = { + name : string; + source : source; + doc : string option; + inputs : input_spec Rrbvec.t; + query : Melange_edn_melange.any; +} + +type opts = { + query_edn : string option; + name : string option; + inputs_edn : string option; +} + +type parsed = Parsed_run of opts | Parsed_list + +type action = + | Run of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + query : Melange_edn_melange.any; + inputs : Melange_edn_melange.any Rrbvec.t; + name : string option; + } + | List + +let edn_value_of_string ~label text = + try Ok (Melange_edn_melange.of_edn_string text) + with Melange_edn_melange.Parse_error _ -> + Error (Error.invalid_options ("invalid " ^ label ^ " edn")) + +let normalize_query_name value = + let value = String.trim value in + if value = "" then None + else if String.length value > 0 && value.[0] = ':' then + Some (String.sub value 1 (String.length value - 1)) + else Some value + +let parsed_edn text = + match edn_value_of_string ~label:"built-in query" text with + | Ok value -> value + | Error err -> failwith err.message + +let kw value = Edn_util.keyword value +let sym value = Edn_util.symbol value +let in_sym value = Melange_edn_melange.symbol value +let vector_vec values = Edn_util.vector_vec values +let vector_t_vec values = Edn_util.vector_t_vec values +let vector_t values = vector_t_vec values +let list_vec values = Edn_util.list_vec values +let list_t_vec values = Edn_util.list_t_vec values +let list_t values = list_t_vec values +let where_v values = Cli_primitive.V (vector_t values) +let where_l values = Cli_primitive.L (list_t values) + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let input ?default name = + { name; optional = String.length name > 0 && name.[0] = '?'; default } + +let built_in_queries = + Vec.of_array + [| + { + name = "list-priority"; + source = Built_in; + doc = Some "List closed values for the Priority property."; + inputs = Vec.empty; + query = + query_value + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?value"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/order"; + |]); + |]); + sym "..."; + |]); + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| + sym "?property"; + kw "db/ident"; + kw "logseq.property/priority"; + |]); + where_v + (Vec.of_array + [| + sym "?value"; + kw "block/closed-value-property"; + sym "?property"; + |]); + |]) + ()); + }; + { + name = "list-status"; + source = Built_in; + doc = Some "List closed values for the Status property."; + inputs = Vec.empty; + query = + query_value + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?value"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/order"; + |]); + |]); + sym "..."; + |]); + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| + sym "?property"; + kw "db/ident"; + kw "logseq.property/status"; + |]); + where_v + (Vec.of_array + [| + sym "?value"; + kw "block/closed-value-property"; + sym "?property"; + |]); + |]) + ()); + }; + { + name = "recent-updated"; + source = Built_in; + doc = Some "Find entities updated within recent-days."; + inputs = Vec.of_array [| input "recent-days"; input "?now-ms" |]; + query = + query_value + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?e"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/title"; + kw "block/updated-at"; + |]); + |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| in_sym "$"; in_sym "?recent-days"; in_sym "?now-ms" |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| + sym "?e"; kw "block/updated-at"; sym "?updated-at"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "missing?"; + sym "$"; + sym "?e"; + kw "logseq.property/built-in?"; + |]); + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "*"; + sym "?recent-days"; + Edn_util.int 86400000; + |]); + sym "?recent-days-ms"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "-"; + sym "?now-ms"; + sym "?recent-days-ms"; + |]); + sym "?days-ago"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym ">="; sym "?updated-at"; sym "?days-ago"; + |]); + |]); + |]) + ()); + }; + { + name = "task-search"; + source = Built_in; + doc = + Some + "Find tasks by status, optional title substring, optional \ + recent-days."; + inputs = + Vec.of_array + [| + input "search-status"; + input ~default:(Edn_util.string "") "?search-title"; + input ~default:(Edn_util.int 0) "?recent-days"; + input "?now-ms"; + |]; + query = + query_value + (Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "pull"; + sym "?e"; + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/title"; + kw "block/updated-at"; + |]); + |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + in_sym "$"; + in_sym "?search-status"; + in_sym "?search-title"; + in_sym "?recent-days"; + in_sym "?now-ms"; + |]) + ~where: + (Vec.of_array + [| + where_v + (Vec.of_array + [| sym "?e"; kw "block/title"; sym "?title" |]); + where_v + (Vec.of_array + [| + sym "?e"; + kw "logseq.property/status"; + sym "?status"; + |]); + where_v + (Vec.of_array + [| + sym "?status"; kw "db/ident"; sym "?status-ident"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "="; + sym "?status-ident"; + sym "?search-status"; + |]); + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "clojure.string/lower-case"; + sym "?title"; + |]); + sym "?title-lower-case"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| sym "str"; sym "?search-title" |]); + sym "?search-title-string"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "clojure.string/lower-case"; + sym "?search-title-string"; + |]); + sym "?search-title-lower-case"; + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "clojure.string/includes?"; + sym "?title-lower-case"; + sym "?search-title-lower-case"; + |]); + |]); + where_v + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "get-else"; + sym "$"; + sym "?e"; + kw "block/updated-at"; + Edn_util.int 0; + |]); + sym "?updated-at"; + |]); + where_l + (Vec.of_array + [| + sym "or-join"; + vector_vec + (Vec.of_array + [| + sym "?recent-days"; + sym "?updated-at"; + sym "?now-ms"; + sym "?days-ago"; + |]); + list_vec + (Vec.of_array + [| + sym "and"; + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "nil?"; sym "?recent-days"; + |]); + |]); + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "identity"; Edn_util.int 0; + |]); + sym "?days-ago"; + |]); + |]); + list_vec + (Vec.of_array + [| + sym "and"; + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "<="; + sym "?recent-days"; + Edn_util.int 0; + |]); + |]); + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "identity"; Edn_util.int 0; + |]); + sym "?days-ago"; + |]); + |]); + list_vec + (Vec.of_array + [| + sym "and"; + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "*"; + sym "?recent-days"; + Edn_util.int 86400000; + |]); + sym "?recent-days-ms"; + |]); + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym "-"; + sym "?now-ms"; + sym "?recent-days-ms"; + |]); + sym "?days-ago"; + |]); + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| + sym ">="; + sym "?updated-at"; + sym "?days-ago"; + |]); + |]); + |]); + |]); + |]) + ()); + }; + |] + |> Vec.sort (fun (a : query_entry) (b : query_entry) -> + String.compare a.name b.name) + +let db_query_dsl_rules = + parsed_edn + {|[ + [(parent ?p ?c) [?c :block/parent ?p]] + [(parent ?p ?c) [?t :block/parent ?p] (parent ?t ?c)] + [(class-extends ?p ?c) [?c :logseq.property.class/extends ?p]] + [(class-extends ?p ?c) [?t :logseq.property.class/extends ?p] (class-extends ?t ?c)] + [(alias ?e2 ?e1) [?e2 :block/alias ?e1]] + [(alias ?e2 ?e1) [?e1 :block/alias ?e2]] + [(self-ref ?b ?ref) [?b :block/refs ?ref]] + [(has-ref ?b ?r) [?b :block/refs ?r]] + [(has-ref ?b ?r) (parent ?p ?b) [?p :block/refs ?r]] + [(page-ref ?b ?ref) (has-ref ?b ?ref)] + [(block-content ?b ?query) [?b :block/title ?content] [(clojure.string/includes? ?content ?query)]] + [(page ?b ?page-name) [?b :block/page ?bp] [?bp :block/name ?page-name]] + [(between ?b ?start ?end) [?b :block/page ?p] [?p :block/tags :logseq.class/Journal] [?p :block/journal-day ?d] [(>= ?d ?start)] [(<= ?d ?end)]] + [(ref->val ?pv ?val) [?pv :block/title ?val]] + [(ref->val ?pv ?val) [?pv :logseq.property/value ?val]] + [(property-missing-value ?b ?prop-e ?default-p ?default-v) [?t :logseq.property.class/properties ?prop-e] [?prop-e :db/ident ?prop] (object-has-class-property? ?b ?prop) [(get-else $ ?b ?prop "N/A") ?prop-v] [(= ?prop-v "N/A")] [?prop-e ?default-p ?default-v]] + [(scalar-property-value ?b ?prop-e ?val) [?prop-e :db/ident ?prop] [?b ?prop ?val]] + [(scalar-property-value-with-default ?b ?prop-e ?val) (scalar-property-value ?b ?prop-e ?val)] + [(scalar-property-value-with-default ?b ?prop-e ?val) (property-missing-value ?b ?prop-e :logseq.property/scalar-default-value ?val)] + [(ref-property-value ?b ?prop-e ?val) [?prop-e :db/ident ?prop] [?b ?prop ?pv] (ref->val ?pv ?val)] + [(ref-property-value-with-default ?b ?prop-e ?val) (ref-property-value ?b ?prop-e ?val)] + [(ref-property-value-with-default ?b ?prop-e ?val) (property-missing-value ?b ?prop-e :logseq.property/default-value ?pv) (ref->val ?pv ?val)] + [(object-has-class-property? ?b ?prop) [?prop-e :db/ident ?prop] [?t :logseq.property.class/properties ?prop-e] [?b :block/tags ?tc] (or [(= ?t ?tc)] (class-extends ?t ?tc))] + [(has-property-or-object-property? ?b ?prop) [?prop-e :db/ident ?prop] (or [?b ?prop _] (object-has-class-property? ?b ?prop))] + [(has-simple-query-property ?b ?prop) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (has-property-or-object-property? ?b ?prop) (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true])] + [(has-private-simple-query-property ?b ?prop) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (has-property-or-object-property? ?b ?prop)] + [(has-property ?b ?prop) [?b ?prop _] [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true])] + [(scalar-property ?b ?prop ?val) [?prop-e :db/ident ?prop] (scalar-property-value ?b ?prop-e ?val) (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true])] + [(scalar-property-with-default ?b ?prop ?val) [?prop-e :db/ident ?prop] (scalar-property-value-with-default ?b ?prop-e ?val) (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true])] + [(ref-property ?b ?prop ?val) [?prop-e :db/ident ?prop] (ref-property-value ?b ?prop-e ?val) (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true])] + [(ref-property-with-default ?b ?prop ?val) [?prop-e :db/ident ?prop] (ref-property-value-with-default ?b ?prop-e ?val) (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true])] + [(private-scalar-property ?b ?prop ?val) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (scalar-property-value ?b ?prop-e ?val)] + [(private-scalar-property-with-default ?b ?prop ?val) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (scalar-property-value-with-default ?b ?prop-e ?val)] + [(private-ref-property ?b ?prop ?val) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (ref-property-value ?b ?prop-e ?val)] + [(private-ref-property-with-default ?b ?prop ?val) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (ref-property-value-with-default ?b ?prop-e ?val)] + [(property ?b ?prop ?val) [?prop-e :db/ident ?prop] [?prop-e :block/tags :logseq.class/Property] (or [(missing? $ ?prop-e :logseq.property/public?)] [?prop-e :logseq.property/public? true]) [?b ?prop ?pv] (or (and [(missing? $ ?prop-e :db/valueType)] [?b ?prop ?val]) (and [?prop-e :db/valueType :db.type/ref] (or [?pv :block/title ?val] [?pv :logseq.property/value ?val])))] + [(tag-spec->tag ?tag ?spec) [(number? ?spec)] [(identity ?spec) ?tag]] + [(tag-spec->tag ?tag ?spec) [?tag :block/title ?spec]] + [(tag-spec->tag ?tag ?spec) [?tag :db/ident ?spec]] + [(tags ?b ?tags) [(identity ?tags) [?spec ...]] (tag-spec->tag ?tag ?spec) [?b :block/tags ?tc] (or [(= ?tag ?tc)] (class-extends ?tag ?tc)) [(missing? $ ?b :block/link)]] + [(task ?b ?statuses) (ref-property-with-default ?b :logseq.property/status ?val) [(contains? ?statuses ?val)]] + [(priority ?b ?priorities) (ref-property-with-default ?b :logseq.property/priority ?priority) [(contains? ?priorities ?priority)]] + ]|} + +let trim_non_empty value = + let value = String.trim value in + if value = "" then None else Some value + +let strip_leading_colon value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let query_name_of_value value = + match (Edn_util.as_string value, Edn_util.as_keyword value) with + | Some value, _ | _, Some value -> trim_non_empty (strip_leading_colon value) + | _ -> + trim_non_empty + (Melange_edn_melange.to_edn_string value |> strip_leading_colon) + +let custom_query_input value = + match (Edn_util.as_string value, Edn_util.as_keyword value) with + | Some value, _ | _, Some value -> + Option.map + (fun name -> input name) + (trim_non_empty (strip_leading_colon value)) + | _ -> ( + match Edn_util.as_map value with + | Some _ -> ( + match Option.bind (Edn_util.get value "name") query_name_of_value with + | None -> None + | Some name -> + Some + { + name; + optional = String.length name > 0 && name.[0] = '?'; + default = Edn_util.get value "default"; + }) + | None -> None) + +let custom_query_inputs = function + | Some value -> ( + match Edn_util.as_seq value with + | Some values -> Vec.filter_map custom_query_input values + | _ -> Vec.empty) + | _ -> Vec.empty + +let custom_query_entry name source spec = + let query, doc, inputs = + match spec with + | spec -> ( + match (Edn_util.as_vector spec, Edn_util.as_map spec) with + | Some _, _ -> (Some spec, None, Vec.empty) + | _, Some _ -> + ( Edn_util.get spec "query", + Edn_util.get_string spec "doc", + custom_query_inputs (Edn_util.get spec "inputs") ) + | _ -> (None, None, Vec.empty)) + in + match (query_name_of_value name, query) with + | Some name, Some query -> Some { name; source; doc; inputs; query } + | _ -> None + +let custom_queries config = + match + Option.bind config.Cli_config.raw_file_config (fun value -> + Edn_util.get value "custom-queries") + with + | Some value -> ( + match Edn_util.as_map value with + | Some fields -> + Vec.filter_map + (fun (name, spec) -> custom_query_entry name Custom spec) + fields + | _ -> Vec.empty) + | _ -> Vec.empty + +let merge_queries entries = + let upsert acc (entry : query_entry) = + acc + |> Vec.filter (fun (existing : query_entry) -> existing.name <> entry.name) + |> fun acc -> Vec.push_back acc entry + in + entries + |> Vec.fold_left upsert Vec.empty + |> Vec.sort (fun (a : query_entry) (b : query_entry) -> + String.compare a.name b.name) + +let list_queries config : query_entry Rrbvec.t = + merge_queries (Vec.append built_in_queries (custom_queries config)) + +let find_query config name = + Vec.find_opt + (fun (entry : query_entry) -> entry.name = name) + (list_queries config) + +let rec contains_db_id_datom_clause value = + match (Edn_util.as_vector value, Edn_util.as_list value) with + | Some values, _ + when Vec.length values >= 2 + && Edn_util.as_keyword (Vec.nth values 1) = Some "db/id" -> + true + | Some values, _ | _, Some values -> + Vec.exists contains_db_id_datom_clause values + | _ -> false + +let validate_query query = + match Edn_util.as_vector query with + | Some values -> + let rec after_where values = + match Vec.pop_front values with + | None -> Vec.empty + | Some (keyword, clauses) + when Edn_util.as_keyword keyword = Some "where" -> + clauses + | Some (_, rest) -> after_where rest + in + if Vec.exists contains_db_id_datom_clause (after_where values) then + Error + (Error.make Error.Invalid_query + "invalid query: :db/id cannot be used as a datom attribute in \ + :where clauses. Bind entity ids through :in and --inputs.") + else Ok query + | _ -> Error (Error.invalid_options "query must be a vector") + +let current_epoch_ms () = Time.time_to_epoch_ms (Time.now ()) + +let normalize_task_search_inputs (entry : query_entry option) inputs = + match entry with + | Some { name = "task-search"; _ } -> ( + match Vec.pop_front inputs with + | Some (status_value, rest) + when Option.is_some (Edn_util.as_string status_value) -> + let status = + Option.value (Edn_util.as_string status_value) ~default:"" + in + let status = String.trim status |> String.lowercase_ascii in + if status = "" then inputs + else + Vec.push_front rest + (Edn_util.keyword ("logseq.property/status." ^ status)) + | _ -> inputs) + | _ -> inputs + +let validate_recent_updated_inputs (entry : query_entry option) inputs = + match (entry, Vec.peek_front_opt inputs) with + | ( Some { name = "recent-updated"; source = Built_in; _ }, + Some recent_days_value ) + when Option.value (Edn_util.as_int recent_days_value) ~default:0 > 0 -> + Ok inputs + | Some { name = "recent-updated"; source = Built_in; _ }, _ -> + Error (Error.invalid_options "recent-days must be a positive integer") + | _ -> Ok inputs + +let normalize_inputs entry inputs = + match entry with + | None -> Ok inputs + | Some entry -> + let required = + Vec.length (Vec.filter (fun spec -> not spec.optional) entry.inputs) + in + if Vec.length inputs < required then + Error (Error.invalid_options "inputs missing required values") + else + let rec fill acc specs values = + match Vec.pop_front specs with + | Some (_spec, rest_specs) when not (Vec.is_empty values) -> + let value, rest_values = + match Vec.pop_front values with + | Some pair -> pair + | None -> failwith "non-empty vector must have a front value" + in + fill (Vec.push_back acc value) rest_specs rest_values + | None -> Vec.append acc values + | Some (spec, rest) -> + let default = + match spec.default with + | Some value when Edn_util.as_keyword value = Some "now-ms" -> + Edn_util.int64 (current_epoch_ms ()) + | Some value -> value + | None when spec.name = "?now-ms" -> + Edn_util.int64 (current_epoch_ms ()) + | None -> Edn_util.nil + in + fill (Vec.push_back acc default) rest Vec.empty + in + fill Vec.empty entry.inputs inputs + |> normalize_task_search_inputs (Some entry) + |> validate_recent_updated_inputs (Some entry) + +let command_id = function + | Parsed_run _ -> Command_id.Query + | Parsed_list -> Query_list + +let validate_parsed _ = Ok () + +let symbol_is name = function + | Melange_edn_melange.Any (Melange_edn_melange.Symbol value) -> value = name + | _ -> false + +let query_in_ends_with_percent query = + match Edn_util.as_vector query with + | Some values -> ( + let rec find_in values = + match Vec.pop_front values with + | None -> Vec.empty + | Some (keyword, rest) when Edn_util.as_keyword keyword = Some "in" -> + collect_in Vec.empty rest + | Some (_, rest) -> find_in rest + and collect_in acc values = + match Vec.pop_front values with + | None -> acc + | Some (keyword, _) when Option.is_some (Edn_util.as_keyword keyword) -> + acc + | Some (value, rest) -> collect_in (Vec.push_back acc value) rest + in + match find_in values with + | values when not (Vec.is_empty values) -> + symbol_is "%" (Vec.nth values (Vec.length values - 1)) + | _ -> false) + | _ -> false + +let query_args query inputs = + let args = Vec.push_front inputs query in + if query_in_ends_with_percent query then Vec.push_back args db_query_dsl_rules + else args + +let build ?registry:_ config _globals parsed = + match parsed with + | Parsed_list -> Ok List + | Parsed_run opts -> ( + match config.Cli_config.repo with + | None -> Error (Error.missing_repo "repo is required for query") + | Some repo -> + let query_text = Option.bind opts.query_edn Cli_primitive.non_empty in + let query_name = Option.bind opts.name normalize_query_name in + if Option.is_some query_text && Option.is_some query_name then + Error + (Error.invalid_options "use either --query or --name, not both") + else if Option.is_none query_text && Option.is_none query_name then + Error (Error.make Error.Missing_query "query is required") + else + let query_result, entry = + match (query_text, query_name) with + | Some text, _ -> (edn_value_of_string ~label:"query" text, None) + | None, Some name -> ( + match find_query config name with + | Some entry -> (Ok entry.query, Some entry) + | None -> + ( Error + (Error.make Error.Unknown_query + ("unknown query: " ^ name)), + None )) + | _ -> + ( Error (Error.make Error.Missing_query "query is required"), + None ) + in + Error.bind query_result (fun query -> + Error.bind (validate_query query) (fun query -> + let inputs_result = + match + Option.bind opts.inputs_edn Cli_primitive.non_empty + with + | None -> Ok Vec.empty + | Some text -> ( + match edn_value_of_string ~label:"inputs" text with + | Ok value -> ( + match Edn_util.as_vector value with + | Some inputs -> Ok inputs + | _ -> + Error + (Error.invalid_options + "inputs must be a vector")) + | Error _ as err -> err) + in + Error.bind inputs_result (fun inputs -> + Error.bind (normalize_inputs entry inputs) + (fun inputs -> + Ok + (Run + { + repo; + graph = Cli_config.repo_to_graph repo; + query; + inputs; + name = query_name; + })))))) + +let source_value = function + | Built_in -> Edn_util.string "built-in" + | Custom -> Edn_util.string "custom" + +let input_value (input : input_spec) = + let fields = + Vec.of_array + [| + (Edn_util.keyword "name", Edn_util.string input.name); + (Edn_util.keyword "optional", Edn_util.bool input.optional); + |] + |> fun fields -> + match input.default with + | Some value -> Vec.push_back fields (Edn_util.keyword "default", value) + | None -> fields + in + Edn_util.map_vec fields + +let hide_internal_inputs (entry : query_entry) : query_entry = + { + entry with + inputs = + Vec.filter + (fun (input : input_spec) -> input.name <> "?now-ms") + entry.inputs; + } + +let query_entry_value (entry : query_entry) = + let entry = hide_internal_inputs entry in + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "name", Edn_util.string entry.name); + (Edn_util.keyword "source", source_value entry.source); + ( Edn_util.keyword "doc", + match entry.doc with + | Some doc -> Edn_util.string doc + | None -> Edn_util.nil ); + ( Edn_util.keyword "inputs", + Edn_util.vector_vec (entry.inputs |> Vec.map input_value) ); + (Edn_util.keyword "query", entry.query); + |]) + +let execute_with_mode action config mode = + let open Cli_effect in + match action with + | List -> + pure + (Cli_result.ok ~command:Command_id.Query_list mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "queries", + Edn_util.vector_vec + (list_queries config |> Vec.map query_entry_value) ); + |])))) + | Run { repo; query; inputs; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Output_mode.error ~command:Command_id.Query mode err) + | Ok invoke_config -> + bind + (Transport.thread_api_q invoke_config ~repo + ~query:(Edn_util.vector_t_vec (query_args query inputs))) + (fun value -> + pure + (Cli_result.ok ~command:Command_id.Query mode + (Query_result + (Edn_util.map_vec + (Vec.of_array + [| (Edn_util.keyword "result", value) |])))))) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples: + (Vec.of_array + [| + "logseq query --graph my-graph --name recent-updated --inputs \ + '[30]'"; + "logseq query --graph my-graph --name task-search --inputs \ + '[:logseq.property/status.done \"daily\"]'"; + "logseq query --graph my-graph --query '[:find [?e ...] :where \ + [?e :block/name]]'"; + |]) + Command_id.Query "Run a Datascript query"; + meta + ~examples: + (Vec.of_array + [| + "logseq query list --graph my-graph"; + "logseq query list --graph my-graph --output edn"; + |]) + Query_list "List available queries"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/remove.ml b/cli/lib/remove.ml new file mode 100644 index 0000000..ec6a9e4 --- /dev/null +++ b/cli/lib/remove.ml @@ -0,0 +1,743 @@ +type block_opts = { id_raw : string option; uuid : Cli_primitive.uuid option } +type page_opts = { id : Cli_primitive.db_id option; page : string option } + +type named_entity_opts = { + id : Cli_primitive.db_id option; + name : string option; +} + +type parsed = + | Parsed_block of block_opts + | Parsed_page of page_opts + | Parsed_tag of named_entity_opts + | Parsed_property of named_entity_opts + +type action = + | Remove_block of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + ids : Cli_primitive.db_id Rrbvec.t; + multi_id : bool; + uuid : Cli_primitive.uuid option; + } + | Remove_page of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + page : string option; + } + | Remove_tag of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + name : string option; + } + | Remove_property of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + name : string option; + } + +let command_id = function + | Parsed_block _ -> Command_id.Remove_block + | Parsed_page _ -> Remove_page + | Parsed_tag _ -> Remove_tag + | Parsed_property _ -> Remove_property + +let count_some values = + Vec.fold_left + (fun count -> function Some _ -> count + 1 | None -> count) + 0 values + +let invalid_options = function + | Parsed_block opts + when count_some (Vec.of_array [| opts.id_raw; opts.uuid |]) > 1 -> + Some "only one of --id or --uuid is allowed" + | Parsed_page opts + when count_some + (Vec.of_array [| Option.map Int64.to_string opts.id; opts.page |]) + > 1 -> + Some "only one of --id or --page is allowed" + | Parsed_page { page = Some page; _ } when String.trim page = "" -> + Some "page must be non-empty" + | (Parsed_tag opts | Parsed_property opts) + when count_some + (Vec.of_array [| Option.map Int64.to_string opts.id; opts.name |]) + > 1 -> + Some "only one of --id or --name is allowed" + | Parsed_tag { name = Some name; _ } + | Parsed_property { name = Some name; _ } + when String.trim name = "" -> + Some "name must be non-empty" + | _ -> None + +let validate_parsed parsed = + match invalid_options parsed with + | Some message -> Error (Error.invalid_options message) + | None -> Ok () + +let repo_or_error config = + match config.Cli_config.repo with + | Some repo -> Ok (repo, Cli_config.repo_to_graph repo) + | None -> Error (Error.missing_repo "repo is required for remove") + +let build_block repo graph (opts : block_opts) = + match (opts.id_raw, opts.uuid) with + | None, None -> Error (Error.make Error.Missing_target "block is required") + | Some id_raw, None -> ( + match Id_parse.parse_id_string id_raw with + | Ok ids -> + Ok + (Remove_block + { + repo; + graph; + id = Id_parse.to_single ids; + ids = ids.ids; + multi_id = ids.multi; + uuid = None; + }) + | Error err -> Error err) + | None, Some uuid -> + let uuid = String.trim uuid in + if Cli_primitive.is_uuid_string uuid then + Ok + (Remove_block + { + repo; + graph; + id = None; + ids = Vec.empty; + multi_id = false; + uuid = Some uuid; + }) + else + Error (Error.invalid_options "Option uuid must be a valid UUID string") + | Some _, Some _ -> + Error (Error.invalid_options "only one of --id or --uuid is allowed") + +let build_page repo graph (opts : page_opts) = + match (opts.id, Option.map String.trim opts.page) with + | None, None -> + Error (Error.make Error.Missing_page_name "page name or id is required") + | Some id, None -> Ok (Remove_page { repo; graph; id = Some id; page = None }) + | None, Some page when page <> "" -> + Ok (Remove_page { repo; graph; id = None; page = Some page }) + | None, Some _ -> Error (Error.invalid_options "page must be non-empty") + | Some _, Some _ -> + Error (Error.invalid_options "only one of --id or --page is allowed") + +let build_named make repo graph (opts : named_entity_opts) = + match (opts.id, Option.map String.trim opts.name) with + | None, None -> + Error (Error.make Error.Missing_target "name or id is required") + | Some id, None -> Ok (make repo graph (Some id) None) + | None, Some name when name <> "" -> Ok (make repo graph None (Some name)) + | None, Some _ -> Error (Error.invalid_options "name must be non-empty") + | Some _, Some _ -> + Error (Error.invalid_options "only one of --id or --name is allowed") + +let build ?registry:_ config _globals parsed = + Error.bind (validate_parsed parsed) (fun () -> + Error.bind (repo_or_error config) (fun (repo, graph) -> + match parsed with + | Parsed_block opts -> build_block repo graph opts + | Parsed_page opts -> build_page repo graph opts + | Parsed_tag opts -> + build_named + (fun repo graph id name -> Remove_tag { repo; graph; id; name }) + repo graph opts + | Parsed_property opts -> + build_named + (fun repo graph id name -> + Remove_property { repo; graph; id; name }) + repo graph opts)) + +let kw value = Edn_util.keyword value +let vector_vec values = Edn_util.vector_vec values + +let block_selector = + vector_vec (Vec.of_array [| kw "db/id"; kw "block/uuid"; kw "block/name" |]) + +let page_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |]) + +let entity_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/type"; + kw "logseq.property/public?"; + kw "logseq.property/built-in?"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/title"; + kw "block/name"; + |]) ); + |]); + |]) + +let result_value value = + Edn_util.map_vec (Vec.of_array [| (kw "result", value) |]) + +let entity_result_value result id name = + Edn_util.map_vec + (Vec.of_array + [| + (kw "result", result); + (kw "id", Edn_util.int64 id); + (kw "name", Edn_util.string name); + |]) + +let multi_block_result ~deleted_ids ~missing_ids ~result ~page_ids = + let fields = + Vec.of_array + [| + ( kw "deleted-ids", + Edn_util.vector_vec + (deleted_ids |> Vec.map (fun id -> Edn_util.int64 id)) ); + ( kw "missing-ids", + Edn_util.vector_vec + (missing_ids |> Vec.map (fun id -> Edn_util.int64 id)) ); + (kw "result", result); + |] + in + let fields = + if Vec.is_empty page_ids then fields + else + let ids = page_ids in + Vec.push_back fields + ( kw "page-ids", + Edn_util.vector_vec (ids |> Vec.map (fun id -> Edn_util.int64 id)) ) + in + Edn_util.map_vec fields + +let uuid_of_entity value = + Option.bind (Edn_util.get value "block/uuid") Edn_util.as_string_like + +let id_of_entity value = Edn_util.get_int64 value "db/id" +let has_name value = Option.is_some (Edn_util.get_string value "block/name") +let has_id value = Option.is_some (Edn_util.get_int64 value "db/id") + +let pull config repo selector lookup = + Transport.thread_api_pull config ~repo + ~selector:(Edn_util.expect_vector_t "remove pull selector" selector) + ~lookup + +let list_named_entities config repo method_ = + let options = + Edn_util.map_t_vec + (Vec.of_array + [| + (kw "include-built-in", Edn_util.bool true); + (kw "expand", Edn_util.bool true); + |]) + in + match Edn_util.keyword_to_string method_ with + | "thread-api/cli-list-pages" -> + Transport.thread_api_cli_list_pages config ~repo ~options + | "thread-api/cli-list-tags" -> + Transport.thread_api_cli_list_tags config ~repo ~options + | "thread-api/cli-list-properties" -> + Transport.thread_api_cli_list_properties config ~repo ~options + | method_name -> invalid_arg ("unsupported list method: " ^ method_name) + +let list_pages config repo = + list_named_entities config repo + (Edn_util.keyword_t "thread-api/cli-list-pages") + +let apply_outliner_ops config repo ops = + Transport.thread_api_apply_outliner_ops config ~repo + ~ops:(Edn_util.vector_t_vec ops) + ~options:(Edn_util.map_t_vec Vec.empty) + +let apply_delete_op config repo op = + let open Cli_effect in + bind (apply_outliner_ops config repo (Vec.singleton op)) (fun response -> + let result = + if Edn_util.is_null response then Edn_util.bool true + else + match Edn_util.get response "result" with + | Some result -> result + | None -> response + in + pure result) + +let delete_block_uuids config repo uuids = + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "delete-blocks"; + Edn_util.vector_vec + (Vec.of_array + [| + Edn_util.vector_vec + (uuids |> Vec.map (fun uuid -> Edn_util.uuid uuid)); + Edn_util.map_vec Vec.empty; + |]); + |]) + in + apply_delete_op config repo op + +let delete_page_uuid config repo uuid = + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "delete-page"; + Edn_util.vector_vec + (Vec.of_array [| Edn_util.uuid uuid; Edn_util.map_vec Vec.empty |]); + |]) + in + apply_delete_op config repo op + +let entities_of_value value = + match + (Edn_util.as_list value, Edn_util.as_vector value, Edn_util.as_map value) + with + | Some pages, _, _ | _, Some pages, _ -> pages + | _, _, Some _ -> Vec.singleton value + | _ -> Vec.empty + +let entity_name value = + match Edn_util.get_string value "block/title" with + | Some title -> Some title + | None -> ( + match Edn_util.get_string value "block/name" with + | Some name -> Some name + | None -> ( + match Edn_util.get_string value "title" with + | Some title -> Some title + | None -> Edn_util.get_string value "name")) + +let normalize_name name = String.lowercase_ascii (String.trim name) + +let name_matches target entity = + let target = normalize_name target in + let candidates = + Vec.of_array + [| + entity_name entity; + Edn_util.get_string entity "block/name"; + Edn_util.get_string entity "block/title"; + Edn_util.get_string entity "name"; + Edn_util.get_string entity "title"; + |] + in + Vec.exists + (function + | Some candidate -> String.equal target (normalize_name candidate) + | None -> false) + candidates + +let candidate_of_entity entity = + match id_of_entity entity with + | Some id -> Some { Error.id = Some id; name = entity_name entity } + | None -> None + +let plural_label = function "property" -> "properties" | label -> label ^ "s" + +let ambiguous_error code label name matches = + Error.make + ~candidates:(Vec.filter_map candidate_of_entity matches) + code + ("multiple " ^ plural_label label ^ " match name: " ^ name + ^ "; rerun with --id") + +let tag_entity value = + match Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq with + | Some tags -> + Vec.exists + (fun tag -> + match + Option.bind (Edn_util.get tag "db/ident") Edn_util.as_string_like + with + | Some "logseq.class/Tag" -> true + | _ -> false) + tags + | None -> false + +let property_entity value = + Option.is_some (Edn_util.get value "logseq.property/type") + +let validate_tag_target entity = + if not (has_id entity) then + Error (Error.make Error.Tag_not_found "tag not found") + else if not (tag_entity entity) then + Error (Error.make Error.Invalid_tag_target "target is not a tag") + else if Edn_util.get_bool entity "logseq.property/built-in?" = Some true then + Error (Error.make Error.Tag_built_in "built-in tag cannot be removed") + else if Edn_util.get_bool entity "logseq.property/public?" = Some false then + Error (Error.make Error.Tag_hidden "hidden tag cannot be removed") + else + match uuid_of_entity entity with + | Some _ -> Ok entity + | None -> Error (Error.make Error.Tag_not_found "tag uuid not found") + +let validate_property_target entity = + if not (has_id entity) then + Error (Error.make Error.Property_not_found "property not found") + else if not (property_entity entity) then + Error (Error.make Error.Invalid_property_target "target is not a property") + else if Edn_util.get_bool entity "logseq.property/built-in?" = Some true then + Error + (Error.make Error.Property_built_in "built-in property cannot be removed") + else if Edn_util.get_bool entity "logseq.property/public?" = Some false then + Error (Error.make Error.Property_hidden "hidden property cannot be removed") + else + match uuid_of_entity entity with + | Some _ -> Ok entity + | None -> + Error (Error.make Error.Property_not_found "property uuid not found") + +let target_name label action_name entity = + match action_name with + | Some name -> name + | None -> ( match entity_name entity with Some name -> name | None -> label) + +let delete_resolved_block mode invoke_config repo entity = + let open Cli_effect in + if not (has_id entity) then + pure + (Cli_result.error ~command:Command_id.Remove_block mode + (Error.make Error.Block_not_found "block not found")) + else if has_name entity then + pure + (Cli_result.error ~command:Command_id.Remove_block mode + (Error.make Error.Invalid_target + "target is not a block, use 'remove page' instead")) + else + match uuid_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Remove_block mode + (Error.make Error.Block_not_found "block uuid not found")) + | Some uuid -> + bind + (delete_block_uuids invoke_config repo (Vec.singleton uuid)) + (fun result -> + pure + (Cli_result.ok ~command:Command_id.Remove_block mode + (Raw (result_value result)))) + +let execute_remove_block_id mode invoke_config repo id = + let open Cli_effect in + bind + (pull invoke_config repo block_selector (Edn_util.int64 id)) + (delete_resolved_block mode invoke_config repo) + +let execute_remove_block_uuid mode invoke_config repo uuid = + let open Cli_effect in + let lookup = + vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]) + in + bind (pull invoke_config repo block_selector lookup) (fun entity -> + if has_id entity then delete_resolved_block mode invoke_config repo entity + else + let string_lookup = + vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.string uuid |]) + in + bind + (pull invoke_config repo block_selector string_lookup) + (delete_resolved_block mode invoke_config repo)) + +let execute_remove_block_ids mode invoke_config repo ids = + let open Cli_effect in + let entity_effects = + map_s + (fun id -> pull invoke_config repo block_selector (Edn_util.int64 id)) + ids + in + bind entity_effects (fun entities -> + let id_entities = Vec.combine ids entities in + let deleted_ids = + id_entities + |> Vec.filter_map (fun (id, entity) -> + if has_id entity && not (has_name entity) then Some id else None) + in + let deleted_uuids = + id_entities + |> Vec.filter_map (fun (_, entity) -> + if has_id entity && not (has_name entity) then uuid_of_entity entity + else None) + in + let missing_ids = + id_entities + |> Vec.filter_map (fun (id, entity) -> + if has_id entity then None else Some id) + in + let page_ids = + id_entities + |> Vec.filter_map (fun (id, entity) -> + if has_id entity && has_name entity then Some id else None) + in + if Vec.is_empty deleted_ids && not (Vec.is_empty page_ids) then + pure + (Cli_result.error ~command:Command_id.Remove_block mode + (Error.make Error.Invalid_target + "target is not a block, use 'remove page' instead")) + else + let delete_effect = + if Vec.is_empty deleted_uuids then pure Edn_util.nil + else delete_block_uuids invoke_config repo deleted_uuids + in + bind delete_effect (fun result -> + pure + (Cli_result.ok ~command:Command_id.Remove_block mode + (Raw + (multi_block_result ~deleted_ids ~missing_ids ~result + ~page_ids))))) + +let execute_remove_page_id mode invoke_config repo id = + let open Cli_effect in + bind + (pull invoke_config repo page_selector (Edn_util.int64 id)) + (fun entity -> + if not (has_id entity) then + pure + (Cli_result.error ~command:Command_id.Remove_page mode + (Error.make Error.Page_not_found "page not found")) + else + match uuid_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Remove_page mode + (Error.make Error.Page_not_found "page not found")) + | Some uuid -> + bind (delete_page_uuid invoke_config repo uuid) (fun result -> + pure + (Cli_result.ok ~command:Command_id.Remove_page mode + (Raw (result_value result))))) + +let execute_remove_page_name mode invoke_config repo name = + let open Cli_effect in + bind (list_pages invoke_config repo) (fun pages_value -> + let matches = + pages_value |> entities_of_value |> Vec.filter (name_matches name) + in + if Vec.is_empty matches then + pure + (Cli_result.error ~command:Command_id.Remove_page mode + (Error.make Error.Page_not_found "page not found")) + else if Vec.length matches > 1 then + pure + (Cli_result.error ~command:Command_id.Remove_page mode + (ambiguous_error Error.Ambiguous_page_name "page" name matches)) + else + let page = Vec.nth matches 0 in + match uuid_of_entity page with + | None -> + pure + (Cli_result.error ~command:Command_id.Remove_page mode + (Error.make Error.Page_not_found "page not found")) + | Some uuid -> + bind (delete_page_uuid invoke_config repo uuid) (fun result -> + pure + (Cli_result.ok ~command:Command_id.Remove_page mode + (Raw (result_value result))))) + +let execute_remove_entity mode invoke_config repo ~command ~list_method + ~not_found_code ~ambiguous_code ~label ~validate ~id ~name = + let open Cli_effect in + let delete entity resolved_name = + match validate entity with + | Error err -> pure (Cli_result.error ~command mode err) + | Ok entity -> ( + match (id_of_entity entity, uuid_of_entity entity) with + | Some id, Some uuid -> + bind (delete_page_uuid invoke_config repo uuid) (fun result -> + pure + (Cli_result.ok ~command mode + (Raw + (entity_result_value result id + (target_name label resolved_name entity))))) + | _ -> + pure + (Cli_result.error ~command mode + (Error.make not_found_code (label ^ " not found")))) + in + match (id, name) with + | Some id, _ -> + bind + (pull invoke_config repo entity_selector (Edn_util.int64 id)) + (fun entity -> delete entity None) + | None, Some name -> + bind (list_named_entities invoke_config repo list_method) + (fun items_value -> + let matches = + items_value |> entities_of_value |> Vec.filter (name_matches name) + in + if Vec.is_empty matches then + pure + (Cli_result.error ~command mode + (Error.make not_found_code (label ^ " not found"))) + else if Vec.length matches > 1 then + pure + (Cli_result.error ~command mode + (ambiguous_error ambiguous_code label name matches)) + else + let item = Vec.nth matches 0 in + let lookup_name = + Option.value + (Option.map normalize_name (entity_name item)) + ~default:(normalize_name name) + in + let lookup = + vector_vec + (Vec.of_array + [| kw "block/name"; Edn_util.string lookup_name |]) + in + let resolved_name = entity_name item in + bind (pull invoke_config repo entity_selector lookup) (fun entity -> + delete entity resolved_name)) + | None, None -> + pure + (Cli_result.error ~command mode + (Error.make Error.Missing_target (label ^ " name or id is required"))) + +let execute_with_mode action config mode = + let open Cli_effect in + match action with + | Remove_block { repo; id = Some id; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_block mode err) + | Ok invoke_config -> execute_remove_block_id mode invoke_config repo id) + | Remove_block { repo; ids; multi_id = true; _ } when not (Vec.is_empty ids) + -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_block mode err) + | Ok invoke_config -> + execute_remove_block_ids mode invoke_config repo ids) + | Remove_block { repo; uuid = Some uuid; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_block mode err) + | Ok invoke_config -> + execute_remove_block_uuid mode invoke_config repo uuid) + | Remove_block _ -> + pure + (Cli_result.error ~command:Command_id.Remove_block mode + (Error.make Error.Missing_target "block is required")) + | Remove_page { repo; id = Some id; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_page mode err) + | Ok invoke_config -> execute_remove_page_id mode invoke_config repo id) + | Remove_page { repo; page = Some page; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_page mode err) + | Ok invoke_config -> + execute_remove_page_name mode invoke_config repo page) + | Remove_page _ -> + pure + (Cli_result.error ~command:Command_id.Remove_page mode + (Error.make Error.Missing_page_name "page name or id is required")) + | Remove_tag { repo; id; name; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_tag mode err) + | Ok invoke_config -> + execute_remove_entity mode invoke_config repo + ~command:Command_id.Remove_tag + ~list_method:(Edn_util.keyword_t "thread-api/cli-list-tags") + ~not_found_code:Error.Tag_not_found + ~ambiguous_code:Error.Ambiguous_tag_name ~label:"tag" + ~validate:validate_tag_target ~id ~name) + | Remove_property { repo; id; name; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Remove_property mode err) + | Ok invoke_config -> + execute_remove_entity mode invoke_config repo + ~command:Command_id.Remove_property + ~list_method:(Edn_util.keyword_t "thread-api/cli-list-properties") + ~not_found_code:Error.Property_not_found + ~ambiguous_code:Error.Ambiguous_property_name ~label:"property" + ~validate:validate_property_target ~id ~name) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples: + (Vec.of_array + [| + "logseq remove block --graph my-graph --id 123"; + "logseq remove block --graph my-graph --id '[123,456]'"; + "logseq remove block --graph my-graph --uuid \ + 7f0f4bb3-2e48-4b46-ae0f-18f52ef0f8be"; + |]) + Command_id.Remove_block "Remove blocks"; + meta + ~examples: + (Vec.of_array + [| + "logseq remove page --graph my-graph --page Home"; + "logseq remove page --graph my-graph --id 123"; + |]) + Remove_page "Remove page"; + meta + ~examples: + (Vec.singleton "logseq remove tag --graph my-graph --name project") + Remove_tag "Remove tag"; + meta + ~examples: + (Vec.of_array + [| + "logseq remove property --graph my-graph --name owner"; + "logseq remove property --graph my-graph --id 321"; + |]) + Remove_property "Remove property"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/root_dir_types.ml b/cli/lib/root_dir_types.ml new file mode 100644 index 0000000..66f3bdf --- /dev/null +++ b/cli/lib/root_dir_types.ml @@ -0,0 +1,48 @@ +let default_root_dir () = Cli_config.default_root_dir () + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let expand_home path = + match Sys.getenv_opt "HOME" with + | Some home when path = "~" -> home + | Some home when starts_with ~prefix:"~/" path -> + Filename.concat home (String.sub path 2 (String.length path - 2)) + | _ -> path + +let normalize_root_dir root = + let path = + root |> Option.value ~default:(default_root_dir ()) |> expand_home + in + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || Cli_unix.file_exists path then + () + else ( + mkdir_p (Filename.dirname path); + Cli_unix.mkdir path 0o755) + +let root_dir_error path message = + Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array [| (Edn_util.keyword "path", Edn_util.string path) |])) + Error.Root_dir_permission message + +let ensure_root_dir root = + let path = normalize_root_dir root in + try + mkdir_p path; + if not (Cli_unix.is_directory path) then + Error (root_dir_error path ("root-dir is not a directory: " ^ path)) + else ( + Cli_unix.access path (Vec.of_array [| Cli_unix.R_OK; Cli_unix.W_OK |]); + Ok path) + with exn -> + Error + (root_dir_error path + ("root-dir is not readable/writable: " ^ path ^ " (" + ^ Printexc.to_string exn ^ ")")) diff --git a/cli/lib/search.ml b/cli/lib/search.ml new file mode 100644 index 0000000..31c1d64 --- /dev/null +++ b/cli/lib/search.ml @@ -0,0 +1,294 @@ +type scope = Block | Page | Property | Tag +type opts = { content : string } + +type parsed = + | Parsed_block of opts + | Parsed_page of opts + | Parsed_property of opts + | Parsed_tag of opts + +type action = { + scope : scope; + command : Command_id.t; + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + query : string; +} + +let scope_of_parsed = function + | Parsed_block _ -> Block + | Parsed_page _ -> Page + | Parsed_property _ -> Property + | Parsed_tag _ -> Tag + +let command_id = function + | Parsed_block _ -> Command_id.Search_block + | Parsed_page _ -> Search_page + | Parsed_property _ -> Search_property + | Parsed_tag _ -> Search_tag + +let validate_parsed _ = Ok () + +let opts_of_parsed = function + | Parsed_block opts + | Parsed_page opts + | Parsed_property opts + | Parsed_tag opts -> + opts + +let trim_query parsed = (opts_of_parsed parsed).content |> String.trim + +let build ?registry:_ config _globals parsed = + match config.Cli_config.repo with + | None -> Error (Error.missing_repo "repo is required for search") + | Some repo -> + let query = trim_query parsed in + if query = "" then + Error + (Error.make + ~hint: + "Use: logseq search --content " + Error.Missing_query_text "query text is required") + else + Ok + { + scope = scope_of_parsed parsed; + command = command_id parsed; + repo; + graph = Cli_config.repo_to_graph repo; + query; + } + +let items_value items = + Edn_util.map_vec + (Vec.of_array [| (Edn_util.keyword "items", Edn_util.vector_vec items) |]) + +let sym name = Edn_util.symbol name +let kw name = Edn_util.keyword name +let vector_vec values = Edn_util.vector_vec values +let vector values = vector_vec values +let list_vec values = Edn_util.list_vec values +let variable name = sym ("?" ^ name) +let call name args = Edn_util.list_vec (Vec.push_front args (sym name)) +let where_v values = Cli_primitive.V (Edn_util.vector_t_vec values) + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let pull_attrs = function + | Block -> + Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/title"; + kw "logseq.property/deleted-at"; + Edn_util.map_vec (Vec.of_array [| (kw "block/parent", sym "...") |]); + |] + | Page -> + Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |] + | Property | Tag -> + Vec.of_array [| kw "db/id"; kw "db/ident"; kw "block/title" |] + +let text_attr = function + | Block -> kw "block/title" + | Page -> kw "block/name" + | Property | Tag -> kw "block/title" + +let class_filter = function + | Property -> Some (kw "logseq.class/Property") + | Tag -> Some (kw "logseq.class/Tag") + | Block | Page -> None + +let query_of_scope scope = + let entity = variable "e" in + let query = variable "query" in + let title = variable "title" in + let title_lower = variable "title-lower" in + let query_lower = variable "query-lower" in + let where = + Vec.of_array + [| + where_v (Vec.of_array [| entity; text_attr scope; title |]); + where_v + (Vec.of_array + [| + call "clojure.string/lower-case" (Vec.singleton title); + title_lower; + |]); + where_v + (Vec.of_array + [| + call "clojure.string/lower-case" (Vec.singleton query); + query_lower; + |]); + where_v + (Vec.of_array + [| + call "clojure.string/includes?" + (Vec.of_array [| title_lower; query_lower |]); + |]); + |] + |> fun clauses -> + match class_filter scope with + | Some class_ -> + Vec.push_front clauses + (where_v (Vec.of_array [| entity; kw "block/tags"; class_ |])) + | None -> clauses + in + Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| sym "pull"; entity; vector (pull_attrs scope) |]); + sym "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?query"; + |]) + ~where () + +let search_result_keys = Vec.of_array [| "db/id"; "db/ident"; "block/title" |] + +let select_search_item item = + Edn_util.map_vec + (Vec.filter_map + (fun key -> + Option.map + (fun value -> (Edn_util.keyword key, value)) + (Edn_util.get item key)) + search_result_keys) + +let rec recycled item = + match Edn_util.as_map item with + | Some _ -> ( + Option.is_some (Edn_util.get item "logseq.property/deleted-at") + || + match Edn_util.get item "block/parent" with + | Some parent -> recycled parent + | None -> false) + | None -> false + +let compare_search_item a b = + let title item = + Edn_util.get_string item "block/title" + |> Option.value ~default:"" |> String.lowercase_ascii + in + let id item = + Edn_util.get_int64 item "db/id" |> Option.value ~default:Int64.max_int + in + let title_cmp = String.compare (title a) (title b) in + if title_cmp <> 0 then title_cmp else Int64.compare (id a) (id b) + +let normalize_items scope items = + let items = + match scope with + | Block | Page -> Vec.filter (fun item -> not (recycled item)) items + | Property | Tag -> items + in + items + |> Vec.filter (fun value -> Option.is_some (Edn_util.as_map value)) + |> Vec.map select_search_item + |> Vec.sort compare_search_item + +let normalize_uuid_refs config repo items = + let entities = Vec.map Entity.of_value items in + let uuids = + Uuid_refs_types.collect_uuid_refs_from_items entities + (Vec.singleton (Edn_util.keyword_t "block/title")) + in + if Vec.is_empty uuids then Cli_effect.pure items + else + Cli_effect.map + (fun labels -> + Uuid_refs_types.normalize_item_string_fields entities + (Vec.singleton (Edn_util.keyword_t "block/title")) + labels + |> Vec.map (fun item -> item.Entity.raw)) + (Uuid_refs_types.fetch_uuid_labels config repo uuids) + +let execute_with_mode action config mode = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> pure (Output_mode.error ~command:action.command mode err) + | Ok invoke_config -> + bind + (Transport.thread_api_q invoke_config ~repo:action.repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (query_of_scope action.scope); + Edn_util.string action.query; + |]))) + (fun value -> + let items = + match (Edn_util.as_vector value, Edn_util.as_list value) with + | Some xs, _ | _, Some xs -> xs + | _ when Edn_util.is_null value -> Vec.empty + | _ -> Vec.singleton value + in + let items = normalize_items action.scope items in + bind (normalize_uuid_refs config action.repo items) (fun items -> + pure + (Cli_result.ok ~command:action.command mode + (Raw (items_value items)))))) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples: + (Vec.singleton + "logseq search block --content \"task\" --graph my-graph") + Command_id.Search_block "Search blocks by title"; + meta + ~examples: + (Vec.singleton + "logseq search page --content \"home\" --graph my-graph") + Search_page "Search pages by name"; + meta + ~examples: + (Vec.singleton + "logseq search property --content \"owner\" --graph my-graph") + Search_property "Search properties by title"; + meta + ~examples: + (Vec.singleton + "logseq search tag --content \"quote\" --graph my-graph") + Search_tag "Search tags by title"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/selector.ml b/cli/lib/selector.ml new file mode 100644 index 0000000..18779b8 --- /dev/null +++ b/cli/lib/selector.ml @@ -0,0 +1,26 @@ +type block = + | Block_id of Cli_primitive.db_id + | Block_uuid of Cli_primitive.uuid + +type page = + | Page_id of Cli_primitive.db_id + | Page_name of string + | Page_uuid of Cli_primitive.uuid + +type property = + | Property_id of Cli_primitive.db_id + | Property_ident of Cli_primitive.keyword + | Property_name of string + | Property_uuid of Cli_primitive.uuid + +type tag = + | Tag_id of Cli_primitive.db_id + | Tag_name of string + | Tag_ident of Cli_primitive.keyword + | Tag_uuid of Cli_primitive.uuid + +type entity = + | Entity_id of Cli_primitive.db_id + | Entity_uuid of Cli_primitive.uuid + | Entity_ident of Cli_primitive.keyword + | Entity_name of string diff --git a/cli/lib/server_command.ml b/cli/lib/server_command.ml new file mode 100644 index 0000000..ccbfa8a --- /dev/null +++ b/cli/lib/server_command.ml @@ -0,0 +1,234 @@ +type parsed = + | Parsed_list + | Parsed_cleanup + | Parsed_start + | Parsed_stop + | Parsed_restart + +type action = + | Server_list + | Server_cleanup + | Server_start of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Server_stop of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Server_restart of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + +let command_id = function + | Parsed_list -> Command_id.Server_list + | Parsed_cleanup -> Server_cleanup + | Parsed_start -> Server_start + | Parsed_stop -> Server_stop + | Parsed_restart -> Server_restart + +let validate_parsed _ = Ok () + +let action_with_repo config make message = + match config.Cli_config.repo with + | Some repo -> Ok (make repo (Cli_config.repo_to_graph repo)) + | None -> Error (Error.missing_repo message) + +let build ?registry:_ config _globals = function + | Parsed_list -> Ok Server_list + | Parsed_cleanup -> Ok Server_cleanup + | Parsed_start -> + action_with_repo config + (fun repo graph -> Server_start { repo; graph }) + "repo is required for server start" + | Parsed_stop -> + action_with_repo config + (fun repo graph -> Server_stop { repo; graph }) + "repo is required for server stop" + | Parsed_restart -> + action_with_repo config + (fun repo graph -> Server_restart { repo; graph }) + "repo is required for server restart" + +let status_value = function + | Server_runtime.Starting -> Edn_util.string "starting" + | Ready -> Edn_util.string "ready" + | Error -> Edn_util.string "error" + | Unknown -> Edn_util.string "unknown" + +let owner_source_value source = + Edn_util.string (Cli_primitive.string_of_owner_source source) + +let optional_string key = function + | Some value -> Vec.singleton (Edn_util.keyword key, Edn_util.string value) + | None -> Vec.empty + +let server_value (server : Server_runtime.server) = + Edn_util.map_vec + ( Vec.of_array + [| + ( Edn_util.keyword "repo", + Edn_util.string (Cli_primitive.string_of_repo server.repo) ); + ( Edn_util.keyword "graph", + match server.graph with + | Some graph -> + Edn_util.string (Cli_primitive.string_of_graph graph) + | None -> Edn_util.nil ); + (Edn_util.keyword "pid", Edn_util.int server.pid); + (Edn_util.keyword "host", Edn_util.string server.host); + (Edn_util.keyword "port", Edn_util.int server.port); + (Edn_util.keyword "base-url", Edn_util.string server.base_url); + (Edn_util.keyword "status", status_value server.status); + ( Edn_util.keyword "owner-source", + owner_source_value server.owner_source ); + (Edn_util.keyword "owned", Edn_util.bool server.owned); + |] + |> fun fields -> + Vec.append fields (optional_string "revision" server.revision) + |> fun fields -> + Vec.append fields (optional_string "root-dir" server.root_dir) ) + +let start_result_value (result : Server_runtime.start_result) = + Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "repo", + Edn_util.string (Cli_primitive.string_of_repo result.repo) ); + ( Edn_util.keyword "owner-source", + owner_source_value result.owner_source ); + (Edn_util.keyword "owned", Edn_util.bool result.owned); + |]) + +let stop_result_value (result : Server_runtime.stop_result) = + Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "repo", + Edn_util.string (Cli_primitive.string_of_repo result.repo) ); + |]) + +let error_value (err : Error.t) = + Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "code", + Edn_util.string (Error.code_to_string err.code) ); + (Edn_util.keyword "message", Edn_util.string err.message); + |]) + +let failed_cleanup_value (server, err) = + match Edn_util.as_map (server_value server) with + | Some fields -> + Edn_util.map_vec + (Vec.push_back fields (Edn_util.keyword "error", error_value err)) + | None -> + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "server", server_value server); + (Edn_util.keyword "error", error_value err); + |]) + +let execute_with_mode action config mode = + let open Cli_effect in + match action with + | Server_list -> + bind (Server_runtime.list_servers config) (fun servers -> + pure + (Cli_result.ok ~command:Command_id.Server_list mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "servers", + Edn_util.vector_vec (servers |> Vec.map server_value) + ); + |]))))) + | Server_cleanup -> + bind + (Server_runtime.cleanup_revision_mismatched_servers config + ~cli_revision:"unknown") (function + | Ok result -> + pure + (Cli_result.ok ~command:Command_id.Server_cleanup mode + (Raw + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "cli-revision", + Edn_util.string result.cli_revision ); + ( Edn_util.keyword "checked", + Edn_util.int result.checked ); + ( Edn_util.keyword "mismatched", + Edn_util.int result.mismatched ); + ( Edn_util.keyword "eligible", + Edn_util.int result.eligible ); + ( Edn_util.keyword "skipped-owner", + Edn_util.int result.skipped_owner ); + ( Edn_util.keyword "killed", + Edn_util.vector_vec + (result.killed |> Vec.map server_value) ); + ( Edn_util.keyword "failed", + Edn_util.vector_vec + (result.failed |> Vec.map failed_cleanup_value) + ); + |])))) + | Error err -> + pure (Output_mode.error ~command:Command_id.Server_cleanup mode err)) + | Server_start { repo; _ } -> + bind (Server_runtime.start_server config repo ~create_empty_db:false) + (function + | Ok result -> + pure + (Cli_result.ok ~command:Command_id.Server_start mode + (Raw (start_result_value result))) + | Error err -> + pure (Output_mode.error ~command:Command_id.Server_start mode err)) + | Server_stop { repo; _ } -> + bind (Server_runtime.stop_server config repo) (function + | Ok result -> + pure + (Cli_result.ok ~command:Command_id.Server_stop mode + (Raw (stop_result_value result))) + | Error err -> + pure (Output_mode.error ~command:Command_id.Server_stop mode err)) + | Server_restart { repo; _ } -> + bind (Server_runtime.restart_server config repo) (function + | Ok result -> + pure + (Cli_result.ok ~command:Command_id.Server_restart mode + (Raw (start_result_value result))) + | Error err -> + pure (Output_mode.error ~command:Command_id.Server_restart mode err)) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_management; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples:(Vec.singleton "logseq server list") + Command_id.Server_list "List db-worker-node servers"; + meta + ~examples:(Vec.singleton "logseq server cleanup") + Server_cleanup + "Clean up revision-mismatched CLI-owned db-worker-node servers"; + meta + ~examples:(Vec.singleton "logseq server start --graph my-graph") + Server_start "Start db-worker-node for a graph"; + meta + ~examples:(Vec.singleton "logseq server stop --graph my-graph") + Server_stop "Stop db-worker-node for a graph"; + meta + ~examples:(Vec.singleton "logseq server restart --graph my-graph") + Server_restart "Restart db-worker-node for a graph"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/server_runtime.ml b/cli/lib/server_runtime.ml new file mode 100644 index 0000000..dd604ad --- /dev/null +++ b/cli/lib/server_runtime.ml @@ -0,0 +1,714 @@ +open Cli_effect.Infix + +type server_status = Starting | Ready | Error | Unknown + +type server = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph option; + pid : Cli_primitive.pid; + host : string; + port : Cli_primitive.port; + base_url : Cli_primitive.url; + status : server_status; + revision : string option; + root_dir : Cli_primitive.path option; + owner_source : Cli_primitive.owner_source; + owned : bool; + raw : Melange_edn_melange.any option; +} + +type start_result = { + repo : Cli_primitive.repo; + owner_source : Cli_primitive.owner_source; + owned : bool; +} + +type stop_result = { repo : Cli_primitive.repo } +type revision_mismatch = { cli_revision : string; servers : server Rrbvec.t } + +type cleanup_result = { + cli_revision : string; + checked : int; + mismatched : int; + eligible : int; + skipped_owner : int; + killed : server Rrbvec.t; + failed : (server * Error.t) Rrbvec.t; +} + +let resolve_root_dir config = config.Cli_config.root_dir +let graphs_dir config = Filename.concat (resolve_root_dir config) "graphs" + +let lock_path ~root_dir repo = + Filename.concat + (Filename.concat + (Filename.concat root_dir "graphs") + (Graph_dir.graph_dir_name_of_repo repo)) + "db-worker.lock" + +let env_db_worker_script_path () = + match Sys.getenv_opt "LOGSEQ_DB_WORKER_NODE_SCRIPT" with + | Some path when String.trim path <> "" -> Some path + | _ -> None + +let cli_entrypoint_path () = + let argv = Cli_platform.argv () in + if Array.length argv > 1 then Some argv.(1) else None + +let parent_executable_path () = + let argv = Cli_platform.argv () in + if Array.length argv > 0 then argv.(0) else "node" + +let cli_dir_db_worker_script_paths () = + match cli_entrypoint_path () with + | Some entrypoint -> + let dir = Filename.dirname entrypoint in + Vec.of_array + [| + Filename.concat dir "db-worker-node.js"; + Filename.concat (Filename.concat dir "js") "db-worker-node.js"; + |] + | _ -> Vec.empty + +let db_worker_runtime_script_path () = + match env_db_worker_script_path () with + | Some path -> path + | None -> ( + match + Vec.find_opt Cli_unix.file_exists (cli_dir_db_worker_script_paths ()) + with + | Some path -> path + | None -> "db-worker-node.js") + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let server_list_path config = + Filename.concat (resolve_root_dir config) "server-list" + +let repo_identity repo = Cli_config.repo_to_graph repo +let same_repo a b = repo_identity a = repo_identity b + +let process_alive pid = + try + Cli_unix.kill pid 0; + true + with + | Cli_unix.Cli_unix_error (Cli_unix.ESRCH, _, _) -> false + | Cli_unix.Cli_unix_error (Cli_unix.EPERM, _, _) -> true + +let http_request ~(method_ : Fetch.requestMethod) ~url ~headers ~body + ~timeout_span = + Cli_platform.HTTP.request ?timeout_span method_ url ~headers ~body + >>= fun (response, body) -> Cli_effect.pure (response, body) + +let http_success response = + let status = Fetch.Response.status response in + status >= 200 && status < 300 + +let http_health_response response = + let status = Fetch.Response.status response in + status = 200 || status = 503 + +let server_publish_timeout_span = Time.span_of_ms 30_000L +let server_ready_timeout_span = Time.span_of_ms 30_000L + +let parse_server_list_line line = + match Vec.split_on_char ' ' (String.trim line) |> Vec.filter (( <> ) "") with + | values when Vec.length values = 2 -> ( + let pid = Vec.nth values 0 in + let port = Vec.nth values 1 in + match (int_of_string_opt pid, int_of_string_opt port) with + | Some pid, Some port when pid > 0 && port > 0 -> Some (pid, port) + | _ -> None) + | _ -> None + +let read_server_list path = + if not (Cli_unix.file_exists path) then Vec.empty + else + Cli_unix.read_text_file path + |> Vec.split_on_char '\n' + |> Vec.filter_map parse_server_list_line + +let status_of_string = function + | "ready" -> Ready + | "starting" -> Starting + | "error" -> Error + | _ -> Unknown + +let owner_source_of_string = function + | "cli" -> Cli_primitive.Cli + | "electron" -> Electron + | _ -> Unknown + +let server_of_health ~fallback_port body = + let raw = Json_util.value_of_json_string body in + let repo = + Edn_util.get_string raw "repo" + |> Option.value ~default:"" |> Cli_primitive.create_repo + in + let host = + Edn_util.get_string raw "host" |> Option.value ~default:"127.0.0.1" + in + let port = + Edn_util.get_int raw "port" |> Option.value ~default:fallback_port + in + let pid = Edn_util.get_int raw "pid" |> Option.value ~default:0 in + let status = + Edn_util.get_string raw "status" + |> Option.map status_of_string + |> Option.value ~default:Unknown + in + let owner_source = + Edn_util.get_string raw "owner-source" + |> Option.map owner_source_of_string + |> Option.value ~default:Cli_primitive.Unknown + in + { + repo; + graph = Some (Cli_config.repo_to_graph repo); + pid; + host; + port; + base_url = "http://" ^ host ^ ":" ^ string_of_int port; + status; + revision = Edn_util.get_string raw "revision"; + root_dir = Edn_util.get_string raw "root-dir"; + owner_source; + owned = owner_source = Cli_primitive.Cli || owner_source = Unknown; + raw = Some raw; + } + +let discover_server (_pid, port) = + Cli_effect.catch + (Cli_effect.map + (fun (response, body) -> + if http_health_response response then + Some (server_of_health ~fallback_port:port body) + else None) + (http_request ~method_:Fetch.Get + ~url:("http://127.0.0.1:" ^ string_of_int port ^ "/healthz") + ~headers:(Vec.singleton ("Accept", "application/json")) + ~body:"" + ~timeout_span:(Some (Time.span_of_ms 1_000L)))) + (fun _ -> Cli_effect.pure None) + +let list_servers config = + let entries = + read_server_list (server_list_path config) + |> Vec.filter (fun (pid, _) -> process_alive pid) + in + Cli_effect.map (Vec.filter_map Fun.id) + (Cli_effect.all (Vec.map discover_server entries)) + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || Cli_unix.file_exists path then + () + else ( + mkdir_p (Filename.dirname path); + Cli_unix.mkdir path 0o755) + +let ensure_repo_dir config repo = + let path = + Filename.concat (graphs_dir config) (Graph_dir.graph_dir_name_of_repo repo) + in + try + mkdir_p path; + if not (Cli_unix.is_directory path) then + Stdlib.Error + (Error.make Error.Root_dir_permission + ("graph-dir is not a directory: " ^ path)) + else Stdlib.Ok path + with exn -> + Stdlib.Error + (Error.make Error.Root_dir_permission + ("graph-dir is not readable/writable: " ^ path ^ " (" + ^ Printexc.to_string exn ^ ")")) + +let script_candidates config = + let project_dir = config.Cli_config.project_dir in + let project_candidates = + match project_dir with + | None -> Vec.empty + | Some dir -> + Vec.of_array + [| + Filename.concat (Filename.concat dir "static") "db-worker-node.js"; + Filename.concat (Filename.concat dir "dist") "db-worker-node.js"; + Filename.concat + (Filename.concat (Filename.concat dir "static") "js") + "db-worker-node.js"; + |] + in + Vec.filter_map Fun.id (Vec.singleton (env_db_worker_script_path ())) + |> fun candidates -> + Vec.append candidates (cli_dir_db_worker_script_paths ()) |> fun candidates -> + Vec.push_back candidates "db-worker-node.js" |> fun candidates -> + Vec.append candidates project_candidates + +let resolve_script_path config = + let candidates = script_candidates config in + match Vec.find_opt Cli_unix.file_exists candidates with + | Some path -> Stdlib.Ok path + | None -> + Stdlib.Error + (Error.make + ~context: + (Edn_util.vector_vec + (candidates |> Vec.map (fun path -> Edn_util.string path))) + Error.Server_script_missing + ("db-worker script is missing. Checked paths: " + ^ Vec.string_concat ", " candidates)) + +let env_with_node_runtime () = + let without_key key = + Cli_unix.environment () |> Vec.of_array + |> Vec.filter (fun entry -> + let prefix = key ^ "=" in + not (starts_with ~prefix entry)) + in + Vec.push_front (without_key "ELECTRON_RUN_AS_NODE") "ELECTRON_RUN_AS_NODE=1" + |> Vec.to_array + +let db_worker_spawn_args ~executable ~script ~root_dir ~repo ~owner_source + ~create_empty_db = + let repo = Cli_primitive.string_of_repo repo in + let args = + Vec.of_array + [| + executable; + script; + "--repo"; + repo; + "--root-dir"; + root_dir; + "--owner-source"; + owner_source; + |] + in + if create_empty_db then Vec.push_back args "--create-empty-db" else args + +let shell_quote value = + let rec needs_quote i = + i < String.length value + && + match value.[i] with + | ' ' | '\t' | '\n' | '\'' | '"' | '\\' -> true + | _ -> needs_quote (i + 1) + in + if value <> "" && not (needs_quote 0) then value + else "'" ^ Vec.string_concat "'\\''" (Vec.split_on_char '\'' value) ^ "'" + +let db_worker_command_line ~script ~root_dir ~repo ~owner_source + ~create_empty_db = + let executable = parent_executable_path () in + Vec.push_front + (db_worker_spawn_args ~executable ~script ~root_dir ~repo ~owner_source + ~create_empty_db) + "ELECTRON_RUN_AS_NODE=1" + |> Vec.map shell_quote |> Vec.string_concat " " + +let spawn_server_process ~script ~root_dir ~repo ~owner_source ~create_empty_db + = + let devnull = + Cli_unix.openfile "/dev/null" (Vec.singleton Cli_unix.O_RDWR) 0 + in + Fun.protect + ~finally:(fun () -> Cli_unix.close devnull) + (fun () -> + let executable = parent_executable_path () in + let argv = + db_worker_spawn_args ~executable ~script ~root_dir ~repo ~owner_source + ~create_empty_db + |> Vec.to_array + in + ignore + (Cli_unix.create_process_env executable argv (env_with_node_runtime ()) + devnull devnull devnull)) + +let find_repo_server config repo = + Cli_effect.map + (Vec.find_opt (fun (server : server) -> same_repo server.repo repo)) + (list_servers config) + +let invoke_config_of_server config server = + { + Transport.base_url = server.base_url; + timeout_span = config.Cli_config.timeout_span; + profile_session = config.profile_session; + } + +let wait_until_effect ?(timeout_span = Time.span_of_ms 8_000L) + ?(interval_span = Time.span_of_ms 50L) predicate = + let open Cli_effect in + let deadline = + Option.value + (Time.add_span (Time.now ()) timeout_span) + ~default:Time.max_time + in + let rec loop () = + bind (predicate ()) (function + | Some _ as result -> pure result + | None -> + if Time.compare_time (Time.now ()) deadline >= 0 then pure None + else bind (sleep interval_span) (fun () -> loop ())) + in + loop () + +let wait_for_lock path = + wait_until_effect (fun () -> + Cli_effect.pure (if Cli_unix.file_exists path then Some () else None)) + +let wait_for_ready config repo = + wait_until_effect ~timeout_span:server_ready_timeout_span (fun () -> + Cli_effect.map + (function + | Some ({ status = Ready; _ } as server) -> Some server | _ -> None) + (find_repo_server config repo)) + +let read_lock_pid path = + if not (Cli_unix.file_exists path) then None + else + try + Cli_unix.read_text_file path |> Json_util.value_of_json_string + |> fun raw -> Edn_util.get_int raw "pid" + with _ -> None + +let live_lock path = + match read_lock_pid path with Some pid -> process_alive pid | None -> false + +let owner_manageable = function + | Cli_primitive.Cli | Cli_primitive.Unknown -> true + | _ -> false + +let start_result_of_server repo (server : server) = + { repo; owner_source = server.owner_source; owned = server.owned } + +let wait_for_published_ready time config repo = + let open Cli_effect in + bind + (time "server.wait-publish" (fun () -> + wait_until_effect ~timeout_span:server_publish_timeout_span (fun () -> + find_repo_server config repo))) + (function + | None -> + pure + (Stdlib.Error + (Error.make Error.Server_start_failed + "db-worker-node failed to publish health")) + | Some _ -> + bind + (time "server.wait-ready" (fun () -> wait_for_ready config repo)) + (function + | Some server -> + pure (Stdlib.Ok (start_result_of_server repo server)) + | None -> + pure + (Stdlib.Error + (Error.make Error.Server_start_failed + "db-worker-node failed to start")))) + +let start_server_unprofiled config repo ~create_empty_db = + let open Cli_effect in + let time stage f = + Profile_types.time config.Cli_config.profile_session stage f + in + bind (find_repo_server config repo) (function + | Some ({ status = Ready; _ } as server) -> + pure (Stdlib.Ok (start_result_of_server repo server)) + | Some _ -> wait_for_published_ready time config repo + | None when Option.is_some config.Cli_config.base_url -> + pure + (Stdlib.Ok { repo; owner_source = config.owner_source; owned = false }) + | None -> ( + match ensure_repo_dir config repo with + | Stdlib.Error err -> pure (Stdlib.Error err) + | Stdlib.Ok _ -> ( + let lock = lock_path ~root_dir:(resolve_root_dir config) repo in + if live_lock lock then wait_for_published_ready time config repo + else + match resolve_script_path config with + | Stdlib.Error err -> pure (Stdlib.Error err) + | Stdlib.Ok script -> ( + try + bind + (time "server.spawn-daemon" (fun () -> + spawn_server_process ~script + ~root_dir:(resolve_root_dir config) ~repo + ~owner_source:"cli" ~create_empty_db; + pure ())) + (fun () -> + bind + (time "server.wait-lock" (fun () -> + wait_for_lock lock)) + (function + | None -> + pure + (Stdlib.Error + (Error.make + Error.Server_start_timeout_orphan + ("db-worker-node failed to start. \ + Command: " + ^ db_worker_command_line ~script + ~root_dir:(resolve_root_dir config) + ~repo ~owner_source:"cli" + ~create_empty_db))) + | Some () -> + wait_for_published_ready time config repo)) + with exn -> + pure + (Stdlib.Error + (Error.make Error.Server_start_failed + ("failed to spawn db-worker-node: " + ^ Printexc.to_string exn))))))) + +let start_server config repo ~create_empty_db = + Profile_types.time config.Cli_config.profile_session "server.ensure-started" + (fun () -> start_server_unprofiled config repo ~create_empty_db) + +let ensure_server config repo ~create_empty_db = + match config.Cli_config.base_url with + | Some base_url -> + Cli_effect.pure + (Ok + { + Transport.base_url; + timeout_span = config.timeout_span; + profile_session = config.profile_session; + }) + | None -> + let open Cli_effect in + bind (find_repo_server config repo) (function + | Some ({ status = Ready; _ } as server) -> + pure (Ok (invoke_config_of_server config server)) + | Some _ -> + bind (wait_for_ready config repo) (function + | Some server -> pure (Ok (invoke_config_of_server config server)) + | None -> + pure + (Stdlib.Error + (Error.make Error.Server_start_failed + "db-worker-node failed to start"))) + | None -> + bind (start_server config repo ~create_empty_db) (function + | Stdlib.Error err -> pure (Stdlib.Error err) + | Stdlib.Ok _ -> + bind (wait_for_ready config repo) (function + | Some server -> + pure (Ok (invoke_config_of_server config server)) + | None -> + pure + (Stdlib.Error + (Error.make Error.Server_start_failed + "db-worker-node failed to publish health"))))) + +let shutdown_server server = + Cli_effect.catch + (Cli_effect.map + (fun (response, _body) -> http_success response) + (http_request ~method_:Fetch.Post + ~url:(server.base_url ^ "/v1/shutdown") + ~headers:(Vec.singleton ("Content-Type", "application/json")) + ~body:"{}" + ~timeout_span:(Some (Time.span_of_ms 1_000L)))) + (fun _ -> Cli_effect.pure false) + +let stop_server config repo = + let open Cli_effect in + bind (find_repo_server config repo) (function + | None when Option.is_some config.Cli_config.base_url -> + pure (Stdlib.Ok { repo }) + | None -> + pure + (Stdlib.Error + (Error.make Error.Server_not_found "server is not running")) + | Some server -> + if not (owner_manageable server.owner_source) then + pure + (Stdlib.Error + (Error.make Error.Server_owned_by_other + "server is owned by another process")) + else + let shutdown_timeout_span = Time.span_of_ms 5_000L in + let shutdown_interval_span = Time.span_of_ms 200L in + let kill_timeout_span = Time.span_of_ms 1_000L in + let kill_interval_span = Time.span_of_ms 100L in + bind (shutdown_server server) (fun _ -> + bind + (wait_until_effect ~timeout_span:shutdown_timeout_span + ~interval_span:shutdown_interval_span (fun () -> + map + (function None -> Some () | Some _ -> None) + (find_repo_server config repo))) + (function + | Some () -> pure (Stdlib.Ok { repo }) + | None -> + (try + if process_alive server.pid then + Cli_unix.kill server.pid Sys.sigterm + with _ -> ()); + bind + (wait_until_effect ~timeout_span:kill_timeout_span + ~interval_span:kill_interval_span (fun () -> + map + (function None -> Some () | Some _ -> None) + (find_repo_server config repo))) + (function + | Some () -> pure (Stdlib.Ok { repo }) + | None -> + pure + (Stdlib.Error + (Error.make Error.Server_stop_timeout + "timed out stopping server")))))) + +let restart_server config repo = + stop_server config repo >>= function + | Stdlib.Ok _ -> start_server config repo ~create_empty_db:false + | Stdlib.Error err when err.code = Error.Server_not_found -> + start_server config repo ~create_empty_db:false + | Stdlib.Error err -> Cli_effect.pure (Stdlib.Error err) + +let ignored_graph_dir name = + name = "Unlinked graphs" || name = "backup" + || starts_with ~prefix:"file-version-" name + || starts_with ~prefix:"logseq_db_" name + +let contains_substring ~needle text = + let needle_len = String.length needle in + let text_len = String.length text in + let rec loop index = + index + needle_len <= text_len + && (String.sub text index needle_len = needle || loop (index + 1)) + in + needle_len = 0 || loop 0 + +let legacy_derivation_signal dir_name = + contains_substring ~needle:"++" dir_name + || contains_substring ~needle:"+3A+" dir_name + || contains_substring ~needle:"%" dir_name + +let decode_legacy_graph_dir_name dir_name = + if not (legacy_derivation_signal dir_name) then None + else Graph_dir.decode_legacy_graph_dir_name dir_name + +let canonical_graph_name graph = + if graph <> "" && not (starts_with ~prefix:"logseq_db_" graph) then Some graph + else None + +let classify_graph_dir graphs_root dir_name = + if ignored_graph_dir dir_name then None + else + match + Option.bind + (Graph_dir.canonical_graph_name_of_dir dir_name) + canonical_graph_name + with + | Some graph_name -> + Some + { + Graph_types.kind = Graph_types.Canonical; + graph_name = Some (Cli_primitive.create_graph graph_name); + graph_dir = Some dir_name; + legacy_dir = None; + target_graph_dir = None; + conflict = false; + reason = None; + } + | None -> ( + match + Option.bind + (decode_legacy_graph_dir_name dir_name) + canonical_graph_name + with + | Some graph_name -> + let target_graph_dir = Graph_dir.encode_graph_dir_name graph_name in + Some + { + Graph_types.kind = Graph_types.Legacy; + graph_name = Some (Cli_primitive.create_graph graph_name); + graph_dir = None; + legacy_dir = Some dir_name; + target_graph_dir = Some target_graph_dir; + conflict = + target_graph_dir <> dir_name + && Cli_unix.file_exists + (Filename.concat graphs_root target_graph_dir); + reason = None; + } + | None -> + if legacy_derivation_signal dir_name then + Some + { + Graph_types.kind = Graph_types.Legacy_undecodable; + graph_name = None; + graph_dir = None; + legacy_dir = Some dir_name; + target_graph_dir = None; + conflict = false; + reason = Some (Edn_util.keyword_t "graph-name-not-derivable"); + } + else None) + +let list_graph_items config = + let dir = graphs_dir config in + if Cli_unix.file_exists dir then + Cli_unix.readdir dir |> Vec.of_array + |> Vec.filter (fun name -> Cli_unix.is_directory (Filename.concat dir name)) + |> Vec.sort_uniq String.compare + |> Vec.filter_map (classify_graph_dir dir) + else Vec.empty + +let list_graphs config = + list_graph_items config + |> Vec.filter_map (function + | { Graph_types.kind = Graph_types.Canonical; graph_name = Some graph; _ } + -> + Some graph + | _ -> None) + +let revision_matches cli_revision server = + match server.revision with + | Some revision -> String.equal revision cli_revision + | None -> false + +let cleanup_revision_mismatched_servers config ~cli_revision = + let open Cli_effect in + bind (list_servers config) (fun servers -> + let mismatched = + Vec.filter + (fun server -> not (revision_matches cli_revision server)) + servers + in + let eligible, skipped = + Vec.partition + (fun (server : server) -> server.owner_source = Cli_primitive.Cli) + mismatched + in + let rec stop_loop killed failed targets = + match Vec.pop_front targets with + | None -> + pure + (Ok + { + cli_revision; + checked = Vec.length servers; + mismatched = Vec.length mismatched; + eligible = Vec.length eligible; + skipped_owner = Vec.length skipped; + killed; + failed; + }) + | Some (server, rest) -> + bind (shutdown_server server) (fun stopped -> + if stopped then + stop_loop (Vec.push_back killed server) failed rest + else + stop_loop killed + (Vec.push_back failed + ( server, + Error.make Error.Server_cleanup_failed + "failed to stop revision-mismatched server" )) + rest) + in + stop_loop Vec.empty Vec.empty eligible) diff --git a/cli/lib/sha256.ml b/cli/lib/sha256.ml new file mode 100644 index 0000000..53cc22c --- /dev/null +++ b/cli/lib/sha256.ml @@ -0,0 +1,3 @@ +let hex = Cli_platform.Crypto.sha256_hex +let base64url = Cli_platform.Crypto.sha256_base64url +let file_hex path = Cli_unix.read_binary_file path |> hex diff --git a/cli/lib/show.ml b/cli/lib/show.ml new file mode 100644 index 0000000..52022af --- /dev/null +++ b/cli/lib/show.ml @@ -0,0 +1,1845 @@ +type target = + | By_id of Cli_primitive.db_id + | By_ids of Cli_primitive.db_id Rrbvec.t + | By_uuid of Cli_primitive.uuid + | By_page of string + +type opts = { + id_raw : string option; + uuid : Cli_primitive.uuid option; + page : string option; + page_hierarchy : bool; + linked_references : bool option; + ref_id_footer : bool option; + level : int option; + stdin_id : string option; +} + +type parsed = Parsed_show of opts + +type action = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + target : target; + multi_id : bool; + linked_references : bool; + ref_id_footer : bool; + page_hierarchy : bool; + level : int option; +} + +type linked_references = { count : int; blocks : Block.t Rrbvec.t } + +type tree_data = { + root : Block.t; + linked_references : linked_references option; + referenced_uuids : Cli_primitive.uuid Rrbvec.t; + uuid_to_entity : (Cli_primitive.uuid * Entity.t) Rrbvec.t; + breadcrumb_line : string option; +} + +let is_integer_text value = + let value = String.trim value in + let len = String.length value in + if len = 0 then false + else + let start = if value.[0] = '-' then 1 else 0 in + start < len + && String.for_all + (fun c -> c >= '0' && c <= '9') + (String.sub value start (len - start)) + +let split_lines value = + Vec.split_on_char '\n' value + |> Vec.map String.trim + |> Vec.filter (fun line -> line <> "") + +let split_ws value = + Vec.split_on_char ' ' value + |> Vec.concat_map (fun value -> Vec.split_on_char '\t' value) + |> Vec.map String.trim + |> Vec.filter (fun token -> token <> "") + +let bracketed_id_vector value = + let value = String.trim value in + let len = String.length value in + len >= 2 + && value.[0] = '[' + && value.[len - 1] = ']' + && + let body = String.sub value 1 (len - 2) in + Vec.split_on_char ',' body |> Vec.concat_map split_ws + |> Vec.for_all is_integer_text + +let normalize_stdin_id = function + | Some raw -> + let text = String.trim raw in + if text = "" then None + else if bracketed_id_vector text || is_integer_text text then Some text + else + let lines = split_lines text in + let last_line = Option.value (Vec.peek_back_opt lines) ~default:"" in + if bracketed_id_vector last_line then Some last_line + else + let tokens = split_ws text in + if (not (Vec.is_empty tokens)) && Vec.for_all is_integer_text tokens + then + Some + (Melange_edn_melange.to_edn_string + (Edn_util.vector_vec + (tokens + |> Vec.map (fun token -> + Edn_util.int64 (Int64.of_string token))))) + else Some text + | None -> None + +let id_raw opts = + match opts.id_raw with + | Some _ as id -> id + | None -> normalize_stdin_id opts.stdin_id + +let count_present values = values |> Vec.filter Option.is_some |> Vec.length + +let target_count opts = + count_present + (Vec.of_array [| opts.id_raw; opts.stdin_id; opts.uuid; opts.page |]) + +let resolve_target opts = + match id_raw opts with + | Some s -> ( + match Id_parse.parse_id_string s with + | Ok { ids; _ } when Vec.length ids = 1 -> Ok (By_id (Vec.nth ids 0)) + | Ok { ids; _ } when not (Vec.is_empty ids) -> Ok (By_ids ids) + | Ok _ -> Error (Error.invalid_options "invalid id") + | Error e -> Error e) + | None -> ( + match (opts.uuid, opts.page) with + | Some uuid, _ -> + let uuid = String.trim uuid in + if Cli_primitive.is_uuid_string uuid then Ok (By_uuid uuid) + else + Error + (Error.invalid_options "Option uuid must be a valid UUID string") + | None, Some page when String.trim page <> "" -> + Ok (By_page (String.trim page)) + | _ -> Error (Error.missing_target "block or page is required")) + +let invalid_options (opts : opts) = + if target_count opts > 1 then + Some "only one of --id, --uuid, or --page is allowed" + else + match opts.level with + | Some level when level < 1 -> Some "level must be >= 1" + | _ -> None + +let command_id _ = Command_id.Show +let default_level = 10 + +let validate_parsed (Parsed_show opts) = + match invalid_options opts with + | Some message -> Error (Error.invalid_options message) + | None -> Ok () + +let build ?registry:_ config _globals (Parsed_show opts) = + match config.Cli_config.repo with + | None -> Error (Error.missing_repo "repo is required for show") + | Some repo -> + Error.bind (validate_parsed (Parsed_show opts)) (fun () -> + Error.bind (resolve_target opts) (fun target -> + Ok + { + repo; + graph = Cli_config.repo_to_graph repo; + target; + multi_id = (match target with By_ids _ -> true | _ -> false); + linked_references = + Option.value opts.linked_references ~default:true; + ref_id_footer = Option.value opts.ref_id_footer ~default:true; + page_hierarchy = opts.page_hierarchy; + level = Some (Option.value opts.level ~default:default_level); + })) + +let kw value = Edn_util.keyword value +let sym value = Edn_util.symbol value +let vector_vec values = Edn_util.vector_vec values +let wildcard = sym "*" + +let link_target_selector = + vector_vec + (Vec.of_array + [| + wildcard; + kw "db/id"; + kw "db/ident"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "block/order"; + kw "logseq.property/deleted-at"; + kw "logseq.property/created-from-property"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property/status", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/page", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/parent", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + |]) + +let pull_selector = + vector_vec + (Vec.of_array + [| + wildcard; + kw "db/id"; + kw "db/ident"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "block/order"; + kw "logseq.property/deleted-at"; + kw "logseq.property/created-from-property"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property/status", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/page", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/parent", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array [| (kw "block/link", link_target_selector) |]); + |]) + +let lookup_of_target = function + | By_id id -> Edn_util.int64 id + | By_uuid uuid -> + vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]) + | By_page page -> + vector_vec (Vec.of_array [| kw "block/name"; Edn_util.string page |]) + | By_ids _ -> Edn_util.nil + +let strip_keyword_prefix value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let value_key_matches expected key_value = + let expected = strip_keyword_prefix expected in + match (Edn_util.as_keyword key_value, Edn_util.as_string key_value) with + | Some key, _ | _, Some key -> strip_keyword_prefix key = expected + | _ -> false + +let string_starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let show_internal_key key_value = + match (Edn_util.as_keyword key_value, Edn_util.as_string key_value) with + | Some key, _ | _, Some key -> + string_starts_with ~prefix:"show/" (strip_keyword_prefix key) + | _ -> false + +let structured_internal_key key = + value_key_matches "block/uuid" key || show_internal_key key + +let rec sanitize_structured_value value = + match Edn_util.as_map value with + | Some fields -> + fields + |> Vec.filter (fun (key, _) -> not (structured_internal_key key)) + |> Vec.map (fun (key, value) -> (key, sanitize_structured_value value)) + |> fun fields -> Edn_util.map_vec fields + | None -> ( + match Edn_util.as_list value with + | Some values -> + Edn_util.list_vec (values |> Vec.map sanitize_structured_value) + | None -> ( + match Edn_util.as_vector value with + | Some values -> + Edn_util.vector_vec (values |> Vec.map sanitize_structured_value) + | None -> ( + match Edn_util.as_set value with + | Some values -> + Edn_util.set_vec (values |> Vec.map sanitize_structured_value) + | None -> value))) + +let linked_references_value (refs : linked_references) = + Edn_util.map_vec + (Vec.of_array + [| + (kw "count", Edn_util.int refs.count); + ( kw "blocks", + Edn_util.vector_vec + (Vec.map + (fun block -> sanitize_structured_value block.Block.raw) + refs.blocks) ); + |]) + +let tree_data_value root linked_references = + let fields = Vec.singleton (kw "root", sanitize_structured_value root) in + let fields = + match linked_references with + | Some refs -> + Vec.push_back fields + (kw "linked-references", linked_references_value refs) + | None -> fields + in + Edn_util.map_vec fields + +let missing_entity value = + if Edn_util.is_null value then true + else + match Edn_util.as_map value with + | Some fields -> Vec.mem_assoc (kw "db/id") fields && Vec.length fields = 1 + | _ -> false + +let property_value_block value = + Option.is_some (Edn_util.get value "logseq.property/created-from-property") + +let recycled_page value = + Option.is_some (Edn_util.get value "block/name") + && Option.is_some (Edn_util.get value "logseq.property/deleted-at") + +let recycled_page_error () = Error.make Error.Recycled_page "page is recycled" + +let ident_value value = + Option.map strip_keyword_prefix + (Option.bind (Edn_util.get value "db/ident") Edn_util.as_string_like) + +let tag_ident_matches expected tag = + let expected = strip_keyword_prefix expected in + match Edn_util.as_string_like tag with + | Some ident -> strip_keyword_prefix ident = expected + | None -> ( + match Edn_util.as_map tag with + | Some _ -> ident_value tag = Some expected + | None -> false) + +let has_tag_ident value expected = + match Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq with + | Some tags -> Vec.exists (tag_ident_matches expected) tags + | _ -> false + +let page_hierarchy_display_page value = + (has_tag_ident value "logseq.class/Page" + || has_tag_ident value "logseq.class/Journal") + && (not (has_tag_ident value "logseq.class/Tag")) + && not (has_tag_ident value "logseq.class/Property") + +let page_hierarchy_target_page value = + page_hierarchy_display_page value + && Option.is_none (Edn_util.get value "block/page") + +let library_page value = + Edn_util.get_bool value "logseq.property/built-in?" = Some true + && Edn_util.get_string value "block/title" = Some "Library" + +let entity_error = function + | By_page _ -> Error.make Error.Page_not_found "page not found" + | _ -> Error.make Error.Entity_not_found "entity not found" + +exception Show_error of Error.t + +let block_link_target_not_found () = + Error.make Error.Block_link_target_not_found "block link target not found" + +let block_link_cycle () = + Error.make Error.Block_link_cycle "block link cycle detected" + +let page_hierarchy_parent_cycle () = + Error.make Error.Page_hierarchy_parent_cycle + "page hierarchy parent cycle detected" + +let fail_show_error err = Cli_effect.error (Show_error err) + +let multi_id_error_value id err = + Edn_util.map_vec + (Vec.of_array + [| + (kw "id", Edn_util.int64 id); + ( kw "error", + Edn_util.map_vec + (Vec.of_array + [| + ( kw "code", + Edn_util.string (Error.code_to_string err.Error.code) ); + ( kw "message", + Edn_util.string + ("Entity " ^ Int64.to_string id ^ " not found") ); + |]) ); + |]) + +let multi_id_error_message id _err = + "Entity " ^ Int64.to_string id ^ " not found" + +let pull_entity config repo target = + let open Cli_effect in + bind + (Transport.thread_api_pull config ~repo + ~selector:(Edn_util.expect_vector_t "show pull selector" pull_selector) + ~lookup:(lookup_of_target target)) + (fun value -> pure value) + +let tree_block_selector = + vector_vec + (Vec.of_array + [| + wildcard; + kw "db/id"; + kw "db/ident"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/created-from-property"; + kw "block/order"; + Edn_util.map_vec + (Vec.of_array + [| + ( kw "logseq.property/status", + vector_vec + (Vec.of_array [| kw "db/ident"; kw "block/title" |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/parent", vector_vec (Vec.of_array [| kw "db/id" |])); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/page", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array + [| + ( kw "block/tags", + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/name"; + kw "block/title"; + kw "block/uuid"; + |]) ); + |]); + Edn_util.map_vec + (Vec.of_array [| (kw "block/link", link_target_selector) |]); + |]) + +let page_blocks_query = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (Edn_util.list_vec + (Vec.of_array [| sym "pull"; sym "?b"; tree_block_selector |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?page-id"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?b"; kw "block/page"; sym "?page-id" |])); + |]) + () + +let page_hierarchy_children_query = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (Edn_util.list_vec + (Vec.of_array [| sym "pull"; sym "?child"; tree_block_selector |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?parent-id"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| sym "?child"; kw "block/parent"; sym "?parent-id" |])); + |]) + () + +let row_entity value = + match + (Edn_util.as_vector value, Edn_util.as_list value, Edn_util.as_map value) + with + | Some values, _, _ when Vec.length values = 1 -> Some (Vec.peek_front values) + | _, Some values, _ when Vec.length values = 1 -> Some (Vec.peek_front values) + | _, _, Some _ -> Some value + | _ -> None + +let id_of value = Edn_util.get_int64 value "db/id" + +let parent_id_of value = + match Edn_util.get value "block/parent" with + | Some parent -> ( + match (Edn_util.as_map parent, Edn_util.as_int64 parent) with + | Some _, _ -> Edn_util.get_int64 parent "db/id" + | _, Some id -> Some id + | _ -> None) + | None -> None + +let page_id_of root = + match Edn_util.get root "block/page" with + | Some page -> ( + match (Edn_util.as_map page, Edn_util.as_int64 page) with + | Some _, _ -> Edn_util.get_int64 page "db/id" + | _, Some id -> Some id + | _ -> id_of root) + | None -> id_of root + +let order_key value = + match Edn_util.get value "block/order" with + | Some value -> ( + match (Edn_util.as_string value, Edn_util.as_int value) with + | Some s, _ -> s + | _, Some i -> string_of_int i + | _ -> "") + | _ -> "" + +let assoc_children value children = + if Vec.is_empty children then value + else Edn_util.assoc "block/children" (Edn_util.vector_vec children) value + +let build_children ?max_depth blocks root_id = + let rec children_of depth parent_id = + match max_depth with + | Some max_depth when depth >= max_depth -> Vec.empty + | _ -> + blocks + |> Vec.filter (fun block -> parent_id_of block = Some parent_id) + |> Vec.sort (fun a b -> String.compare (order_key a) (order_key b)) + |> Vec.map (fun block -> + match id_of block with + | Some id -> assoc_children block (children_of (depth + 1) id) + | None -> block) + in + children_of 1 root_id + +let fetch_page_blocks config repo page_id = + let open Cli_effect in + bind + (Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + Edn_util.any + (Cli_primitive.datascript_query_to_edn page_blocks_query); + Edn_util.int64 page_id; + |]))) + (fun rows -> + let blocks = + Option.value (Edn_util.as_seq rows) ~default:Vec.empty + |> Vec.filter_map row_entity + in + pure (Vec.filter (fun block -> not (property_value_block block)) blocks)) + +let fetch_page_hierarchy_children config repo parent_id = + let open Cli_effect in + bind + (Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + Edn_util.any + (Cli_primitive.datascript_query_to_edn + page_hierarchy_children_query); + Edn_util.int64 parent_id; + |]))) + (fun rows -> + let blocks = + Option.value (Edn_util.as_seq rows) ~default:Vec.empty + |> Vec.filter_map row_entity + in + pure + (blocks + |> Vec.filter page_hierarchy_display_page + |> Vec.sort (fun a b -> String.compare (order_key a) (order_key b)))) + +let linked_ref_ids value = + match Edn_util.as_seq value with + | Some refs -> + Vec.filter_map + (fun ref -> + match (Edn_util.as_map ref, Edn_util.as_int64 ref) with + | Some _, _ -> Edn_util.get_int64 ref "db/id" + | _, Some id -> Some id + | _ -> None) + refs + | _ -> Vec.empty + +let fetch_linked_references config repo root_id = + let open Cli_effect in + bind (Transport.thread_api_get_block_refs config ~repo ~block_id:root_id) + (fun refs -> + let rec pull_blocks acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (id, rest) -> + bind + (Transport.thread_api_pull config ~repo + ~selector: + (Edn_util.expect_vector_t "show pull selector" pull_selector) + ~lookup:(Edn_util.int64 id)) + (fun value -> + let acc = + if missing_entity value || property_value_block value then acc + else Vec.push_back acc (Block.of_value value) + in + pull_blocks acc rest) + in + bind + (pull_blocks Vec.empty (linked_ref_ids refs)) + (fun blocks -> pure { count = Vec.length blocks; blocks })) + +let attach_children config repo ?max_depth root = + let open Cli_effect in + match (id_of root, page_id_of root) with + | Some root_id, Some page_id -> + bind (fetch_page_blocks config repo page_id) (fun blocks -> + pure (assoc_children root (build_children ?max_depth blocks root_id))) + | _ -> pure root + +let attach_page_hierarchy config repo ?max_depth root = + let open Cli_effect in + let rec attach_node depth visited node = + match id_of node with + | Some id when Vec.mem id visited -> + fail_show_error (page_hierarchy_parent_cycle ()) + | None -> pure node + | Some parent_id -> ( + let visited = Vec.push_front visited parent_id in + match max_depth with + | Some max_depth when depth >= max_depth -> pure node + | _ -> + bind (fetch_page_hierarchy_children config repo parent_id) + (fun children -> + let rec attach_all acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (child, rest) -> + bind + (attach_node (depth + 1) visited child) + (fun child -> attach_all (Vec.push_back acc child) rest) + in + bind (attach_all Vec.empty children) (fun children -> + pure (assoc_children node children)))) + in + attach_node 1 Vec.empty root + +let attach_tree config (action : action) root = + if library_page root then + attach_page_hierarchy config action.repo ?max_depth:action.level root + else if action.page_hierarchy && page_hierarchy_target_page root then + attach_page_hierarchy config action.repo ?max_depth:action.level root + else attach_children config action.repo ?max_depth:action.level root + +let lookup_of_link_value link = + match Edn_util.as_map link with + | Some _ -> ( + match Edn_util.get_int64 link "db/id" with + | Some id -> Some (Edn_util.int64 id) + | None -> ( + match + Option.bind (Edn_util.get link "block/uuid") Edn_util.as_string_like + with + | Some uuid -> + Some + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + | _ -> None)) + | None -> ( + match (Edn_util.as_int64 link, Edn_util.as_uuid link) with + | Some id, _ -> Some (Edn_util.int64 id) + | _, Some uuid -> + Some + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + | _ -> ( + let values = + match (Edn_util.as_vector link, Edn_util.as_list link) with + | Some values, _ | _, Some values -> Some values + | _ -> None + in + match values with + | Some values + when Vec.length values = 2 + && value_key_matches "db/id" (Vec.nth values 0) + && Option.is_some (Edn_util.as_int64 (Vec.nth values 1)) -> + let id = Vec.nth values 1 in + Some + (Edn_util.int64 + (Option.value (Edn_util.as_int64 id) ~default:0L)) + | Some values + when Vec.length values = 2 + && value_key_matches "block/uuid" (Vec.nth values 0) -> ( + let uuid_value = Vec.nth values 1 in + match Edn_util.as_string_like uuid_value with + | Some uuid -> + Some + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + | _ -> None) + | _ -> None)) + +let pull_link_target config repo link = + let open Cli_effect in + match lookup_of_link_value link with + | Some lookup -> + bind + (Transport.thread_api_pull config ~repo + ~selector: + (Edn_util.expect_vector_t "show pull selector" pull_selector) + ~lookup) + (fun target -> + if missing_entity target then + fail_show_error (block_link_target_not_found ()) + else pure target) + | None -> fail_show_error (block_link_target_not_found ()) + +let link_target_id link = + match (Edn_util.as_map link, Edn_util.as_int64 link) with + | Some _, _ -> Edn_util.get_int64 link "db/id" + | _, Some id -> Some id + | _ -> None + +let push_value_id value ids = + match id_of value with Some id -> Vec.push_front ids id | None -> ids + +let remaining_linked_depth max_depth depth = + Option.map (fun max_depth -> max 1 (1 + max_depth - depth)) max_depth + +let linked_target_action action depth = + { action with level = remaining_linked_depth action.level depth } + +let rec resolve_linked_blocks ?(depth = 1) ?(visited = Vec.empty) config + (action : action) root = + let open Cli_effect in + match Edn_util.get root "block/link" with + | Some link -> ( + match link_target_id link with + | Some target_id when Vec.mem target_id visited -> + fail_show_error (block_link_cycle ()) + | _ -> + bind (pull_link_target config action.repo link) (fun target -> + match id_of target with + | Some target_id when Vec.mem target_id visited -> + fail_show_error (block_link_cycle ()) + | _ -> + let visited = + visited |> push_value_id root |> push_value_id target + in + let target_action = linked_target_action action depth in + bind (attach_tree config target_action target) (fun target -> + bind + (resolve_linked_blocks ~depth ~visited config action + target) (fun target -> + pure + (Edn_util.assoc "show/linked-display?" + (Edn_util.bool true) target))))) + | None -> + let children = + Option.value + (Option.bind (Edn_util.get root "block/children") Edn_util.as_seq) + ~default:Vec.empty + in + if Vec.is_empty children then pure root + else + let rec resolve_all acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (child, rest) -> + bind + (resolve_linked_blocks ~depth:(depth + 1) ~visited config action + child) + (fun child -> resolve_all (Vec.push_back acc child) rest) + in + bind (resolve_all Vec.empty children) (fun children -> + pure + (Edn_util.assoc "block/children" + (Edn_util.vector_vec children) + root)) + +let nonblank_string = function + | Some text when String.trim text <> "" -> Some text + | _ -> None + +let uuid_string value key = + Option.bind (Edn_util.get value key) Edn_util.as_string_like + |> nonblank_string + +let tag_label tag = + match nonblank_string (Edn_util.get_string tag "block/title") with + | Some title -> Some title + | None -> ( + match nonblank_string (Edn_util.get_string tag "block/name") with + | Some name -> Some name + | None -> uuid_string tag "block/uuid") + +let tags_suffix value = + let tags = + Option.value + (Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq) + ~default:Vec.empty + in + let labels = Vec.filter_map tag_label tags in + if Vec.is_empty labels then None + else Some (Vec.string_concat " " (Vec.map (fun label -> "#" ^ label) labels)) + +let status_label value = + match Edn_util.get value "logseq.property/status" with + | Some status -> nonblank_string (Edn_util.get_string status "block/title") + | None -> None + +let base_label_of value = + match Edn_util.get_string value "block/title" with + | Some title when String.trim title <> "" -> title + | _ -> ( + match Edn_util.get_string value "block/name" with + | Some name when String.trim name <> "" -> name + | _ -> ( + match Edn_util.get_string value "db/ident" with + | Some ident when String.trim ident <> "" -> ident + | _ -> "-")) + +let linked_display value = + Option.bind (Edn_util.get value "show/linked-display?") Edn_util.as_bool + = Some true + +let linked_arrow = Cli_platform.Symbols.linked_arrow + +let label_of value = + let base = + match status_label value with + | Some status -> status ^ " " ^ base_label_of value + | None -> base_label_of value + in + let label = + match tags_suffix value with + | Some suffix -> base ^ " " ^ suffix + | None -> base + in + match linked_display value with + | true -> linked_arrow ^ label + | false -> label + +let children_of value = + Option.value + (Option.bind (Edn_util.get value "block/children") Edn_util.as_seq) + ~default:Vec.empty + +type render_metadata = { + property_titles : (string * string) Rrbvec.t; + property_value_labels : (Cli_primitive.db_id * string) Rrbvec.t; +} + +let empty_render_metadata = + { property_titles = Vec.empty; property_value_labels = Vec.empty } + +let unique_preserve_order values = + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> acc + | Some (value, rest) -> + if Vec.mem value rest then loop acc rest + else loop (Vec.push_back acc value) rest + in + loop Vec.empty values + +let property_key_namespace key = + let key = strip_keyword_prefix key in + key <> "logseq.property/status" + && (not (string_starts_with ~prefix:"logseq.property/created-" key)) + && (string_starts_with ~prefix:"user.property/" key + || string_starts_with ~prefix:"logseq.property/" key + || string_starts_with ~prefix:"logseq.property." key) + +let property_entity_hidden value = + Edn_util.get_bool value "logseq.property/hide?" = Some true + +let property_entity_block_left value = + match Edn_util.get_string value "logseq.property/ui-position" with + | Some "block-left" -> true + | _ -> false + +let property_entity value = + Option.is_some (Edn_util.get value "logseq.property/type") + +let visible_property_entity value = + property_entity value + && (not (property_entity_hidden value)) + && not (property_entity_block_left value) + +let display_property_key key = + match (Edn_util.as_keyword key, Edn_util.as_keyword_t key) with + | Some text, Some ident when property_key_namespace text -> Some (text, ident) + | _ -> None + +let property_entries value = + match Edn_util.as_map value with + | Some fields -> + fields + |> Vec.filter_map (fun (key, value) -> + Option.map + (fun (key_text, key_ident) -> (key_text, key_ident, value)) + (display_property_key key)) + | None -> Vec.empty + +let rec collect_tree_property_entries root = + Vec.append (property_entries root) + (children_of root |> Vec.concat_map collect_tree_property_entries) + +let collect_property_entries root linked_references = + let linked_blocks = + match linked_references with + | None -> Vec.empty + | Some refs -> Vec.map (fun block -> block.Block.raw) refs.blocks + in + Vec.push_front linked_blocks root + |> Vec.concat_map collect_tree_property_entries + +let lookup_id_of_value value = + match Edn_util.as_map value with + | Some _ -> Edn_util.get_int64 value "db/id" + | None -> ( + let values = + match (Edn_util.as_vector value, Edn_util.as_list value) with + | Some values, _ | _, Some values -> Some values + | _ -> None + in + match values with + | Some values + when Vec.length values = 2 + && value_key_matches "db/id" (Vec.nth values 0) -> + Edn_util.as_int64 (Vec.nth values 1) + | _ -> None) + +let rec property_value_ref_ids value = + match lookup_id_of_value value with + | Some id -> Vec.singleton id + | None -> ( + match Edn_util.as_seq value with + | Some values -> property_value_ref_ids_seq values + | None -> Vec.empty) + +and property_value_ref_ids_seq values = + match Vec.pop_front values with + | Some (key, values) + when (not (Vec.is_empty values)) && value_key_matches "db/id" key -> ( + let id, rest = + match Vec.pop_front values with + | Some pair -> pair + | None -> failwith "non-empty vector must have a front value" + in + match Edn_util.as_int64 id with + | Some id -> Vec.push_front (property_value_ref_ids_seq rest) id + | None -> + Vec.append + (property_value_ref_ids key) + (Vec.append + (property_value_ref_ids id) + (property_value_ref_ids_seq rest))) + | Some (value, rest) -> + Vec.append + (property_value_ref_ids value) + (property_value_ref_ids_seq rest) + | None -> Vec.empty + +let property_title_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/title"; + kw "block/name"; + kw "logseq.property/type"; + kw "logseq.property/hide?"; + kw "logseq.property/ui-position"; + |]) + +let property_value_label_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/title"; + kw "block/name"; + kw "logseq.property/value"; + |]) + +let property_lookup ident = + vector_vec (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]) + +let fallback_property_title key = + let key = + if String.length key > 0 && key.[0] = ':' then + String.sub key 1 (String.length key - 1) + else key + in + match Vec.peek_back_opt (Vec.split_on_char '/' key) with + | Some value when value <> "" -> value + | _ -> key + +let entity_label_from_value value = + match nonblank_string (Edn_util.get_string value "block/title") with + | Some title -> Some title + | None -> ( + match nonblank_string (Edn_util.get_string value "block/name") with + | Some name -> Some name + | None -> ( + match + nonblank_string (Edn_util.get_string value "logseq.property/value") + with + | Some value -> Some value + | None -> ( + match nonblank_string (Edn_util.get_string value "db/ident") with + | Some ident -> Some ident + | None -> None))) + +let fetch_property_titles invoke_config repo idents = + let open Cli_effect in + let rec pull acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some ((key_text, ident), rest) -> + bind + (Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "property title selector" + property_title_selector) + ~lookup:(property_lookup ident)) + (fun value -> + let acc = + if visible_property_entity value then + let title = + Option.value + (entity_label_from_value value) + ~default:(fallback_property_title key_text) + in + Vec.push_back acc (key_text, title) + else acc + in + pull acc rest) + in + pull Vec.empty idents + +let fetch_property_value_labels invoke_config repo ids = + let open Cli_effect in + let rec pull acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (id, rest) -> + bind + (Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "property value selector" + property_value_label_selector) + ~lookup:(Edn_util.int64 id)) + (fun value -> + let acc = + match entity_label_from_value value with + | Some label -> Vec.push_back acc (id, label) + | None -> acc + in + pull acc rest) + in + pull Vec.empty ids + +let replace_uuid_refs_in_property_value_labels config repo property_value_labels + = + let label_uuids = + property_value_labels |> Vec.map snd + |> Uuid_refs_types.collect_uuid_refs_from_strings + in + let open Cli_effect in + bind (Uuid_refs_types.fetch_uuid_entities config repo label_uuids) + (fun uuid_entities -> + let labels = + Vec.filter_map + (fun (entry : Uuid_refs_types.uuid_label) -> + Option.map (fun label -> (entry.uuid, label)) entry.label) + uuid_entities + in + let property_value_labels = + Vec.map + (fun (id, label) -> + (id, Uuid_refs_types.replace_uuid_refs label labels)) + property_value_labels + in + pure property_value_labels) + +let prepare_property_render_metadata config invoke_config action root + linked_references = + let entries = collect_property_entries root linked_references in + if Vec.is_empty entries then Cli_effect.pure empty_render_metadata + else + let property_idents = + entries + |> Vec.map (fun (key_text, key_ident, _) -> (key_text, key_ident)) + |> unique_preserve_order + in + let open Cli_effect in + bind (fetch_property_titles invoke_config action.repo property_idents) + (fun property_titles -> + let property_title_key key = + Option.is_some (Vec.assoc_opt key property_titles) + in + let visible_entries = + Vec.filter + (fun (key_text, _, _) -> property_title_key key_text) + entries + in + let value_ids = + visible_entries + |> Vec.concat_map (fun (_, _, value) -> property_value_ref_ids value) + |> unique_preserve_order + in + bind (fetch_property_value_labels invoke_config action.repo value_ids) + (fun property_value_labels -> + bind + (replace_uuid_refs_in_property_value_labels config action.repo + property_value_labels) (fun property_value_labels -> + pure { property_titles; property_value_labels }))) + +let id_text value = + match id_of value with Some id -> Int64.to_string id | None -> "-" + +let rec tree_id_width value = + children_of value + |> Vec.fold_left + (fun width child -> max width (tree_id_width child)) + (String.length (id_text value)) + +let pad_id width id = id ^ String.make (max 0 (width - String.length id)) ' ' + +let rstrip_spaces value = + let rec loop idx = + if idx < 0 then "" + else + match value.[idx] with + | ' ' | '\t' -> loop (idx - 1) + | _ -> String.sub value 0 (idx + 1) + in + loop (String.length value - 1) + +let append_label_lines lines ~id_width ~id ~prefix ~branch ~continuation_prefix + label = + match Vec.split_on_char '\n' label with + | values when Vec.is_empty values -> lines + | values -> + let first = Vec.peek_front values in + let rest = + match Vec.pop_front values with + | Some (_, rest) -> rest + | None -> Vec.empty + in + let id_padding = String.make (id_width + 1) ' ' in + let continuation_prefix = + if Vec.is_empty rest then continuation_prefix + else id_padding ^ continuation_prefix + in + let continuation_line line = + let line = continuation_prefix ^ line in + rstrip_spaces line + in + Vec.append + (Vec.push_back lines + (pad_id id_width id ^ " " ^ prefix ^ branch ^ first)) + (Vec.map continuation_line rest) + +let property_title metadata key = Vec.assoc_opt key metadata.property_titles + +let property_value_label metadata id = + Vec.assoc_opt id metadata.property_value_labels + +let scalar_property_value_text value = + match + ( Edn_util.as_string_like value, + Edn_util.as_bool value, + Edn_util.as_int64 value, + Edn_util.as_float value ) + with + | Some text, _, _, _ -> Some (strip_keyword_prefix text) + | _, Some bool, _, _ -> Some (string_of_bool bool) + | _, _, Some int, _ -> Some (Int64.to_string int) + | _, _, _, Some float -> Some (string_of_float float) + | _ -> None + +let rec property_value_items metadata value = + match lookup_id_of_value value with + | Some id -> ( + match property_value_label metadata id with + | Some label -> Vec.singleton label + | None -> Vec.empty) + | None -> ( + match entity_label_from_value value with + | Some label -> Vec.singleton (strip_keyword_prefix label) + | None -> ( + match Edn_util.as_seq value with + | Some values -> property_value_items_seq metadata values + | None -> ( + match scalar_property_value_text value with + | Some text -> Vec.singleton text + | None -> Vec.empty))) + +and property_value_items_seq metadata values = + match Vec.pop_front values with + | Some (key, values) + when (not (Vec.is_empty values)) && value_key_matches "db/id" key -> ( + let id, rest = + match Vec.pop_front values with + | Some pair -> pair + | None -> failwith "non-empty vector must have a front value" + in + match Edn_util.as_int64 id with + | Some id -> + Vec.append + (match property_value_label metadata id with + | Some label -> Vec.singleton label + | None -> Vec.empty) + (property_value_items_seq metadata rest) + | None -> + Vec.append + (property_value_items metadata key) + (Vec.append + (property_value_items metadata id) + (property_value_items_seq metadata rest))) + | Some (value, rest) -> + Vec.append + (property_value_items metadata value) + (property_value_items_seq metadata rest) + | None -> Vec.empty + +let property_line metadata (key, _, value) = + match property_title metadata key with + | None -> None + | Some title -> ( + match property_value_items metadata value with + | values when Vec.is_empty values -> None + | values when Vec.length values = 1 -> + Some (title ^ ": " ^ Vec.peek_front values) + | values -> + Some + (title ^ ":\n" + ^ Vec.string_concat "\n" + (Vec.map (fun value -> "- " ^ value) values))) + +let property_lines metadata value = + property_entries value |> Vec.filter_map (property_line metadata) + +let append_property_lines lines ~id_width ~prefix metadata value = + let line_prefix = String.make (id_width + 1) ' ' ^ prefix in + let append_line lines line = + match Vec.split_on_char '\n' line with + | values when Vec.is_empty values -> lines + | values -> + let first = Vec.peek_front values in + let rest = + match Vec.pop_front values with + | Some (_, rest) -> rest + | None -> Vec.empty + in + Vec.append + (Vec.push_back lines (rstrip_spaces (line_prefix ^ first))) + (Vec.map (fun line -> rstrip_spaces (line_prefix ^ " " ^ line)) rest) + in + Vec.fold_left append_line lines (property_lines metadata value) + +let render_tree_text_value ?(metadata = empty_render_metadata) root = + let lines = ref Vec.empty in + let id_width = tree_id_width root in + let root_id = id_text root in + lines := + append_label_lines !lines ~id_width ~id:root_id ~prefix:"" ~branch:"" + ~continuation_prefix:"" (label_of root); + lines := append_property_lines !lines ~id_width ~prefix:"" metadata root; + let rec walk prefix node = + let children = children_of node in + let total = Vec.length children in + Vec.iteri + (fun idx child -> + let last_child = idx = total - 1 in + let branch = + if last_child then Cli_platform.Symbols.tree_last + else Cli_platform.Symbols.tree_middle + in + let next_prefix = + prefix ^ if last_child then " " else Cli_platform.Symbols.tree_pipe + in + lines := + append_label_lines !lines ~id_width ~id:(id_text child) ~prefix + ~branch ~continuation_prefix:next_prefix (label_of child); + lines := + append_property_lines !lines ~id_width ~prefix:next_prefix metadata + child; + walk next_prefix child) + children + in + walk "" root; + Vec.string_concat "\n" !lines + +let linked_reference_context_value block = + match Edn_util.get block "block/page" with + | Some page when Option.is_some (Edn_util.as_map page) -> + Edn_util.assoc "block/children" + (Edn_util.vector_vec (Vec.of_array [| block |])) + page + | _ -> block + +let render_linked_reference_text metadata block = + render_tree_text_value ~metadata (linked_reference_context_value block) + +let collect_uuid_ref_strings value = + let rec collect acc value = + let acc = + match Edn_util.as_string value with + | Some text -> Vec.push_front acc text + | None -> acc + in + match Edn_util.as_map value with + | Some fields -> + Vec.fold_left (fun acc (_, value) -> collect acc value) acc fields + | None -> ( + match Edn_util.as_seq value with + | Some values -> Vec.fold_left collect acc values + | None -> acc) + in + collect Vec.empty value + +let linked_reference_values = function + | None -> Vec.empty + | Some refs -> Vec.map (fun block -> block.Block.raw) refs.blocks + +let referenced_uuids root linked_references = + Vec.push_front (linked_reference_values linked_references) root + |> Vec.concat_map collect_uuid_ref_strings + |> Uuid_refs_types.collect_uuid_refs_from_strings + +let label_pairs uuid_entities = + Vec.filter_map + (fun (entry : Uuid_refs_types.uuid_label) -> + Option.map (fun label -> (entry.uuid, label)) entry.label) + uuid_entities + +let rec replace_uuid_refs_in_value labels value = + match Edn_util.as_string value with + | Some text -> Edn_util.string (Uuid_refs_types.replace_uuid_refs text labels) + | None -> ( + match Edn_util.as_list value with + | Some values -> + Edn_util.list_vec + (values |> Vec.map (replace_uuid_refs_in_value labels)) + | None -> ( + match Edn_util.as_vector value with + | Some values -> + Edn_util.vector_vec + (values |> Vec.map (replace_uuid_refs_in_value labels)) + | None -> ( + match Edn_util.as_set value with + | Some values -> + Edn_util.set_vec + (values |> Vec.map (replace_uuid_refs_in_value labels)) + | None -> ( + match Edn_util.as_map value with + | Some fields -> + Edn_util.map_vec + (fields + |> Vec.map (fun (key, value) -> + (key, replace_uuid_refs_in_value labels value))) + | None -> value)))) + +let replace_linked_references labels = function + | None -> None + | Some refs -> + Some + { + refs with + blocks = + Vec.map + (fun block -> + let raw = replace_uuid_refs_in_value labels block.Block.raw in + { (Block.of_value raw) with Block.raw }) + refs.blocks; + } + +let referenced_entity_line (entry : Uuid_refs_types.uuid_label) = + let id = match entry.id with Some id -> Int64.to_string id | None -> "-" in + let label = Option.value entry.label ~default:entry.uuid in + id ^ " -> " ^ label + +let referenced_entities_footer (action : action) ordered_uuids uuid_entities = + if (not action.ref_id_footer) || Vec.is_empty ordered_uuids then None + else + let rows = + ordered_uuids + |> Vec.filter_map (fun uuid -> + Vec.find_opt + (fun (entry : Uuid_refs_types.uuid_label) -> entry.uuid = uuid) + uuid_entities) + |> Vec.map referenced_entity_line + in + if Vec.is_empty rows then None + else + Some + ("Referenced Entities (" + ^ Humanize_types.format_count (Vec.length rows) + ^ ")\n" + ^ Vec.string_concat "\n" rows) + +let block_page_id root = + match Edn_util.get root "block/page" with + | Some page -> ( + match (Edn_util.as_map page, Edn_util.as_int64 page) with + | Some _, _ -> Edn_util.get_int64 page "db/id" + | _, Some id -> Some id + | _ -> None) + | None -> None + +let ordinary_block_root root = + Option.is_some (id_of root) && Option.is_some (block_page_id root) + +let breadcrumb_label value = label_of value + +let render_breadcrumb_line parents = + let rows = + parents + |> Vec.filter_map (fun parent -> + let label = breadcrumb_label parent in + if String.trim label = "" || label = "-" then None + else Some (parent, label)) + in + if Vec.is_empty rows then None + else + let id_width = + rows + |> Vec.map (fun (parent, _) -> String.length (id_text parent)) + |> Vec.fold_left max 0 + in + rows + |> Vec.mapi (fun index (parent, label) -> + let id = id_text parent in + let padding = String.make (max 0 (id_width - String.length id)) ' ' in + let indent = String.make (index * 2) ' ' in + id ^ padding ^ indent ^ " > " ^ label) + |> Vec.string_concat "\n" |> Option.some + +let fetch_breadcrumb_line invoke_config (action : action) root = + let open Cli_effect in + match id_of root with + | Some root_id when ordinary_block_root root -> + bind + (Transport.thread_api_get_block_parents invoke_config ~repo:action.repo + ~block_id:root_id) (fun value -> + let parents = + Option.value (Edn_util.as_seq value) ~default:Vec.empty + in + pure (render_breadcrumb_line parents)) + | _ -> pure None + +let prepare_uuid_refs config (action : action) root linked_references = + let uuids = referenced_uuids root linked_references in + let open Cli_effect in + bind (Uuid_refs_types.fetch_uuid_entities config action.repo uuids) + (fun uuid_entities -> + let labels = label_pairs uuid_entities in + let root = replace_uuid_refs_in_value labels root in + let linked_references = + replace_linked_references labels linked_references + in + pure + ( root, + linked_references, + referenced_entities_footer action uuids uuid_entities, + uuids )) + +let human_output_for_mode mode = not (Output.Mode.structured mode) + +let show_result mode _config _action root linked_references footer metadata = + if not (human_output_for_mode mode) then + Cli_result.ok ~command:Command_id.Show mode + (Raw (tree_data_value root linked_references)) + else + let tree_message = + match linked_references with + | Some { count; blocks } when not (Vec.is_empty blocks) -> + render_tree_text_value ~metadata root + ^ "\n\nLinked References (" ^ string_of_int count ^ ")\n" + ^ Vec.string_concat "\n\n" + (Vec.map + (fun block -> + render_linked_reference_text metadata block.Block.raw) + blocks) + | _ -> render_tree_text_value ~metadata root + in + let message = + match footer with + | Some footer -> tree_message ^ "\n\n" ^ footer + | None -> tree_message + in + Cli_result.ok ~command:Command_id.Show mode (Message message) + +let show_result_with_breadcrumb mode config action root linked_references footer + metadata breadcrumb_line = + match + show_result mode config action root linked_references footer metadata + with + | { Cli_result.data = Some (Message message); _ } -> + let message = + match breadcrumb_line with + | Some line -> line ^ "\n" ^ message + | None -> message + in + Cli_result.ok ~command:Command_id.Show mode (Message message) + | result -> result + +let attach_linked_references config (action : action) root = + let open Cli_effect in + if not action.linked_references then pure None + else + match id_of root with + | Some root_id -> + bind (fetch_linked_references config action.repo root_id) (fun refs -> + pure (Some refs)) + | None -> pure (Some { count = 0; blocks = Vec.empty }) + +let resolve_linked_references config (action : action) = function + | None -> Cli_effect.pure None + | Some refs -> + let open Cli_effect in + let rec resolve_all acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (block, rest) -> + bind (resolve_linked_blocks config action block.Block.raw) + (fun raw -> + let block = Block.of_value raw in + resolve_all (Vec.push_back acc { block with Block.raw }) rest) + in + bind (resolve_all Vec.empty refs.blocks) (fun blocks -> + pure (Some { refs with blocks })) + +let rec collect_tree_ids root = + let child_ids = children_of root |> Vec.concat_map collect_tree_ids in + match id_of root with + | Some id -> Vec.push_front child_ids id + | None -> child_ids + +let multi_tree_value root linked_references = + tree_data_value root linked_references + +let multi_tree_message mode config action root linked_references footer metadata + = + match + show_result mode config action root linked_references footer metadata + with + | { Cli_result.data = Some (Message message); _ } -> message + | _ -> render_tree_text_value ~metadata root + +let execute_single mode action config target = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> pure (Cli_result.error ~command:Command_id.Show mode err) + | Ok invoke_config -> + bind (pull_entity invoke_config action.repo target) (fun value -> + if missing_entity value then + pure + (Cli_result.error ~command:Command_id.Show mode + (entity_error target)) + else if recycled_page value then + pure + (Cli_result.error ~command:Command_id.Show mode + (recycled_page_error ())) + else + bind (attach_tree invoke_config action value) (fun value -> + bind (resolve_linked_blocks invoke_config action value) + (fun value -> + bind (attach_linked_references invoke_config action value) + (fun linked_references -> + bind + (resolve_linked_references invoke_config action + linked_references) (fun linked_references -> + bind + (prepare_uuid_refs config action value + linked_references) + (fun (value, linked_references, footer, _) -> + let metadata = + if human_output_for_mode mode then + prepare_property_render_metadata config + invoke_config action value + linked_references + else pure empty_render_metadata + in + bind metadata (fun metadata -> + let breadcrumb = + if human_output_for_mode mode then + fetch_breadcrumb_line invoke_config + action value + else pure None + in + bind breadcrumb (fun breadcrumb_line -> + pure + (show_result_with_breadcrumb mode + config action value + linked_references footer metadata + breadcrumb_line)))))))))) + +let execute_with_mode action config mode = + let run = + match action.target with + | By_ids ids -> + let open Cli_effect in + bind + (Server_runtime.ensure_server config action.repo + ~create_empty_db:false) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Show mode err) + | Ok invoke_config -> + let rec build_entries acc remaining = + match Vec.pop_front remaining with + | None -> pure acc + | Some (id, rest) -> + bind (pull_entity invoke_config action.repo (By_id id)) + (fun value -> + if missing_entity value then + build_entries + (Vec.push_back acc + (id, Error (entity_error (By_id id)))) + rest + else if recycled_page value then + build_entries + (Vec.push_back acc + (id, Error (recycled_page_error ()))) + rest + else + bind (attach_tree invoke_config action value) + (fun root -> + bind + (resolve_linked_blocks invoke_config action root) + (fun root -> + bind + (attach_linked_references invoke_config + action root) (fun linked_references -> + bind + (resolve_linked_references invoke_config + action linked_references) + (fun linked_references -> + bind + (prepare_uuid_refs config action + root linked_references) + (fun + ( root, + linked_references, + footer, + _ ) + -> + let metadata = + if human_output_for_mode mode + then + prepare_property_render_metadata + config invoke_config action + root linked_references + else pure empty_render_metadata + in + bind metadata (fun metadata -> + build_entries + (Vec.push_back acc + ( id, + Ok + ( root, + linked_references, + footer, + metadata ) )) + rest))))))) + in + bind (build_entries Vec.empty ids) (fun entries -> + let tree_ids = + entries + |> Vec.filter_map (function + | id, Ok (root, _, _, _) -> + Some (id, collect_tree_ids root) + | _ -> None) + in + let contained id = + Vec.exists + (fun (other_id, ids) -> other_id <> id && Vec.mem id ids) + tree_ids + in + let entries = + Vec.filter + (function + | id, Ok _ -> not (contained id) | _, Error _ -> true) + entries + in + let values = + Vec.map + (function + | _, Ok (root, linked_references, _, _) -> + multi_tree_value root linked_references + | id, Error err -> multi_id_error_value id err) + entries + in + let human_messages = + Vec.map + (function + | _, Ok (root, linked_references, footer, metadata) -> + multi_tree_message mode config action root + linked_references footer metadata + | id, Error err -> multi_id_error_message id err) + entries + in + let data = + if human_output_for_mode mode then + Cli_result.Message + (Vec.string_concat + "\n\ + ================================================================\n" + human_messages) + else Cli_result.Raw (Edn_util.vector_vec values) + in + pure (Cli_result.ok ~command:Command_id.Show mode data))) + | target -> execute_single mode action config target + in + Cli_effect.catch run (function + | Show_error err -> + Cli_effect.pure (Cli_result.error ~command:Command_id.Show mode err) + | exn -> Cli_effect.error exn) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples: + (Vec.of_array + [| + "logseq show --graph my-graph --page Home"; + "logseq show --graph my-graph --page Foo --page-hierarchy true"; + "logseq show --graph my-graph --page \"Meeting Notes\" --level 2"; + "logseq show --graph my-graph --id 123 --level 3"; + "logseq show --graph my-graph --id '[123,456,789]'"; + "logseq show --graph my-graph --uuid \ + 11111111-1111-1111-1111-111111111111"; + |]) + Command_id.Show "Show tree"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/skill.ml b/cli/lib/skill.ml new file mode 100644 index 0000000..123d319 --- /dev/null +++ b/cli/lib/skill.ml @@ -0,0 +1,249 @@ +type install_opts = { global : bool } +type parsed = Parsed_show | Parsed_install of install_opts + +type action = + | Skill_show of { source_path : Cli_primitive.path option } + | Skill_install of { + global : bool; + source_path : Cli_primitive.path option; + destination_dir : Cli_primitive.path option; + destination_file : Cli_primitive.path option; + } + +type install_target = { + scope : Cli_primitive.keyword; + path : Cli_primitive.path; + update_command : string; +} + +type update_status = { + installed : bool; + outdated : bool; + outdated_targets : install_target Rrbvec.t; + error : Error.t option; +} + +let skill_dir_name = "logseq-cli" +let skill_file_name = "SKILL.md" + +let skill_file_under base = + Filename.concat + (Filename.concat + (Filename.concat (Filename.concat base ".agents") "skills") + skill_dir_name) + skill_file_name + +external process_argv : string array = "argv" [@@mel.module "process"] + +let executable_dir_candidates () = + let candidates_for_script script = + let script_dir = Filename.dirname script in + Vec.of_array + [| + skill_file_under script_dir; + skill_file_under (Filename.dirname script_dir); + |] + in + if Array.length process_argv >= 2 then candidates_for_script process_argv.(1) + else if Array.length process_argv = 1 then + candidates_for_script process_argv.(0) + else Vec.empty + +let non_empty value = + match Option.map String.trim value with + | Some "" | None -> None + | Some value -> Some value + +let resolve_install_target ~global ~cwd ~home_dir = + match (global, non_empty home_dir) with + | true, None -> + Error + (Error.make Error.Skill_home_dir_unavailable + "home directory is unavailable; cannot resolve --global install \ + target") + | _ -> + let base = + if global then Option.value (non_empty home_dir) ~default:cwd else cwd + in + Ok + { + scope = Edn_util.keyword_t (if global then "global" else "local"); + path = skill_file_under base; + update_command = + (if global then "logseq skill install --global" + else "logseq skill install"); + } + +let installed_skill_targets ~cwd ~home_dir = + let local = resolve_install_target ~global:false ~cwd ~home_dir in + let global = resolve_install_target ~global:true ~cwd ~home_dir in + Vec.of_array [| local; global |] + |> Vec.filter_map (function Ok target -> Some target | Error _ -> None) + +let read_file = Cli_unix.read_text_file + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || Cli_unix.file_exists path then + () + else ( + mkdir_p (Filename.dirname path); + Cli_unix.mkdir path 0o755) + +let write_file path content = + mkdir_p (Filename.dirname path); + Cli_unix.write_text_file path content + +let default_source_candidates config = + let executable = executable_dir_candidates () in + let project = + match config.Cli_config.project_dir with + | Some project_dir -> Vec.singleton (skill_file_under project_dir) + | None -> Vec.empty + in + let home = + match Sys.getenv_opt "HOME" with + | Some home -> Vec.singleton (skill_file_under home) + | None -> Vec.empty + in + Vec.append executable project |> fun candidates -> + Vec.append candidates home |> fun candidates -> + Vec.push_back candidates (skill_file_under (Sys.getcwd ())) + +let resolve_source_path ?source_path config = + match non_empty source_path with + | Some path when Cli_unix.file_exists path -> Ok path + | Some path -> + Error + (Error.make + ~context: + (Edn_util.vector_vec (Vec.of_array [| Edn_util.string path |])) + Error.Skill_source_not_found + ("skill source file not found: " ^ path)) + | None -> ( + let candidates = default_source_candidates config in + match Vec.find_opt Cli_unix.file_exists candidates with + | Some path -> Ok path + | None -> + Error + (Error.make + ~context: + (Edn_util.vector_vec + (candidates |> Vec.map (fun path -> Edn_util.string path))) + Error.Skill_source_not_found + ("skill source file not found. Checked paths: " + ^ Vec.string_concat ", " candidates))) + +let command_id = function + | Parsed_show -> Command_id.Skill_show + | Parsed_install _ -> Skill_install + +let validate_parsed _ = Ok () + +let build ?registry:_ config _globals = function + | Parsed_show -> Ok (Skill_show { source_path = None }) + | Parsed_install { global } -> + let cwd = + Option.value config.Cli_config.project_dir ~default:(Sys.getcwd ()) + in + let home_dir = Sys.getenv_opt "HOME" in + Error.bind (resolve_install_target ~global ~cwd ~home_dir) (fun target -> + Ok + (Skill_install + { + global; + source_path = None; + destination_dir = Some (Filename.dirname target.path); + destination_file = Some target.path; + })) + +let target_from_action config global destination_dir destination_file = + match (destination_dir, destination_file) with + | Some dir, Some file -> Ok (dir, file) + | _ -> + let cwd = + Option.value config.Cli_config.project_dir ~default:(Sys.getcwd ()) + in + Error.bind + (resolve_install_target ~global ~cwd ~home_dir:(Sys.getenv_opt "HOME")) + (fun target -> Ok (Filename.dirname target.path, target.path)) + +let execute_show config mode source_path = + let command = Command_id.Skill_show in + match resolve_source_path ?source_path config with + | Error err -> Cli_effect.pure (Output_mode.error ~command mode err) + | Ok path -> ( + match read_file path with + | content -> + Cli_effect.pure (Cli_result.ok ~command mode (Message content)) + | exception exn -> + Cli_effect.pure + (Output_mode.error ~command mode + (Error.make Error.Skill_show_failed + ("failed to read skill source: " ^ Printexc.to_string exn)))) + +let install_value ~source ~file = + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "source-path", Edn_util.string source); + (Edn_util.keyword "installed-path", Edn_util.string file); + ( Edn_util.keyword "message", + Edn_util.string ("Installed skill to " ^ file) ); + |]) + +let execute_install config mode ~global ~source_path ~destination_dir + ~destination_file = + let command = Command_id.Skill_install in + match resolve_source_path ?source_path config with + | Error err -> Cli_effect.pure (Output_mode.error ~command mode err) + | Ok source -> ( + match + target_from_action config global destination_dir destination_file + with + | Error err -> Cli_effect.pure (Output_mode.error ~command mode err) + | Ok (dir, file) -> ( + try + if Cli_unix.file_exists dir && not (Cli_unix.is_directory dir) then + raise (Invalid_argument ("destination path is a file: " ^ dir)); + let content = read_file source in + write_file file content; + Cli_effect.pure + (Cli_result.ok ~command mode (Raw (install_value ~source ~file))) + with exn -> + Cli_effect.pure + (Output_mode.error ~command mode + (Error.make Error.Skill_install_failed + ("failed to install skill: " ^ Printexc.to_string exn))))) + +let execute_with_mode action config mode = + match action with + | Skill_show { source_path } -> execute_show config mode source_path + | Skill_install { global; source_path; destination_dir; destination_file } -> + execute_install config mode ~global ~source_path ~destination_dir + ~destination_file + +let meta id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Command_registry.Utilities; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta Command_id.Skill_show "Show built-in logseq-cli skill"; + meta Skill_install "Install built-in logseq-cli skill"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/style_types.ml b/cli/lib/style_types.ml new file mode 100644 index 0000000..e69de29 diff --git a/cli/lib/sync.ml b/cli/lib/sync.ml new file mode 100644 index 0000000..9a7bc0e --- /dev/null +++ b/cli/lib/sync.ml @@ -0,0 +1,1404 @@ +open Cli_effect.Infix + +type config_key = Ws_url | Http_base +type start_opts = { e2ee_password : string option } +type upload_opts = { e2ee_password : string option } +type download_opts = { progress : bool option; e2ee_password : string option } + +type asset_download_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; +} + +type ensure_keys_opts = { e2ee_password : string option; upload_keys : bool } + +type grant_access_opts = { + graph_id : Cli_primitive.uuid option; + email : Cli_primitive.email option; +} + +type config_get_opts = { key : config_key option } +type config_set_opts = { key : config_key option; value : string option } +type config_unset_opts = { key : config_key option } + +type parsed = + | Parsed_status + | Parsed_start of start_opts + | Parsed_stop + | Parsed_upload of upload_opts + | Parsed_download of download_opts + | Parsed_asset_download of asset_download_opts + | Parsed_remote_graphs + | Parsed_ensure_keys of ensure_keys_opts + | Parsed_grant_access of grant_access_opts + | Parsed_config_get of config_get_opts + | Parsed_config_set of config_set_opts + | Parsed_config_unset of config_unset_opts + +type action = + | Sync_status of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Sync_start of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + e2ee_password : string option; + } + | Sync_stop of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Sync_upload of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + e2ee_password : string option; + } + | Sync_download of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + progress : bool; + progress_explicit : bool; + e2ee_password : string option; + allow_missing_graph : bool; + require_missing_graph : bool; + } + | Sync_asset_download of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + } + | Sync_remote_graphs + | Sync_ensure_keys of { e2ee_password : string option; upload_keys : bool } + | Sync_grant_access of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + graph_id : Cli_primitive.uuid; + email : Cli_primitive.email; + } + | Sync_config_get of { key : config_key } + | Sync_config_set of { key : config_key; value : string } + | Sync_config_unset of { key : config_key } + +type remote_graph = { + graph_id : Cli_primitive.uuid; + graph_name : Cli_primitive.graph; + graph_e2ee : bool; + raw : Melange_edn_melange.any; +} + +type sync_status = { + ws_state : Cli_primitive.keyword option; + graph_id : Cli_primitive.uuid option; + last_error : Melange_edn_melange.any option; + raw : Melange_edn_melange.any; +} + +let config_key_of_string = function + | "ws-url" -> Some Ws_url + | "http-base" -> Some Http_base + | _ -> None + +let string_of_config_key = function + | Ws_url -> "ws-url" + | Http_base -> "http-base" + +let command_id = function + | Parsed_status -> Command_id.Sync_status + | Parsed_start _ -> Sync_start + | Parsed_stop -> Sync_stop + | Parsed_upload _ -> Sync_upload + | Parsed_download _ -> Sync_download + | Parsed_asset_download _ -> Sync_asset_download + | Parsed_remote_graphs -> Sync_remote_graphs + | Parsed_ensure_keys _ -> Sync_ensure_keys + | Parsed_grant_access _ -> Sync_grant_access + | Parsed_config_get _ -> Sync_config_get + | Parsed_config_set _ -> Sync_config_set + | Parsed_config_unset _ -> Sync_config_unset + +let validate_parsed _ = Ok () +let key_value key = Edn_util.keyword (string_of_config_key key) + +let config_value config = function + | Ws_url -> config.Cli_config.ws_url + | Http_base -> config.http_base + +let config_patch key value = + Edn_util.map_vec (Vec.of_array [| (key_value key, value) |]) + +let config_result ?value key = + let fields = Vec.singleton (Edn_util.keyword "key", key_value key) in + let fields = + match value with + | Some value -> Vec.push_back fields (Edn_util.keyword "value", value) + | None -> fields + in + Edn_util.map_vec fields + +let action_with_repo config make message = + match config.Cli_config.repo with + | Some repo -> Ok (make repo (Cli_config.repo_to_graph repo)) + | None -> Error (Error.missing_repo message) + +let require_key = function + | Some key -> Ok key + | None -> Error (Error.invalid_options "config key is required") + +let require_value = function + | Some value when String.trim value <> "" -> Ok value + | _ -> Error (Error.invalid_options "config value is required") + +let require_uuid option message = + match option with + | Some value when Cli_primitive.is_uuid_string value -> Ok value + | Some _ -> Error (Error.invalid_options message) + | None -> Error (Error.invalid_options message) + +let require_email = function + | Some value when String.trim value <> "" -> Ok (String.trim value) + | _ -> Error (Error.invalid_options "--email is required") + +let explicit_graph_and_repo globals = + let graph = + Option.bind globals.Global_opts.graph (fun graph -> + graph |> Cli_primitive.string_of_graph |> Cli_primitive.non_empty + |> Option.map Cli_primitive.create_graph) + in + (graph, Option.map Cli_config.graph_to_repo graph) + +let build ?registry:_ config globals = function + | Parsed_status -> + action_with_repo config + (fun repo graph -> Sync_status { repo; graph }) + "repo is required for sync-status" + | Parsed_start opts -> + action_with_repo config + (fun repo graph -> + Sync_start { repo; graph; e2ee_password = opts.e2ee_password }) + "repo is required for sync-start" + | Parsed_stop -> + action_with_repo config + (fun repo graph -> Sync_stop { repo; graph }) + "repo is required for sync-stop" + | Parsed_upload opts -> + action_with_repo config + (fun repo graph -> + Sync_upload { repo; graph; e2ee_password = opts.e2ee_password }) + "repo is required for sync-upload" + | Parsed_download opts -> ( + let graph, repo = explicit_graph_and_repo globals in + match (graph, repo) with + | Some graph, Some repo -> + Ok + (Sync_download + { + repo; + graph; + progress = Option.value opts.progress ~default:false; + progress_explicit = Option.is_some opts.progress; + e2ee_password = opts.e2ee_password; + allow_missing_graph = true; + require_missing_graph = true; + }) + | _ -> Error (Error.missing_graph ())) + | Parsed_asset_download opts -> ( + match (config.Cli_config.repo, opts.id, opts.uuid) with + | None, _, _ -> + Error (Error.missing_repo "repo is required for sync asset download") + | Some _, None, None | Some _, Some _, Some _ -> + Error + (Error.invalid_options "exactly one of --id or --uuid is required") + | Some _, _, Some uuid when not (Cli_primitive.is_uuid_string uuid) -> + Error + (Error.invalid_options "Option uuid must be a valid UUID string") + | Some repo, id, uuid -> + Ok + (Sync_asset_download + { repo; graph = Cli_config.repo_to_graph repo; id; uuid })) + | Parsed_remote_graphs -> Ok Sync_remote_graphs + | Parsed_ensure_keys opts -> + Ok + (Sync_ensure_keys + { + e2ee_password = opts.e2ee_password; + upload_keys = opts.upload_keys; + }) + | Parsed_grant_access opts -> + Error.bind + (action_with_repo config + (fun repo graph -> (repo, graph)) + "repo is required for sync grant-access") + (fun (repo, graph) -> + Error.bind (require_uuid opts.graph_id "--graph-id is required") + (fun graph_id -> + Error.bind (require_email opts.email) (fun email -> + Ok (Sync_grant_access { repo; graph; graph_id; email })))) + | Parsed_config_get opts -> + Error.bind (require_key opts.key) (fun key -> + Ok (Sync_config_get { key })) + | Parsed_config_set opts -> + Error.bind (require_key opts.key) (fun key -> + Error.bind (require_value opts.value) (fun value -> + Ok (Sync_config_set { key; value }))) + | Parsed_config_unset opts -> + Error.bind (require_key opts.key) (fun key -> + Ok (Sync_config_unset { key })) + +let execute_config_get mode config key = + let value = + config_value config key |> Option.map (fun value -> Edn_util.string value) + in + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_config_get mode + (Raw (config_result ?value key))) + +let execute_config_set mode config key value = + Cli_config.update_config config (config_patch key (Edn_util.string value)) + >>= function + | Ok _ -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_config_set mode + (Raw (config_result ~value:(Edn_util.string value) key))) + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_config_set mode err) + +let execute_config_unset mode config key = + Cli_config.update_config config (config_patch key Edn_util.nil) >>= function + | Ok _ -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_config_unset mode + (Raw (config_result key))) + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_config_unset mode err) + +let sync_config_value config = + Edn_util.map_t_vec + (Vec.of_array + [| + ( Edn_util.keyword "ws-url", + match config.Cli_config.ws_url with + | Some value -> Edn_util.string value + | None -> Edn_util.nil ); + ( Edn_util.keyword "http-base", + match config.Cli_config.http_base with + | Some value -> Edn_util.string value + | None -> Edn_util.nil ); + |]) + +let add_runtime_auth key value fields = + match value with + | Some value when String.trim value <> "" -> + Vec.push_back fields (Edn_util.keyword key, Edn_util.string value) + | _ -> fields + +let runtime_auth_state config = + let fields = + Vec.empty + |> add_runtime_auth "auth/id-token" config.Cli_config.id_token + |> add_runtime_auth "auth/access-token" config.Cli_config.access_token + |> add_runtime_auth "auth/refresh-token" config.Cli_config.refresh_token + in + if Vec.is_empty fields then None else Some (Edn_util.map_t_vec fields) + +let config_with_auth config (auth : Auth_state.auth_data) = + { + config with + Cli_config.id_token = auth.id_token; + access_token = auth.access_token; + refresh_token = auth.refresh_token; + } + +let should_resolve_auth_file config = + match + (runtime_auth_state config, config.Cli_config.auth_path, config.base_url) + with + | Some _, _, _ -> false + | None, Some _, _ -> true + | None, None, None -> true + | None, None, Some _ -> false + +let resolve_runtime_auth_if_available config = + if not (should_resolve_auth_file config) then Cli_effect.pure (Ok config) + else + Auth_state.read_auth_file config >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok None -> Cli_effect.pure (Ok config) + | Ok (Some _) -> ( + Auth_state.resolve_auth config >>= function + | Error err -> Cli_effect.pure (Error err) + | Ok auth -> Cli_effect.pure (Ok (config_with_auth config auth))) + +let prepare_worker_runtime invoke_config config = + let set_sync_config () = + Transport.thread_api_set_db_sync_config invoke_config + ~config:(sync_config_value config) + in + match runtime_auth_state config with + | None -> set_sync_config () + | Some auth_state -> + Transport.thread_api_sync_app_state invoke_config ~auth_state >>= fun _ -> + set_sync_config () + +let unquote_transit_value = function + | Melange_edn_melange.Any + (Melange_edn_melange.Tagged (("transit/quote" | "'"), value)) -> + value + | value -> value + +let result_value result = + let result = unquote_transit_value result in + match Edn_util.as_map result with + | Some _ -> result + | _ -> + Edn_util.map_vec (Vec.of_array [| (Edn_util.keyword "result", result) |]) + +let kw name = Edn_util.keyword name +let sym name = Edn_util.symbol name +let vector_vec values = Edn_util.vector_vec values +let repo_string repo = Cli_primitive.string_of_repo repo +let graph_string graph = Cli_primitive.string_of_graph graph + +let graph_e2ee_query = + Cli_primitive.make_datascript_query + ~find:(Vec.of_array [| sym "?v"; kw "." |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| sym "?e"; kw "db/ident"; kw "logseq.kv/graph-rtc-e2ee?" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "kv/value"; sym "?v" |])); + |]) + () + +let sync_download_non_empty_query = + Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + Edn_util.list_vec (Vec.of_array [| sym "count"; sym "?e" |]); kw "."; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "_" |])); + Cli_primitive.L + (Edn_util.list_t_vec + (Vec.of_array + [| + sym "not"; + vector_vec + (Vec.of_array + [| + sym "?e"; + kw "logseq.property/built-in?"; + Edn_util.bool true; + |]); + |])); + Cli_primitive.L + (Edn_util.list_t_vec + (Vec.of_array + [| + sym "not"; + vector_vec (Vec.of_array [| sym "?e"; kw "db/ident" |]); + |])); + Cli_primitive.L + (Edn_util.list_t_vec + (Vec.of_array + [| + sym "not"; + vector_vec (Vec.of_array [| sym "?e"; kw "file/path" |]); + |])); + |]) + () + +let sync_asset_pull_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/tags", vector_vec (Vec.of_array [| kw "db/ident" |])); + |]); + kw "logseq.property.asset/type"; + kw "logseq.property.asset/checksum"; + kw "logseq.property.asset/remote-metadata"; + kw "logseq.property.asset/external-url"; + |]) + +let trim_keyword value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let status_ws_state status = + match Edn_util.get_string status "ws-state" with + | Some state -> Some (trim_keyword state) + | None -> None + +let status_last_error status = + match Edn_util.get status "last-error" with + | Some value when not (Edn_util.is_null value) -> Some value + | _ -> None + +let invoke_global_config ?(create_empty_db = false) config = + match (config.Cli_config.base_url, config.repo) with + | Some base_url, _ -> + Cli_effect.pure + (Ok + { + Transport.base_url; + timeout_span = config.timeout_span; + profile_session = config.profile_session; + }) + | None, Some repo -> Server_runtime.ensure_server config repo ~create_empty_db + | None, None -> Cli_effect.pure (Error (Error.missing_graph ())) + +let graphs_value graphs = + if Edn_util.is_null graphs then Edn_util.vector_vec Vec.empty + else + match (Edn_util.as_vector graphs, Edn_util.as_list graphs) with + | Some _, _ -> graphs + | _, Some graphs -> Edn_util.vector_vec graphs + | _ -> Edn_util.vector_vec (Vec.of_array [| graphs |]) + +let tagged_error_value value = + match unquote_transit_value value with + | Melange_edn_melange.Any (Melange_edn_melange.Tagged ("error", value)) -> + Some value + | _ -> None + +let remote_graphs_error graphs = + match tagged_error_value graphs with + | Some value -> Some value + | None -> ( + match Edn_util.as_seq graphs with + | Some values -> Vec.find_map tagged_error_value values + | None -> None) + +let worker_error_message ~default_message value = + let message = + match Edn_util.get_string value "message" with + | Some message -> message + | None -> ( + match Edn_util.as_string_like value with + | Some message -> message + | None -> default_message) + in + message + +let worker_error ~code ~default_message value = + Error.make ~context:value code (worker_error_message ~default_message value) + +let remote_graphs_worker_error value = + worker_error ~code:Error.Sync_remote_graphs_failed + ~default_message:"sync remote graphs failed" value + +let sync_upload_worker_error value = + let default_message = "sync upload failed" in + let message = worker_error_message ~default_message value in + let code = + Option.map trim_keyword + (Option.bind (Edn_util.get value "code") Edn_util.as_string_like) + in + if + code = Some "db-sync/graph-already-exists" + || code = Some "graph-already-exists" + || message = "remote graph already exists; delete it before uploading again" + then Error.make ~context:value Error.Graph_already_exists message + else Error.make ~context:value Error.Sync_upload_failed message + +let e2ee_password_worker_error value = + worker_error ~code:Error.E2ee_password_failed + ~default_message:"e2ee password failed" value + +let value_string value = Option.map trim_keyword (Edn_util.as_string_like value) + +let remote_graph_values value = + if Edn_util.is_null value then Vec.empty + else Option.value (Edn_util.as_seq value) ~default:(Vec.singleton value) + +let remote_graph_name value = + match Edn_util.get value "graph-name" with + | Some value -> value_string value + | None -> None + +let remote_graph_id value = + match Edn_util.get value "graph-id" with + | Some value -> value_string value + | None -> None + +let remote_graph_e2ee value = + Option.value (Edn_util.get_bool value "graph-e2ee?") ~default:false + +let find_remote_graph graph graphs = + remote_graph_values graphs + |> Vec.find_opt (fun value -> + Option.equal String.equal (remote_graph_name value) (Some graph)) + +let remote_graph_not_found graph = + Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array [| (kw "graph", Edn_util.string graph) |])) + Error.Remote_graph_not_found + ("remote graph not found: " ^ graph) + +let graph_db_not_empty repo count = + Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "repo", Edn_util.string (repo_string repo)); + (kw "non-empty-entity-count", Edn_util.int count); + |])) + Error.Graph_db_not_empty "graph db is not empty" + +let download_progress_enabled config ~progress ~progress_explicit = + if progress_explicit then progress + else + match config.Cli_config.output_format with + | Some (Output.Mode.Packed mode) -> not (Output.Mode.structured mode) + | None -> true + +let download_progress_message graph_id event_type payload = + match (Edn_util.keyword_to_string event_type, Edn_util.as_map payload) with + | "rtc-log", Some _ -> + let event_kind = Option.bind (Edn_util.get payload "type") value_string in + let event_graph_id = + Option.bind (Edn_util.get payload "graph-uuid") value_string + in + let message = Option.bind (Edn_util.get payload "message") value_string in + if event_kind = Some "rtc.log/download" && event_graph_id = Some graph_id + then message + else None + | _ -> None + +let maybe_connect_download_progress _config invoke_config ~enabled ~graph_id = + if not enabled then Cli_effect.pure None + else + Transport.connect_events invoke_config (fun event_type payload -> + (match download_progress_message graph_id event_type payload with + | Some message when String.trim message <> "" -> print_endline message + | _ -> ()); + Cli_effect.pure ()) + >>= fun subscription -> Cli_effect.pure (Some subscription) + +let asset_download_error code message repo graph = + Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "repo", Edn_util.string (repo_string repo)); + (kw "graph", Edn_util.string (graph_string graph)); + |])) + code message + +let asset_lookup_ref ~id ~uuid = + match (id, uuid) with + | Some id, _ -> Edn_util.int64 id + | None, Some uuid -> + vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]) + | None, None -> Edn_util.nil + +let asset_tag_ident = "logseq.class/Asset" + +let asset_tag value = + match Edn_util.as_string_like value with + | Some ident -> trim_keyword ident = trim_keyword asset_tag_ident + | None -> ( + match Edn_util.as_map value with + | Some _ -> ( + match Edn_util.get value "db/ident" with + | Some ident -> + Option.value + (Option.map + (fun value -> value = trim_keyword asset_tag_ident) + (value_string ident)) + ~default:false + | None -> false) + | None -> false) + +let asset_tags value = + Option.value + (Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq) + ~default:Vec.empty + +let non_empty_string_field value key = + match Edn_util.get value key with + | Some field -> ( + match value_string field with + | Some s when String.trim s <> "" -> Some s + | _ -> None) + | None -> None + +let field_present value key = + match Edn_util.get value key with + | Some value -> not (Edn_util.is_null value) + | None -> false + +let asset_result_data ?(extra = Vec.empty) asset ~download_requested + ~checksum_status = + let fields = + Vec.of_array + [| + ( kw "asset-uuid", + Edn_util.string + (Option.value + (non_empty_string_field asset "block/uuid") + ~default:"") ); + ( kw "asset-type", + Edn_util.string + (Option.value + (non_empty_string_field asset "logseq.property.asset/type") + ~default:"") ); + (kw "download-requested?", Edn_util.bool download_requested); + (kw "checksum-status", kw checksum_status); + |] + |> fun fields -> Vec.append fields extra + in + match Edn_util.get_int64 asset "db/id" with + | Some id -> + Edn_util.map_vec + (Vec.push_front fields (kw "asset-id", Edn_util.int64 id)) + | None -> Edn_util.map_vec fields + +let asset_file_path config repo asset = + let asset_uuid = + Option.value (non_empty_string_field asset "block/uuid") ~default:"" + in + let asset_type = + Option.value + (non_empty_string_field asset "logseq.property.asset/type") + ~default:"" + in + Filename.concat + (Filename.concat + (Filename.concat + (Filename.concat config.Cli_config.root_dir "graphs") + (Graph_dir.graph_dir_name_of_repo repo)) + "assets") + (asset_uuid ^ "." ^ asset_type) + +let file_sha256 path = try Some (Sha256.file_hex path) with _ -> None + +type local_asset_status = + | Local_missing + | Local_match + | Local_mismatch of Cli_primitive.path + +let local_asset_status config repo asset = + let path = asset_file_path config repo asset in + if not (Cli_unix.file_exists path) then Local_missing + else + match + ( file_sha256 path, + non_empty_string_field asset "logseq.property.asset/checksum" ) + with + | Some actual, Some expected when String.equal actual expected -> + Local_match + | _ -> Local_mismatch path + +let remove_local_asset path = + if Cli_unix.file_exists path && not (Cli_unix.is_directory path) then + Cli_unix.remove_tree path + +let ensure_keys_args ~upload_keys ~e2ee_password = + if not upload_keys then None + else + let fields = Vec.singleton (kw "ensure-server?", Edn_util.bool true) in + let fields = + match e2ee_password with + | Some password when String.trim password <> "" -> + Vec.push_back fields (kw "password", Edn_util.string password) + | _ -> fields + in + Some (Edn_util.map_t_vec fields) + +let e2ee_password_not_found repo = + Error.make ~hint:"Provide --e2ee-password to verify and persist it." + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "repo", Edn_util.string (repo_string repo)); + (kw "action", kw "sync-start"); + |])) + Error.E2ee_password_not_found "e2ee-password not found" + +let missing_refresh_token_error config = + Error.make ~hint:"Run `logseq login` first." + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "auth-path", Edn_util.string (Auth_state.auth_path config)); + |])) + Error.Missing_auth "missing refresh token" + +let refresh_token_required config = + match config.Cli_config.refresh_token with + | Some token when String.trim token <> "" -> Ok token + | _ -> Error (missing_refresh_token_error config) + +let contains_substring ~needle text = + let needle_len = String.length needle in + let text_len = String.length text in + let rec loop index = + index + needle_len <= text_len + && (String.sub text index needle_len = needle || loop (index + 1)) + in + needle_len = 0 || loop 0 + +let missing_e2ee_password_diagnostic text = + let text = String.lowercase_ascii text in + Vec.exists + (fun needle -> contains_substring ~needle text) + (Vec.of_array + [| + "db-sync/missing-e2ee-password"; + "missing-e2ee-password"; + "db-sync/invalid-e2ee-password-payload"; + "invalid-e2ee-password-payload"; + "decrypt-text-by-text-password"; + "e2ee-password-not-found"; + |]) + +let ensure_e2ee_password_available config invoke_config repo e2ee_password + graph_e2ee = + if not graph_e2ee then Cli_effect.pure (Ok ()) + else + match refresh_token_required config with + | Error err -> Cli_effect.pure (Error err) + | Ok refresh_token -> + let verify_e2ee_password = + match e2ee_password with + | Some password when String.trim password <> "" -> + Transport.thread_api_verify_and_save_e2ee_password invoke_config + ~refresh_token ~password + | _ -> + Transport.thread_api_get_e2ee_password invoke_config + ~refresh_token + in + Cli_effect.catch + ( verify_e2ee_password >>= fun result -> + match tagged_error_value result with + | Some value -> + let message = + worker_error_message ~default_message:"e2ee password failed" + value + in + if missing_e2ee_password_diagnostic message then + Cli_effect.pure (Error (e2ee_password_not_found repo)) + else Cli_effect.pure (Error (e2ee_password_worker_error value)) + | None -> Cli_effect.pure (Ok ()) ) + (fun exn -> + let message = Printexc.to_string exn in + if missing_e2ee_password_diagnostic message then + Cli_effect.pure (Error (e2ee_password_not_found repo)) + else Cli_effect.error exn) + +let runtime_error repo status last_error = + Error.make + ~hint: + "Run sync status to inspect last-error and fix sync runtime error before \ + retrying." + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "repo", Edn_util.string (repo_string repo)); + (kw "status", status); + (kw "last-error", last_error); + |])) + Error.Sync_start_runtime_error + "sync start reached open websocket but runtime sync error is present" + +let sync_start_timeout_error repo status = + Error.make + ~hint: + "Run sync status to inspect ws-state and ensure sync endpoint/token are \ + valid." + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "repo", Edn_util.string (repo_string repo)); + (kw "status", status); + |])) + Error.Sync_start_timeout + "sync start timed out before websocket reached open state" + +let wait_sync_start_ready config invoke_config repo = + let deadline = + Option.value + (Time.add_span (Time.now ()) config.Cli_config.timeout_span) + ~default:Time.max_time + in + let rec loop () = + Transport.thread_api_db_sync_status invoke_config ~repo >>= fun status -> + match (status_ws_state status, status_last_error status) with + | Some "open", Some last_error -> + Cli_effect.pure (Error (runtime_error repo status last_error)) + | Some "open", _ -> Cli_effect.pure (Ok status) + | _ when Time.compare_time (Time.now ()) deadline >= 0 -> + Cli_effect.pure (Error (sync_start_timeout_error repo status)) + | _ -> Cli_effect.sleep (Time.span_of_ms 100L) >>= fun () -> loop () + in + loop () + +let execute_status mode config repo = + Server_runtime.ensure_server config repo ~create_empty_db:false >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_status mode err) + | Ok invoke_config -> + prepare_worker_runtime invoke_config config >>= fun _ -> + Transport.thread_api_db_sync_status invoke_config ~repo >>= fun result -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_status mode (Raw result)) + +let execute_stop mode config repo = + Server_runtime.ensure_server config repo ~create_empty_db:false >>= function + | Error err -> + Cli_effect.pure (Cli_result.error ~command:Command_id.Sync_stop mode err) + | Ok invoke_config -> + prepare_worker_runtime invoke_config config >>= fun _ -> + Transport.thread_api_db_sync_stop invoke_config >>= fun result -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_stop mode + (Raw (result_value result))) + +let sync_download_timeout_span = Time.span_of_ms 1_800_000L + +let sync_download_invoke_config invoke_config = + { + invoke_config with + Transport.timeout_span = + (if + Time.compare_span invoke_config.Transport.timeout_span + sync_download_timeout_span + >= 0 + then invoke_config.Transport.timeout_span + else sync_download_timeout_span); + } + +let execute_upload mode config repo e2ee_password = + let command = Command_id.Sync_upload in + let error err = Cli_effect.pure (Cli_result.error ~command mode err) in + resolve_runtime_auth_if_available config >>= function + | Error err -> error err + | Ok config -> ( + Server_runtime.ensure_server config repo ~create_empty_db:false + >>= function + | Error err -> error err + | Ok invoke_config -> ( + let upload_invoke_config = + sync_download_invoke_config invoke_config + in + let ensure_upload_e2ee_password () = + match e2ee_password with + | Some password when String.trim password <> "" -> ( + let options = + ensure_keys_args ~upload_keys:true ~e2ee_password + in + Transport.thread_api_db_sync_ensure_user_rsa_keys ?options + upload_invoke_config + >>= fun result -> + match tagged_error_value result with + | Some value -> + Cli_effect.pure (Error (e2ee_password_worker_error value)) + | None -> + ensure_e2ee_password_available config upload_invoke_config + repo e2ee_password true) + | _ -> Cli_effect.pure (Ok ()) + in + prepare_worker_runtime upload_invoke_config config >>= fun _ -> + ensure_upload_e2ee_password () >>= function + | Error err -> error err + | Ok () -> ( + Transport.thread_api_db_sync_upload_graph upload_invoke_config + ~repo + >>= fun result -> + match tagged_error_value result with + | Some value -> error (sync_upload_worker_error value) + | None -> + Cli_effect.pure + (Cli_result.ok ~command mode (Raw (result_value result)))))) + +let execute_start mode config repo e2ee_password = + let command = Command_id.Sync_start in + let error err = Cli_effect.pure (Cli_result.error ~command mode err) in + let ok status = Cli_effect.pure (Cli_result.ok ~command mode (Raw status)) in + resolve_runtime_auth_if_available config >>= function + | Error err -> error err + | Ok config -> ( + Server_runtime.ensure_server config repo ~create_empty_db:false + >>= function + | Error err -> error err + | Ok invoke_config -> + prepare_worker_runtime invoke_config config >>= fun _ -> + let start_sync () = + Transport.thread_api_db_sync_start invoke_config ~repo >>= fun _ -> + wait_sync_start_ready config invoke_config repo >>= function + | Ok status -> ok status + | Error err -> error err + in + Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + Edn_util.any + (Cli_primitive.datascript_query_to_edn graph_e2ee_query); + |])) + >>= fun graph_e2ee -> + let graph_e2ee = Edn_util.as_bool graph_e2ee = Some true in + let handle_e2ee = function + | Error err -> error err + | Ok () -> start_sync () + in + ensure_e2ee_password_available config invoke_config repo e2ee_password + graph_e2ee + >>= handle_e2ee) + +let execute_remote_graphs mode config = + Auth_state.resolve_auth config >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_remote_graphs mode err) + | Ok auth -> ( + let config = config_with_auth config auth in + invoke_global_config config >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_remote_graphs mode err) + | Ok invoke_config -> ( + prepare_worker_runtime invoke_config config >>= fun _ -> + Transport.thread_api_db_sync_list_remote_graphs invoke_config + >>= fun graphs -> + match remote_graphs_error graphs with + | Some error_value -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_remote_graphs mode + (remote_graphs_worker_error error_value)) + | None -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_remote_graphs mode + (Raw + (Edn_util.map_vec + (Vec.of_array [| (kw "graphs", graphs_value graphs) |])))) + )) + +let execute_grant_access mode config repo graph_id email = + Server_runtime.ensure_server config repo ~create_empty_db:false >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_grant_access mode err) + | Ok invoke_config -> + prepare_worker_runtime invoke_config config >>= fun _ -> + Transport.thread_api_db_sync_grant_graph_access invoke_config ~repo + ~graph_id ~email + >>= fun result -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_grant_access mode + (Raw (result_value result))) + +let verify_e2ee_password_if_provided config invoke_config e2ee_password = + match e2ee_password with + | Some password when String.trim password <> "" -> ( + match refresh_token_required config with + | Error err -> Cli_effect.pure (Error err) + | Ok refresh_token -> ( + Transport.thread_api_verify_and_save_e2ee_password invoke_config + ~refresh_token ~password + >>= fun result -> + match tagged_error_value result with + | Some value -> + Cli_effect.pure (Error (e2ee_password_worker_error value)) + | None -> Cli_effect.pure (Ok ()))) + | _ -> Cli_effect.pure (Ok ()) + +let execute_ensure_keys mode config ~upload_keys ~e2ee_password = + let command = Command_id.Sync_ensure_keys in + let error err = Cli_effect.pure (Cli_result.error ~command mode err) in + let ok result = Cli_effect.pure (Cli_result.ok ~command mode (Raw result)) in + resolve_runtime_auth_if_available config >>= function + | Error err -> error err + | Ok config -> ( + invoke_global_config config >>= function + | Error err -> error err + | Ok invoke_config -> ( + prepare_worker_runtime invoke_config config >>= fun _ -> + let options = ensure_keys_args ~upload_keys ~e2ee_password in + let ensure_keys () = + Transport.thread_api_db_sync_ensure_user_rsa_keys ?options + invoke_config + >>= fun result -> + match tagged_error_value result with + | Some value -> + Cli_effect.pure (Error (e2ee_password_worker_error value)) + | None -> Cli_effect.pure (Ok (result_value result)) + in + let verify_password () = + verify_e2ee_password_if_provided config invoke_config e2ee_password + in + if upload_keys then + ensure_keys () >>= function + | Error err -> error err + | Ok result -> ( + verify_password () >>= function + | Error err -> error err + | Ok () -> ok result) + else + verify_password () >>= function + | Error err -> error err + | Ok () -> ( + ensure_keys () >>= function + | Error err -> error err + | Ok result -> ok result))) + +let ensure_empty_download_db invoke_config repo = + Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + Edn_util.any + (Cli_primitive.datascript_query_to_edn + sync_download_non_empty_query); + |])) + >>= function + | count_value when Option.value (Edn_util.as_int count_value) ~default:0 > 0 + -> + let count = Option.value (Edn_util.as_int count_value) ~default:0 in + Cli_effect.pure (Error (graph_db_not_empty repo count)) + | _ -> Cli_effect.pure (Ok ()) + +let invoke_download_graph mode _config invoke_config repo graph_id graph_e2ee + subscription = + Cli_effect.finally + ( Transport.thread_api_db_sync_download_graph_by_id invoke_config ~repo + ~graph_id ~graph_e2ee + >>= fun result -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_download mode + (Raw (result_value result))) ) + (fun () -> + match subscription with + | Some subscription -> subscription.Transport.close () + | None -> Cli_effect.pure ()) + +let execute_download_with_remote mode config repo graph progress + progress_explicit e2ee_password remote_graph = + match remote_graph_id remote_graph with + | None -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode + (remote_graph_not_found graph)) + | Some graph_id -> ( + let graph_e2ee = remote_graph_e2ee remote_graph in + Server_runtime.ensure_server config repo ~create_empty_db:true + >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode err) + | Ok invoke_config -> ( + let download_invoke_config = + sync_download_invoke_config invoke_config + in + prepare_worker_runtime invoke_config config >>= fun _ -> + ensure_e2ee_password_available config invoke_config repo e2ee_password + graph_e2ee + >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode err) + | Ok () -> ( + ensure_empty_download_db invoke_config repo >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode err) + | Ok () -> + let progress_enabled = + download_progress_enabled config ~progress + ~progress_explicit + in + maybe_connect_download_progress config download_invoke_config + ~enabled:progress_enabled ~graph_id + >>= fun subscription -> + invoke_download_graph mode config download_invoke_config repo + graph_id graph_e2ee subscription))) + +let execute_download mode config repo graph progress progress_explicit + e2ee_password = + let graph_name = graph_string graph in + resolve_runtime_auth_if_available config >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode err) + | Ok config -> ( + invoke_global_config config ~create_empty_db:true >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode err) + | Ok global_config -> ( + prepare_worker_runtime global_config config >>= fun _ -> + Transport.thread_api_db_sync_list_remote_graphs global_config + >>= fun remote_graphs -> + match find_remote_graph graph_name remote_graphs with + | None -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_download mode + (remote_graph_not_found graph_name)) + | Some remote_graph -> + execute_download_with_remote mode config repo graph_name progress + progress_explicit e2ee_password remote_graph)) + +let validate_asset repo graph asset = + match Edn_util.as_map asset with + | Some _ -> + if not (Vec.exists asset_tag (asset_tags asset)) then + Error + (asset_download_error Error.Not_asset + "selected entity is not an asset" repo graph) + else if Option.is_none (non_empty_string_field asset "block/uuid") then + Error + (asset_download_error Error.Asset_uuid_missing "asset uuid is missing" + repo graph) + else if + Option.is_none + (non_empty_string_field asset "logseq.property.asset/type") + then + Error + (asset_download_error Error.Asset_type_missing "asset type is missing" + repo graph) + else if + Option.is_none + (non_empty_string_field asset "logseq.property.asset/checksum") + then + Error + (asset_download_error Error.Asset_checksum_missing + "asset checksum is missing" repo graph) + else if not (field_present asset "logseq.property.asset/remote-metadata") + then + Error + (asset_download_error Error.Asset_not_remote + "asset remote metadata is missing" repo graph) + else if + Option.is_some + (non_empty_string_field asset "logseq.property.asset/external-url") + then + Error + (asset_download_error Error.External_asset + "external URL assets cannot be downloaded through sync" repo graph) + else Ok asset + | _ -> + Error + (asset_download_error Error.Asset_not_found "asset not found" repo graph) + +let request_asset_download mode _config invoke_config repo asset checksum_status + extra = + match non_empty_string_field asset "block/uuid" with + | Some asset_uuid -> + Transport.thread_api_db_sync_request_asset_download invoke_config ~repo + ~asset_uuid + >>= fun _ -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_asset_download mode + (Raw + (asset_result_data ~extra asset ~download_requested:true + ~checksum_status))) + | None -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_asset_download mode + (Error.make Error.Asset_uuid_missing "asset uuid is missing")) + +let execute_asset_download_request mode config invoke_config repo graph asset = + Transport.thread_api_db_sync_status invoke_config ~repo >>= fun status -> + match (status_ws_state status, Edn_util.get status "graph-id") with + | Some "open", Some graph_id when Option.is_some (value_string graph_id) -> ( + match local_asset_status config repo asset with + | Local_match -> + Cli_effect.pure + (Cli_result.ok ~command:Command_id.Sync_asset_download mode + (Raw + (asset_result_data + ~extra: + (Vec.singleton + (kw "skipped-reason", kw "already-downloaded")) + asset ~download_requested:false ~checksum_status:"match"))) + | Local_mismatch path -> + remove_local_asset path; + request_asset_download mode config invoke_config repo asset "mismatch" + (Vec.singleton + ( kw "hint", + Edn_util.string + "Local asset checksum mismatched; requested re-download." )) + | Local_missing -> + request_asset_download mode config invoke_config repo asset "missing" + Vec.empty) + | _ -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_asset_download mode + (Error.make + ~hint: + ("Run logseq sync start --graph " ^ graph_string graph + ^ " first.") + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "repo", Edn_util.string (repo_string repo)); + (kw "graph", Edn_util.string (graph_string graph)); + (kw "status", status); + |])) + Error.Sync_not_started "sync is not started for this graph")) + +let execute_asset_download mode config repo graph id uuid = + Server_runtime.ensure_server config repo ~create_empty_db:false >>= function + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_asset_download mode err) + | Ok invoke_config -> ( + prepare_worker_runtime invoke_config config >>= fun _ -> + Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "sync asset pull selector" + sync_asset_pull_selector) + ~lookup:(asset_lookup_ref ~id ~uuid) + >>= fun asset -> + match validate_asset repo graph asset with + | Error err -> + Cli_effect.pure + (Cli_result.error ~command:Command_id.Sync_asset_download mode err) + | Ok asset -> + execute_asset_download_request mode config invoke_config repo graph + asset) + +let execute_with_mode action config mode = + match action with + | Sync_config_get { key } -> execute_config_get mode config key + | Sync_config_set { key; value } -> execute_config_set mode config key value + | Sync_config_unset { key } -> execute_config_unset mode config key + | Sync_status { repo; _ } -> execute_status mode config repo + | Sync_start { repo; e2ee_password; _ } -> + execute_start mode config repo e2ee_password + | Sync_stop { repo; _ } -> execute_stop mode config repo + | Sync_upload { repo; e2ee_password; _ } -> + execute_upload mode config repo e2ee_password + | Sync_download { repo; graph; progress; progress_explicit; e2ee_password; _ } + -> + execute_download mode config repo graph progress progress_explicit + e2ee_password + | Sync_asset_download { repo; graph; id; uuid } -> + execute_asset_download mode config repo graph id uuid + | Sync_remote_graphs -> execute_remote_graphs mode config + | Sync_ensure_keys { e2ee_password; upload_keys } -> + execute_ensure_keys mode config ~upload_keys ~e2ee_password + | Sync_grant_access { repo; graph_id; email; _ } -> + execute_grant_access mode config repo graph_id email + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_management; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples:(Vec.singleton "logseq sync status --graph my-graph") + Command_id.Sync_status "Show db-sync runtime status"; + meta + ~examples: + (Vec.of_array + [| + "logseq sync start --graph my-graph"; + "logseq sync start --graph my-graph --e2ee-password \ + \"my-secret\""; + |]) + Sync_start "Start db-sync client"; + meta + ~examples:(Vec.singleton "logseq sync stop --graph my-graph") + Sync_stop "Stop db-sync client"; + meta + ~examples: + (Vec.of_array + [| + "logseq sync upload --graph my-graph"; + "logseq sync upload --graph my-graph --e2ee-password \ + \"my-secret\""; + |]) + Sync_upload "Initialize upload of the entire graph"; + meta + ~examples: + (Vec.of_array + [| + "logseq sync download --graph my-graph"; + "logseq sync download --graph my-graph --progress"; + "logseq sync download --graph my-graph --e2ee-password \ + \"my-secret\""; + |]) + Sync_download "Download remote graph snapshot"; + meta + ~examples: + (Vec.of_array + [| + "logseq sync asset download --graph my-graph --id 123"; + "logseq sync asset download --graph my-graph --uuid "; + |]) + Sync_asset_download "Download remote asset"; + meta + ~examples:(Vec.singleton "logseq sync remote-graphs") + Sync_remote_graphs "List remote graphs"; + meta + ~examples: + (Vec.of_array + [| + "logseq sync ensure-keys"; + "logseq sync ensure-keys --e2ee-password \"my-secret\" \ + --upload-keys"; + |]) + Sync_ensure_keys "Ensure user RSA keys for sync/e2ee"; + meta + ~examples: + (Vec.of_array + [| + "logseq sync grant-access --graph my-graph --graph-id \ + 8b6ecdd0-1fab-4a9f-b3fb-3069c5f76e95 --email \ + teammate@example.com"; + |]) + Sync_grant_access "Grant graph access to an email"; + meta + ~examples:(Vec.singleton "logseq sync config set sync-enabled true") + Sync_config_set "Set sync config key"; + meta + ~examples:(Vec.singleton "logseq sync config get sync-enabled") + Sync_config_get "Get sync config key"; + meta + ~examples:(Vec.singleton "logseq sync config unset sync-enabled") + Sync_config_unset "Unset sync config key"; + |] + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/task_status.ml b/cli/lib/task_status.ml new file mode 100644 index 0000000..a8a61ff --- /dev/null +++ b/cli/lib/task_status.ml @@ -0,0 +1,129 @@ +type status = { ident : Cli_primitive.keyword; value : string } + +let kw value = Edn_util.keyword value +let sym value = Edn_util.symbol value +let vector_vec values = Edn_util.vector_vec values +let vector_t_vec values = Edn_util.vector_t_vec values + +let status_closed_values_query = + Cli_primitive.( + make_datascript_query + ~find: + (Vec.singleton + (vector_vec (Vec.of_array [| sym "?status-ident"; sym "..." |]))) + ~where: + (Vec.of_array + [| + V + (vector_t_vec + (Vec.of_array + [| + sym "?property"; + kw "db/ident"; + kw "logseq.property/status"; + |])); + V + (vector_t_vec + (Vec.of_array + [| + sym "?value"; + kw "block/closed-value-property"; + sym "?property"; + |])); + V + (vector_t_vec + (Vec.of_array + [| sym "?value"; kw "db/ident"; sym "?status-ident" |])); + |]) + () + |> datascript_query_to_edn) + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let drop_prefix prefix value = + if starts_with ~prefix value then + String.sub value (String.length prefix) + (String.length value - String.length prefix) + else value + +let normalize_token value = + value |> String.trim |> String.lowercase_ascii |> drop_prefix ":" + |> drop_prefix "logseq.property/status." + |> drop_prefix "status." + |> String.map (function ' ' | '_' -> '-' | c -> c) + +let status_value_of_ident ident = + ident |> drop_prefix ":" + |> drop_prefix "logseq.property/status." + |> drop_prefix "status." |> normalize_token + +let keyword_of_string value = Edn_util.keyword_t value + +let status_of_value value = + match Edn_util.as_string_like value with + | Some ident -> + let value = status_value_of_ident ident in + if value = "" || value = ident then None + else Some { ident = keyword_of_string ident; value } + | None -> ( + match Edn_util.as_map value with + | None -> None + | Some fields -> ( + let value_of_key key = + Vec.find_map + (fun (k, v) -> + match Edn_util.as_string_like k with + | Some k when k = key -> Some v + | _ -> None) + fields + in + let ident = + match + Option.bind (value_of_key "ident") Edn_util.as_string_like + with + | Some ident -> Some (keyword_of_string ident) + | None -> None + in + let value = + match + Option.bind (value_of_key "value") Edn_util.as_string_like + with + | Some value -> Some (normalize_token value) + | _ -> + Option.map + (fun ident -> + status_value_of_ident (Edn_util.keyword_to_string ident)) + ident + in + match (ident, value) with + | Some ident, Some value when value <> "" -> Some { ident; value } + | _ -> None)) + +let normalize_available_statuses values = + values + |> Vec.filter_map status_of_value + |> Vec.sort (fun a b -> compare (a.value, a.ident) (b.value, b.ident)) + +let resolve_status_ident value statuses = + let token = normalize_token value in + let ident_from_token = + keyword_of_string ("logseq.property/status." ^ token) + in + statuses + |> Vec.find_map (fun s -> + if + s.value = token + || s.ident = keyword_of_string value + || s.ident = ident_from_token + then Some s.ident + else None) + +let invalid_status_message value statuses = + let available = + statuses |> Vec.map (fun s -> s.value) |> Vec.string_concat ", " + in + let available = if available = "" then "(none)" else available in + "Invalid value for option :status: " ^ value ^ ". Available values: " + ^ available diff --git a/cli/lib/time.ml b/cli/lib/time.ml new file mode 100644 index 0000000..4dcf527 --- /dev/null +++ b/cli/lib/time.ml @@ -0,0 +1,89 @@ +type span = float +type date = Js.Date.t + +let finite value = + match classify_float value with FP_nan | FP_infinite -> false | _ -> true + +let invalid_time () = invalid_arg "invalid POSIX timestamp" +let invalid_span () = invalid_arg "invalid POSIX time span" + +let time_of_epoch_ms_float ms = + if finite ms then + let time = Js.Date.fromFloat ms in + if finite (Js.Date.getTime time) then time else invalid_time () + else invalid_time () + +let now () = Js.Date.make () +let epoch = time_of_epoch_ms_float 0. +let max_time = time_of_epoch_ms_float 8_640_000_000_000_000. +let time_of_epoch_ms ms = time_of_epoch_ms_float (Int64.to_float ms) +let time_to_epoch_ms time = Int64.of_float (Js.Date.getTime time) +let time_to_epoch_seconds time = Int64.of_float (Js.Date.getTime time /. 1000.) +let time_to_epoch_seconds_float time = Js.Date.getTime time /. 1000. + +let compare_time left right = + Float.compare (Js.Date.getTime left) (Js.Date.getTime right) + +let span_of_ms ms = + let span = Int64.to_float ms in + if finite span then span else invalid_span () + +let span_to_ms span = Int64.of_float span +let zero_span = 0. +let compare_span = Float.compare +let add_span_value left right = left +. right + +let add_span time span = + let ms = Js.Date.getTime time +. span in + if finite ms then Some (Js.Date.fromFloat ms) else None + +let local_date time = + ( int_of_float (Js.Date.getFullYear time), + int_of_float (Js.Date.getMonth time) + 1, + int_of_float (Js.Date.getDate time) ) + +let utc_date_time time = + ( int_of_float (Js.Date.getUTCFullYear time), + int_of_float (Js.Date.getUTCMonth time) + 1, + int_of_float (Js.Date.getUTCDate time), + int_of_float (Js.Date.getUTCHours time), + int_of_float (Js.Date.getUTCMinutes time), + int_of_float (Js.Date.getUTCSeconds time) ) + +let non_negative_diff ~start_time ~end_time = + max 0. (Js.Date.getTime end_time -. Js.Date.getTime start_time) + +let avg_span span count = + if count <= 0 then zero_span else span /. float_of_int count + +let rfc3339_millis time = Js.Date.toISOString time + +let valid_parsed_ms ms = + try Some (time_of_epoch_ms_float ms) with Invalid_argument _ -> None + +let parse_rfc3339 text = + let valid_shape = + String.length text >= 20 + && text.[4] = '-' + && text.[7] = '-' + && (text.[10] = 'T' || text.[10] = 't') + && text.[13] = ':' + && text.[16] = ':' + in + if valid_shape then valid_parsed_ms (Js.Date.parseAsFloat text) else None + +let parse_date_as_utc text = + if String.length text = 10 && text.[4] = '-' && text.[7] = '-' then + parse_rfc3339 (text ^ "T00:00:00Z") + else None + +let parse_time text = + let text = String.trim text in + if text = "" then None + else + match parse_rfc3339 text with + | Some _ as time -> time + | None -> ( + match Int64.of_string_opt text with + | Some ms -> Some (time_of_epoch_ms ms) + | None -> parse_date_as_utc text) diff --git a/cli/lib/transport.ml b/cli/lib/transport.ml new file mode 100644 index 0000000..b0fbbd6 --- /dev/null +++ b/cli/lib/transport.ml @@ -0,0 +1,431 @@ +open Cli_effect.Infix + +type invoke_config = { + base_url : Cli_primitive.url; + timeout_span : float; + profile_session : Profile_types.session option; +} + +type event_type = Cli_primitive.keyword +type event_payload = Melange_edn_melange.any +type event_subscription = { close : unit -> unit Cli_effect.t } + +let thread_api_method name = Edn_util.keyword_t ("thread-api/" ^ name) + +module T = Transit_melange.Transit.Json +module E = Melange_edn_melange + +let starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let strip_prefix prefix value = + if starts_with ~prefix value then + String.sub value (String.length prefix) + (String.length value - String.length prefix) + else value + +let transit_keyword_name keyword = strip_prefix ":" keyword +let value_of_transit = T.to_edn +let transit_json_of_value value = T.to_string (T.of_edn value) +let value_of_transit_string text = T.of_string text |> value_of_transit +let edn_of_value value = value +let value_of_edn value = value + +let normalize_base_url base_url = + let base_url = String.trim base_url in + if base_url = "" then invalid_arg "base-url is required" + else if + String.length base_url > 0 && base_url.[String.length base_url - 1] = '/' + then String.sub base_url 0 (String.length base_url - 1) + else base_url + +let find_substring_from ~needle haystack start = + let needle_len = String.length needle in + let haystack_len = String.length haystack in + let rec matches needle_index haystack_index = + needle_index = needle_len + || haystack.[haystack_index] = needle.[needle_index] + && matches (needle_index + 1) (haystack_index + 1) + in + let rec loop idx = + match String.index_from_opt haystack idx needle.[0] with + | None -> None + | Some idx -> + if idx + needle_len > haystack_len then None + else if matches 0 idx then Some idx + else loop (idx + 1) + in + if needle_len = 0 then Some start else loop start + +let http_error_message status body = + let fallback = + if String.trim body = "" then + "http request failed (" ^ string_of_int status ^ ")" + else + "http request failed (" ^ string_of_int status ^ ")\nhttp response: " + ^ body + in + try + match Json_util.object_of_json_string body with + | Some object_ -> + Option.value + (match Json_util.nested_string_field object_ "error" "message" with + | Some _ as message -> message + | None -> Json_util.string_field object_ "message") + ~default:fallback + | None -> fallback + with _ -> fallback + +let response_status response = Fetch.Response.status response +let success_status status = status >= 200 && status <= 299 + +let request ?timeout_span method_ uri ~headers ~body = + Cli_platform.HTTP.request ?timeout_span method_ uri ~headers ~body + >>= fun (response, body) -> + let status = response_status response in + if success_status status then Cli_effect.pure (response, body) + else Cli_effect.error (Failure (http_error_message status body)) + +let method_name method_ = Edn_util.keyword_to_string method_ |> String.trim + +let invoke_body method_ args = + let args_transit = transit_json_of_value (Edn_util.vector_vec args) in + Json_util.string_of_string_fields + (Vec.of_array + [| ("method", method_name method_); ("argsTransit", args_transit) |]) + +let invoke config method_ args = + let base_url = normalize_base_url config.base_url in + let stage = "transport.invoke:" ^ method_name method_ in + Profile_types.time config.profile_session stage (fun () -> + Cli_effect.map + (fun (_response, body) -> + match Json_util.object_of_json_string body with + | Some object_ -> ( + match Json_util.string_field object_ "resultTransit" with + | Some result_transit -> value_of_transit_string result_transit + | None -> Edn_util.nil) + | None -> Edn_util.nil) + (request ~timeout_span:config.timeout_span Fetch.Post + (base_url ^ "/v1/invoke") + ~headers: + (Vec.of_array + [| + ("Content-Type", "application/json"); + ("Accept", "application/json"); + |]) + ~body:(invoke_body method_ args))) + +let repo_value repo = Edn_util.string (Cli_primitive.string_of_repo repo) + +let thread_api_apply_outliner_ops config ~(repo : Cli_primitive.repo) + ~(ops : Melange_edn_melange.vector Melange_edn_melange.t) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "apply-outliner-ops") + (Vec.of_array [| repo_value repo; Edn_util.any ops; Edn_util.any options |]) + +let thread_api_backup_db_sqlite config ~(repo : Cli_primitive.repo) ~path = + invoke config + (thread_api_method "backup-db-sqlite") + (Vec.of_array [| repo_value repo; Edn_util.string path |]) + +let thread_api_cli_list_nodes config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "cli-list-nodes") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_cli_list_pages config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "cli-list-pages") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_cli_list_properties config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "cli-list-properties") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_cli_list_tags config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "cli-list-tags") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_cli_list_tasks config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "cli-list-tasks") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_create_or_open_db config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "create-or-open-db") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_db_sync_download_graph_by_id config ~(repo : Cli_primitive.repo) + ~graph_id ~graph_e2ee = + invoke config + (thread_api_method "db-sync-download-graph-by-id") + (Vec.of_array + [| repo_value repo; Edn_util.string graph_id; Edn_util.bool graph_e2ee |]) + +let thread_api_db_sync_ensure_user_rsa_keys ?options config = + let args = + match options with + | None -> Vec.empty + | Some options -> Vec.singleton (Edn_util.any options) + in + invoke config (thread_api_method "db-sync-ensure-user-rsa-keys") args + +let thread_api_db_sync_grant_graph_access config ~(repo : Cli_primitive.repo) + ~graph_id ~email = + invoke config + (thread_api_method "db-sync-grant-graph-access") + (Vec.of_array + [| repo_value repo; Edn_util.string graph_id; Edn_util.string email |]) + +let thread_api_db_sync_list_remote_graphs config = + invoke config (thread_api_method "db-sync-list-remote-graphs") Vec.empty + +let thread_api_db_sync_request_asset_download config + ~(repo : Cli_primitive.repo) ~asset_uuid = + invoke config + (thread_api_method "db-sync-request-asset-download") + (Vec.of_array [| repo_value repo; Edn_util.uuid asset_uuid |]) + +let thread_api_db_sync_start config ~(repo : Cli_primitive.repo) = + invoke config + (thread_api_method "db-sync-start") + (Vec.singleton (repo_value repo)) + +let thread_api_db_sync_status config ~(repo : Cli_primitive.repo) = + invoke config + (thread_api_method "db-sync-status") + (Vec.singleton (repo_value repo)) + +let thread_api_db_sync_stop config = + invoke config (thread_api_method "db-sync-stop") Vec.empty + +let thread_api_db_sync_upload_graph config ~(repo : Cli_primitive.repo) = + invoke config + (thread_api_method "db-sync-upload-graph") + (Vec.singleton (repo_value repo)) + +let thread_api_export_edn config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "export-edn") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_get_block_parents config ~(repo : Cli_primitive.repo) ~block_id = + invoke config + (thread_api_method "get-block-parents") + (Vec.of_array [| repo_value repo; Edn_util.int64 block_id |]) + +let thread_api_get_block_refs config ~(repo : Cli_primitive.repo) ~block_id = + invoke config + (thread_api_method "get-block-refs") + (Vec.of_array [| repo_value repo; Edn_util.int64 block_id |]) + +let thread_api_get_e2ee_password config ~refresh_token = + invoke config + (thread_api_method "get-e2ee-password") + (Vec.singleton (Edn_util.string refresh_token)) + +let thread_api_import_db_binary config ~(repo : Cli_primitive.repo) ~data = + invoke config + (thread_api_method "import-db-binary") + (Vec.of_array [| repo_value repo; data |]) + +let thread_api_import_edn config ~(repo : Cli_primitive.repo) ~data = + invoke config + (thread_api_method "import-edn") + (Vec.of_array [| repo_value repo; data |]) + +let thread_api_pull config ~(repo : Cli_primitive.repo) + ~(selector : Melange_edn_melange.vector Melange_edn_melange.t) ~lookup = + invoke config (thread_api_method "pull") + (Vec.of_array [| repo_value repo; Edn_util.any selector; lookup |]) + +let thread_api_q config ~(repo : Cli_primitive.repo) + ~(query : Melange_edn_melange.vector Melange_edn_melange.t) = + invoke config (thread_api_method "q") + (Vec.of_array [| repo_value repo; Edn_util.any query |]) + +let thread_api_set_db_sync_config config ~config:sync_config = + invoke config + (thread_api_method "set-db-sync-config") + (Vec.singleton (Edn_util.any sync_config)) + +let thread_api_sync_app_state config + ~(auth_state : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "sync-app-state") + (Vec.singleton (Edn_util.any auth_state)) + +let thread_api_validate_db config ~(repo : Cli_primitive.repo) + ~(options : Melange_edn_melange.map Melange_edn_melange.t) = + invoke config + (thread_api_method "validate-db") + (Vec.of_array [| repo_value repo; Edn_util.any options |]) + +let thread_api_verify_and_save_e2ee_password config ~refresh_token ~password = + invoke config + (thread_api_method "verify-and-save-e2ee-password") + (Vec.of_array [| Edn_util.string refresh_token; Edn_util.string password |]) + +let value_get_string_key key value = + match Edn_util.as_map value with + | Some fields -> + Vec.find_map + (fun (field_key, value) -> + match + (Edn_util.as_string_like field_key, Edn_util.as_string value) + with + | Some field, Some value when field = key -> Some value + | _ -> None) + fields + | None -> None + +let keyword_from_string value = Edn_util.keyword_t (transit_keyword_name value) + +let decode_event_payload payload = + try value_of_transit_string payload with _ -> Edn_util.string payload + +let max_non_progress_event_decode_bytes = 256 * 1024 + +let decode_event event_text = + let data_line = + Vec.split_on_char '\n' event_text + |> Vec.find_map (fun line -> + if starts_with ~prefix:"data: " line then + Some (String.sub line 6 (String.length line - 6)) + else None) + in + match data_line with + | None -> None + | Some data -> ( + try + let json = Js.Json.parseExn data in + let event_type = + Option.bind (Js.Json.decodeObject json) (fun object_ -> + Json_util.string_field object_ "type") + in + match event_type with + | Some event_type + when event_type <> "rtc-log" + && String.length data > max_non_progress_event_decode_bytes -> + Some (keyword_from_string event_type, Edn_util.nil) + | _ -> ( + let event = Json_util.value_of_json json in + let decoded_payload = + match value_get_string_key "payload" event with + | Some payload -> decode_event_payload payload + | None -> Edn_util.nil + in + match Edn_util.as_vector decoded_payload with + | Some values when Vec.length values = 2 -> ( + let event_type_value = Vec.nth values 0 in + let payload = Vec.nth values 1 in + match Edn_util.as_keyword_t event_type_value with + | Some event_type -> Some (event_type, payload) + | None -> None) + | _ -> ( + match value_get_string_key "type" event with + | Some event_type -> + Some (keyword_from_string event_type, decoded_payload) + | None -> None)) + with _ -> None) + +let split_sse_events buffer = + let rec loop start acc = + match find_substring_from ~needle:"\n\n" buffer start with + | None -> + (acc, String.sub buffer start (String.length buffer - start)) + | Some idx -> + let event_text = String.sub buffer start (idx - start) in + loop (idx + 2) (Vec.push_back acc event_text) + in + loop 0 Vec.empty + +let dispatch_event on_event event_type payload = + Cli_effect.async (fun () -> + Cli_effect.catch (on_event event_type payload) (fun _ -> + Cli_effect.pure ())) + +let consume_sse_chunk on_event buffer chunk = + let events, rest = split_sse_events (!buffer ^ chunk) in + buffer := rest; + Vec.iter + (fun event_text -> + match decode_event event_text with + | Some (event_type, payload) -> dispatch_event on_event event_type payload + | None -> ()) + events + +let connect_events config on_event = + let base_url = normalize_base_url config.base_url in + let buffer = ref "" in + Cli_effect.map + (fun (subscription : Cli_platform.Events.subscription) -> + { close = subscription.close }) + (Cli_platform.Events.connect ~url:(base_url ^ "/v1/events") + ~on_chunk:(consume_sse_chunk on_event buffer)) + +let normalize_format format = Edn_util.keyword_to_string format |> String.trim + +let write_file_text path content = + Cli_unix.write_text_file path Ustring.(of_string content |> to_string) + +let read_file_text path = Cli_unix.read_text_file path +let write_file_binary path content = Cli_unix.write_binary_file path content +let read_file_binary path = Bytes.of_string (Cli_unix.read_binary_file path) + +let bytes_of_output_data value = + match (Edn_util.as_bytes value, Edn_util.as_string value) with + | Some bytes, _ -> Bytes.to_string bytes + | _, Some value -> value + | _ -> Melange_edn_melange.to_edn_string value + +let unsupported_output_format format = + Error.make Error.Unsupported_output_format + ("unsupported output format: " ^ format) + +let unsupported_input_format format = + Error.make Error.Unsupported_input_format + ("unsupported input format: " ^ format) + +let write_output ~format ~path ~data = + let format = normalize_format format in + try + let result = + match format with + | "edn" -> + write_file_text path (E.to_edn_string (edn_of_value data)); + Ok () + | "db" | "sqlite" -> + write_file_binary path (bytes_of_output_data data); + Ok () + | _ -> Error (unsupported_output_format format) + in + Cli_effect.pure result + with exn -> Cli_effect.pure (Error (Error.exception_error exn)) + +let read_input ~format ~path = + let format = normalize_format format in + try + let result = + match format with + | "edn" -> + let content = read_file_text path in + Ok (E.of_edn_string content |> value_of_edn) + | "db" | "sqlite" -> Ok (Edn_util.bytes (read_file_binary path)) + | _ -> Error (unsupported_input_format format) + in + Cli_effect.pure result + with exn -> Cli_effect.pure (Error (Error.exception_error exn)) diff --git a/cli/lib/tree_text_types.ml b/cli/lib/tree_text_types.ml new file mode 100644 index 0000000..d02a239 --- /dev/null +++ b/cli/lib/tree_text_types.ml @@ -0,0 +1,6 @@ +type render_options = { + uuid_labels : (Cli_primitive.uuid * string) Rrbvec.t; + property_titles : (Cli_primitive.keyword * string) Rrbvec.t; + property_value_labels : (Melange_edn_melange.any * string) Rrbvec.t; + show_ids : bool; +} diff --git a/cli/lib/update.ml b/cli/lib/update.ml new file mode 100644 index 0000000..c784c08 --- /dev/null +++ b/cli/lib/update.ml @@ -0,0 +1,851 @@ +type opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + status : string option; + content : string option; + update_tags_edn : string option; + update_properties_edn : string option; + remove_tags_edn : string option; + remove_properties_edn : string option; + blocks_edn : string option; + blocks_file : Cli_primitive.path option; +} + +type parsed = Parsed_update_block of opts + +type action = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + update_tags : Selector.tag Rrbvec.t; + update_properties : Property.assignment Rrbvec.t; + remove_tags : Selector.tag Rrbvec.t; + remove_properties : Property.key Rrbvec.t; + content : string option; + source_label : string option; + target_label : string option; +} + +let kw value = Edn_util.keyword value +let vector_vec values = Edn_util.vector_vec values +let vector values = vector_vec values + +let trim_non_empty = function + | None -> None + | Some value -> + let value = String.trim value in + if value = "" then None else Some value + +let count_present values = + Vec.fold_left + (fun count -> function Some _ -> count + 1 | None -> count) + 0 values + +let has_text value = Option.is_some (trim_non_empty value) + +let invalid_uuid_option option_name = function + | Some value when not (Cli_primitive.is_uuid_string value) -> + Some ("Option " ^ option_name ^ " must be a valid UUID string") + | _ -> None + +let invalid_options (opts : opts) = + let source_selectors = + Vec.of_array + [| Option.map Int64.to_string opts.id; trim_non_empty opts.uuid |] + in + let target_page = trim_non_empty opts.target_page in + let target_selectors = + Vec.of_array + [| + Option.map Int64.to_string opts.target_id; + trim_non_empty opts.target_uuid; + target_page; + |] + in + let has_target = count_present target_selectors > 0 in + let has_updates = + has_text opts.update_tags_edn + || has_text opts.update_properties_edn + || has_text opts.remove_tags_edn + || has_text opts.remove_properties_edn + || Option.is_some opts.status + || Option.is_some opts.content + in + match invalid_uuid_option "uuid" opts.uuid with + | Some message -> Some message + | None -> ( + match invalid_uuid_option "target-uuid" opts.target_uuid with + | Some message -> Some message + | None -> + if count_present source_selectors > 1 then + Some "only one of --id or --uuid is allowed" + else if count_present target_selectors > 1 then + Some + "only one of --target-id, --target-uuid, or --target-page is \ + allowed" + else if opts.pos = Some Block.Sibling && Option.is_some target_page + then Some "--pos sibling is only valid for block targets" + else if Option.is_some opts.pos && not has_target then + Some "--pos is only valid when a target option is provided" + else if (not has_target) && not has_updates then + Some "target or update/remove options are required" + else if + Option.is_some opts.blocks_edn || Option.is_some opts.blocks_file + then Some "--blocks and --blocks-file are only for create mode" + else None) + +let command_id _ = Command_id.Upsert_block + +let validate_parsed (Parsed_update_block opts) = + match invalid_options opts with + | Some message -> Error (Error.invalid_options message) + | None -> Ok () + +let parse_tags_edn _label value = + match value with + | None -> Ok Vec.empty + | Some text when String.trim text = "" -> Ok Vec.empty + | Some _ -> Add.parse_tags_vector_option value + +let property_key_of_value value = + match + ( Edn_util.as_int64 value, + Edn_util.as_keyword_t value, + Edn_util.as_string value ) + with + | Some id, _, _ -> Some (Property.Key_id id) + | _, Some ident, _ -> Some (Key_ident ident) + | _, _, Some name -> + let name = String.trim name in + if name = "" then None else Some (Key_name name) + | _ -> None + +let edn_value_of_string ~label text = + try Ok (Melange_edn_melange.of_edn_string text) + with Melange_edn_melange.Parse_error _ -> + Error (Error.invalid_options ("invalid " ^ label ^ " edn")) + +let parse_properties_edn value = + match value with + | None -> Ok Vec.empty + | Some text when String.trim text = "" -> Ok Vec.empty + | Some text -> + Error.bind (edn_value_of_string ~label:"properties" text) (fun parsed -> + match Edn_util.as_map parsed with + | Some fields when Vec.is_empty fields -> + Error (Error.invalid_options "properties must be a non-empty map") + | Some fields -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> Ok acc + | Some ((key, value), rest) -> ( + match property_key_of_value key with + | Some key -> + loop (Vec.push_back acc { Property.key; value }) rest + | None -> + Error + (Error.invalid_options + ("invalid property key: " + ^ Melange_edn_melange.to_edn_string key))) + in + loop Vec.empty fields + | None -> Error (Error.invalid_options "properties must be a map")) + +let parse_property_keys_edn value = + match value with + | None -> Ok Vec.empty + | Some text when String.trim text = "" -> Ok Vec.empty + | Some text -> + Error.bind (edn_value_of_string ~label:"properties" text) (fun parsed -> + match Edn_util.as_vector parsed with + | Some values when Vec.is_empty values -> + Error + (Error.invalid_options "properties must be a non-empty vector") + | Some values -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> Ok acc + | Some (value, rest) -> ( + match property_key_of_value value with + | Some key -> loop (Vec.push_back acc key) rest + | None -> + Error + (Error.invalid_options + ("invalid property key: " + ^ Melange_edn_melange.to_edn_string value))) + in + loop Vec.empty values + | None -> Error (Error.invalid_options "properties must be a vector")) + +let status_assignment = function + | None -> Ok Vec.empty + | Some status -> ( + match Add.normalize_status status with + | Some ident -> + Ok + (Vec.singleton + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property/status"); + value = Edn_util.any ident; + }) + | None -> Error (Error.invalid_options ("invalid status: " ^ status))) + +let source_label (opts : opts) = + match (trim_non_empty opts.uuid, opts.id) with + | Some uuid, _ -> Some uuid + | None, Some id -> Some (Int64.to_string id) + | None, None -> None + +let target_label (opts : opts) = + match + ( trim_non_empty opts.target_page, + trim_non_empty opts.target_uuid, + opts.target_id ) + with + | Some page, _, _ -> Some ("page:" ^ page) + | None, Some uuid, _ -> Some uuid + | None, None, Some id -> Some (Int64.to_string id) + | None, None, None -> None + +let opts_has_target (opts : opts) = + Option.is_some opts.target_id + || Option.is_some (trim_non_empty opts.target_uuid) + || Option.is_some (trim_non_empty opts.target_page) + +let action_has_target (action : action) = + Option.is_some action.target_id + || Option.is_some action.target_uuid + || Option.is_some action.target_page + +let build_action opts repo = + match invalid_options opts with + | Some message -> Error (Error.invalid_options message) + | None -> + if Option.is_none opts.id && Option.is_none (trim_non_empty opts.uuid) + then Error (Error.make Error.Missing_source "source block is required") + else + Error.bind (status_assignment opts.status) (fun status_properties -> + Error.bind (parse_tags_edn "update-tags" opts.update_tags_edn) + (fun update_tags -> + Error.bind (parse_tags_edn "remove-tags" opts.remove_tags_edn) + (fun remove_tags -> + Error.bind (parse_properties_edn opts.update_properties_edn) + (fun update_properties -> + Error.bind + (parse_property_keys_edn opts.remove_properties_edn) + (fun remove_properties -> + Ok + { + repo; + graph = Cli_config.repo_to_graph repo; + id = opts.id; + uuid = trim_non_empty opts.uuid; + target_id = opts.target_id; + target_uuid = trim_non_empty opts.target_uuid; + target_page = trim_non_empty opts.target_page; + pos = + (if opts_has_target opts then + Some + (Option.value opts.pos + ~default:Block.First_child) + else None); + update_tags; + update_properties = + Vec.append status_properties update_properties; + remove_tags; + remove_properties; + content = opts.content; + source_label = source_label opts; + target_label = target_label opts; + }))))) + +let build ?registry:_ config _globals (Parsed_update_block opts) = + match config.Cli_config.repo with + | None -> Error (Error.missing_repo "repo is required for update") + | Some repo -> build_action opts repo + +let block_selector = + vector_vec + (Vec.of_array + [| kw "db/id"; kw "block/uuid"; kw "block/name"; kw "block/title" |]) + +let page_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |]) + +let tag_selector = + vector_vec + (Vec.of_array + [| kw "db/id"; kw "block/uuid"; kw "block/name"; kw "block/title" |]) + +let property_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/type"; + |]) + +let id_of_entity value = Edn_util.get_int64 value "db/id" + +let uuid_of_entity value = + Option.bind (Edn_util.get value "block/uuid") Edn_util.as_string_like + +let ident_of_entity value = + Option.bind (Edn_util.get value "db/ident") Edn_util.as_keyword_t + +let page_entity value = Option.is_some (Edn_util.get_string value "block/name") + +let recycled_entity value = + Option.is_some (Edn_util.get value "logseq.property/deleted-at") + +let page_not_found () = Error.make Error.Page_not_found "page not found" +let recycled_page_error () = Error.make Error.Recycled_page "page is recycled" + +let pull invoke_config repo selector lookup = + Transport.thread_api_pull invoke_config ~repo + ~selector:(Edn_util.expect_vector_t "update pull selector" selector) + ~lookup + +let pull_by_id invoke_config repo selector id = + pull invoke_config repo selector (Edn_util.int64 id) + +let pull_by_uuid invoke_config repo selector uuid = + pull invoke_config repo selector + (vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + +let normalized_page_name value = String.lowercase_ascii (String.trim value) +let variable value = Edn_util.symbol value +let list_vec values = Edn_util.list_vec values +let list values = list_vec values + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let class_query selector class_ident = + Cli_primitive.make_datascript_query + ~find: + (Vec.of_array + [| + vector_vec + (Vec.of_array + [| + list_vec + (Vec.of_array + [| variable "pull"; variable "?e"; selector |]); + variable "..."; + |]); + |]) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| variable "?e"; kw "block/name"; variable "?name" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| variable "?e"; kw "block/tags"; variable "?t" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array + [| variable "?t"; kw "db/ident"; kw class_ident |])); + |]) + () + +let tag_query selector = query_value (class_query selector "logseq.class/Tag") +let property_query selector = class_query selector "logseq.class/Property" + +let first_entity value = + match + (Edn_util.as_vector value, Edn_util.as_list value, Edn_util.as_map value) + with + | Some values, _, _ when not (Vec.is_empty values) -> + Some (Vec.peek_front values) + | _, Some values, _ when not (Vec.is_empty values) -> + Some (Vec.peek_front values) + | _, _, Some _ -> Some value + | _ -> None + +let pull_tag_by_name invoke_config repo name = + Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + tag_query tag_selector; + Edn_util.string (normalized_page_name name); + |])) + +let list_tags invoke_config repo = + Transport.thread_api_cli_list_tags invoke_config ~repo + ~options:(Edn_util.map_t_vec Vec.empty) + +let tag_name_matches name entity = + let expected = normalized_page_name name in + let matches value = String.equal (normalized_page_name value) expected in + match + ( Edn_util.get_string entity "block/title", + Edn_util.get_string entity "block/name" ) + with + | Some title, _ when matches title -> true + | _, Some name when matches name -> true + | _ -> false + +let find_tag_by_name invoke_config repo name = + let open Cli_effect in + bind (list_tags invoke_config repo) (fun value -> + match Edn_util.as_seq value with + | Some tags -> pure (Vec.find_opt (tag_name_matches name) tags) + | None -> pure None) + +let pull_property_by_name invoke_config repo name = + Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (property_query property_selector); + Edn_util.string (normalized_page_name name); + |])) + +let source_not_found () = + Error.make Error.Source_not_found "source block not found" + +let target_not_found () = + Error.make Error.Target_not_found "target block not found" + +let ensure_non_page entity message code = + if page_entity entity then Error (Error.make code message) else Ok entity + +let resolve_source invoke_config repo action = + let open Cli_effect in + match (action.id, action.uuid) with + | Some id, _ -> + bind (pull_by_id invoke_config repo block_selector id) (fun entity -> + match id_of_entity entity with + | None -> pure (Error (source_not_found ())) + | Some _ -> + pure + (ensure_non_page entity "source must be a non-page block" + Error.Invalid_source)) + | None, Some uuid -> + bind (pull_by_uuid invoke_config repo block_selector uuid) (fun entity -> + match id_of_entity entity with + | None -> pure (Error (source_not_found ())) + | Some _ -> + pure + (ensure_non_page entity "source must be a non-page block" + Error.Invalid_source)) + | None, None -> + pure (Error (Error.make Error.Missing_source "source is required")) + +let resolve_target invoke_config repo action = + let open Cli_effect in + match (action.target_id, action.target_uuid, action.target_page) with + | Some id, _, _ -> + bind (pull_by_id invoke_config repo block_selector id) (fun entity -> + match id_of_entity entity with + | None -> pure (Error (target_not_found ())) + | Some _ -> + pure + (ensure_non_page entity "target must be a block" + Error.Invalid_target)) + | None, Some uuid, _ -> + bind (pull_by_uuid invoke_config repo block_selector uuid) (fun entity -> + match id_of_entity entity with + | None -> pure (Error (target_not_found ())) + | Some _ -> + pure + (ensure_non_page entity "target must be a block" + Error.Invalid_target)) + | None, None, Some page -> + bind + (pull invoke_config repo page_selector + (vector_vec + (Vec.of_array + [| + kw "block/name"; Edn_util.string (normalized_page_name page); + |]))) + (fun entity -> + match id_of_entity entity with + | None -> pure (Error (page_not_found ())) + | Some _ when recycled_entity entity -> + pure (Error (recycled_page_error ())) + | Some _ -> pure (Ok entity)) + | None, None, None -> + pure (Error (Error.make Error.Missing_target "target is required")) + +let pos_opts = function + | Some Block.Last_child -> + Edn_util.map_vec + (Vec.of_array + [| + (kw "sibling?", Edn_util.bool false); + (kw "bottom?", Edn_util.bool true); + |]) + | Some Sibling -> + Edn_util.map_vec (Vec.of_array [| (kw "sibling?", Edn_util.bool true) |]) + | Some First_child | None -> + Edn_util.map_vec (Vec.of_array [| (kw "sibling?", Edn_util.bool false) |]) + +let lookup_of_tag = function + | Selector.Tag_id id -> Edn_util.int64 id + | Tag_name name -> Edn_util.string (normalized_page_name name) + | Tag_ident ident -> Edn_util.any ident + | Tag_uuid uuid -> Edn_util.uuid uuid + +let resolve_tag_id invoke_config repo = function + | Selector.Tag_id id -> Cli_effect.pure (Ok id) + | Tag_name name -> + let open Cli_effect in + bind (find_tag_by_name invoke_config repo name) (fun result -> + match Option.bind result id_of_entity with + | Some id -> pure (Ok id) + | None -> + pure (Error (Error.make Error.Tag_not_found "tag not found"))) + | (Tag_ident _ | Tag_uuid _) as tag -> + let open Cli_effect in + bind + (pull invoke_config repo tag_selector (lookup_of_tag tag)) + (fun result -> + match id_of_entity result with + | Some id -> pure (Ok id) + | None -> + pure (Error (Error.make Error.Tag_not_found "tag not found"))) + +let resolve_property_ident invoke_config repo = function + | Property.Key_ident ident -> Cli_effect.pure (Ok ident) + | Key_id id -> + let open Cli_effect in + bind + (pull invoke_config repo + (vector_vec (Vec.of_array [| kw "db/ident" |])) + (Edn_util.int64 id)) + (fun result -> + match ident_of_entity result with + | Some ident -> pure (Ok ident) + | None -> + pure + (Error + (Error.make Error.Property_not_found "property not found"))) + | Key_name name -> + let open Cli_effect in + bind (pull_property_by_name invoke_config repo name) (fun result -> + match Option.bind (first_entity result) ident_of_entity with + | Some ident -> pure (Ok ident) + | None -> + let ident = Edn_util.keyword_t name in + bind + (pull invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id" |])) + (vector_vec + (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]))) + (fun result -> + if Option.is_some (id_of_entity result) then pure (Ok ident) + else + pure + (Error + (Error.make Error.Property_not_found + "property not found")))) + +let resolve_tag_ids invoke_config repo tags = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (tag, rest) -> + bind (resolve_tag_id invoke_config repo tag) (function + | Error err -> pure (Error err) + | Ok id -> loop (Vec.push_back acc id) rest) + in + loop Vec.empty tags + +let resolve_property_keys invoke_config repo keys = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (key, rest) -> + bind (resolve_property_ident invoke_config repo key) (function + | Error err -> pure (Error err) + | Ok ident -> loop (Vec.push_back acc ident) rest) + in + loop Vec.empty keys + +let resolve_property_assignments invoke_config repo assignments = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (assignment, rest) -> + bind (resolve_property_ident invoke_config repo assignment.Property.key) + (function + | Error err -> pure (Error err) + | Ok ident -> loop (Vec.push_back acc (ident, assignment.value)) rest) + in + loop Vec.empty assignments + +let property_ops block_uuids ~update_tag_ids ~remove_tag_ids ~update_properties + ~remove_properties = + let uuid_values = + Edn_util.vector_vec (block_uuids |> Vec.map (fun uuid -> Edn_util.uuid uuid)) + in + let remove_tag_ops = + Vec.map + (fun tag_id -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-delete-property-value"; + Edn_util.vector_vec + (Vec.of_array + [| uuid_values; kw "block/tags"; Edn_util.int64 tag_id |]); + |])) + remove_tag_ids + in + let remove_property_ops = + Vec.map + (fun ident -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-remove-property"; + Edn_util.vector_vec + (Vec.of_array [| uuid_values; Edn_util.any ident |]); + |])) + remove_properties + in + let update_tag_ops = + Vec.map + (fun tag_id -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + kw "block/tags"; + Edn_util.int64 tag_id; + Edn_util.map_vec Vec.empty; + |]); + |])) + update_tag_ids + in + let update_property_ops = + Vec.map + (fun (ident, value) -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + Edn_util.any ident; + value; + Edn_util.map_vec Vec.empty; + |]); + |])) + update_properties + in + Vec.append remove_tag_ops + (Vec.append remove_property_ops + (Vec.append update_tag_ops update_property_ops)) + +let resolve_update_ops invoke_config repo action block_uuids = + let open Cli_effect in + bind (resolve_tag_ids invoke_config repo action.update_tags) (function + | Error err -> pure (Error err) + | Ok update_tag_ids -> + bind (resolve_tag_ids invoke_config repo action.remove_tags) (function + | Error err -> pure (Error err) + | Ok remove_tag_ids -> + bind + (resolve_property_assignments invoke_config repo + action.update_properties) (function + | Error err -> pure (Error err) + | Ok update_properties -> + bind + (resolve_property_keys invoke_config repo + action.remove_properties) (function + | Error err -> pure (Error err) + | Ok remove_properties -> + pure + (Ok + (property_ops block_uuids ~update_tag_ids + ~remove_tag_ids ~update_properties + ~remove_properties)))))) + +let apply_outliner_ops invoke_config repo ops = + Transport.thread_api_apply_outliner_ops invoke_config ~repo + ~ops:(Edn_util.vector_t_vec ops) + ~options:(Edn_util.map_t_vec Vec.empty) + +let save_ops source_uuid = function + | Some content -> + Vec.singleton + (Edn_util.vector_vec + (Vec.of_array + [| + kw "save-block"; + Edn_util.vector_vec + (Vec.of_array + [| + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/uuid", Edn_util.uuid source_uuid); + (kw "block/title", Edn_util.string content); + |]); + Edn_util.map_vec Vec.empty; + |]); + |])) + | None -> Vec.empty + +let move_ops source_uuid pos = function + | Some target_uuid -> + Vec.singleton + (Edn_util.vector_vec + (Vec.of_array + [| + kw "move-blocks"; + Edn_util.vector_vec + (Vec.of_array + [| + Edn_util.vector_vec + (Vec.of_array [| Edn_util.uuid source_uuid |]); + Edn_util.uuid target_uuid; + pos_opts pos; + |]); + |])) + | None -> Vec.empty + +let execute_ops mode invoke_config action source_uuid target_uuid = + let open Cli_effect in + bind + (resolve_update_ops invoke_config action.repo action + (Vec.singleton source_uuid)) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok property_ops -> + let ops = + Vec.append + (save_ops source_uuid action.content) + (Vec.append + (move_ops source_uuid action.pos target_uuid) + property_ops) + in + if Vec.is_empty ops then + pure + (Cli_result.ok ~command:Command_id.Upsert_block mode + (Raw + (Edn_util.map_vec + (Vec.of_array [| (kw "result", Edn_util.nil) |])))) + else + bind (apply_outliner_ops invoke_config action.repo ops) + (fun result -> + pure + (Cli_result.ok ~command:Command_id.Upsert_block mode + (Raw + (Edn_util.map_vec + (Vec.of_array [| (kw "result", result) |])))))) + +let resolve_optional_target_uuid invoke_config action = + let open Cli_effect in + if action_has_target action then + bind (resolve_target invoke_config action.repo action) (function + | Error err -> pure (Error err) + | Ok target -> ( + match uuid_of_entity target with + | Some target_uuid -> pure (Ok (Some target_uuid)) + | None -> + pure + (Error + (Error.make Error.Target_not_found + "target block uuid not found")))) + else pure (Ok None) + +let execute action config mode = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok invoke_config -> + bind (resolve_source invoke_config action.repo action) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok source -> ( + match uuid_of_entity source with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_block mode + (Error.make Error.Source_not_found + "source block uuid not found")) + | Some source_uuid -> + bind (resolve_optional_target_uuid invoke_config action) + (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_block + mode err) + | Ok target_uuid -> + execute_ops mode invoke_config action source_uuid + target_uuid)))) + +let metadata () = + Vec.singleton + { + Command_registry.id = Command_id.Upsert_block; + path = Command_id.to_path Command_id.Upsert_block; + doc = "Upsert block"; + long_doc = None; + examples = + Vec.of_array + [| + "logseq upsert block --graph my-graph --id 123 --content \"Updated \ + content\""; + "logseq upsert block --graph my-graph --id 123 --target-page Home \ + --pos last-child"; + "logseq upsert block --graph my-graph --uuid \ + 11111111-1111-1111-1111-111111111111 --status done"; + |]; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph Command_id.Upsert_block; + requires_auth = Command_id.requires_auth Command_id.Upsert_block; + write_command = Command_id.is_write Command_id.Upsert_block; + human_table_headers_order = Vec.empty; + } diff --git a/cli/lib/upsert.ml b/cli/lib/upsert.ml new file mode 100644 index 0000000..1043304 --- /dev/null +++ b/cli/lib/upsert.ml @@ -0,0 +1,3224 @@ +type mode = Create | Update | Page + +type block_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + content : string option; + blocks_edn : string option; + blocks_file : Cli_primitive.path option; + update_tags_edn : string option; + update_properties_edn : string option; + remove_tags_edn : string option; + remove_properties_edn : string option; +} + +type page_opts = { + id : Cli_primitive.db_id option; + page : string option; + restore : bool; + update_tags_edn : string option; + update_properties_edn : string option; + remove_tags_edn : string option; + remove_properties_edn : string option; +} + +type task_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + page : string option; + content : string option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + status : string option; + priority : string option; + scheduled : Js.Date.t option; + deadline : Js.Date.t option; + no_status : bool; + no_priority : bool; + no_scheduled : bool; + no_deadline : bool; +} + +type asset_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + path : Cli_primitive.path option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + content : string option; +} + +type tag_opts = { + id : Cli_primitive.db_id option; + name : string option; + add_properties_edn : string option; + remove_properties_edn : string option; +} + +type property_opts = { + id : Cli_primitive.db_id option; + name : string option; + kind : Property.kind option; + cardinality : Property.cardinality option; + hide : bool option; + public : bool option; +} + +type parsed = + | Parsed_block of block_opts + | Parsed_page of page_opts + | Parsed_task of task_opts + | Parsed_asset of asset_opts + | Parsed_tag of tag_opts + | Parsed_property of property_opts + +type block_target = + | Target_id of Cli_primitive.db_id + | Target_uuid of Cli_primitive.uuid + | Target_page of string + +type block_source = + | Source_id of Cli_primitive.db_id + | Source_uuid of Cli_primitive.uuid + +type block_create = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + target : block_target; + pos : Block.position; + status : Cli_primitive.keyword option; + tags : Selector.tag Rrbvec.t; + properties : Property.assignment Rrbvec.t; + blocks : Block.t Rrbvec.t; + update_plan : Property.update_plan; +} + +type block_update = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + source : block_source; + target : block_target option; + pos : Block.position option; + update_tags : Selector.tag Rrbvec.t; + update_properties : Property.assignment Rrbvec.t; + remove_tags : Selector.tag Rrbvec.t; + remove_properties : Property.key Rrbvec.t; + content : string option; + source_label : string option; + target_label : string option; +} + +type block_action = + | Block_create of block_create + | Block_update of block_update + +type action = + | Upsert_block of block_action + | Upsert_page of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + page : string option; + restore : bool; + plan : Property.update_plan; + } + | Upsert_task of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + page : string option; + content : string option; + update_properties : Property.assignment Rrbvec.t; + clear_properties : Property.key Rrbvec.t; + status_input : string option; + } + | Upsert_asset of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + path : Cli_primitive.path option; + content : string option; + create_action : block_create option; + } + | Upsert_tag of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + name : string option; + add_properties : Property.key Rrbvec.t; + remove_properties : Property.key Rrbvec.t; + } + | Upsert_property of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + name : string option; + schema : Property.schema; + } + +let update_mode (opts : block_opts) = + Option.is_some opts.id || Option.is_some opts.uuid + +let normalize_tag_name value = + let value = String.trim value in + let rec drop_hash idx = + if idx < String.length value && value.[idx] = '#' then drop_hash (idx + 1) + else idx + in + let start = drop_hash 0 in + let value = + String.sub value start (String.length value - start) |> String.trim + in + if value = "" then None else Some value + +let normalized_lookup_name value = String.lowercase_ascii (String.trim value) + +let strip_keyword_prefix value = + if String.length value > 0 && value.[0] = ':' then + String.sub value 1 (String.length value - 1) + else value + +let nonempty_trimmed = function + | Some value -> + let value = String.trim value in + if value = "" then None else Some value + | None -> None + +let count_present values = + Vec.fold_left + (fun count -> function Some _ -> count + 1 | None -> count) + 0 values + +let invalid_uuid_option option_name = function + | Some value when not (Cli_primitive.is_uuid_string (String.trim value)) -> + Some ("Option " ^ option_name ^ " must be a valid UUID string") + | _ -> None + +let first_error values = Vec.find_map Fun.id values + +let invalid_uuid_options = function + | Parsed_block opts -> + first_error + (Vec.of_array + [| + invalid_uuid_option "uuid" opts.uuid; + invalid_uuid_option "target-uuid" opts.target_uuid; + |]) + | Parsed_task opts -> + first_error + (Vec.of_array + [| + invalid_uuid_option "uuid" opts.uuid; + invalid_uuid_option "target-uuid" opts.target_uuid; + |]) + | Parsed_asset opts -> + first_error + (Vec.of_array + [| + invalid_uuid_option "uuid" opts.uuid; + invalid_uuid_option "target-uuid" opts.target_uuid; + |]) + | _ -> None + +let update_opts_of_block_opts (opts : block_opts) : Update.opts = + { + id = opts.id; + uuid = opts.uuid; + target_id = opts.target_id; + target_uuid = opts.target_uuid; + target_page = opts.target_page; + pos = opts.pos; + status = None; + content = opts.content; + update_tags_edn = opts.update_tags_edn; + update_properties_edn = opts.update_properties_edn; + remove_tags_edn = opts.remove_tags_edn; + remove_properties_edn = opts.remove_properties_edn; + blocks_edn = opts.blocks_edn; + blocks_file = opts.blocks_file; + } + +let invalid_options = function + | Parsed_block opts when update_mode opts -> + Update.invalid_options (update_opts_of_block_opts opts) + | Parsed_block opts + when (not (update_mode opts)) + && (Option.is_some opts.remove_tags_edn + || Option.is_some opts.remove_properties_edn) -> + Some "--remove-tags and --remove-properties are only for update mode" + | Parsed_page opts + when count_present + (Vec.of_array + [| + Option.map Int64.to_string opts.id; nonempty_trimmed opts.page; + |]) + > 1 -> + Some "only one of --id or --page is allowed" + | Parsed_page { page = Some page; _ } when String.trim page = "" -> + Some "page must be non-empty" + | Parsed_tag { name = Some name; _ } when normalize_tag_name name = None -> + Some "tag name must not be blank" + | Parsed_property opts + when count_present + (Vec.of_array + [| + Option.map Int64.to_string opts.id; nonempty_trimmed opts.name; + |]) + > 1 -> + Some "only one of --id or --name is allowed" + | Parsed_property { name = Some name; _ } when String.trim name = "" -> + Some "name must be non-empty" + | Parsed_asset opts -> + let path = nonempty_trimmed opts.path in + let target_page = nonempty_trimmed opts.target_page in + let target_selectors = + Vec.of_array + [| + Option.map Int64.to_string opts.target_id; + opts.target_uuid; + target_page; + |] + in + let update_mode = Option.is_some opts.id || Option.is_some opts.uuid in + if Option.is_some opts.id && Option.is_some opts.uuid then + Some "only one of --id or --uuid is allowed" + else if update_mode && Option.is_some path then + Some "--path is only valid in create mode" + else if (not update_mode) && Option.is_none path then + Some "--path is required in create mode" + else if count_present target_selectors > 1 then + Some + "only one of --target-id, --target-uuid, or --target-page is allowed" + else if + update_mode + && (count_present target_selectors > 0 || Option.is_some opts.pos) + then Some "--target-* and --pos are only valid in create mode" + else if Option.is_some opts.pos && count_present target_selectors = 0 then + Some "--pos is only valid when a target option is provided" + else if opts.pos = Some Block.Sibling && Option.is_some target_page then + Some "--pos sibling is only valid for block targets" + else None + | Parsed_task opts -> + let uuid = nonempty_trimmed opts.uuid in + let page = nonempty_trimmed opts.page in + let content = nonempty_trimmed opts.content in + let target_page = nonempty_trimmed opts.target_page in + let status = nonempty_trimmed opts.status in + let priority = nonempty_trimmed opts.priority in + let selectors = + Vec.of_array [| Option.map Int64.to_string opts.id; uuid; page |] + in + let target_selectors = + Vec.of_array + [| + Option.map Int64.to_string opts.target_id; + opts.target_uuid; + target_page; + |] + in + let selector_mode = count_present selectors > 0 in + if count_present selectors > 1 then + Some "only one of --id, --uuid, or --page is allowed" + else if Option.is_some page && Option.is_some content then + Some "--content and --page are mutually exclusive" + else if + (Option.is_some opts.id || Option.is_some uuid) + && Option.is_some content + then Some "--content is only valid when creating a block task" + else if count_present target_selectors > 1 then + Some + "only one of --target-id, --target-uuid, or --target-page is allowed" + else if + selector_mode + && (count_present target_selectors > 0 || Option.is_some opts.pos) + then + Some + "--target-* and --pos are only valid when creating a block task with \ + --content" + else if Option.is_some opts.pos && count_present target_selectors = 0 then + Some "--pos is only valid when a target option is provided" + else if opts.pos = Some Block.Sibling && Option.is_some target_page then + Some "--pos sibling is only valid for block targets" + else if Option.is_some status && opts.no_status then + Some "--status and --no-status are mutually exclusive" + else if Option.is_some priority && opts.no_priority then + Some "--priority and --no-priority are mutually exclusive" + else if Option.is_some opts.scheduled && opts.no_scheduled then + Some "--scheduled and --no-scheduled are mutually exclusive" + else if Option.is_some opts.deadline && opts.no_deadline then + Some "--deadline and --no-deadline are mutually exclusive" + else None + | _ -> None + +let command_id = function + | Parsed_block _ -> Command_id.Upsert_block + | Parsed_page _ -> Upsert_page + | Parsed_task _ -> Upsert_task + | Parsed_asset _ -> Upsert_asset + | Parsed_tag _ -> Upsert_tag + | Parsed_property _ -> Upsert_property + +let validate_parsed parsed = + match invalid_uuid_options parsed with + | Some message -> Error (Error.invalid_options message) + | None -> ( + match invalid_options parsed with + | Some message -> Error (Error.invalid_options message) + | None -> Ok ()) + +let repo_or_error config = + match config.Cli_config.repo with + | Some repo -> Ok (repo, Cli_config.repo_to_graph repo) + | None -> Error (Error.missing_repo "repo is required for upsert") + +let ordinal value = + let suffix = + match value mod 100 with + | 11 | 12 | 13 -> "th" + | _ -> ( + match value mod 10 with 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th") + in + string_of_int value ^ suffix + +let default_journal_title () = + let year, month, day = Time.local_date (Time.now ()) in + let months = + [| + "Jan"; + "Feb"; + "Mar"; + "Apr"; + "May"; + "Jun"; + "Jul"; + "Aug"; + "Sep"; + "Oct"; + "Nov"; + "Dec"; + |] + in + months.(month - 1) ^ " " ^ ordinal day ^ ", " ^ string_of_int year + +let block_target_of_parts target_id target_uuid target_page = + match + (target_id, nonempty_trimmed target_uuid, nonempty_trimmed target_page) + with + | Some id, None, None -> Ok (Target_id id) + | None, Some uuid, None -> Ok (Target_uuid uuid) + | None, None, Some page -> Ok (Target_page page) + | None, None, None -> + Error (Error.make Error.Missing_target "target page or block is required") + | _ -> + Error + (Error.invalid_options + "only one of --target-id, --target-uuid, or --target-page is allowed") + +let optional_block_target_of_parts target_id target_uuid target_page = + match + (target_id, nonempty_trimmed target_uuid, nonempty_trimmed target_page) + with + | None, None, None -> Ok None + | Some id, None, None -> Ok (Some (Target_id id)) + | None, Some uuid, None -> Ok (Some (Target_uuid uuid)) + | None, None, Some page -> Ok (Some (Target_page page)) + | _ -> + Error + (Error.invalid_options + "only one of --target-id, --target-uuid, or --target-page is allowed") + +let add_action_of_block_create (action : block_create) : Add.action = + let target_id, target_uuid, target_page_name = + match action.target with + | Target_id id -> (Some id, None, None) + | Target_uuid uuid -> (None, Some uuid, None) + | Target_page page -> (None, None, Some page) + in + { + Add.repo = action.repo; + graph = action.graph; + target_id; + target_uuid; + target_page_name; + pos = action.pos; + status = action.status; + tags = action.tags; + properties = action.properties; + blocks = action.blocks; + } + +let block_create_of_add_action ~update_plan (action : Add.action) = + Error.map + (fun target -> + { + repo = action.repo; + graph = action.graph; + target; + pos = action.pos; + status = action.status; + tags = action.tags; + properties = action.properties; + blocks = action.blocks; + update_plan; + }) + (block_target_of_parts action.target_id action.target_uuid + action.target_page_name) + +let block_update_of_update_action (action : Update.action) = + let source = + match (action.id, action.uuid) with + | Some id, None -> Ok (Source_id id) + | None, Some uuid -> Ok (Source_uuid uuid) + | _ -> Error (Error.make Error.Missing_source "source block is required") + in + Error.bind source (fun source -> + Error.map + (fun target -> + { + repo = action.repo; + graph = action.graph; + source; + target; + pos = action.pos; + update_tags = action.update_tags; + update_properties = action.update_properties; + remove_tags = action.remove_tags; + remove_properties = action.remove_properties; + content = action.content; + source_label = action.source_label; + target_label = action.target_label; + }) + (optional_block_target_of_parts action.target_id action.target_uuid + action.target_page)) + +let update_action_of_block_update (action : block_update) : Update.action = + let id, uuid = + match action.source with + | Source_id id -> (Some id, None) + | Source_uuid uuid -> (None, Some uuid) + in + let target_id, target_uuid, target_page = + match action.target with + | None -> (None, None, None) + | Some (Target_id id) -> (Some id, None, None) + | Some (Target_uuid uuid) -> (None, Some uuid, None) + | Some (Target_page page) -> (None, None, Some page) + in + { + Update.repo = action.repo; + graph = action.graph; + id; + uuid; + target_id; + target_uuid; + target_page; + pos = action.pos; + update_tags = action.update_tags; + update_properties = action.update_properties; + remove_tags = action.remove_tags; + remove_properties = action.remove_properties; + content = action.content; + source_label = action.source_label; + target_label = action.target_label; + } + +let normalize_page_name_input value = + let value = String.trim value in + if value = "" then None else Some value + +let strip_tag_prefix value = + let value = String.trim value in + let rec loop idx = + if idx < String.length value && value.[idx] = '#' then loop (idx + 1) + else idx + in + let start = loop 0 in + String.sub value start (String.length value - start) |> String.trim + +let tag_of_value value = + match + ( Edn_util.as_int64 value, + Edn_util.as_uuid value, + Edn_util.as_keyword_t value, + Edn_util.as_string value ) + with + | Some id, _, _, _ -> Some (Selector.Tag_id id) + | _, Some uuid, _, _ -> Some (Tag_uuid uuid) + | _, _, Some ident, _ -> Some (Tag_ident ident) + | _, _, _, Some value -> + let value = strip_tag_prefix value in + if value = "" then None + else if Cli_primitive.is_uuid_string value then Some (Tag_uuid value) + else if value.[0] = ':' then Some (Tag_ident (Edn_util.keyword_t value)) + else Some (Tag_name value) + | _ -> None + +let key_of_value = Property.parse_key + +let edn_value_of_string ~label text = + try Ok (Melange_edn_melange.of_edn_string text) + with Melange_edn_melange.Parse_error _ -> + Error (Error.invalid_options ("invalid " ^ label ^ " edn")) + +let parse_vector_edn ~label text = + Error.bind (edn_value_of_string ~label text) (fun parsed -> + match Edn_util.as_vector parsed with + | Some values -> Ok values + | _ -> Error (Error.invalid_options (label ^ " must be a vector"))) + +let parse_tags_edn ~label = function + | None -> Ok Vec.empty + | Some text -> + Error.bind (parse_vector_edn ~label text) (fun values -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> Ok acc + | Some (value, rest) -> ( + match tag_of_value value with + | Some tag -> loop (Vec.push_back acc tag) rest + | None -> + Error + (Error.invalid_options + (label + ^ " must contain strings, keywords, uuids, or ids"))) + in + loop Vec.empty values) + +let parse_property_keys_edn ~label = function + | None -> Ok Vec.empty + | Some text -> + Error.bind (parse_vector_edn ~label text) (fun values -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> Ok acc + | Some (value, rest) -> ( + match key_of_value value with + | Some key -> loop (Vec.push_back acc key) rest + | None -> + Error + (Error.invalid_options + (label ^ " must contain property keys"))) + in + loop Vec.empty values) + +let parse_tag_schema_properties opts = + Error.bind + (parse_property_keys_edn ~label:"add-properties" opts.add_properties_edn) + (fun add_properties -> + Error.map + (fun remove_properties -> (add_properties, remove_properties)) + (parse_property_keys_edn ~label:"remove-properties" + opts.remove_properties_edn)) + +let build_tag repo graph (opts : tag_opts) = + Error.bind (parse_tag_schema_properties opts) + (fun (add_properties, remove_properties) -> + match (opts.id, Option.bind opts.name normalize_tag_name) with + | Some id, name -> + Ok + (Upsert_tag + { + repo; + graph; + mode = Update; + id = Some id; + name; + add_properties; + remove_properties; + }) + | None, Some name -> + Ok + (Upsert_tag + { + repo; + graph; + mode = Create; + id = None; + name = Some name; + add_properties; + remove_properties; + }) + | None, None -> + Error (Error.make Error.Missing_tag_name "tag name is required")) + +let parse_property_assignments_edn ~label = function + | None -> Ok Vec.empty + | Some text -> + Error.bind (edn_value_of_string ~label text) (fun parsed -> + match Edn_util.as_map parsed with + | Some fields -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> Ok acc + | Some ((key, value), rest) -> ( + match key_of_value key with + | Some key -> + loop (Vec.push_back acc { Property.key; value }) rest + | None -> + Error + (Error.invalid_options + (label ^ " must contain property keys"))) + in + loop Vec.empty fields + | _ -> Error (Error.invalid_options (label ^ " must be a map"))) + +let parse_update_plan (opts : page_opts) = + Error.bind (parse_tags_edn ~label:"update-tags" opts.update_tags_edn) + (fun update_tags -> + Error.bind (parse_tags_edn ~label:"remove-tags" opts.remove_tags_edn) + (fun remove_tags -> + Error.bind + (parse_property_assignments_edn ~label:"update-properties" + opts.update_properties_edn) (fun update_properties -> + Error.bind + (parse_property_keys_edn ~label:"remove-properties" + opts.remove_properties_edn) (fun remove_properties -> + Ok + { + Property.update_tags; + remove_tags; + update_properties; + remove_properties; + })))) + +let build_page repo graph (opts : page_opts) = + Error.bind (parse_update_plan opts) (fun plan -> + match (opts.id, Option.bind opts.page normalize_page_name_input) with + | Some id, page -> + Ok + (Upsert_page + { + repo; + graph; + mode = Update; + id = Some id; + page; + restore = opts.restore; + plan; + }) + | None, Some page -> + Ok + (Upsert_page + { + repo; + graph; + mode = Create; + id = None; + page = Some page; + restore = opts.restore; + plan; + }) + | None, None -> + Error (Error.make Error.Missing_page_name "page name is required")) + +let schema_of_property_opts (opts : property_opts) = + { + Property.kind = opts.kind; + cardinality = opts.cardinality; + hidden = opts.hide; + public = opts.public; + } + +let normalize_property_name value = + let value = String.trim value in + if value = "" then None else Some value + +let build_property repo graph (opts : property_opts) = + let schema = schema_of_property_opts opts in + match (opts.id, Option.bind opts.name normalize_property_name) with + | Some id, name -> + Ok + (Upsert_property + { repo; graph; mode = Update; id = Some id; name; schema }) + | None, Some name -> + Ok + (Upsert_property + { repo; graph; mode = Create; id = None; name = Some name; schema }) + | None, None -> + Error (Error.make Error.Missing_property_name "property name is required") + +let normalize_content value = + let value = String.trim value in + if value = "" then None else Some value + +let normalize_priority value = + match String.lowercase_ascii (String.trim value) with + | "low" -> Some (Edn_util.keyword_t "logseq.property/priority.low") + | "medium" -> Some (Edn_util.keyword_t "logseq.property/priority.medium") + | "high" -> Some (Edn_util.keyword_t "logseq.property/priority.high") + | "urgent" -> Some (Edn_util.keyword_t "logseq.property/priority.urgent") + | _ -> None + +let invalid_priority_message value = + "Invalid value for option :priority: " ^ value + ^ ". Available values: low, medium, high, urgent" + +let build_asset repo graph (opts : asset_opts) = + let content = Option.bind opts.content normalize_content in + match (opts.id, opts.uuid, opts.path) with + | Some id, None, _ -> + Ok + (Upsert_asset + { + repo; + graph; + mode = Update; + id = Some id; + uuid = None; + path = None; + content; + create_action = None; + }) + | None, Some uuid, _ -> + Ok + (Upsert_asset + { + repo; + graph; + mode = Update; + id = None; + uuid = Some uuid; + path = None; + content; + create_action = None; + }) + | None, None, Some path -> + let title = + match content with + | Some content -> content + | None -> Filename.basename path + in + let add_opts = + { + Add.target_id = opts.target_id; + target_uuid = opts.target_uuid; + target_page_name = opts.target_page; + pos = opts.pos; + status = None; + tags_edn = None; + properties_edn = None; + content = Some title; + blocks_edn = None; + blocks_file = None; + } + in + Error.bind (Add.build_add_block_action add_opts Vec.empty repo) + (fun create_action -> + Error.map + (fun create_action -> + Upsert_asset + { + repo; + graph; + mode = Create; + id = None; + uuid = None; + path = Some path; + content = Some title; + create_action = Some create_action; + }) + (block_create_of_add_action ~update_plan:Property.empty_update_plan + create_action)) + | None, None, None -> + Error + (Error.make Error.Missing_asset_selector + "asset id, uuid, or path is required") + | Some _, Some _, _ -> + Error (Error.invalid_options "only one of --id or --uuid is allowed") + +let build_task repo graph (opts : task_opts) = + let priority = Option.bind opts.priority normalize_priority in + match (opts.priority, priority) with + | Some value, None -> + Error (Error.invalid_options (invalid_priority_message value)) + | _ -> ( + let clear_properties = + let properties = Vec.empty in + let properties = + if opts.no_status then + Vec.push_back properties + (Property.Key_ident (Edn_util.keyword_t "logseq.property/status")) + else properties + in + let properties = + if opts.no_priority then + Vec.push_back properties + (Property.Key_ident + (Edn_util.keyword_t "logseq.property/priority")) + else properties + in + let properties = + if opts.no_scheduled then + Vec.push_back properties + (Property.Key_ident + (Edn_util.keyword_t "logseq.property/scheduled")) + else properties + in + let properties = + if opts.no_deadline then + Vec.push_back properties + (Property.Key_ident + (Edn_util.keyword_t "logseq.property/deadline")) + else properties + in + properties + in + let update_properties = + let properties = Vec.empty in + let properties = + match priority with + | Some priority -> + Vec.push_back properties + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property/priority"); + value = Edn_util.any priority; + } + | None -> properties + in + let properties = + match opts.scheduled with + | Some scheduled -> + Vec.push_back properties + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property/scheduled"); + value = Edn_util.float (Js.Date.getTime scheduled); + } + | None -> properties + in + let properties = + match opts.deadline with + | Some deadline -> + Vec.push_back properties + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property/deadline"); + value = Edn_util.float (Js.Date.getTime deadline); + } + | None -> properties + in + properties + in + match (opts.page, opts.id, opts.uuid, opts.content) with + | Some page, None, None, _ when String.trim page <> "" -> + Ok + (Upsert_task + { + repo; + graph; + mode = Page; + id = None; + uuid = None; + page = Some page; + content = None; + update_properties; + clear_properties; + status_input = opts.status; + }) + | None, Some id, None, _ -> + Ok + (Upsert_task + { + repo; + graph; + mode = Update; + id = Some id; + uuid = None; + page = None; + content = opts.content; + update_properties; + clear_properties; + status_input = opts.status; + }) + | None, None, Some uuid, _ -> + Ok + (Upsert_task + { + repo; + graph; + mode = Update; + id = None; + uuid = Some uuid; + page = None; + content = opts.content; + update_properties; + clear_properties; + status_input = opts.status; + }) + | None, None, None, Some content when String.trim content <> "" -> ( + match + (opts.target_id, opts.target_uuid, opts.target_page, opts.pos) + with + | Some _, _, _, _ | _, Some _, _, _ | _, _, _, Some _ -> + Error + (Error.make Error.Spec_blocker + "upsert task create currently supports only --target-page \ + because spec/l3/upsert.mli does not carry target id, \ + target uuid, or pos in Upsert_task actions") + | None, None, Some target_page, None + when String.trim target_page <> "" -> + Ok + (Upsert_task + { + repo; + graph; + mode = Create; + id = None; + uuid = None; + page = Some target_page; + content = Some content; + update_properties; + clear_properties; + status_input = opts.status; + }) + | None, None, None, None -> + Ok + (Upsert_task + { + repo; + graph; + mode = Create; + id = None; + uuid = None; + page = Some (default_journal_title ()); + content = Some content; + update_properties; + clear_properties; + status_input = opts.status; + }) + | _ -> + Error + (Error.make Error.Missing_target + "target page is required when creating a task block")) + | _ -> Error (Error.make Error.Missing_target "block or page is required") + ) + +let parse_block_update_plan (opts : block_opts) = + Error.bind (parse_tags_edn ~label:"update-tags" opts.update_tags_edn) + (fun update_tags -> + Error.bind (parse_tags_edn ~label:"remove-tags" opts.remove_tags_edn) + (fun remove_tags -> + Error.bind + (parse_property_assignments_edn ~label:"update-properties" + opts.update_properties_edn) (fun update_properties -> + Error.bind + (parse_property_keys_edn ~label:"remove-properties" + opts.remove_properties_edn) (fun remove_properties -> + Ok + { + Property.update_tags; + remove_tags; + update_properties; + remove_properties; + })))) + +let build_block repo (opts : block_opts) = + Error.bind (parse_block_update_plan opts) (fun plan -> + if update_mode opts then + Error.bind + (Update.build_action (update_opts_of_block_opts opts) repo) + (fun action -> + Error.map + (fun action -> Upsert_block (Block_update action)) + (block_update_of_update_action action)) + else + let target_page = + match + ( opts.target_id, + nonempty_trimmed opts.target_uuid, + nonempty_trimmed opts.target_page ) + with + | None, None, None -> Some (default_journal_title ()) + | _ -> opts.target_page + in + let add_opts = + { + Add.target_id = opts.target_id; + target_uuid = opts.target_uuid; + target_page_name = target_page; + pos = opts.pos; + status = None; + tags_edn = None; + properties_edn = None; + content = opts.content; + blocks_edn = opts.blocks_edn; + blocks_file = opts.blocks_file; + } + in + Error.bind (Add.build_add_block_action add_opts Vec.empty repo) + (fun action -> + Error.map + (fun action -> Upsert_block (Block_create action)) + (block_create_of_add_action ~update_plan:plan action))) + +let build ?registry:_ config _globals parsed = + Error.bind (validate_parsed parsed) (fun () -> + Error.bind (repo_or_error config) (fun (repo, graph) -> + match parsed with + | Parsed_block opts -> build_block repo opts + | Parsed_page opts -> build_page repo graph opts + | Parsed_tag opts -> build_tag repo graph opts + | Parsed_property opts -> build_property repo graph opts + | Parsed_asset opts -> build_asset repo graph opts + | Parsed_task opts -> build_task repo graph opts)) + +let kw value = Edn_util.keyword value +let sym value = Edn_util.symbol value +let vector_vec values = Edn_util.vector_vec values +let list_vec values = Edn_util.list_vec values + +let query_value query = + Edn_util.any (Cli_primitive.datascript_query_to_edn query) + +let tag_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/tags", vector_vec (Vec.of_array [| kw "db/ident" |])); + |]); + |]) + +let page_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/deleted-at"; + |]) + +let property_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "db/ident"; + kw "block/uuid"; + kw "block/name"; + kw "block/title"; + kw "logseq.property/type"; + |]) + +let asset_selector = + vector_vec + (Vec.of_array + [| + kw "db/id"; + kw "block/uuid"; + kw "block/title"; + kw "logseq.property/deleted-at"; + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/tags", vector_vec (Vec.of_array [| kw "db/ident" |])); + |]); + |]) + +let task_selector = + vector_vec + (Vec.of_array + [| kw "db/id"; kw "block/uuid"; kw "block/name"; kw "block/title" |]) + +let result_ids ids = + Edn_util.map_vec + (Vec.of_array + [| + ( kw "result", + Edn_util.vector_vec (ids |> Vec.map (fun id -> Edn_util.int64 id)) ); + |]) + +module Node_crypto = struct + type hash + + external create_hash : string -> hash = "createHash" [@@mel.module "crypto"] + external update_buffer : hash -> Node.Buffer.t -> hash = "update" [@@mel.send] + external digest : hash -> string -> string = "digest" [@@mel.send] +end + +let file_sha256 path = + try + let payload = Cli_unix.read_binary_file path in + let hash = Node_crypto.create_hash "sha256" in + let payload_buffer = + Node.Buffer.fromStringWithEncoding payload ~encoding:`latin1 + in + Some + (Node_crypto.digest (Node_crypto.update_buffer hash payload_buffer) "hex") + with _ -> None + +let file_size path = (Cli_unix.stat path).Cli_unix.st_size + +let file_extension path = + let base = Filename.basename path in + match String.rindex_opt base '.' with + | Some index when index + 1 < String.length base -> + Some + (String.sub base (index + 1) (String.length base - index - 1) + |> String.lowercase_ascii) + | _ -> None + +let rec mkdir_p path = + if path = "" || path = Filename.dirname path || Cli_unix.file_exists path then + () + else ( + mkdir_p (Filename.dirname path); + Cli_unix.mkdir path 0o755) + +let copy_file source destination = + mkdir_p (Filename.dirname destination); + Cli_unix.copy_file source destination + +let graph_assets_dir config repo = + Filename.concat + (Filename.concat + (Filename.concat config.Cli_config.root_dir "graphs") + (Graph_dir.graph_dir_name_of_repo repo)) + "assets" + +let class_query selector class_ident = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (vector_vec + (Vec.of_array + [| + list_vec (Vec.of_array [| sym "pull"; sym "?e"; selector |]); + sym "..."; + |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "?name" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/tags"; sym "?t" |])); + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?t"; kw "db/ident"; kw class_ident |])); + |]) + () + +let tag_query selector = class_query selector "logseq.class/Tag" +let property_query selector = class_query selector "logseq.class/Property" + +let page_query selector = + Cli_primitive.make_datascript_query + ~find: + (Vec.singleton + (vector_vec + (Vec.of_array + [| + list_vec (Vec.of_array [| sym "pull"; sym "?e"; selector |]); + sym "..."; + |]))) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; Melange_edn_melange.symbol "?name"; + |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V + (Edn_util.vector_t_vec + (Vec.of_array [| sym "?e"; kw "block/name"; sym "?name" |])); + |]) + () + +let pull_pages_by_name config repo name selector = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (page_query selector); + Edn_util.string (normalized_lookup_name name); + |])) + +let pull_tag_by_name config repo name selector = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (tag_query selector); + Edn_util.string (normalized_lookup_name name); + |])) + +let list_tags config repo = + Transport.thread_api_cli_list_tags config ~repo + ~options:(Edn_util.map_t_vec Vec.empty) + +let tag_name_matches name entity = + let expected = normalized_lookup_name name in + let matches value = String.equal (normalized_lookup_name value) expected in + match + ( Edn_util.get_string entity "block/title", + Edn_util.get_string entity "block/name" ) + with + | Some title, _ when matches title -> true + | _, Some name when matches name -> true + | _ -> false + +let find_tag_by_name config repo name = + let open Cli_effect in + bind (list_tags config repo) (fun value -> + match Edn_util.as_seq value with + | Some tags -> pure (Vec.find_opt (tag_name_matches name) tags) + | None -> pure None) + +let pull_property_by_name config repo name selector = + Transport.thread_api_q config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| + query_value (property_query selector); + Edn_util.string (normalized_lookup_name name); + |])) + +let pull_entity_by_id config repo selector id = + Transport.thread_api_pull config ~repo + ~selector:(Edn_util.expect_vector_t "upsert pull selector" selector) + ~lookup:(Edn_util.int64 id) + +let pull_entity_by_lookup config repo selector lookup = + Transport.thread_api_pull config ~repo + ~selector:(Edn_util.expect_vector_t "upsert pull selector" selector) + ~lookup + +let first_entity value = + match (Edn_util.as_vector value, Edn_util.as_list value) with + | Some values, _ when not (Vec.is_empty values) -> + Some (Vec.peek_front values) + | _, Some values when not (Vec.is_empty values) -> + Some (Vec.peek_front values) + | _ -> None + +let id_of_entity value = Edn_util.get_int64 value "db/id" + +let uuid_of_entity value = + Option.bind (Edn_util.get value "block/uuid") Edn_util.as_string_like + +let name_of_entity value = Edn_util.get_string value "block/name" + +let tag_entity value = + match Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq with + | Some tags -> + Vec.exists + (fun tag -> + match + Option.bind (Edn_util.get tag "db/ident") Edn_util.as_string_like + with + | Some ident -> strip_keyword_prefix ident = "logseq.class/Tag" + | _ -> false) + tags + | _ -> false + +let property_entity value = + Option.is_some (Edn_util.get value "logseq.property/type") + +let asset_entity value = + match Option.bind (Edn_util.get value "block/tags") Edn_util.as_seq with + | Some tags -> + Vec.exists + (fun tag -> + match + Option.bind (Edn_util.get tag "db/ident") Edn_util.as_string_like + with + | Some ident -> strip_keyword_prefix ident = "logseq.class/Asset" + | _ -> false) + tags + | _ -> false + +let ident_of_entity value = + Option.bind (Edn_util.get value "db/ident") Edn_util.as_keyword_t + +let lookup_of_tag = function + | Selector.Tag_id id -> Edn_util.int64 id + | Tag_name name -> Edn_util.string (normalized_lookup_name name) + | Tag_ident ident -> Edn_util.any ident + | Tag_uuid uuid -> Edn_util.uuid uuid + +let apply_outliner_ops config repo ops = + Transport.thread_api_apply_outliner_ops config ~repo + ~ops:(Edn_util.vector_t_vec ops) + ~options:(Edn_util.map_t_vec Vec.empty) + +let create_tag_page config repo name = + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "create-page"; + Edn_util.vector_vec + (Vec.of_array + [| + Edn_util.string name; + Edn_util.map_vec + (Vec.of_array [| (kw "class?", Edn_util.bool true) |]); + |]); + |]) + in + apply_outliner_ops config repo (Vec.singleton op) + +let create_page config repo name = + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "create-page"; + Edn_util.vector_vec + (Vec.of_array + [| Edn_util.string name; Edn_util.map_vec Vec.empty |]); + |]) + in + apply_outliner_ops config repo (Vec.singleton op) + +let rename_page config repo uuid name = + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "rename-page"; + Edn_util.vector_vec + (Vec.of_array [| Edn_util.uuid uuid; Edn_util.string name |]); + |]) + in + apply_outliner_ops config repo (Vec.singleton op) + +let value_of_kind kind = Edn_util.keyword (Property.string_of_kind kind) + +let value_of_cardinality = function + | Property.One -> Edn_util.keyword "db.cardinality/one" + | Many -> Edn_util.keyword "db.cardinality/many" + +let value_of_schema (schema : Property.schema) = + let fields = Vec.empty in + let fields = + match schema.kind with + | Some kind -> + Vec.push_back fields (kw "logseq.property/type", value_of_kind kind) + | None -> fields + in + let fields = + match schema.cardinality with + | Some cardinality -> + Vec.push_back fields + (kw "db/cardinality", value_of_cardinality cardinality) + | None -> fields + in + let fields = + match schema.hidden with + | Some hidden -> + Vec.push_back fields (kw "logseq.property/hide?", Edn_util.bool hidden) + | None -> fields + in + let fields = + match schema.public with + | Some public -> + Vec.push_back fields (kw "logseq.property/public?", Edn_util.bool public) + | None -> fields + in + Edn_util.map_vec fields + +let schema_empty (schema : Property.schema) = + schema.kind = None && schema.cardinality = None && schema.hidden = None + && schema.public = None + +let upsert_property_op ?(opts = Edn_util.map_vec Vec.empty) ident schema = + let ident = + match ident with Some ident -> Edn_util.any ident | None -> Edn_util.nil + in + Edn_util.vector_vec + (Vec.of_array + [| + kw "upsert-property"; + Edn_util.vector_vec + (Vec.of_array [| ident; value_of_schema schema; opts |]); + |]) + +let upsert_property config repo ?opts ident schema = + apply_outliner_ops config repo + (Vec.singleton (upsert_property_op ?opts ident schema)) + +let ok_tag_ids mode ids = + Cli_result.ok ~command:Command_id.Upsert_tag mode (Raw (result_ids ids)) + +let ok_page_ids mode ids = + Cli_result.ok ~command:Command_id.Upsert_page mode (Raw (result_ids ids)) + +let ok_property_ids mode ids = + Cli_result.ok ~command:Command_id.Upsert_property mode (Raw (result_ids ids)) + +let ok_asset_ids mode ids = + Cli_result.ok ~command:Command_id.Upsert_asset mode (Raw (result_ids ids)) + +let tag_not_found mode = + Cli_result.error ~command:Command_id.Upsert_tag mode + (Error.make Error.Tag_not_found "tag not found after upsert") + +let upsert_id_not_found entity_type id = + Error.make Error.Upsert_id_not_found + (entity_type ^ " not found for id") + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "entity-type", Edn_util.string entity_type); + (kw "id", Edn_util.int64 id); + |])) + +let upsert_id_type_mismatch entity_type id = + Error.make Error.Upsert_id_type_mismatch + ("id must be a node tagged with #" ^ entity_type) + ~context: + (Edn_util.map_vec + (Vec.of_array + [| + (kw "entity-type", Edn_util.string entity_type); + (kw "id", Edn_util.int64 id); + |])) + +let same_tag_name entity target_name = + match name_of_entity entity with + | Some current_name -> + String.equal current_name (normalized_lookup_name target_name) + | None -> false + +let rename_conflict current_entity target = + match (id_of_entity target, id_of_entity current_entity) with + | None, _ -> None + | Some target_id, Some current_id when target_id = current_id -> None + | Some _, _ when tag_entity target -> + Some + (Error.make Error.Tag_rename_conflict + "rename target already exists as a tag") + | Some _, _ -> + Some + (Error.make Error.Tag_name_conflict + "tag already exists as a page and is not a tag") + +let page_entity value = Option.is_some (name_of_entity value) + +let recycled_entity value = + Option.is_some (Edn_util.get value "logseq.property/deleted-at") + +let page_not_found () = Error.make Error.Page_not_found "page not found" +let recycled_page_error () = Error.make Error.Recycled_page "page is recycled" + +let resolve_tag_id invoke_config repo tag = + let open Cli_effect in + match tag with + | Selector.Tag_id id -> pure (Ok id) + | Tag_name name -> + bind (find_tag_by_name invoke_config repo name) (fun result -> + match Option.bind result id_of_entity with + | Some id -> pure (Ok id) + | None -> + pure (Error (Error.make Error.Tag_not_found "tag not found"))) + | Tag_ident _ | Tag_uuid _ -> + bind + (pull_entity_by_lookup invoke_config repo tag_selector + (lookup_of_tag tag)) + (fun result -> + match id_of_entity result with + | Some id -> pure (Ok id) + | None -> + pure (Error (Error.make Error.Tag_not_found "tag not found"))) + +let property_key_label = function + | Property.Key_ident ident -> + Edn_util.keyword_to_string ident |> strip_keyword_prefix + | Key_id id -> Int64.to_string id + | Key_name name -> name + +let string_starts_with ~prefix value = + let prefix_len = String.length prefix in + String.length value >= prefix_len && String.sub value 0 prefix_len = prefix + +let qualified_property_ident ident = + String.contains (property_key_label (Property.Key_ident ident)) '/' + +let property_not_found_error key = + let label = property_key_label key in + Error.make Error.Property_not_found ("property not found: " ^ label) + +let resolved_property_ident key entity = + match (id_of_entity entity, ident_of_entity entity) with + | Some _, Some ident -> Ok ident + | _ -> Error (property_not_found_error key) + +let resolved_property_ident_from_query key result = + match first_entity result with + | Some entity -> resolved_property_ident key entity + | None -> Error (property_not_found_error key) + +let resolved_schema_property_ident key entity = + if property_entity entity then resolved_property_ident key entity + else Error (property_not_found_error key) + +let resolved_schema_property_ident_from_query key result = + match first_entity result with + | Some entity -> resolved_schema_property_ident key entity + | None -> Error (property_not_found_error key) + +let resolve_property_ident invoke_config repo key = + let open Cli_effect in + match key with + | Property.Key_ident ident when qualified_property_ident ident -> + pure (Ok ident) + | Property.Key_ident ident -> + bind + (pull_entity_by_lookup invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id" |])) + (vector_vec (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]))) + (fun result -> + match resolved_property_ident key result with + | Ok ident -> pure (Ok ident) + | Error _ when not (qualified_property_ident ident) -> + bind + (pull_property_by_name invoke_config repo + (property_key_label key) property_selector) + (fun result -> + pure (resolved_property_ident_from_query key result)) + | Error err -> pure (Error err)) + | Key_id id -> + bind + (pull_entity_by_lookup invoke_config repo + (vector_vec (Vec.of_array [| kw "db/ident" |])) + (Edn_util.int64 id)) + (fun result -> + match ident_of_entity result with + | Some ident -> pure (Ok ident) + | None -> pure (Error (property_not_found_error key))) + | Key_name name -> + bind (pull_property_by_name invoke_config repo name property_selector) + (fun result -> + match resolved_property_ident_from_query key result with + | Ok ident -> pure (Ok ident) + | Error _ -> + let ident = Edn_util.keyword_t name in + bind + (pull_entity_by_lookup invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id" |])) + (vector_vec + (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]))) + (fun result -> + if Option.is_some (id_of_entity result) then pure (Ok ident) + else pure (Error (property_not_found_error key)))) + +let resolve_schema_property_ident invoke_config repo key = + let open Cli_effect in + match key with + | Property.Key_ident ident -> + bind + (pull_entity_by_lookup invoke_config repo property_selector + (vector_vec (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]))) + (fun result -> + match resolved_schema_property_ident key result with + | Ok ident -> pure (Ok ident) + | Error _ when not (qualified_property_ident ident) -> + bind + (pull_property_by_name invoke_config repo + (property_key_label key) property_selector) + (fun result -> + pure (resolved_schema_property_ident_from_query key result)) + | Error err -> pure (Error err)) + | Key_id id -> + bind + (pull_entity_by_lookup invoke_config repo property_selector + (Edn_util.int64 id)) + (fun result -> pure (resolved_schema_property_ident key result)) + | Key_name name -> + bind (pull_property_by_name invoke_config repo name property_selector) + (fun result -> + match resolved_schema_property_ident_from_query key result with + | Ok ident -> pure (Ok ident) + | Error _ -> + let ident = Edn_util.keyword_t name in + bind + (pull_entity_by_lookup invoke_config repo property_selector + (vector_vec + (Vec.of_array [| kw "db/ident"; Edn_util.any ident |]))) + (fun result -> pure (resolved_schema_property_ident key result))) + +let resolve_tag_ids invoke_config repo tags = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (tag, rest) -> + bind (resolve_tag_id invoke_config repo tag) (function + | Error err -> pure (Error err) + | Ok id -> loop (Vec.push_back acc id) rest) + in + loop Vec.empty tags + +let resolve_property_keys invoke_config repo keys = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (key, rest) -> + bind (resolve_property_ident invoke_config repo key) (function + | Error err -> pure (Error err) + | Ok ident -> loop (Vec.push_back acc ident) rest) + in + loop Vec.empty keys + +let resolve_schema_property_keys invoke_config repo keys = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (key, rest) -> + bind (resolve_schema_property_ident invoke_config repo key) (function + | Error err -> pure (Error err) + | Ok ident -> loop (Vec.push_back acc ident) rest) + in + loop Vec.empty keys + +let block_uuid_lookup_ref value = + match (Edn_util.as_vector value, Edn_util.as_list value) with + | Some values, _ when Vec.length values = 2 -> ( + match + ( Edn_util.as_string_like (Vec.nth values 0), + Edn_util.as_string_like (Vec.nth values 1) ) + with + | Some "block/uuid", Some uuid -> Some uuid + | _ -> None) + | _, Some values when Vec.length values = 2 -> ( + match + ( Edn_util.as_string_like (Vec.nth values 0), + Edn_util.as_string_like (Vec.nth values 1) ) + with + | Some "block/uuid", Some uuid -> Some uuid + | _ -> None) + | _ -> None + +let resolve_block_uuid_ref invoke_config repo uuid = + let open Cli_effect in + bind + (pull_entity_by_lookup invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + (vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]))) + (fun entity -> + match id_of_entity entity with + | Some id -> pure (Ok (Edn_util.int64 id)) + | None -> + pure + (Error + (Error.make Error.Block_not_found ("block not found: " ^ uuid)))) + +let rec resolve_property_value_refs invoke_config repo value = + let open Cli_effect in + match block_uuid_lookup_ref value with + | Some uuid -> resolve_block_uuid_ref invoke_config repo uuid + | None -> ( + let resolve_values wrap values = + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok (wrap acc)) + | Some (value, rest) -> + bind (resolve_property_value_refs invoke_config repo value) + (function + | Error err -> pure (Error err) + | Ok value -> loop (Vec.push_back acc value) rest) + in + loop Vec.empty values + in + match value with + | Melange_edn_melange.Any (Melange_edn_melange.Vector values) -> + resolve_values Edn_util.vector_vec (Edn_util.vec_of_array values) + | Any (List values) -> + resolve_values Edn_util.list_vec (Edn_util.vec_of_array values) + | Any (Set values) -> + resolve_values Edn_util.set_vec (Edn_util.vec_of_array values) + | Any (Map fields) -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok (Edn_util.map_vec acc)) + | Some ((key, value), rest) -> + bind (resolve_property_value_refs invoke_config repo value) + (function + | Error err -> pure (Error err) + | Ok value -> loop (Vec.push_back acc (key, value)) rest) + in + loop Vec.empty (Edn_util.vec_of_array fields) + | _ -> pure (Ok value)) + +let resolve_property_assignments invoke_config repo assignments = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (assignment, rest) -> + bind (resolve_property_ident invoke_config repo assignment.Property.key) + (function + | Error err -> pure (Error err) + | Ok ident -> + bind + (resolve_property_value_refs invoke_config repo assignment.value) + (function + | Error err -> pure (Error err) + | Ok value -> loop (Vec.push_back acc (ident, value)) rest)) + in + loop Vec.empty assignments + +let option_resolution_error option err = + { + err with + Error.context = + Some + (Edn_util.map_vec + (Vec.of_array + [| + (kw "option", Edn_util.string option); + (kw "phase", Edn_util.string "resolve-options"); + |])); + } + +let structural_block_field key = + match Edn_util.as_string_like key with + | Some key -> + key = "block/title" || key = "block/content" || key = "block/uuid" + || key = "block/children" || key = "block/tags" + || string_starts_with ~prefix:"db/" key + || string_starts_with ~prefix:"build/" key + | None -> false + +let inline_property_assignments block = + match Edn_util.as_map block.Block.raw with + | None -> Ok Vec.empty + | Some fields -> + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> Ok acc + | Some ((key, value), rest) -> ( + if structural_block_field key then loop acc rest + else + match Property.parse_key key with + | Some key -> + loop (Vec.push_back acc { Property.key; value }) rest + | None -> + Error + (Error.invalid_options + ("invalid block property key: " + ^ Melange_edn_melange.to_edn_string key))) + in + loop Vec.empty fields + +let rec resolve_block_inline_properties invoke_config repo block = + let open Cli_effect in + match inline_property_assignments block with + | Error err -> pure (Error err) + | Ok assignments -> + bind (resolve_property_assignments invoke_config repo assignments) + (function + | Error err -> pure (Error (option_resolution_error "--blocks" err)) + | Ok resolved_assignments -> + let resolved_properties = + Vec.map + (fun (ident, value) -> + { Property.key = Property.Key_ident ident; value }) + resolved_assignments + in + let rec resolve_children acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (child, rest) -> + bind + (resolve_block_inline_properties invoke_config repo child) + (function + | Error err -> pure (Error err) + | Ok child -> + resolve_children (Vec.push_back acc child) rest) + in + bind (resolve_children Vec.empty block.Block.children) (function + | Error err -> pure (Error err) + | Ok children -> + pure + (Ok + { + block with + Block.properties = + Vec.append block.Block.properties resolved_properties; + children; + }))) + +let resolve_blocks_inline_properties invoke_config repo blocks = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (block, rest) -> + bind (resolve_block_inline_properties invoke_config repo block) + (function + | Error err -> pure (Error err) + | Ok block -> loop (Vec.push_back acc block) rest) + in + loop Vec.empty blocks + +let rec strip_block_properties block = + { + block with + Block.properties = Vec.empty; + children = Vec.map strip_block_properties block.Block.children; + } + +let property_key_value = function + | Property.Key_ident ident -> Edn_util.any ident + | Key_id id -> Edn_util.int64 id + | Key_name name -> Edn_util.string name + +let rec block_inline_property_ops block = + let own_ops = + match (block.Block.uuid, block.Block.properties) with + | Some uuid, properties -> + Vec.map + (fun assignment -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + Edn_util.vector_vec + (Vec.of_array [| Edn_util.uuid uuid |]); + property_key_value assignment.Property.key; + assignment.value; + Edn_util.map_vec Vec.empty; + |]); + |])) + properties + | None, _ -> Vec.empty + in + Vec.append own_ops + (Vec.concat_map block_inline_property_ops block.Block.children) + +let inline_property_ops blocks = Vec.concat_map block_inline_property_ops blocks + +let apply_inline_property_ops invoke_config repo blocks = + let open Cli_effect in + let ops = inline_property_ops blocks in + if Vec.is_empty ops then pure (Ok Edn_util.nil) + else + bind (apply_outliner_ops invoke_config repo ops) (fun result -> + pure (Ok result)) + +let append_tag_and_property_ops block_uuids ~update_tag_ids ~remove_tag_ids + ~update_properties ~remove_properties = + let uuid_values = + Edn_util.vector_vec (block_uuids |> Vec.map (fun uuid -> Edn_util.uuid uuid)) + in + let remove_tag_ops = + Vec.map + (fun tag_id -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-delete-property-value"; + Edn_util.vector_vec + (Vec.of_array + [| uuid_values; kw "block/tags"; Edn_util.int64 tag_id |]); + |])) + remove_tag_ids + in + let remove_property_ops = + Vec.map + (fun ident -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-remove-property"; + Edn_util.vector_vec + (Vec.of_array [| uuid_values; Edn_util.any ident |]); + |])) + remove_properties + in + let update_tag_ops = + Vec.map + (fun tag_id -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + kw "block/tags"; + Edn_util.int64 tag_id; + Edn_util.map_vec Vec.empty; + |]); + |])) + update_tag_ids + in + let update_property_ops = + Vec.map + (fun (ident, value) -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "batch-set-property"; + Edn_util.vector_vec + (Vec.of_array + [| + uuid_values; + Edn_util.any ident; + value; + Edn_util.map_vec Vec.empty; + |]); + |])) + update_properties + in + Vec.append remove_tag_ops + (Vec.append remove_property_ops + (Vec.append update_tag_ops update_property_ops)) + +let resolve_update_plan invoke_config repo plan = + let open Cli_effect in + bind (resolve_tag_ids invoke_config repo plan.Property.update_tags) (function + | Error err -> pure (Error (option_resolution_error "--update-tags" err)) + | Ok update_tag_ids -> + bind (resolve_tag_ids invoke_config repo plan.remove_tags) (function + | Error err -> + pure (Error (option_resolution_error "--remove-tags" err)) + | Ok remove_tag_ids -> + bind + (resolve_property_assignments invoke_config repo + plan.update_properties) (function + | Error err -> + pure + (Error (option_resolution_error "--update-properties" err)) + | Ok update_properties -> + bind + (resolve_property_keys invoke_config repo + plan.remove_properties) (function + | Error err -> + pure + (Error + (option_resolution_error "--remove-properties" + err)) + | Ok remove_properties -> + pure + (Ok + ( update_tag_ids, + remove_tag_ids, + update_properties, + remove_properties )))))) + +let apply_resolved_update_plan invoke_config repo block_uuids + (update_tag_ids, remove_tag_ids, update_properties, remove_properties) = + let open Cli_effect in + let ops = + append_tag_and_property_ops block_uuids ~update_tag_ids ~remove_tag_ids + ~update_properties ~remove_properties + in + if Vec.is_empty ops then pure (Ok Edn_util.nil) + else + bind (apply_outliner_ops invoke_config repo ops) (fun result -> + pure (Ok result)) + +let execute_plan_on_uuids invoke_config repo block_uuids plan = + let open Cli_effect in + bind (resolve_update_plan invoke_config repo plan) (function + | Error err -> pure (Error err) + | Ok resolved -> + apply_resolved_update_plan invoke_config repo block_uuids resolved) + +let execute_page_plan invoke_config repo page_uuid plan = + execute_plan_on_uuids invoke_config repo (Vec.singleton page_uuid) plan + +let restore_recycled_page invoke_config repo page_uuid = + apply_outliner_ops invoke_config repo + (Vec.singleton + (vector_vec + (Vec.of_array + [| + kw "restore-recycled"; + vector_vec (Vec.of_array [| Edn_util.uuid page_uuid |]); + |]))) + +let pull_created_page invoke_config repo name create_result = + let uuid_value = + match + (Edn_util.as_vector create_result, Edn_util.as_list create_result) + with + | Some values, _ -> Vec.nth_opt values 1 + | _, Some values -> Vec.nth_opt values 1 + | _ -> None + in + match uuid_value with + | Some uuid_value -> ( + match Edn_util.as_string_like uuid_value with + | Some uuid -> + Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.vector_t_vec + (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + ~lookup: + (vector_vec + (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + | _ -> + Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.vector_t_vec + (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + ~lookup: + (vector_vec + (Vec.of_array + [| + kw "block/name"; + Edn_util.string (normalized_lookup_name name); + |]))) + | _ -> + Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.vector_t_vec + (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + ~lookup: + (vector_vec + (Vec.of_array + [| + kw "block/name"; Edn_util.string (normalized_lookup_name name); + |])) + +let tag_schema_property_ops tag_uuid ~add_properties ~remove_properties = + let add_ops = + Vec.map + (fun ident -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "class-add-property"; + Edn_util.vector_vec + (Vec.of_array [| Edn_util.uuid tag_uuid; Edn_util.any ident |]); + |])) + add_properties + in + let remove_ops = + Vec.map + (fun ident -> + Edn_util.vector_vec + (Vec.of_array + [| + kw "class-remove-property"; + Edn_util.vector_vec + (Vec.of_array [| Edn_util.uuid tag_uuid; Edn_util.any ident |]); + |])) + remove_properties + in + Vec.append add_ops remove_ops + +let execute_tag_schema_properties invoke_config repo tag_uuid ~add_properties + ~remove_properties = + let open Cli_effect in + bind (resolve_schema_property_keys invoke_config repo add_properties) + (function + | Error err -> pure (Error (option_resolution_error "--add-properties" err)) + | Ok add_properties -> + bind (resolve_schema_property_keys invoke_config repo remove_properties) + (function + | Error err -> + pure (Error (option_resolution_error "--remove-properties" err)) + | Ok remove_properties -> + let ops = + tag_schema_property_ops tag_uuid ~add_properties + ~remove_properties + in + if Vec.is_empty ops then pure (Ok Edn_util.nil) + else + bind (apply_outliner_ops invoke_config repo ops) (fun result -> + pure (Ok result)))) + +let tag_result_with_schema_properties mode invoke_config repo entity + ~add_properties ~remove_properties = + let open Cli_effect in + match (id_of_entity entity, uuid_of_entity entity) with + | Some id, Some uuid -> + if tag_entity entity then + bind + (execute_tag_schema_properties invoke_config repo uuid ~add_properties + ~remove_properties) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_tag mode err) + | Ok _ -> pure (ok_tag_ids mode (Vec.singleton id))) + else + pure + (Cli_result.error ~command:Command_id.Upsert_tag mode + (Error.make Error.Tag_create_not_tag + "created entity is not tagged as :logseq.class/Tag")) + | Some _, None -> + pure + (Cli_result.error ~command:Command_id.Upsert_tag mode + (Error.make Error.Upsert_id_not_found "tag uuid not found")) + | None, _ -> pure (tag_not_found mode) + +let execute_create_tag mode invoke_config repo name ~add_properties + ~remove_properties = + let open Cli_effect in + bind (pull_tag_by_name invoke_config repo name tag_selector) (fun existing -> + match first_entity existing with + | Some entity -> + tag_result_with_schema_properties mode invoke_config repo entity + ~add_properties ~remove_properties + | None -> + bind (create_tag_page invoke_config repo name) (fun _ -> + bind (pull_tag_by_name invoke_config repo name tag_selector) + (fun page -> + match first_entity page with + | Some entity -> + tag_result_with_schema_properties mode invoke_config repo + entity ~add_properties ~remove_properties + | None -> pure (tag_not_found mode)))) + +let execute_update_tag mode invoke_config repo id name ~add_properties + ~remove_properties = + let open Cli_effect in + bind (pull_entity_by_id invoke_config repo tag_selector id) (fun entity -> + match id_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_tag mode + (upsert_id_not_found "tag" id)) + | Some id when not (tag_entity entity) -> + pure + (Cli_result.error ~command:Command_id.Upsert_tag mode + (upsert_id_type_mismatch "Tag" id)) + | Some _ -> ( + match name with + | None -> + tag_result_with_schema_properties mode invoke_config repo entity + ~add_properties ~remove_properties + | Some target_name when same_tag_name entity target_name -> + tag_result_with_schema_properties mode invoke_config repo entity + ~add_properties ~remove_properties + | Some target_name -> + bind + (pull_tag_by_name invoke_config repo target_name tag_selector) + (fun target -> + match + Option.bind (first_entity target) (rename_conflict entity) + with + | Some err -> + pure + (Cli_result.error ~command:Command_id.Upsert_tag mode + err) + | None -> ( + match uuid_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_tag + mode + (Error.make Error.Upsert_id_not_found + "tag uuid not found")) + | Some uuid -> + bind (rename_page invoke_config repo uuid target_name) + (fun _ -> + tag_result_with_schema_properties mode + invoke_config repo entity ~add_properties + ~remove_properties))))) + +let execute_create_property mode invoke_config repo name schema = + let open Cli_effect in + bind (pull_property_by_name invoke_config repo name property_selector) + (fun existing -> + let ident = Option.bind (first_entity existing) ident_of_entity in + let opts = + match ident with + | Some _ -> Edn_util.map_vec Vec.empty + | None -> + Edn_util.map_vec + (Vec.of_array [| (kw "property-name", Edn_util.string name) |]) + in + bind (upsert_property invoke_config repo ~opts ident schema) (fun _ -> + bind (pull_property_by_name invoke_config repo name property_selector) + (fun property -> + match Option.bind (first_entity property) id_of_entity with + | Some id -> pure (ok_property_ids mode (Vec.singleton id)) + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_property mode + (Error.make Error.Property_not_found + "property not found after upsert"))))) + +let execute_update_property mode invoke_config repo id schema = + let open Cli_effect in + bind (pull_entity_by_id invoke_config repo property_selector id) + (fun entity -> + match id_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_property mode + (upsert_id_not_found "property" id)) + | Some id when not (property_entity entity) -> + pure + (Cli_result.error ~command:Command_id.Upsert_property mode + (upsert_id_type_mismatch "Property" id)) + | Some id -> + if schema_empty schema then + pure (ok_property_ids mode (Vec.singleton id)) + else + let ident = ident_of_entity entity in + bind (upsert_property invoke_config repo ident schema) (fun _ -> + pure (ok_property_ids mode (Vec.singleton id)))) + +let save_block_title invoke_config repo uuid content = + let block = + Edn_util.map_vec + (Vec.of_array + [| + (kw "block/uuid", Edn_util.uuid uuid); + (kw "block/title", Edn_util.string content); + |]) + in + let op = + Edn_util.vector_vec + (Vec.of_array + [| + kw "save-block"; + Edn_util.vector_vec + (Vec.of_array [| block; Edn_util.map_vec Vec.empty |]); + |]) + in + apply_outliner_ops invoke_config repo (Vec.singleton op) + +let pull_asset_by_uuid invoke_config repo uuid = + pull_entity_by_lookup invoke_config repo asset_selector + (vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + +let ensure_asset_file_metadata path = + if not (Cli_unix.file_exists path) then + Error + (Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array [| (kw "path", Edn_util.string path) |])) + Error.Asset_file_not_found "asset file not found") + else + match file_extension path with + | None -> + Error + (Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array [| (kw "path", Edn_util.string path) |])) + Error.Invalid_options "asset path must include a file extension") + | Some asset_type -> ( + match file_sha256 path with + | None -> + Error + (Error.make + ~context: + (Edn_util.map_vec + (Vec.of_array [| (kw "path", Edn_util.string path) |])) + Error.Asset_checksum_failed "asset checksum failed") + | Some checksum -> Ok (asset_type, file_size path, checksum)) + +let ensure_asset_tag_id invoke_config repo = + let open Cli_effect in + bind + (pull_entity_by_lookup invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id" |])) + (vector_vec (Vec.of_array [| kw "db/ident"; kw "logseq.class/Asset" |]))) + (fun entity -> + match id_of_entity entity with + | Some id -> pure (Ok id) + | None -> + pure + (Error (Error.make Error.Asset_tag_not_found "asset tag not found"))) + +let with_asset_metadata (block : Block.t) asset_tag_id asset_type asset_size + asset_checksum = + { + block with + Block.tags = Vec.push_front block.tags (Selector.Tag_id asset_tag_id); + properties = + Vec.prepend_array block.properties + [| + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property.asset/type"); + value = Edn_util.string asset_type; + }; + { + key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property.asset/size"); + value = Edn_util.int64 (Int64.of_int asset_size); + }; + { + key = + Property.Key_ident + (Edn_util.keyword_t "logseq.property.asset/checksum"); + value = Edn_util.string asset_checksum; + }; + |]; + } + +let result_ids_of_create_result result = + match Cli_result.data_value result with + | Some value -> ( + match Option.bind (Edn_util.get value "result") Edn_util.as_seq with + | Some values -> + let ids = Vec.filter_map Edn_util.as_int64 values in + if Vec.length ids = Vec.length values then Some ids else None + | _ -> None) + | None -> None + +let execute_create_asset mode config repo path create_action = + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_asset mode err) + | Ok invoke_config -> ( + match ensure_asset_file_metadata path with + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_asset mode err) + | Ok (asset_type, asset_size, asset_checksum) -> + bind (ensure_asset_tag_id invoke_config repo) (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset mode err) + | Ok asset_tag_id -> ( + match Vec.pop_front create_action.blocks with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset mode + (Error.make Error.Asset_create_failed + "created asset block missing uuid")) + | Some (first, rest) -> ( + match first.Block.uuid with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset + mode + (Error.make Error.Asset_create_failed + "created asset block missing uuid")) + | Some block_uuid -> ( + let first = + with_asset_metadata first asset_tag_id asset_type + asset_size asset_checksum + in + let create_action = + { + create_action with + blocks = Vec.push_front rest first; + } + in + let add_action = + add_action_of_block_create create_action + in + let destination = + Filename.concat + (graph_assets_dir config repo) + (block_uuid ^ "." ^ asset_type) + in + try + copy_file path destination; + bind (Add.execute_add_block add_action config mode) + (fun result -> + if Cli_result.is_error result then pure result + else + match result_ids_of_create_result result with + | Some ids -> + pure + (Cli_result.ok + ~command:Command_id.Upsert_asset mode + (Raw (result_ids ids))) + | None -> + pure + (Cli_result.error + ~command:Command_id.Upsert_asset mode + (Error.make Error.Asset_create_failed + "asset block not created"))) + with exn -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset + mode + (Error.make Error.Asset_file_copy_failed + (Printexc.to_string exn))))))))) + +let execute_update_asset mode invoke_config repo id uuid content = + let open Cli_effect in + let pulled = + match (id, uuid) with + | Some id, _ -> pull_entity_by_id invoke_config repo asset_selector id + | None, Some uuid -> pull_asset_by_uuid invoke_config repo uuid + | None, None -> pure Edn_util.nil + in + bind pulled (fun entity -> + match id_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset mode + (upsert_id_not_found "asset" (Option.value id ~default:0L))) + | Some id when not (asset_entity entity) -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset mode + (upsert_id_type_mismatch "Asset" id)) + | Some id -> ( + match (content, uuid_of_entity entity) with + | Some content, Some uuid -> + bind (save_block_title invoke_config repo uuid content) (fun _ -> + pure (ok_asset_ids mode (Vec.singleton id))) + | Some _, None -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset mode + (Error.make Error.Upsert_id_not_found "asset uuid not found")) + | None, _ -> pure (ok_asset_ids mode (Vec.singleton id)))) + +let ok_task_ids mode ids = + Cli_result.ok ~command:Command_id.Upsert_task mode (Raw (result_ids ids)) + +let status_query invoke_config repo = + Transport.thread_api_q invoke_config ~repo + ~query: + (Edn_util.vector_t_vec + (Vec.of_array + [| Edn_util.any Task_status.status_closed_values_query |])) + +let resolve_task_status invoke_config repo = function + | None -> Cli_effect.pure (Ok None) + | Some status_input -> + let open Cli_effect in + bind (status_query invoke_config repo) (fun result -> + let values = + Option.value (Edn_util.as_seq result) ~default:Vec.empty + in + let statuses = Task_status.normalize_available_statuses values in + match Task_status.resolve_status_ident status_input statuses with + | Some ident -> pure (Ok (Some ident)) + | None -> + pure + (Error + (Error.invalid_options + (Task_status.invalid_status_message status_input statuses)))) + +let ensure_task_tag_id invoke_config repo = + let open Cli_effect in + bind + (pull_entity_by_lookup invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id" |])) + (vector_vec (Vec.of_array [| kw "db/ident"; kw "logseq.class/Task" |]))) + (fun entity -> + match id_of_entity entity with + | Some id -> pure (Ok id) + | None -> + pure + (Error (Error.make Error.Task_tag_not_found "task tag not found"))) + +let ensure_task_page invoke_config repo page = + let open Cli_effect in + bind (pull_pages_by_name invoke_config repo page task_selector) (fun result -> + match first_entity result with + | Some entity -> ( + match (id_of_entity entity, uuid_of_entity entity) with + | Some id, Some uuid -> pure (Ok (id, uuid)) + | _ -> + pure + (Error + (Error.make Error.Page_not_found + "page not found after upsert"))) + | None -> + bind (create_page invoke_config repo page) (fun create_result -> + bind (pull_created_page invoke_config repo page create_result) + (fun entity -> + match (id_of_entity entity, uuid_of_entity entity) with + | Some id, Some uuid -> pure (Ok (id, uuid)) + | _ -> + pure + (Error + (Error.make Error.Page_not_found + "page not found after upsert"))))) + +let execute_task_plan_on_uuids invoke_config repo block_uuids task_tag_id + status_ident update_property_assignments clear_properties = + let update_properties = + match status_ident with + | Some ident -> + Vec.singleton + (Edn_util.keyword_t "logseq.property/status", Edn_util.any ident) + | None -> Vec.empty + in + let update_properties = + Vec.append update_properties + (Vec.filter_map + (fun assignment -> + match assignment.Property.key with + | Property.Key_ident ident -> Some (ident, assignment.value) + | _ -> None) + update_property_assignments) + in + let remove_properties = + Vec.filter_map + (function Property.Key_ident ident -> Some ident | _ -> None) + clear_properties + in + apply_resolved_update_plan invoke_config repo block_uuids + (Vec.singleton task_tag_id, Vec.empty, update_properties, remove_properties) + +let execute_task_plan_on_uuid invoke_config repo block_uuid task_tag_id + status_ident update_property_assignments clear_properties = + execute_task_plan_on_uuids invoke_config repo (Vec.singleton block_uuid) + task_tag_id status_ident update_property_assignments clear_properties + +let execute_task_page mode invoke_config repo page status_input + update_properties clear_properties = + let open Cli_effect in + bind (resolve_task_status invoke_config repo status_input) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok status_ident -> + bind (ensure_task_tag_id invoke_config repo) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok task_tag_id -> + bind (ensure_task_page invoke_config repo page) (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok (page_id, page_uuid) -> + bind + (execute_task_plan_on_uuid invoke_config repo page_uuid + task_tag_id status_ident update_properties + clear_properties) (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_task + mode err) + | Ok _ -> pure (ok_task_ids mode (Vec.singleton page_id)))))) + +let pull_task_by_uuid invoke_config repo uuid = + pull_entity_by_lookup invoke_config repo task_selector + (vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |])) + +let execute_task_update mode invoke_config repo id uuid status_input + update_properties clear_properties = + let open Cli_effect in + bind (resolve_task_status invoke_config repo status_input) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok status_ident -> + bind (ensure_task_tag_id invoke_config repo) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok task_tag_id -> + let pulled = + match (id, uuid) with + | Some id, _ -> + pull_entity_by_id invoke_config repo task_selector id + | None, Some uuid -> pull_task_by_uuid invoke_config repo uuid + | None, None -> pure Edn_util.nil + in + bind pulled (fun entity -> + match (id_of_entity entity, uuid_of_entity entity) with + | Some node_id, Some node_uuid -> + bind + (execute_task_plan_on_uuid invoke_config repo node_uuid + task_tag_id status_ident update_properties + clear_properties) (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_task + mode err) + | Ok _ -> + pure (ok_task_ids mode (Vec.singleton node_id))) + | Some _, None -> + pure + (Cli_result.error ~command:Command_id.Upsert_task mode + (Error.make Error.Upsert_id_not_found + "task uuid not found")) + | None, _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_task mode + (upsert_id_not_found "task" + (Option.value id ~default:0L)))))) + +let page_result_with_plan ?(restore = false) mode invoke_config repo page_id + page_uuid plan = + let open Cli_effect in + let restore_effect = + if restore then restore_recycled_page invoke_config repo page_uuid + else pure Edn_util.nil + in + bind restore_effect (fun _ -> + bind (execute_page_plan invoke_config repo page_uuid plan) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_page mode err) + | Ok _ -> pure (ok_page_ids mode (Vec.singleton page_id)))) + +let execute_create_page mode invoke_config repo name restore plan = + let open Cli_effect in + bind (pull_pages_by_name invoke_config repo name page_selector) + (fun existing -> + match first_entity existing with + | Some page -> ( + let recycled = recycled_entity page in + if recycled && not restore then + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (recycled_page_error ())) + else + match (id_of_entity page, uuid_of_entity page) with + | Some id, Some uuid -> + page_result_with_plan ~restore:recycled mode invoke_config repo + id uuid plan + | Some _, None when recycled -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (recycled_page_error ())) + | Some id, None -> pure (ok_page_ids mode (Vec.singleton id)) + | _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (page_not_found ()))) + | None -> + bind (create_page invoke_config repo name) (fun create_result -> + bind (pull_created_page invoke_config repo name create_result) + (fun page -> + match (id_of_entity page, uuid_of_entity page) with + | Some id, Some uuid -> + page_result_with_plan mode invoke_config repo id uuid plan + | Some id, None -> pure (ok_page_ids mode (Vec.singleton id)) + | _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (page_not_found ()))))) + +let execute_update_page mode invoke_config repo id restore plan = + let open Cli_effect in + bind (pull_entity_by_id invoke_config repo page_selector id) (fun entity -> + match id_of_entity entity with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (upsert_id_not_found "page" id)) + | Some id when not (page_entity entity) -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (upsert_id_type_mismatch "Page" id)) + | Some _ when recycled_entity entity && not restore -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (recycled_page_error ())) + | Some id -> ( + let recycled = recycled_entity entity in + match uuid_of_entity entity with + | Some uuid -> + page_result_with_plan ~restore:recycled mode invoke_config repo id + uuid plan + | None when recycled -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (recycled_page_error ())) + | None -> pure (ok_page_ids mode (Vec.singleton id)))) + +let update_plan_empty (plan : Property.update_plan) = + Vec.is_empty plan.update_tags + && Vec.is_empty plan.remove_tags + && Vec.is_empty plan.update_properties + && Vec.is_empty plan.remove_properties + +let result_ids_of_cli_result result = + match Cli_result.data_value result with + | Some value -> ( + match Option.bind (Edn_util.get value "result") Edn_util.as_seq with + | Some values -> + let ids = Vec.filter_map Edn_util.as_int64 values in + if Vec.length ids = Vec.length values then Some ids else None + | _ -> None) + | _ -> None + +let resolve_block_uuids_by_id invoke_config repo ids = + let open Cli_effect in + let rec loop acc remaining = + match Vec.pop_front remaining with + | None -> pure (Ok acc) + | Some (id, rest) -> + bind + (pull_entity_by_id invoke_config repo + (vector_vec (Vec.of_array [| kw "db/id"; kw "block/uuid" |])) + id) + (fun entity -> + match uuid_of_entity entity with + | None -> pure (Error (upsert_id_not_found "block" id)) + | Some uuid -> loop (Vec.push_back acc uuid) rest) + in + loop Vec.empty ids + +let block_uuids_of_add_action action = + Vec.filter_map (fun block -> block.Block.uuid) action.Add.blocks + +let execute_task_create mode config invoke_config repo target_page content + status_input update_properties clear_properties = + let open Cli_effect in + bind (resolve_task_status invoke_config repo status_input) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok status_ident -> + bind (ensure_task_tag_id invoke_config repo) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok task_tag_id -> ( + let add_opts = + { + Add.target_id = None; + target_uuid = None; + target_page_name = Some target_page; + pos = None; + status = None; + tags_edn = None; + properties_edn = None; + content = Some content; + blocks_edn = None; + blocks_file = None; + } + in + match Add.build_add_block_action add_opts Vec.empty repo with + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok create_action -> + bind (Add.execute_add_block create_action config mode) + (fun result -> + if Cli_result.is_error result then + pure + (Cli_result.with_command Command_id.Upsert_task result) + else + match result_ids_of_cli_result result with + | None -> + pure + (Cli_result.error ~command:Command_id.Upsert_task + mode + (Error.make Error.Add_id_resolution_failed + "unable to resolve created ids")) + | Some ids -> + let block_uuids = + block_uuids_of_add_action create_action + in + let resolved_uuids = + if Vec.is_empty block_uuids then + resolve_block_uuids_by_id invoke_config repo ids + else pure (Ok block_uuids) + in + bind resolved_uuids (function + | Error err -> + pure + (Cli_result.error + ~command:Command_id.Upsert_task mode err) + | Ok block_uuids -> + bind + (execute_task_plan_on_uuids invoke_config + repo block_uuids task_tag_id status_ident + update_properties clear_properties) + (function + | Error err -> + pure + (Cli_result.error + ~command:Command_id.Upsert_task + mode err) + | Ok _ -> pure (ok_task_ids mode ids))))))) + +let execute_create_block mode (action : block_create) config = + let open Cli_effect in + bind (Server_runtime.ensure_server config action.repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok invoke_config -> + bind + (resolve_blocks_inline_properties invoke_config action.repo + action.blocks) (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_block mode err) + | Ok blocks -> + let action = { action with blocks } in + let resolved_plan_effect = + if update_plan_empty action.update_plan then pure (Ok None) + else + map + (function + | Ok plan -> Ok (Some plan) | Error err -> Error err) + (resolve_update_plan invoke_config action.repo + action.update_plan) + in + bind resolved_plan_effect (function + | Error err -> + pure + (Cli_result.error ~command:Command_id.Upsert_block mode + err) + | Ok resolved_plan -> + let add_action = + add_action_of_block_create + { + action with + blocks = Vec.map strip_block_properties action.blocks; + } + in + bind (Add.execute_add_block add_action config mode) + (fun result -> + if Cli_result.is_error result then pure result + else + bind + (apply_inline_property_ops invoke_config action.repo + action.blocks) (function + | Error err -> + pure + (Cli_result.error + ~command:Command_id.Upsert_block mode err) + | Ok _ -> ( + match resolved_plan with + | None -> pure result + | Some resolved_plan -> ( + match result_ids_of_cli_result result with + | None -> + pure + (Cli_result.error + ~command:Command_id.Upsert_block + mode + (Error.make + Error.Add_id_resolution_failed + "unable to resolve created ids")) + | Some ids -> + bind + (resolve_block_uuids_by_id + invoke_config action.repo ids) + (function + | Error err -> + pure + (Cli_result.error + ~command: + Command_id.Upsert_block + mode err) + | Ok block_uuids -> + bind + (apply_resolved_update_plan + invoke_config action.repo + block_uuids resolved_plan) + (function + | Error err -> + pure + (Cli_result.error + ~command: + Command_id + .Upsert_block mode + err) + | Ok _ -> pure result))))))))) + +let execute_with_mode action config mode = + let open Cli_effect in + match action with + | Upsert_block (Block_create action) -> + execute_create_block mode action config + | Upsert_block (Block_update action) -> + map + (Cli_result.with_command Command_id.Upsert_block) + (Update.execute (update_action_of_block_update action) config mode) + | Upsert_tag + { + repo; + mode = Create; + name = Some name; + add_properties; + remove_properties; + _; + } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_tag mode err) + | Ok invoke_config -> + execute_create_tag mode invoke_config repo name ~add_properties + ~remove_properties) + | Upsert_tag + { + repo; + mode = Update; + id = Some id; + name; + add_properties; + remove_properties; + _; + } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_tag mode err) + | Ok invoke_config -> + execute_update_tag mode invoke_config repo id name ~add_properties + ~remove_properties) + | Upsert_tag _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_tag mode + (Error.make Error.Not_implemented "upsert tag is not implemented")) + | Upsert_page { repo; mode = Create; page = Some page; restore; plan; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_page mode err) + | Ok invoke_config -> + execute_create_page mode invoke_config repo page restore plan) + | Upsert_page { repo; mode = Update; id = Some id; restore; plan; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_page mode err) + | Ok invoke_config -> + execute_update_page mode invoke_config repo id restore plan) + | Upsert_page _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_page mode + (Error.make Error.Not_implemented "upsert page is not implemented")) + | Upsert_property { repo; mode = Create; name = Some name; schema; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_property mode err) + | Ok invoke_config -> + execute_create_property mode invoke_config repo name schema) + | Upsert_property { repo; mode = Update; id = Some id; schema; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_property mode err) + | Ok invoke_config -> + execute_update_property mode invoke_config repo id schema) + | Upsert_property _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_property mode + (Error.make Error.Not_implemented + "upsert property is not implemented")) + | Upsert_asset { repo; mode = Update; id; uuid; content; _ } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_asset mode err) + | Ok invoke_config -> + execute_update_asset mode invoke_config repo id uuid content) + | Upsert_asset + { + repo; + mode = Create; + path = Some path; + create_action = Some create_action; + _; + } -> + execute_create_asset mode config repo path create_action + | Upsert_asset _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_asset mode + (Error.make Error.Not_implemented "upsert asset is not implemented")) + | Upsert_task + { + repo; + mode = Page; + page = Some page; + status_input; + update_properties; + clear_properties; + _; + } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok invoke_config -> + execute_task_page mode invoke_config repo page status_input + update_properties clear_properties) + | Upsert_task + { + repo; + mode = Create; + page = Some target_page; + content = Some content; + status_input; + update_properties; + clear_properties; + _; + } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok invoke_config -> + execute_task_create mode config invoke_config repo target_page + content status_input update_properties clear_properties) + | Upsert_task + { + repo; + mode = Update; + id; + uuid; + status_input; + update_properties; + clear_properties; + _; + } -> + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error err -> + pure (Cli_result.error ~command:Command_id.Upsert_task mode err) + | Ok invoke_config -> + execute_task_update mode invoke_config repo id uuid status_input + update_properties clear_properties) + | Upsert_task _ -> + pure + (Cli_result.error ~command:Command_id.Upsert_task mode + (Error.make Error.Not_implemented "upsert task is not implemented")) + +let meta ?(examples = Vec.empty) id doc = + { + Command_registry.id; + path = Command_id.to_path id; + doc; + long_doc = None; + examples; + options = Vec.empty; + category = Command_registry.Graph_inspect_and_edit; + requires_graph = Command_id.requires_graph id; + requires_auth = Command_id.requires_auth id; + write_command = Command_id.is_write id; + human_table_headers_order = Vec.empty; + } + +let metadata () = + Vec.of_array + [| + meta + ~examples: + (Vec.of_array + [| + "logseq upsert block --graph my-graph --target-page Home \ + --content \"New block\""; + "logseq upsert block --graph my-graph --id 123 --content \ + \"Updated content\""; + "logseq upsert block --graph my-graph --id 123 --target-page \ + Home"; + "logseq upsert block --graph my-graph --target-page Meeting \ + Notes --content \"AI summary of the discussion\" --update-tags \ + '[\"AI-GENERATED\"]'"; + "logseq upsert block --graph my-graph --blocks '[{:block/title \ + \"A\"} {:block/title \"B\"}]'"; + |]) + Command_id.Upsert_block "Upsert block"; + meta + ~examples: + (Vec.of_array + [| + "logseq upsert page --graph my-graph --page Home --update-tags \ + '[\"project\"]'"; + "logseq upsert page --graph my-graph --id 999 \ + --update-properties '{:logseq.property/description \ + \"Example\"}'"; + |]) + Upsert_page "Upsert page"; + meta + ~examples: + (Vec.of_array + [| + "logseq upsert task --graph my-graph --content \"Ship release\" \ + --target-page Home --status todo --priority high --scheduled \ + \"2026-02-10T08:00:00.000Z\" --deadline \ + \"2026-02-12T18:00:00.000Z\""; + "logseq upsert task --graph my-graph --page Weekly Plan \ + --status doing"; + "logseq upsert task --graph my-graph --id 123 --no-status \ + --no-priority"; + |]) + Upsert_task "Upsert task"; + meta + ~examples: + (Vec.of_array + [| + "logseq upsert asset --graph my-graph --path ./assets/logo.png \ + --target-page Home"; + "logseq upsert asset --graph my-graph --id 123 --content \ + \"Updated asset title\""; + |]) + Upsert_asset "Upsert asset"; + meta + ~examples: + (Vec.of_array + [| + "logseq upsert tag --graph my-graph --name project"; + "logseq upsert tag --graph my-graph --id 200 --name Project \ + Renamed"; + "logseq upsert tag --graph my-graph --name project \ + --add-properties '[\"status\" \"owner\"]'"; + |]) + Upsert_tag "Upsert tag"; + meta + ~examples: + (Vec.of_array + [| + "logseq upsert property --graph my-graph --name status --type \ + default --cardinality one"; + "logseq upsert property --graph my-graph --id 321 --hide true"; + |]) + Upsert_property "Upsert property"; + |] + +let repo = function + | Upsert_block (Block_create a) -> a.repo + | Upsert_block (Block_update a) -> a.repo + | Upsert_page { repo; _ } + | Upsert_task { repo; _ } + | Upsert_asset { repo; _ } + | Upsert_tag { repo; _ } + | Upsert_property { repo; _ } -> + repo + +let graph = function + | Upsert_block (Block_create a) -> a.graph + | Upsert_block (Block_update a) -> a.graph + | Upsert_page { graph; _ } + | Upsert_task { graph; _ } + | Upsert_asset { graph; _ } + | Upsert_tag { graph; _ } + | Upsert_property { graph; _ } -> + graph + +let execute action config = + let (Output.Mode.Packed mode) = Output_mode.for_config config in + execute_with_mode action config mode diff --git a/cli/lib/ustring.ml b/cli/lib/ustring.ml new file mode 100644 index 0000000..5f976f9 --- /dev/null +++ b/cli/lib/ustring.ml @@ -0,0 +1,38 @@ +type t = string +type uint8_array +type text_decoder + +external char_code_at : string -> int -> int = "charCodeAt" [@@mel.send] +external uint8_array_from : string -> (string -> int) -> uint8_array = "from" +[@@mel.scope "Uint8Array"] + +external make_text_decoder : string -> < fatal : bool > Js.t -> text_decoder + = "TextDecoder" +[@@mel.new] + +external decode : text_decoder -> uint8_array -> string = "decode" [@@mel.send] + +type string_kind = Ascii | Utf8_bytes | Unicode + +let decoder = make_text_decoder "utf-8" [%obj { fatal = true }] + +let string_kind value = + let length = String.length value in + let rec loop index = + if index >= length then Ascii + else + let code = char_code_at value index in + if code > 0xff then Unicode + else if code > 0x7f then Utf8_bytes + else loop (index + 1) + in + loop 0 + +let of_string value = + match string_kind value with + | Ascii | Unicode -> value + | Utf8_bytes -> + let payload = uint8_array_from value (fun ch -> char_code_at ch 0) in + (try decode decoder payload with _ -> value) + +let to_string value = value diff --git a/cli/lib/uuid_refs_types.ml b/cli/lib/uuid_refs_types.ml new file mode 100644 index 0000000..4c8a5a5 --- /dev/null +++ b/cli/lib/uuid_refs_types.ml @@ -0,0 +1,198 @@ +type uuid_label = { + uuid : Cli_primitive.uuid; + id : Cli_primitive.db_id option; + label : string option; +} + +let uuid_ref_max_depth = 10 +let lower_uuid uuid = String.lowercase_ascii uuid + +let unique_preserve_order values = + let rec loop seen acc values = + match Vec.pop_front values with + | None -> acc + | Some (value, rest) -> + if Vec.mem value seen then loop seen acc rest + else loop (Vec.push_front seen value) (Vec.push_back acc value) rest + in + loop Vec.empty Vec.empty values + +let find_substring_from ~needle haystack start = + let needle_len = String.length needle in + let haystack_len = String.length haystack in + let rec loop index = + if index + needle_len > haystack_len then None + else if String.sub haystack index needle_len = needle then Some index + else loop (index + 1) + in + loop start + +let extract_wiki_refs value = + let rec loop start acc = + match find_substring_from ~needle:"[[" value start with + | None -> unique_preserve_order acc + | Some open_index -> ( + let content_start = open_index + 2 in + match find_substring_from ~needle:"]]" value content_start with + | None -> unique_preserve_order acc + | Some close_index -> + let candidate = + String.sub value content_start (close_index - content_start) + in + loop (close_index + 2) (Vec.push_back acc candidate)) + in + loop 0 Vec.empty + +let extract_uuid_refs value = + value |> extract_wiki_refs + |> Vec.filter Cli_primitive.is_uuid_string + |> Vec.map lower_uuid |> unique_preserve_order + +let label_lookup labels uuid = + let uuid = lower_uuid uuid in + Vec.find_map + (fun (candidate, label) -> + if lower_uuid candidate = uuid then Some label else None) + labels + +let replace_uuid_refs_once value labels = + let buffer = Buffer.create (String.length value) in + let rec loop start changed = + match find_substring_from ~needle:"[[" value start with + | None -> + Buffer.add_substring buffer value start (String.length value - start); + (Buffer.contents buffer, changed) + | Some open_index -> ( + Buffer.add_substring buffer value start (open_index - start); + let content_start = open_index + 2 in + match find_substring_from ~needle:"]]" value content_start with + | None -> + Buffer.add_substring buffer value open_index + (String.length value - open_index); + (Buffer.contents buffer, changed) + | Some close_index -> ( + let candidate = + String.sub value content_start (close_index - content_start) + in + match label_lookup labels candidate with + | Some label when Cli_primitive.is_uuid_string candidate -> + Buffer.add_string buffer ("[[" ^ label ^ "]]"); + loop (close_index + 2) true + | _ -> + Buffer.add_substring buffer value open_index + (close_index + 2 - open_index); + loop (close_index + 2) changed)) + in + loop 0 false + +let replace_uuid_refs text labels = + let rec loop remaining current = + if remaining <= 0 || Vec.is_empty labels then current + else + let next, changed = replace_uuid_refs_once current labels in + if changed then loop (remaining - 1) next else current + in + loop uuid_ref_max_depth text + +let collect_uuid_refs_from_strings values = + values |> Vec.concat_map extract_uuid_refs |> unique_preserve_order + +let field_text field = Edn_util.keyword_to_string field + +let string_field item field = + Edn_util.get_string item.Entity.raw (field_text field) + +let collect_uuid_refs_from_items items fields = + items + |> Vec.concat_map (fun item -> Vec.filter_map (string_field item) fields) + |> collect_uuid_refs_from_strings + +let normalize_entity_field item field labels = + match string_field item field with + | None -> item + | Some value -> + let value = replace_uuid_refs value labels in + let field = field_text field in + let raw = Edn_util.assoc field (Edn_util.string value) item.Entity.raw in + let item = { item with Entity.raw } in + if field = "block/title" then { item with title = Some value } + else if field = "block/name" || field = "name" then + { item with name = Some value } + else item + +let normalize_item_string_fields items fields labels = + Vec.map + (fun item -> + Vec.fold_left + (fun item field -> normalize_entity_field item field labels) + item fields) + items + +let kw value = Edn_util.keyword value +let vector_vec values = Edn_util.vector_vec values + +let uuid_lookup_selector = + vector_vec + (Vec.of_array + [| kw "db/id"; kw "block/uuid"; kw "block/title"; kw "block/name" |]) + +let uuid_lookup uuid = + vector_vec (Vec.of_array [| kw "block/uuid"; Edn_util.uuid uuid |]) + +let value_uuid value = + match + Option.bind (Edn_util.get value "block/uuid") Edn_util.as_string_like + with + | Some uuid when Cli_primitive.is_uuid_string uuid -> Some (lower_uuid uuid) + | _ -> None + +let label_of_value value = + match Edn_util.get_string value "block/title" with + | Some title when String.trim title <> "" -> Some title + | _ -> ( + match Edn_util.get_string value "block/name" with + | Some name when String.trim name <> "" -> Some name + | _ -> value_uuid value) + +let fetch_uuid_entities config repo uuids = + let uuids = + uuids + |> Vec.filter Cli_primitive.is_uuid_string + |> Vec.map lower_uuid |> unique_preserve_order + in + let open Cli_effect in + bind (Server_runtime.ensure_server config repo ~create_empty_db:false) + (function + | Error _ -> pure Vec.empty + | Ok invoke_config -> + let rec pull acc uuids = + match Vec.pop_front uuids with + | None -> pure acc + | Some (uuid, rest) -> + bind + (Transport.thread_api_pull invoke_config ~repo + ~selector: + (Edn_util.expect_vector_t "uuid lookup selector" + uuid_lookup_selector) + ~lookup:(uuid_lookup uuid)) + (fun value -> + let acc = + match value_uuid value with + | None -> acc + | Some uuid -> + Vec.push_back acc + { + uuid; + id = Edn_util.get_int64 value "db/id"; + label = label_of_value value; + } + in + pull acc rest) + in + pull Vec.empty uuids) + +let fetch_uuid_labels config repo uuids = + Cli_effect.map + (Vec.filter_map (fun entry -> + Option.map (fun label -> (entry.uuid, label)) entry.label)) + (fetch_uuid_entities config repo uuids) diff --git a/cli/lib/vec.ml b/cli/lib/vec.ml new file mode 100644 index 0000000..d5ca00b --- /dev/null +++ b/cli/lib/vec.ml @@ -0,0 +1,14 @@ +include Rrbvec + +let string_concat sep values = String.concat sep (to_list values) + +let split_on_char sep value = + let len = String.length value in + let rec loop start index acc = + if index = len then push_back acc (String.sub value start (index - start)) + else if value.[index] = sep then + let acc = push_back acc (String.sub value start (index - start)) in + loop (index + 1) (index + 1) acc + else loop start (index + 1) acc + in + loop 0 0 empty diff --git a/cli/logseq-cli.opam b/cli/logseq-cli.opam new file mode 100644 index 0000000..d71a6f9 --- /dev/null +++ b/cli/logseq-cli.opam @@ -0,0 +1,47 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "A short synopsis" +description: "A longer description" +maintainer: ["Maintainer Name "] +authors: ["Author Name "] +license: "LICENSE" +tags: ["add topics" "to describe" "your" "project"] +homepage: "https://github.com/username/reponame" +doc: "https://url/to/documentation" +bug-reports: "https://github.com/username/reponame/issues" +depends: [ + "dune" {>= "3.23"} + "ocaml" {>= "5.1.1"} + "melange" + "melange-fetch" + "melange-transit-melange" + "melange-edn-melange" + "melange-fest" + "rrbvec" + "humanize" + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/username/reponame.git" +x-maintenance-intent: ["(latest)"] +pin-depends: [ + ["melange-edn-core.0.5.0" "git+https://github.com/RCmerci/melange-edn.git#main"] + ["melange-edn-melange.0.5.0" "git+https://github.com/RCmerci/melange-edn.git#main"] + ["melange-transit-core.0.1.0" "git+https://github.com/RCmerci/melange-transit.git#main"] + ["melange-transit-melange.0.1.0" "git+https://github.com/RCmerci/melange-transit.git#main"] + ["humanize.dev" "git+https://github.com/rcmerci/humanize.git#main"] + ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#main"] +] diff --git a/cli/logseq-cli.opam.template b/cli/logseq-cli.opam.template new file mode 100644 index 0000000..f259467 --- /dev/null +++ b/cli/logseq-cli.opam.template @@ -0,0 +1,8 @@ +pin-depends: [ + ["melange-edn-core.0.5.0" "git+https://github.com/RCmerci/melange-edn.git#main"] + ["melange-edn-melange.0.5.0" "git+https://github.com/RCmerci/melange-edn.git#main"] + ["melange-transit-core.0.1.0" "git+https://github.com/RCmerci/melange-transit.git#main"] + ["melange-transit-melange.0.1.0" "git+https://github.com/RCmerci/melange-transit.git#main"] + ["humanize.dev" "git+https://github.com/rcmerci/humanize.git#main"] + ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#main"] +] diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..c81b7c4 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,14 @@ +{ + "name": "logseq-cli", + "private": true, + "scripts": { + "bundle": "opam exec -- dune build @bundle", + "test": "opam exec -- dune runtest" + }, + "devDependencies": { + "vite": "^8.0.16" + }, + "dependencies": { + "transit-js": "0.8.874" + } +} diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml new file mode 100644 index 0000000..d8334e8 --- /dev/null +++ b/cli/pnpm-lock.yaml @@ -0,0 +1,505 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + transit-js: + specifier: 0.8.874 + version: 0.8.874 + devDependencies: + vite: + specifier: ^8.0.16 + version: 8.0.16 + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + transit-js@0.8.874: + resolution: {integrity: sha512-IDJJGKRzUbJHmN0P15HBBa05nbKor3r2MmG6aSt0UxXIlJZZKcddTk67/U7WyAeW9Hv/VYI02IqLzolsC4sbPA==} + engines: {node: '>= 0.10.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + transit-js@0.8.874: {} + + tslib@2.8.1: + optional: true + + vite@8.0.16: + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/cli/spec/README.md b/cli/spec/README.md new file mode 100644 index 0000000..9dce36b --- /dev/null +++ b/cli/spec/README.md @@ -0,0 +1,26 @@ +# Spec Layout + +The spec virtual library is organized by capability boundary instead of an +abstract layer number. + +Dependency direction: + +```text +core -> domain -> runtime/formatting -> commands -> cli +``` + +Rules: + +- `core` contains primitive shared contracts and must not depend on other spec + groups. +- `domain` contains Logseq domain model contracts and may depend on `core`. +- `runtime` contains external runtime boundaries such as transport, server, and + auth state. It may depend on `core` and `domain`. +- `formatting` contains presentation and shared formatting contracts. It may + depend on `core` and `domain`. +- `commands` contains command contracts. It may depend on `core`, `domain`, + `runtime`, and `formatting`. +- `cli` contains top-level CLI assembly contracts. It may depend on lower groups. +- Command modules should not depend on each other. Move shared command data to + `domain` or a clearly named shared contract before adding cross-command + dependencies. diff --git a/cli/spec/cli/cli.mli b/cli/spec/cli/cli.mli new file mode 100644 index 0000000..099d926 --- /dev/null +++ b/cli/spec/cli/cli.mli @@ -0,0 +1,68 @@ +type run_input = { + argv : string Rrbvec.t; + env : (string * string) Rrbvec.t; + cwd : Cli_primitive.path; + stdin : string option; +} +(** Top-level CLI lifecycle contract. *) + +val env_lookup : (string * string) Rrbvec.t -> Cli_config.env + +type raw_argv +type parsed_argv +type resolved_config +type built_action +type executed_action +type error +type final +type not_final + +type app_context = private { + registry : Command_registry.t; + defaults : Cli_config.defaults; +} + +type ('phase, 'final) state = private + | Raw_argv_state : run_input -> (raw_argv, not_final) state + | Parsed_argv_state : + (run_input * Cli_request.t) + -> (parsed_argv, not_final) state + | Resolved_config_state : + (Cli_config.t * Cli_request.t) + -> (resolved_config, not_final) state + | Built_action_state : + (Cli_config.t * Cli_action.t) + -> (built_action, not_final) state + | Executed_action_state : + (Cli_config.t * Cli_result.t) + -> (executed_action, final) state + | Error_state : Error.t -> (error, final) state + +val make_app_context : ?version:string -> unit -> app_context +val make_error_state : Error.t -> (error, final) state + +val make_raw_argv_state : + app_context -> run_input -> (raw_argv, not_final) state + +val parse_args : + app_context -> + (raw_argv, _) state -> + (parsed_argv, not_final) state Error.build_result + +val resolve_config : + app_context -> + (parsed_argv, _) state -> + (resolved_config, not_final) state Error.build_result Cli_effect.t + +val build_action : + app_context -> + (resolved_config, _) state -> + (built_action, not_final) state Error.build_result Cli_effect.t + +val execute_action : + app_context -> + (built_action, _) state -> + (executed_action, final) state Error.build_result Cli_effect.t + +val format_result : app_context -> (executed_action, final) state -> string +val final_effect : (_, final) state -> int Cli_effect.t diff --git a/cli/spec/cli/cli_action.mli b/cli/spec/cli/cli_action.mli new file mode 100644 index 0000000..551bbf0 --- /dev/null +++ b/cli/spec/cli/cli_action.mli @@ -0,0 +1,24 @@ +type t = + | Version + | Graph of Graph.action + | List of List_command.action + | Upsert of Upsert.action + | Remove of Remove.action + | Search of Search.action + | Query of Query.action + | Show of Show.action + | Server of Server_command.action + | Sync of Sync.action + | Auth of Auth_command.action + | Agent of Agent.action + | Doctor of Doctor.action + | Debug of Debug.action + | Skill of Skill.action + | Completion of Completion.action + | Example of Example.action + +val build : Cli_config.t -> Cli_request.t -> t Error.build_result Cli_effect.t +val execute : t -> Cli_config.t -> Cli_result.t Cli_effect.t +val requires_missing_graph : t -> bool +val repo : t -> Cli_primitive.repo option +val graph : t -> Cli_primitive.graph option diff --git a/cli/spec/cli/cli_config.mli b/cli/spec/cli/cli_config.mli new file mode 100644 index 0000000..124af4b --- /dev/null +++ b/cli/spec/cli/cli_config.mli @@ -0,0 +1,64 @@ +type env = string -> string option + +type defaults = { + timeout_span : Time.span; + login_timeout_span : Time.span; + logout_timeout_span : Time.span; + list_title_max_display_width : int; + root_dir : Cli_primitive.path; + ws_url : Cli_primitive.url; + http_base : Cli_primitive.url; +} + +type t = { + graph : Cli_primitive.graph option; + repo : Cli_primitive.repo option; + root_dir : Cli_primitive.path; + config_path : Cli_primitive.path; + timeout_span : Time.span; + login_timeout_span : Time.span; + logout_timeout_span : Time.span; + list_title_max_display_width : int; + output_format : Output.Mode.packed option; + verbose : bool; + profile : bool; + ws_url : Cli_primitive.url option; + http_base : Cli_primitive.url option; + auth_path : Cli_primitive.path option; + id_token : string option; + access_token : string option; + refresh_token : string option; + base_url : Cli_primitive.url option; + owner_source : Cli_primitive.owner_source; + project_dir : Cli_primitive.path option; + raw_file_config : Melange_edn_melange.any option; + profile_session : Profile_types.session option; +} + +type source = Defaults | Env | File of Cli_primitive.path | Argv +type resolved = { config : t; sources : source Rrbvec.t } + +val default_root_dir : unit -> Cli_primitive.path +val default_config_path : Cli_primitive.path -> Cli_primitive.path +val defaults : unit -> defaults + +val read_config_file : + Cli_primitive.path -> Melange_edn_melange.any option Error.build_result + +val env_config : env -> Melange_edn_melange.map Melange_edn_melange.t +val sanitize_file_config : Melange_edn_melange.any -> Melange_edn_melange.any + +val resolve : + defaults:defaults -> + env:env -> + Global_opts.t -> + resolved Error.build_result Cli_effect.t + +val update_config : + t -> + Melange_edn_melange.any -> + Melange_edn_melange.map Melange_edn_melange.t Error.build_result Cli_effect.t + +val graph_to_repo : Cli_primitive.graph -> Cli_primitive.repo +val repo_to_graph : Cli_primitive.repo -> Cli_primitive.graph +val pick_graph : t -> Global_opts.t -> Cli_primitive.graph option diff --git a/cli/spec/cli/cli_request.mli b/cli/spec/cli/cli_request.mli new file mode 100644 index 0000000..a7bca37 --- /dev/null +++ b/cli/spec/cli/cli_request.mli @@ -0,0 +1,34 @@ +type parsed_command = + | Version + | Graph of Graph.parsed + | List of List_command.parsed + | Upsert of Upsert.parsed + | Remove of Remove.parsed + | Search of Search.parsed + | Query of Query.parsed + | Show of Show.parsed + | Server of Server_command.parsed + | Sync of Sync.parsed + | Auth of Auth_command.parsed + | Agent of Agent.parsed + | Doctor of Doctor.parsed + | Debug of Debug.parsed + | Skill of Skill.parsed + | Completion of Completion.parsed + | Example of Example.parsed + +type t = { + globals : Global_opts.t; + path : string Rrbvec.t; + command : parsed_command; + raw_args : string Rrbvec.t; +} + +val make : + globals:Global_opts.t -> + path:string Rrbvec.t -> + command:parsed_command -> + raw_args:string Rrbvec.t -> + t + +val command_id : t -> Command_id.t diff --git a/cli/spec/cli/cli_result.mli b/cli/spec/cli/cli_result.mli new file mode 100644 index 0000000..9803fce --- /dev/null +++ b/cli/spec/cli/cli_result.mli @@ -0,0 +1,42 @@ +type status = Ok | Error + +type ok_data = + | Message of string + | Items of Melange_edn_melange.any Rrbvec.t + | Entity of Melange_edn_melange.any + | Query_result of Melange_edn_melange.any + | Raw of Melange_edn_melange.any + | Empty + +type error_data = Error.t + +type t = private { + status : status; + data : ok_data option; + error : error_data option; + command : Command_id.t option; + context : Melange_edn_melange.any option; + output : 'o. 'o Output.Mode.t -> 'o Output.t; + exit_code : int option; + human_table_headers_order : string Rrbvec.t; +} + +val ok : + ?command:Command_id.t -> + ?context:Melange_edn_melange.any -> + 'o Output.Mode.t -> + ok_data -> + t + +val error : + ?command:Command_id.t -> + ?context:Melange_edn_melange.any -> + 'o Output.Mode.t -> + Error.t -> + t + +val is_error : t -> bool +val exit_code : t -> int +val data_value : t -> Melange_edn_melange.any option +val with_command : Command_id.t -> t -> t +val with_human_table_headers_order : string Rrbvec.t -> t -> t diff --git a/cli/spec/cli/command_id.mli b/cli/spec/cli/command_id.mli new file mode 100644 index 0000000..615528b --- /dev/null +++ b/cli/spec/cli/command_id.mli @@ -0,0 +1,69 @@ +type t = + | Version + | Graph_list + | Graph_create + | Graph_switch + | Graph_remove + | Graph_validate + | Graph_info + | Graph_backup_list + | Graph_backup_create + | Graph_backup_restore + | Graph_backup_remove + | Graph_export + | Graph_import + | Server_list + | Server_cleanup + | Server_start + | Server_stop + | Server_restart + | List_page + | List_tag + | List_property + | List_task + | List_node + | List_asset + | Search_block + | Search_page + | Search_property + | Search_tag + | Upsert_block + | Upsert_page + | Upsert_task + | Upsert_asset + | Upsert_tag + | Upsert_property + | Remove_block + | Remove_page + | Remove_tag + | Remove_property + | Query + | Query_list + | Show + | Debug_pull + | Doctor + | Sync_status + | Sync_start + | Sync_stop + | Sync_upload + | Sync_download + | Sync_asset_download + | Sync_remote_graphs + | Sync_ensure_keys + | Sync_grant_access + | Sync_config_get + | Sync_config_set + | Sync_config_unset + | Login + | Logout + | Completion + | Skill_show + | Skill_install + | Example + | Agent_bridge + +val to_string : t -> string +val to_path : t -> string Rrbvec.t +val is_write : t -> bool +val requires_graph : t -> bool +val requires_auth : t -> bool diff --git a/cli/spec/cli/command_registry.mli b/cli/spec/cli/command_registry.mli new file mode 100644 index 0000000..bc359f0 --- /dev/null +++ b/cli/spec/cli/command_registry.mli @@ -0,0 +1,51 @@ +type category = + | Graph_inspect_and_edit + | Graph_management + | Authentication + | Utilities + | Hidden + +type option_arity = Flag | Required_value of string | Optional_value of string + +type option_meta = { + names : string Rrbvec.t; + arity : option_arity; + doc : string; + required : bool; + repeatable : bool; + choices : string Rrbvec.t; + (* value is a comma-separated list; choices complete each list element *) + multi : bool; + default : string option; +} + +type command_meta = { + id : Command_id.t; + path : string Rrbvec.t; + doc : string; + long_doc : string option; + examples : string Rrbvec.t; + options : option_meta Rrbvec.t; + category : category; + requires_graph : bool; + requires_auth : bool; + write_command : bool; + (* headers order when print table in human output *) + human_table_headers_order : string Rrbvec.t; +} + +type group_meta = { + name : string; + doc : string; + category : category; + children : string Rrbvec.t Rrbvec.t; +} + +type t = { commands : command_meta Rrbvec.t; groups : group_meta Rrbvec.t } + +val empty : t +val make : command_meta Rrbvec.t -> t +val add : command_meta -> t -> t +val find_by_path : string Rrbvec.t -> t -> command_meta option +val global_options : option_meta Rrbvec.t +val render_help : ?group:string Rrbvec.t -> t -> string diff --git a/cli/spec/cli/command_spec.mli b/cli/spec/cli/command_spec.mli new file mode 100644 index 0000000..ab8fc28 --- /dev/null +++ b/cli/spec/cli/command_spec.mli @@ -0,0 +1,19 @@ +(** Shared command lifecycle signature for CLI command families. *) + +module type S = sig + type parsed + type action + + val command_id : parsed -> Command_id.t + val validate_parsed : parsed -> unit Error.build_result + + val build : + ?registry:Command_registry.t -> + Cli_config.t -> + Global_opts.t -> + parsed -> + action Error.build_result + + val execute : action -> Cli_config.t -> Cli_result.t Cli_effect.t + val metadata : unit -> Command_registry.command_meta Rrbvec.t +end diff --git a/cli/spec/cli/global_opts.mli b/cli/spec/cli/global_opts.mli new file mode 100644 index 0000000..e5c8f2e --- /dev/null +++ b/cli/spec/cli/global_opts.mli @@ -0,0 +1,20 @@ +type t = private { + graph : Cli_primitive.graph option; + root_dir : Cli_primitive.path option; + config_path : Cli_primitive.path option; + timeout_span : Time.span option; + output_format : Output.Mode.packed option; + verbose : bool; + profile : bool; +} + +val create : + ?graph:Cli_primitive.graph -> + ?root_dir:Cli_primitive.path -> + ?config_path:Cli_primitive.path -> + ?timeout_span:Time.span -> + ?output_format:Output.Mode.packed -> + ?verbose:bool -> + ?profile:bool -> + unit -> + t diff --git a/cli/spec/commands/agent.mli b/cli/spec/commands/agent.mli new file mode 100644 index 0000000..6f6451f --- /dev/null +++ b/cli/spec/commands/agent.mli @@ -0,0 +1,36 @@ +type parsed = Parsed_bridge + +type action = + | Agent_bridge of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + +type routable_reason = + | Missing_stable_uuid + | Missing_task_tag + | Not_todo + | Assignee_mismatch + | Already_routed + +type routable_decision = Routable | Not_routable of routable_reason +type template_kind = Task | Comment + +type prompt_template = { + kind : template_kind; + body : string; + required_vars : string Rrbvec.t; + allowed_vars : string Rrbvec.t; +} + +type bridge_result = { + mode : Cli_primitive.keyword; + graph : Cli_primitive.graph; + agent_name : string; + routed : Melange_edn_melange.any Rrbvec.t; +} + +include Command_spec.S with type parsed := parsed and type action := action + +val resolve_agent_name : + Cli_config.t -> string option -> string Error.build_result + +val routable_task_decision : Entity.t -> agent_name:string -> routable_decision +val validate_prompt_template : prompt_template -> unit Error.build_result diff --git a/cli/spec/commands/auth_command.mli b/cli/spec/commands/auth_command.mli new file mode 100644 index 0000000..04a674a --- /dev/null +++ b/cli/spec/commands/auth_command.mli @@ -0,0 +1,4 @@ +type parsed = Parsed_login | Parsed_logout +type action = Login | Logout + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/completion.mli b/cli/spec/commands/completion.mli new file mode 100644 index 0000000..2b20cbf --- /dev/null +++ b/cli/spec/commands/completion.mli @@ -0,0 +1,12 @@ +type opts = { shell : Cli_primitive.shell option } +type parsed = Parsed_completion of opts + +type action = + | Completion of { + shell : Cli_primitive.shell; + registry : Command_registry.t option; + } + +val generate : Cli_primitive.shell -> Command_registry.t -> string + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/debug.mli b/cli/spec/commands/debug.mli new file mode 100644 index 0000000..04991b0 --- /dev/null +++ b/cli/spec/commands/debug.mli @@ -0,0 +1,25 @@ +type selector = + | By_id of Cli_primitive.db_id + | By_uuid of Cli_primitive.uuid + | By_ident of Cli_primitive.keyword + +type opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + ident : Cli_primitive.keyword option; +} + +type parsed = Parsed_pull of opts + +type action = + | Debug_pull of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + lookup : selector; + selector : Melange_edn_melange.any; + } + +val parse_ident_option : string -> Cli_primitive.keyword Error.build_result +val resolve_selector : opts -> selector Error.build_result + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/doctor.mli b/cli/spec/commands/doctor.mli new file mode 100644 index 0000000..eec5b70 --- /dev/null +++ b/cli/spec/commands/doctor.mli @@ -0,0 +1,22 @@ +type opts = { dev_script : bool } +type parsed = Parsed_doctor of opts +type action = Doctor of { script_path : Cli_primitive.path option } +type check_status = Ok | Warning | Error + +type check = { + id : Cli_primitive.keyword; + status : check_status; + code : Error.code option; + message : string; + path : Cli_primitive.path option; + servers : Melange_edn_melange.any Rrbvec.t; + raw : Melange_edn_melange.any option; +} + +type report = { status : check_status; checks : check Rrbvec.t } + +val check_db_worker_script : action -> check +val check_root_dir : Cli_config.t -> check +val check_running_servers : Cli_config.t -> check Cli_effect.t + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/example.mli b/cli/spec/commands/example.mli new file mode 100644 index 0000000..97427ca --- /dev/null +++ b/cli/spec/commands/example.mli @@ -0,0 +1,15 @@ +type opts = { selector : string Rrbvec.t } +type parsed = Parsed_example of opts + +type action = { + selector : string; + selector_path : string Rrbvec.t; + matched_commands : string Rrbvec.t; + examples : string Rrbvec.t; + message : string; +} + +val resolve_selector : + Command_registry.t -> string Rrbvec.t -> action Error.build_result + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/graph.mli b/cli/spec/commands/graph.mli new file mode 100644 index 0000000..c858817 --- /dev/null +++ b/cli/spec/commands/graph.mli @@ -0,0 +1,92 @@ +type export_type = Edn | Sqlite +type import_type = Import_edn | Import_sqlite +type create_opts = { enable_sync : bool; e2ee_password : string option } +type validate_opts = { fix : bool } +type backup_create_opts = { name : string option } +type backup_restore_opts = { src : string; dst : Cli_primitive.graph } +type backup_remove_opts = { src : string } + +type export_opts = { + export_type : export_type; + file : Cli_primitive.path option; + edn_options : Melange_edn_melange.any option; + pretty_print : bool; + include_timestamps : bool; + exclude_built_in_pages : bool; + exclude_namespaces : string Rrbvec.t; +} + +type import_opts = { import_type : import_type; input : Cli_primitive.path } + +type parsed = + | Parsed_list + | Parsed_create of create_opts + | Parsed_switch + | Parsed_remove + | Parsed_validate of validate_opts + | Parsed_info + | Parsed_backup_list + | Parsed_backup_create of backup_create_opts + | Parsed_backup_restore of backup_restore_opts + | Parsed_backup_remove of backup_remove_opts + | Parsed_export of export_opts + | Parsed_import of import_opts + +type action = + | Graph_list + | Graph_create of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + opts : create_opts; + } + | Graph_switch of { graph : Cli_primitive.graph; repo : Cli_primitive.repo } + | Graph_remove of { graph : Cli_primitive.graph; repo : Cli_primitive.repo } + | Graph_validate of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + fix : bool; + } + | Graph_info of { graph : Cli_primitive.graph; repo : Cli_primitive.repo } + | Graph_backup_list of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + } + | Graph_backup_create of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + name : string option; + backup_name : string option; + } + | Graph_backup_restore of { + source_repo : Cli_primitive.repo; + source_graph : Cli_primitive.graph; + dst_repo : Cli_primitive.repo; + dst_graph : Cli_primitive.graph; + src : string; + dst : string; + } + | Graph_backup_remove of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + src : string; + } + | Graph_export of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + opts : export_opts; + } + | Graph_import of { + graph : Cli_primitive.graph; + repo : Cli_primitive.repo; + opts : import_opts; + require_missing_graph : bool; + } + +val normalize_export_type : string -> export_type option +val normalize_import_type : string -> import_type option +val string_of_import_type : import_type -> string + +include Command_spec.S with type parsed := parsed and type action := action + +val repo : action -> Cli_primitive.repo option +val graph : action -> Cli_primitive.graph option diff --git a/cli/spec/commands/list_command.mli b/cli/spec/commands/list_command.mli new file mode 100644 index 0000000..eff95c2 --- /dev/null +++ b/cli/spec/commands/list_command.mli @@ -0,0 +1,79 @@ +type order = Asc | Desc + +type common_opts = { + fields : string Rrbvec.t option; + limit : int option; + offset : int option; + sort : string option; + order : order option; +} + +type page_opts = { + common : common_opts; + expand : bool; + include_built_in : bool option; + include_journal : bool option; + journal_only : bool; + include_hidden : bool; + updated_after : Time.date option; + created_after : Time.date option; +} + +type tag_opts = { + common : common_opts; + expand : bool; + include_built_in : bool option; + with_properties : bool; + with_extends : bool; +} + +type property_opts = { + common : common_opts; + expand : bool; + include_built_in : bool option; + with_classes : bool; + with_type : bool; +} + +type task_opts = { + common : common_opts; + status : string option; + priority : string option; + content : string option; +} + +type node_opts = { + common : common_opts; + tags : string Rrbvec.t; + properties : string Rrbvec.t; +} + +type asset_opts = { common : common_opts } + +type parsed = + | Parsed_page of page_opts + | Parsed_tag of tag_opts + | Parsed_property of property_opts + | Parsed_task of task_opts + | Parsed_node of node_opts + | Parsed_asset of asset_opts + +type kind = Page | Tag | Property | Task | Node | Asset + +type action = { + kind : kind; + command : Command_id.t; + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + options : Melange_edn_melange.any; +} + +type list_result = { items : Entity.t Rrbvec.t } + +val order_of_string : string -> order option +val string_of_order : order -> string +val kind_of_parsed : parsed -> kind +val normalize_options : parsed -> parsed Error.build_result +val apply_offset_limit : common_opts -> Entity.t Rrbvec.t -> Entity.t Rrbvec.t + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/query.mli b/cli/spec/commands/query.mli new file mode 100644 index 0000000..9c345fb --- /dev/null +++ b/cli/spec/commands/query.mli @@ -0,0 +1,48 @@ +type input_spec = { + name : string; + optional : bool; + default : Melange_edn_melange.any option; +} + +type source = Built_in | Custom + +type query_entry = { + name : string; + source : source; + doc : string option; + inputs : input_spec Rrbvec.t; + query : Melange_edn_melange.any; +} + +type opts = { + query_edn : string option; + name : string option; + inputs_edn : string option; +} + +type parsed = Parsed_run of opts | Parsed_list + +type action = + | Run of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + query : Melange_edn_melange.any; + inputs : Melange_edn_melange.any Rrbvec.t; + name : string option; + } + | List + +val built_in_queries : query_entry Rrbvec.t +val normalize_query_name : string -> string option +val list_queries : Cli_config.t -> query_entry Rrbvec.t +val find_query : Cli_config.t -> string -> query_entry option + +val validate_query : + Melange_edn_melange.any -> Melange_edn_melange.any Error.build_result + +val normalize_inputs : + query_entry option -> + Melange_edn_melange.any Rrbvec.t -> + Melange_edn_melange.any Rrbvec.t Error.build_result + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/remove.mli b/cli/spec/commands/remove.mli new file mode 100644 index 0000000..88fc277 --- /dev/null +++ b/cli/spec/commands/remove.mli @@ -0,0 +1,45 @@ +type block_opts = { id_raw : string option; uuid : Cli_primitive.uuid option } +type page_opts = { id : Cli_primitive.db_id option; page : string option } + +type named_entity_opts = { + id : Cli_primitive.db_id option; + name : string option; +} + +type parsed = + | Parsed_block of block_opts + | Parsed_page of page_opts + | Parsed_tag of named_entity_opts + | Parsed_property of named_entity_opts + +type action = + | Remove_block of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + ids : Cli_primitive.db_id Rrbvec.t; + multi_id : bool; + uuid : Cli_primitive.uuid option; + } + | Remove_page of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + page : string option; + } + | Remove_tag of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + name : string option; + } + | Remove_property of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + name : string option; + } + +val invalid_options : parsed -> string option + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/search.mli b/cli/spec/commands/search.mli new file mode 100644 index 0000000..eba4863 --- /dev/null +++ b/cli/spec/commands/search.mli @@ -0,0 +1,20 @@ +type scope = Block | Page | Property | Tag +type opts = { content : string } + +type parsed = + | Parsed_block of opts + | Parsed_page of opts + | Parsed_property of opts + | Parsed_tag of opts + +type action = { + scope : scope; + command : Command_id.t; + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + query : string; +} + +val scope_of_parsed : parsed -> scope + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/server_command.mli b/cli/spec/commands/server_command.mli new file mode 100644 index 0000000..7bc8c37 --- /dev/null +++ b/cli/spec/commands/server_command.mli @@ -0,0 +1,15 @@ +type parsed = + | Parsed_list + | Parsed_cleanup + | Parsed_start + | Parsed_stop + | Parsed_restart + +type action = + | Server_list + | Server_cleanup + | Server_start of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Server_stop of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Server_restart of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/show.mli b/cli/spec/commands/show.mli new file mode 100644 index 0000000..4e80e69 --- /dev/null +++ b/cli/spec/commands/show.mli @@ -0,0 +1,45 @@ +type target = + | By_id of Cli_primitive.db_id + | By_ids of Cli_primitive.db_id Rrbvec.t + | By_uuid of Cli_primitive.uuid + | By_page of string + +type opts = { + id_raw : string option; + uuid : Cli_primitive.uuid option; + page : string option; + page_hierarchy : bool; + linked_references : bool option; + ref_id_footer : bool option; + level : int option; + stdin_id : string option; +} + +type parsed = Parsed_show of opts + +type action = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + target : target; + multi_id : bool; + linked_references : bool; + ref_id_footer : bool; + page_hierarchy : bool; + level : int option; +} + +type linked_references = { count : int; blocks : Block.t Rrbvec.t } + +type tree_data = { + root : Block.t; + linked_references : linked_references option; + referenced_uuids : Cli_primitive.uuid Rrbvec.t; + uuid_to_entity : (Cli_primitive.uuid * Entity.t) Rrbvec.t; + breadcrumb_line : string option; +} + +val normalize_stdin_id : string option -> string option +val resolve_target : opts -> target Error.build_result +val invalid_options : opts -> string option + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/skill.mli b/cli/spec/commands/skill.mli new file mode 100644 index 0000000..3faf8fb --- /dev/null +++ b/cli/spec/commands/skill.mli @@ -0,0 +1,37 @@ +type install_opts = { global : bool } +type parsed = Parsed_show | Parsed_install of install_opts + +type action = + | Skill_show of { source_path : Cli_primitive.path option } + | Skill_install of { + global : bool; + source_path : Cli_primitive.path option; + destination_dir : Cli_primitive.path option; + destination_file : Cli_primitive.path option; + } + +type install_target = { + scope : Cli_primitive.keyword; + path : Cli_primitive.path; + update_command : string; +} + +type update_status = { + installed : bool; + outdated : bool; + outdated_targets : install_target Rrbvec.t; + error : Error.t option; +} + +val resolve_install_target : + global:bool -> + cwd:Cli_primitive.path -> + home_dir:Cli_primitive.path option -> + install_target Error.build_result + +val installed_skill_targets : + cwd:Cli_primitive.path -> + home_dir:Cli_primitive.path option -> + install_target Rrbvec.t + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/sync.mli b/cli/spec/commands/sync.mli new file mode 100644 index 0000000..5510f22 --- /dev/null +++ b/cli/spec/commands/sync.mli @@ -0,0 +1,93 @@ +type config_key = Ws_url | Http_base +type start_opts = { e2ee_password : string option } +type upload_opts = { e2ee_password : string option } +type download_opts = { progress : bool option; e2ee_password : string option } + +type asset_download_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; +} + +type ensure_keys_opts = { e2ee_password : string option; upload_keys : bool } + +type grant_access_opts = { + graph_id : Cli_primitive.uuid option; + email : Cli_primitive.email option; +} + +type config_get_opts = { key : config_key option } +type config_set_opts = { key : config_key option; value : string option } +type config_unset_opts = { key : config_key option } + +type parsed = + | Parsed_status + | Parsed_start of start_opts + | Parsed_stop + | Parsed_upload of upload_opts + | Parsed_download of download_opts + | Parsed_asset_download of asset_download_opts + | Parsed_remote_graphs + | Parsed_ensure_keys of ensure_keys_opts + | Parsed_grant_access of grant_access_opts + | Parsed_config_get of config_get_opts + | Parsed_config_set of config_set_opts + | Parsed_config_unset of config_unset_opts + +type action = + | Sync_status of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Sync_start of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + e2ee_password : string option; + } + | Sync_stop of { repo : Cli_primitive.repo; graph : Cli_primitive.graph } + | Sync_upload of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + e2ee_password : string option; + } + | Sync_download of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + progress : bool; + progress_explicit : bool; + e2ee_password : string option; + allow_missing_graph : bool; + require_missing_graph : bool; + } + | Sync_asset_download of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + } + | Sync_remote_graphs + | Sync_ensure_keys of { e2ee_password : string option; upload_keys : bool } + | Sync_grant_access of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + graph_id : Cli_primitive.uuid; + email : Cli_primitive.email; + } + | Sync_config_get of { key : config_key } + | Sync_config_set of { key : config_key; value : string } + | Sync_config_unset of { key : config_key } + +type remote_graph = { + graph_id : Cli_primitive.uuid; + graph_name : Cli_primitive.graph; + graph_e2ee : bool; + raw : Melange_edn_melange.any; +} + +type sync_status = { + ws_state : Cli_primitive.keyword option; + graph_id : Cli_primitive.uuid option; + last_error : Melange_edn_melange.any option; + raw : Melange_edn_melange.any; +} + +val config_key_of_string : string -> config_key option +val string_of_config_key : config_key -> string + +include Command_spec.S with type parsed := parsed and type action := action diff --git a/cli/spec/commands/upsert.mli b/cli/spec/commands/upsert.mli new file mode 100644 index 0000000..a12f2cc --- /dev/null +++ b/cli/spec/commands/upsert.mli @@ -0,0 +1,180 @@ +type mode = Create | Update | Page + +type block_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + content : string option; + blocks_edn : string option; + blocks_file : Cli_primitive.path option; + update_tags_edn : string option; + update_properties_edn : string option; + remove_tags_edn : string option; + remove_properties_edn : string option; +} + +type page_opts = { + id : Cli_primitive.db_id option; + page : string option; + restore : bool; + update_tags_edn : string option; + update_properties_edn : string option; + remove_tags_edn : string option; + remove_properties_edn : string option; +} + +type task_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + page : string option; + content : string option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + status : string option; + priority : string option; + scheduled : Time.date option; + deadline : Time.date option; + no_status : bool; + no_priority : bool; + no_scheduled : bool; + no_deadline : bool; +} + +type asset_opts = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + path : Cli_primitive.path option; + target_id : Cli_primitive.db_id option; + target_uuid : Cli_primitive.uuid option; + target_page : string option; + pos : Block.position option; + content : string option; +} + +type tag_opts = { + id : Cli_primitive.db_id option; + name : string option; + add_properties_edn : string option; + remove_properties_edn : string option; +} + +type property_opts = { + id : Cli_primitive.db_id option; + name : string option; + kind : Property.kind option; + cardinality : Property.cardinality option; + hide : bool option; + public : bool option; +} + +type parsed = + | Parsed_block of block_opts + | Parsed_page of page_opts + | Parsed_task of task_opts + | Parsed_asset of asset_opts + | Parsed_tag of tag_opts + | Parsed_property of property_opts + +type block_target = + | Target_id of Cli_primitive.db_id + | Target_uuid of Cli_primitive.uuid + | Target_page of string + +type block_source = + | Source_id of Cli_primitive.db_id + | Source_uuid of Cli_primitive.uuid + +type block_create = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + target : block_target; + pos : Block.position; + status : Cli_primitive.keyword option; + tags : Selector.tag Rrbvec.t; + properties : Property.assignment Rrbvec.t; + blocks : Block.t Rrbvec.t; + update_plan : Property.update_plan; +} + +type block_update = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + source : block_source; + target : block_target option; + pos : Block.position option; + update_tags : Selector.tag Rrbvec.t; + update_properties : Property.assignment Rrbvec.t; + remove_tags : Selector.tag Rrbvec.t; + remove_properties : Property.key Rrbvec.t; + content : string option; + source_label : string option; + target_label : string option; +} + +type block_action = + | Block_create of block_create + | Block_update of block_update + +type action = + | Upsert_block of block_action + | Upsert_page of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + page : string option; + restore : bool; + plan : Property.update_plan; + } + | Upsert_task of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + page : string option; + content : string option; + update_properties : Property.assignment Rrbvec.t; + clear_properties : Property.key Rrbvec.t; + status_input : string option; + } + | Upsert_asset of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + path : Cli_primitive.path option; + content : string option; + create_action : block_create option; + } + | Upsert_tag of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + name : string option; + add_properties : Property.key Rrbvec.t; + remove_properties : Property.key Rrbvec.t; + } + | Upsert_property of { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph; + mode : mode; + id : Cli_primitive.db_id option; + name : string option; + schema : Property.schema; + } + +val update_mode : block_opts -> bool +val invalid_options : parsed -> string option + +include Command_spec.S with type parsed := parsed and type action := action + +val repo : action -> Cli_primitive.repo +val graph : action -> Cli_primitive.graph diff --git a/cli/spec/core/cli_primitive.mli b/cli/spec/core/cli_primitive.mli new file mode 100644 index 0000000..e09c162 --- /dev/null +++ b/cli/spec/core/cli_primitive.mli @@ -0,0 +1,46 @@ +(** Shared primitive aliases used by every layer. *) + +type keyword = Melange_edn_melange.keyword Melange_edn_melange.t +type graph = private string +type repo = private string +type db_id = int64 +type uuid = string +type path = string +type url = string +type email = string +type shell = Bash | Zsh +type port = int +type pid = int +type owner_source = Cli | Electron | Unknown | Other of string + +type ds_where_clause = + | V of Melange_edn_melange.vector Melange_edn_melange.t + | L of Melange_edn_melange.list_ Melange_edn_melange.t + +type datascript_query = private { + find : Melange_edn_melange.any Rrbvec.t; + in_ : Melange_edn_melange.symbol Melange_edn_melange.t Rrbvec.t option; + where : ds_where_clause Rrbvec.t; +} + +val create_graph : string -> graph + +val create_repo : string -> repo +(** repo is a string with prefix "logseq_db_" *) + +val string_of_graph : graph -> string +val string_of_repo : repo -> string +val non_empty : string -> string option +val is_uuid_string : string -> bool +val shell_of_string : string -> shell option +val string_of_owner_source : owner_source -> string + +val make_datascript_query : + find:Melange_edn_melange.any Rrbvec.t -> + ?in_:Melange_edn_melange.symbol Melange_edn_melange.t Rrbvec.t -> + where:ds_where_clause Rrbvec.t -> + unit -> + datascript_query + +val datascript_query_to_edn : + datascript_query -> Melange_edn_melange.vector Melange_edn_melange.t diff --git a/cli/spec/core/error.mli b/cli/spec/core/error.mli new file mode 100644 index 0000000..bd32396 --- /dev/null +++ b/cli/spec/core/error.mli @@ -0,0 +1,176 @@ +(** Shared error model. *) + +type code = + | Add_id_resolution_failed + | Agent_bridge_already_running + | Agent_bridge_lock_failed + | Agent_master_prompt_invalid + | Agent_name_invalid + | Agent_prompt_template_invalid + | Ambiguous_page_name + | Ambiguous_property_name + | Ambiguous_tag_name + | Asset_checksum_failed + | Asset_checksum_missing + | Asset_create_failed + | Asset_file_copy_failed + | Asset_file_not_found + | Asset_not_found + | Asset_not_remote + | Asset_tag_not_found + | Asset_type_missing + | Asset_uuid_missing + | Auth_code_exchange_failed + | Auth_file_delete_failed + | Auth_file_write_failed + | Auth_refresh_failed + | Backup_create_failed + | Backup_not_found + | Block_link_cycle + | Block_link_target_not_found + | Block_not_found + | Browser_open_failed + | Codex_not_found + | Codex_session_id_missing + | Codex_start_failed + | Config_write_failed + | Doctor_error + | Doctor_script_missing + | Doctor_script_unreadable + | Doctor_server_not_ready + | E2ee_password_failed + | E2ee_password_not_found + | Entity_not_found + | Exception + | External_asset + | Graph_already_exists + | Graph_db_not_empty + | Graph_exists + | Graph_not_exists + | Graph_not_removed + | Graph_validation_failed + | Invalid_auth_file + | Invalid_auth_token + | Invalid_blocks + | Invalid_callback_request + | Invalid_callback_state + | Invalid_config + | Invalid_options + | Invalid_property_target + | Invalid_query + | Invalid_source + | Invalid_tag_target + | Invalid_target + | Login_callback_error + | Login_callback_not_found + | Login_callback_server_start_failed + | Login_not_configured + | Login_timeout + | Logout_not_configured + | Missing_asset_selector + | Missing_auth + | Missing_callback_code + | Missing_content + | Missing_dst + | Missing_examples + | Missing_graph + | Missing_id_token + | Missing_page_name + | Missing_property_name + | Missing_query + | Missing_query_text + | Missing_repo + | Missing_snapshot + | Missing_source + | Missing_tag_name + | Missing_target + | Missing_template_code_block + | Missing_template_vars + | Not_asset + | Not_implemented + | Page_hierarchy_parent_cycle + | Page_not_found + | Property_built_in + | Property_hidden + | Property_not_found + | Recycled_page + | Remote_graph_not_found + | Root_dir_permission + | Server_cleanup_failed + | Server_not_found + | Server_owned_by_other + | Server_revision_mismatch_after_restart + | Server_script_missing + | Server_start_failed + | Server_start_timeout_orphan + | Server_stop_timeout + | Skill_home_dir_unavailable + | Skill_install_failed + | Skill_show_failed + | Skill_source_not_found + | Source_not_found + | Spec_blocker + | Sync_not_started + | Sync_remote_graphs_failed + | Sync_start_runtime_error + | Sync_start_timeout + | Sync_upload_failed + | Tag_built_in + | Tag_create_not_tag + | Tag_hidden + | Tag_name_conflict + | Tag_not_found + | Tag_rename_conflict + | Target_not_found + | Task_tag_not_found + | Unknown_command + | Unknown_query + | Unknown_template_vars + | Unsupported_input_format + | Unsupported_output_format + | Upsert_id_not_found + | Upsert_id_type_mismatch + +val code_to_string : code -> string +val code_to_keyword : code -> Cli_primitive.keyword + +type candidate = { id : Cli_primitive.db_id option; name : string option } + +type t = { + code : code; + message : string; + hint : string option; + candidates : candidate Rrbvec.t; + context : Melange_edn_melange.any option; +} + +type 'a build_result = ('a, t) result + +type source = + | Cli_parse + | Config + | Build_action + | Transport + | Server + | Auth + | Sync + | Db_worker + | Filesystem + | Unknown + +val make : + ?hint:string -> + ?candidates:candidate Rrbvec.t -> + ?context:Melange_edn_melange.any -> + code -> + string -> + t + +val invalid_options : string -> t +val missing_graph : unit -> t +val missing_repo : string -> t +val missing_target : string -> t +val unknown_command : string -> t +val exception_error : ?context:Melange_edn_melange.any -> exn -> t +val map : ('a -> 'b) -> 'a build_result -> 'b build_result +val bind : 'a build_result -> ('a -> 'b build_result) -> 'b build_result diff --git a/cli/spec/core/output.mli b/cli/spec/core/output.mli new file mode 100644 index 0000000..59056c8 --- /dev/null +++ b/cli/spec/core/output.mli @@ -0,0 +1,35 @@ +type human +type json +type edn + +module Mode : sig + type _ t = Human : human t | Json : json t | Edn : edn t + and packed = Packed : _ t -> packed + + val default : human t + val of_string : string -> packed option + val to_string : 'a t -> string + val structured : 'a t -> bool +end + +module Human_output : sig + type t + + val create : + ?headers:string Rrbvec.t -> + ?footer:string -> + rows:string Rrbvec.t Rrbvec.t -> + unit -> + t + + val to_string : t -> string + (** format as table. + - columns need to be aligned + - empty row value display as '-' + - humanize created-at/updated-at *) +end + +type _ t = + | Human : Human_output.t -> human t + | Edn : Melange_edn_melange.any -> edn t + | Json : Melange_edn_melange.any -> json t diff --git a/cli/spec/core/time.mli b/cli/spec/core/time.mli new file mode 100644 index 0000000..6d2a584 --- /dev/null +++ b/cli/spec/core/time.mli @@ -0,0 +1,25 @@ +type span = float +type date = Js.Date.t + +val now : unit -> date +val epoch : date +val max_time : date +val time_of_epoch_ms : int64 -> date +val time_to_epoch_ms : date -> int64 +val time_to_epoch_seconds : date -> int64 +val time_to_epoch_seconds_float : date -> float +val compare_time : date -> date -> int +val span_of_ms : int64 -> span +val span_to_ms : span -> int64 +val zero_span : span +val compare_span : span -> span -> int +val add_span_value : span -> span -> span +val add_span : date -> span -> date option +val local_date : date -> int * int * int +val utc_date_time : date -> int * int * int * int * int * int +val non_negative_diff : start_time:date -> end_time:date -> span +val avg_span : span -> int -> span +val rfc3339_millis : date -> string +val parse_rfc3339 : string -> date option +val parse_date_as_utc : string -> date option +val parse_time : string -> date option diff --git a/cli/spec/core/ustring.mli b/cli/spec/core/ustring.mli new file mode 100644 index 0000000..9852895 --- /dev/null +++ b/cli/spec/core/ustring.mli @@ -0,0 +1,8 @@ +type t = private string +(** unicode string *) + +val of_string : string -> t +(** convert utf8-encoded byte string to unicode string *) + +val to_string : t -> string +(** identity *) diff --git a/cli/spec/domain/block.mli b/cli/spec/domain/block.mli new file mode 100644 index 0000000..eff7fb9 --- /dev/null +++ b/cli/spec/domain/block.mli @@ -0,0 +1,27 @@ +type position = First_child | Last_child | Sibling + +type t = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + title : string option; + name : string option; + order : int option; + parent : Selector.block option; + page : Selector.page option; + tags : Selector.tag Rrbvec.t; + properties : Property.assignment Rrbvec.t; + children : t Rrbvec.t; + raw : Melange_edn_melange.any; +} + +type tree = { root : t } + +val position_of_string : string -> position option + +val make : + ?uuid:Cli_primitive.uuid -> ?title:string -> ?children:t Rrbvec.t -> unit -> t + +val of_value : Melange_edn_melange.any -> t +val to_value : t -> Melange_edn_melange.map Melange_edn_melange.t +val flatten : t Rrbvec.t -> t Rrbvec.t +val label : t -> string option diff --git a/cli/spec/domain/entity.mli b/cli/spec/domain/entity.mli new file mode 100644 index 0000000..552327a --- /dev/null +++ b/cli/spec/domain/entity.mli @@ -0,0 +1,17 @@ +type kind = Page | Block | Tag | Property | Task | Asset | Unknown + +type t = { + id : Cli_primitive.db_id option; + uuid : Cli_primitive.uuid option; + ident : Cli_primitive.keyword option; + name : string option; + title : string option; + kind : kind; + tags : Cli_primitive.keyword Rrbvec.t; + created_at : Time.date option; + updated_at : Time.date option; + deleted_at : Time.date option; + raw : Melange_edn_melange.any; +} + +val of_value : Melange_edn_melange.any -> t diff --git a/cli/spec/domain/graph_types.mli b/cli/spec/domain/graph_types.mli new file mode 100644 index 0000000..d27e3c0 --- /dev/null +++ b/cli/spec/domain/graph_types.mli @@ -0,0 +1,11 @@ +type graph_item_kind = Canonical | Legacy | Legacy_undecodable + +type graph_item = { + kind : graph_item_kind; + graph_name : Cli_primitive.graph option; + graph_dir : Cli_primitive.path option; + legacy_dir : Cli_primitive.path option; + target_graph_dir : Cli_primitive.path option; + conflict : bool; + reason : Cli_primitive.keyword option; +} diff --git a/cli/spec/domain/id_parse.mli b/cli/spec/domain/id_parse.mli new file mode 100644 index 0000000..3b5aea1 --- /dev/null +++ b/cli/spec/domain/id_parse.mli @@ -0,0 +1,5 @@ +type parsed = { ids : Cli_primitive.db_id Rrbvec.t; multi : bool } + +val valid_id : Cli_primitive.db_id -> bool +val parse_id_string : string -> parsed Error.build_result +val to_single : parsed -> Cli_primitive.db_id option diff --git a/cli/spec/domain/property.mli b/cli/spec/domain/property.mli new file mode 100644 index 0000000..36ab89d --- /dev/null +++ b/cli/spec/domain/property.mli @@ -0,0 +1,42 @@ +type key = + | Key_ident of Cli_primitive.keyword + | Key_id of Cli_primitive.db_id + | Key_name of string + +type cardinality = One | Many + +type kind = + | Default + | Page + | Class + | Property + | Entity + | Node + | Date + | Url + | Checkbox + | Number + | Template + | Other of Cli_primitive.keyword + +type schema = { + kind : kind option; + cardinality : cardinality option; + hidden : bool option; + public : bool option; +} + +type assignment = { key : key; value : Melange_edn_melange.any } + +type update_plan = { + update_tags : Selector.tag Rrbvec.t; + remove_tags : Selector.tag Rrbvec.t; + update_properties : assignment Rrbvec.t; + remove_properties : key Rrbvec.t; +} + +val empty_update_plan : update_plan +val parse_key : Melange_edn_melange.any -> key option +val kind_of_string : string -> kind option +val string_of_kind : kind -> string +val cardinality_of_string : string -> cardinality option diff --git a/cli/spec/domain/selector.mli b/cli/spec/domain/selector.mli new file mode 100644 index 0000000..18779b8 --- /dev/null +++ b/cli/spec/domain/selector.mli @@ -0,0 +1,26 @@ +type block = + | Block_id of Cli_primitive.db_id + | Block_uuid of Cli_primitive.uuid + +type page = + | Page_id of Cli_primitive.db_id + | Page_name of string + | Page_uuid of Cli_primitive.uuid + +type property = + | Property_id of Cli_primitive.db_id + | Property_ident of Cli_primitive.keyword + | Property_name of string + | Property_uuid of Cli_primitive.uuid + +type tag = + | Tag_id of Cli_primitive.db_id + | Tag_name of string + | Tag_ident of Cli_primitive.keyword + | Tag_uuid of Cli_primitive.uuid + +type entity = + | Entity_id of Cli_primitive.db_id + | Entity_uuid of Cli_primitive.uuid + | Entity_ident of Cli_primitive.keyword + | Entity_name of string diff --git a/cli/spec/domain/task_status.mli b/cli/spec/domain/task_status.mli new file mode 100644 index 0000000..64cfcf1 --- /dev/null +++ b/cli/spec/domain/task_status.mli @@ -0,0 +1,12 @@ +type status = { ident : Cli_primitive.keyword; value : string } + +val status_closed_values_query : + Melange_edn_melange.vector Melange_edn_melange.t + +val normalize_available_statuses : + Melange_edn_melange.any Rrbvec.t -> status Rrbvec.t + +val resolve_status_ident : + string -> status Rrbvec.t -> Cli_primitive.keyword option + +val invalid_status_message : string -> status Rrbvec.t -> string diff --git a/cli/spec/dune b/cli/spec/dune new file mode 100644 index 0000000..5b877f5 --- /dev/null +++ b/cli/spec/dune @@ -0,0 +1,79 @@ +(include_subdirs unqualified) + +(library + (name logseq_cli_platform_spec) + (wrapped false) + (modes melange) + (libraries melange-fetch rrbvec) + (modules Cli_effect Cli_platform Cli_unix) + (virtual_modules Cli_effect Cli_platform Cli_unix)) + +(library + (name logseq_cli_spec) + (wrapped false) + (modes melange) + (libraries + melange.js + melange-fetch + melange-edn-melange + rrbvec + logseq_cli_platform_spec) + (modules + (:standard \ Cli_effect Cli_platform Cli_unix)) + (modules_without_implementation Command_spec) + (virtual_modules + ; core + Cli_primitive + Error + Output + Time + Ustring + ; domain + Selector + Property + Entity + Block + Id_parse + Task_status + Graph_types + ; runtime + Transport + Server_runtime + Auth_state + ; formatting + Profile_types + Log_types + Style_types + Humanize_types + Root_dir_types + Uuid_refs_types + Tree_text_types + Db_worker_types + Common_cli_types + Pretty_print + ; cli + Global_opts + Cli_config + Cli_result + Cli_request + Cli_action + Cli + Command_id + Command_registry + ; commands + Graph + List_command + Upsert + Remove + Search + Query + Show + Server_command + Sync + Auth_command + Agent + Doctor + Debug + Skill + Completion + Example)) diff --git a/cli/spec/formatting/common_cli_types.mli b/cli/spec/formatting/common_cli_types.mli new file mode 100644 index 0000000..e69de29 diff --git a/cli/spec/formatting/db_worker_types.mli b/cli/spec/formatting/db_worker_types.mli new file mode 100644 index 0000000..b4124d0 --- /dev/null +++ b/cli/spec/formatting/db_worker_types.mli @@ -0,0 +1,19 @@ +type list_options = Melange_edn_melange.any + +type page_filter = { + expand : bool; + include_hidden : bool; + include_built_in : bool; + include_journal : bool; + journal_only : bool; + created_after : Time.date option; + updated_after : Time.date option; +} + +type node_filter = { + tag_ids : Cli_primitive.db_id Rrbvec.t; + property_idents : Cli_primitive.keyword Rrbvec.t; +} + +val minimal_list_item : + Entity.t -> Melange_edn_melange.map Melange_edn_melange.t diff --git a/cli/spec/formatting/humanize_types.mli b/cli/spec/formatting/humanize_types.mli new file mode 100644 index 0000000..1d6b6b3 --- /dev/null +++ b/cli/spec/formatting/humanize_types.mli @@ -0,0 +1,5 @@ +val format_count : int -> string +val pluralize_noun : int -> string -> string +val format_count_with_noun : int -> string -> string +val datetime : now:int64 -> ?suffix:string -> ?prefix:string -> int64 -> string +val relative_datetime : then_time:Time.date -> now_time:Time.date -> string diff --git a/cli/spec/formatting/log_types.mli b/cli/spec/formatting/log_types.mli new file mode 100644 index 0000000..ed444ee --- /dev/null +++ b/cli/spec/formatting/log_types.mli @@ -0,0 +1,13 @@ +type preview = { preview : string; length : int; truncated : bool } +type level = Debug | Info | Warn | Error + +type record = { + time : Time.date option; + level : level; + logger : string option; + message : string; + exception_ : string option; +} + +val truncate_preview : ?max_len:int -> Melange_edn_melange.any -> preview +val install_stderr_handler : unit -> unit diff --git a/cli/spec/formatting/pretty_print.mli b/cli/spec/formatting/pretty_print.mli new file mode 100644 index 0000000..26b3ac1 --- /dev/null +++ b/cli/spec/formatting/pretty_print.mli @@ -0,0 +1,2 @@ +val pprint_edn : Melange_edn_melange.any -> string +val pprint_json : Js.Json.t -> string diff --git a/cli/spec/formatting/profile_types.mli b/cli/spec/formatting/profile_types.mli new file mode 100644 index 0000000..faaeab8 --- /dev/null +++ b/cli/spec/formatting/profile_types.mli @@ -0,0 +1,33 @@ +type span = { + stage : string; + span_id : int; + start_time : Time.date; + end_time : Time.date; + elapsed_span : Time.span; +} + +type stage_summary = { + stage : string; + count : int; + total_span : Time.span; + avg_span : Time.span; +} + +type session + +type report = { + command : string; + status : Cli_primitive.keyword; + total_span : Time.span; + spans : span Rrbvec.t; + stages : stage_summary Rrbvec.t; +} + +val create_session : bool -> session option + +val time : + session option -> string -> (unit -> 'a Cli_effect.t) -> 'a Cli_effect.t + +val record_span : session -> span -> unit +val report : session -> command:string -> status:Cli_primitive.keyword -> report +val render_lines : report -> string Rrbvec.t diff --git a/cli/spec/formatting/root_dir_types.mli b/cli/spec/formatting/root_dir_types.mli new file mode 100644 index 0000000..36cd8e7 --- /dev/null +++ b/cli/spec/formatting/root_dir_types.mli @@ -0,0 +1,5 @@ +val default_root_dir : unit -> Cli_primitive.path +val normalize_root_dir : Cli_primitive.path option -> Cli_primitive.path + +val ensure_root_dir : + Cli_primitive.path option -> Cli_primitive.path Error.build_result diff --git a/cli/spec/formatting/style_types.mli b/cli/spec/formatting/style_types.mli new file mode 100644 index 0000000..e69de29 diff --git a/cli/spec/formatting/tree_text_types.mli b/cli/spec/formatting/tree_text_types.mli new file mode 100644 index 0000000..d02a239 --- /dev/null +++ b/cli/spec/formatting/tree_text_types.mli @@ -0,0 +1,6 @@ +type render_options = { + uuid_labels : (Cli_primitive.uuid * string) Rrbvec.t; + property_titles : (Cli_primitive.keyword * string) Rrbvec.t; + property_value_labels : (Melange_edn_melange.any * string) Rrbvec.t; + show_ids : bool; +} diff --git a/cli/spec/formatting/uuid_refs_types.mli b/cli/spec/formatting/uuid_refs_types.mli new file mode 100644 index 0000000..4efe6a2 --- /dev/null +++ b/cli/spec/formatting/uuid_refs_types.mli @@ -0,0 +1,38 @@ +type uuid_label = { + uuid : Cli_primitive.uuid; + id : Cli_primitive.db_id option; + label : string option; +} + +val unique_preserve_order : 'a Rrbvec.t -> 'a Rrbvec.t +val extract_wiki_refs : string -> string Rrbvec.t +val extract_uuid_refs : string -> Cli_primitive.uuid Rrbvec.t + +val replace_uuid_refs : + string -> (Cli_primitive.uuid * string) Rrbvec.t -> string + +val collect_uuid_refs_from_strings : + string Rrbvec.t -> Cli_primitive.uuid Rrbvec.t + +val collect_uuid_refs_from_items : + Entity.t Rrbvec.t -> + Cli_primitive.keyword Rrbvec.t -> + Cli_primitive.uuid Rrbvec.t + +val normalize_item_string_fields : + Entity.t Rrbvec.t -> + Cli_primitive.keyword Rrbvec.t -> + (Cli_primitive.uuid * string) Rrbvec.t -> + Entity.t Rrbvec.t + +val fetch_uuid_entities : + Cli_config.t -> + Cli_primitive.repo -> + Cli_primitive.uuid Rrbvec.t -> + uuid_label Rrbvec.t Cli_effect.t + +val fetch_uuid_labels : + Cli_config.t -> + Cli_primitive.repo -> + Cli_primitive.uuid Rrbvec.t -> + (Cli_primitive.uuid * string) Rrbvec.t Cli_effect.t diff --git a/cli/spec/platform/cli_effect.mli b/cli/spec/platform/cli_effect.mli new file mode 100644 index 0000000..3852982 --- /dev/null +++ b/cli/spec/platform/cli_effect.mli @@ -0,0 +1,24 @@ +(** Runtime effect abstraction. *) + +type 'a t +type 'a resolver + +val pure : 'a -> 'a t +val error : exn -> 'a t +val map : ('a -> 'b) -> 'a t -> 'b t +val map_s : ('a -> 'b t) -> 'a Rrbvec.t -> 'b Rrbvec.t t +val bind : 'a t -> ('a -> 'b t) -> 'b t +val both : 'a t -> 'b t -> ('a * 'b) t +val all : 'a t Rrbvec.t -> 'a Rrbvec.t t +val catch : 'a t -> (exn -> 'a t) -> 'a t +val finally : 'a t -> (unit -> unit t) -> 'a t +val sleep : float -> unit t +val wait : unit -> 'a t * 'a resolver +val wakeup : 'a resolver -> 'a -> unit +val is_pending : 'a t -> bool +val async : (unit -> unit t) -> unit +val on_any : 'a t -> ('a -> unit) -> (exn -> unit) -> unit + +module Infix : sig + val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t +end diff --git a/cli/spec/platform/cli_platform.mli b/cli/spec/platform/cli_platform.mli new file mode 100644 index 0000000..8393b19 --- /dev/null +++ b/cli/spec/platform/cli_platform.mli @@ -0,0 +1,50 @@ +val hostname : unit -> string +val argv : unit -> string array + +type login_callback_request = { target : string option } +type login_callback_response = { status : int; body : string } + +type login_callback_server_error = + | Login_callback_timeout + | Login_callback_server_start_failed of string + | Login_callback_server_aborted of string + +val login_callback_server : + host:string -> + port:int -> + timeout_span:float -> + on_listen:(unit -> (unit, string) result Cli_effect.t) -> + handle_request:(login_callback_request -> login_callback_response * 'result) -> + ('result, login_callback_server_error) result Cli_effect.t + +module Symbols : sig + val ellipsis : string + val linked_arrow : string + val tree_last : string + val tree_middle : string + val tree_pipe : string +end + +module HTTP : sig + val request : + ?timeout_span:float -> + Fetch.requestMethod -> + string -> + headers:(string * string) Rrbvec.t -> + body:string -> + (Fetch.Response.t * string) Cli_effect.t +end + +module Crypto : sig + val sha256_hex : string -> string + val sha256_base64url : string -> string + val random_base64url : int -> string + val base64url_decode : string -> string +end + +module Events : sig + type subscription = { close : unit -> unit Cli_effect.t } + + val connect : + url:string -> on_chunk:(string -> unit) -> subscription Cli_effect.t +end diff --git a/cli/spec/platform/cli_unix.mli b/cli/spec/platform/cli_unix.mli new file mode 100644 index 0000000..fca1842 --- /dev/null +++ b/cli/spec/platform/cli_unix.mli @@ -0,0 +1,53 @@ +type error = EAGAIN | EWOULDBLOCK | ETIMEDOUT | ESRCH | EPERM +type access_permission = R_OK | W_OK +type open_flag = O_RDWR +type file_descr = int +type stats = { st_size : int; st_mtime : float } +type process_result = { status : int; stdout : string; stderr : string } +type mkdir_result = Created | Already_exists + +exception Cli_unix_error of error * string * string + +val mkdir : string -> int -> unit +val rmdir : string -> unit +val mkdir_exclusive : string -> int -> mkdir_result +val file_exists : string -> bool +val is_directory : string -> bool +val readdir : string -> string array +val mkdir_p : string -> unit +val write_text_file : string -> string -> unit +val read_text_file : string -> string +val read_stdin_all : unit -> string +val write_binary_file : string -> string -> unit +val read_binary_file : string -> string +val copy_file : string -> string -> unit +val remove_tree : string -> unit +val rename : string -> string -> unit +val getpid : unit -> int +val process_running : int -> bool +val chmod : string -> int -> unit +val access : string -> access_permission Rrbvec.t -> unit +val stat : string -> stats +val environment : unit -> string array +val gethostname : unit -> string +val openfile : string -> open_flag Rrbvec.t -> int -> file_descr +val close : file_descr -> unit + +val create_process_env : + string -> + string array -> + string array -> + file_descr -> + file_descr -> + file_descr -> + int + +val run_process_capture : + string -> string Rrbvec.t -> string array -> process_result + +val start_process_capture_session_line : + string -> string Rrbvec.t -> string array -> process_result + +val kill : int -> int -> unit +val open_url : string -> bool +val write_stdout : string -> unit diff --git a/cli/spec/runtime/auth_state.mli b/cli/spec/runtime/auth_state.mli new file mode 100644 index 0000000..7674e27 --- /dev/null +++ b/cli/spec/runtime/auth_state.mli @@ -0,0 +1,46 @@ +type auth_data = { + provider : string; + id_token : string option; + access_token : string option; + refresh_token : string option; + expires_at : Time.date option; + sub : string option; + email : Cli_primitive.email option; + updated_at : Time.date; +} + +type login_result = { + auth_path : Cli_primitive.path; + authorize_url : Cli_primitive.url; + opened : bool; + email : Cli_primitive.email option; + sub : string option; + updated_at : Time.date; +} + +type logout_result = { + auth_path : Cli_primitive.path; + deleted : bool; + logout_url : Cli_primitive.url; + opened : bool; + logout_completed : bool; +} + +val default_auth_path : unit -> Cli_primitive.path +val auth_path : Cli_config.t -> Cli_primitive.path + +val read_auth_file : + Cli_config.t -> auth_data option Error.build_result Cli_effect.t + +val write_auth_file : + Cli_config.t -> auth_data -> auth_data Error.build_result Cli_effect.t + +val delete_auth_file : Cli_config.t -> unit Error.build_result Cli_effect.t +val expired_auth : auth_data -> bool + +val refresh_auth : + Cli_config.t -> auth_data -> auth_data Error.build_result Cli_effect.t + +val resolve_auth : Cli_config.t -> auth_data Error.build_result Cli_effect.t +val login : Cli_config.t -> login_result Error.build_result Cli_effect.t +val logout : Cli_config.t -> logout_result Error.build_result Cli_effect.t diff --git a/cli/spec/runtime/server_runtime.mli b/cli/spec/runtime/server_runtime.mli new file mode 100644 index 0000000..7287983 --- /dev/null +++ b/cli/spec/runtime/server_runtime.mli @@ -0,0 +1,74 @@ +type server_status = Starting | Ready | Error | Unknown + +type server = { + repo : Cli_primitive.repo; + graph : Cli_primitive.graph option; + pid : Cli_primitive.pid; + host : string; + port : Cli_primitive.port; + base_url : Cli_primitive.url; + status : server_status; + revision : string option; + root_dir : Cli_primitive.path option; + owner_source : Cli_primitive.owner_source; + owned : bool; + raw : Melange_edn_melange.any option; +} + +type start_result = { + repo : Cli_primitive.repo; + owner_source : Cli_primitive.owner_source; + owned : bool; +} + +type stop_result = { repo : Cli_primitive.repo } +type revision_mismatch = { cli_revision : string; servers : server Rrbvec.t } + +type cleanup_result = { + cli_revision : string; + checked : int; + mismatched : int; + eligible : int; + skipped_owner : int; + killed : server Rrbvec.t; + failed : (server * Error.t) Rrbvec.t; +} + +val resolve_root_dir : Cli_config.t -> Cli_primitive.path +val graphs_dir : Cli_config.t -> Cli_primitive.path + +val lock_path : + root_dir:Cli_primitive.path -> Cli_primitive.repo -> Cli_primitive.path + +val db_worker_runtime_script_path : unit -> Cli_primitive.path + +val ensure_server : + Cli_config.t -> + Cli_primitive.repo -> + create_empty_db:bool -> + Transport.invoke_config Error.build_result Cli_effect.t + +val start_server : + Cli_config.t -> + Cli_primitive.repo -> + create_empty_db:bool -> + start_result Error.build_result Cli_effect.t + +val stop_server : + Cli_config.t -> + Cli_primitive.repo -> + stop_result Error.build_result Cli_effect.t + +val restart_server : + Cli_config.t -> + Cli_primitive.repo -> + start_result Error.build_result Cli_effect.t + +val list_servers : Cli_config.t -> server Rrbvec.t Cli_effect.t +val list_graph_items : Cli_config.t -> Graph_types.graph_item Rrbvec.t +val list_graphs : Cli_config.t -> Cli_primitive.graph Rrbvec.t + +val cleanup_revision_mismatched_servers : + Cli_config.t -> + cli_revision:string -> + cleanup_result Error.build_result Cli_effect.t diff --git a/cli/spec/runtime/transport.mli b/cli/spec/runtime/transport.mli new file mode 100644 index 0000000..9168e04 --- /dev/null +++ b/cli/spec/runtime/transport.mli @@ -0,0 +1,188 @@ +type invoke_config = { + base_url : Cli_primitive.url; + timeout_span : Time.span; + profile_session : Profile_types.session option; +} + +type event_type = Cli_primitive.keyword +type event_payload = Melange_edn_melange.any +type event_subscription = { close : unit -> unit Cli_effect.t } + +val connect_events : + invoke_config -> + (event_type -> event_payload -> unit Cli_effect.t) -> + event_subscription Cli_effect.t + +val write_output : + format:Cli_primitive.keyword -> + path:Cli_primitive.path -> + data:Melange_edn_melange.any -> + unit Error.build_result Cli_effect.t + +val read_input : + format:Cli_primitive.keyword -> + path:Cli_primitive.path -> + Melange_edn_melange.any Error.build_result Cli_effect.t + +val thread_api_apply_outliner_ops : + invoke_config -> + repo:Cli_primitive.repo -> + ops:Melange_edn_melange.vector Melange_edn_melange.t -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_backup_db_sqlite : + invoke_config -> + repo:Cli_primitive.repo -> + path:Cli_primitive.path -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_cli_list_nodes : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_cli_list_pages : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_cli_list_properties : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_cli_list_tags : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_cli_list_tasks : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_create_or_open_db : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_download_graph_by_id : + invoke_config -> + repo:Cli_primitive.repo -> + graph_id:Cli_primitive.uuid -> + graph_e2ee:bool -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_ensure_user_rsa_keys : + ?options:Melange_edn_melange.map Melange_edn_melange.t -> + invoke_config -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_grant_graph_access : + invoke_config -> + repo:Cli_primitive.repo -> + graph_id:Cli_primitive.uuid -> + email:Cli_primitive.email -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_list_remote_graphs : + invoke_config -> Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_request_asset_download : + invoke_config -> + repo:Cli_primitive.repo -> + asset_uuid:Cli_primitive.uuid -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_start : + invoke_config -> + repo:Cli_primitive.repo -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_status : + invoke_config -> + repo:Cli_primitive.repo -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_stop : + invoke_config -> Melange_edn_melange.any Cli_effect.t + +val thread_api_db_sync_upload_graph : + invoke_config -> + repo:Cli_primitive.repo -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_export_edn : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_get_block_parents : + invoke_config -> + repo:Cli_primitive.repo -> + block_id:Cli_primitive.db_id -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_get_block_refs : + invoke_config -> + repo:Cli_primitive.repo -> + block_id:Cli_primitive.db_id -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_get_e2ee_password : + invoke_config -> refresh_token:string -> Melange_edn_melange.any Cli_effect.t + +val thread_api_import_db_binary : + invoke_config -> + repo:Cli_primitive.repo -> + data:Melange_edn_melange.any -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_import_edn : + invoke_config -> + repo:Cli_primitive.repo -> + data:Melange_edn_melange.any -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_pull : + invoke_config -> + repo:Cli_primitive.repo -> + selector:Melange_edn_melange.vector Melange_edn_melange.t -> + lookup:Melange_edn_melange.any -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_q : + invoke_config -> + repo:Cli_primitive.repo -> + query:Melange_edn_melange.vector Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_set_db_sync_config : + invoke_config -> + config:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_sync_app_state : + invoke_config -> + auth_state:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_validate_db : + invoke_config -> + repo:Cli_primitive.repo -> + options:Melange_edn_melange.map Melange_edn_melange.t -> + Melange_edn_melange.any Cli_effect.t + +val thread_api_verify_and_save_e2ee_password : + invoke_config -> + refresh_token:string -> + password:string -> + Melange_edn_melange.any Cli_effect.t diff --git a/cli/test/cli_parity_test_cases.ml b/cli/test/cli_parity_test_cases.ml new file mode 100644 index 0000000..057a338 --- /dev/null +++ b/cli/test/cli_parity_test_cases.ml @@ -0,0 +1,8293 @@ +open Test_support + +let expect_equal name expected actual = + if expected = actual then pass + else fail_test (Printf.sprintf "%s: expected %S, got %S" name expected actual) + +let expect_string_vec name expected actual = + if Vec.to_array actual = expected then pass + else + fail_test + (Printf.sprintf "%s: expected %S, got %S" name + (Vec.string_concat "," (Vec.of_array expected)) + (Vec.string_concat "," actual)) + +let expect_bool name expected actual = + if expected = actual then pass + else fail_test (Printf.sprintf "%s: expected %b, got %b" name expected actual) + +let expect_int name expected actual = + if expected = actual then pass + else fail_test (Printf.sprintf "%s: expected %d, got %d" name expected actual) + +let expect_int64 name expected actual = + if expected = actual then pass + else + fail_test + (Printf.sprintf "%s: expected %s, got %s" name (Int64.to_string expected) + (Int64.to_string actual)) + +external string_char_code_at : string -> int -> int = "charCodeAt" [@@mel.send] + +let expect_some name = function + | Some value -> value + | None -> + fail_test (name ^ ": expected Some value"); + assert false + +let expect_none name = function + | None -> pass + | Some _ -> fail_test (name ^ ": expected None") + +let expect_ok name = function + | Ok value -> value + | Error err -> + fail_test (name ^ ": " ^ err.Error.message); + assert false + +let expect_error_code name expected = function + | Error err -> + let normalize_code code = + if String.length code > 0 && code.[0] = ':' then + String.sub code 1 (String.length code - 1) + else code + in + expect_equal name (normalize_code expected) + (normalize_code (Error.code_to_string err.Error.code)) + | Ok _ -> fail_test (name ^ ": expected Error") + +let expect_error_context_code name expected err = + match err.Error.context with + | None -> fail_test (name ^ ": expected error context") + | Some context -> + expect_equal name expected + (expect_some (name ^ " code") + (Option.bind (Edn_util.get context "code") Edn_util.as_keyword)) + +let expect_parse_ok name argv = + match Cli_parse.parse (Vec.of_array argv) with + | Ok request -> request + | Error err -> + fail_test (name ^ ": " ^ err.Error.message); + assert false + +let expect_parse_error_code name expected argv = + expect_error_code name expected (Cli_parse.parse (Vec.of_array argv)) + +let keyword_text value = Edn_util.keyword_to_string value +let edn_of_string text = Melange_edn_melange.of_edn_string text +let repo_text repo = Cli_primitive.string_of_repo repo +let graph_text graph = Cli_primitive.string_of_graph graph + +let unicode_text codepoints = + codepoints |> Vec.of_array + |> Vec.map Js.String.fromCodePoint + |> Vec.string_concat "" + +let start_open_url_capture () : unit = + [%mel.raw + {| +(function () { + const childProcess = require("child_process"); + const capture = { + originalSpawn: childProcess.spawn, + originalPlatformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"), + calls: [] + }; + globalThis.__logseqOpenUrlCapture = capture; + childProcess.spawn = function (command, args, options) { + capture.calls.push({ command, args: Array.from(args), options }); + return { pid: 4242, unref: function () {} }; + }; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); +})() +|}] + +let stop_open_url_capture () : unit = + [%mel.raw + {| +(function () { + const capture = globalThis.__logseqOpenUrlCapture; + if (!capture) return; + const childProcess = require("child_process"); + childProcess.spawn = capture.originalSpawn; + if (capture.originalPlatformDescriptor) { + Object.defineProperty(process, "platform", capture.originalPlatformDescriptor); + } + delete globalThis.__logseqOpenUrlCapture; +})() +|}] + +let open_url_capture_calls () : string = + [%mel.raw + {| +JSON.stringify((globalThis.__logseqOpenUrlCapture && globalThis.__logseqOpenUrlCapture.calls) || []) +|}] + +let start_spawn_capture () : unit = + [%mel.raw + {| +(function () { + const childProcess = require("child_process"); + const capture = { + originalSpawn: childProcess.spawn, + calls: [] + }; + globalThis.__logseqSpawnCapture = capture; + childProcess.spawn = function (command, args, options) { + capture.calls.push({ command, args: Array.from(args), options }); + if (typeof capture.onSpawn === "function") capture.onSpawn(command, args, options); + return { pid: 4242, unref: function () {} }; + }; +})() +|}] + +let stop_spawn_capture () : unit = + [%mel.raw + {| +(function () { + const capture = globalThis.__logseqSpawnCapture; + if (!capture) return; + const childProcess = require("child_process"); + childProcess.spawn = capture.originalSpawn; + delete globalThis.__logseqSpawnCapture; +})() +|}] + +let spawn_capture_calls () : string = + [%mel.raw + {| +JSON.stringify((globalThis.__logseqSpawnCapture && globalThis.__logseqSpawnCapture.calls) || []) +|}] + +let set_spawn_capture_on_spawn_raw : string -> string -> int -> unit = + [%mel.raw + {| +function (lockPath, serverListPath, port) { + const capture = globalThis.__logseqSpawnCapture; + if (!capture) throw new Error("spawn capture is not active"); + capture.onSpawn = function () { + const fs = require("fs"); + fs.writeFileSync(lockPath, JSON.stringify({ + repo: "logseq_db_demo", + pid: process.pid, + "lock-id": "test", + "owner-source": "cli" + })); + fs.writeFileSync(serverListPath, process.pid + " " + port + "\n"); + }; +} +|}] + +let set_spawn_capture_on_spawn ~lock_path ~server_list_path ~port : unit = + set_spawn_capture_on_spawn_raw lock_path server_list_path port + +let set_env key value : unit = Js.Dict.set Node.Process.process##env key value + +let unset_env_raw : string -> unit = + [%mel.raw {| function (key) { delete process.env[key]; } |}] + +let unset_env key : unit = unset_env_raw key + +let property_key_text = function + | Property.Key_ident ident -> keyword_text ident + | Property.Key_id id -> Int64.to_string id + | Property.Key_name name -> name + +let empty_common_opts = + { + List_command.fields = None; + limit = None; + offset = None; + sort = None; + order = None; + } + +let entity ?id ?title ?name ?ident ?updated_at ?created_at () = + let add_field fields key value = + Vec.push_back fields (Edn_util.keyword key, value) + in + let raw_fields = + ( ( ( ( ( Vec.empty |> fun fields -> + match id with + | Some id -> add_field fields "db/id" (Edn_util.int64 id) + | None -> fields ) + |> fun fields -> + match title with + | Some title -> + add_field fields "block/title" (Edn_util.string title) + | None -> fields ) + |> fun fields -> + match name with + | Some name -> add_field fields "block/name" (Edn_util.string name) + | None -> fields ) + |> fun fields -> + match ident with + | Some ident -> add_field fields "db/ident" (Edn_util.keyword ident) + | None -> fields ) + |> fun fields -> + match updated_at with + | Some updated_at -> + add_field fields "block/updated-at" (Edn_util.int64 updated_at) + | None -> fields ) + |> fun fields -> + match created_at with + | Some created_at -> + add_field fields "block/created-at" (Edn_util.int64 created_at) + | None -> fields + in + Entity.of_value (Edn_util.map_vec raw_fields) + +let span stage span_id start_ms end_ms = + let start_time = Time.time_of_epoch_ms (Int64.of_int start_ms) in + let end_time = Time.time_of_epoch_ms (Int64.of_int end_ms) in + { + Profile_types.stage; + span_id; + start_time; + end_time; + elapsed_span = Time.non_negative_diff ~start_time ~end_time; + } + +let stage_count stage summaries = + summaries + |> Vec.find_map (fun (summary : Profile_types.stage_summary) -> + if summary.stage = stage then Some summary.count else None) + +let config ?graph ?repo ?output_format ?auth_path ?id_token ?access_token + ?refresh_token ?raw_file_config ?list_title_max_display_width + ?(root_dir = "/tmp/logseq-cli-test") () = + let defaults = Cli_config.defaults () in + { + Cli_config.graph = Option.map Cli_primitive.create_graph graph; + repo = Option.map Cli_primitive.create_repo repo; + root_dir; + config_path = Node.Path.join [| root_dir; "cli.edn" |]; + timeout_span = defaults.timeout_span; + login_timeout_span = defaults.login_timeout_span; + logout_timeout_span = defaults.logout_timeout_span; + list_title_max_display_width = + Option.value list_title_max_display_width + ~default:defaults.list_title_max_display_width; + output_format; + verbose = false; + profile = false; + ws_url = Some defaults.ws_url; + http_base = Some defaults.http_base; + auth_path; + id_token; + access_token; + refresh_token; + base_url = None; + owner_source = Cli_primitive.Cli; + project_dir = None; + raw_file_config; + profile_session = None; + } + +let config_with_output config mode = + { config with Cli_config.output_format = Some (Output.Mode.Packed mode) } + +let execute_with_output execute action config mode = + execute action (config_with_output config mode) + +let effect_result name task = + let value = ref None in + let error = ref None in + Cli_effect.on_any task + (fun result -> value := Some result) + (fun exn -> error := Some exn); + match (!value, !error) with + | Some value, None -> value + | None, Some exn -> + fail_test (name ^ ": " ^ Printexc.to_string exn); + assert false + | None, None -> + fail_test (name ^ ": effect is still pending"); + assert false + | Some _, Some _ -> + fail_test (name ^ ": effect resolved and rejected"); + assert false + +let effect_to_promise task = + Js.Promise.make (fun ~resolve ~reject -> + Cli_effect.on_any task + (fun value -> (resolve value [@u])) + (fun exn -> (reject exn [@u]))) + +type lifecycle_output = { + stdout : string option; + stderr : string Rrbvec.t; + exit_code : int; +} + +let run_cli_lifecycle ?(env = [||]) argv = + let result = spawn_cli ~env argv in + let stderr = + let stderr = string_trim_end result##stderr in + if stderr = "" then Vec.empty else Vec.of_array (string_split stderr "\n") + in + { stdout = Some result##stdout; stderr; exit_code = result_status result } + +let stdout_text name output = expect_some name output.stdout + +let invoke_args body = + match Json_util.object_of_json_string body with + | Some object_ -> ( + match + Option.bind (Js.Dict.get object_ "argsTransit") Js.Json.decodeString + with + | Some args -> Transit_melange.Transit.Json.(of_string args |> to_edn) + | None -> + fail_test ("missing invoke argsTransit: " ^ body); + Edn_util.vector_vec Vec.empty) + | None -> + fail_test ("invalid invoke JSON body: " ^ body); + Edn_util.vector_vec Vec.empty + +let invoke_arg_string body index = + let args = + expect_some "invoke args vector" (Edn_util.as_seq (invoke_args body)) + in + expect_some + ("invoke arg " ^ string_of_int index) + (Edn_util.as_string (Vec.nth args index)) + +let sample_auth ?(id_token = Some "id-token-1") + ?(access_token = Some "access-token-1") + ?(refresh_token = Some "refresh-token-1") ?expires_at + ?(updated_at = 1_735_686_000_000L) ?(sub = Some "user-123") + ?(email = Some "user@example.com") () : Auth_state.auth_data = + { + provider = "cognito"; + id_token; + access_token; + refresh_token; + expires_at; + sub; + email; + updated_at = Time.time_of_epoch_ms updated_at; + } + +let env_from_pairs pairs key = Vec.assoc_opt key (Vec.of_array pairs) + +let resolve_config ?(env = [||]) globals = + let resolved = + expect_ok "resolve config" + (effect_result "resolve config" + (Cli_config.resolve ~defaults:(Cli_config.defaults ()) + ~env:(env_from_pairs env) globals)) + in + resolved.Cli_config.config + +let mode_text (Output.Mode.Packed mode) = Output.Mode.to_string mode + +let agent_task_entity ?(uuid = Some "11111111-1111-4111-8111-111111111111") + ?(tags = [| "logseq.class/Task" |]) ?(status = Some "todo") + ?(assignees = [| "build-host" |]) ?session_id () = + let tag_values = + tags |> Vec.of_array + |> Vec.map (fun ident -> + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "db/ident", Edn_util.keyword ident); + (Edn_util.keyword "block/title", Edn_util.string "Task"); + |])) + in + let assignee_values = + assignees |> Vec.of_array + |> Vec.map (fun title -> + Edn_util.map_vec + (Vec.of_array + [| (Edn_util.keyword "block/title", Edn_util.string title) |])) + in + let fields = + Vec.of_array + [| + (Edn_util.keyword "db/id", Edn_util.int64 42L); + (Edn_util.keyword "block/title", Edn_util.string "Ship the CLI bridge"); + (Edn_util.keyword "block/tags", Edn_util.vector_vec tag_values); + ( Edn_util.keyword "logseq.property/assignee", + Edn_util.vector_vec assignee_values ); + |] + in + let fields = + match uuid with + | Some uuid -> + Vec.push_front fields + (Edn_util.keyword "block/uuid", Edn_util.string uuid) + | None -> fields + in + let fields = + match status with + | Some status -> + Vec.push_front fields + ( Edn_util.keyword "logseq.property/status", + Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "db/ident", + Edn_util.keyword ("logseq.property/status." ^ status) ); + |]) ) + | None -> fields + in + let fields = + match session_id with + | Some session_id -> + Vec.push_front fields + ( Edn_util.keyword "logseq.property.agent/session-id", + Edn_util.string session_id ) + | None -> fields + in + Entity.of_value (Edn_util.map_vec fields) + +let routable_reason_text = function + | Agent.Missing_stable_uuid -> "missing-stable-uuid" + | Missing_task_tag -> "missing-task-tag" + | Not_todo -> "not-todo" + | Assignee_mismatch -> "assignee-mismatch" + | Already_routed -> "already-routed" + +let () = + test "CLI parity output modes parse and mark structured modes" (fun () -> + let mode_name = function + | Output.Mode.Packed mode -> Output.Mode.to_string mode + in + expect_equal "human mode" "human" + (mode_name (expect_some "human" (Output.Mode.of_string "human"))); + expect_equal "json mode" "json" + (mode_name (expect_some "json" (Output.Mode.of_string "json"))); + expect_equal "edn mode" "edn" + (mode_name (expect_some "edn" (Output.Mode.of_string "edn"))); + expect_none "invalid output mode" (Output.Mode.of_string "yaml"); + expect_bool "human structured" false (Output.Mode.structured Human); + expect_bool "json structured" true (Output.Mode.structured Json); + expect_bool "edn structured" true (Output.Mode.structured Edn)); + + test "CLI parity primitive parsers keep graph repo shell and uuid contracts" + (fun () -> + let graph = Cli_primitive.create_graph "demo" in + let repo = Cli_config.graph_to_repo graph in + expect_equal "graph text" "demo" (Cli_primitive.string_of_graph graph); + expect_equal "repo text" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "repo to graph" "demo" + (Cli_primitive.string_of_graph (Cli_config.repo_to_graph repo)); + expect_bool "uuid valid" true + (Cli_primitive.is_uuid_string "00000000-0000-4000-8000-000000000001"); + expect_bool "uuid invalid" false + (Cli_primitive.is_uuid_string "not-a-uuid"); + (match expect_some "bash" (Cli_primitive.shell_of_string "bash") with + | Cli_primitive.Bash -> pass + | _ -> fail_test "expected bash shell"); + expect_none "unknown shell" (Cli_primitive.shell_of_string "fish")); + + test "CLI parity ustring decodes utf8 byte strings to JS unicode strings" + (fun () -> + let ascii = Ustring.of_string "ABC" |> Ustring.to_string in + expect_equal "ascii text" "ABC" ascii; + let utf8_byte_string = + Js.String.fromCharCode 0xe4 + ^ Js.String.fromCharCode 0xb8 + ^ Js.String.fromCharCode 0xad + in + let decoded = Ustring.of_string utf8_byte_string |> Ustring.to_string in + expect_equal "decoded unicode text" (unicode_text [| 0x4e2d |]) decoded; + expect_int "decoded JS length" 1 (String.length decoded); + expect_int "decoded char code" 0x4e2d (string_char_code_at decoded 0); + let latin1_unicode = + "Diese Vorteile " ^ unicode_text [| 0x00f6 |] ^ " und " + ^ unicode_text [| 0x00fc |] + in + let preserved = Ustring.of_string latin1_unicode |> Ustring.to_string in + expect_equal "latin1 supplement unicode text" latin1_unicode preserved; + expect_int "latin1 supplement char code" 0x00f6 + (string_char_code_at preserved 15); + expect_int "latin1 supplement char code 2" 0x00fc + (string_char_code_at preserved 21)); + + test + "CLI parity id parser accepts single multi comma whitespace and vector \ + input" (fun () -> + let single = expect_ok "single id" (Id_parse.parse_id_string "42") in + expect_bool "single multi" false single.multi; + expect_int64 "single id value" 42L + (expect_some "single" (Id_parse.to_single single)); + let multi = + expect_ok "multi ids" (Id_parse.parse_id_string "[1, 2 3\t4]") + in + expect_bool "multi flag" true multi.multi; + expect_int "multi length" 4 (Vec.length multi.ids); + expect_none "multi to_single" (Id_parse.to_single multi); + expect_error_code "zero id" ":invalid-options" + (Id_parse.parse_id_string "0"); + expect_error_code "non numeric id" ":invalid-options" + (Id_parse.parse_id_string "abc")); + + test + "CLI parity graph directory names preserve canonical and old-format \ + encoding" (fun () -> + expect_equal "space graph dir" "space name" + (Graph_dir.encode_graph_dir_name "space name"); + expect_equal "slash graph dir" "old~2Fname" + (Graph_dir.encode_graph_dir_name "old/name"); + expect_equal "tilde graph dir" "tilde~7Ename" + (Graph_dir.encode_graph_dir_name "tilde~name"); + expect_equal "decode canonical" "old/name" + (expect_some "canonical" + (Graph_dir.canonical_graph_name_of_dir "old~2Fname")); + expect_none "old-format dir is not canonical" + (Graph_dir.canonical_graph_name_of_dir "old++name"); + expect_equal "decode old-format slash" "old/name" + (expect_some "old-format" + (Graph_dir.decode_legacy_graph_dir_name "old++name"))); + + test "CLI parity unlink graph moves canonical encoded dir to unlinked dir" + (fun () -> + let root = temp_dir "logseq-cli-parity-unlink-graph-" in + let graphs_dir = Node.Path.join [| root; "graphs" |] in + let graph = Cli_primitive.create_graph "foo/bar" in + let repo = Cli_config.graph_to_repo graph in + let encoded_dir = Graph_dir.encode_graph_dir_name "foo/bar" in + let graph_path = Node.Path.join [| graphs_dir; encoded_dir |] in + let unlinked_path = + Node.Path.join [| graphs_dir; "Unlinked graphs"; encoded_dir |] + in + try + mkdir_p graph_path; + write_file (Node.Path.join [| graph_path; "db.sqlite" |]) "test-data"; + let result = + effect_result "graph remove slash" + (execute_with_output Graph.execute + (Graph.Graph_remove { graph; repo }) + (config ~root_dir:root ()) Output.Mode.Human) + in + expect_bool "graph remove ok" true + (result.Cli_result.status = Cli_result.Ok); + expect_bool "source removed" false (Cli_unix.file_exists graph_path); + expect_bool "destination exists" true + (Cli_unix.file_exists unlinked_path); + expect_equal "contents preserved" "test-data" + (read_file (Node.Path.join [| unlinked_path; "db.sqlite" |])); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test + "CLI parity unlink graph moves space-preserving canonical dir to unlinked \ + dir" (fun () -> + let root = temp_dir "logseq-cli-parity-unlink-space-" in + let graphs_dir = Node.Path.join [| root; "graphs" |] in + let graph = Cli_primitive.create_graph "space name" in + let repo = Cli_config.graph_to_repo graph in + let graph_path = Node.Path.join [| graphs_dir; "space name" |] in + let unlinked_path = + Node.Path.join [| graphs_dir; "Unlinked graphs"; "space name" |] + in + try + mkdir_p graph_path; + write_file (Node.Path.join [| graph_path; "db.sqlite" |]) "test-data"; + let result = + effect_result "graph remove space" + (execute_with_output Graph.execute + (Graph.Graph_remove { graph; repo }) + (config ~root_dir:root ()) Output.Mode.Human) + in + expect_bool "space graph remove ok" true + (result.Cli_result.status = Cli_result.Ok); + expect_bool "space source removed" false + (Cli_unix.file_exists graph_path); + expect_bool "space destination exists" true + (Cli_unix.file_exists unlinked_path); + expect_equal "space contents preserved" "test-data" + (read_file (Node.Path.join [| unlinked_path; "db.sqlite" |])); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity root-dir helpers create directories and reject files" + (fun () -> + let root = temp_dir "logseq-cli-parity-root-" in + let nested = Node.Path.join [| root; "a"; "b" |] in + let file_path = Node.Path.join [| root; "file" |] in + try + let ensured = + expect_ok "ensure nested" + (Root_dir_types.ensure_root_dir (Some nested)) + in + expect_bool "nested exists" true (Node.Fs.existsSync ensured); + write_file file_path "not a dir"; + expect_error_code "reject file" ":root-dir-permission" + (Root_dir_types.ensure_root_dir (Some file_path)); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity root-dir defaults and normalization stay stable" (fun () -> + let home = Sys.getenv_opt "HOME" |> Option.value ~default:"." in + let default_root = Node.Path.join [| home; "logseq" |] in + expect_equal "default root" default_root + (Root_dir_types.normalize_root_dir None); + expect_equal "relative root" + (Node.Path.join [| Sys.getcwd (); "relative-logseq" |]) + (Root_dir_types.normalize_root_dir (Some "relative-logseq")); + expect_equal "home root" + (Node.Path.join [| home; "custom-logseq" |]) + (Root_dir_types.normalize_root_dir (Some "~/custom-logseq"))); + + test "CLI parity root-dir rejects read-only directories" (fun () -> + if Node.Process.process##platform = "win32" then pass + else + let root = temp_dir "logseq-cli-parity-root-readonly-" in + try + chmod_sync root 0o555; + expect_error_code "read-only root" ":root-dir-permission" + (Root_dir_types.ensure_root_dir (Some root)); + chmod_sync root 0o755; + remove_tree root + with exn -> + chmod_sync root 0o755; + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity auth default path follows HOME logseq auth.json" (fun () -> + let home = Sys.getenv_opt "HOME" |> Option.value ~default:"." in + let expected = Node.Path.join [| home; "logseq"; "auth.json" |] in + expect_equal "default auth path" expected + (Auth_state.default_auth_path ())); + + test "CLI parity auth file write read and delete round-trips token data" + (fun () -> + let root = temp_dir "logseq-cli-parity-auth-" in + let auth_path = + Node.Path.join [| root; "nested"; "tokens"; "auth.json" |] + in + let config = config ~root_dir:root ~auth_path () in + let auth = sample_auth () in + try + expect_bool "auth dir missing" false + (Node.Fs.existsSync (Node.Path.dirname auth_path)); + ignore + (expect_ok "write auth" + (effect_result "write auth" + (Auth_state.write_auth_file config auth))); + expect_bool "auth dir exists" true + (Node.Fs.existsSync (Node.Path.dirname auth_path)); + expect_bool "auth file exists" true (Node.Fs.existsSync auth_path); + let stored = + expect_some "stored auth" + (expect_ok "read auth" + (effect_result "read auth" (Auth_state.read_auth_file config))) + in + expect_equal "provider" auth.provider stored.provider; + expect_equal "id-token" "id-token-1" + (expect_some "id-token" stored.id_token); + expect_equal "access-token" "access-token-1" + (expect_some "access-token" stored.access_token); + expect_equal "refresh-token" "refresh-token-1" + (expect_some "refresh-token" stored.refresh_token); + expect_equal "sub" "user-123" (expect_some "sub" stored.sub); + expect_equal "email" "user@example.com" + (expect_some "email" stored.email); + ignore + (expect_ok "delete auth" + (effect_result "delete auth" (Auth_state.delete_auth_file config))); + expect_bool "auth file deleted" false (Node.Fs.existsSync auth_path); + ignore + (expect_ok "delete auth idempotent" + (effect_result "delete auth again" + (Auth_state.delete_auth_file config))); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test + "CLI parity auth read returns none when missing and errors on invalid json" + (fun () -> + let root = temp_dir "logseq-cli-parity-auth-read-" in + let missing_path = Node.Path.join [| root; "missing"; "auth.json" |] in + let invalid_path = Node.Path.join [| root; "auth.json" |] in + try + let missing = + expect_ok "missing auth" + (effect_result "read missing auth" + (Auth_state.read_auth_file + (config ~root_dir:root ~auth_path:missing_path ()))) + in + expect_none "missing auth none" missing; + write_file invalid_path "{\"provider\":"; + expect_error_code "invalid auth json" "invalid-auth-file" + (effect_result "read invalid auth" + (Auth_state.read_auth_file + (config ~root_dir:root ~auth_path:invalid_path ()))); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity auth expired status uses expires-at strictly" (fun () -> + expect_bool "missing expires-at is expired" true + (Auth_state.expired_auth (sample_auth ())); + expect_bool "past expires-at is expired" true + (Auth_state.expired_auth + (sample_auth ~expires_at:(Time.time_of_epoch_ms 0L) ())); + expect_bool "future expires-at is not expired" false + (Auth_state.expired_auth (sample_auth ~expires_at:Time.max_time ()))); + + test "CLI parity auth read supports old underscore fields and config tokens" + (fun () -> + let root = temp_dir "logseq-cli-parity-auth-compat-" in + let auth_path = Node.Path.join [| root; "auth.json" |] in + try + write_file auth_path + "{\"id_token\":\"id-token-2\",\"access_token\":\"access-token-2\",\"refresh_token\":\"refresh-token-2\",\"updated_at\":1735686000000}"; + let underscore = + expect_ok "read underscore auth" + (effect_result "read underscore auth" + (Auth_state.read_auth_file (config ~root_dir:root ~auth_path ()))) + in + let underscore = expect_some "underscore auth" underscore in + expect_equal "default provider" "cognito" underscore.provider; + expect_equal "underscore id token" "id-token-2" + (expect_some "underscore id token" underscore.id_token); + expect_int64 "underscore updated-at" 1_735_686_000_000L + (Time.time_to_epoch_ms underscore.updated_at); + let from_config = + expect_ok "resolve config auth" + (effect_result "resolve config auth" + (Auth_state.resolve_auth + (config ~id_token:"id-token-cfg" + ~access_token:"access-token-cfg" + ~refresh_token:"refresh-token-cfg" ()))) + in + expect_equal "config auth provider" "cognito" from_config.provider; + expect_equal "config auth id token" "id-token-cfg" + (expect_some "config id token" from_config.id_token); + expect_none "config auth expires-at" from_config.expires_at; + expect_error_code "resolve missing auth" "missing-auth" + (effect_result "resolve missing auth" + (Auth_state.resolve_auth + (config ~root_dir:root + ~auth_path:(Node.Path.join [| root; "missing.json" |]) + ()))); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test_promise + "CLI parity auth refresh uses token endpoint and validates id token" + (fun () -> + let request_bodies = ref Vec.empty in + let token_server response_body = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + request_bodies := Vec.push_back !request_bodies !body; + if req_method req <> "POST" || req_url req <> "/oauth2/token" + then write_json res 404 (error_response "not found") + else write_json res 200 response_body)) + in + let* () = + with_server + (token_server + "{\"id_token\":\"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJmcmVzaC11c2VyIiwiZW1haWwiOiJmcmVzaEBleGFtcGxlLmNvbSIsImV4cCI6NDEwMjQ0NDgwMH0.signature\",\"access_token\":\"fresh-access-token\"}") + (fun base_url -> + let raw_config = + Edn_util.map + [ + ( Edn_util.keyword "oauth-token-endpoint", + Edn_util.string (base_url ^ "/oauth2/token") ); + ( Edn_util.keyword "oauth-client-id", + Edn_util.string "client-1" ); + ] + in + let config = + config ~raw_file_config:raw_config + ~refresh_token:"refresh-token-cfg" () + in + let* refreshed = + effect_to_promise + (Auth_state.refresh_auth config + (sample_auth ~id_token:(Some "expired-id") + ~access_token:(Some "expired-access") + ~refresh_token:(Some "refresh-token-1") + ~expires_at:(Time.time_of_epoch_ms 0L) ())) + in + let refreshed = expect_ok "refresh auth" refreshed in + expect_equal "fresh id token sub" "fresh-user" + (expect_some "fresh sub" refreshed.sub); + expect_equal "fresh email" "fresh@example.com" + (expect_some "fresh email" refreshed.email); + expect_equal "fresh access token" "fresh-access-token" + (expect_some "fresh access token" refreshed.access_token); + expect_equal "refresh token preserved" "refresh-token-1" + (expect_some "fresh refresh token" refreshed.refresh_token); + let body = Vec.string_concat "\n" !request_bodies in + expect_named_contains "refresh grant type" body + "grant_type=refresh_token"; + expect_named_contains "refresh token body" body + "refresh_token=refresh-token-1"; + expect_named_contains "refresh client id" body "client_id=client-1"; + Js.Promise.resolve pass) + in + with_server + (token_server + "{\"access_token\":\"access-token-only\",\"refresh_token\":\"refresh-token-2\"}") + (fun base_url -> + let raw_config = + Edn_util.map + [ + ( Edn_util.keyword "oauth-token-endpoint", + Edn_util.string (base_url ^ "/oauth2/token") ); + ] + in + let* result = + effect_to_promise + (Auth_state.refresh_auth + (config ~raw_file_config:raw_config ()) + (sample_auth ~refresh_token:(Some "refresh-token-1") ())) + in + expect_error_code "missing id token" "missing-id-token" result; + Js.Promise.resolve pass)); + + test_promise "CLI parity auth refresh returns precise token errors" (fun () -> + let token_server response_body = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + ignore !body; + if req_method req <> "POST" || req_url req <> "/oauth2/token" + then write_json res 404 (error_response "not found") + else write_json res 200 response_body)) + in + let config_for base_url = + config + ~raw_file_config: + (Edn_util.map + [ + ( Edn_util.keyword "oauth-token-endpoint", + Edn_util.string (base_url ^ "/oauth2/token") ); + ]) + () + in + let* () = + with_server + (token_server + "{\"id_token\":\"not-a-jwt\",\"access_token\":\"access-token\"}") + (fun base_url -> + let* result = + effect_to_promise + (Auth_state.refresh_auth (config_for base_url) + (sample_auth ~refresh_token:(Some "refresh-token-1") ())) + in + expect_error_code "invalid token" "invalid-auth-token" result; + Js.Promise.resolve pass) + in + let missing_result = ref None in + let* () = + with_server (token_server "{\"access_token\":\"access-token-only\"}") + (fun base_url -> + let* result = + effect_to_promise + (Auth_state.refresh_auth (config_for base_url) + (sample_auth ~refresh_token:(Some "refresh-token-1") ())) + in + missing_result := Some result; + Js.Promise.resolve pass) + in + let result = expect_some "missing id token result" !missing_result in + match result with + | Ok _ -> + fail_test "missing id token: expected Error"; + Js.Promise.resolve pass + | Error err -> + expect_equal "missing id token code" "missing-id-token" + (Error.code_to_string err.Error.code); + Js.Promise.resolve pass); + + test_promise + "CLI parity resolve auth refreshes expired file auth and stores it" + (fun () -> + let root = temp_dir "logseq-cli-parity-auth-resolve-refresh-" in + let auth_path = Node.Path.join [| root; "auth.json" |] in + let refreshed_id_token = + id_token ~sub:"resolved-user" ~email:"resolved@example.com" () + in + let response_body = + Printf.sprintf + "{\"id_token\":%s,\"access_token\":\"resolved-access-token\",\"refresh_token\":\"resolved-refresh-token\"}" + (Js.Json.stringify (Js.Json.string refreshed_id_token)) + in + let server = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + ignore !body; + if req_method req <> "POST" || req_url req <> "/oauth2/token" + then write_json res 404 (error_response "not found") + else write_json res 200 response_body)) + in + with_server server (fun base_url -> + let raw_config = + Edn_util.map + [ + ( Edn_util.keyword "oauth-token-endpoint", + Edn_util.string (base_url ^ "/oauth2/token") ); + ] + in + let config = + config ~root_dir:root ~auth_path ~raw_file_config:raw_config () + in + let* written = + effect_to_promise + (Auth_state.write_auth_file config + (sample_auth ~id_token:(Some "expired-id-token") + ~access_token:(Some "expired-access-token") + ~refresh_token:(Some "refresh-token-1") + ~expires_at:(Time.time_of_epoch_ms 0L) ())) + in + ignore (expect_ok "write expired auth" written); + let* resolved = effect_to_promise (Auth_state.resolve_auth config) in + let resolved = expect_ok "resolve refreshed auth" resolved in + expect_equal "resolved sub" "resolved-user" + (expect_some "resolved sub" resolved.sub); + expect_equal "resolved email" "resolved@example.com" + (expect_some "resolved email" resolved.email); + expect_equal "resolved access" "resolved-access-token" + (expect_some "resolved access" resolved.access_token); + let* stored = effect_to_promise (Auth_state.read_auth_file config) in + let stored = + expect_some "stored refreshed auth" + (expect_ok "read refreshed auth" stored) + in + expect_equal "stored id token" refreshed_id_token + (expect_some "stored id token" stored.id_token); + expect_equal "stored refresh token" "resolved-refresh-token" + (expect_some "stored refresh token" stored.refresh_token); + remove_tree root; + Js.Promise.resolve pass)); + + test "CLI parity auth commands parse and build login logout actions" + (fun () -> + let login_request = expect_parse_ok "login parse" [| "login" |] in + (match login_request.command with + | Cli_request.Auth Auth_command.Parsed_login -> pass + | _ -> fail_test "login parse: expected auth login"); + let logout_request = expect_parse_ok "logout parse" [| "logout" |] in + (match logout_request.command with + | Cli_request.Auth Auth_command.Parsed_logout -> pass + | _ -> fail_test "logout parse: expected auth logout"); + (match + Auth_command.build (config ()) (Global_opts.create ()) + Auth_command.Parsed_login + with + | Ok Auth_command.Login -> pass + | Ok _ -> fail_test "login build: expected Login" + | Error err -> fail_test ("login build: " ^ err.Error.message)); + match + Auth_command.build (config ()) (Global_opts.create ()) + Auth_command.Parsed_logout + with + | Ok Auth_command.Logout -> pass + | Ok _ -> fail_test "logout build: expected Logout" + | Error err -> fail_test ("logout build: " ^ err.Error.message)); + + test "CLI parity Windows open_url keeps query params inside start URL" + (fun () -> + let url = + "https://logseq-prod.auth.us-east-1.amazoncognito.com/oauth2/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fauth%2Fcallback&state=xyz&code_challenge=pkce&code_challenge_method=S256" + in + try + start_open_url_capture (); + expect_bool "open url result" true (Cli_unix.open_url url); + let calls = open_url_capture_calls () in + stop_open_url_capture (); + expect_named_contains "windows open command" calls + "\"command\":\"cmd.exe\""; + expect_named_contains "windows verbatim arguments" calls + "\"windowsVerbatimArguments\":true"; + expect_named_contains "windows quoted start url" calls + ("start \\\"\\\" \\\"" ^ url ^ "\\\""); + expect_named_not_contains "windows split start args" calls + ("\"start\",\"\",\"" ^ url ^ "\"") + with exn -> + stop_open_url_capture (); + fail_test (Printexc.to_string exn)); + + test "CLI parity auth logout command deletes file and reports absent file" + (fun () -> + let root = temp_dir "logseq-cli-parity-auth-logout-" in + let auth_path = Node.Path.join [| root; "auth.json" |] in + let raw_config = + Edn_util.map + [ + (Edn_util.keyword "open-browser", Edn_util.bool false); + (Edn_util.keyword "oauth-logout-state", Edn_util.string "state-1"); + ] + in + let config = + { + (config ~root_dir:root ~auth_path ()) with + raw_file_config = Some raw_config; + } + in + let deleted_flag result = + let data = expect_some "logout data" (Cli_result.data_value result) in + expect_some "deleted flag" (Edn_util.get_bool data "deleted") + in + try + ignore + (expect_ok "write auth" + (effect_result "write auth" + (Auth_state.write_auth_file config (sample_auth ())))); + let result = + effect_result "logout existing" + (execute_with_output Auth_command.execute Auth_command.Logout config + Output.Mode.Json) + in + expect_bool "logout status" false (Cli_result.is_error result); + expect_bool "deleted existing" true (deleted_flag result); + expect_bool "auth file removed" false (Node.Fs.existsSync auth_path); + let missing_result = + effect_result "logout missing" + (execute_with_output Auth_command.execute Auth_command.Logout config + Output.Mode.Json) + in + expect_bool "missing logout status" false + (Cli_result.is_error missing_result); + expect_bool "deleted missing" false (deleted_flag missing_result); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config resolves argv over env over file" (fun () -> + let root = temp_dir "logseq-cli-parity-config-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path + "{:graph \"file-graph\" :root-dir \"file-root\" :timeout-ms 111 \ + :login-timeout-ms 444 :logout-timeout-ms 555 :output-format :edn \ + :auth-token \"legacy-secret\" :retries 2 :e2ee-password \ + \"legacy-password\"}\n"; + let config = + resolve_config + ~env: + [| + ("LOGSEQ_CLI_GRAPH", "env-graph"); + ("LOGSEQ_CLI_ROOT_DIR", "env-root"); + ("LOGSEQ_CLI_TIMEOUT_MS", "222"); + ("LOGSEQ_CLI_LOGIN_TIMEOUT_MS", "666"); + ("LOGSEQ_CLI_LOGOUT_TIMEOUT_MS", "777"); + ("LOGSEQ_CLI_OUTPUT", "json"); + |] + (Global_opts.create + ~graph:(Cli_primitive.create_graph "argv-graph") + ~root_dir:"argv-root" ~config_path:cfg_path + ~timeout_span:(Time.span_of_ms 333L) + ~output_format:(Output.Mode.Packed Output.Mode.Human) ()) + in + expect_equal "config path" cfg_path config.config_path; + expect_equal "graph" "argv-graph" + (Cli_primitive.string_of_graph (expect_some "graph" config.graph)); + expect_equal "root-dir" "argv-root" config.root_dir; + expect_int64 "timeout" 333L (Time.span_to_ms config.timeout_span); + expect_int64 "login timeout" 666L + (Time.span_to_ms config.login_timeout_span); + expect_int64 "logout timeout" 777L + (Time.span_to_ms config.logout_timeout_span); + expect_equal "output" "human" + (mode_text (expect_some "output" config.output_format)); + (match config.raw_file_config with + | Some raw -> + expect_none "sanitized auth-token" (Edn_util.get raw "auth-token"); + expect_none "sanitized retries" (Edn_util.get raw "retries"); + expect_none "sanitized e2ee-password" + (Edn_util.get raw "e2ee-password") + | None -> fail_test "missing raw file config"); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config env output overrides file output" (fun () -> + let root = temp_dir "logseq-cli-parity-config-output-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path "{:output-format :edn}\n"; + let config = + resolve_config + ~env:[| ("LOGSEQ_CLI_OUTPUT", "json") |] + (Global_opts.create ~config_path:cfg_path ()) + in + expect_equal "env output" "json" + (mode_text (expect_some "output" config.output_format)); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config output-format key overrides old output key" (fun () -> + let root = temp_dir "logseq-cli-parity-config-output-key-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path "{:output :json :output-format :edn}\n"; + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_equal "output-format wins" "edn" + (mode_text (expect_some "output" config.output_format)); + write_file cfg_path "{:output :json}\n"; + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_equal "old output fallback" "json" + (mode_text (expect_some "output" config.output_format)); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config defaults use standard root URLs and timeouts" + (fun () -> + let root = temp_dir "logseq-cli-parity-config-default-" in + let cfg_path = Node.Path.join [| root; "missing-cli.edn" |] in + try + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_equal "config path" cfg_path config.config_path; + expect_equal "root-dir" + (Node.Path.join + [| Sys.getenv_opt "HOME" |> Option.value ~default:"."; "logseq" |]) + config.root_dir; + expect_equal "ws-url" "wss://api.logseq.io/sync/%s" + (expect_some "ws-url" config.ws_url); + expect_equal "http-base" "https://api.logseq.io" + (expect_some "http-base" config.http_base); + expect_int64 "timeout" 10_000L (Time.span_to_ms config.timeout_span); + expect_int64 "login timeout" 300_000L + (Time.span_to_ms config.login_timeout_span); + expect_int64 "logout timeout" 120_000L + (Time.span_to_ms config.logout_timeout_span); + expect_int "title max width" 40 config.list_title_max_display_width; + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity empty config file uses defaults" (fun () -> + let root = temp_dir "logseq-cli-parity-config-empty-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path " \n\t"; + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_equal "config path" cfg_path config.config_path; + expect_equal "root-dir" + (Node.Path.join + [| Sys.getenv_opt "HOME" |> Option.value ~default:"."; "logseq" |]) + config.root_dir; + expect_equal "ws-url" "wss://api.logseq.io/sync/%s" + (expect_some "ws-url" config.ws_url); + expect_equal "http-base" "https://api.logseq.io" + (expect_some "http-base" config.http_base); + expect_int64 "timeout" 10_000L (Time.span_to_ms config.timeout_span); + expect_int64 "login timeout" 300_000L + (Time.span_to_ms config.login_timeout_span); + expect_int64 "logout timeout" 120_000L + (Time.span_to_ms config.logout_timeout_span); + expect_int "title max width" 40 config.list_title_max_display_width; + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config path follows root-dir unless explicitly set" + (fun () -> + let root = temp_dir "logseq-cli-parity-config-path-" in + let custom_root = Node.Path.join [| root; "custom-logseq" |] in + let explicit_cfg = + Node.Path.join [| root; "nested"; "custom-cli.edn" |] + in + try + let derived = + resolve_config (Global_opts.create ~root_dir:custom_root ()) + in + expect_equal "derived root" custom_root derived.root_dir; + expect_equal "derived config path" + (Node.Path.join [| custom_root; "cli.edn" |]) + derived.config_path; + let explicit = + resolve_config + (Global_opts.create ~root_dir:custom_root ~config_path:explicit_cfg + ()) + in + expect_equal "explicit root" custom_root explicit.root_dir; + expect_equal "explicit config path" explicit_cfg explicit.config_path; + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity server runtime dirs are derived from resolved root" + (fun () -> + let config = config ~root_dir:"/tmp/logseq-root" () in + expect_equal "resolved root" "/tmp/logseq-root" + (Server_runtime.resolve_root_dir config); + expect_equal "graphs dir" "/tmp/logseq-root/graphs" + (Server_runtime.graphs_dir config)); + + test + "CLI parity config list title max width reads positive and defaults \ + non-positive" (fun () -> + let root = temp_dir "logseq-cli-parity-title-width-" in + try + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + write_file cfg_path "{:list-title-max-display-width 72}\n"; + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_int "positive width" 72 config.list_title_max_display_width; + write_file cfg_path "{:list-title-max-display-width 0}\n"; + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_int "zero width default" 40 config.list_title_max_display_width; + write_file cfg_path "{:list-title-max-display-width -3}\n"; + let config = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + expect_int "negative width default" 40 + config.list_title_max_display_width; + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config invalid list title width fails fast" (fun () -> + let root = temp_dir "logseq-cli-parity-title-width-invalid-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path "{:list-title-max-display-width \"abc\"}\n"; + let result = + effect_result "resolve invalid title width fallback" + (Cli_config.resolve ~defaults:(Cli_config.defaults ()) + ~env:(env_from_pairs [||]) + (Global_opts.create ~config_path:cfg_path ())) + in + (match result with + | Ok _ -> fail_test "invalid width: expected Error" + | Error err -> + expect_equal "invalid width code" "invalid-config" + (Error.code_to_string err.Error.code); + expect_named_contains "invalid width message" err.Error.message + "Expected integer"); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity config invalid output fails fast" (fun () -> + let root = temp_dir "logseq-cli-parity-invalid-output-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path "{:output-format :edn}\n"; + let result = + effect_result "resolve invalid output fallback" + (Cli_config.resolve ~defaults:(Cli_config.defaults ()) + ~env:(env_from_pairs [| ("LOGSEQ_CLI_OUTPUT", "yaml") |]) + (Global_opts.create ~config_path:cfg_path ())) + in + (match result with + | Ok _ -> fail_test "invalid output: expected Error" + | Error err -> + expect_equal "invalid output code" "invalid-config" + (Error.code_to_string err.Error.code); + expect_named_contains "invalid output message" err.Error.message + "Expected one of human, json, edn"); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test + "CLI parity uuid ref helpers extract unique lowercase refs and replace \ + labels" (fun () -> + let first = "11111111-1111-4111-8111-111111111111" in + let upper = "22222222-2222-4222-8222-222222222222" in + let text = + "A [[" ^ first ^ "]] B [[not-a-uuid]] C [[" + ^ String.uppercase_ascii upper + ^ "]] A [[" ^ first ^ "]]" + in + let refs = Uuid_refs_types.extract_uuid_refs text in + expect_int "uuid ref count" 2 (Vec.length refs); + expect_equal "first uuid" first (Vec.nth refs 0); + expect_equal "second uuid" upper (Vec.nth refs 1); + let replaced = + Uuid_refs_types.replace_uuid_refs text + (Vec.of_array [| (first, "First"); (upper, "Second") |]) + in + expect_named_contains "replace first" replaced "[[First]]"; + expect_named_contains "replace second" replaced "[[Second]]"; + expect_named_contains "keep invalid" replaced "[[not-a-uuid]]"); + + test "CLI parity uuid refs are collected from entity string fields" (fun () -> + let uuid = "33333333-3333-4333-8333-333333333333" in + let items = + Vec.of_array + [| + entity ~title:("Title [[" ^ uuid ^ "]]") (); + entity ~name:("Name [[" ^ uuid ^ "]]") (); + |] + in + let refs = + Uuid_refs_types.collect_uuid_refs_from_items items + (Vec.of_array + [| + Edn_util.keyword_t "block/title"; Edn_util.keyword_t "block/name"; + |]) + in + expect_int "collected uuid refs" 1 (Vec.length refs); + expect_equal "collected uuid" uuid (Vec.peek_front refs)); + + test "CLI parity human count helpers use grouped counts and pluralization" + (fun () -> + expect_equal "count" "1,234" (Humanize_types.format_count 1234); + expect_equal "singular" "1 graph" + (Humanize_types.format_count_with_noun 1 "graph"); + expect_equal "plural" "2 graphs" + (Humanize_types.format_count_with_noun 2 "graph")); + + test "CLI parity display width truncates ascii cjk and combining text" + (fun () -> + let cjk = decode_uri_component "%E7%95%8C" in + let combining_acute = decode_uri_component "%CC%81" in + let o_umlaut = decode_uri_component "%C3%B6" in + let accented_title = + decode_uri_component + "Diese%20Vorteile%20k%C3%B6nnten%20besonders%20f%C3%BCr%20eine" + in + let repeat count value = + Vec.string_concat "" (Vec.init count (fun _ -> value)) + in + expect_int "ascii width" 5 (Display_width.width "Hello"); + expect_int "cjk width" 2 (Display_width.width cjk); + expect_int "combining width" 1 + (Display_width.width ("e" ^ combining_acute)); + expect_int "latin-1 code point width" 1 (Display_width.width o_umlaut); + expect_int "latin-1 followed by ascii width" 4 + (Display_width.width (o_umlaut ^ "abc")); + expect_int "accented latin title width" 41 + (Display_width.width accented_title); + expect_equal "short segment" "Project Alpha" + (Display_width.truncate "Project Alpha" 24); + expect_equal "exact ascii segment" "123456789012345678901234" + (Display_width.truncate "123456789012345678901234" 24); + expect_equal "one over ascii segment" + ("12345678901234567890123" ^ Cli_platform.Symbols.ellipsis) + (Display_width.truncate "1234567890123456789012345" 24); + expect_equal "exact cjk segment" (repeat 12 cjk) + (Display_width.truncate (repeat 12 cjk) 24); + expect_equal "one over cjk segment" + (repeat 11 cjk ^ Cli_platform.Symbols.ellipsis) + (Display_width.truncate (repeat 13 cjk) 24); + expect_equal "accented latin title truncation" + (decode_uri_component + "Diese%20Vorteile%20k%C3%B6nnten%20besonders%20f%C3%BCr%20ei" + ^ Cli_platform.Symbols.ellipsis) + (Display_width.truncate accented_title 40)); + + test "CLI parity profile sessions aggregate repeated stages" (fun () -> + expect_none "disabled profile" (Profile_types.create_session false); + let session = + expect_some "enabled profile" (Profile_types.create_session true) + in + Profile_types.record_span session (span "cli.parse-argv" 1 0 2); + Profile_types.record_span session (span "cli.parse-argv" 2 3 5); + Profile_types.record_span session (span "cli.build-action" 3 6 8); + let report = + Profile_types.report session ~command:"graph-list" + ~status:(Edn_util.keyword_t "ok") + in + expect_equal "profile command" "graph-list" report.command; + expect_equal "profile status" "ok" (keyword_text report.status); + expect_int "parse count" 2 + (expect_some "parse count" (stage_count "cli.parse-argv" report.stages)); + expect_int "build count" 1 + (expect_some "build count" + (stage_count "cli.build-action" report.stages)); + expect_int "total count" 1 + (expect_some "total count" (stage_count "cli.total" report.stages))); + + test "CLI parity profile render lines use containment tree" (fun () -> + let report : Profile_types.report = + { + command = "graph-list"; + status = Edn_util.keyword_t "ok"; + total_span = Time.span_of_ms 42L; + stages = Vec.empty; + spans = + Vec.of_array + [| + span "cli.total" 0 0 42; + span "cli.parse-argv" 1 1 3; + span "cli.execute-action" 2 5 35; + span "transport.invoke:thread-api/q" 3 12 20; + |]; + } + in + let lines = Profile_types.render_lines report in + expect_equal "profile headline" "42ms command=graph-list status=ok" + (Vec.peek_front lines); + expect_bool "profile stages heading" true + (Vec.exists (fun line -> String.trim line = "stages") lines); + expect_bool "profile total line" true + (Vec.exists + (fun line -> Js.String.includes ~search:"cli.total" line) + lines); + expect_bool "profile nested transport" true + (Vec.exists + (fun line -> + Js.String.includes ~search:"transport.invoke:thread-api/q" line) + lines); + expect_bool "profile no aggregate count labels" false + (Vec.exists + (fun line -> Js.String.includes ~search:"count=" line) + lines)); + + test "CLI parity profile render keeps repeated stage calls separate" + (fun () -> + let report : Profile_types.report = + { + command = "query"; + status = Edn_util.keyword_t "ok"; + total_span = Time.span_of_ms 30L; + stages = Vec.empty; + spans = + Vec.of_array + [| + span "outer" 0 0 30; + span "transport.invoke:thread-api/q" 1 5 10; + span "transport.invoke:thread-api/q" 2 15 20; + |]; + } + in + let lines = Profile_types.render_lines report in + let q_lines = + Vec.filter + (fun line -> + Js.String.includes ~search:"transport.invoke:thread-api/q" line) + lines + in + expect_int "repeated stage lines" 2 (Vec.length q_lines)); + + test "CLI parity human table formats blanks and appends footer" (fun () -> + let output = + Output.Human_output.create + ~headers:(Vec.of_array [| "ID"; "TITLE" |]) + ~rows: + (Vec.of_array + [| Vec.of_array [| "1"; "Alpha" |]; Vec.of_array [| "2"; "" |] |]) + ~footer:"Count: 2" () + |> Output.Human_output.to_string + in + expect_named_contains "header" output "ID TITLE"; + expect_named_contains "blank value" output "2 -"; + expect_named_contains "footer" output "Count: 2"); + + test "CLI parity human table renders keyword headers without namespaces" + (fun () -> + let output = + Output.Human_output.create + ~headers: + (Vec.of_array + [| + "db/id"; + "block/title"; + ":logseq.property/status"; + "Input/Output"; + |]) + ~rows: + (Vec.of_array + [| + Vec.of_array + [| "1"; "Ship"; "logseq.property/status.todo"; "Ready" |]; + |]) + () + |> Output.Human_output.to_string + in + expect_named_contains "keyword headers" output "id title status"; + expect_named_contains "plain slash header" output "Input/Output"; + expect_named_not_contains "db namespace" output "db/id"; + expect_named_not_contains "block namespace" output "block/title"; + expect_named_not_contains "property namespace" output + ":logseq.property/status"); + + test "CLI parity output mode parser accepts exact lowercase values only" + (fun () -> + expect_equal "json" "json" + (mode_text (expect_some "json" (Output.Mode.of_string "json"))); + expect_equal "edn" "edn" + (mode_text (expect_some "edn" (Output.Mode.of_string "edn"))); + expect_none "uppercase json" (Output.Mode.of_string " JSON "); + expect_none "uppercase edn" (Output.Mode.of_string "EDN"); + expect_none "blank output" (Output.Mode.of_string " "); + expect_none "unknown output" (Output.Mode.of_string "yaml")); + + test "CLI parity log truncate preview keeps string length without EDN quotes" + (fun () -> + let long_value = String.make 50 'a' |> Edn_util.string in + let long_preview = Log_types.truncate_preview ~max_len:10 long_value in + expect_int "long string length" 50 long_preview.length; + expect_int "long preview length" 10 (String.length long_preview.preview); + expect_bool "long preview truncated" true long_preview.truncated; + let short_preview = + Log_types.truncate_preview ~max_len:10 (Edn_util.string "short") + in + expect_int "short string length" 5 short_preview.length; + expect_equal "short preview" "short" short_preview.preview; + expect_bool "short preview not truncated" false short_preview.truncated; + let nil_preview = Log_types.truncate_preview ~max_len:10 Edn_util.nil in + expect_int "nil length" 3 nil_preview.length; + expect_equal "nil preview" "nil" nil_preview.preview; + expect_bool "nil not truncated" false nil_preview.truncated; + let vector_preview = + Log_types.truncate_preview ~max_len:100 + (Edn_util.vector_vec + (Vec.of_array [| Edn_util.int 1; Edn_util.int 2; Edn_util.int 3 |])) + in + expect_equal "vector preview" "[1 2 3]" vector_preview.preview; + expect_bool "vector not truncated" false vector_preview.truncated); + + test "CLI parity pretty edn matches jet command formatting" (fun () -> + expect_equal "flat pretty edn" "{:a 1, :bb 22, :ccc 333, :dddd 4444}\n" + (Pretty_print.pprint_edn + (edn_of_string "{:a 1 :bb 22 :ccc 333 :dddd 4444}")); + expect_equal "escaped string pretty edn" + "{:text \"a { b [ c ] } \\\" q\", :empty [], :m {}}\n" + (Pretty_print.pprint_edn + (edn_of_string "{:text \"a { b [ c ] } \\\" q\" :empty [] :m {}}")); + expect_equal "nested pretty edn" + "{:classes {:user.class/DUOL {:block/title \"DUOL\",\n\ + \ :block/created-at 1773581891284,\n\ + \ :block/updated-at 1773581891284},\n\ + \ :user.class/NVDA {:block/title \"NVDA\",\n\ + \ :block/created-at 1773583745410,\n\ + \ :block/updated-at 1773583745410}},\n\ + \ :pages [{:block/title \"A\", :block/tags [:x :y]}\n\ + \ {:block/title \"B\", :block/properties {:k \"v\"}}]}\n" + (Pretty_print.pprint_edn + (edn_of_string + "{:classes {:user.class/DUOL {:block/title \"DUOL\" \ + :block/created-at 1773581891284 :block/updated-at \ + 1773581891284} :user.class/NVDA {:block/title \"NVDA\" \ + :block/created-at 1773583745410 :block/updated-at \ + 1773583745410}} :pages [{:block/title \"A\" :block/tags [:x \ + :y]} {:block/title \"B\" :block/properties {:k \"v\"}}]}"))); + + test "CLI parity json output preserves namespaced keys and keyword values" + (fun () -> + let value = + Edn_util.map + [ + (Edn_util.keyword "block/title", Edn_util.string "Block title"); + (Edn_util.keyword "db/id", Edn_util.int64 42L); + (Edn_util.keyword "db/ident", Edn_util.keyword "logseq.class/Tag"); + ] + in + let output = + Format_types.to_json + (Cli_result.ok Output.Mode.Json (Cli_result.Raw value)) + in + expect_named_contains "json title" output + "\"block/title\":\"Block title\""; + expect_named_contains "json id" output "\"db/id\":42"; + expect_named_contains "json ident" output + "\"db/ident\":\"logseq.class/Tag\""); + + test "CLI parity cli result exit codes follow status" (fun () -> + let ok = Cli_result.ok Output.Mode.Human (Cli_result.Message "ok") in + expect_bool "ok is not error" false (Cli_result.is_error ok); + expect_int "ok exit" 0 (Cli_result.exit_code ok); + let err = Error.make Error.Missing_auth "missing auth" in + let error = Cli_result.error Output.Mode.Human err in + expect_bool "error is error" true (Cli_result.is_error error); + expect_int "error exit" 1 (Cli_result.exit_code error)); + + test "CLI parity edn includes hint and human error stays concise" (fun () -> + let err = + Error.make ~hint:"Run `logseq login` first." Error.Missing_auth + "missing auth" + in + let edn = Format_types.to_edn (Cli_result.error Output.Mode.Edn err) in + let human = + Format_types.format_result + (Cli_result.error Output.Mode.Human err) + (config ()) + in + expect_named_contains "edn code" edn ":missing-auth"; + expect_named_contains "edn hint" edn "Run `logseq login` first."; + expect_equal "human error" "Error (missing-auth): missing auth" human); + + test + "CLI parity parse rejects unknown command and retired qmd qsearch commands" + (fun () -> + expect_parse_error_code "unknown command" ":unknown-command" [| "bogus" |]; + expect_parse_error_code "qmd retired" ":unknown-command" [| "qmd" |]; + expect_parse_error_code "qsearch retired" ":unknown-command" + [| "qsearch" |]); + + test + "CLI parity parse reads global graph output timeout verbose and profile \ + flags" (fun () -> + let request = + expect_parse_ok "global parse" + [| + "--graph"; + "demo"; + "--output"; + "json"; + "--timeout-ms"; + "1234"; + "--verbose"; + "--profile"; + "graph"; + "list"; + |] + in + expect_equal "path" "graph/list" (Vec.string_concat "/" request.path); + expect_equal "graph" "demo" + (Cli_primitive.string_of_graph + (expect_some "graph" request.globals.Global_opts.graph)); + expect_bool "verbose" true request.globals.verbose; + expect_bool "profile" true request.globals.profile; + match request.globals.output_format with + | Some (Output.Mode.Packed Output.Mode.Json) -> pass + | _ -> fail_test "expected json output mode"); + + test "CLI parity parse skips an earlier duplicate option without a value" + (fun () -> + let request = + expect_parse_ok "duplicate output parse" + [| "--output"; "--output"; "json"; "graph"; "list" |] + in + match request.globals.output_format with + | Some (Output.Mode.Packed Output.Mode.Json) -> pass + | _ -> fail_test "expected later json output mode"); + + test "CLI parity parse supports list page filters and validation" (fun () -> + let request = + expect_parse_ok "list page parse" + [| + "list"; + "page"; + "--fields"; + "title,updated-at"; + "--limit"; + "10"; + "--offset"; + "2"; + "--sort"; + "updated-at"; + "--order"; + "asc"; + "--include-hidden"; + |] + in + match request.command with + | Cli_request.List (List_command.Parsed_page opts) -> + expect_int "limit" 10 (expect_some "limit" opts.common.limit); + expect_int "offset" 2 (expect_some "offset" opts.common.offset); + expect_equal "sort" "updated-at" (expect_some "sort" opts.common.sort); + expect_equal "order" "asc" + (List_command.string_of_order + (expect_some "order" opts.common.order)); + expect_bool "include hidden" true opts.include_hidden + | _ -> + fail_test "expected list page request"; + expect_parse_error_code "bad order" ":invalid-options" + [| "list"; "page"; "--order"; "sideways" |]; + expect_parse_error_code "bad limit" ":invalid-options" + [| "list"; "page"; "--limit"; "nope" |]); + + test "CLI parity parse rejects malformed boolean and selector values" + (fun () -> + expect_parse_error_code "bad boolean equals" ":invalid-options" + [| "list"; "page"; "--include-built-in=maybe" |]; + expect_parse_error_code "bad boolean value" ":invalid-options" + [| "show"; "--id"; "1"; "--linked-references=maybe" |]; + expect_parse_error_code "bad remove page id" ":invalid-options" + [| "remove"; "page"; "--id"; "abc"; "--page"; "Home" |]; + expect_parse_error_code "bad sync asset id" ":invalid-options" + [| "sync"; "asset"; "download"; "--id"; "abc"; "--uuid"; "id" |]; + expect_parse_error_code "bad debug id" ":invalid-options" + [| "debug"; "pull"; "--id"; "abc"; "--uuid"; "id" |]); + + test "CLI parity parse supports list tag property task node and asset options" + (fun () -> + let tag = + expect_parse_ok "list tag parse" + [| + "list"; + "tag"; + "--expand"; + "--include-built-in"; + "--with-properties"; + "--with-extends"; + "--fields"; + "title,properties"; + |] + in + (match tag.command with + | Cli_request.List (List_command.Parsed_tag opts) -> + expect_bool "tag expand" true opts.expand; + expect_bool "tag include built in" true + (expect_some "tag include built in" opts.include_built_in); + expect_bool "tag with properties" true opts.with_properties; + expect_bool "tag with extends" true opts.with_extends; + expect_int "tag fields" 2 + (Vec.length (expect_some "tag fields" opts.common.fields)) + | _ -> fail_test "expected list tag"); + let property = + expect_parse_ok "list property parse" + [| + "list"; + "property"; + "--expand"; + "--include-built-in"; + "--with-classes"; + "--with-type"; + "--sort"; + "cardinality"; + "--fields"; + "title,type,cardinality"; + |] + in + (match property.command with + | Cli_request.List (List_command.Parsed_property opts) -> + expect_bool "property expand" true opts.expand; + expect_bool "property include built in" true + (expect_some "property include built in" opts.include_built_in); + expect_bool "property with classes" true opts.with_classes; + expect_bool "property with type" true opts.with_type; + expect_equal "property sort" "cardinality" + (expect_some "property sort" opts.common.sort) + | _ -> fail_test "expected list property"); + let task = + expect_parse_ok "list task parse" + [| + "list"; + "task"; + "--status"; + "doing"; + "--priority"; + "high"; + "-c"; + "1111"; + "--fields"; + "id,title,status,priority"; + "--limit"; + "10"; + "--offset"; + "2"; + "--sort"; + "priority"; + "--order"; + "desc"; + |] + in + (match task.command with + | Cli_request.List (List_command.Parsed_task opts) -> + expect_equal "task status" "doing" + (expect_some "task status" opts.status); + expect_equal "task priority" "high" + (expect_some "task priority" opts.priority); + expect_equal "task numeric content" "1111" + (expect_some "task content" opts.content); + expect_int "task limit" 10 + (expect_some "task limit" opts.common.limit); + expect_equal "task order" "desc" + (List_command.string_of_order + (expect_some "task order" opts.common.order)) + | _ -> fail_test "expected list task"); + let node = + expect_parse_ok "list node parse" + [| + "list"; + "node"; + "--tags"; + "project,work"; + "--properties"; + "status,priority"; + "--fields"; + "id,title,type,updated-at"; + "--limit"; + "10"; + "--offset"; + "2"; + "--sort"; + "updated-at"; + "--order"; + "desc"; + |] + in + (match node.command with + | Cli_request.List (List_command.Parsed_node opts) -> + expect_int "node tags" 2 (Vec.length opts.tags); + expect_int "node properties" 2 (Vec.length opts.properties); + expect_equal "node sort" "updated-at" + (expect_some "node sort" opts.common.sort) + | _ -> fail_test "expected list node"); + let asset = + expect_parse_ok "list asset parse" + [| + "list"; + "asset"; + "--fields"; + "id,title,asset-type,size,updated-at"; + "--limit"; + "5"; + "--offset"; + "1"; + "--sort"; + "updated-at"; + "--order"; + "desc"; + |] + in + match asset.command with + | Cli_request.List (List_command.Parsed_asset opts) -> + expect_int "asset limit" 5 + (expect_some "asset limit" opts.common.limit); + expect_int "asset offset" 1 + (expect_some "asset offset" opts.common.offset); + expect_equal "asset order" "desc" + (List_command.string_of_order + (expect_some "asset order" opts.common.order)) + | _ -> fail_test "expected list asset"); + + test "CLI parity list rejects old name fields for tag and property" (fun () -> + expect_parse_error_code "list tag old name field" ":invalid-options" + [| "list"; "tag"; "--fields"; "name,properties" |]; + expect_parse_error_code "list property old name field" ":invalid-options" + [| "list"; "property"; "--fields"; "name,type,cardinality" |]); + + test "CLI parity list apply offset limit returns requested window" (fun () -> + let opts = { empty_common_opts with offset = Some 1; limit = Some 2 } in + let items = + Vec.of_array + [| + entity ~id:1L (); + entity ~id:2L (); + entity ~id:3L (); + entity ~id:4L (); + |] + in + let result = List_command.apply_offset_limit opts items in + expect_int "window size" 2 (Vec.length result); + expect_int64 "first window id" 2L + (expect_some "id" (Vec.peek_front result).Entity.id)); + + test "CLI parity list build action validates repo and option contracts" + (fun () -> + let page_opts = + List_command.Parsed_page + { + common = + { + empty_common_opts with + fields = Some (Vec.of_array [| "id"; "title" |]); + limit = Some 20; + sort = Some "updated-at"; + order = Some List_command.Desc; + }; + expand = false; + include_built_in = None; + include_journal = Some false; + journal_only = true; + include_hidden = false; + updated_after = None; + created_after = None; + } + in + let action = + expect_ok "list page build" + (List_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + page_opts) + in + expect_bool "list page kind" true + (action.List_command.kind = List_command.Page); + expect_equal "list page command" "list-page" + (Command_id.to_string action.command); + expect_equal "list page repo" "logseq_db_demo" + (Cli_primitive.string_of_repo action.repo); + expect_equal "list page graph" "demo" + (Cli_primitive.string_of_graph action.graph); + expect_int "list page limit" 20 + (expect_some "limit" (Edn_util.get_int action.options "limit")); + expect_equal "list page order" "desc" + (expect_some "order" (Edn_util.get_string action.options "order")); + expect_error_code "list requires repo" "missing-repo" + (List_command.build (config ()) (Global_opts.create ()) page_opts); + let conflict = + List_command.Parsed_page + { + common = empty_common_opts; + expand = false; + include_built_in = None; + include_journal = Some true; + journal_only = true; + include_hidden = false; + updated_after = None; + created_after = None; + } + in + expect_error_code "journal conflict" "invalid-options" + (List_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + conflict); + let task = + List_command.Parsed_task + { + common = empty_common_opts; + status = Some "todo"; + priority = Some "bad"; + content = None; + } + in + expect_error_code "task bad priority" "invalid-options" + (List_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + task); + let node = + List_command.Parsed_node + { + common = empty_common_opts; + tags = Vec.empty; + properties = Vec.empty; + } + in + expect_error_code "node requires filters" "invalid-options" + (List_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + node)); + + test_promise + "CLI parity list execute default sort limit and explicit order contracts" + (fun () -> + let call_count = ref 0 in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/cli-list-pages" body then ( + incr call_count; + match !call_count with + | 1 -> + "[[\"^ \",\"~:db/id\",11,\"~:block/title\",\"Page \ + C\",\"~:block/updated-at\",30],[\"^ \ + \",\"^0\",7,\"^1\",\"Page B\",\"^2\",10],[\"^ \ + \",\"^0\",5,\"^1\",\"Page A\",\"^2\",10]]" + | 2 -> + "[[\"^ \",\"~:db/id\",4,\"~:block/title\",\"Page \ + 4\",\"~:block/updated-at\",4],[\"^ \ + \",\"^0\",12,\"^1\",\"Page 12\",\"^2\",12],[\"^ \ + \",\"^0\",1,\"^1\",\"Page 1\",\"^2\",1],[\"^ \ + \",\"^0\",8,\"^1\",\"Page 8\",\"^2\",8],[\"^ \ + \",\"^0\",3,\"^1\",\"Page 3\",\"^2\",3],[\"^ \ + \",\"^0\",11,\"^1\",\"Page 11\",\"^2\",11],[\"^ \ + \",\"^0\",2,\"^1\",\"Page 2\",\"^2\",2],[\"^ \ + \",\"^0\",10,\"^1\",\"Page 10\",\"^2\",10],[\"^ \ + \",\"^0\",5,\"^1\",\"Page 5\",\"^2\",5],[\"^ \ + \",\"^0\",9,\"^1\",\"Page 9\",\"^2\",9],[\"^ \ + \",\"^0\",6,\"^1\",\"Page 6\",\"^2\",6],[\"^ \ + \",\"^0\",7,\"^1\",\"Page 7\",\"^2\",7]]" + | _ -> + "[[\"^ \ + \",\"~:db/id\",3,\"~:block/title\",\"Beta\",\"~:block/updated-at\",20],[\"^ \ + \",\"^0\",2,\"^1\",\"Gamma\",\"^2\",5],[\"^ \ + \",\"^0\",1,\"^1\",\"Alpha\",\"^2\",10]]") + else "null") + in + with_server server (fun base_url -> + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let run ?(common = empty_common_opts) () = + let action = + expect_ok "list page build" + (List_command.build cfg (Global_opts.create ()) + (List_command.Parsed_page + { + common; + expand = false; + include_built_in = None; + include_journal = None; + journal_only = false; + include_hidden = false; + updated_after = None; + created_after = None; + })) + in + execute_with_output List_command.execute action cfg + Output.Mode.Human + in + let ids result = + let data = expect_some "list data" (Cli_result.data_value result) in + let items = + expect_some "items" + (Option.bind (Edn_util.get data "items") Edn_util.as_seq) + in + items + |> Vec.map (fun item -> + Int64.to_string + (expect_some "item id" (Edn_util.get_int64 item "db/id"))) + |> Vec.string_concat "," + in + let* default_result = effect_to_promise (run ()) in + expect_equal "default updated-at desc with id tie" "11,7,5" + (ids default_result); + let* limited = + effect_to_promise + (run ~common:{ empty_common_opts with limit = Some 10 } ()) + in + expect_equal "default limit newest records" "12,11,10,9,8,7,6,5,4,3" + (ids limited); + let* explicit_order = + effect_to_promise + (run + ~common: + { empty_common_opts with order = Some List_command.Asc } + ()) + in + expect_equal "explicit asc order" "2,1,3" (ids explicit_order); + let* explicit_sort = + effect_to_promise + (run ~common:{ empty_common_opts with sort = Some "title" } ()) + in + expect_equal "explicit title sort desc" "2,3,1" (ids explicit_sort); + Js.Promise.resolve pass)); + + test_promise + "CLI parity list property execute supports cardinality sort and fields" + (fun () -> + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/cli-list-properties" body + then + "[[\"^ \ + \",\"~:db/id\",30,\"~:block/title\",\"Gamma\",\"~:db/cardinality\",\"~:db.cardinality/one\"],[\"^ \ + \",\"^0\",10,\"^1\",\"Alpha\",\"^2\",\"~:db.cardinality/many\"],[\"^ \ + \",\"^0\",20,\"^1\",\"Beta\",\"^2\",\"~:db.cardinality/many\"]]" + else "null") + in + with_server server (fun base_url -> + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + expect_ok "list property build" + (List_command.build cfg (Global_opts.create ()) + (List_command.Parsed_property + { + common = + { + empty_common_opts with + sort = Some "cardinality"; + fields = + Some + (Vec.of_array [| "id"; "title"; "cardinality" |]); + }; + expand = false; + include_built_in = None; + with_classes = false; + with_type = true; + })) + in + let* result = + effect_to_promise + (execute_with_output List_command.execute action cfg + Output.Mode.Human) + in + let data = + expect_some "property list data" (Cli_result.data_value result) + in + let items = + expect_some "property items" + (Option.bind (Edn_util.get data "items") Edn_util.as_seq) + in + let ids = + items + |> Vec.map (fun item -> + Int64.to_string + (expect_some "item id" (Edn_util.get_int64 item "db/id"))) + |> Vec.string_concat "," + in + expect_equal "cardinality sort ids" "30,20,10" ids; + Vec.iter + (fun item -> + expect_bool "has id" true + (Option.is_some (Edn_util.get item "db/id")); + expect_bool "has title" true + (Option.is_some (Edn_util.get item "block/title")); + expect_bool "has cardinality" true + (Option.is_some (Edn_util.get item "db/cardinality")); + expect_bool "only selected fields" true + (match Edn_util.as_map item with + | Some fields -> Vec.length fields = 3 + | None -> false)) + items; + Js.Promise.resolve pass)); + + test_promise "CLI parity list asset execute filters by asset tag id" + (fun () -> + let calls = ref Vec.empty in + let server = + invoke_server (fun body -> + calls := Vec.push_front !calls body; + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \",\"~:db/id\",900]" + else if Js.String.includes ~search:"thread-api/cli-list-nodes" body + then ( + expect_named_contains "asset tag id option" body "900"; + "[[\"^ \ + \",\"~:db/id\",2,\"~:block/title\",\"asset-b\",\"~:node/type\",\"block\",\"~:block/updated-at\",30],[\"^ \ + \",\"^0\",1,\"^1\",\"asset-a\",\"^2\",\"block\",\"^3\",10]]") + else "null") + in + with_server server (fun base_url -> + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + expect_ok "list asset build" + (List_command.build cfg (Global_opts.create ()) + (List_command.Parsed_asset + { common = { empty_common_opts with limit = Some 1 } })) + in + let* result = + effect_to_promise + (execute_with_output List_command.execute action cfg + Output.Mode.Human) + in + let data = + expect_some "asset list data" (Cli_result.data_value result) + in + let items = + expect_some "asset items" + (Option.bind (Edn_util.get data "items") Edn_util.as_seq) + in + expect_int "asset limit" 1 (Vec.length items); + expect_int64 "newest asset first" 2L + (expect_some "asset id" + (Edn_util.get_int64 (Vec.peek_front items) "db/id")); + expect_int "pull plus list calls" 2 (Vec.length !calls); + Js.Promise.resolve pass)); + + test "CLI parity parse supports search scopes and rejects unknown option" + (fun () -> + let request = + expect_parse_ok "search tag" [| "search"; "tag"; "--content"; "quote" |] + in + match request.command with + | Cli_request.Search (Search.Parsed_tag opts) -> + expect_equal "query" "quote" opts.content + | _ -> + fail_test "expected search tag"; + expect_parse_error_code "unknown search option" ":invalid-options" + [| "search"; "tag"; "--unknown"; "x"; "quote" |]); + + test + "CLI parity search parser keeps content alias globals and strict \ + positionals" (fun () -> + let alias = + expect_parse_ok "search block alias" + [| "search"; "block"; "-c"; "1111"; "--output"; "json" |] + in + (match alias.command with + | Cli_request.Search (Search.Parsed_block opts) -> + expect_equal "alias content" "1111" opts.content + | _ -> fail_test "expected search block"); + (match alias.globals.output_format with + | Some (Output.Mode.Packed Output.Mode.Json) -> pass + | _ -> fail_test "expected search json output"); + let page = + expect_parse_ok "search page multi-word" + [| "search"; "page"; "--content"; "project notes" |] + in + (match page.command with + | Cli_request.Search (Search.Parsed_page opts) -> + expect_equal "page query" "project notes" opts.content + | _ -> fail_test "expected search page"); + expect_parse_error_code "search positional command" ":unknown-command" + [| "search"; "block"; "alpha" |]); + + test "CLI parity search build action validates repo and query text" (fun () -> + let action = + expect_ok "search block action" + (Search.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Search.Parsed_block { content = " Alpha Beta " })) + in + expect_bool "search block scope" true (action.Search.scope = Search.Block); + expect_equal "search block command" "search-block" + (Command_id.to_string action.command); + expect_equal "search repo" "logseq_db_demo" + (Cli_primitive.string_of_repo action.repo); + expect_equal "search graph" "demo" + (Cli_primitive.string_of_graph action.graph); + expect_equal "search query trim" "Alpha Beta" action.query; + expect_error_code "search requires repo" "missing-repo" + (Search.build (config ()) (Global_opts.create ()) + (Search.Parsed_page { content = "Home" })); + expect_error_code "search rejects blank content" "missing-query-text" + (Search.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Search.Parsed_tag { content = " " }))); + + test_promise "CLI parity search execute sorts scopes and strips raw fields" + (fun () -> + let calls = ref Vec.empty in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then ( + calls := Vec.push_front !calls body; + "[[\"^ \ + \",\"~:db/id\",9,\"~:block/title\",\"beta\",\"~:unused\",true],[\"^ \ + \",\"^0\",7,\"^1\",\"Alpha\"]]") + else "null") + in + with_server server (fun base_url -> + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let repo = Cli_primitive.create_repo "demo" in + let run scope command query = + execute_with_output Search.execute + { + Search.scope; + command; + repo; + graph = Cli_config.repo_to_graph repo; + query; + } + cfg Output.Mode.Human + in + let item_titles result = + let data = + expect_some "search data" (Cli_result.data_value result) + in + let items = + expect_some "search items" + (Option.bind (Edn_util.get data "items") Edn_util.as_seq) + in + Vec.map + (fun item -> + expect_some "item title" + (Edn_util.get_string item "block/title")) + items + in + let* block = + effect_to_promise (run Search.Block Command_id.Search_block "alpha") + in + let* page = + effect_to_promise (run Search.Page Command_id.Search_page "home") + in + let* property = + effect_to_promise + (run Search.Property Command_id.Search_property "owner") + in + let* tag = + effect_to_promise (run Search.Tag Command_id.Search_tag "quote") + in + Vec.iter + (fun result -> + expect_bool "search ok" false (Cli_result.is_error result); + expect_equal "sorted titles" "Alpha,beta" + (Vec.string_concat "," (item_titles result))) + (Vec.of_array [| block; page; property; tag |]); + expect_int "q calls" 4 (Vec.length !calls); + expect_named_contains "block query pulls deleted-at" + (Vec.nth !calls 3) "logseq.property/deleted-at"; + expect_named_contains "page query uses block name" (Vec.nth !calls 2) + "block/name"; + expect_named_contains "property query class" (Vec.nth !calls 1) + "logseq.class/Property"; + expect_named_contains "tag query class" (Vec.nth !calls 0) + "logseq.class/Tag"; + Js.Promise.resolve pass)); + + test_promise "CLI parity search execute filters recycled pages and blocks" + (fun () -> + let block_results = + "[[\"^ \",\"~:db/id\",1,\"~:block/title\",\"alpha \ + live\",\"~:block/parent\",[\"^ \",\"^0\",51,\"^1\",\"Live \ + Page\"]],[\"^ \",\"^0\",2,\"^1\",\"alpha orphan\",\"^2\",[\"^ \ + \",\"^0\",10,\"^1\",\"intermediate\",\"^2\",[\"^ \ + \",\"^0\",50,\"^1\",\"Old \ + Page\",\"~:logseq.property/deleted-at\",1712000000000,\"^2\",[\"^ \ + \",\"^0\",999,\"^1\",\"Recycle\"]]]],[\"^ \",\"^0\",3,\"^1\",\"alpha \ + tombstone\",\"^2\",[\"^ \",\"^0\",50,\"^1\",\"Old \ + Page\",\"^3\",1712000000000]]]" + in + let page_results = + "[[\"^ \",\"~:db/id\",1,\"~:block/title\",\"Home\"],[\"^ \ + \",\"^0\",2,\"^1\",\"Recycled \ + Home\",\"~:logseq.property/deleted-at\",1712000000000],[\"^ \ + \",\"^0\",3,\"^1\",\"Homework\"]]" + in + let call_count = ref 0 in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then ( + incr call_count; + if !call_count = 1 then block_results else page_results) + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let run scope command query = + execute_with_output Search.execute + { + Search.scope; + command; + repo; + graph = Cli_config.repo_to_graph repo; + query; + } + cfg Output.Mode.Human + in + let titles result = + let data = + expect_some "search data" (Cli_result.data_value result) + in + let items = + expect_some "items" + (Option.bind (Edn_util.get data "items") Edn_util.as_seq) + in + Vec.map + (fun item -> + expect_some "title" (Edn_util.get_string item "block/title")) + items + in + let* block = + effect_to_promise (run Search.Block Command_id.Search_block "alpha") + in + expect_equal "live block only" "alpha live" + (Vec.string_concat "," (titles block)); + let* page = + effect_to_promise (run Search.Page Command_id.Search_page "home") + in + expect_equal "live pages only" "Home,Homework" + (Vec.string_concat "," (titles page)); + Js.Promise.resolve pass)); + + test_promise "CLI parity search execute replaces uuid refs in block titles" + (fun () -> + let ref_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" in + let calls = ref Vec.empty in + let server = + invoke_server (fun body -> + calls := Vec.push_front !calls body; + if Js.String.includes ~search:"thread-api/q" body then + "[[\"^ \",\"~:db/id\",7,\"~:block/title\",\"foo \ + [[aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa]]\"]]" + else if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",99,\"~:block/uuid\",\"~uaaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"~:block/title\",\"bar\"]" + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Search.execute + { + Search.scope = Search.Block; + command = Command_id.Search_block; + repo; + graph = Cli_config.repo_to_graph repo; + query = "foo"; + } + cfg Output.Mode.Human) + in + let data = expect_some "search data" (Cli_result.data_value result) in + let items = + expect_some "items" + (Option.bind (Edn_util.get data "items") Edn_util.as_seq) + in + let title = + expect_some "title" + (Edn_util.get_string (Vec.peek_front items) "block/title") + in + expect_equal "replaced uuid ref" "foo [[bar]]" title; + expect_int "q and pull calls" 2 (Vec.length !calls); + expect_named_contains "pull lookup uuid" (Vec.peek_front !calls) + ref_uuid; + Js.Promise.resolve pass)); + + test "CLI parity remove validation rejects ambiguous and blank targets" + (fun () -> + expect_equal "remove page ambiguous" + "only one of --id or --page is allowed" + (expect_some "invalid" + (Remove.invalid_options + (Remove.Parsed_page { id = Some 1L; page = Some "Home" }))); + expect_equal "remove tag blank" "name must be non-empty" + (expect_some "invalid" + (Remove.invalid_options + (Remove.Parsed_tag { id = None; name = Some " " })))); + + test "CLI parity remove build action resolves targets and repo" (fun () -> + let block = + expect_ok "remove block ids" + (Remove.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Remove.Parsed_block { id_raw = Some "[1,2]"; uuid = None })) + in + (match block with + | Remove.Remove_block { repo; graph; id; ids; multi_id; uuid } -> + expect_equal "remove repo" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "remove graph" "demo" + (Cli_primitive.string_of_graph graph); + expect_none "remove block single id" id; + expect_int "remove block ids" 2 (Vec.length ids); + expect_bool "remove block multi" true multi_id; + expect_none "remove block uuid none" uuid + | _ -> fail_test "expected remove block"); + let block_uuid = + expect_ok "remove block uuid" + (Remove.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Remove.Parsed_block + { + id_raw = None; + uuid = Some "00000000-0000-4000-8000-000000000001"; + })) + in + (match block_uuid with + | Remove.Remove_block { uuid = Some uuid; multi_id; ids; _ } -> + expect_equal "remove block uuid" + "00000000-0000-4000-8000-000000000001" uuid; + expect_bool "remove uuid multi" false multi_id; + expect_int "remove uuid ids" 0 (Vec.length ids) + | _ -> fail_test "expected uuid remove block"); + let page = + expect_ok "remove page by title" + (Remove.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Remove.Parsed_page { id = None; page = Some " Home " })) + in + (match page with + | Remove.Remove_page { page = Some page; id = None; _ } -> + expect_equal "remove page trims" "Home" page + | _ -> fail_test "expected remove page by title"); + let tag = + expect_ok "remove tag by id" + (Remove.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Remove.Parsed_tag { id = Some 42L; name = None })) + in + (match tag with + | Remove.Remove_tag { id = Some 42L; name = None; _ } -> pass + | _ -> fail_test "expected remove tag by id"); + expect_error_code "remove requires repo" "missing-repo" + (Remove.build (config ()) (Global_opts.create ()) + (Remove.Parsed_block { id_raw = Some "1"; uuid = None })); + expect_error_code "remove block missing target" "missing-target" + (Remove.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Remove.Parsed_block { id_raw = None; uuid = None })); + expect_error_code "remove page missing" "missing-page-name" + (Remove.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Remove.Parsed_page { id = None; page = None }))); + + test_promise "CLI parity remove page unwraps apply result map" (fun () -> + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/cli-list-pages" body then + "[[\"^ \ + \",\"~:db/id\",190,\"~:block/title\",\"Home\",\"~:block/name\",\"home\",\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000190\"]]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then "[\"^ \",\"~:result\",true]" + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Remove.Remove_page + { + repo; + graph = Cli_config.repo_to_graph repo; + id = None; + page = Some "Home"; + } + in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Remove.execute action cfg Output.Mode.Json) + in + expect_bool "remove page succeeds" false (Cli_result.is_error result); + let data = expect_some "remove page data" (Cli_result.data_value result) in + expect_bool "remove page result" true + (expect_some "remove page result bool" + (Edn_util.get_bool data "result")); + Js.Promise.resolve pass)); + + test_promise "CLI parity remove block rejects page entities before delete" + (fun () -> + let apply_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",190,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000190\",\"~:block/name\",\"some-page\"]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "[\"^ \",\"~:result\",true]") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Remove.Remove_block + { + repo; + graph = Cli_config.repo_to_graph repo; + id = Some 190L; + ids = Vec.singleton 190L; + multi_id = false; + uuid = None; + } + in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Remove.execute action cfg Output.Mode.Human) + in + expect_bool "remove page target is error" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "remove page target code" "invalid-target" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected remove error"); + expect_bool "delete not called" false !apply_called; + Js.Promise.resolve pass)); + + test_promise "CLI parity remove block by id deletes resolved block uuid" + (fun () -> + let captured_apply_body = ref None in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",190,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000190\"]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + captured_apply_body := Some body; + "[\"^ \",\"~:result\",true]") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Remove.Remove_block + { + repo; + graph = Cli_config.repo_to_graph repo; + id = Some 190L; + ids = Vec.singleton 190L; + multi_id = false; + uuid = None; + } + in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Remove.execute action cfg Output.Mode.Human) + in + expect_bool "remove block succeeds" false (Cli_result.is_error result); + let body = expect_some "captured apply body" !captured_apply_body in + expect_named_contains "delete method" body + "thread-api/apply-outliner-ops"; + expect_named_contains "delete op" body "delete-blocks"; + expect_named_contains "delete uuid" body + "00000000-0000-4000-8000-000000000190"; + Js.Promise.resolve pass)); + + test "CLI parity show target resolves stdin ids vectors ids uuid and pages" + (fun () -> + expect_equal "stdin trim" "42" + (expect_some "stdin" (Show.normalize_stdin_id (Some " 42\n"))); + expect_none "blank stdin" (Show.normalize_stdin_id (Some " \n")); + let by_id = + expect_ok "show id" + (Show.resolve_target + { + id_raw = Some "42"; + uuid = None; + page = None; + page_hierarchy = false; + linked_references = None; + ref_id_footer = None; + level = None; + stdin_id = None; + }) + in + (match by_id with + | Show.By_id 42L -> pass + | _ -> fail_test "expected By_id 42"); + let multi = + expect_ok "show ids" + (Show.resolve_target + { + id_raw = Some "[1,2]"; + uuid = None; + page = None; + page_hierarchy = false; + linked_references = None; + ref_id_footer = None; + level = None; + stdin_id = None; + }) + in + (match multi with + | Show.By_ids ids -> + expect_int "show id count" 2 (Vec.length ids); + expect_int64 "show first id" 1L (Vec.nth ids 0); + expect_int64 "show second id" 2L (Vec.nth ids 1) + | _ -> fail_test "expected By_ids"); + expect_equal "ambiguous show" + "only one of --id, --uuid, or --page is allowed" + (expect_some "invalid" + (Show.invalid_options + { + id_raw = Some "1"; + uuid = Some "00000000-0000-4000-8000-000000000001"; + page = None; + page_hierarchy = false; + linked_references = None; + ref_id_footer = None; + level = None; + stdin_id = None; + }))); + + test "CLI parity show build threads hierarchy references footer and level" + (fun () -> + let base_opts = + { + Show.id_raw = Some "42"; + uuid = None; + page = None; + page_hierarchy = false; + linked_references = None; + ref_id_footer = None; + level = None; + stdin_id = None; + } + in + let default_action = + expect_ok "show defaults" + (Show.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Show.Parsed_show base_opts)) + in + expect_equal "show repo" "logseq_db_demo" + (repo_text default_action.Show.repo); + expect_equal "show graph" "demo" (graph_text default_action.Show.graph); + (match default_action.Show.target with + | Show.By_id 42L -> pass + | _ -> fail_test "expected By_id target"); + expect_bool "single id" false default_action.Show.multi_id; + expect_bool "linked refs default" true + default_action.Show.linked_references; + expect_bool "ref id footer default" true default_action.Show.ref_id_footer; + expect_bool "page hierarchy default" false + default_action.Show.page_hierarchy; + expect_int "default level" 10 + (expect_some "level" default_action.Show.level); + let custom_action = + expect_ok "show custom flags" + (Show.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Show.Parsed_show + { + base_opts with + id_raw = Some "[1,2]"; + linked_references = Some false; + ref_id_footer = Some false; + page_hierarchy = true; + level = Some 3; + })) + in + (match custom_action.Show.target with + | Show.By_ids ids -> + expect_int "multi id count" 2 (Vec.length ids); + expect_int64 "multi first id" 1L (Vec.nth ids 0); + expect_int64 "multi second id" 2L (Vec.nth ids 1) + | _ -> fail_test "expected multi-id target"); + expect_bool "multi id flag" true custom_action.Show.multi_id; + expect_bool "linked refs disabled" false + custom_action.Show.linked_references; + expect_bool "ref id footer disabled" false + custom_action.Show.ref_id_footer; + expect_bool "page hierarchy enabled" true + custom_action.Show.page_hierarchy; + expect_int "custom level" 3 + (expect_some "custom level" custom_action.Show.level); + expect_error_code "show requires repo" "missing-repo" + (Show.build (config ()) (Global_opts.create ()) + (Show.Parsed_show base_opts)); + expect_error_code "show invalid level" "invalid-options" + (Show.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Show.Parsed_show { base_opts with level = Some 0 }))); + + test_promise "CLI parity show rejects recycled pages" (fun () -> + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000042\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]" + else if Js.String.includes ~search:"thread-api/q" body then "[]" + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + { + Show.repo; + graph = Cli_config.repo_to_graph repo; + target = Show.By_page "home"; + multi_id = false; + linked_references = false; + ref_id_footer = false; + page_hierarchy = false; + level = Some 10; + } + in + let* result = + effect_to_promise + (execute_with_output Show.execute action cfg Output.Mode.Human) + in + expect_bool "show recycled page errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "show recycled code" "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected show error"); + Js.Promise.resolve pass)); + + test + "CLI parity upsert validation rejects ambiguous and mode-specific options" + (fun () -> + expect_equal "upsert page ambiguous" + "only one of --id or --page is allowed" + (expect_some "invalid" + (Upsert.invalid_options + (Upsert.Parsed_page + { + id = Some 1L; + page = Some "Home"; + restore = false; + update_tags_edn = None; + update_properties_edn = None; + remove_tags_edn = None; + remove_properties_edn = None; + }))); + expect_equal "asset missing path" "--path is required in create mode" + (expect_some "invalid" + (Upsert.invalid_options + (Upsert.Parsed_asset + { + id = None; + uuid = None; + path = None; + target_id = None; + target_uuid = None; + target_page = None; + pos = None; + content = None; + }))); + expect_equal "task no status conflict" + "--status and --no-status are mutually exclusive" + (expect_some "invalid" + (Upsert.invalid_options + (Upsert.Parsed_task + { + id = None; + uuid = None; + page = Some "Home"; + content = None; + target_id = None; + target_uuid = None; + target_page = None; + pos = None; + status = Some "todo"; + priority = None; + scheduled = None; + deadline = None; + no_status = true; + no_priority = false; + no_scheduled = false; + no_deadline = false; + })))); + + test "CLI parity upsert page parses restore option" (fun () -> + let request = + expect_parse_ok "upsert page restore" + [| + "upsert"; + "page"; + "--page"; + "Home"; + "--restore"; + "--update-properties"; + "{\"status\" \"done\"}"; + |] + in + match request.command with + | Cli_request.Upsert (Upsert.Parsed_page opts) -> + expect_equal "page" "Home" (expect_some "page" opts.page); + expect_bool "restore" true opts.restore + | _ -> fail_test "expected upsert page"); + + test_promise "CLI parity upsert page rejects recycled page by default" + (fun () -> + let apply_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then + "[[\"^ \ + \",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + Upsert.Upsert_page + { + repo; + graph = Cli_config.repo_to_graph repo; + mode = Upsert.Create; + id = None; + page = Some "Home"; + restore = false; + plan = Property.empty_update_plan; + } + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert recycled page errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "upsert recycled code" "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected upsert error"); + expect_bool "no ops applied" false !apply_called; + Js.Promise.resolve pass)); + + test_promise "CLI parity upsert page update rejects recycled page by default" + (fun () -> + let apply_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + Upsert.Upsert_page + { + repo; + graph = Cli_config.repo_to_graph repo; + mode = Upsert.Update; + id = Some 50L; + page = None; + restore = false; + plan = Property.empty_update_plan; + } + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert recycled page update errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "upsert recycled update code" "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected upsert update error"); + expect_bool "no update ops applied" false !apply_called; + Js.Promise.resolve pass)); + + test_promise "CLI parity upsert page restores recycled page when requested" + (fun () -> + let apply_calls = ref Vec.empty in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then + "[[\"^ \ + \",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_calls := Vec.push_back !apply_calls body; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let plan = + { + Property.empty_update_plan with + update_properties = + Vec.of_array + [| + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "user.property/status"); + value = Edn_util.string "done"; + }; + |]; + } + in + let action = + Upsert.Upsert_page + { + repo; + graph = Cli_config.repo_to_graph repo; + mode = Upsert.Create; + id = None; + page = Some "Home"; + restore = true; + plan; + } + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert restore ok" false (Cli_result.is_error result); + expect_int "restore then update calls" 2 (Vec.length !apply_calls); + expect_named_contains "first op restores recycled page" + (Vec.nth !apply_calls 0) "restore-recycled"; + expect_named_contains "second op updates restored page" + (Vec.nth !apply_calls 1) "batch-set-property"; + Js.Promise.resolve pass)); + + test_promise + "CLI parity upsert page restore rejects recycled page without uuid" + (fun () -> + let apply_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then + "[[\"^ \ + \",\"~:db/id\",50,\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + Upsert.Upsert_page + { + repo; + graph = Cli_config.repo_to_graph repo; + mode = Upsert.Create; + id = None; + page = Some "Home"; + restore = true; + plan = Property.empty_update_plan; + } + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert recycled restore without uuid errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "upsert recycled restore without uuid code" + "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected upsert restore error"); + expect_bool "no restore ops applied" false !apply_called; + Js.Promise.resolve pass)); + + test_promise + "CLI parity upsert page update restore rejects recycled page without uuid" + (fun () -> + let apply_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",50,\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + Upsert.Upsert_page + { + repo; + graph = Cli_config.repo_to_graph repo; + mode = Upsert.Update; + id = Some 50L; + page = None; + restore = true; + plan = Property.empty_update_plan; + } + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert recycled update restore without uuid errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "upsert recycled update restore without uuid code" + "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected upsert update restore error"); + expect_bool "no update restore ops applied" false !apply_called; + Js.Promise.resolve pass)); + + test_promise "CLI parity upsert block create rejects recycled target page" + (fun () -> + let apply_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then + "[[\"^ \ + \",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]" + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + Upsert.Upsert_block + (Upsert.Block_create + { + repo; + graph = Cli_config.repo_to_graph repo; + target = Upsert.Target_page "Home"; + pos = Block.Last_child; + status = None; + tags = Vec.empty; + properties = Vec.empty; + blocks = Vec.singleton (Block.make ~title:"Child" ()); + update_plan = Property.empty_update_plan; + }) + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert block create recycled target errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "upsert block create recycled code" "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected upsert block create error"); + expect_bool "no create ops applied" false !apply_called; + Js.Promise.resolve pass)); + + test_promise "CLI parity upsert block update rejects recycled target page" + (fun () -> + let apply_called = ref false in + let pull_count = ref 0 in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then ( + incr pull_count; + match !pull_count with + | 1 -> + "[\"^ \ + \",\"~:db/id\",207,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000207\",\"~:block/title\",\"Source\"]" + | 2 -> + "[\"^ \ + \",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]" + | _ -> + fail_test + (Printf.sprintf "unexpected pull %d: %s" !pull_count body); + "") + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then ( + apply_called := true; + "true") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + Upsert.Upsert_block + (Upsert.Block_update + { + repo; + graph = Cli_config.repo_to_graph repo; + source = Upsert.Source_id 207L; + target = Some (Upsert.Target_page "Home"); + pos = Some Block.Last_child; + update_tags = Vec.empty; + update_properties = Vec.empty; + remove_tags = Vec.empty; + remove_properties = Vec.empty; + content = None; + source_label = Some "207"; + target_label = Some "page:Home"; + }) + in + let* result = + effect_to_promise + (execute_with_output Upsert.execute action cfg Output.Mode.Human) + in + expect_bool "upsert block update recycled target errors" true + (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "upsert block update recycled code" "recycled-page" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected upsert block update error"); + expect_bool "no update ops applied" false !apply_called; + Js.Promise.resolve pass)); + + test "CLI parity upsert block update property parsing keeps update contracts" + (fun () -> + let block_update_opts = + { + Upsert.id = Some 207L; + uuid = None; + target_id = None; + target_uuid = None; + target_page = None; + pos = None; + content = None; + blocks_edn = None; + blocks_file = None; + update_tags_edn = None; + update_properties_edn = None; + remove_tags_edn = None; + remove_properties_edn = None; + } + in + expect_error_code "update-properties vector" "invalid-options" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_block + { block_update_opts with update_properties_edn = Some "[\"p1\"]" })); + expect_error_code "update-tags map" "invalid-options" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_block + { block_update_opts with update_tags_edn = Some "{\"t1\" true}" })); + let action = + expect_ok "valid update-properties map" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_block + { + block_update_opts with + update_properties_edn = Some "{\"p1\" \"val\"}"; + })) + in + match action with + | Upsert.Upsert_block (Upsert.Block_update update) -> + expect_int "update property count" 1 + (Vec.length update.update_properties); + expect_equal "update property key" "p1" + (property_key_text + (Vec.peek_front update.update_properties).Property.key) + | _ -> fail_test "expected Upsert_block update action"); + + test "CLI parity upsert tag parses schema property vectors" (fun () -> + let action = + expect_ok "valid tag schema properties" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_tag + { + id = None; + name = Some "Project"; + add_properties_edn = Some "[\"status\" :user.property/owner]"; + remove_properties_edn = Some "[123]"; + })) + in + (match action with + | Upsert.Upsert_tag + { mode = Upsert.Create; add_properties; remove_properties; _ } -> + expect_int "add property count" 2 (Vec.length add_properties); + expect_equal "add property by name" "status" + (property_key_text (Vec.peek_front add_properties)); + expect_equal "remove property by id" "123" + (property_key_text (Vec.peek_front remove_properties)) + | _ -> fail_test "expected Upsert_tag create action"); + expect_error_code "add-properties map" "invalid-options" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_tag + { + id = None; + name = Some "Project"; + add_properties_edn = Some "{\"status\" true}"; + remove_properties_edn = None; + }))); + + test + "CLI parity upsert task build preserves create update and clear semantics" + (fun () -> + let base_task_opts = + { + Upsert.id = None; + uuid = None; + page = None; + content = None; + target_id = None; + target_uuid = None; + target_page = None; + pos = None; + status = None; + priority = None; + scheduled = None; + deadline = None; + no_status = false; + no_priority = false; + no_scheduled = false; + no_deadline = false; + } + in + let build_task name opts = + expect_ok name + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_task opts)) + in + expect_error_code "task requires target" "missing-target" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_task base_task_opts)); + expect_error_code "task rejects page and content" "invalid-options" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_task + { base_task_opts with page = Some "Home"; content = Some "Task" })); + let scheduled = + expect_some "scheduled date" + (Time.parse_rfc3339 "2026-02-10T08:00:00.000Z") + in + let deadline = + expect_some "deadline date" + (Time.parse_rfc3339 "2026-02-12T18:00:00.000Z") + in + let created = + build_task "create task" + { + base_task_opts with + content = Some "Task from CLI"; + status = Some "todo"; + priority = Some "high"; + scheduled = Some scheduled; + deadline = Some deadline; + } + in + (match created with + | Upsert.Upsert_task task -> + expect_bool "create mode" true (task.mode = Upsert.Create); + expect_equal "create repo" "logseq_db_demo" (repo_text task.repo); + expect_equal "create graph" "demo" (graph_text task.graph); + expect_equal "create content" "Task from CLI" + (expect_some "content" task.content); + expect_equal "status input" "todo" + (expect_some "status input" task.status_input); + let property_keys = + task.update_properties + |> Vec.map (fun item -> item.Property.key) + |> Vec.map property_key_text + in + expect_bool "priority update" true + (Vec.mem "logseq.property/priority" property_keys); + expect_bool "scheduled update" true + (Vec.mem "logseq.property/scheduled" property_keys); + expect_bool "deadline update" true + (Vec.mem "logseq.property/deadline" property_keys) + | _ -> fail_test "expected Upsert_task create action"); + let updated = + build_task "update task" + { + base_task_opts with + id = Some 42L; + no_status = true; + no_priority = true; + no_scheduled = true; + no_deadline = true; + } + in + (match updated with + | Upsert.Upsert_task task -> + expect_bool "update mode" true (task.mode = Upsert.Update); + expect_int64 "update id" 42L (expect_some "update id" task.id); + let clear_keys = task.clear_properties |> Vec.map property_key_text in + expect_bool "clear status" true + (Vec.mem "logseq.property/status" clear_keys); + expect_bool "clear priority" true + (Vec.mem "logseq.property/priority" clear_keys); + expect_bool "clear scheduled" true + (Vec.mem "logseq.property/scheduled" clear_keys); + expect_bool "clear deadline" true + (Vec.mem "logseq.property/deadline" clear_keys) + | _ -> fail_test "expected Upsert_task update action"); + Vec.iter + (fun opts -> + expect_error_code "task set no conflict" "invalid-options" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_task opts))) + (Vec.of_array + [| + { + base_task_opts with + id = Some 42L; + status = Some "todo"; + no_status = true; + }; + { + base_task_opts with + id = Some 42L; + priority = Some "high"; + no_priority = true; + }; + { + base_task_opts with + id = Some 42L; + scheduled = Some scheduled; + no_scheduled = true; + }; + { + base_task_opts with + id = Some 42L; + deadline = Some deadline; + no_deadline = true; + }; + |]); + let unknown_status = + build_task "unknown status" + { + base_task_opts with + content = Some "Task from CLI"; + status = Some "wat"; + } + in + (match unknown_status with + | Upsert.Upsert_task task -> + expect_bool "unknown status create mode" true + (task.mode = Upsert.Create); + expect_equal "unknown status input" "wat" + (expect_some "unknown status input" task.status_input) + | _ -> fail_test "expected Upsert_task unknown status action"); + let invalid_priority = + Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_task + { + base_task_opts with + content = Some "Task from CLI"; + priority = Some "wat"; + }) + in + expect_error_code "invalid priority code" "invalid-options" + invalid_priority; + match invalid_priority with + | Error err -> + expect_named_contains "invalid priority message" err.Error.message + "Invalid value for option :priority: wat"; + expect_named_contains "invalid priority values" err.Error.message + "Available values: low, medium, high, urgent" + | Ok _ -> fail_test "expected invalid priority"); + + test "CLI parity upsert asset build derives title and selector modes" + (fun () -> + let base_asset_opts = + { + Upsert.id = None; + uuid = None; + path = None; + target_id = None; + target_uuid = None; + target_page = Some "Home"; + pos = None; + content = None; + } + in + let created = + expect_ok "asset create" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_asset + { base_asset_opts with path = Some "/tmp/images/team-logo.png" })) + in + (match created with + | Upsert.Upsert_asset asset -> + expect_bool "asset create mode" true (asset.mode = Upsert.Create); + expect_equal "asset create path" "/tmp/images/team-logo.png" + (expect_some "asset path" asset.path); + expect_equal "asset default title" "team-logo.png" + (expect_some "asset content" asset.content); + ignore (expect_some "asset create action" asset.create_action) + | _ -> fail_test "expected Upsert_asset create action"); + let updated = + expect_ok "asset update" + (Upsert.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Upsert.Parsed_asset + { + base_asset_opts with + id = Some 42L; + target_page = None; + content = Some "Caption"; + })) + in + match updated with + | Upsert.Upsert_asset asset -> + expect_bool "asset update mode" true (asset.mode = Upsert.Update); + expect_int64 "asset id" 42L (expect_some "asset id" asset.id); + expect_none "asset update path ignored" asset.path; + expect_equal "asset update content" "Caption" + (expect_some "asset update content" asset.content) + | _ -> fail_test "expected Upsert_asset update action"); + + test "CLI parity property and block option parsers normalize known values" + (fun () -> + expect_bool "position first-child" true + (Block.position_of_string "first-child" = Some Block.First_child); + expect_bool "position sibling" true + (Block.position_of_string "sibling" = Some Block.Sibling); + expect_none "position invalid" (Block.position_of_string "middle"); + expect_bool "property url kind" true + (Property.kind_of_string "url" = Some Property.Url); + expect_equal "property kind string" "date" + (Property.string_of_kind Property.Date); + expect_equal "property other kind string" "user.property/custom" + (Property.string_of_kind + (Property.Other (Edn_util.keyword_t "user.property/custom"))); + expect_bool "cardinality many" true + (Property.cardinality_of_string "many" = Some Property.Many); + expect_none "bad cardinality" (Property.cardinality_of_string "all"); + (match Property.parse_key (Edn_util.int64 801L) with + | Some (Property.Key_id 801L) -> pass + | _ -> fail_test "expected property key id"); + (match Property.parse_key (Edn_util.keyword "user.property/rating") with + | Some (Property.Key_ident ident) -> + expect_equal "property ident key" "user.property/rating" + (Edn_util.keyword_to_string ident) + | _ -> fail_test "expected property key ident"); + (match Property.parse_key (Edn_util.string "Correct Answer") with + | Some (Property.Key_name name) -> + expect_equal "property name key" "Correct Answer" name + | _ -> fail_test "expected property key name"); + expect_none "property key rejects bool" + (Property.parse_key (Edn_util.bool true)); + let child = + Block.make ~uuid:"00000000-0000-4000-8000-000000000102" ~title:"Child" + () + in + let root = + Block.make ~uuid:"00000000-0000-4000-8000-000000000101" ~title:"Root" + ~children:(Vec.singleton child) () + in + let flat = Block.flatten (Vec.singleton root) in + expect_int "flatten count" 2 (Vec.length flat); + expect_equal "flatten root first" "Root" + (expect_some "root label" (Block.label (Vec.nth flat 0))); + expect_equal "flatten child second" "Child" + (expect_some "child label" (Block.label (Vec.nth flat 1))); + let serialized = Edn_util.any (Block.to_value root) in + expect_equal "serialized block title" "Root" + (expect_some "serialized title" + (Edn_util.get_string serialized "block/title")); + expect_bool "serialized child vector" true + (Option.is_some (Edn_util.get serialized "block/children"))); + + test "CLI parity add block parsing preserves uuids tags and raw blocks" + (fun () -> + let parsed = + expect_ok "parse blocks" + (Add.parse_blocks_edn ~label:"blocks" + "[{:block/title \"Root\" :block/uuid #uuid \ + \"00000000-0000-4000-8000-000000000301\" :block/tags \ + [\"Project\" :logseq.class/Tag] :block/children [{:block/content \ + \"Child\" :block/uuid #uuid \ + \"00000000-0000-4000-8000-000000000302\"}]} \"Loose\"]") + in + expect_int "parsed block count" 2 (Vec.length parsed); + let root = Vec.nth parsed 0 in + expect_equal "root title" "Root" (expect_some "root title" root.title); + expect_equal "root uuid" "00000000-0000-4000-8000-000000000301" + (expect_some "root uuid" root.uuid); + expect_int "root tags" 2 (Vec.length root.tags); + expect_int "root children" 1 (Vec.length root.children); + expect_equal "child content title" "Child" + (expect_some "child title" (Vec.peek_front root.children).title); + expect_equal "string block title" "Loose" + (expect_some "loose title" (Vec.nth parsed 1).title); + expect_error_code "blocks must be vector" "invalid-blocks" + (Add.parse_blocks_edn ~label:"blocks" "{:block/title \"Root\"}"); + expect_error_code "invalid blocks edn" "invalid-options" + (Add.parse_blocks_edn ~label:"blocks" "[{:block/title")); + + test "CLI parity add collect created block uuids depth-first and unique" + (fun () -> + let nested = + Edn_util.map + [ + ( Edn_util.keyword "block/uuid", + Edn_util.uuid "00000000-0000-4000-8000-000000000401" ); + ( Edn_util.keyword "block/children", + Edn_util.vector + [ + Edn_util.map + [ + ( Edn_util.keyword "block/uuid", + Edn_util.uuid "00000000-0000-4000-8000-000000000402" ); + ]; + Edn_util.map + [ + (Edn_util.keyword "block/title", Edn_util.string "No uuid"); + ( Edn_util.keyword "block/children", + Edn_util.vector + [ + Edn_util.map + [ + ( Edn_util.keyword "block/uuid", + Edn_util.uuid + "00000000-0000-4000-8000-000000000403" ); + ]; + ] ); + ]; + ] ); + ] + in + let inserted = + Edn_util.vector + [ + nested; + Edn_util.map + [ + ( Edn_util.keyword "block/uuid", + Edn_util.uuid "00000000-0000-4000-8000-000000000404" ); + ]; + Edn_util.map + [ + ( Edn_util.keyword "block/uuid", + Edn_util.uuid "00000000-0000-4000-8000-000000000402" ); + ]; + ] + in + expect_equal "collected uuids" + "00000000-0000-4000-8000-000000000401,00000000-0000-4000-8000-000000000402,00000000-0000-4000-8000-000000000403,00000000-0000-4000-8000-000000000404" + (Vec.string_concat "," + (Add.collect_uuids_from_value inserted |> Add.unique)); + let child = + Block.make ~uuid:"00000000-0000-4000-8000-000000000502" ~title:"Child" + () + in + let root = + Block.make ~uuid:"00000000-0000-4000-8000-000000000501" ~title:"Root" + ~children:(Vec.singleton child) () + in + let duplicate = + Block.make ~uuid:"00000000-0000-4000-8000-000000000502" + ~title:"Duplicate" () + in + expect_equal "action uuids" + "00000000-0000-4000-8000-000000000501,00000000-0000-4000-8000-000000000502" + (Vec.string_concat "," + (Add.collect_action_block_uuids (Vec.of_array [| root; duplicate |])))); + + test "CLI parity add action validates targets metadata and status" (fun () -> + let base_opts = + { + Add.target_id = None; + target_uuid = None; + target_page_name = Some "Home"; + pos = None; + status = None; + tags_edn = None; + properties_edn = None; + content = Some "Hello from add"; + blocks_edn = None; + blocks_file = None; + } + in + expect_error_code "ambiguous target" "invalid-options" + (Add.build_add_block_action + { base_opts with target_id = Some 42L } + Vec.empty + (Cli_primitive.create_repo "demo")); + expect_error_code "invalid target uuid" "invalid-options" + (Add.build_add_block_action + { base_opts with target_page_name = None; target_uuid = Some "bad" } + Vec.empty + (Cli_primitive.create_repo "demo")); + expect_error_code "sibling requires block target" "invalid-options" + (Add.build_add_block_action + { base_opts with pos = Some Block.Sibling } + Vec.empty + (Cli_primitive.create_repo "demo")); + expect_error_code "blocks cannot combine metadata" "invalid-options" + (Add.build_add_block_action + { + base_opts with + blocks_edn = Some "[\"Block\"]"; + tags_edn = Some "[\"Project\"]"; + } + Vec.empty + (Cli_primitive.create_repo "demo")); + expect_error_code "invalid status" "invalid-options" + (Add.build_add_block_action + { base_opts with status = Some "wat" } + Vec.empty + (Cli_primitive.create_repo "demo")); + let action = + expect_ok "add action" + (Add.build_add_block_action + { + base_opts with + status = Some "in progress"; + tags_edn = + Some + "[\"Project\" :logseq.class/Tag #uuid \ + \"00000000-0000-4000-8000-000000000601\" 701]"; + properties_edn = Some "{\"Correct Answer\" \"42\" 801 true}"; + } + Vec.empty + (Cli_primitive.create_repo "demo")) + in + expect_equal "repo" "logseq_db_demo" (repo_text action.repo); + expect_equal "graph" "demo" (graph_text action.graph); + expect_bool "default position" true (action.pos = Block.Last_child); + expect_equal "normalized status" "logseq.property/status.doing" + (keyword_text (expect_some "status" action.status)); + expect_int "tag selectors" 4 (Vec.length action.tags); + expect_int "property assignments" 2 (Vec.length action.properties); + expect_int "block count" 1 (Vec.length action.blocks); + expect_equal "content block" "Hello from add" + (expect_some "block title" (Vec.peek_front action.blocks).title); + ignore (expect_some "generated uuid" (Vec.peek_front action.blocks).uuid)); + + test "CLI parity add metadata ops apply status tags and properties by uuid" + (fun () -> + let ops = + Add.metadata_ops + (Vec.of_array + [| + "00000000-0000-4000-8000-000000000701"; + "00000000-0000-4000-8000-000000000702"; + |]) + (Some (Edn_util.keyword_t "logseq.property/status.todo")) + (Vec.of_array + [| + Entity.of_value + (Edn_util.map_vec + (Vec.singleton + (Edn_util.keyword "db/id", Edn_util.int64 901L))); + Entity.of_value + (Edn_util.map_vec + (Vec.singleton + (Edn_util.keyword "db/id", Edn_util.int64 901L))); + |]) + (Vec.of_array + [| + { + Property.key = + Property.Key_ident + (Edn_util.keyword_t "user.property/root-answer"); + value = Edn_util.string "root"; + }; + |]) + in + expect_int "metadata ops count" 3 (Vec.length ops); + let output = + Vec.string_concat "\n" (Vec.map Melange_edn_melange.to_edn_string ops) + in + expect_named_contains "status op" output ":logseq.property/status.todo"; + expect_named_contains "tag op" output ":block/tags 901"; + expect_named_contains "property op" output + ":user.property/root-answer \"root\""); + + test_promise + "CLI parity add reports target-not-found when concurrent delete removes \ + insert target" (fun () -> + let pull_count = ref 0 in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then ( + incr pull_count; + match !pull_count with + | 1 -> + "[\"^ \ + \",\"~:db/id\",627,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000627\"]" + | _ -> "null") + else if + Js.String.includes ~search:"thread-api/apply-outliner-ops" body + then "null" + else "null") + in + with_server server (fun base_url -> + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let action = + expect_ok "add action" + (Add.build_add_block_action + { + Add.target_id = Some 627L; + target_uuid = None; + target_page_name = None; + pos = Some Block.First_child; + status = None; + tags_edn = None; + properties_edn = None; + content = Some "Child"; + blocks_edn = None; + blocks_file = None; + } + Vec.empty + (Cli_primitive.create_repo "demo")) + in + let* result = + effect_to_promise + (Add.execute_add_block action + (config_with_output cfg Output.Mode.Human) + Output.Mode.Human) + in + expect_bool "add result is error" true (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "add concurrent target delete code" + "target-not-found" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected add error"); + expect_int "target was rechecked" 3 !pull_count; + Js.Promise.resolve pass)); + + test "CLI parity update build action validates property and tag edn" + (fun () -> + let base_opts = + { + Update.id = Some 207L; + uuid = None; + target_id = None; + target_uuid = None; + target_page = None; + pos = None; + status = None; + content = None; + update_tags_edn = None; + update_properties_edn = None; + remove_tags_edn = None; + remove_properties_edn = None; + blocks_edn = None; + blocks_file = None; + } + in + expect_error_code "update properties vector" "invalid-options" + (Update.build_action + { base_opts with update_properties_edn = Some "[\"p1\"]" } + (Cli_primitive.create_repo "demo")); + expect_error_code "update tags map" "invalid-options" + (Update.build_action + { base_opts with update_tags_edn = Some "{\"t1\" true}" } + (Cli_primitive.create_repo "demo")); + expect_error_code "missing source" "missing-source" + (Update.build_action + { + base_opts with + id = None; + update_properties_edn = Some "{\"p1\" 1}"; + } + (Cli_primitive.create_repo "demo")); + let action = + expect_ok "valid update" + (Update.build_action + { + base_opts with + status = Some "todo"; + content = Some "Updated"; + target_page = Some "Archive"; + update_tags_edn = Some "[\"Project\"]"; + update_properties_edn = Some "{\"p1\" \"val\"}"; + remove_tags_edn = Some "[701]"; + remove_properties_edn = Some "[\"old-prop\"]"; + } + (Cli_primitive.create_repo "demo")) + in + expect_equal "repo" "logseq_db_demo" (repo_text action.repo); + expect_equal "graph" "demo" (graph_text action.graph); + expect_int64 "source id" 207L (expect_some "id" action.id); + expect_equal "content" "Updated" (expect_some "content" action.content); + expect_equal "target page" "Archive" + (expect_some "target page" action.target_page); + expect_bool "default move position" true + (action.pos = Some Block.First_child); + expect_equal "source label" "207" + (expect_some "source label" action.source_label); + expect_equal "target label" "page:Archive" + (expect_some "target label" action.target_label); + expect_int "update tags" 1 (Vec.length action.update_tags); + expect_int "remove tags" 1 (Vec.length action.remove_tags); + expect_int "update properties include status" 2 + (Vec.length action.update_properties); + expect_int "remove properties" 1 (Vec.length action.remove_properties)); + + test "CLI parity update tag query uses datascript find syntax" (fun () -> + let query = + Melange_edn_melange.to_edn_string (Update.tag_query Update.tag_selector) + in + expect_named_contains "find" query ":find"; + expect_named_contains "tag class" query "logseq.class/Tag"); + + test "CLI parity sync config key parser accepts ws-url and http-base only" + (fun () -> + expect_bool "ws-url" true + (Sync.config_key_of_string "ws-url" = Some Sync.Ws_url); + expect_bool "http-base" true + (Sync.config_key_of_string "http-base" = Some Sync.Http_base); + expect_equal "ws-url string" "ws-url" + (Sync.string_of_config_key Sync.Ws_url); + expect_none "bad sync key" (Sync.config_key_of_string "graph")); + + test "CLI parity sync build validates repo selectors and e2ee options" + (fun () -> + let globals = + Global_opts.create ~graph:(Cli_primitive.create_graph "demo") () + in + expect_error_code "sync status requires repo" "missing-repo" + (Sync.build (config ()) (Global_opts.create ()) Sync.Parsed_status); + expect_error_code "sync download requires graph" "missing-graph" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_download { progress = None; e2ee_password = None })); + let default_download = + expect_ok "sync download default" + (Sync.build (config ()) globals + (Sync.Parsed_download { progress = None; e2ee_password = None })) + in + (match default_download with + | Sync.Sync_download action -> + expect_equal "download repo" "logseq_db_demo" (repo_text action.repo); + expect_equal "download graph" "demo" (graph_text action.graph); + expect_bool "download allow missing" true action.allow_missing_graph; + expect_bool "download require missing" true + action.require_missing_graph; + expect_bool "download progress default" false action.progress; + expect_bool "download progress not explicit" false + action.progress_explicit + | _ -> fail_test "expected Sync_download action"); + let explicit_download = + expect_ok "sync download explicit" + (Sync.build (config ()) globals + (Sync.Parsed_download + { progress = Some false; e2ee_password = Some "pw" })) + in + (match explicit_download with + | Sync.Sync_download action -> + expect_bool "download progress false" false action.progress; + expect_bool "download progress explicit" true action.progress_explicit; + expect_equal "download password" "pw" + (expect_some "download e2ee" action.e2ee_password) + | _ -> fail_test "expected explicit Sync_download action"); + let start = + expect_ok "sync start" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_start { e2ee_password = Some "pw" })) + in + (match start with + | Sync.Sync_start action -> + expect_equal "start password" "pw" + (expect_some "start e2ee" action.e2ee_password) + | _ -> fail_test "expected Sync_start action"); + let upload = + expect_ok "sync upload" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_upload { e2ee_password = Some "pw" })) + in + (match upload with + | Sync.Sync_upload action -> + expect_equal "upload password" "pw" + (expect_some "upload e2ee" action.e2ee_password) + | _ -> fail_test "expected Sync_upload action"); + let ensure_keys = + expect_ok "sync ensure keys" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_ensure_keys + { e2ee_password = Some "pw"; upload_keys = true })) + in + match ensure_keys with + | Sync.Sync_ensure_keys action -> + expect_equal "ensure keys password" "pw" + (expect_some "ensure keys e2ee" action.e2ee_password); + expect_bool "ensure keys upload" true action.upload_keys + | _ -> fail_test "expected Sync_ensure_keys action"); + + test "CLI parity sync build validates config and asset download actions" + (fun () -> + expect_error_code "config set missing key" "invalid-options" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_config_set { key = None; value = None })); + expect_error_code "config set missing value" "invalid-options" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_config_set { key = Some Sync.Ws_url; value = None })); + expect_error_code "config get missing key" "invalid-options" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_config_get { key = None })); + expect_error_code "config unset missing key" "invalid-options" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_config_unset { key = None })); + let set_action = + expect_ok "config set" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_config_set + { key = Some Sync.Ws_url; value = Some "ws://example/%s" })) + in + (match set_action with + | Sync.Sync_config_set { key; value } -> + expect_bool "config set key" true (key = Sync.Ws_url); + expect_equal "config set value" "ws://example/%s" value + | _ -> fail_test "expected Sync_config_set action"); + expect_error_code "grant access missing graph id" "invalid-options" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_grant_access + { graph_id = None; email = Some "user@example.com" })); + expect_error_code "grant access missing email" "invalid-options" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_grant_access + { + graph_id = Some "11111111-1111-4111-8111-111111111111"; + email = None; + })); + let by_id = + expect_ok "asset download id" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_asset_download { id = Some 123L; uuid = None })) + in + (match by_id with + | Sync.Sync_asset_download action -> + expect_equal "asset repo" "logseq_db_demo" (repo_text action.repo); + expect_equal "asset graph" "demo" (graph_text action.graph); + expect_int64 "asset id" 123L (expect_some "asset id" action.id); + expect_none "asset uuid absent" action.uuid + | _ -> fail_test "expected Sync_asset_download id action"); + let asset_uuid = "11111111-1111-4111-8111-111111111111" in + let by_uuid = + expect_ok "asset download uuid" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_asset_download { id = None; uuid = Some asset_uuid })) + in + (match by_uuid with + | Sync.Sync_asset_download action -> + expect_equal "asset uuid" asset_uuid + (expect_some "asset uuid" action.uuid); + expect_none "asset id absent" action.id + | _ -> fail_test "expected Sync_asset_download uuid action"); + expect_error_code "asset download requires repo" "missing-repo" + (Sync.build (config ()) (Global_opts.create ()) + (Sync.Parsed_asset_download { id = Some 123L; uuid = None })); + expect_error_code "asset download requires selector" "invalid-options" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_asset_download { id = None; uuid = None })); + expect_error_code "asset download rejects conflict" "invalid-options" + (Sync.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Sync.Parsed_asset_download + { id = Some 123L; uuid = Some asset_uuid }))); + + test_promise + "CLI parity sync asset download requests missing local remote asset" + (fun () -> + let root = temp_dir "logseq-cli-sync-asset-" in + let request_called = ref false in + let asset_uuid = "00000000-0000-4000-8000-000000000abc" in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then ( + expect_named_contains "uuid lookup" body asset_uuid; + "[\"^ \ + \",\"~:db/id\",123,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000abc\",\"~:block/tags\",[[\"^ \ + \",\"~:db/ident\",\"~:logseq.class/Asset\"]],\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]]") + else if Js.String.includes ~search:"thread-api/db-sync-status" body + then + "[\"^ \ + \",\"~:repo\",\"logseq_db_demo\",\"~:graph-id\",\"graph-id\",\"~:ws-state\",\"~:open\"]" + else if + Js.String.includes + ~search:"thread-api/db-sync-request-asset-download" body + then ( + request_called := true; + expect_named_contains "download request uuid" body asset_uuid; + "[\"^ \",\"~:result\",true]") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Sync.Sync_asset_download + { + repo; + graph = Cli_config.repo_to_graph repo; + id = None; + uuid = Some asset_uuid; + } + in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Sync.execute action cfg Output.Mode.Human) + in + remove_tree root; + expect_bool "asset download ok" false (Cli_result.is_error result); + let data = + expect_some "asset download data" (Cli_result.data_value result) + in + expect_equal "asset uuid" asset_uuid + (expect_some "asset uuid" (Edn_util.get_string data "asset-uuid")); + expect_equal "asset type" "png" + (expect_some "asset type" (Edn_util.get_string data "asset-type")); + expect_bool "download requested" true + (expect_some "download requested" + (Edn_util.get_bool data "download-requested?")); + expect_equal "checksum missing" "missing" + (expect_some "checksum status" + (Option.bind + (Edn_util.get data "checksum-status") + Edn_util.as_keyword)); + expect_bool "request called" true !request_called; + Js.Promise.resolve pass)); + + test_promise + "CLI parity sync asset download requires active sync before requesting" + (fun () -> + let root = temp_dir "logseq-cli-sync-asset-stopped-" in + let request_called = ref false in + let asset_uuid = "00000000-0000-4000-8000-000000000abc" in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + "[\"^ \ + \",\"~:db/id\",123,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000abc\",\"~:block/tags\",[[\"^ \ + \",\"~:db/ident\",\"~:logseq.class/Asset\"]],\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]]" + else if Js.String.includes ~search:"thread-api/db-sync-status" body + then + "[\"^ \ + \",\"~:repo\",\"logseq_db_demo\",\"~:graph-id\",\"graph-id\",\"~:ws-state\",\"~:stopped\"]" + else if + Js.String.includes + ~search:"thread-api/db-sync-request-asset-download" body + then ( + request_called := true; + "[\"^ \",\"~:result\",true]") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Sync.Sync_asset_download + { + repo; + graph = Cli_config.repo_to_graph repo; + id = None; + uuid = Some asset_uuid; + } + in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Sync.execute action cfg Output.Mode.Human) + in + remove_tree root; + expect_bool "asset download error" true (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal "sync not started code" "sync-not-started" + (Error.code_to_string err.Error.code); + expect_equal "sync start hint" + "Run logseq sync start --graph demo first." + (expect_some "sync hint" err.hint) + | None -> fail_test "expected sync not started error"); + expect_bool "request not called" false !request_called; + Js.Promise.resolve pass)); + + test_promise "CLI parity sync asset download validates remote asset metadata" + (fun () -> + let asset_uuid = "00000000-0000-4000-8000-000000000abc" in + let valid_tags = + "\"~:block/tags\",[[\"^ \",\"~:db/ident\",\"~:logseq.class/Asset\"]]" + in + let base_fields = + "\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\"," + ^ valid_tags + ^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]" + in + let asset fields = "[\"^ \"," ^ fields ^ "]" in + let run_case label asset_transit expected_code = + let root = temp_dir ("logseq-cli-sync-asset-" ^ label ^ "-") in + let request_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/pull" body then + asset_transit + else if + Js.String.includes + ~search:"thread-api/db-sync-request-asset-download" body + then ( + request_called := true; + "[\"^ \",\"~:result\",true]") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Sync.Sync_asset_download + { + repo; + graph = Cli_config.repo_to_graph repo; + id = None; + uuid = Some asset_uuid; + } + in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Sync.execute action cfg Output.Mode.Human) + in + remove_tree root; + expect_bool (label ^ " error") true (Cli_result.is_error result); + (match result.Cli_result.error with + | Some err -> + expect_equal (label ^ " code") expected_code + (Error.code_to_string err.Error.code) + | None -> fail_test (label ^ ": expected error")); + expect_bool (label ^ " no request") false !request_called; + Js.Promise.resolve pass) + in + let* () = run_case "missing" "null" "asset-not-found" in + let* () = + run_case "non-asset" + (asset + ("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid + ^ "\",\"~:block/tags\",[],\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]")) + "not-asset" + in + let* () = + run_case "missing-uuid" + (asset + ("\"~:db/id\",123," ^ valid_tags + ^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]")) + "asset-uuid-missing" + in + let* () = + run_case "missing-type" + (asset + ("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\"," + ^ valid_tags + ^ ",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]")) + "asset-type-missing" + in + let* () = + run_case "missing-checksum" + (asset + ("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\"," + ^ valid_tags + ^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/remote-metadata\",[\"^ \ + \"]")) + "asset-checksum-missing" + in + let* () = + run_case "missing-remote" + (asset + ("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\"," + ^ valid_tags + ^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\"" + )) + "asset-not-remote" + in + let* () = + run_case "external" + (asset + (base_fields + ^ ",\"~:logseq.property.asset/external-url\",\"https://example.com/a.png\"" + )) + "external-asset" + in + Js.Promise.resolve pass); + + test + "CLI parity query validation rejects db/id datom clauses and non-vector \ + queries" (fun () -> + let valid = + edn_of_string "[:find ?b :in $ ?id :where [?b :block/title ?title]]" + in + ignore (expect_ok "valid query" (Query.validate_query valid)); + let invalid = edn_of_string "[:find ?b :where [?b :db/id ?id]]" in + expect_error_code "db/id datom" ":invalid-query" + (Query.validate_query invalid); + expect_error_code "non vector query" ":invalid-options" + (Query.validate_query (Edn_util.map_vec Vec.empty))); + + test "CLI parity datascript query primitive serializes query clauses" + (fun () -> + let title_clause = + Edn_util.vector_t + [ + Edn_util.symbol "?b"; + Edn_util.keyword "block/title"; + Edn_util.symbol "?title"; + ] + in + let page_clause = + Edn_util.vector_t + [ + Edn_util.symbol "?b"; + Edn_util.keyword "block/page"; + Edn_util.symbol "?page"; + ] + in + let predicate_clause = + Edn_util.list_t + [ + Edn_util.symbol "clojure.string/includes?"; + Edn_util.symbol "?title"; + Edn_util.symbol "?query"; + ] + in + let edn query = + Melange_edn_melange.to_edn_string + (Edn_util.any (Cli_primitive.datascript_query_to_edn query)) + in + let without_inputs = + Cli_primitive.make_datascript_query + ~find:(Vec.singleton (Edn_util.symbol "?b")) + ~where:(Vec.singleton (Cli_primitive.V title_clause)) + () + in + expect_equal "datascript query without inputs" + "[:find ?b :where [?b :block/title ?title]]" (edn without_inputs); + let with_inputs = + Cli_primitive.make_datascript_query + ~find:(Vec.singleton (Edn_util.symbol "?b")) + ~in_: + (Vec.of_array + [| + Melange_edn_melange.symbol "$"; + Melange_edn_melange.symbol "?title"; + |]) + ~where:(Vec.singleton (Cli_primitive.V title_clause)) + () + in + expect_equal "datascript query with inputs" + "[:find ?b :in $ ?title :where [?b :block/title ?title]]" + (edn with_inputs); + let with_empty_inputs = + Cli_primitive.make_datascript_query + ~find:(Vec.singleton (Edn_util.symbol "?b")) + ~in_:Vec.empty + ~where:(Vec.singleton (Cli_primitive.V title_clause)) + () + in + expect_equal "datascript query with empty inputs" + "[:find ?b :where [?b :block/title ?title]]" (edn with_empty_inputs); + let with_multiple_clauses = + Cli_primitive.make_datascript_query + ~find:(Vec.singleton (Edn_util.symbol "?b")) + ~where: + (Vec.of_array + [| Cli_primitive.V title_clause; Cli_primitive.V page_clause |]) + () + in + expect_equal "datascript query with multiple where clauses" + "[:find ?b :where [?b :block/title ?title] [?b :block/page ?page]]" + (edn with_multiple_clauses); + let with_list_clause = + Cli_primitive.make_datascript_query + ~find: + (Vec.of_array [| Edn_util.symbol "?b"; Edn_util.symbol "?title" |]) + ~where: + (Vec.of_array + [| + Cli_primitive.V title_clause; Cli_primitive.L predicate_clause; + |]) + () + in + expect_equal "datascript query with list where clause" + "[:find ?b ?title :where [?b :block/title ?title] \ + (clojure.string/includes? ?title ?query)]" + (edn with_list_clause)); + + test + "CLI parity query input normalization fills defaults and validates \ + required inputs" (fun () -> + let entry = + { + Query.name = "custom"; + source = Query.Custom; + doc = None; + inputs = + Vec.of_array + [| + ({ name = "?required"; optional = false; default = None } + : Query.input_spec); + ({ + name = "?optional"; + optional = true; + default = Some (Edn_util.string "fallback"); + } + : Query.input_spec); + |]; + query = Edn_util.vector_vec Vec.empty; + } + in + expect_error_code "missing required input" ":invalid-options" + (Query.normalize_inputs (Some entry) Vec.empty); + let inputs = + expect_ok "normalize inputs" + (Query.normalize_inputs (Some entry) + (Vec.singleton (Edn_util.string "value"))) + in + expect_int "input count" 2 (Vec.length inputs); + expect_equal "default input" "fallback" + (expect_some "default" (Edn_util.as_string (Vec.nth inputs 1))); + let padded = + { + entry with + Query.name = "padded"; + inputs = + Vec.of_array + [| + ({ name = "required"; optional = false; default = None } + : Query.input_spec); + ({ name = "?optional"; optional = true; default = None } + : Query.input_spec); + |]; + } + in + let padded_inputs = + expect_ok "pad optional input" + (Query.normalize_inputs (Some padded) + (Vec.singleton (Edn_util.string "value"))) + in + expect_bool "optional input pads nil" true + (Edn_util.is_null (Vec.nth padded_inputs 1)); + let task_search = + expect_some "task-search entry" + (Query.find_query (config ()) "task-search") + in + let task_inputs = + expect_ok "task-search defaults" + (Query.normalize_inputs (Some task_search) + (Vec.singleton (Edn_util.string "doing"))) + in + expect_equal "task status keyword" "logseq.property/status.doing" + (expect_some "status keyword" + (Edn_util.as_keyword (Vec.nth task_inputs 0))); + expect_equal "task title default" "" + (expect_some "title default" + (Edn_util.as_string (Vec.nth task_inputs 1))); + expect_int "task recent-days default" 0 + (expect_some "recent-days default" + (Edn_util.as_int (Vec.nth task_inputs 2))); + ignore + (expect_some "task now-ms default" + (Edn_util.as_int64 (Vec.nth task_inputs 3)))); + + test "CLI parity query build action parses query inputs and named queries" + (fun () -> + let run = + expect_ok "query run" + (Query.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = + Some + "[:find ?e :in $ ?title :where [?e :block/title ?title]]"; + name = None; + inputs_edn = Some "[\"Hello\"]"; + })) + in + (match run with + | Query.Run { repo; graph; query; inputs; name } -> + expect_equal "query repo" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "query graph" "demo" + (Cli_primitive.string_of_graph graph); + ignore (expect_ok "built query valid" (Query.validate_query query)); + expect_int "query inputs length" 1 (Vec.length inputs); + expect_equal "query input" "Hello" + (expect_some "query input string" + (Edn_util.as_string (Vec.peek_front inputs))); + expect_none "query name none" name + | Query.List -> fail_test "expected query run"); + expect_error_code "query invalid edn" "invalid-options" + (Query.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Query.Parsed_run + { query_edn = Some "[:find ?e"; name = None; inputs_edn = None })); + expect_error_code "query invalid inputs edn" "invalid-options" + (Query.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = Some "[:find ?e :where [?e :block/title \"Hello\"]]"; + name = None; + inputs_edn = Some "[\"Hello"; + })); + let non_vector_inputs = + Query.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = None; + name = Some "recent-updated"; + inputs_edn = Some "1"; + }) + in + expect_error_code "query non-vector inputs" "invalid-options" + non_vector_inputs; + (match non_vector_inputs with + | Error err -> + expect_equal "query non-vector inputs message" + "inputs must be a vector" err.Error.message + | Ok _ -> fail_test "query non-vector inputs: expected error"); + expect_error_code "query rejects db/id datom" "invalid-query" + (Query.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = + Some + "[:find (pull ?v [*]) :where [?b :db/id 400] [?b \ + :user.property/owner ?v]]"; + name = None; + inputs_edn = None; + })); + expect_error_code "query requires repo" "missing-repo" + (Query.build (config ()) (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = Some "[:find ?e :where [?e :block/title]]"; + name = None; + inputs_edn = None; + })); + let raw_config = + Edn_util.map + [ + ( Edn_util.keyword "custom-queries", + Edn_util.map + [ + ( Edn_util.string "my-query", + Edn_util.map + [ + (Edn_util.keyword "doc", Edn_util.string "Custom query"); + ( Edn_util.keyword "inputs", + Edn_util.vector_vec + (Vec.singleton (Edn_util.string "title")) ); + ( Edn_util.keyword "query", + edn_of_string + "[:find ?e :in $ ?title :where [?e :block/title \ + ?title]]" ); + ] ); + ] ); + ] + in + let named = + expect_ok "named query" + (Query.build + (config ~repo:"demo" ~raw_file_config:raw_config ()) + (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = None; + name = Some ":my-query"; + inputs_edn = Some "[\"Alpha\"]"; + })) + in + (match named with + | Query.Run { inputs; name; _ } -> + expect_equal "named query normalized" "my-query" + (expect_some "named query name" name); + expect_equal "named query input" "Alpha" + (expect_some "named query input" + (Edn_util.as_string (Vec.peek_front inputs))) + | Query.List -> fail_test "expected named query run"); + expect_error_code "unknown named query" "unknown-query" + (Query.build + (config ~repo:"demo" + ~raw_file_config:(Edn_util.map_vec Vec.empty) + ()) + (Global_opts.create ()) + (Query.Parsed_run + { query_edn = None; name = Some "missing"; inputs_edn = None })); + expect_error_code "query name conflict" "invalid-options" + (Query.build + (config ~repo:"demo" ~raw_file_config:raw_config ()) + (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = Some "[:find ?e]"; + name = Some "my-query"; + inputs_edn = None; + }))); + + test_promise "CLI parity query execute appends DSL rules for percent input" + (fun () -> + let raw_config = + Edn_util.map + [ + ( Edn_util.keyword "custom-queries", + Edn_util.map + [ + ( Edn_util.string "task", + Edn_util.map + [ + ( Edn_util.keyword "inputs", + Edn_util.vector + [ Edn_util.string "task statuses set" ] ); + ( Edn_util.keyword "query", + edn_of_string + "[:find (pull ?b [*]) :in $ ?status % :where (task \ + ?b ?status)]" ); + ] ); + ] ); + ] + in + let cfg = config ~repo:"demo" ~raw_file_config:raw_config () in + let action = + expect_ok "task query build" + (Query.build cfg (Global_opts.create ()) + (Query.Parsed_run + { + query_edn = None; + name = Some "task"; + inputs_edn = Some "[\"Todo\"]"; + })) + in + let captured_body = ref None in + let server = + invoke_server (fun body -> + captured_body := Some body; + "[]") + in + with_server server (fun base_url -> + let cfg = { cfg with Cli_config.base_url = Some base_url } in + let* result = + effect_to_promise + (execute_with_output Query.execute action cfg Output.Mode.Human) + in + expect_bool "query execute ok" false (Cli_result.is_error result); + let body = expect_some "query request body" !captured_body in + expect_named_contains "query method" body "thread-api/q"; + expect_named_contains "query input" body "Todo"; + expect_named_contains "rules parent" body "parent"; + expect_named_contains "rules class extends" body "class-extends"; + expect_named_contains "rules task" body "task"; + Js.Promise.resolve pass)); + + test "CLI parity query list merges built-in and custom query metadata" + (fun () -> + let raw_config = + Edn_util.map + [ + ( Edn_util.keyword "custom-queries", + Edn_util.map + [ + (Edn_util.string "custom-q", edn_of_string "[:find ?e]"); + (Edn_util.string "block-search", edn_of_string "[:find ?b]"); + ] ); + ] + in + let queries = + Query.list_queries (config ~raw_file_config:raw_config ()) + in + let names = + Vec.map (fun (entry : Query.query_entry) -> entry.name) queries + in + expect_bool "task-search built-in listed" true + (Vec.mem "task-search" names); + expect_bool "list-status built-in listed" true + (Vec.mem "list-status" names); + expect_bool "list-priority built-in listed" true + (Vec.mem "list-priority" names); + expect_bool "custom query listed" true (Vec.mem "custom-q" names); + let block_search = + expect_some "block-search override" + (Vec.find_opt + (fun (entry : Query.query_entry) -> entry.name = "block-search") + queries) + in + expect_bool "block-search custom source" true + (block_search.source = Query.Custom); + let list_status = + expect_some "list-status" + (Vec.find_opt + (fun (entry : Query.query_entry) -> entry.name = "list-status") + queries) + in + expect_int "list-status inputs" 0 (Vec.length list_status.inputs); + let status_query = Melange_edn_melange.to_edn_string list_status.query in + expect_named_contains "list-status query status keyword" status_query + ":logseq.property/status"; + let list_priority = + expect_some "list-priority" + (Vec.find_opt + (fun (entry : Query.query_entry) -> entry.name = "list-priority") + queries) + in + expect_int "list-priority inputs" 0 (Vec.length list_priority.inputs); + expect_named_contains "list-priority query priority keyword" + (Melange_edn_melange.to_edn_string list_priority.query) + ":logseq.property/priority"); + + test "CLI parity debug selector requires exactly one id uuid or ident" + (fun () -> + expect_error_code "missing selector" ":invalid-options" + (Debug.resolve_selector { id = None; uuid = None; ident = None }); + let by_id = + expect_ok "debug id" + (Debug.resolve_selector { id = Some 9L; uuid = None; ident = None }) + in + (match by_id with Debug.By_id 9L -> pass | _ -> fail_test "expected id"); + expect_error_code "ambiguous selector" ":invalid-options" + (Debug.resolve_selector + { + id = Some 9L; + uuid = Some "00000000-0000-4000-8000-000000000001"; + ident = None; + }); + expect_error_code "bad uuid" ":invalid-options" + (Debug.resolve_selector + { id = None; uuid = Some "not-a-uuid"; ident = None })); + + test "CLI parity debug build action resolves id uuid ident and repo" + (fun () -> + let action = + expect_ok "debug id build" + (Debug.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Debug.Parsed_pull { id = Some 42L; uuid = None; ident = None })) + in + (match action with + | Debug.Debug_pull { repo; graph; lookup; selector } -> + expect_equal "debug repo" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "debug graph" "demo" + (Cli_primitive.string_of_graph graph); + (match lookup with + | Debug.By_id 42L -> pass + | _ -> fail_test "expected debug id lookup"); + expect_equal "debug selector" "[*]" + (Melange_edn_melange.to_edn_string selector)); + let ident = + expect_ok "debug ident option" + (Debug.parse_ident_option ":logseq.class/Tag") + in + let ident_action = + expect_ok "debug ident build" + (Debug.build (config ~repo:"demo" ()) (Global_opts.create ()) + (Debug.Parsed_pull { id = None; uuid = None; ident = Some ident })) + in + (match ident_action with + | Debug.Debug_pull { lookup = Debug.By_ident parsed_ident; _ } -> + expect_equal "debug ident" "logseq.class/Tag" + (Edn_util.keyword_to_string parsed_ident) + | _ -> fail_test "expected ident lookup"); + expect_error_code "debug invalid ident" "invalid-options" + (Debug.parse_ident_option "logseq.class/Tag"); + expect_error_code "debug requires repo" "missing-repo" + (Debug.build (config ()) (Global_opts.create ()) + (Debug.Parsed_pull { id = Some 1L; uuid = None; ident = None }))); + + test "CLI parity doctor build and filesystem checks report stable statuses" + (fun () -> + let missing_path = "/tmp/logseq-cli-missing-db-worker-node.js" in + let missing = + Doctor.check_db_worker_script + (Doctor.Doctor { script_path = Some missing_path }) + in + expect_equal "doctor missing script id" "db-worker-script" + (Edn_util.keyword_to_string missing.id); + expect_bool "doctor missing status" true (missing.status = Doctor.Error); + expect_equal "doctor missing code" "doctor-script-missing" + (Error.code_to_string (expect_some "missing script code" missing.code)); + expect_equal "doctor missing path" missing_path + (expect_some "missing script path" missing.path); + let root = temp_dir "logseq-cli-parity-doctor-" in + let script_path = Node.Path.join [| root; "db-worker-node.js" |] in + try + write_file script_path "console.log('ok');\n"; + let readable = + Doctor.check_db_worker_script + (Doctor.Doctor { script_path = Some script_path }) + in + expect_bool "doctor readable script" true (readable.status = Doctor.Ok); + expect_none "doctor readable script code" readable.code; + let nested_root = Node.Path.join [| root; "nested-root" |] in + let root_check = + Doctor.check_root_dir (config ~root_dir:nested_root ()) + in + expect_equal "doctor root id" "root-dir" + (Edn_util.keyword_to_string root_check.id); + expect_bool "doctor root ok" true (root_check.status = Doctor.Ok); + expect_bool "doctor root created" true + (Cli_unix.file_exists nested_root); + (match + Doctor.build (config ()) (Global_opts.create ()) + (Doctor.Parsed_doctor { dev_script = true }) + with + | Ok (Doctor.Doctor { script_path = Some path }) -> + expect_named_contains "doctor dev script" path "db-worker-node.js" + | Ok (Doctor.Doctor { script_path = None }) -> + fail_test "doctor dev script: expected script path" + | Error err -> fail_test ("doctor build: " ^ err.Error.message)); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity command metadata keeps expected command ids and paths" + (fun () -> + expect_equal "graph list id" "graph-list" + (Command_id.to_string Command_id.Graph_list); + expect_bool "graph create write" true + (Command_id.is_write Command_id.Graph_create); + expect_bool "graph list read" false + (Command_id.is_write Command_id.Graph_list); + expect_bool "show requires graph" true + (Command_id.requires_graph Command_id.Show); + expect_bool "login does not require auth" false + (Command_id.requires_auth Command_id.Login)); + + test "CLI parity command lookup compares path contents" (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let path = Vec.push_front (Vec.singleton "list") "graph" in + let command = + expect_some "graph list command" + (Command_registry.find_by_path path registry) + in + expect_equal "graph list command id" "graph-list" + (Command_id.to_string command.id)); + + test "CLI parity example lookup compares selector contents" (fun () -> + let selector = Vec.push_front (Vec.singleton "list") "graph" in + let action = + expect_ok "graph list examples" + (Example.resolve_selector Command_registry.empty selector) + in + expect_equal "graph list selector" "graph list" action.selector; + expect_named_contains "graph list example" + (Vec.peek_front action.examples) + "logseq graph list"); + + test "CLI parity example selector returns matched commands and examples" + (fun () -> + let registry = + Command_registry.make + (Vec.of_array + [| + { + Command_registry.id = Command_id.Graph_list; + path = Vec.of_array [| "graph"; "list" |]; + doc = "List graphs"; + long_doc = None; + examples = Vec.singleton "logseq graph list"; + options = Vec.empty; + category = Command_registry.Graph_management; + requires_graph = false; + requires_auth = false; + write_command = false; + human_table_headers_order = Vec.empty; + }; + { + id = Command_id.Graph_create; + path = Vec.of_array [| "graph"; "create" |]; + doc = "Create graph"; + long_doc = None; + examples = Vec.singleton "logseq graph create --graph demo"; + options = Vec.empty; + category = Graph_management; + requires_graph = true; + requires_auth = false; + write_command = true; + human_table_headers_order = Vec.empty; + }; + |]) + in + let action = + expect_ok "graph examples" + (Example.resolve_selector registry (Vec.singleton "graph")) + in + expect_equal "selector" "graph" action.selector; + expect_int "matched commands" 2 (Vec.length action.matched_commands); + expect_int "examples" 2 (Vec.length action.examples); + expect_error_code "unknown selector" ":unknown-command" + (Example.resolve_selector registry (Vec.singleton "sync"))); + + test "CLI parity example selector reports missing and large example counts" + (fun () -> + let registry = + Command_registry.make + (Vec.of_array + [| + { + Command_registry.id = Command_id.Upsert_page; + path = Vec.of_array [| "upsert"; "page" |]; + doc = "Upsert page"; + long_doc = None; + examples = + Vec.singleton "logseq upsert page --graph demo --page Home"; + options = Vec.empty; + category = Graph_inspect_and_edit; + requires_graph = true; + requires_auth = false; + write_command = true; + human_table_headers_order = Vec.empty; + }; + { + id = Command_id.Upsert_tag; + path = Vec.of_array [| "upsert"; "tag" |]; + doc = "Upsert tag"; + long_doc = None; + examples = Vec.empty; + options = Vec.empty; + category = Graph_inspect_and_edit; + requires_graph = true; + requires_auth = false; + write_command = true; + human_table_headers_order = Vec.empty; + }; + |]) + in + expect_error_code "missing examples" "missing-examples" + (Example.resolve_selector registry (Vec.singleton "upsert")); + let large_registry = + Command_registry.make + (Vec.of_array + [| + { + Command_registry.id = Command_id.Upsert_page; + path = Vec.of_array [| "upsert"; "page" |]; + doc = "Upsert page"; + long_doc = None; + examples = + Vec.init 1234 (fun index -> + "logseq upsert page --graph demo --page Page-" + ^ string_of_int index); + options = Vec.empty; + category = Graph_inspect_and_edit; + requires_graph = true; + requires_auth = false; + write_command = true; + human_table_headers_order = Vec.empty; + }; + |]) + in + let action = + expect_ok "large example count" + (Example.resolve_selector large_registry + (Vec.of_array [| "upsert"; "page" |])) + in + expect_equal "large example message" + "Found 1,234 examples for selector upsert page" action.message); + + test "CLI parity task status normalization resolves available statuses" + (fun () -> + let values = + Vec.of_array + [| + Edn_util.map + [ + ( Edn_util.keyword "ident", + Edn_util.keyword "logseq.property/status.todo" ); + (Edn_util.keyword "value", Edn_util.string "Todo"); + ]; + Edn_util.map + [ + ( Edn_util.keyword "ident", + Edn_util.keyword "logseq.property/status.done" ); + (Edn_util.keyword "value", Edn_util.string "Done"); + ]; + |] + in + let statuses = Task_status.normalize_available_statuses values in + expect_int "status count" 2 (Vec.length statuses); + expect_bool "resolve todo" true + (Option.is_some (Task_status.resolve_status_ident "todo" statuses)); + expect_named_contains "invalid status message" + (Task_status.invalid_status_message "bad" statuses) + "Available values"); + + test + "CLI parity config graph repo conversion strips one leading db prefix only" + (fun () -> + expect_equal "repo to graph" "demo" + (Cli_primitive.string_of_graph + (Cli_config.repo_to_graph + (Cli_primitive.create_repo "logseq_db_demo"))); + expect_equal "nested prefix graph" "logseq_db_other" + (Cli_primitive.string_of_graph + (Cli_config.repo_to_graph + (Cli_primitive.create_repo "logseq_db_logseq_db_other")))); + + test + "CLI parity parse covers graph backup import export and sync requirements" + (fun () -> + let backup = + expect_parse_ok "backup restore" + [| + "graph"; + "backup"; + "restore"; + "--src"; + "demo-nightly"; + "--dst"; + "demo-restored"; + |] + in + (match backup.command with + | Cli_request.Graph (Graph.Parsed_backup_restore opts) -> + expect_equal "backup src" "demo-nightly" opts.src; + expect_equal "backup dst" "demo-restored" + (Cli_primitive.string_of_graph opts.dst) + | _ -> fail_test "expected backup restore"); + let export = + expect_parse_ok "graph export" + [| + "graph"; + "export"; + "--type"; + "edn"; + "--file"; + "/tmp/export.edn"; + "--edn-options"; + "{:export-type :graph :include-timestamps? true}"; + "--pretty-print"; + |] + in + (match export.command with + | Cli_request.Graph (Graph.Parsed_export opts) -> + expect_bool "export edn" true (opts.export_type = Graph.Edn); + expect_equal "export file" "/tmp/export.edn" + (expect_some "file" opts.file); + expect_bool "pretty" true opts.pretty_print; + expect_some "edn options" opts.edn_options |> ignore + | _ -> fail_test "expected graph export"); + let export_aliases = + expect_parse_ok "graph export aliases" + [| + "graph"; + "export"; + "-t"; + "edn"; + "--file"; + "/tmp/export.edn"; + "-e"; + "{:export-type :graph}"; + "-p"; + |] + in + (match export_aliases.command with + | Cli_request.Graph (Graph.Parsed_export opts) -> + expect_bool "export alias edn" true (opts.export_type = Graph.Edn); + expect_bool "export alias pretty" true opts.pretty_print; + expect_some "export alias edn options" opts.edn_options |> ignore + | _ -> fail_test "expected graph export"); + Vec.iter + (fun flag -> + expect_parse_error_code + ("retired export option " ^ flag) + ":invalid-options" + [| + "graph"; + "export"; + "--type"; + "edn"; + "--file"; + "/tmp/export.edn"; + flag; + |]) + (Vec.of_array [| "--include-timestamps"; "--exclude-built-in-pages" |]); + expect_parse_error_code "retired export option --exclude-namespaces" + ":invalid-options" + [| + "graph"; + "export"; + "--type"; + "edn"; + "--file"; + "/tmp/export.edn"; + "--exclude-namespaces"; + "journal"; + |]; + expect_parse_error_code "missing backup src" ":invalid-options" + [| "graph"; "backup"; "restore"; "--dst"; "demo" |]; + expect_parse_error_code "bad sync config key" ":invalid-options" + [| "sync"; "config"; "get"; "--key"; "graph" |]); + + test "CLI parity graph build validates graph selection and export contracts" + (fun () -> + let globals = + Global_opts.create ~graph:(Cli_primitive.create_graph "demo") () + in + expect_error_code "graph create requires explicit graph" "missing-graph" + (Graph.build (config ()) (Global_opts.create ()) + (Graph.Parsed_create { enable_sync = false; e2ee_password = None })); + expect_error_code "graph create password requires sync" "invalid-options" + (Graph.build (config ()) globals + (Graph.Parsed_create + { enable_sync = false; e2ee_password = Some "pw" })); + Vec.iter + (fun graph_name -> + expect_error_code + ("reject graph name " ^ graph_name) + "invalid-options" + (Graph.build (config ()) + (Global_opts.create + ~graph:(Cli_primitive.create_graph graph_name) + ()) + (Graph.Parsed_create + { enable_sync = false; e2ee_password = None }))) + (Vec.of_array [| ""; " "; "."; " .. " |]); + let create = + expect_ok "graph create build" + (Graph.build (config ()) globals + (Graph.Parsed_create + { enable_sync = true; e2ee_password = Some "pw" })) + in + (match create with + | Graph.Graph_create { graph; repo; opts } -> + expect_equal "create graph" "demo" (graph_text graph); + expect_equal "create repo" "logseq_db_demo" (repo_text repo); + expect_bool "create sync" true opts.enable_sync; + expect_equal "create password" "pw" + (expect_some "create password" opts.e2ee_password) + | _ -> fail_test "expected Graph_create action"); + expect_error_code "export edn requires file" "invalid-options" + (Graph.build (config ~graph:"demo" ()) (Global_opts.create ()) + (Graph.Parsed_export + { + export_type = Graph.Edn; + file = None; + edn_options = None; + pretty_print = false; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + })); + expect_error_code "sqlite rejects edn options" "invalid-options" + (Graph.build (config ~graph:"demo" ()) (Global_opts.create ()) + (Graph.Parsed_export + { + export_type = Graph.Sqlite; + file = Some "/tmp/demo.sqlite"; + edn_options = Some (Edn_util.map_vec Vec.empty); + pretty_print = false; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + })); + expect_error_code "export edn options must be map" "invalid-options" + (Graph.build (config ~graph:"demo" ()) (Global_opts.create ()) + (Graph.Parsed_export + { + export_type = Graph.Edn; + file = Some "/tmp/demo.edn"; + edn_options = Some (Edn_util.vector_vec Vec.empty); + pretty_print = false; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + })); + let export = + expect_ok "export edn build" + (Graph.build (config ~graph:"demo" ()) (Global_opts.create ()) + (Graph.Parsed_export + { + export_type = Graph.Edn; + file = Some "/tmp/demo.edn"; + edn_options = + Some + (edn_of_string + "{:export-type :graph :include-timestamps? true}"); + pretty_print = true; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + })) + in + match export with + | Graph.Graph_export { graph; repo; opts } -> + expect_equal "export graph" "demo" (graph_text graph); + expect_equal "export repo" "logseq_db_demo" (repo_text repo); + expect_bool "export type" true (opts.export_type = Graph.Edn); + expect_equal "export file" "/tmp/demo.edn" + (expect_some "export file" opts.file); + expect_bool "export pretty" true opts.pretty_print; + expect_some "export edn options" opts.edn_options |> ignore + | _ -> fail_test "expected Graph_export action"); + + test_promise "CLI parity graph validate result reports ok and error states" + (fun () -> + let run_validate response_transit assert_result = + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/validate-db" body then + response_transit + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Graph.Graph_validate + { graph = Cli_config.repo_to_graph repo; repo; fix = false } + in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Graph.execute action cfg Output.Mode.Human) + in + assert_result result; + Js.Promise.resolve pass) + in + let* () = + run_validate + "[\"^ \",\"~:errors\",[[\"^ \",\"~:entity\",[\"^ \ + \",\"~:db/id\",1],\"^0\",[\"^ \",\"~:foo\",[\"bad\"]]]]]" + (fun invalid -> + expect_bool "invalid graph status" true + (Cli_result.is_error invalid); + match invalid.Cli_result.error with + | Some err -> + expect_equal "validation error code" "graph-validation-failed" + (Error.code_to_string err.Error.code); + expect_named_contains "validation error count" err.message + "Found 1 entity with errors:" + | None -> fail_test "expected graph validation error") + in + let* () = + run_validate "[\"^ \",\"~:errors\",null,\"~:datoms\",10]" (fun valid -> + expect_bool "valid graph status" false (Cli_result.is_error valid); + let data = + expect_some "validation data" (Cli_result.data_value valid) + in + expect_bool "validation wraps result" true + (Option.is_some (Edn_util.get data "result"))) + in + Js.Promise.resolve pass); + + test_promise "CLI parity graph info queries kv rows with thread api q" + (fun () -> + let q_called = ref false in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/q" body then ( + q_called := true; + expect_named_contains "graph info repo" body "logseq_db_demo"; + expect_named_contains "graph info query namespace" body + "logseq.kv"; + "[[\"~:logseq.kv/schema-version\",7],[\"~:logseq.kv/graph-created-at\",40000],[\"~:logseq.kv/db-type\",\"~:sqlite\"]]") + else "null") + in + with_server server (fun base_url -> + let repo = Cli_primitive.create_repo "demo" in + let action = + Graph.Graph_info { graph = Cli_config.repo_to_graph repo; repo } + in + let cfg = + { + (config ~repo:"demo" ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Graph.execute action cfg Output.Mode.Edn) + in + expect_bool "graph info ok" false (Cli_result.is_error result); + expect_bool "q called" true !q_called; + let data = + expect_some "graph info data" (Cli_result.data_value result) + in + expect_equal "graph info graph" "demo" + (expect_some "graph" (Edn_util.get_string data "graph")); + expect_int "schema version" 7 + (expect_some "schema" + (Edn_util.get_int data "logseq.kv/schema-version")); + expect_bool "kv map present" true + (Option.is_some (Edn_util.get data "kv")); + Js.Promise.resolve pass)); + + test "CLI parity graph build normalizes backup and import actions" (fun () -> + let selected = config ~graph:"demo" () in + let backup = + expect_ok "backup create" + (Graph.build selected (Global_opts.create ()) + (Graph.Parsed_backup_create { name = Some " nightly " })) + in + (match backup with + | Graph.Graph_backup_create { graph; repo; name; backup_name } -> + expect_equal "backup graph" "demo" (graph_text graph); + expect_equal "backup repo" "logseq_db_demo" (repo_text repo); + expect_equal "backup label" "nightly" (expect_some "name" name); + expect_named_contains "backup generated name" + (expect_some "backup name" backup_name) + "demo-nightly-" + | _ -> fail_test "expected Graph_backup_create action"); + let restore = + expect_ok "backup restore" + (Graph.build selected (Global_opts.create ()) + (Graph.Parsed_backup_restore + { + src = " nightly "; + dst = Cli_primitive.create_graph " restored "; + })) + in + (match restore with + | Graph.Graph_backup_restore + { source_repo; source_graph; dst_repo; dst_graph; src; dst } -> + expect_equal "restore source repo" "logseq_db_demo" + (repo_text source_repo); + expect_equal "restore source graph" "demo" (graph_text source_graph); + expect_equal "restore dst repo" "logseq_db_restored" + (repo_text dst_repo); + expect_equal "restore dst graph" "restored" (graph_text dst_graph); + expect_equal "restore src trim" "nightly" src; + expect_equal "restore dst trim" "restored" dst + | _ -> fail_test "expected Graph_backup_restore action"); + expect_error_code "backup restore blank dst" "missing-dst" + (Graph.build selected (Global_opts.create ()) + (Graph.Parsed_backup_restore + { src = "nightly"; dst = Cli_primitive.create_graph " " })); + let sqlite_import = + expect_ok "sqlite import" + (Graph.build selected (Global_opts.create ()) + (Graph.Parsed_import + { + import_type = Graph.Import_sqlite; + input = "/tmp/demo.sqlite"; + })) + in + (match sqlite_import with + | Graph.Graph_import { graph; repo; opts; require_missing_graph } -> + expect_equal "sqlite import graph" "demo" (graph_text graph); + expect_equal "sqlite import repo" "logseq_db_demo" (repo_text repo); + expect_bool "sqlite import type" true + (opts.import_type = Graph.Import_sqlite); + expect_bool "sqlite requires missing" true require_missing_graph + | _ -> fail_test "expected Graph_import sqlite action"); + let edn_import = + expect_ok "edn import" + (Graph.build selected (Global_opts.create ()) + (Graph.Parsed_import + { import_type = Graph.Import_edn; input = "/tmp/demo.edn" })) + in + match edn_import with + | Graph.Graph_import { opts; require_missing_graph; _ } -> + expect_bool "edn import type" true + (opts.import_type = Graph.Import_edn); + expect_bool "edn does not require missing" false require_missing_graph + | _ -> fail_test "expected Graph_import edn action"); + + test_promise + "CLI parity graph backup create invokes worker and writes metadata" + (fun () -> + let root = temp_dir "logseq-cli-backup-create-" in + let invoke_calls = ref Vec.empty in + let server = + invoke_server (fun body -> + invoke_calls := Vec.push_front !invoke_calls body; + if Js.String.includes ~search:"thread-api/backup-db-sqlite" body + then ( + let tmp_path = invoke_arg_string body 1 in + mkdir_p (Filename.dirname tmp_path); + write_file tmp_path "sqlite-copy"; + "[\"^ \",\"~:ok\",true]") + else "null") + in + with_server server (fun base_url -> + let graph = Cli_primitive.create_graph "demo" in + let repo = Cli_primitive.create_repo "demo" in + let backup_name = "demo-nightly-20260101T000000Z" in + let action = + Graph.Graph_backup_create + { + graph; + repo; + name = Some "nightly"; + backup_name = Some backup_name; + } + in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + let* result = + effect_to_promise + (execute_with_output Graph.execute action cfg Output.Mode.Edn) + in + let backup_dir = + Node.Path.join [| root; "graphs"; "demo"; "backup"; backup_name |] + in + let db_path = Node.Path.join [| backup_dir; "db.sqlite" |] in + let metadata_path = Node.Path.join [| backup_dir; "metadata.edn" |] in + expect_bool "backup create ok" false (Cli_result.is_error result); + expect_int "backup invoke count" 1 (Vec.length !invoke_calls); + expect_equal "backup method repo" "logseq_db_demo" + (invoke_arg_string (Vec.peek_front !invoke_calls) 0); + expect_equal "final sqlite payload" "sqlite-copy" (read_file db_path); + let data = + expect_some "backup create data" (Cli_result.data_value result) + in + expect_equal "backup result name" backup_name + (expect_some "backup-name" (Edn_util.get_string data "backup-name")); + expect_equal "backup result path" db_path + (expect_some "path" (Edn_util.get_string data "path")); + let metadata = edn_of_string (read_file metadata_path) in + expect_equal "metadata source" "cli" + (expect_some "metadata source" + (Edn_util.get_string metadata "source")); + expect_equal "metadata name" backup_name + (expect_some "metadata name" (Edn_util.get_string metadata "name")); + remove_tree root; + Js.Promise.resolve pass)); + + test_promise + "CLI parity graph backup create cleans reserved target after worker failure" + (fun () -> + let root = temp_dir "logseq-cli-backup-create-cleanup-" in + let backup_name = "demo-failure" in + let backup_dir = + Node.Path.join [| root; "graphs"; "demo"; "backup"; backup_name |] + in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/backup-db-sqlite" body + then failwith "snapshot failed" + else "null") + in + Js.Promise.make (fun ~resolve ~reject -> + server_listen server 0 "127.0.0.1" (fun[@u] () -> + let port = (server_address server)##port in + let base_url = Printf.sprintf "http://127.0.0.1:%d" port in + let resolve_after_close () = + server_close server (fun[@u] () -> (resolve pass [@u])) + in + let reject_after_close message = + server_close server (fun[@u] () -> + (reject (Failure message) [@u])) + in + let close_and_fail message = + remove_tree root; + reject_after_close message + in + let action = + Graph.Graph_backup_create + { + graph = Cli_primitive.create_graph "demo"; + repo = Cli_primitive.create_repo "demo"; + name = None; + backup_name = Some backup_name; + } + in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + Cli_effect.on_any + (execute_with_output Graph.execute action cfg Output.Mode.Human) + (fun result -> + if not (Cli_result.is_error result) then + close_and_fail "expected backup create error result" + else + let target_exists = Node.Fs.existsSync backup_dir in + if target_exists then + close_and_fail + "reserved backup target removed: expected false, got \ + true" + else ( + remove_tree root; + resolve_after_close ())) + (fun exn -> + let target_exists = Node.Fs.existsSync backup_dir in + if target_exists then + close_and_fail + "reserved backup target removed: expected false, got true" + else ( + remove_tree root; + reject_after_close (Printexc.to_string exn)))))); + + test_promise + "CLI parity graph backup list restore and remove use scoped backup dirs" + (fun () -> + let root = temp_dir "logseq-cli-backup-flow-" in + let demo_backup = + Node.Path.join + [| root; "graphs"; "demo"; "backup"; "demo-nightly"; "db.sqlite" |] + in + let other_backup = + Node.Path.join + [| root; "graphs"; "other"; "backup"; "other-nightly"; "db.sqlite" |] + in + mkdir_p (Filename.dirname demo_backup); + mkdir_p (Filename.dirname other_backup); + write_file demo_backup "demo"; + write_file other_backup "other"; + let calls = ref Vec.empty in + let server = + invoke_server (fun body -> + calls := Vec.push_front !calls body; + if Js.String.includes ~search:"thread-api/import-db-binary" body + then "[\"^ \",\"~:ok\",true]" + else "null") + in + with_server server (fun base_url -> + let graph = Cli_primitive.create_graph "demo" in + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + let* list_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_backup_list { graph; repo }) + cfg Output.Mode.Edn) + in + let backups = + expect_some "backups" + (Option.bind + (Option.bind (Cli_result.data_value list_result) (fun data -> + Edn_util.get data "backups")) + Edn_util.as_seq) + in + expect_int "only current graph backups" 1 (Vec.length backups); + expect_equal "backup name" "demo-nightly" + (expect_some "backup name" + (Edn_util.get_string (Vec.peek_front backups) "name")); + let* missing_restore = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_backup_restore + { + source_repo = repo; + source_graph = graph; + dst_repo = Cli_primitive.create_repo "restored"; + dst_graph = Cli_primitive.create_graph "restored"; + src = "missing"; + dst = "restored"; + }) + cfg Output.Mode.Human) + in + expect_bool "missing restore is error" true + (Cli_result.is_error missing_restore); + (match missing_restore.Cli_result.error with + | Some err -> + expect_equal "missing restore code" "backup-not-found" + (Error.code_to_string err.Error.code) + | None -> fail_test "expected missing restore error"); + let* restore_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_backup_restore + { + source_repo = repo; + source_graph = graph; + dst_repo = Cli_primitive.create_repo "restored"; + dst_graph = Cli_primitive.create_graph "restored"; + src = "demo-nightly"; + dst = "restored"; + }) + cfg Output.Mode.Edn) + in + expect_bool "restore ok" false (Cli_result.is_error restore_result); + expect_bool "restore invokes import" true + (Vec.exists + (fun body -> + Js.String.includes ~search:"thread-api/import-db-binary" body) + !calls); + let* missing_remove = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_backup_remove + { graph; repo; src = "missing-nightly" }) + cfg Output.Mode.Human) + in + expect_bool "missing remove is error" true + (Cli_result.is_error missing_remove); + let* remove_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_backup_remove + { graph; repo; src = "demo-nightly" }) + cfg Output.Mode.Human) + in + expect_bool "remove ok" false (Cli_result.is_error remove_result); + expect_bool "backup dir removed" false + (Node.Fs.existsSync (Filename.dirname demo_backup)); + remove_tree root; + Js.Promise.resolve pass)); + + test_promise + "CLI parity graph export and import execute through transport and files" + (fun () -> + let root = temp_dir "logseq-cli-graph-io-" in + let edn_export = Node.Path.join [| root; "export.edn" |] in + let import_edn = Node.Path.join [| root; "import.edn" |] in + let import_sqlite = Node.Path.join [| root; "import.sqlite" |] in + write_file import_edn "{:page \"Import Page\"}"; + write_file import_sqlite "sqlite"; + let calls = ref Vec.empty in + let server = + invoke_server (fun body -> + calls := Vec.push_front !calls body; + if Js.String.includes ~search:"thread-api/backup-db-sqlite" body + then ( + let path = invoke_arg_string body 1 in + mkdir_p (Filename.dirname path); + write_file path "sqlite-copy"; + "[\"^ \",\"~:ok\",true]") + else if Js.String.includes ~search:"thread-api/export-edn" body then + "[\"^ \",\"~:exported\",true]" + else if Js.String.includes ~search:"thread-api/import-edn" body then + "[\"^ \",\"~:ok\",true]" + else if + Js.String.includes ~search:"thread-api/import-db-binary" body + then "[\"^ \",\"~:ok\",true]" + else "null") + in + with_server server (fun base_url -> + let graph = Cli_primitive.create_graph "demo" in + let repo = Cli_primitive.create_repo "demo" in + let cfg = + { + (config ~repo:"demo" ~root_dir:root ()) with + Cli_config.base_url = Some base_url; + } + in + let edn_opts = + { + Graph.export_type = Graph.Edn; + file = Some edn_export; + edn_options = + Some + (edn_of_string + "{:export-type :graph :include-timestamps? true}"); + pretty_print = true; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + } + in + let sqlite_opts = + { + Graph.export_type = Graph.Sqlite; + file = None; + edn_options = None; + pretty_print = false; + include_timestamps = false; + exclude_built_in_pages = false; + exclude_namespaces = Vec.empty; + } + in + let* edn_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_export { graph; repo; opts = edn_opts }) + cfg Output.Mode.Edn) + in + expect_bool "edn export ok" false (Cli_result.is_error edn_result); + expect_named_contains "export file content" (read_file edn_export) + ":exported true"; + expect_named_contains "pretty export writes multiline edn" + (read_file edn_export) "\n"; + let* sqlite_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_export { graph; repo; opts = sqlite_opts }) + cfg Output.Mode.Human) + in + expect_bool "sqlite export ok" false + (Cli_result.is_error sqlite_result); + let sqlite_path = invoke_arg_string (Vec.peek_front !calls) 1 in + expect_named_contains "default sqlite export path" sqlite_path + (Node.Path.join [| root; "graphs"; "demo"; "export"; "demo_" |]); + expect_bool "default sqlite export suffix" true + (Js.String.endsWith ~suffix:".sqlite" sqlite_path); + let* import_edn_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_import + { + graph; + repo; + opts = + { + Graph.import_type = Graph.Import_edn; + input = import_edn; + }; + require_missing_graph = false; + }) + cfg Output.Mode.Edn) + in + expect_bool "edn import ok" false + (Cli_result.is_error import_edn_result); + let* import_sqlite_result = + effect_to_promise + (execute_with_output Graph.execute + (Graph.Graph_import + { + graph; + repo; + opts = + { + Graph.import_type = Graph.Import_sqlite; + input = import_sqlite; + }; + require_missing_graph = true; + }) + cfg Output.Mode.Edn) + in + expect_bool "sqlite import ok" false + (Cli_result.is_error import_sqlite_result); + let joined = Vec.string_concat "\n" !calls in + expect_named_contains "export edn invoked" joined + "thread-api/export-edn"; + expect_named_contains "sqlite export invoked" joined + "thread-api/backup-db-sqlite"; + expect_named_contains "import edn invoked" joined + "thread-api/import-edn"; + expect_named_contains "import sqlite invoked" joined + "thread-api/import-db-binary"; + remove_tree root; + Js.Promise.resolve pass)); + + test "CLI parity top-level graph constraints reject existing sqlite imports" + (fun () -> + let root = temp_dir "logseq-cli-graph-constraints-" in + let graph_dir = Node.Path.join [| root; "graphs"; "demo" |] in + let sqlite_path = Node.Path.join [| root; "input.sqlite" |] in + mkdir_p graph_dir; + write_file sqlite_path "sqlite"; + let output = + run_cli_lifecycle + [| + "--root-dir"; + root; + "--graph"; + "demo"; + "graph"; + "import"; + "--type"; + "sqlite"; + "--input"; + sqlite_path; + |] + in + expect_int "existing sqlite import exit" 1 output.exit_code; + expect_named_contains "existing sqlite import code" + (stdout_text "existing sqlite import stdout" output) + "graph-exists"; + remove_tree root); + + test "CLI parity errors are printed to stdout with non-zero exit" (fun () -> + let result = spawn_cli [| "list"; "page"; "--limit"; "nope" |] in + ignore (expect_exit_non_zero "bad list limit" result); + expect_named_contains "error code" result##stdout "invalid-options"; + expect_named_contains "error message" result##stdout "Expected integer"); + + test "CLI parity main version output includes build metadata" (fun () -> + let output = run_cli_lifecycle [| "--version" |] in + expect_int "version exit" 0 output.exit_code; + let stdout = stdout_text "version stdout" output in + expect_named_contains "version build time" stdout "Build time: "; + expect_named_contains "version revision" stdout "Revision: "; + expect_bool "profile disabled" true (Vec.is_empty output.stderr)); + + test "CLI parity main uses a later duplicate option with a value" (fun () -> + let output = + run_cli_lifecycle [| "--output"; "--output"; "json"; "--version" |] + in + expect_int "duplicate output version exit" 0 output.exit_code; + let stdout = stdout_text "duplicate output version stdout" output in + expect_valid_json "duplicate output version json" stdout; + expect_named_contains "duplicate output version status" stdout + "\"status\":\"ok\""); + + test "CLI parity version action output includes build metadata" (fun () -> + let result = + effect_result "version action" + (execute_with_output Cli_action.execute Cli_action.Version (config ()) + Output.Mode.Human) + in + let output = Format_types.format_result result (config ()) in + expect_named_contains "version action build time" output "Build time: "; + expect_named_contains "version action revision" output "Revision: "); + + test "CLI parity main structured help respects output modes" (fun () -> + let json_output = run_cli_lifecycle [| "--output"; "json"; "--help" |] in + expect_int "json help exit" 0 json_output.exit_code; + let json_stdout = stdout_text "json help stdout" json_output in + expect_valid_json "json help output" json_stdout; + expect_named_contains "json help status" json_stdout "\"status\":\"ok\""; + expect_named_contains "json help message" json_stdout "Usage: logseq"; + let edn_output = run_cli_lifecycle [| "--output"; "edn"; "--help" |] in + expect_int "edn help exit" 0 edn_output.exit_code; + let edn_stdout = stdout_text "edn help stdout" edn_output in + expect_valid_edn "edn help output" edn_stdout; + expect_named_contains "edn help status" edn_stdout ":status :ok"; + expect_named_contains "edn help message" edn_stdout "Usage: logseq"); + + test "CLI parity main structured parse errors respect output modes" (fun () -> + let json_output = run_cli_lifecycle [| "--output"; "json"; "wat" |] in + expect_int "json parse exit" 1 json_output.exit_code; + let json_stdout = stdout_text "json parse stdout" json_output in + expect_valid_json "json parse error output" json_stdout; + expect_named_contains "json parse error status" json_stdout + "\"status\":\"error\""; + expect_named_contains "json parse error message" json_stdout "\"message\""; + let edn_output = run_cli_lifecycle [| "--output"; "edn"; "wat" |] in + expect_int "edn parse exit" 1 edn_output.exit_code; + let edn_stdout = stdout_text "edn parse stdout" edn_output in + expect_valid_edn "edn parse error output" edn_stdout; + expect_named_contains "edn parse error status" edn_stdout ":status :error"; + expect_named_contains "edn parse error message" edn_stdout ":message"); + + test "CLI parity main profile lines are emitted for version" (fun () -> + let output = run_cli_lifecycle [| "--profile"; "--version" |] in + expect_int "profile version exit" 0 output.exit_code; + let stdout = stdout_text "profile version stdout" output in + expect_named_contains "profile version build time" stdout "Build time: "; + let profile = Vec.string_concat "\n" output.stderr in + expect_named_contains "profile version headline" profile + "command=version status=ok"; + expect_named_contains "profile version stages" profile "stages"; + expect_named_contains "profile version parse stage" profile + "cli.parse-args"; + expect_named_contains "profile version total stage" profile "cli.total"); + + test "CLI parity main profile lines are disabled by default" (fun () -> + let output = run_cli_lifecycle [| "--help" |] in + expect_int "help exit" 0 output.exit_code; + expect_bool "no profile lines" true (Vec.is_empty output.stderr)); + + test "CLI parity command help includes primary and secondary command groups" + (fun () -> + let output = run_cli [| "--help" |] in + expect_named_contains "graph command" output "graph list"; + expect_named_contains "upsert command" output "upsert block"; + expect_named_contains "sync command" output "sync upload"; + expect_named_contains "skill command" output "skill show"; + expect_named_not_contains "retired qmd" output "qmd"); + + test "CLI parity group help surfaces subcommands without option suffix" + (fun () -> + let list_help = run_cli [| "list" |] in + expect_named_contains "list usage" list_help + "Usage: logseq list [options]"; + expect_named_contains "list page command" list_help "list page"; + expect_named_contains "list asset command" list_help "list asset"; + expect_named_not_contains "list command options suffix" list_help + "list page [options]"; + let backup_help = run_cli [| "graph"; "backup" |] in + expect_named_contains "backup usage" backup_help + "Usage: logseq graph backup [options]"; + expect_named_contains "backup list command" backup_help + "graph backup list"; + expect_named_contains "backup restore command" backup_help + "graph backup restore"; + let query_help = run_cli [| "query"; "-h" |] in + expect_named_contains "query option" query_help "--query"; + expect_named_contains "query name" query_help "--name"; + expect_named_contains "query inputs" query_help "--inputs"); + + test "CLI parity command help includes registry examples" (fun () -> + let remove_help = run_cli [| "remove"; "block"; "--help" |] in + expect_named_contains "remove block usage" remove_help + "Usage: logseq remove block"; + expect_named_contains "remove block options" remove_help + "Command options:"; + expect_named_contains "remove block examples" remove_help "Examples:"; + let upsert_help = run_cli [| "upsert"; "block"; "--help" |] in + expect_named_contains "upsert block usage" upsert_help + "Usage: logseq upsert block"; + expect_named_contains "upsert block examples" upsert_help "Examples:"; + let example_help = run_cli [| "example"; "upsert"; "--help" |] in + expect_named_contains "example upsert usage" example_help + "Usage: logseq example upsert"; + expect_named_not_contains "example upsert group has no examples" + example_help "Examples:"); + + test + "CLI parity structured example output keeps selector matched commands and \ + message" (fun () -> + let result = spawn_cli [| "--output"; "json"; "example"; "upsert" |] in + ignore (expect_exit_zero "example upsert json" result); + expect_named_contains "selector" result##stdout "\"selector\":\"upsert\""; + expect_named_contains "matched command" result##stdout "upsert block"; + expect_named_contains "message" result##stdout "Found"); + + test "CLI parity show validation rejects ambiguous targets" (fun () -> + expect_error_code "show id uuid" "invalid-options" + (Show.validate_parsed + (Show.Parsed_show + { + id_raw = Some "1"; + uuid = Some "00000000-0000-4000-8000-000000000001"; + page = None; + page_hierarchy = false; + linked_references = None; + ref_id_footer = None; + level = None; + stdin_id = None; + })); + let result = + spawn_cli [| "--graph"; "demo"; "show"; "--id"; "1"; "--page"; "Home" |] + in + ignore (expect_exit_non_zero "show ambiguous cli" result); + expect_named_contains "show ambiguous message" result##stdout + "only one of --id, --uuid, or --page is allowed"); + + test "CLI parity completion command parses shell argument and validation" + (fun () -> + let positional = + expect_parse_ok "completion positional" [| "completion"; "zsh" |] + in + (match positional.command with + | Cli_request.Completion + (Completion.Parsed_completion { shell = Some Cli_primitive.Zsh }) -> + pass + | _ -> fail_test "expected zsh completion request"); + let option = + expect_parse_ok "completion option" + [| "completion"; "--shell"; "bash" |] + in + (match option.command with + | Cli_request.Completion + (Completion.Parsed_completion { shell = Some Cli_primitive.Bash }) -> + pass + | _ -> fail_test "expected bash completion request"); + expect_parse_error_code "completion missing shell" ":invalid-options" + [| "completion" |]; + expect_parse_error_code "completion unsupported shell" ":invalid-options" + [| "completion"; "fish" |]); + + test "CLI parity path-specific short aliases resolve to command specific meanings" + (fun () -> + let validate = + expect_parse_ok "graph validate -f" [| "graph"; "validate"; "-f" |] + in + (match validate.command with + | Cli_request.Graph (Graph.Parsed_validate { fix }) -> + expect_bool "validate -f is fix" true fix + | _ -> fail_test "expected graph validate"); + let export = + expect_parse_ok "graph export -f" + [| "graph"; "export"; "-t"; "edn"; "-f"; "/tmp/export.edn" |] + in + (match export.command with + | Cli_request.Graph (Graph.Parsed_export opts) -> + expect_equal "export -f is file" "/tmp/export.edn" + (expect_some "export file" opts.file) + | _ -> fail_test "expected graph export"); + let export_eq = + expect_parse_ok "graph export -f= form" + [| "graph"; "export"; "-t"; "edn"; "-f=/tmp/export.edn" |] + in + (match export_eq.command with + | Cli_request.Graph (Graph.Parsed_export opts) -> + expect_equal "export -f= is file" "/tmp/export.edn" + (expect_some "export file eq" opts.file) + | _ -> fail_test "expected graph export"); + let page = expect_parse_ok "list page -e" [| "list"; "page"; "-e" |] in + (match page.command with + | Cli_request.List (List_command.Parsed_page opts) -> + expect_bool "list page -e is expand" true opts.expand + | _ -> fail_test "expected list page"); + expect_parse_error_code "graph validate rejects --fields" + ":invalid-options" + [| "graph"; "validate"; "--fields"; "id" |]); + + test "CLI parity completion generation includes top nested and value cases" + (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let zsh = Completion.generate Cli_primitive.Zsh registry in + expect_named_contains "zsh header" zsh "#compdef logseq"; + expect_named_contains "zsh graph group" zsh "_logseq_graph()"; + expect_named_contains "zsh nested backup" zsh "_logseq_graph_backup()"; + expect_named_contains "zsh graph completions" zsh "_logseq_graphs"; + expect_named_contains "zsh graph compadd" zsh "compadd -a graphs"; + expect_named_contains "zsh graph short alias" zsh + "'(-g --graph)'{-g=,--graph=}'[Graph name]:graph:{_logseq_graphs}'"; + expect_named_contains "zsh output choices" zsh + "'(-o --output)'{-o=,--output=}'[Output format]:mode:(human json edn)'"; + expect_named_contains "zsh nested leaf function" zsh "_logseq_graph_list()"; + expect_named_contains "zsh show id option" zsh "'--id=[Entity id]::id:'"; + expect_named_contains "zsh show level option" zsh + "'--level=[Tree depth]:n:'"; + expect_named_contains "zsh query group dispatcher keeps own options" zsh + "'--query=[Datascript query]:edn:'"; + expect_named_contains "zsh comma separated fields completion" zsh + "'--fields=[Comma-separated fields to include]:fields:{_values -s , \ + fields id title ident uuid created-at updated-at}'"; + expect_named_contains "zsh export file short alias" zsh + "'(-f --file)'{-f=,--file=}'[Output file]:path:_files'"; + expect_named_contains "zsh list expand short alias" zsh + "'(-e --expand)'{-e,--expand}'[Expand page data]'"; + let bash = Completion.generate Cli_primitive.Bash registry in + expect_named_contains "bash header" bash + "# Auto-generated by `logseq completion bash`"; + expect_named_contains "bash graph command" bash " graph "; + expect_named_contains "bash upsert command" bash " upsert"; + expect_named_contains "bash search command" bash " search "; + expect_named_contains "bash nested backup case" bash "graph:backup"; + expect_named_contains "bash graph names" bash "_logseq_json_names_bash"); + + test "CLI parity completion generation keeps current dynamic helper surface" + (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let zsh = Completion.generate Cli_primitive.Zsh registry in + expect_named_contains "zsh graph helper" zsh "_logseq_graphs"; + expect_named_not_contains "zsh page helper removed" zsh "_logseq_pages"; + expect_named_not_contains "zsh query helper removed" zsh "_logseq_queries"; + expect_named_not_contains "zsh current graph helper removed" zsh + "_logseq_current_graph"; + let bash = Completion.generate Cli_primitive.Bash registry in + expect_named_not_contains "bash page helper removed" bash + "_logseq_pages_bash"; + expect_named_not_contains "bash query helper removed" bash + "_logseq_queries_bash"; + expect_named_contains "bash json name helper" bash + "_logseq_json_names_bash"; + expect_named_not_contains "bash current graph helper removed" bash + "_logseq_current_graph_bash"); + + test "CLI parity zsh completion keeps current global option contracts" + (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let zsh = Completion.generate Cli_primitive.Zsh registry in + expect_named_contains "zsh top dispatcher" zsh "_logseq()"; + expect_named_contains "zsh graph helper" zsh "_logseq_graphs"; + expect_named_contains "zsh global output option" zsh + "'(-o --output)'{-o=,--output=}'[Output format]:mode:(human json edn)'"; + expect_named_contains "zsh graph export edn options" zsh "--edn-options"; + expect_named_contains "zsh graph create sync option" zsh "--enable-sync"; + expect_named_contains "zsh search content option" zsh "--content"; + expect_named_not_contains "zsh content short alias removed" zsh "-c["; + expect_named_not_contains "zsh command boolean negation removed" zsh + "--no-include-built-in"; + expect_named_not_contains "zsh global boolean negation" zsh "--no-verbose"; + expect_named_not_contains "zsh multi value helper removed" zsh + "_logseq_multi_values"); + + test "CLI parity bash completion keeps current global option contracts" + (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let bash = Completion.generate Cli_primitive.Bash registry in + expect_named_contains "bash global opts helper" bash "_logseq_opts_for"; + expect_named_contains "bash global options" bash + "--help -h --version --config --graph -g --root-dir --timeout-ms \ + --output -o --verbose -v --profile"; + expect_named_not_contains "bash graph export edn options removed" bash + "--edn-options"; + expect_named_not_contains "bash search content option removed" bash + "--content -c"; + expect_named_not_contains "bash command boolean negation removed" bash + "--no-include-built-in"; + expect_named_not_contains "bash global boolean negation" bash + "--no-verbose"; + expect_named_not_contains "bash multi value helper removed" bash + "_logseq_multi_values_bash"; + expect_named_not_contains "bash enum whitespace helper removed" bash + "_logseq_enum_values_bash"); + + test "CLI parity command registry fills catalog options for known paths" + (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let has_name name (option : Command_registry.option_meta) = + Vec.mem name option.names + in + let option_by_name name options = Vec.find_opt (has_name name) options in + let assert_timestamp_headers_last + (command : Command_registry.command_meta) = + let headers = command.human_table_headers_order in + if Vec.mem "created-at" headers || Vec.mem "updated-at" headers then + let reversed = Vec.rev headers in + if + Vec.length reversed >= 2 + && Vec.nth reversed 0 = "updated-at" + && Vec.nth reversed 1 = "created-at" + then pass + else + fail_test + (Printf.sprintf + "%s: expected created-at,updated-at suffix, got %s" + (Vec.string_concat " " command.path) + (Vec.string_concat "," headers)) + in + Vec.iter assert_timestamp_headers_last registry.commands; + let output_option = + expect_some "global output option" + (option_by_name "--output" Command_registry.global_options) + in + expect_bool "global output has -o alias" true + (has_name "-o" output_option); + expect_equal "global output default" "human" + (expect_some "output default" output_option.default); + expect_string_vec "global output choices" + [| "human"; "json"; "edn" |] + output_option.choices; + let graph_option = + expect_some "global graph option" + (option_by_name "--graph" Command_registry.global_options) + in + expect_bool "global graph has -g alias" true (has_name "-g" graph_option); + let profile_option = + expect_some "global profile option" + (option_by_name "--profile" Command_registry.global_options) + in + expect_bool "profile is flag" true + (profile_option.arity = Command_registry.Flag); + let completion = + expect_some "completion command" + (Command_registry.find_by_path (Vec.singleton "completion") registry) + in + expect_bool "completion shell choices" true + (Vec.exists + (fun (option : Command_registry.option_meta) -> + Vec.to_array option.names = [| "--shell" |] + && Vec.to_array option.choices = [| "zsh"; "bash" |]) + completion.Command_registry.options); + let graph_create = + expect_some "graph create" + (Command_registry.find_by_path + (Vec.of_array [| "graph"; "create" |]) + registry) + in + expect_bool "graph create enable sync" true + (Option.is_some (option_by_name "--enable-sync" graph_create.options)); + expect_bool "graph create password" true + (Option.is_some (option_by_name "--e2ee-password" graph_create.options)); + let graph_export = + expect_some "graph export" + (Command_registry.find_by_path + (Vec.of_array [| "graph"; "export" |]) + registry) + in + let export_type = + expect_some "export type option" + (option_by_name "--type" graph_export.options) + in + expect_string_vec "export type choices" [| "edn"; "sqlite" |] + export_type.choices; + expect_bool "export edn options" true + (Option.is_some (option_by_name "--edn-options" graph_export.options)); + expect_bool "export edn options alias" true + (Option.is_some (option_by_name "-e" graph_export.options)); + expect_bool "export pretty print" true + (Option.is_some (option_by_name "--pretty-print" graph_export.options)); + expect_bool "export pretty print alias" true + (Option.is_some (option_by_name "-p" graph_export.options)); + expect_bool "export timestamps retired" true + (Option.is_none + (option_by_name "--include-timestamps" graph_export.options)); + expect_bool "export built-ins retired" true + (Option.is_none + (option_by_name "--exclude-built-in-pages" graph_export.options)); + expect_bool "export namespaces retired" true + (Option.is_none + (option_by_name "--exclude-namespaces" graph_export.options)); + let graph_import = + expect_some "graph import" + (Command_registry.find_by_path + (Vec.of_array [| "graph"; "import" |]) + registry) + in + let import_input = + expect_some "import input option" + (option_by_name "--input" graph_import.options) + in + expect_bool "import input required" true import_input.required; + let list_page = + expect_some "list page" + (Command_registry.find_by_path + (Vec.of_array [| "list"; "page" |]) + registry) + in + let page_sort = + expect_some "page sort option" + (option_by_name "--sort" list_page.options) + in + expect_bool "page sort has title" true (Vec.mem "title" page_sort.choices); + let page_order = + expect_some "page order option" + (option_by_name "--order" list_page.options) + in + expect_string_vec "page order choices" [| "asc"; "desc" |] + page_order.choices; + let list_tag = + expect_some "list tag" + (Command_registry.find_by_path + (Vec.of_array [| "list"; "tag" |]) + registry) + in + let tag_fields = + expect_some "tag fields option" + (option_by_name "--fields" list_tag.options) + in + expect_bool "tag fields include title" true + (Vec.mem "title" tag_fields.choices); + expect_bool "tag fields include uuid" true + (Vec.mem "uuid" tag_fields.choices); + let list_task = + expect_some "list task" + (Command_registry.find_by_path + (Vec.of_array [| "list"; "task" |]) + registry) + in + expect_equal "list task human header order" + "id,title,status,priority,scheduled,deadline,created-at,updated-at" + (Vec.string_concat "," list_task.human_table_headers_order); + let show = + expect_some "show command" + (Command_registry.find_by_path (Vec.singleton "show") registry) + in + expect_bool "show page option" true + (Option.is_some (option_by_name "--page" show.options)); + let upsert_block = + expect_some "upsert block" + (Command_registry.find_by_path + (Vec.of_array [| "upsert"; "block" |]) + registry) + in + let block_pos = + expect_some "block pos option" + (option_by_name "--pos" upsert_block.options) + in + expect_string_vec "block pos choices" + [| "first-child"; "last-child"; "sibling" |] + block_pos.choices; + let task = + expect_some "upsert task" + (Command_registry.find_by_path + (Vec.of_array [| "upsert"; "task" |]) + registry) + in + expect_bool "task status option" true + (Vec.exists + (fun (option : Command_registry.option_meta) -> + Vec.to_array option.names = [| "--status" |]) + task.options); + expect_bool "task clear status option" true + (Vec.exists + (fun (option : Command_registry.option_meta) -> + Vec.to_array option.names = [| "--no-status" |]) + task.options); + let property = + expect_some "upsert property" + (Command_registry.find_by_path + (Vec.of_array [| "upsert"; "property" |]) + registry) + in + expect_bool "property type choices" true + (Vec.exists + (fun (option : Command_registry.option_meta) -> + Vec.to_array option.names = [| "--type" |] + && Vec.mem "default" option.choices + && Vec.mem "checkbox" option.choices) + property.options); + let query = + expect_some "query command" + (Command_registry.find_by_path (Vec.singleton "query") registry) + in + let query_list = + expect_some "query list command" + (Command_registry.find_by_path + (Vec.of_array [| "query"; "list" |]) + registry) + in + expect_bool "query examples metadata" true + (not (Vec.is_empty query.examples)); + expect_bool "query name option" true + (Option.is_some (option_by_name "--name" query.options)); + expect_bool "query list examples metadata" true + (not (Vec.is_empty query_list.examples)); + let search_block = + expect_some "search block" + (Command_registry.find_by_path + (Vec.of_array [| "search"; "block" |]) + registry) + in + let search_content = + expect_some "search content option" + (option_by_name "--content" search_block.options) + in + expect_equal "search content doc" "Content search text" search_content.doc; + expect_equal "search block human header order" "id,ident,title" + (Vec.string_concat "," search_block.human_table_headers_order)); + + test "CLI parity command registry help renders command and option details" + (fun () -> + let registry = (Cli.make_app_context ()).Cli.registry in + let top_help = Command_registry.render_help registry in + expect_named_contains "top commands heading" top_help + "Available commands:"; + expect_named_contains "skill show command" top_help "skill show"; + expect_named_contains "completion command" top_help "completion"; + let upsert_help = + Command_registry.render_help + ~group:(Vec.of_array [| "upsert"; "task" |]) + registry + in + expect_named_contains "task usage" upsert_help + "Usage: logseq upsert task [options]"; + expect_named_contains "task status option" upsert_help "--status "; + expect_named_contains "task no status option" upsert_help "--no-status"); + + test "CLI parity skill install target resolves local global and missing home" + (fun () -> + let local = + expect_ok "local skill target" + (Skill.resolve_install_target ~global:false ~cwd:"/tmp/work" + ~home_dir:(Some "/Users/demo")) + in + expect_equal "local scope" "local" (keyword_text local.scope); + expect_equal "local path" "/tmp/work/.agents/skills/logseq-cli/SKILL.md" + local.path; + expect_equal "local update command" "logseq skill install" + local.update_command; + let global = + expect_ok "global skill target" + (Skill.resolve_install_target ~global:true ~cwd:"/tmp/work" + ~home_dir:(Some "/Users/demo")) + in + expect_equal "global scope" "global" (keyword_text global.scope); + expect_equal "global path" + "/Users/demo/.agents/skills/logseq-cli/SKILL.md" global.path; + expect_error_code "missing home" ":skill-home-dir-unavailable" + (Skill.resolve_install_target ~global:true ~cwd:"/tmp/work" + ~home_dir:None)); + + test "CLI parity installed skill targets omit unavailable global target" + (fun () -> + let targets = + Skill.installed_skill_targets ~cwd:"/tmp/work" + ~home_dir:(Some "/Users/demo") + in + expect_int "two targets" 2 (Vec.length targets); + expect_equal "local target" "/tmp/work/.agents/skills/logseq-cli/SKILL.md" + (Vec.nth targets 0).Skill.path; + expect_equal "global target" + "/Users/demo/.agents/skills/logseq-cli/SKILL.md" + (Vec.nth targets 1).path; + let local_only = + Skill.installed_skill_targets ~cwd:"/tmp/work" ~home_dir:None + in + expect_int "local only" 1 (Vec.length local_only); + expect_equal "local only scope" "local" + (keyword_text (Vec.peek_front local_only).scope)); + + test "CLI parity skill show and install read source and preserve neighbors" + (fun () -> + let root = temp_dir "logseq-cli-parity-skill-" in + let source = Node.Path.join [| root; "source"; "SKILL.md" |] in + let destination_dir = + Node.Path.join [| root; "work"; ".agents"; "skills"; "logseq-cli" |] + in + let destination_file = Node.Path.join [| destination_dir; "SKILL.md" |] in + let neighbor = + Node.Path.join + [| root; "work"; ".agents"; "skills"; "other"; "SKILL.md" |] + in + try + mkdir_p (Node.Path.dirname source); + mkdir_p (Node.Path.dirname neighbor); + write_file source "# skill\nfrom source"; + write_file neighbor "keep me"; + let show = + effect_result "skill show" + (execute_with_output Skill.execute + (Skill.Skill_show { source_path = Some source }) + (config ~root_dir:root ()) Output.Mode.Human) + in + expect_equal "show output" "# skill\nfrom source" + (Format_types.format_result show (config ~root_dir:root ())); + let install = + effect_result "skill install" + (execute_with_output Skill.execute + (Skill.Skill_install + { + global = false; + source_path = Some source; + destination_dir = Some destination_dir; + destination_file = Some destination_file; + }) + (config ~root_dir:root ()) Output.Mode.Json) + in + let json_config = + config ~root_dir:root + ~output_format:(Output.Mode.Packed Output.Mode.Json) () + in + let output = Format_types.format_result install json_config in + expect_named_contains "installed path" output destination_file; + expect_equal "installed content" "# skill\nfrom source" + (read_file destination_file); + expect_equal "neighbor preserved" "keep me" (read_file neighbor); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity example selector uses registry examples before defaults" + (fun () -> + let registry = + Command_registry.make + (Vec.of_array + [| + { + Command_registry.id = Command_id.Graph_list; + path = Vec.of_array [| "graph"; "list" |]; + doc = "List graphs"; + long_doc = None; + examples = Vec.singleton "custom graph list example"; + options = Vec.empty; + category = Command_registry.Graph_management; + requires_graph = false; + requires_auth = false; + write_command = false; + human_table_headers_order = Vec.empty; + }; + |]) + in + let action = + expect_ok "registry graph examples" + (Example.resolve_selector registry (Vec.singleton "graph")) + in + expect_equal "registry example" "custom graph list example" + (Vec.peek_front action.examples); + let defaults = + expect_ok "default examples" + (Example.resolve_selector Command_registry.empty + (Vec.of_array [| "graph"; "list" |])) + in + expect_equal "default selector" "graph list" defaults.selector; + expect_named_contains "default graph list example" + (Vec.peek_front defaults.examples) + "logseq graph list"); + + test "CLI parity config update writes sanitized patch and removes null fields" + (fun () -> + let root = temp_dir "logseq-cli-parity-config-update-" in + let cfg_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file cfg_path + "{:graph \"old\" :ws-url \"wss://old.example/%s\" :auth-token \ + \"secret\" :retries 2 :e2ee-password \"password\"}\n"; + let resolved = + resolve_config (Global_opts.create ~config_path:cfg_path ()) + in + ignore + (expect_ok "update config" + (effect_result "update config" + (Cli_config.update_config resolved + (Edn_util.map + [ + (Edn_util.keyword "graph", Edn_util.string "new"); + (Edn_util.keyword "ws-url", Edn_util.nil); + ])))); + let parsed = edn_of_string (read_file cfg_path) in + expect_equal "updated graph" "new" + (expect_some "graph" (Edn_util.get_string parsed "graph")); + expect_none "removed ws-url" (Edn_util.get parsed "ws-url"); + expect_none "sanitized auth-token" (Edn_util.get parsed "auth-token"); + expect_none "sanitized retries" (Edn_util.get parsed "retries"); + expect_none "sanitized e2ee-password" + (Edn_util.get parsed "e2ee-password"); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity agent name resolves config hostname and invalid blanks" + (fun () -> + let with_raw raw = + { (config ()) with Cli_config.raw_file_config = Some raw } + in + expect_equal "configured agent" "bridge-a" + (expect_ok "configured agent" + (Agent.resolve_agent_name + (with_raw + (Edn_util.map + [ + ( Edn_util.keyword "agent-name", + Edn_util.string " bridge-a " ); + ])) + (Some "fallback-host"))); + expect_equal "hostname fallback" "fallback-host" + (expect_ok "hostname fallback" + (Agent.resolve_agent_name (config ()) (Some "fallback-host"))); + expect_error_code "blank configured agent" ":agent-name-invalid" + (Agent.resolve_agent_name + (with_raw + (Edn_util.map + [ (Edn_util.keyword "agent-name", Edn_util.string " ") ])) + (Some "fallback-host")); + let platform_hostname = + expect_ok "missing hostname falls back to platform hostname" + (Agent.resolve_agent_name (config ()) (Some "")) + in + expect_bool "platform hostname non-empty" true + (String.trim platform_hostname <> "")); + + test "CLI parity agent task routing returns explicit non-routable reasons" + (fun () -> + (match + Agent.routable_task_decision (agent_task_entity ()) + ~agent_name:"build-host" + with + | Agent.Routable -> pass + | Not_routable reason -> + fail_test ("expected routable, got " ^ routable_reason_text reason)); + let expect_reason name expected entity = + match Agent.routable_task_decision entity ~agent_name:"build-host" with + | Agent.Not_routable reason -> + expect_equal name expected (routable_reason_text reason) + | Routable -> fail_test (name ^ ": expected non-routable") + in + expect_reason "missing uuid" "missing-stable-uuid" + (agent_task_entity ~uuid:None ()); + expect_reason "missing task tag" "missing-task-tag" + (agent_task_entity ~tags:[||] ()); + expect_reason "done status" "not-todo" + (agent_task_entity ~status:(Some "done") ()); + expect_reason "assignee mismatch" "assignee-mismatch" + (agent_task_entity ~assignees:[| "other-host" |] ()); + expect_reason "already routed" "already-routed" + (agent_task_entity ~session_id:"codex-1" ())); + + test "CLI parity agent prompt template validates required and unknown vars" + (fun () -> + let template body = + { + Agent.kind = Agent.Task; + body; + required_vars = Vec.of_array [| "graph"; "task-block-tree" |]; + allowed_vars = + Vec.of_array [| "graph"; "agent-name"; "task-block-tree" |]; + } + in + ignore + (expect_ok "valid template" + (Agent.validate_prompt_template + (template "{{graph}}\n{{task-block-tree}}\n{{agent-name}}"))); + expect_error_code "blank template" ":missing-template-code-block" + (Agent.validate_prompt_template (template " ")); + expect_error_code "unknown var" ":unknown-template-vars" + (Agent.validate_prompt_template + (template "{{graph}}\n{{task-block-tree}}\n{{other}}")); + expect_error_code "missing var" ":missing-template-vars" + (Agent.validate_prompt_template (template "{{graph}}")); + let documented_template = + template "{{graph}}\n{{task-block-tree}}\n'{{documented-only}}'" + in + ignore + (expect_ok "quoted docs var ignored" + (Agent.validate_prompt_template documented_template))); + + test "CLI parity parse covers additional command option surfaces" (fun () -> + let list_node = + expect_parse_ok "list node" + [| + "list"; + "node"; + "--tags"; + "project,work"; + "--properties"; + "owner,status"; + "--sort"; + "updated-at"; + |] + in + (match list_node.command with + | Cli_request.List (List_command.Parsed_node opts) -> + expect_int "node tags" 2 (Vec.length opts.tags); + expect_int "node properties" 2 (Vec.length opts.properties); + expect_equal "node sort" "updated-at" + (expect_some "node sort" opts.common.sort) + | _ -> fail_test "expected list node"); + let query_run = + expect_parse_ok "query run" + [| + "query"; + "--query"; + "[:find ?e :in $ ?title :where [?e :block/title ?title]]"; + "--inputs"; + "[\"Hello\"]"; + "--graph"; + "demo"; + "--output"; + "json"; + |] + in + (match query_run.command with + | Cli_request.Query + (Query.Parsed_run + { query_edn = Some query; inputs_edn = Some inputs; name = None }) + -> ( + expect_equal "query parse text" + "[:find ?e :in $ ?title :where [?e :block/title ?title]]" query; + expect_equal "query parse inputs" "[\"Hello\"]" inputs; + match query_run.globals.output_format with + | Some (Output.Mode.Packed Output.Mode.Json) -> pass + | _ -> fail_test "expected json output mode") + | _ -> fail_test "expected query run"); + let query_list = expect_parse_ok "query list" [| "query"; "list" |] in + (match query_list.command with + | Cli_request.Query Query.Parsed_list -> pass + | _ -> fail_test "expected query list"); + let task = + expect_parse_ok "upsert task" + [| + "upsert"; + "task"; + "--page"; + "Inbox"; + "--content"; + "Ship release"; + "--status"; + "todo"; + "--priority"; + "high"; + "--no-deadline"; + |] + in + (match task.command with + | Cli_request.Upsert (Upsert.Parsed_task opts) -> + expect_equal "task page" "Inbox" (expect_some "page" opts.page); + expect_equal "task content" "Ship release" + (expect_some "content" opts.content); + expect_equal "task status" "todo" (expect_some "status" opts.status); + expect_bool "task no deadline" true opts.no_deadline + | _ -> fail_test "expected upsert task"); + let property = + expect_parse_ok "upsert property" + [| + "upsert"; + "property"; + "--name"; + "status"; + "--type"; + "default"; + "--cardinality"; + "many"; + "--hide"; + "false"; + "--public"; + "true"; + |] + in + (match property.command with + | Cli_request.Upsert (Upsert.Parsed_property opts) -> + expect_equal "property name" "status" (expect_some "name" opts.name); + expect_bool "property kind" true (opts.kind = Some Property.Default); + expect_bool "property cardinality" true + (opts.cardinality = Some Property.Many); + expect_bool "hide false" false (expect_some "hide" opts.hide); + expect_bool "public true" true (expect_some "public" opts.public) + | _ -> fail_test "expected upsert property"); + let tag = + expect_parse_ok "upsert tag schema properties" + [| + "upsert"; + "tag"; + "--name"; + "project"; + "--add-properties"; + "[\"status\"]"; + "--remove-properties"; + "[:user.property/owner]"; + |] + in + (match tag.command with + | Cli_request.Upsert (Upsert.Parsed_tag opts) -> + expect_equal "tag name" "project" (expect_some "name" opts.name); + expect_equal "add properties" "[\"status\"]" + (expect_some "add-properties" opts.add_properties_edn); + expect_equal "remove properties" "[:user.property/owner]" + (expect_some "remove-properties" opts.remove_properties_edn) + | _ -> fail_test "expected upsert tag"); + let sync_download = + expect_parse_ok "sync download" + [| "sync"; "download"; "--progress"; "--e2ee-password"; "secret" |] + in + (match sync_download.command with + | Cli_request.Sync (Sync.Parsed_download opts) -> + expect_bool "progress" true (expect_some "progress" opts.progress); + expect_equal "download password" "secret" + (expect_some "password" opts.e2ee_password) + | _ -> fail_test "expected sync download"); + let sync_asset = + expect_parse_ok "sync asset" + [| + "sync"; + "asset"; + "download"; + "--uuid"; + "00000000-0000-4000-8000-000000000001"; + |] + in + (match sync_asset.command with + | Cli_request.Sync (Sync.Parsed_asset_download opts) -> + expect_equal "asset uuid" "00000000-0000-4000-8000-000000000001" + (expect_some "asset uuid" opts.uuid) + | _ -> fail_test "expected sync asset download"); + let grant = + expect_parse_ok "sync grant" + [| + "sync"; + "grant-access"; + "--graph-id"; + "00000000-0000-4000-8000-000000000001"; + "--email"; + "user@example.com"; + |] + in + (match grant.command with + | Cli_request.Sync (Sync.Parsed_grant_access opts) -> + expect_equal "grant graph id" "00000000-0000-4000-8000-000000000001" + (expect_some "graph id" opts.graph_id); + expect_equal "grant email" "user@example.com" + (expect_some "email" opts.email) + | _ -> fail_test "expected sync grant access"); + let graph_create = + expect_parse_ok "graph create sync" + [| "graph"; "create"; "--enable-sync"; "--e2ee-password"; "secret" |] + in + (match graph_create.command with + | Cli_request.Graph (Graph.Parsed_create opts) -> + expect_bool "enable sync" true opts.enable_sync; + expect_equal "graph password" "secret" + (expect_some "graph password" opts.e2ee_password) + | _ -> fail_test "expected graph create"); + expect_parse_error_code "unknown agent subcommand" ":unknown-command" + [| "agent"; "bridge"; "list" |]; + expect_parse_error_code "unknown option" ":invalid-options" + [| "server"; "cleanup"; "--graph"; "demo"; "--unknown"; "x" |]); + + test "CLI parity transport reads and writes edn sqlite and db files" + (fun () -> + let root = temp_dir "logseq-cli-parity-transport-io-" in + let edn_path = Node.Path.join [| root; "data.edn" |] in + let edn_utf8_path = Node.Path.join [| root; "utf8.edn" |] in + let edn_bytes_path = Node.Path.join [| root; "bytes.edn" |] in + let sqlite_path = Node.Path.join [| root; "graph.sqlite" |] in + let db_path = Node.Path.join [| root; "graph.db" |] in + let title = unicode_text [| 0x4e2d; 0x6587; 0x6807; 0x9898 |] in + let imported_title = unicode_text [| 0x5bfc; 0x5165; 0x4e2d; 0x6587 |] in + try + ignore + (expect_ok "write edn" + (effect_result "write edn" + (Transport.write_output ~format:(Edn_util.keyword_t "edn") + ~path:edn_path + ~data: + (Edn_util.map + [ + (Edn_util.keyword "title", Edn_util.string "中文标题"); + (Edn_util.keyword "count", Edn_util.int 2); + ])))); + expect_named_contains "edn file preserves utf8" (read_file edn_path) + title; + let edn_value = + expect_ok "read edn" + (effect_result "read edn" + (Transport.read_input ~format:(Edn_util.keyword_t "edn") + ~path:edn_path)) + in + expect_equal "edn title" title + (expect_some "title" (Edn_util.get_string edn_value "title")); + write_file edn_utf8_path ("{:title \"" ^ imported_title ^ "\"}"); + let edn_utf8_value = + expect_ok "read utf8 edn" + (effect_result "read utf8 edn" + (Transport.read_input ~format:(Edn_util.keyword_t "edn") + ~path:edn_utf8_path)) + in + expect_equal "utf8 edn title" imported_title + (expect_some "utf8 title" + (Edn_util.get_string edn_utf8_value "title")); + ignore + (expect_ok "write bytes edn" + (effect_result "write bytes edn" + (Transport.write_output ~format:(Edn_util.keyword_t "edn") + ~path:edn_bytes_path + ~data: + (Edn_util.map + [ + ( Edn_util.keyword "payload", + Edn_util.bytes (Bytes.of_string "payload") ); + ])))); + expect_named_contains "edn bytes use transit tag" + (read_file edn_bytes_path) "#transit/bytes"; + ignore + (expect_ok "write sqlite" + (effect_result "write sqlite" + (Transport.write_output + ~format:(Edn_util.keyword_t "sqlite") + ~path:sqlite_path + ~data:(Edn_util.string "sqlite-bytes")))); + let sqlite_value = + expect_ok "read sqlite" + (effect_result "read sqlite" + (Transport.read_input + ~format:(Edn_util.keyword_t "sqlite") + ~path:sqlite_path)) + in + expect_equal "sqlite bytes" "sqlite-bytes" + (Bytes.to_string + (expect_some "sqlite bytes" (Edn_util.as_bytes sqlite_value))); + expect_named_contains "read sqlite uses transit bytes tag" + (Melange_edn_melange.to_edn_string sqlite_value) + "#transit/bytes"; + ignore + (expect_ok "write db" + (effect_result "write db" + (Transport.write_output ~format:(Edn_util.keyword_t "db") + ~path:db_path + ~data:(Edn_util.bytes (Bytes.of_string "db"))))); + let db_value = + expect_ok "read db" + (effect_result "read db" + (Transport.read_input ~format:(Edn_util.keyword_t "db") + ~path:db_path)) + in + expect_equal "db bytes" "db" + (Bytes.to_string + (expect_some "db bytes" (Edn_util.as_bytes db_value))); + expect_named_contains "read db uses transit bytes tag" + (Melange_edn_melange.to_edn_string db_value) + "#transit/bytes"; + expect_error_code "bad write format" ":unsupported-output-format" + (effect_result "bad write format" + (Transport.write_output + ~format:(Edn_util.keyword_t "json") + ~path:(Node.Path.join [| root; "data.json" |]) + ~data:(Edn_util.string "nope"))); + expect_error_code "bad read format" ":unsupported-input-format" + (effect_result "bad read format" + (Transport.read_input + ~format:(Edn_util.keyword_t "json") + ~path:edn_path)); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test_promise + "CLI parity transport thread-api invoke sends transit args and decodes \ + resultTransit" (fun () -> + let request_count = ref 0 in + let large_int = 1_773_666_723_828L in + let server = + invoke_server (fun body -> + incr request_count; + expect_named_contains "invoke method" body "thread-api/q"; + expect_named_contains "invoke repo" body "logseq_db_alpha"; + expect_named_contains "invoke args transit field" body "argsTransit"; + let args = + expect_some "invoke args" (Edn_util.as_seq (invoke_args body)) + in + let query = + expect_some "invoke query" (Edn_util.as_seq (Vec.nth args 1)) + in + expect_int64 "invoke large int" large_int + (expect_some "invoke large int value" + (Edn_util.as_int64 (Vec.nth query 5))); + (match Vec.nth query 6 with + | Melange_edn_melange.Any (Melange_edn_melange.Bigint value) -> + expect_equal "invoke bigint" "900719925474099312345" value + | _ -> fail_test "invoke bigint: expected Bigint"); + (match Vec.nth query 7 with + | Melange_edn_melange.Any (Melange_edn_melange.Decimal value) -> + expect_equal "invoke decimal" "1234567890.123456789" value + | _ -> fail_test "invoke decimal: expected Decimal"); + "\"ok\"") + in + with_server server (fun base_url -> + let invoke_config = + { + Transport.base_url; + timeout_span = Time.span_of_ms 1_000L; + profile_session = None; + } + in + let* result = + effect_to_promise + (Transport.thread_api_q invoke_config + ~repo:(Cli_primitive.create_repo "logseq_db_alpha") + ~query: + (Edn_util.vector_t + [ + Edn_util.keyword "find"; + Edn_util.symbol "?title"; + Edn_util.keyword "where"; + Edn_util.vector + [ + Edn_util.symbol "?b"; + Edn_util.keyword "block/title"; + Edn_util.symbol "?title"; + ]; + Edn_util.keyword "created-at"; + Edn_util.int64 large_int; + Edn_util.any + (Melange_edn_melange.bigint "900719925474099312345"); + Edn_util.any + (Melange_edn_melange.decimal "1234567890.123456789"); + ])) + in + expect_equal "decoded invoke result" "ok" + (expect_some "invoke result string" (Edn_util.as_string result)); + expect_int "one invoke request" 1 !request_count; + Js.Promise.resolve pass)); + + test_promise + "CLI parity transport keeps prefixed symbol-looking strings as strings" + (fun () -> + let server = + invoke_server (fun body -> + let args = + expect_some "invoke args" (Edn_util.as_seq (invoke_args body)) + in + let query = + expect_some "invoke query" (Edn_util.as_seq (Vec.nth args 1)) + in + expect_equal "string stays string" "~$?title" + (expect_some "string value" + (Edn_util.as_string (Vec.nth query 1))); + "\"ok\"") + in + with_server server (fun base_url -> + let invoke_config = + { + Transport.base_url; + timeout_span = Time.span_of_ms 1_000L; + profile_session = None; + } + in + let* result = + effect_to_promise + (Transport.thread_api_q invoke_config + ~repo:(Cli_primitive.create_repo "logseq_db_alpha") + ~query: + (Edn_util.vector_t + [ Edn_util.keyword "find"; Edn_util.string "~$?title" ])) + in + expect_equal "decoded invoke result" "ok" + (expect_some "invoke result string" (Edn_util.as_string result)); + Js.Promise.resolve pass)); + + test_promise + "CLI parity transport thread-api invoke decodes cached keyword vectors" + (fun () -> + let server = + invoke_server (fun body -> + expect_named_contains "invoke method" body "thread-api/q"; + "[\"~:block/uuid\",[\"^0\",\"x\"]]") + in + with_server server (fun base_url -> + let invoke_config = + { + Transport.base_url; + timeout_span = Time.span_of_ms 1_000L; + profile_session = None; + } + in + let* result = + effect_to_promise + (Transport.thread_api_q invoke_config + ~repo:(Cli_primitive.create_repo "logseq_db_alpha") + ~query: + (Edn_util.vector_t_vec + (Vec.singleton (Edn_util.keyword "find")))) + in + match Edn_util.as_seq result with + | Some values when Vec.length values = 2 -> ( + let key = Vec.nth values 0 in + let nested = Vec.nth values 1 in + expect_equal "decoded key" "block/uuid" + (expect_some "keyword" (Edn_util.as_keyword key)); + match Edn_util.as_seq nested with + | Some nested_values when Vec.length nested_values = 2 -> + let nested_key = Vec.nth nested_values 0 in + let value = Vec.nth nested_values 1 in + expect_equal "decoded nested key" "block/uuid" + (expect_some "nested keyword" + (Edn_util.as_keyword nested_key)); + expect_equal "decoded nested value" "x" + (expect_some "nested value" (Edn_util.as_string value)); + Js.Promise.resolve pass + | _ -> fail_promise "expected nested vector") + | _ -> fail_promise "expected decoded vector")); + + test_promise + "CLI parity transport connect-events decodes rtc-log and closes \ + subscription" (fun () -> + let received = ref Vec.empty in + let closed = ref false in + let server = + create_server (fun[@u] req res -> + if req_method req = "GET" && req_url req = "/v1/events" then ( + res_write_head res 200 + (Js.Dict.fromArray + [| + ("Content-Type", "text/event-stream"); + ("Cache-Control", "no-cache"); + ("Connection", "keep-alive"); + |]); + res_write res + "data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ + \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u00000000-0000-4000-8000-000000000001\\\",\\\"~:message\\\",\\\"downloaded \ + 1 block\\\"]]\"}\n\n"; + req_on_end req "close" (fun[@u] () -> closed := true)) + else write_json res 404 (error_response "not found")) + in + with_server server (fun base_url -> + let invoke_config = + { + Transport.base_url; + timeout_span = Time.span_of_ms 1_000L; + profile_session = None; + } + in + let* subscription = + effect_to_promise + (Transport.connect_events invoke_config (fun event_type payload -> + received := + Vec.push_front !received + (event_type, Melange_edn_melange.to_edn_string payload); + Cli_effect.pure ())) + in + let* () = sleep_ms 50 in + let* () = effect_to_promise (subscription.Transport.close ()) in + let* () = sleep_ms 50 in + let event_type, payload = + match Vec.peek_back_opt !received with + | Some event -> event + | None -> + fail_test "received event: expected Some value"; + assert false + in + expect_equal "event type" "rtc-log" + (Edn_util.keyword_to_string event_type); + expect_named_contains "event payload" payload "downloaded 1 block"; + expect_bool "subscription closed" true !closed; + Js.Promise.resolve pass)); + + test "CLI parity server lock paths use canonical graph directory names" + (fun () -> + expect_equal "plain lock path" + "/tmp/logseq-root/graphs/demo/db-worker.lock" + (Server_runtime.lock_path ~root_dir:"/tmp/logseq-root" + (Cli_primitive.create_repo "logseq_db_demo")); + expect_equal "encoded lock path" + "/tmp/logseq-root/graphs/foo~2Fbar/db-worker.lock" + (Server_runtime.lock_path ~root_dir:"/tmp/logseq-root" + (Cli_primitive.create_repo "logseq_db_foo/bar"))); + + test_promise + "CLI parity server start launches db-worker-node with parent executable" + (fun () -> + let root = temp_dir "logseq-cli-server-parent-exec-" in + let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in + let graphs_dir = Node.Path.join [| root; "graphs" |] in + let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in + let lock_path = Node.Path.join [| graph_dir; "db-worker.lock" |] in + let server_list_path = Node.Path.join [| root; "server-list" |] in + let server = + create_server (fun[@u] req res -> + if req_method req = "GET" && req_url req = "/healthz" then + write_json res 200 + (Printf.sprintf + "{\"repo\":\"logseq_db_demo\",\"status\":\"ready\",\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}" + (Cli_unix.getpid ()) root) + else write_json res 404 (error_response "not found")) + in + try + mkdir_p graph_dir; + write_file worker_script "console.log('db-worker');\n"; + start_spawn_capture (); + set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script; + Js.Promise.make (fun ~resolve ~reject -> + server_listen server 0 "127.0.0.1" (fun[@u] () -> + let port = (server_address server)##port in + set_spawn_capture_on_spawn ~lock_path ~server_list_path ~port; + let config = config ~root_dir:root ~repo:"demo" () in + let finish_ok () = + server_close server (fun[@u] () -> (resolve pass [@u])) + in + let finish_error message = + server_close server (fun[@u] () -> + (reject (Failure message) [@u])) + in + Cli_effect.on_any + (Server_runtime.start_server config + (Cli_primitive.create_repo "logseq_db_demo") + ~create_empty_db:false) + (fun result -> + let calls = spawn_capture_calls () in + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + match result with + | Error err -> + finish_error + ("server start failed: " ^ err.Error.message) + | Ok _ -> ( + let expected_command = Node.Process.argv.(0) in + try + expect_named_contains "spawn command" calls + ("\"command\":\"" ^ expected_command ^ "\""); + expect_named_contains "spawn worker arg" calls + worker_script; + finish_ok () + with exn -> finish_error (Printexc.to_string exn))) + (fun exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + finish_error (Printexc.to_string exn)))) + with exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + server_close server (fun[@u] () -> ()); + fail_promise (Printexc.to_string exn)); + + test_promise "CLI parity server start reuses starting db-worker-node" + (fun () -> + let root = temp_dir "logseq-cli-server-starting-existing-" in + let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in + let graphs_dir = Node.Path.join [| root; "graphs" |] in + let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in + let lock_path = Node.Path.join [| graph_dir; "db-worker.lock" |] in + let server_list_path = Node.Path.join [| root; "server-list" |] in + let health_calls = ref 0 in + let server = + create_server (fun[@u] req res -> + if req_method req = "GET" && req_url req = "/healthz" then ( + incr health_calls; + let status_code, status = + if !health_calls = 1 then (503, "starting") else (200, "ready") + in + write_json res status_code + (Printf.sprintf + "{\"repo\":\"logseq_db_demo\",\"status\":%S,\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}" + status (Cli_unix.getpid ()) root)) + else write_json res 404 (error_response "not found")) + in + try + mkdir_p graph_dir; + write_file worker_script "console.log('db-worker');\n"; + write_file lock_path + (Printf.sprintf + "{\"repo\":\"logseq_db_demo\",\"pid\":%d,\"lock-id\":\"test\",\"owner-source\":\"cli\"}" + (Cli_unix.getpid ())); + start_spawn_capture (); + set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script; + Js.Promise.make (fun ~resolve ~reject -> + server_listen server 0 "127.0.0.1" (fun[@u] () -> + let port = (server_address server)##port in + write_file server_list_path + (string_of_int (Cli_unix.getpid ()) + ^ " " ^ string_of_int port ^ "\n"); + let config = config ~root_dir:root ~repo:"demo" () in + let finish_ok () = + server_close server (fun[@u] () -> (resolve pass [@u])) + in + let finish_error message = + server_close server (fun[@u] () -> + (reject (Failure message) [@u])) + in + Cli_effect.on_any + (Server_runtime.start_server config + (Cli_primitive.create_repo "logseq_db_demo") + ~create_empty_db:false) + (fun result -> + let calls = spawn_capture_calls () in + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + match result with + | Error err -> + finish_error + ("server start failed: " ^ err.Error.message) + | Ok _ -> ( + try + expect_equal "spawn calls" "[]" calls; + expect_bool "health calls include ready retry" true + (!health_calls >= 2); + finish_ok () + with exn -> finish_error (Printexc.to_string exn))) + (fun exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + finish_error (Printexc.to_string exn)))) + with exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + server_close server (fun[@u] () -> ()); + fail_promise (Printexc.to_string exn)); + + test_promise "CLI parity ensure server retries final health discovery" + (fun () -> + let root = temp_dir "logseq-cli-ensure-final-health-" in + let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in + let graphs_dir = Node.Path.join [| root; "graphs" |] in + let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in + let lock_path = Node.Path.join [| graph_dir; "db-worker.lock" |] in + let server_list_path = Node.Path.join [| root; "server-list" |] in + let health_calls = ref 0 in + let server = + create_server (fun[@u] req res -> + if req_method req = "GET" && req_url req = "/healthz" then ( + incr health_calls; + match !health_calls with + | 1 -> + write_json res 503 + (Printf.sprintf + "{\"repo\":\"logseq_db_demo\",\"status\":\"starting\",\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}" + (Cli_unix.getpid ()) root) + | 3 -> write_json res 500 (error_response "busy") + | _ -> + write_json res 200 + (Printf.sprintf + "{\"repo\":\"logseq_db_demo\",\"status\":\"ready\",\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}" + (Cli_unix.getpid ()) root)) + else write_json res 404 (error_response "not found")) + in + try + mkdir_p graph_dir; + write_file worker_script "console.log('db-worker');\n"; + start_spawn_capture (); + set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script; + Js.Promise.make (fun ~resolve ~reject -> + server_listen server 0 "127.0.0.1" (fun[@u] () -> + let port = (server_address server)##port in + set_spawn_capture_on_spawn ~lock_path ~server_list_path ~port; + let config = config ~root_dir:root ~repo:"demo" () in + let finish_ok () = + server_close server (fun[@u] () -> (resolve pass [@u])) + in + let finish_error message = + server_close server (fun[@u] () -> + (reject (Failure message) [@u])) + in + Cli_effect.on_any + (Server_runtime.ensure_server config + (Cli_primitive.create_repo "logseq_db_demo") + ~create_empty_db:false) + (fun result -> + let calls = spawn_capture_calls () in + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + match result with + | Error err -> + finish_error + ("ensure server failed: " ^ err.Error.message) + | Ok _ -> ( + try + expect_named_contains "spawned worker" calls + worker_script; + expect_bool "final health retried" true + (!health_calls >= 4); + finish_ok () + with exn -> finish_error (Printexc.to_string exn))) + (fun exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + finish_error (Printexc.to_string exn)))) + with exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + server_close server (fun[@u] () -> ()); + fail_promise (Printexc.to_string exn)); + + test_promise + "CLI parity server start orphan timeout reports db-worker-node command" + (fun () -> + let root = temp_dir "logseq-cli-server-orphan-command-" in + let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in + let graphs_dir = Node.Path.join [| root; "graphs" |] in + let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in + try + mkdir_p graph_dir; + write_file worker_script "console.log('db-worker');\n"; + start_spawn_capture (); + set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script; + let config = config ~root_dir:root ~repo:"demo" () in + effect_to_promise + (Server_runtime.start_server config + (Cli_primitive.create_repo "logseq_db_demo") + ~create_empty_db:false) + |> Js.Promise.then_ (fun result -> + let calls = spawn_capture_calls () in + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + match result with + | Ok _ -> fail_promise "expected orphan timeout" + | Error err -> + expect_equal "orphan error code" "server-start-timeout-orphan" + (Error.code_to_string err.Error.code); + expect_named_contains "orphan command executable" + err.Error.message Node.Process.argv.(0); + expect_named_contains "orphan command worker" err.Error.message + worker_script; + expect_named_contains "orphan command repo" err.Error.message + "--repo logseq_db_demo"; + expect_named_contains "orphan command root" err.Error.message + ("--root-dir " ^ root); + expect_named_contains "orphan command owner" err.Error.message + "--owner-source cli"; + expect_named_contains "spawn was attempted" calls worker_script; + Js.Promise.resolve pass) + with exn -> + stop_spawn_capture (); + unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT"; + remove_tree root; + fail_promise (Printexc.to_string exn)); + + test "CLI parity server command build keeps cleanup repo-free contract" + (fun () -> + (match + Server_command.build (config ()) (Global_opts.create ()) + Server_command.Parsed_list + with + | Ok Server_command.Server_list -> pass + | Ok _ -> fail_test "server list: expected list action" + | Error err -> fail_test ("server list: " ^ err.Error.message)); + (match + Server_command.build (config ()) (Global_opts.create ()) + Server_command.Parsed_cleanup + with + | Ok Server_command.Server_cleanup -> pass + | Ok _ -> fail_test "server cleanup: expected cleanup action" + | Error err -> fail_test ("server cleanup: " ^ err.Error.message)); + expect_error_code "server start requires repo" "missing-repo" + (Server_command.build (config ()) (Global_opts.create ()) + Server_command.Parsed_start); + expect_error_code "server stop requires repo" "missing-repo" + (Server_command.build (config ()) (Global_opts.create ()) + Server_command.Parsed_stop); + expect_error_code "server restart requires repo" "missing-repo" + (Server_command.build (config ()) (Global_opts.create ()) + Server_command.Parsed_restart); + (match + Server_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + Server_command.Parsed_start + with + | Ok (Server_command.Server_start { repo; graph }) -> + expect_equal "server start repo" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "server start graph" "demo" + (Cli_primitive.string_of_graph graph) + | Ok _ -> fail_test "server start: expected start action" + | Error err -> fail_test ("server start: " ^ err.Error.message)); + (match + Server_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + Server_command.Parsed_stop + with + | Ok (Server_command.Server_stop { repo; graph }) -> + expect_equal "server stop repo" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "server stop graph" "demo" + (Cli_primitive.string_of_graph graph) + | Ok _ -> fail_test "server stop: expected stop action" + | Error err -> fail_test ("server stop: " ^ err.Error.message)); + (match + Server_command.build (config ~repo:"demo" ()) (Global_opts.create ()) + Server_command.Parsed_restart + with + | Ok (Server_command.Server_restart { repo; graph }) -> + expect_equal "server restart repo" "logseq_db_demo" + (Cli_primitive.string_of_repo repo); + expect_equal "server restart graph" "demo" + (Cli_primitive.string_of_graph graph) + | Ok _ -> fail_test "server restart: expected restart action" + | Error err -> fail_test ("server restart: " ^ err.Error.message)); + pass); + + test + "CLI parity server graph item listing ignores non-graphs and marks old dir \ + conflicts" (fun () -> + let root = temp_dir "logseq-cli-parity-graph-items-" in + let graphs = Node.Path.join [| root; "graphs" |] in + try + Vec.iter + (fun dir -> mkdir_p (Node.Path.join [| graphs; dir |])) + (Vec.of_array + [| + "alpha"; + "backup"; + "foo~2G"; + "Unlinked graphs"; + "logseq_local_1"; + "old++name"; + "old~2Fname"; + "yy y"; + "yy~20y"; + "yy%20y"; + "bad%ZZname"; + |]); + let items = + Server_runtime.list_graph_items (config ~root_dir:root ()) + in + let canonical = + Vec.filter + (fun item -> item.Graph_types.kind = Graph_types.Canonical) + items + in + expect_bool "has alpha canonical" true + (Vec.exists + (fun item -> + Option.equal String.equal + (Option.map Cli_primitive.string_of_graph + item.Graph_types.graph_name) + (Some "alpha") + && item.Graph_types.graph_dir = Some "alpha") + canonical); + expect_bool "ignores backup" false + (Vec.exists + (fun item -> item.Graph_types.graph_dir = Some "backup") + items); + let old_item = + expect_some "old format item" + (Vec.find_opt + (fun item -> item.Graph_types.legacy_dir = Some "old++name") + items) + in + expect_bool "old item kind" true + (old_item.Graph_types.kind = Graph_types.Legacy); + expect_equal "old graph name" "old/name" + (Cli_primitive.string_of_graph + (expect_some "old graph name" old_item.Graph_types.graph_name)); + expect_equal "old target dir" "old~2Fname" + (expect_some "target dir" old_item.Graph_types.target_graph_dir); + expect_bool "old conflict" true old_item.Graph_types.conflict; + let percent_encoded_old_dirs = + items + |> Vec.filter (fun item -> + match item.Graph_types.legacy_dir with + | Some ("yy~20y" | "yy%20y") -> true + | _ -> false) + in + expect_int "percent encoded old dir count" 1 + (Vec.length percent_encoded_old_dirs); + Vec.iter + (fun item -> + expect_bool "percent encoded item kind" true + (item.Graph_types.kind = Graph_types.Legacy); + expect_equal "percent encoded graph name" "yy y" + (Cli_primitive.string_of_graph + (expect_some "percent encoded graph name" + item.Graph_types.graph_name)); + expect_equal "percent encoded target dir" "yy y" + (expect_some "percent encoded target" + item.Graph_types.target_graph_dir); + expect_bool "percent encoded conflict" true + item.Graph_types.conflict) + percent_encoded_old_dirs; + let undecodable = + expect_some "undecodable item" + (Vec.find_opt + (fun item -> item.Graph_types.legacy_dir = Some "bad%ZZname") + items) + in + expect_bool "undecodable kind" true + (undecodable.Graph_types.kind = Graph_types.Legacy_undecodable); + remove_tree root + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "CLI parity format renders basic success outputs by selected mode" + (fun () -> + let message mode = Cli_result.ok mode (Cli_result.Message "ok") in + expect_equal "human message" "ok" + (Format_types.format_result (message Output.Mode.Human) (config ())); + expect_equal "json message" + "{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}" + (Format_types.format_result (message Output.Mode.Json) + (config ~output_format:(Output.Mode.Packed Output.Mode.Json) ())); + expect_equal "edn message" "{:status :ok :data {:message \"ok\"}}" + (Format_types.format_result (message Output.Mode.Edn) + (config ~output_format:(Output.Mode.Packed Output.Mode.Edn) ()))); + + test "CLI parity default human tables keep discovered header order" (fun () -> + let value = + Edn_util.map + [ + ( Edn_util.keyword "items", + Edn_util.vector + [ + Edn_util.map + [ + ( Edn_util.keyword "block/updated-at", + Edn_util.int64 90000L ); + (Edn_util.keyword "block/title", Edn_util.string "Alpha"); + ( Edn_util.keyword "block/created-at", + Edn_util.int64 40000L ); + ]; + ] ); + ] + in + let output = + Format_types.format_result + (Cli_result.ok Output.Mode.Human (Cli_result.Raw value)) + (config ()) + in + expect_equal "default table header order" "updated-at,title,created-at" + (headers_from output |> Vec.of_array |> Vec.string_concat ",")); + + test "CLI parity format graph list marks current graph and old graph dirs" + (fun () -> + let graph_list_data = + Edn_util.map + [ + ( Edn_util.keyword "graphs", + Edn_util.vector + [ + Edn_util.string "alpha"; + Edn_util.string "old/name"; + Edn_util.string "mystery"; + ] ); + ( Edn_util.keyword "graph-items", + Edn_util.vector + [ + Edn_util.map + [ + (Edn_util.keyword "kind", Edn_util.keyword "canonical"); + (Edn_util.keyword "graph-name", Edn_util.string "alpha"); + (Edn_util.keyword "graph-dir", Edn_util.string "alpha"); + ]; + Edn_util.map + [ + (Edn_util.keyword "kind", Edn_util.keyword "legacy"); + ( Edn_util.keyword "legacy-dir", + Edn_util.string "old++name" ); + ( Edn_util.keyword "legacy-graph-name", + Edn_util.string "old/name" ); + ( Edn_util.keyword "target-graph-dir", + Edn_util.string "old~2Fname" ); + (Edn_util.keyword "conflict?", Edn_util.bool false); + ]; + Edn_util.map + [ + ( Edn_util.keyword "kind", + Edn_util.keyword "legacy-undecodable" ); + (Edn_util.keyword "legacy-dir", Edn_util.string "mystery"); + (Edn_util.keyword "reason", Edn_util.keyword "undecodable"); + ]; + ] ); + ] + in + let output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Graph_list Output.Mode.Human + (Cli_result.Raw graph_list_data)) + (config ~graph:"old/name" ~root_dir:"/tmp/logseq-root" ()) + in + expect_named_contains "current graph marker" output "* old/name"; + expect_named_not_contains "old graph marker" output "old/name [legacy]"; + expect_named_not_contains "old graph warning" output + "legacy graph directories detected"; + expect_named_not_contains "rename guidance" output "old++name"); + + test "CLI parity format list and search table outputs keep count footer" + (fun () -> + let list_value = + Edn_util.map + [ + ( Edn_util.keyword "items", + Edn_util.vector + [ + Edn_util.map + [ + (Edn_util.keyword "db/id", Edn_util.int64 1L); + (Edn_util.keyword "block/title", Edn_util.string "Alpha"); + ( Edn_util.keyword "block/updated-at", + Edn_util.int64 90000L ); + ( Edn_util.keyword "block/created-at", + Edn_util.int64 40000L ); + ]; + ] ); + ] + in + let list_output = + Format_types.format_result + ~human_table_headers_order: + (Vec.of_array + [| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |]) + (Cli_result.ok ~command:Command_id.List_page Output.Mode.Human + (Cli_result.Raw list_value)) + (config ()) + in + expect_named_contains "list id header" list_output "id"; + expect_named_contains "list title" list_output "Alpha"; + expect_named_contains "list count" list_output "Count: 1"; + let empty_order_output = + Format_types.format_result ~human_table_headers_order:Vec.empty + (Cli_result.ok ~command:Command_id.List_page Output.Mode.Human + (Cli_result.Raw list_value)) + (config ()) + in + expect_equal "empty header order does not infer columns" "Count: 1" + (String.trim empty_order_output); + let search_value = + Edn_util.map + [ + ( Edn_util.keyword "items", + Edn_util.vector + [ + Edn_util.map + [ + (Edn_util.keyword "db/id", Edn_util.int64 3L); + ( Edn_util.keyword "block/title", + Edn_util.string "Alpha block" ); + ]; + Edn_util.map + [ + (Edn_util.keyword "db/id", Edn_util.int64 7L); + ( Edn_util.keyword "block/title", + Edn_util.string "Second line\nIndented" ); + ]; + ] ); + ] + in + let search_output = + Format_types.format_result + ~human_table_headers_order:(Vec.of_array [| "id"; "ident"; "title" |]) + (Cli_result.ok ~command:Command_id.Search_block Output.Mode.Human + (Cli_result.Raw search_value)) + (config ()) + in + expect_named_contains "search title" search_output "Alpha block"; + expect_named_contains "search multiline" search_output "Indented"; + expect_named_contains "search count" search_output "Count: 2"); + + test "CLI parity format list variants use current generic table contracts" + (fun () -> + let row fields = Edn_util.map_vec (Vec.of_array fields) in + let items values = + Edn_util.map_vec + (Vec.singleton + ( Edn_util.keyword "items", + Edn_util.vector_vec (Vec.of_array values) )) + in + let human command value = + Format_types.format_result + ~human_table_headers_order: + (let registry = (Cli.make_app_context ()).Cli.registry in + let meta = + expect_some "command metadata" + (Command_registry.find_by_path + (Command_id.to_path command) + registry) + in + meta.human_table_headers_order) + (Cli_result.ok ~command Output.Mode.Human (Cli_result.Raw value)) + (config ()) + in + let tag_output = + human Command_id.List_tag + (items + [| + row + [| + (Edn_util.keyword "block/title", Edn_util.string "Tag"); + (Edn_util.keyword "db/id", Edn_util.int64 42L); + ( Edn_util.keyword "db/ident", + Edn_util.keyword "logseq.class/Tag" ); + |]; + |]) + in + expect_named_contains "tag id header" tag_output "id"; + expect_named_contains "tag title header" tag_output "title"; + expect_named_contains "tag ident value" tag_output "logseq.class/Tag"; + let property_output = + human Command_id.List_property + (items + [| + row + [| + (Edn_util.keyword "block/title", Edn_util.string "Prop"); + (Edn_util.keyword "db/id", Edn_util.int64 99L); + ( Edn_util.keyword "logseq.property/type", + Edn_util.keyword "node" ); + ( Edn_util.keyword "db/cardinality", + Edn_util.keyword "db.cardinality/many" ); + |]; + row + [| + (Edn_util.keyword "block/title", Edn_util.string "Untyped"); + (Edn_util.keyword "db/id", Edn_util.int64 100L); + |]; + |]) + in + expect_named_contains "property type header" property_output "type"; + expect_named_contains "property cardinality header" property_output + "cardinality"; + expect_named_contains "property cardinality value" property_output + "db.cardinality/many"; + let task_output = + human Command_id.List_task + (items + [| + row + [| + (Edn_util.keyword "db/id", Edn_util.int64 12L); + (Edn_util.keyword "block/title", Edn_util.string "Alpha task"); + ( Edn_util.keyword "logseq.property/status", + Edn_util.keyword "logseq.property/status.todo" ); + ( Edn_util.keyword "logseq.property/priority", + Edn_util.keyword "logseq.property/priority.high" ); + |]; + |]) + in + expect_named_contains "task status column" task_output "status"; + expect_named_contains "task priority column" task_output "priority"; + let unordered_task_output = + Format_types.format_result + ~human_table_headers_order: + (Vec.of_array + [| + "id"; + "title"; + "status"; + "priority"; + "scheduled"; + "deadline"; + "updated-at"; + "created-at"; + |]) + (Cli_result.ok ~command:Command_id.List_task Output.Mode.Human + (Cli_result.Raw + (items + [| + row + [| + ( Edn_util.keyword "logseq.property/priority", + Edn_util.keyword "logseq.property/priority.high" ); + (Edn_util.keyword "block/title", Edn_util.string "Ship"); + ( Edn_util.keyword "logseq.property/status", + Edn_util.keyword "logseq.property/status.todo" ); + ( Edn_util.keyword "block/created-at", + Edn_util.int64 40000L ); + ( Edn_util.keyword "block/updated-at", + Edn_util.int64 90000L ); + (Edn_util.keyword "db/id", Edn_util.int64 12L); + (Edn_util.keyword "block/uuid", Edn_util.string "u1"); + |]; + |]))) + (config ()) + in + expect_equal "ordered task headers" + "id,title,status,priority,updated-at,created-at" + (headers_from unordered_task_output + |> Vec.of_array |> Vec.string_concat ","); + let node_output = + human Command_id.List_node + (items + [| + row + [| + (Edn_util.keyword "db/id", Edn_util.int64 1L); + (Edn_util.keyword "block/title", Edn_util.string "Node Page"); + (Edn_util.keyword "node/type", Edn_util.string "page"); + ( Edn_util.keyword "block/uuid", + Edn_util.uuid "11111111-1111-1111-1111-111111111111" ); + |]; + |]) + in + expect_named_contains "node type column" node_output "type"; + expect_named_not_contains "node uuid column" node_output "uuid"; + let asset_output = + human Command_id.List_asset + (items + [| + row + [| + (Edn_util.keyword "db/id", Edn_util.int64 3L); + (Edn_util.keyword "block/title", Edn_util.string "Asset Node"); + ( Edn_util.keyword "logseq.property.asset/type", + Edn_util.string "md" ); + ( Edn_util.keyword "logseq.property.asset/size", + Edn_util.int64 2552L ); + |]; + |]) + in + expect_named_contains "asset type column" asset_output "type"; + expect_named_contains "asset size column" asset_output "size"; + expect_named_contains "asset raw size" asset_output "2552"); + + test "CLI parity format list title truncation keeps current display contracts" + (fun () -> + let ellipsis = decode_uri_component "%E2%80%A6" in + let item title = + Edn_util.map_vec + (Vec.of_array + [| + (Edn_util.keyword "db/id", Edn_util.int64 1L); + (Edn_util.keyword "block/title", Edn_util.string title); + |]) + in + let value title = + Edn_util.map_vec + (Vec.singleton + ( Edn_util.keyword "items", + Edn_util.vector_vec (Vec.singleton (item title)) )) + in + let truncated = + Format_types.format_result + ~human_table_headers_order: + (Vec.of_array + [| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |]) + (Cli_result.ok ~command:Command_id.List_page Output.Mode.Human + (Cli_result.Raw (value "ABCDEFGH"))) + (config ~list_title_max_display_width:6 ()) + in + expect_named_contains "single line display truncation" truncated + ("ABCDE" ^ ellipsis); + expect_named_not_contains "single line hides full title" truncated + "ABCDEFGH"; + let multiline = + Format_types.format_result + ~human_table_headers_order: + (Vec.of_array + [| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |]) + (Cli_result.ok ~command:Command_id.List_page Output.Mode.Human + (Cli_result.Raw (value "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"))) + (config ()) + in + expect_named_contains "multiline line one" multiline "Line 1"; + expect_named_not_contains "multiline line two" multiline "Line 2"; + ignore ellipsis; + expect_named_not_contains "multiline line three" multiline "Line 3"; + expect_named_not_contains "multiline line four" multiline "Line 4"; + expect_named_not_contains "multiline line five" multiline "Line 5"); + + test "CLI parity list task aligns status after accented latin titles" + (fun () -> + let accented_title = + decode_uri_component + "Diese%20Vorteile%20k%C3%B6nnten%20besonders%20f%C3%BCr%20eine" + in + let item id title = + Edn_util.map + [ + (Edn_util.keyword "db/id", Edn_util.int64 id); + (Edn_util.keyword "block/title", Edn_util.string title); + ( Edn_util.keyword "logseq.property/status", + Edn_util.keyword "logseq.property/status.todo" ); + ] + in + let value = + Edn_util.map_vec + (Vec.singleton + ( Edn_util.keyword "items", + Edn_util.vector_vec + (Vec.of_array [| item 1L "yyy"; item 2L accented_title |]) )) + in + let output = + Format_types.format_result + ~human_table_headers_order: + (Vec.of_array [| "id"; "title"; "status" |]) + (Cli_result.ok ~command:Command_id.List_task Output.Mode.Human + (Cli_result.Raw value)) + (config ()) + in + let status_column line token = + match Js.String.indexOf ~search:token line with + | -1 -> + fail_test ("missing " ^ token ^ " in line:\n" ^ line ^ "\n" ^ output); + 0 + | index -> display_width (String.sub line 0 index) + in + let lines = Vec.of_array (string_split (string_trim_end output) "\n") in + if Vec.length lines = 4 then ( + let header = Vec.nth lines 0 in + let short_row = Vec.nth lines 1 in + let accented_row = Vec.nth lines 2 in + let expected = status_column header "status" in + expect_int "short task status column" expected + (status_column short_row "status.todo"); + expect_int "accented task status column" expected + (status_column accented_row "status.todo")) + else fail_test ("unexpected list task output:\n" ^ output)); + + test "CLI parity format upsert summaries use current generic tables" + (fun () -> + let raw_ids ids = + let ids = Vec.of_array ids in + Cli_result.Raw + (Edn_util.map + [ + ( Edn_util.keyword "result", + Edn_util.vector_vec (Vec.map Edn_util.int64 ids) ); + ]) + in + let upsert_block = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Upsert_block Output.Mode.Human + (raw_ids [| 201L; 202L |])) + (config ()) + in + expect_equal "upsert block table" "Value\n201\n202\nCount: 2" upsert_block; + let upsert_page = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Upsert_page Output.Mode.Human + (raw_ids [| 123L |])) + (config ()) + in + expect_equal "upsert page table" "Value\n123\nCount: 1" upsert_page); + + test "CLI parity format remove summaries use current generic tables" + (fun () -> + let success_data = + Cli_result.Raw + (Edn_util.map_vec + (Vec.singleton + ( Edn_util.keyword "result", + Edn_util.map_vec + (Vec.singleton (Edn_util.keyword "ok", Edn_util.bool true)) + ))) + in + let page_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Remove_page Output.Mode.Human + success_data) + (config ~repo:"demo-repo" ()) + in + expect_equal "remove page result table" "result {:ok true}" page_output; + let block_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Remove_block Output.Mode.Human + success_data) + (config ~repo:"demo-repo" ()) + in + expect_equal "remove block result table" "result {:ok true}" block_output); + + test + "CLI parity format graph sync and auth summaries use current generic output" + (fun () -> + let graph_export = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Graph_export Output.Mode.Human + Cli_result.Empty) + (config ()) + in + expect_equal "graph export empty output" "" graph_export; + let sync_status = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Sync_status Output.Mode.Human + (Cli_result.Raw + (Edn_util.map + [ + (Edn_util.keyword "repo", Edn_util.string "demo-graph"); + (Edn_util.keyword "graph-id", Edn_util.string "graph-uuid"); + (Edn_util.keyword "ws-state", Edn_util.keyword "open"); + (Edn_util.keyword "pending-local", Edn_util.int64 2345L); + ]))) + (config ()) + in + expect_named_contains "sync status repo" sync_status "demo-graph"; + expect_named_contains "sync status pending" sync_status "2345"; + let login = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Login Output.Mode.Human + (Cli_result.Raw + (Edn_util.map + [ + ( Edn_util.keyword "auth-path", + Edn_util.string "/tmp/auth.json" ); + ( Edn_util.keyword "email", + Edn_util.string "user@example.com" ); + ( Edn_util.keyword "id-token", + Edn_util.string "secret-token" ); + ]))) + (config ()) + in + expect_named_contains "login email" login "user@example.com"; + expect_named_contains "login current token field" login "secret-token"); + + test "CLI parity format graph info keeps old kv redaction contracts" + (fun () -> + let token = "secret-token-value" in + let kv_value = + Edn_util.map + [ + (Edn_util.string "logseq.kv/api-token", Edn_util.string token); + (Edn_util.string "logseq.kv/db-type", Edn_util.keyword "sqlite"); + ] + in + let graph_info_value = + Edn_util.map + [ + (Edn_util.keyword "graph", Edn_util.string "demo-graph"); + (Edn_util.keyword "kv", kv_value); + ] + in + let output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Graph_info Output.Mode.Human + (Cli_result.Raw graph_info_value)) + (config ()) + in + expect_named_contains "graph info graph row" output "graph"; + expect_named_contains "graph info graph value" output "demo-graph"; + expect_named_contains "graph info redacts token" output "[REDACTED]"; + expect_named_not_contains "graph info hides token" output token; + let json_graph_info_value = + Edn_util.map + [ + (Edn_util.keyword "graph", Edn_util.string "demo-graph"); + ( Edn_util.keyword "kv", + Edn_util.map + [ + (Edn_util.string "logseq.kv/api-token", Edn_util.string token); + ] ); + ] + in + let json_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Graph_info Output.Mode.Json + (Cli_result.Raw json_graph_info_value)) + (config ~output_format:(Output.Mode.Packed Output.Mode.Json) ()) + in + expect_named_contains "graph info json redaction" json_output + "\"logseq.kv/api-token\":\"[REDACTED]\""; + expect_named_not_contains "graph info json hides token" json_output token); + + test "CLI parity format server outputs use current owner revision contracts" + (fun () -> + let server = + Edn_util.map + [ + (Edn_util.keyword "repo", Edn_util.string "demo-repo"); + (Edn_util.keyword "status", Edn_util.keyword "ready"); + (Edn_util.keyword "host", Edn_util.string "127.0.0.1"); + (Edn_util.keyword "port", Edn_util.int64 1234L); + (Edn_util.keyword "pid", Edn_util.int64 9876L); + (Edn_util.keyword "owner-source", Edn_util.keyword "cli"); + (Edn_util.keyword "revision", Edn_util.string "worker-revision"); + ] + in + let list_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Server_list Output.Mode.Human + (Cli_result.Raw + (Edn_util.map_vec + (Vec.singleton + ( Edn_util.keyword "servers", + Edn_util.vector_vec (Vec.singleton server) ))))) + (config ()) + in + expect_named_contains "server repo header" list_output "repo"; + expect_named_contains "server owner header" list_output "owner-source"; + expect_named_contains "server revision" list_output "worker-revision"; + let cleanup_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Server_cleanup Output.Mode.Human + (Cli_result.Raw + (Edn_util.map_vec + (Vec.of_array + [| + ( Edn_util.keyword "cli-revision", + Edn_util.string "cli-rev" ); + (Edn_util.keyword "checked", Edn_util.int64 4321L); + (Edn_util.keyword "mismatched", Edn_util.int64 3210L); + ( Edn_util.keyword "killed", + Edn_util.vector_vec (Vec.singleton server) ); + |])))) + (config ()) + in + expect_named_contains "cleanup checked row" cleanup_output "checked"; + expect_named_contains "cleanup checked value" cleanup_output "4321"); + + test "CLI parity format doctor and validation outputs use current contracts" + (fun () -> + let validation_success = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Graph_validate + ~context: + (Edn_util.map + [ (Edn_util.keyword "graph", Edn_util.string "foo") ]) + Output.Mode.Human + (Cli_result.Raw + (Edn_util.map + [ + ( Edn_util.keyword "result", + Edn_util.map + [ (Edn_util.keyword "errors", Edn_util.nil) ] ); + ]))) + (config ()) + in + expect_equal "graph validation result table" "result {:errors nil}" + validation_success; + let validation_error = + Format_types.format_result + (Cli_result.error ~command:Command_id.Graph_validate Output.Mode.Human + (Error.make Error.Graph_validation_failed + "Found 1 entity with errors:\n({:entity {:db/id 1}})\n")) + (config ()) + in + expect_named_contains "graph validation error code" validation_error + "Error (graph-validation-failed):"; + expect_named_contains "graph validation error body" validation_error + "Found 1 entity with errors"; + let doctor_value = + Edn_util.map + [ + (Edn_util.keyword "status", Edn_util.keyword "warning"); + ( Edn_util.keyword "checks", + Edn_util.vector + [ + Edn_util.map + [ + (Edn_util.keyword "id", Edn_util.keyword "root-dir"); + (Edn_util.keyword "status", Edn_util.keyword "ok"); + ( Edn_util.keyword "message", + Edn_util.string "Read/write access confirmed" ); + ]; + Edn_util.map + [ + ( Edn_util.keyword "id", + Edn_util.keyword "server-revision-mismatch" ); + (Edn_util.keyword "status", Edn_util.keyword "warning"); + ( Edn_util.keyword "message", + Edn_util.string + "1 server uses a different revision than this CLI" ); + ( Edn_util.keyword "servers", + Edn_util.vector + [ + Edn_util.map + [ + ( Edn_util.keyword "graph", + Edn_util.string "team graph" ); + ]; + ] ); + ]; + ] ); + ] + in + let doctor_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Doctor Output.Mode.Human + (Cli_result.Raw doctor_value)) + (config ()) + in + expect_named_contains "doctor status" doctor_output "warning"; + expect_named_contains "doctor root check" doctor_output "root-dir"; + expect_named_contains "doctor restart target" doctor_output "team graph"; + let revision_error = + Format_types.format_result + (Cli_result.error ~command:Command_id.Server_start Output.Mode.Human + (Error.make + ~hint: + "Logseq restarted db-worker-node, but the replacement still \ + reports a different revision. Check the installed Logseq \ + build and retry" + Error.Server_revision_mismatch_after_restart + "db-worker-node revision still does not match after restart")) + (config ()) + in + expect_named_contains "revision mismatch error" revision_error + "Error (server-revision-mismatch-after-restart):"); + + test "CLI parity format query and show outputs keep current raw contracts" + (fun () -> + let query_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Query Output.Mode.Human + (Cli_result.Query_result + (Edn_util.map + [ + ( Edn_util.keyword "result", + Edn_util.vector + [ + Edn_util.int64 1L; + Edn_util.int64 2L; + Edn_util.int64 3L; + ] ); + ]))) + (config ()) + in + expect_equal "query scalar vector lines" "1\n2\n3" query_output; + let show_output = + Format_types.format_result + (Cli_result.ok ~command:Command_id.Show Output.Mode.Human + (Cli_result.Message "Line 1\nLine 2")) + (config ()) + in + expect_equal "show text payload" "Line 1\nLine 2" show_output) + +let touch = () diff --git a/cli/test/dune b/cli/test/dune new file mode 100644 index 0000000..36ea0b0 --- /dev/null +++ b/cli/test/dune @@ -0,0 +1,29 @@ +(melange.emit + (target node) + (modules test_support cli_parity_test_cases test_cases fake_codex) + (module_systems commonjs) + (libraries + logseq_cli_platform_node + logseq_cli + melange-transit-melange + melange-edn-melange + melange-fest + melange-fetch + melange.node) + (preprocess + (pps melange.ppx))) + +(rule + (alias runtest) + (deps + (alias ../bin/melange) + (alias ./melange) + (alias ../dist/bundle) + (:test_cases ./node/test/test_cases.js) + (:fake_codex ./node/test/fake_codex.js) + (:entrypoint ../dist/logseq-cli.js)) + (action + (setenv + LOGSEQ_CLI_MELANGE_ENTRYPOINT + %{entrypoint} + (run node --test %{test_cases})))) diff --git a/cli/test/fake_codex.ml b/cli/test/fake_codex.ml new file mode 100644 index 0000000..19424c0 --- /dev/null +++ b/cli/test/fake_codex.ml @@ -0,0 +1,21 @@ +let set_timeout f ms = ignore (Js.Global.setTimeout ~f ms : Js.Global.timeoutId) +let argv_contains value = Array.exists (( = ) value) Node.Process.argv + +let () = + if argv_contains "--version" then Node.Process.exit 0; + let marker = + match Js.Dict.get Node.Process.process##env "AGENT_BRIDGE_TEST_MARKER" with + | Some value -> value + | None -> failwith "missing AGENT_BRIDGE_TEST_MARKER" + in + let worker_script = + match + Js.Dict.get Node.Process.process##env "LOGSEQ_DB_WORKER_NODE_SCRIPT" + with + | Some value -> value + | None -> "" + in + print_endline "{\"session_id\":\"cli-live-session\"}"; + set_timeout (fun () -> print_endline "still alive after session id") 200; + set_timeout (fun () -> Node.Fs.writeFileAsUtf8Sync marker worker_script) 500; + set_timeout (fun () -> ()) 5000 diff --git a/cli/test/test_cases.ml b/cli/test/test_cases.ml new file mode 100644 index 0000000..2a337fa --- /dev/null +++ b/cli/test/test_cases.ml @@ -0,0 +1,2917 @@ +open Test_support + +let () = ignore Cli_parity_test_cases.touch +let tree_pipe = decode_uri_component "%E2%94%82" +let tree_corner = decode_uri_component "%E2%94%94%E2%94%80%E2%94%80" +let tree_middle = decode_uri_component "%E2%94%9C%E2%94%80%E2%94%80" + +let unicode_text codepoints = + Vec.string_concat "" + (Vec.map Js.String.fromCodePoint (Vec.of_array codepoints)) + +let () = + test "json output renders transit quoted uuid as string" (fun () -> + let uuid = "11111111-1111-4111-8111-111111111111" in + let value = + Edn_util.any + (Melange_edn_melange.tagged "transit/quote" (Edn_util.uuid uuid)) + in + let output = + Format_types.to_json + (Cli_result.ok Output.Mode.Json (Cli_result.Query_result value)) + in + expect_named_contains "quoted uuid string" output uuid; + expect_named_not_contains "quoted uuid tag" output "transit/quote"; + expect_named_not_contains "uuid tag object" output "\"tag\""); + + test "json error output includes hint" (fun () -> + let err = + Error.make ~hint:"Run logseq sync start --graph demo." + Error.Sync_not_started "sync is not started for this graph" + in + let output = + Format_types.to_json (Cli_result.error Output.Mode.Json err) + in + expect_named_contains "json error code" output + "\"code\":\"sync-not-started\""; + expect_named_contains "json error hint" output + "\"hint\":\"Run logseq sync start --graph demo.\"") + +let () = + test "completion accepts --shell bash" (fun () -> + let output = run_cli [| "completion"; "--shell"; "bash" |] in + expect_named_contains "bash completion" output + "Auto-generated by `logseq completion bash`"; + expect_named_contains "bash completion function" output + "_logseq_json_names_bash"); + + test "skill show ignores structured output mode" (fun () -> + mkdir_p ".agents/skills/logseq-cli"; + write_file ".agents/skills/logseq-cli/SKILL.md" + "---\nname: logseq-cli\n---\n\n# Logseq CLI\n"; + let output = run_cli [| "--output"; "json"; "skill"; "show" |] in + expect_named_contains "skill markdown name" output "name: logseq-cli"; + expect_named_contains "skill markdown title" output "# Logseq CLI"; + expect_named_not_contains "skill markdown status wrapper" output + "\"status\""; + expect_named_not_contains "skill markdown data wrapper" output "\"data\"") + +let write_cli_wrapper path = + write_file path + ("require(" + ^ Js.Json.stringify (Js.Json.string (Node.Path.resolve "." entrypoint)) + ^ ");\n") + +let spawn_cli_from entrypoint ?(env = [||]) args = + spawn_sync Node.Process.argv.(0) + (Array.append [| entrypoint |] args) + [%obj { encoding = "utf8"; env = clone_env env }] + +let doctor_args root = [| "--root-dir"; root; "--output"; "json"; "doctor" |] + +let () = + test "doctor resolves db-worker-node beside CLI entrypoint" (fun () -> + let root = temp_dir "logseq-cli-doctor-entrypoint-root-" in + let app_dir = temp_dir "logseq-cli-doctor-app-" in + let cli_dir = Node.Path.join [| app_dir; "bin" |] in + let cli_path = Node.Path.join [| cli_dir; "logseq-cli.js" |] in + let worker_path = Node.Path.join [| cli_dir; "db-worker-node.js" |] in + try + mkdir_p cli_dir; + write_cli_wrapper cli_path; + write_file worker_path "console.log('db-worker');\n"; + let result = spawn_cli_from cli_path (doctor_args root) in + remove_tree root; + remove_tree app_dir; + ignore (expect_exit_zero "doctor entrypoint worker" result); + expect_named_contains "doctor entrypoint path" result##stdout + worker_path + with exn -> + remove_tree root; + remove_tree app_dir; + fail_test (Printexc.to_string exn)); + + test "doctor resolves db-worker-node beside CLI inside app.asar" (fun () -> + let root = temp_dir "logseq-cli-doctor-asar-root-" in + let app_dir = temp_dir "logseq-cli-doctor-app-" in + let asar_dir = Node.Path.join [| app_dir; "Logseq.app.asar" |] in + let cli_path = Node.Path.join [| asar_dir; "logseq-cli.js" |] in + let worker_path = Node.Path.join [| asar_dir; "db-worker-node.js" |] in + try + mkdir_p asar_dir; + write_cli_wrapper cli_path; + write_file worker_path "console.log('db-worker');\n"; + let result = spawn_cli_from cli_path (doctor_args root) in + remove_tree root; + remove_tree app_dir; + ignore (expect_exit_zero "doctor asar worker" result); + expect_named_contains "doctor asar path" result##stdout worker_path + with exn -> + remove_tree root; + remove_tree app_dir; + fail_test (Printexc.to_string exn)); + + test "doctor resolves db-worker-node under js beside CLI entrypoint" + (fun () -> + let root = temp_dir "logseq-cli-doctor-entrypoint-js-root-" in + let app_dir = temp_dir "logseq-cli-doctor-app-" in + let cli_dir = Node.Path.join [| app_dir; "bin" |] in + let cli_js_dir = Node.Path.join [| cli_dir; "js" |] in + let cli_path = Node.Path.join [| cli_dir; "logseq-cli.js" |] in + let worker_path = Node.Path.join [| cli_js_dir; "db-worker-node.js" |] in + try + mkdir_p cli_js_dir; + write_cli_wrapper cli_path; + write_file worker_path "console.log('db-worker');\n"; + let result = spawn_cli_from cli_path (doctor_args root) in + remove_tree root; + remove_tree app_dir; + ignore (expect_exit_zero "doctor entrypoint js worker" result); + expect_named_contains "doctor entrypoint js path" result##stdout + worker_path + with exn -> + remove_tree root; + remove_tree app_dir; + fail_test (Printexc.to_string exn)); + + test "doctor keeps LOGSEQ_DB_WORKER_NODE_SCRIPT before app.asar worker" + (fun () -> + let root = temp_dir "logseq-cli-doctor-asar-env-root-" in + let app_dir = temp_dir "logseq-cli-doctor-app-" in + let asar_js_dir = Node.Path.join [| app_dir; "Logseq.app.asar"; "js" |] in + let cli_path = Node.Path.join [| asar_js_dir; "logseq-cli.js" |] in + let asar_worker_path = + Node.Path.join [| asar_js_dir; "db-worker-node.js" |] + in + let env_worker_path = Node.Path.join [| app_dir; "env-worker.js" |] in + try + mkdir_p asar_js_dir; + write_cli_wrapper cli_path; + write_file asar_worker_path "console.log('asar db-worker');\n"; + write_file env_worker_path "console.log('env db-worker');\n"; + let result = + spawn_cli_from cli_path + ~env:[| ("LOGSEQ_DB_WORKER_NODE_SCRIPT", env_worker_path) |] + (doctor_args root) + in + remove_tree root; + remove_tree app_dir; + ignore (expect_exit_zero "doctor env worker" result); + expect_named_contains "doctor env path" result##stdout env_worker_path; + expect_named_not_contains "doctor env excludes asar path" result##stdout + asar_worker_path + with exn -> + remove_tree root; + remove_tree app_dir; + fail_test (Printexc.to_string exn)) + +let () = + test "sync download requires an explicit graph" (fun () -> + let root = temp_dir "logseq-cli-sync-download-graph-" in + try + write_file + (Node.Path.join [| root; "cli.edn" |]) + "{:graph \"fallback-file\"}\n"; + let result = + spawn_cli + ~env:[| ("LOGSEQ_CLI_GRAPH", "fallback-env") |] + [| "--root-dir"; root; "sync"; "download" |] + in + remove_tree root; + ignore (expect_exit_non_zero "sync download without --graph" result); + expect_named_contains "sync download missing graph" result##stdout + "graph name is required" + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "graph commands require explicit unicode graph names" (fun () -> + let root = temp_dir "logseq-cli-graph-unicode-" in + let graph = decode_uri_component "%E5%95%8A%E5%95%8A%E5%95%8A%E5%95%8A" in + let fallback_graph = "fallback-env" in + let encoded_graph_dir = "~E5~95~8A~E5~95~8A~E5~95~8A~E5~95~8A" in + let run ?(env = [||]) args = + spawn_cli ~env (Array.append [| "--root-dir"; root |] args) + in + try + mkdir_p root; + write_file + (Node.Path.join [| root; "cli.edn" |]) + "{:graph \"fallback-file\"}\n"; + let create_without_graph = + run + ~env:[| ("LOGSEQ_CLI_GRAPH", fallback_graph) |] + [| "graph"; "create" |] + in + ignore + (expect_exit_non_zero "graph create without -g" create_without_graph); + ignore + (expect_named_contains "graph create without -g" + create_without_graph##stdout + "graph name is required"); + if + Node.Fs.existsSync + (Node.Path.join [| root; "graphs"; fallback_graph |]) + then fail_test "graph create without -g created fallback graph"; + let create_fallback = + run [| "graph"; "create"; "-g"; fallback_graph |] + in + ignore (expect_exit_zero "fallback graph create" create_fallback); + if Node.Fs.existsSync (Node.Path.join [| root; "current-graph" |]) then + fail_test "graph create wrote current-graph"; + let switch_without_graph = + run + ~env:[| ("LOGSEQ_CLI_GRAPH", fallback_graph) |] + [| "graph"; "switch" |] + in + ignore + (expect_exit_non_zero "graph switch without -g" switch_without_graph); + ignore + (expect_named_contains "graph switch without -g" + switch_without_graph##stdout + "graph name is required"); + let remove_without_graph = + run + ~env:[| ("LOGSEQ_CLI_GRAPH", fallback_graph) |] + [| "graph"; "remove" |] + in + ignore + (expect_exit_non_zero "graph remove without -g" remove_without_graph); + ignore + (expect_named_contains "graph remove without -g" + remove_without_graph##stdout + "graph name is required"); + if + not + (Node.Fs.existsSync + (Node.Path.join [| root; "graphs"; fallback_graph |])) + then fail_test "graph remove without -g removed fallback graph"; + let create = run [| "graph"; "create"; "-g"; graph |] in + ignore (expect_exit_zero "graph create" create); + if Node.Fs.existsSync (Node.Path.join [| root; "current-graph" |]) then + fail_test "graph create wrote current-graph"; + ignore (expect_named_contains "graph create" create##stdout graph); + let graph_dirs = + Node.Fs.readdirSync (Node.Path.join [| root; "graphs" |]) + in + if not (Array.exists (( = ) encoded_graph_dir) graph_dirs) then + fail_test + (Printf.sprintf "graph dir: expected %s, got [%s]" encoded_graph_dir + (graph_dirs |> Vec.of_array |> Vec.string_concat ", ")); + let human_list = run [| "graph"; "list" |] in + ignore (expect_exit_zero "human graph list" human_list); + ignore + (expect_named_contains "human graph list" human_list##stdout graph); + let json_list = run [| "--output"; "json"; "graph"; "list" |] in + ignore (expect_exit_zero "json graph list" json_list); + remove_tree root; + expect_graph_in_json_list json_list##stdout graph + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "graph list ignores current-graph file" (fun () -> + let root = temp_dir "logseq-cli-graph-list-current-file-" in + let run args = spawn_cli (Array.append [| "--root-dir"; root |] args) in + try + mkdir_p root; + ignore + (expect_exit_zero "create alpha" + (run [| "graph"; "create"; "-g"; "alpha" |])); + ignore + (expect_exit_zero "create beta" + (run [| "graph"; "create"; "-g"; "beta" |])); + write_file (Node.Path.join [| root; "current-graph" |]) "alpha"; + let result = run [| "graph"; "list" |] in + remove_tree root; + ignore (expect_exit_zero "graph list" result); + expect_named_not_contains "graph list current file marker" + result##stdout "* alpha" + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "graph switch updates cli.edn graph" (fun () -> + let root = temp_dir "logseq-cli-graph-switch-config-" in + let run args = spawn_cli (Array.append [| "--root-dir"; root |] args) in + try + mkdir_p root; + ignore + (expect_exit_zero "create alpha" + (run [| "graph"; "create"; "-g"; "alpha" |])); + ignore + (expect_exit_zero "create beta" + (run [| "graph"; "create"; "-g"; "beta" |])); + write_file + (Node.Path.join [| root; "cli.edn" |]) + "{:http-base \"http://127.0.0.1:65535\"}\n"; + let switch = + spawn_cli + ~env:[| ("LOGSEQ_CLI_BASE_URL", "http://127.0.0.1:65535") |] + [| + "--root-dir"; + root; + "--output"; + "json"; + "--graph"; + "beta"; + "graph"; + "switch"; + |] + in + ignore (expect_exit_zero "graph switch" switch); + ignore + (expect_named_contains "graph switch message" switch##stdout + "\"message\":\"switched to beta\""); + let config = read_file (Node.Path.join [| root; "cli.edn" |]) in + ignore + (expect_named_contains "graph switch config graph" config + ":graph \"beta\""); + ignore + (expect_named_contains "graph switch config preserves http-base" + config ":http-base \"http://127.0.0.1:65535\""); + let list = run [| "graph"; "list" |] in + remove_tree root; + ignore (expect_exit_zero "graph list" list); + expect_named_contains "graph list selected graph" list##stdout "* beta" + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "json output escapes control characters" (fun () -> + let root = temp_dir "logseq-cli-json-output-" in + let graph = "json" ^ control_char ^ "graph" in + try + let result = + spawn_cli + [| + "--root-dir"; + root; + "--output"; + "json"; + "graph"; + "create"; + "--graph"; + graph; + |] + in + remove_tree root; + ignore (assert_exit_zero "json output graph create" result); + expect_valid_json "json output graph create" result##stdout + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "verbose parsed options line is valid edn" (fun () -> + let root = temp_dir "logseq-cli verbose root-" in + try + let result = + spawn_cli + [| + "--verbose"; + "--root-dir"; + root; + "--graph"; + "alpha"; + "graph"; + "list"; + |] + in + remove_tree root; + ignore (assert_exit_zero "verbose graph list" result); + match Vec.peek_front_opt (Vec.split_on_char '\n' result##stderr) with + | Some line when String.trim line <> "" -> + expect_valid_edn "verbose parsed options line" line + | _ -> fail_test ("missing verbose stderr line: " ^ result##stderr) + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test "login times out without socket fallback" (fun () -> + let root = temp_dir "logseq-cli-login-" in + let config_path = Node.Path.join [| root; "cli.edn" |] in + try + write_file config_path "{:open-browser false :login-timeout-ms 1}\n"; + let result = + spawn_cli [| "--root-dir"; root; "--config"; config_path; "login" |] + in + remove_tree root; + ignore (expect_exit_non_zero "login without callback" result); + ignore + (expect_named_not_contains "login stdout socket fallback" + result##stdout "sockets are unavailable"); + ignore + (expect_named_not_contains "login stderr socket fallback" + result##stderr "sockets are unavailable"); + if + String.contains result##stdout 'l' + && Js.String.includes ~search:"login callback timed out" + result##stdout + || String.contains result##stderr 'l' + && Js.String.includes ~search:"login callback timed out" + result##stderr + then pass + else + fail_test + (Printf.sprintf "expected login callback timeout\n%s\n%s" + result##stdout result##stderr) + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test_promise "login generates fresh PKCE challenge across CLI processes" + (fun () -> + let login_once base_url index = + let root = + temp_dir ("logseq-cli-login-pkce-" ^ string_of_int index ^ "-") + in + let config_path = Node.Path.join [| root; "cli.edn" |] in + let auth_path = Node.Path.join [| root; "auth.json" |] in + let state, encoded_state = + if index = 1 then (unicode_text [| 0x4e2d |], "%E4%B8%AD") + else ("fixed-test-state", "fixed-test-state") + in + let code = "auth-code-" ^ string_of_int index in + write_file config_path + ("{:open-browser false\n :login-timeout-ms 5000\n :auth-path " + ^ Printf.sprintf "%S" auth_path + ^ "\n :oauth-authorize-endpoint " + ^ Printf.sprintf "%S" (base_url ^ "/authorize") + ^ "\n :oauth-token-endpoint " + ^ Printf.sprintf "%S" (base_url ^ "/oauth2/token") + ^ "\n :oauth-client-id \"test-client\"\n :oauth-state \"" ^ state + ^ "\"}\n"); + let login = + run_cli_p + [| + "--root-dir"; + root; + "--config"; + config_path; + "--output"; + "json"; + "login"; + |] + in + let callback_url = + Printf.sprintf "http://localhost:8765/auth/callback?state=%s&code=%s" + encoded_state code + in + let* () = fetch_with_retry 100 callback_url in + let* result = login in + remove_tree root; + ignore (expect_cli_exit_zero "login pkce" result); + let authorize_url = json_data_string result.stdout "authorize-url" in + if query_param authorize_url "state" <> Some encoded_state then + fail_promise ("unexpected state: " ^ authorize_url) + else + match query_param authorize_url "code_challenge" with + | Some challenge -> Js.Promise.resolve challenge + | None -> fail_promise ("missing code_challenge: " ^ authorize_url) + in + with_server (oauth_server ()) (fun base_url -> + let* first = login_once base_url 1 in + let* second = login_once base_url 2 in + if first <> second then Js.Promise.resolve pass + else + fail_promise + ("expected fresh PKCE challenge across logins, got " ^ first))); + + test_promise "logout deletes auth and exits without callback" (fun () -> + let root = temp_dir "logseq-cli-logout-flow-" in + let config_path = Node.Path.join [| root; "cli.edn" |] in + let auth_path = Node.Path.join [| root; "auth.json" |] in + let state = "fixed-logout-state" in + let write_auth () = + write_file auth_path + "{\"provider\":\"cognito\",\"refresh-token\":\"refresh-token\"}\n" + in + let logout_once base_url = + write_auth (); + write_file config_path + (Printf.sprintf + "{:open-browser false\n\ + \ :logout-timeout-ms 5000\n\ + \ :auth-path %S\n\ + \ :http-base %S\n\ + \ :oauth-client-id \"test-client\"\n\ + \ :oauth-logout-state %S}\n" + auth_path base_url state); + let logout = + run_cli_p + [| + "--root-dir"; + root; + "--config"; + config_path; + "--output"; + "json"; + "logout"; + |] + in + let* result = logout in + ignore (expect_cli_exit_zero "logout" result); + if Node.Fs.existsSync auth_path then + fail_promise "logout did not delete auth file" + else + let logout_url = json_data_string result.stdout "logout-url" in + let deleted = json_data_bool result.stdout "deleted" in + let opened = json_data_bool result.stdout "opened" in + let completed = json_data_bool result.stdout "logout-completed" in + if not deleted then + fail_promise ("expected deleted=true: " ^ result.stdout) + else if opened then + fail_promise ("expected opened=false: " ^ result.stdout) + else if not completed then + fail_promise ("expected logout-completed=true: " ^ result.stdout) + else if + not + (Js.String.startsWith ~prefix:(base_url ^ "/logout?") logout_url) + then fail_promise ("unexpected logout-url: " ^ logout_url) + else if query_param logout_url "client_id" <> Some "test-client" then + fail_promise ("missing client_id: " ^ logout_url) + else if query_param logout_url "response_type" <> Some "code" then + fail_promise ("missing response_type: " ^ logout_url) + else if + query_param logout_url "scope" <> Some "email%20openid%20phone" + then fail_promise ("missing scope: " ^ logout_url) + else if query_param logout_url "state" <> Some state then + fail_promise ("missing state: " ^ logout_url) + else + match query_param logout_url "redirect_uri" with + | Some uri + when uri = "http%3A%2F%2Flocalhost%3A8765%2Fauth%2Fcallback" -> + Js.Promise.resolve pass + | _ -> fail_promise ("missing redirect_uri: " ^ logout_url) + in + with_server (oauth_server ()) (fun base_url -> + let* result = logout_once base_url in + remove_tree root; + Js.Promise.resolve result)); + + test "example without selector returns all examples as json" (fun () -> + let result = spawn_cli [| "--output"; "json"; "example" |] in + ignore (expect_exit_zero "example all json" result); + ignore + (expect_named_contains "example all json status" result##stdout + "\"status\":\"ok\""); + ignore + (expect_named_contains "example all json selector" result##stdout + "\"selector\":\"all\""); + ignore + (expect_named_contains "example all json graph example" result##stdout + "logseq graph list"); + ignore + (expect_named_contains "example all json sync example" result##stdout + "logseq sync config unset sync-enabled"); + ignore + (expect_named_not_contains "example all json old sync flag" + result##stdout "--key sync-enabled"); + pass); + + test "example human output uses readable command and example lists" (fun () -> + let result = spawn_cli [| "example" |] in + ignore (expect_exit_zero "example all human" result); + ignore + (expect_named_contains "example human selector" result##stdout + "Selector: all"); + ignore + (expect_named_contains "example human commands heading" result##stdout + "Matched commands:"); + ignore + (expect_named_contains "example human command bullet" result##stdout + " - graph list"); + ignore + (expect_named_contains "example human sync command bullet" + result##stdout " - sync config unset"); + ignore + (expect_named_contains "example human examples heading" result##stdout + "Examples:"); + ignore + (expect_named_contains "example human example bullet" result##stdout + " - logseq graph list"); + expect_named_contains "example human sync example bullet" result##stdout + " - logseq sync config unset sync-enabled"; + expect_named_not_contains "example human old sync flag" result##stdout + "--key sync-enabled") + +let () = + test_promise "graph info formats kv metadata" (fun () -> + let root = temp_dir "logseq-cli-graph-info-" in + let request_count = ref 0 in + let result_transit = + "[\"~#set\",[[\"~:logseq.kv/graph-created-at\",1700000000],[\"~:logseq.kv/schema-version\",77],[\"~:logseq.kv/custom\",\"demo\"]]]" + in + let server = + invoke_server (fun body -> + incr request_count; + if not (Js.String.includes ~search:"thread-api/q" body) then + fail_test ("unexpected invoke method: " ^ body); + if not (Js.String.includes ~search:"logseq_db_alpha" body) then + fail_test ("missing repo in request: " ^ body); + if not (Js.String.includes ~search:"logseq.kv" body) then + fail_test ("missing graph info query namespace: " ^ body); + result_transit) + in + with_server server (fun base_url -> + let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in + let* human = + run_cli_p ~env + [| "--root-dir"; root; "--graph"; "alpha"; "graph"; "info" |] + in + ignore (expect_cli_exit_zero "human graph info" human); + ignore (expect_named_contains "human graph" human.stdout "graph"); + ignore + (expect_named_contains "human schema" human.stdout + "logseq.kv/schema-version 77"); + ignore (expect_named_contains "human created" human.stdout "ago"); + ignore + (expect_line_starts_with "human custom kv row" human.stdout + "logseq.kv/custom"); + ignore + (expect_named_not_contains "human created fallback" human.stdout + "ms ago"); + ignore + (expect_named_not_contains "human unreasonable created seconds" + human.stdout "56 years"); + ignore + (expect_named_not_contains "human no field header" human.stdout + "Field"); + ignore + (expect_named_not_contains "human no value header" human.stdout + "Value"); + ignore + (expect_named_not_contains "human no kv aggregate" human.stdout + "kv {"); + ignore + (expect_named_not_contains "human missing created" human.stdout + "logseq.kv/graph-created-at -"); + ignore + (expect_named_not_contains "human empty kv" human.stdout "kv {}"); + let* json = + run_cli_p ~env + [| + "--root-dir"; + root; + "--graph"; + "alpha"; + "--output"; + "json"; + "graph"; + "info"; + |] + in + ignore (expect_cli_exit_zero "json graph info" json); + ignore + (expect_named_contains "json created" json.stdout + "\"logseq.kv/graph-created-at\":1700000000"); + ignore + (expect_named_contains "json schema" json.stdout + "\"logseq.kv/schema-version\":77"); + ignore + (expect_named_contains "json kv" json.stdout + "\"logseq.kv/custom\":\"demo\""); + remove_tree root; + if !request_count = 2 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected two graph info requests, got %d" + !request_count))); + + test_promise "graph info reuses an existing db-worker from health json" + (fun () -> + let root = temp_dir "logseq-cli-graph-info-existing-server-" in + let request_count = ref 0 in + let result_transit = + "[\"~#set\",[[\"~:logseq.kv/schema-version\",77]]]" + in + let port_of_base_url base_url = + let colon = String.rindex base_url ':' in + String.sub base_url (colon + 1) (String.length base_url - colon - 1) + in + let server = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + match (req_method req, req_url req) with + | "GET", "/healthz" -> + write_json res 200 + "{\"repo\":\"logseq_db_alpha\",\"status\":\"ready\",\"host\":\"127.0.0.1\",\"pid\":1,\"owner-source\":\"cli\",\"root-dir\":\"/tmp/logseq-cli\",\"revision\":\"test-revision\"}" + | "POST", "/v1/invoke" -> + incr request_count; + if not (Js.String.includes ~search:"thread-api/q" !body) + then fail_test ("unexpected invoke method: " ^ !body); + if not (Js.String.includes ~search:"logseq_db_alpha" !body) + then fail_test ("missing repo in request: " ^ !body); + write_json res 200 (json_response result_transit) + | _ -> write_json res 404 (error_response "not found"))) + in + with_server server (fun base_url -> + let port = port_of_base_url base_url in + write_file + (Node.Path.join [| root; "server-list" |]) + (string_of_int process_pid ^ " " ^ port ^ "\n"); + let* result = + run_cli_p + ~env: + [| + ( "LOGSEQ_DB_WORKER_NODE_SCRIPT", + Node.Path.join [| root; "missing-db-worker-node.js" |] ); + |] + [| "--root-dir"; root; "--graph"; "alpha"; "graph"; "info" |] + in + remove_tree root; + ignore (expect_cli_exit_zero "graph info existing server" result); + ignore + (expect_named_contains "existing server schema" result.stdout + "logseq.kv/schema-version 77"); + if !request_count = 1 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected one graph info request, got %d" + !request_count))); + + test_promise "list page human output formats timestamps for large result sets" + (fun () -> + let request_count = ref 0 in + let row index = + Printf.sprintf + "{\"~:db/id\":%d,\"~:block/title\":%s,\"~:db/ident\":%s,\"~:logseq.property/type\":\"node\",\"~:block/created-at\":%d,\"~:block/updated-at\":%d}" + (index + 1) + (Js.Json.stringify + (Js.Json.string + (Printf.sprintf "Page %d with enough text for table formatting" + (index + 1)))) + (Js.Json.stringify + (Js.Json.string + (Printf.sprintf "~:user.property/Page-%d-performance-check" + (index + 1)))) + (1700000000000 + index) (1780411164893 + index) + in + let result_transit = + "[" ^ Vec.string_concat "," (Vec.init 700 row) ^ "]" + in + let server = + invoke_server (fun body -> + incr request_count; + if not (Js.String.includes ~search:"thread-api/cli-list-pages" body) + then fail_test ("unexpected invoke method: " ^ body); + result_transit) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| "--graph"; "alpha"; "list"; "page" |] + in + ignore (expect_cli_exit_zero "list page human" output); + if !request_count <> 1 then + fail_promise + (Printf.sprintf "expected one request, got %d" !request_count) + else ( + ignore + (expect_named_not_contains "created timestamp raw" output.stdout + "1700000000000"); + ignore + (expect_named_not_contains "updated timestamp raw" output.stdout + "1780411164893"); + ignore + (expect_named_not_contains "zero years output" output.stdout + "zero years"); + ignore + (expect_named_contains "count footer" output.stdout "Count: 700"); + Js.Promise.resolve pass))); + + test_promise "list asset human output keeps asset columns stable" (fun () -> + let request_count = ref 0 in + let result_transit = + "[{\"~:block/updated-at\":1779715760691,\"~:logseq.property.asset/checksum\":\"abc123\",\"~:logseq.property.asset/size\":4096,\"~:block/title\":\"logo.png\",\"~:block/created-at\":1777063533827,\"~:db/id\":10,\"~:logseq.property.asset/type\":\"png\"}]" + in + let server = + invoke_server (fun body -> + incr request_count; + match !request_count with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"logseq.class/Asset" body -> + "[\"^ \",\"~:db/id\",77]" + | 2 -> result_transit + | _ -> + fail_test + (Printf.sprintf "unexpected request %d: %s" !request_count + body); + "") + in + let root = temp_dir "logseq-cli-list-asset-" in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| "--root-dir"; root; "--graph"; "alpha"; "list"; "asset" |] + in + remove_tree root; + ignore (expect_cli_exit_zero "list asset" output); + if !request_count <> 2 then + fail_promise + (Printf.sprintf "expected two requests, got %d" !request_count) + else + let headers = headers_from output.stdout in + let expected_prefix = [| "id"; "title"; "size"; "type" |] in + let expected_suffix = [| "created-at"; "updated-at" |] in + if + not + (string_array_equal + (array_prefix headers (Array.length expected_prefix)) + expected_prefix) + then + fail_promise + ("unexpected asset header prefix: " + ^ (headers |> Vec.of_array |> Vec.string_concat ",")) + else if + not + (string_array_equal + (array_suffix headers (Array.length expected_suffix)) + expected_suffix) + then + fail_promise + ("unexpected asset header suffix: " + ^ (headers |> Vec.of_array |> Vec.string_concat ",")) + else if not (Array.exists (( = ) "checksum") headers) then + fail_promise ("missing middle asset column:\n" ^ output.stdout) + else if Js.String.includes ~search:"node/type" output.stdout then + fail_promise + ("asset output must not render node/type:\n" ^ output.stdout) + else Js.Promise.resolve pass)); + + test_promise "list task human output uses registry header order" (fun () -> + let result_transit = + "[{\"~:logseq.property/priority\":\"~:logseq.property/priority.high\",\"~:block/title\":\"Ship\",\"~:logseq.property/status\":\"~:logseq.property/status.todo\",\"~:block/created-at\":40000,\"~:block/updated-at\":90000,\"~:db/id\":12,\"~:block/uuid\":\"u1\"}]" + in + let server = + invoke_server (fun body -> + if Js.String.includes ~search:"thread-api/cli-list-tasks" body then + result_transit + else ( + fail_test ("unexpected request: " ^ body); + "null")) + in + let root = temp_dir "logseq-cli-list-task-order-" in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| "--root-dir"; root; "--graph"; "alpha"; "list"; "task" |] + in + remove_tree root; + ignore (expect_cli_exit_zero "list task" output); + let headers = + headers_from output.stdout |> Vec.of_array |> Vec.string_concat "," + in + if headers <> "id,title,status,priority,created-at,updated-at" then + fail_promise ("unexpected task headers: " ^ headers) + else if + Js.String.includes ~search:"logseq.property/status.todo" + output.stdout + then + fail_promise ("task status value kept namespace:\n" ^ output.stdout) + else if + Js.String.includes ~search:"logseq.property/priority.high" + output.stdout + then + fail_promise + ("task priority value kept namespace:\n" ^ output.stdout) + else ( + ignore + (expect_named_contains "task status value" output.stdout + "status.todo"); + ignore + (expect_named_contains "task priority value" output.stdout + "priority.high"); + Js.Promise.resolve pass))); + + test "CLI exits quietly on stdout EPIPE" (fun () -> + let root = temp_dir "logseq-cli-stdout-epipe-" in + let script_path = Node.Path.join [| root; "emit-stdout-epipe.js" |] in + let entrypoint_path = Node.Path.resolve "." entrypoint in + try + write_file script_path + ("process.argv = [process.argv[0], " + ^ Js.Json.stringify (Js.Json.string entrypoint_path) + ^ ", '--version'];\nrequire(" + ^ Js.Json.stringify (Js.Json.string entrypoint_path) + ^ ");\n\ + const err = new Error('write EPIPE');\n\ + err.code = 'EPIPE';\n\ + process.stdout.emit('error', err);\n"); + let output = spawn_cli_from script_path [||] in + remove_tree root; + ignore (expect_exit_zero "stdout EPIPE" output); + expect_named_not_contains "stdout EPIPE stderr" output##stderr "EPIPE"; + expect_named_not_contains "stdout EPIPE unhandled" output##stderr + "Unhandled 'error' event" + with exn -> + remove_tree root; + fail_test (Printexc.to_string exn)); + + test_promise "list page human output aligns ragged property rows" (fun () -> + let request_count = ref 0 in + let title = + decode_uri_component "%E4%B8%AD%E6%96%87%E5%9B%BE%E8%B0%B1Alpha" + in + let truncated_title = + decode_uri_component "%E4%B8%AD%E6%96%87%E2%80%A6" + in + let result_transit = + "[" + ^ String.concat "," + [ + Printf.sprintf + "{\"~:db/id\":1,\"~:block/title\":\"Plain\",\"~:block/created-at\":1777063533827,\"~:block/updated-at\":1779715760691}"; + "{\"~:db/id\":2,\"~:block/title\":\"Property\",\"~:block/created-at\":1733130109047,\"~:block/updated-at\":1779543495725,\"~:db/ident\":\"~:user.property/Assignee-Xf-emnkZ\",\"~:logseq.property/type\":\"node\"}"; + Printf.sprintf + "{\"~:db/id\":3,\"~:block/title\":%s,\"~:block/created-at\":1700000000000,\"~:block/updated-at\":1700000000000}" + (Js.Json.stringify (Js.Json.string title)); + ] + ^ "]" + in + let server = + invoke_server (fun _body -> + incr request_count; + result_transit) + in + let root = temp_dir "logseq-cli-ragged-" in + write_file + (Node.Path.join [| root; "cli.edn" |]) + "{:list-title-max-display-width 6}"; + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| "--root-dir"; root; "--graph"; "alpha"; "list"; "page" |] + in + remove_tree root; + ignore (expect_cli_exit_zero "list page ragged" output); + if !request_count <> 1 then + fail_promise + (Printf.sprintf "expected one request, got %d" !request_count) + else ( + ignore + (expect_named_contains "db ident" output.stdout + "user.property/Assignee-Xf-emnkZ"); + ignore + (expect_named_not_contains "property type column" output.stdout + "logseq.property/type"); + ignore + (expect_named_not_contains "node type value" output.stdout "node"); + ignore + (expect_named_contains "display-width truncation" output.stdout + truncated_title); + ignore (expect_named_not_contains "full title" output.stdout title); + ignore (expect_created_at_column_aligned output.stdout); + ignore + (expect_named_contains "ragged placeholder" output.stdout "-"); + Js.Promise.resolve pass))); + + test_promise "upsert task sends scheduled and deadline as epoch millis" + (fun () -> + let step = ref 0 in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 when Js.String.includes ~search:"thread-api/q" body -> + "[\"~#list\",[\"~:logseq.property/status.todo\"]]" + | 2 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"logseq.class/Task" body -> + "[\"^ \",\"~:db/id\",77]" + | 3 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"taskhome" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\"]]" + | 4 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if + Js.String.includes ~search:"2026-02-10T08:00:00.000Z" body + || Js.String.includes ~search:"2026-02-12T18:00:00.000Z" body + then + fail_test + ("datetime task values must not be ISO strings: " ^ body); + if + Js.String.includes ~search:"\\\"1770710400000\\\"" body + || Js.String.includes ~search:"\\\"1770919200000\\\"" body + then + fail_test + ("datetime task values must be JSON numbers: " ^ body); + if + not + (Js.String.includes ~search:"1770710400000" body + && Js.String.includes ~search:"1770919200000" body) + then fail_test ("missing epoch millis task values: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "task"; + "--page"; + "TaskHome"; + "--status"; + "todo"; + "--priority"; + "high"; + "--scheduled"; + "2026-02-10T08:00:00.000Z"; + "--deadline"; + "2026-02-12T18:00:00.000Z"; + |] + in + ignore (expect_cli_exit_zero "upsert task datetime" output); + if !step = 4 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected four invoke requests, got %d" !step))); + + test_promise "upsert page reports missing update property name" (fun () -> + let step = ref 0 in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && not (Js.String.includes ~search:"missing-prop" body) -> + "[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"atomicpage\"]" + | 2 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"missing-prop" body -> + "null" + | 3 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"missing-prop" body -> + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "page"; + "--id"; + "42"; + "--update-properties"; + "{:missing-prop \"value\"}"; + |] + in + if output.code = 0 then + fail_promise + ("expected missing update property to fail\n" ^ output.stdout) + else if + not (Js.String.includes ~search:"property-not-found" output.stdout) + then + fail_promise + ("missing property code\nstdout:\n" ^ output.stdout + ^ "\nstderr:\n" ^ output.stderr) + else if not (Js.String.includes ~search:"missing-prop" output.stdout) + then + fail_promise + ("missing property name\nstdout:\n" ^ output.stdout + ^ "\nstderr:\n" ^ output.stderr) + else if + not + (Js.String.includes ~search:"\"option\":\"--update-properties\"" + output.stdout) + then + fail_promise + ("missing property option context\nstdout:\n" ^ output.stdout + ^ "\nstderr:\n" ^ output.stderr) + else if + not + (Js.String.includes ~search:"\"phase\":\"resolve-options\"" + output.stdout) + then + fail_promise + ("missing property phase context\nstdout:\n" ^ output.stdout + ^ "\nstderr:\n" ^ output.stderr) + else Js.Promise.resolve pass)); + + test_promise "upsert tag applies schema property outliner ops" (fun () -> + let step = ref 0 in + let tag_uuid = "22222222-2222-4222-8222-222222222222" in + let add_ident = "user.property/status" in + let remove_ident = "user.property/owner" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"project" body -> + "[]" + | 2 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:"create-page" body) then + fail_test ("missing tag create op: " ^ body); + "[]" + | 3 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"project" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"~u22222222-2222-4222-8222-222222222222\",\"~:block/name\",\"project\",\"~:block/tags\",[[\"^ \ + \",\"~:db/ident\",\"~:logseq.class/Tag\"]]]]" + | 4 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"status" body -> + "[[\"^ \ + \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/status\",\"~:logseq.property/type\",\"default\"]]" + | 5 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"owner" body -> + "[[\"^ \ + \",\"~:db/id\",78,\"~:db/ident\",\"~:user.property/owner\",\"~:logseq.property/type\",\"default\"]]" + | 6 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:"class-add-property" body) + then fail_test ("missing class-add-property op: " ^ body); + if not (Js.String.includes ~search:"class-remove-property" body) + then fail_test ("missing class-remove-property op: " ^ body); + if not (Js.String.includes ~search:tag_uuid body) then + fail_test ("missing tag uuid: " ^ body); + if not (Js.String.includes ~search:add_ident body) then + fail_test ("missing add property ident: " ^ body); + if not (Js.String.includes ~search:remove_ident body) then + fail_test ("missing remove property ident: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "tag"; + "--name"; + "Project"; + "--add-properties"; + "[\"status\"]"; + "--remove-properties"; + "[\"owner\"]"; + |] + in + ignore (expect_cli_exit_zero "upsert tag schema properties" output); + if !step = 6 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected six invoke requests, got %d" !step))); + + test_promise "upsert tag reports missing schema property name" (fun () -> + let step = ref 0 in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"project" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"~u22222222-2222-4222-8222-222222222222\",\"~:block/name\",\"project\",\"~:block/tags\",[[\"^ \ + \",\"~:db/ident\",\"~:logseq.class/Tag\"]]]]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"missing-prop" body -> + "[]" + | 3 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"missing-prop" body -> + "null" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "tag"; + "--name"; + "Project"; + "--add-properties"; + "[\"missing-prop\"]"; + |] + in + if output.code = 0 then + fail_promise + ("expected missing tag schema property to fail\n" ^ output.stdout) + else if + not (Js.String.includes ~search:"property-not-found" output.stdout) + then fail_promise ("missing property code\nstdout:\n" ^ output.stdout) + else if not (Js.String.includes ~search:"missing-prop" output.stdout) + then fail_promise ("missing property name\nstdout:\n" ^ output.stdout) + else if + not + (Js.String.includes ~search:"\"option\":\"--add-properties\"" + output.stdout) + then + fail_promise + ("missing add-properties context\nstdout:\n" ^ output.stdout) + else if !step = 3 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected three invoke requests, got %d" !step))); + + test_promise "upsert tag reports missing schema property ident" (fun () -> + let step = ref 0 in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"project" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"~u22222222-2222-4222-8222-222222222222\",\"~:block/name\",\"project\",\"~:block/tags\",[[\"^ \ + \",\"~:db/ident\",\"~:logseq.class/Tag\"]]]]" + | 2 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"user.property/missing-prop" + body -> + "null" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "tag"; + "--name"; + "Project"; + "--add-properties"; + "[:user.property/missing-prop]"; + |] + in + if output.code = 0 then + fail_promise + ("expected missing tag schema property to fail\n" ^ output.stdout) + else if + not (Js.String.includes ~search:"property-not-found" output.stdout) + then fail_promise ("missing property code\nstdout:\n" ^ output.stdout) + else if not (Js.String.includes ~search:"missing-prop" output.stdout) + then fail_promise ("missing property ident\nstdout:\n" ^ output.stdout) + else if + not + (Js.String.includes ~search:"\"option\":\"--add-properties\"" + output.stdout) + then + fail_promise + ("missing add-properties context\nstdout:\n" ^ output.stdout) + else if !step = 2 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected two invoke requests, got %d" !step))); + + test_promise + "upsert page resolves unqualified update property keywords by name" + (fun () -> + let step = ref 0 in + let property_ident = "user.property/node-prop" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && not (Js.String.includes ~search:"node-prop" body) -> + "[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"nodehome\"]" + | 2 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"node-prop" body -> + "null" + | 3 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"node-prop" body -> + "[[\"^ \ + \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/node-prop\",\"~:logseq.property/type\",\"default\"]]" + | 4 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:property_ident body) then + fail_test ("missing resolved property ident: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "page"; + "--id"; + "42"; + "--update-properties"; + "{:node-prop \"v\"}"; + |] + in + ignore (expect_cli_exit_zero "upsert page property by name" output); + if !step = 4 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected four invoke requests, got %d" !step))); + + test_promise "upsert block create resolves inline property names per block" + (fun () -> + let step = ref 0 in + let property_ident = "user.property/inline-note" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"inline note" body -> + "[[\"^ \ + \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/inline-note\",\"~:logseq.property/type\",\"default\"]]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"home" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if Js.String.includes ~search:property_ident body then + fail_test + ("inline property should not be inserted with block: " + ^ body); + "[]" + | 4 when Js.String.includes ~search:"thread-api/pull" body -> + "[\"^ \",\"~:db/id\",10]" + | 5 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:property_ident body) then + fail_test ("missing inline property ident: " ^ body); + if not (Js.String.includes ~search:"First note" body) then + fail_test ("missing inline property value: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "block"; + "--target-page"; + "Home"; + "--blocks"; + "[{:block/title \"First inline\" \"Inline Note\" \"First \ + note\"}]"; + |] + in + ignore (expect_cli_exit_zero "upsert block inline property" output); + if !step = 5 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected five invoke requests, got %d" !step))); + + test_promise "upsert block create sends page refs from block title" (fun () -> + let step = ref 0 in + let ref_uuid = "22222222-2222-4222-8222-222222222222" in + let captured_apply_body = ref None in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"home" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\"]]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"bar_page" body -> + "[[\"^ \",\"~:db/id\",88,\"~:block/uuid\",\"" ^ ref_uuid + ^ "\",\"~:block/name\",\"bar_page\",\"~:block/title\",\"bar_page\"]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + captured_apply_body := Some body; + "[]" + | 4 when Js.String.includes ~search:"thread-api/pull" body -> + "[\"^ \",\"~:db/id\",10]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "block"; + "--target-page"; + "Home"; + "--content"; + "Foo links to [[bar_page]]"; + |] + in + ignore (expect_cli_exit_zero "upsert block title page refs" output); + let body = + match !captured_apply_body with + | Some body -> body + | None -> + fail_test "missing captured apply body"; + failwith "missing captured apply body" + in + expect_named_contains "block refs attr" body "block/refs"; + expect_named_contains "resolved page ref uuid" body ref_uuid; + expect_named_contains "resolved page ref title" body "bar_page"; + if !step = 4 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected four invoke requests, got %d" !step))); + + test_promise "upsert block update resolves tag names from tag list" (fun () -> + let step = ref 0 in + let captured_apply_body = ref None in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"233" body -> + "[\"^ \ + \",\"~:db/id\",233,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000233\"]" + | 2 when Js.String.includes ~search:"thread-api/cli-list-tags" body + -> + "[[\"^ \",\"~:db/id\",200,\"~:block/title\",\"Tag A\"]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + captured_apply_body := Some body; + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "block"; + "--id"; + "233"; + "--update-tags"; + "[\"Tag A\"]"; + |] + in + ignore (expect_cli_exit_zero "upsert block tag name" output); + let body = + match !captured_apply_body with + | Some body -> body + | None -> + fail_test "missing captured apply body"; + failwith "missing captured apply body" + in + expect_named_contains "batch tag set op" body "batch-set-property"; + expect_named_contains "resolved tag id" body "200"; + if !step = 3 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected three invoke requests, got %d" !step))); + + test_promise "upsert block create resolves inline block uuid property refs" + (fun () -> + let step = ref 0 in + let ref_uuid = "22222222-2222-4222-8222-222222222222" in + let property_ident = "logseq.property.comments/blocks" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:ref_uuid body -> + "[\"^ \ + \",\"~:db/id\",88,\"~:block/uuid\",\"22222222-2222-4222-8222-222222222222\"]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"home" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if Js.String.includes ~search:property_ident body then + fail_test + ("inline ref property should not be inserted with block: " + ^ body); + "[]" + | 4 when Js.String.includes ~search:"thread-api/pull" body -> + "[\"^ \",\"~:db/id\",10]" + | 5 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:property_ident body) then + fail_test ("missing comments blocks property ident: " ^ body); + if not (Js.String.includes ~search:"88" body) then + fail_test ("missing resolved block ref id: " ^ body); + if Js.String.includes ~search:ref_uuid body then + fail_test + ("unresolved block uuid leaked into property op: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "block"; + "--target-page"; + "Home"; + "--blocks"; + "[{:block/title \"Comment area\" \ + :logseq.property.comments/blocks [[:block/uuid #uuid \ + \"22222222-2222-4222-8222-222222222222\"]]}]"; + |] + in + ignore + (expect_cli_exit_zero "upsert block inline ref property" output); + if !step = 5 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected five invoke requests, got %d" !step))); + + test_promise + "upsert block create keeps inline tags out of property ops while resolving \ + refs" (fun () -> + let step = ref 0 in + let ref_uuid = "22222222-2222-4222-8222-222222222222" in + let property_ident = "logseq.property.comments/blocks" in + let tag_ident = "logseq.class/Comments" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:ref_uuid body -> + "[\"^ \ + \",\"~:db/id\",88,\"~:block/uuid\",\"22222222-2222-4222-8222-222222222222\"]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"home" body -> + "[[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if Js.String.includes ~search:property_ident body then + fail_test + ("inline ref property should not be inserted with block: " + ^ body); + if not (Js.String.includes ~search:tag_ident body) then + fail_test ("missing inline tag in inserted block: " ^ body); + "[]" + | 4 when Js.String.includes ~search:"thread-api/pull" body -> + "[\"^ \",\"~:db/id\",10]" + | 5 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:property_ident body) then + fail_test ("missing comments blocks property ident: " ^ body); + if Js.String.includes ~search:tag_ident body then + fail_test + ("block/tags leaked into inline property op: " ^ body); + if not (Js.String.includes ~search:"88" body) then + fail_test ("missing resolved block ref id: " ^ body); + if Js.String.includes ~search:ref_uuid body then + fail_test + ("unresolved block uuid leaked into property op: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "block"; + "--target-page"; + "Home"; + "--blocks"; + "[{:block/title \"Comment area\" :block/tags \ + [:logseq.class/Comments] :logseq.property.comments/blocks \ + [[:block/uuid #uuid \ + \"22222222-2222-4222-8222-222222222222\"]]}]"; + |] + in + ignore (expect_cli_exit_zero "upsert block inline ref tags" output); + if !step = 5 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected five invoke requests, got %d" !step))); + + test_promise "update block resolves string update property names by title" + (fun () -> + let step = ref 0 in + let property_ident = "user.property/reproducible-steps" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && not (Js.String.includes ~search:"reproducible" body) -> + "[\"^ \ + \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/title\",\"Alpha \ + block\"]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"reproducible steps" body -> + "[[\"^ \ + \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/reproducible-steps\",\"~:logseq.property/type\",\"default\"]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:property_ident body) then + fail_test ("missing update property ident: " ^ body); + if not (Js.String.includes ~search:"Step 1" body) then + fail_test ("missing update property value: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "block"; + "--id"; + "42"; + "--update-properties"; + "{\"Reproducible steps\" [\"Step 1\"]}"; + |] + in + ignore (expect_cli_exit_zero "update block property by title" output); + if !step = 3 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected three invoke requests, got %d" !step))); + + test_promise "list node resolves unqualified property filters by name" + (fun () -> + let step = ref 0 in + let property_ident = "user.property/node-prop" in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"node-prop" body -> + "[[\"^ \ + \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/node-prop\",\"~:logseq.property/type\",\"default\"]]" + | 2 when Js.String.includes ~search:"thread-api/cli-list-nodes" body + -> + if not (Js.String.includes ~search:property_ident body) then + fail_test ("missing list property ident: " ^ body); + "[]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "--output"; + "json"; + "list"; + "node"; + "--properties"; + "node-prop"; + |] + in + ignore (expect_cli_exit_zero "list node property by name" output); + if !step = 2 then Js.Promise.resolve pass + else + fail_promise + (Printf.sprintf "expected two invoke requests, got %d" !step))); + + test_promise "upsert asset copies file and sends asset metadata" (fun () -> + let root = temp_dir "logseq-cli-upsert-asset-" in + let source_path = Node.Path.join [| root; "logo.png" |] in + let expected_checksum = + "d59386e0ae435e292fbe0ebcdb954b75ed5fb3922091277cb19f798fc5d50718" + in + write_file source_path "asset"; + let step = ref 0 in + let server = + invoke_server (fun body -> + incr step; + match !step with + | 1 + when Js.String.includes ~search:"thread-api/pull" body + && Js.String.includes ~search:"logseq.class/Asset" body -> + "[\"^ \",\"~:db/id\",77]" + | 2 + when Js.String.includes ~search:"thread-api/q" body + && Js.String.includes ~search:"home" body -> + "[\"~#list\",[[\"^ \ + \",\"~:db/id\",1,\"~:block/uuid\",\"~u11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]]" + | 3 + when Js.String.includes ~search:"thread-api/apply-outliner-ops" + body -> + if not (Js.String.includes ~search:expected_checksum body) then + fail_test "missing asset checksum"; + if + not + (Js.String.includes ~search:"logseq.property.asset/type" + body + && Js.String.includes ~search:"png" body) + then fail_test "missing asset type metadata"; + "[]" + | 4 when Js.String.includes ~search:"thread-api/pull" body -> + "[\"^ \",\"~:db/id\",10]" + | _ -> + fail_test + (Printf.sprintf "unexpected request at step %d: %s" !step body); + "") + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--root-dir"; + root; + "--graph"; + "alpha"; + "--output"; + "json"; + "upsert"; + "asset"; + "--path"; + source_path; + "--target-page"; + "Home"; + "--content"; + "Logo"; + |] + in + let finish_with_error message = + remove_tree root; + fail_promise message + in + if output.code <> 0 then + finish_with_error + (Printf.sprintf + "expected CLI upsert asset to exit 0, got %d\n%s\n%s" + output.code output.stdout output.stderr) + else if !step <> 4 then + finish_with_error + (Printf.sprintf "expected four invoke requests, got %d" !step) + else if + not + (Js.String.includes + ~search:"{\"status\":\"ok\",\"data\":{\"result\":[10]}}" + output.stdout) + then + finish_with_error ("expected success json output\n" ^ output.stdout) + else + let assets_dir = + Node.Path.join [| root; "graphs"; "alpha"; "assets" |] + in + let copied = + if Node.Fs.existsSync assets_dir then + Node.Fs.readdirSync assets_dir + else [||] + in + if Array.length copied <> 1 then + finish_with_error + (Printf.sprintf "expected one copied asset, got %d" + (Array.length copied)) + else if + read_file (Node.Path.join [| assets_dir; copied.(0) |]) <> "asset" + then finish_with_error "expected copied asset content" + else ( + remove_tree root; + Js.Promise.resolve pass))); + + test_promise + "show human tree preserves multiline guides and linked references" + (fun () -> + let responses = + [| + ( "thread-api/pull", + "[\"^ \",\"~:db/id\",16764,\"~:block/title\",\"type system to \ + strengthen codebase examples\",\"~:block/name\",\"type system to \ + strengthen codebase examples\"]" ); + ( "thread-api/q", + "[[[\"^ \",\"~:db/id\",16766,\"~:block/title\",\"use type system \ + to ensure block validated, graph-name validated, \ + etc..\",\"~:block/order\",\"a\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",16764]]],[[\"^ \ + \",\"~:db/id\",16767,\"~:block/title\",\"module type Block : \ + sig\\ntype raw\\ntype validated\\n\\ntype _ t\\n\\nval of_edn : \ + Edn.t -> raw t\\nval validate : raw t -> (validated t, string \ + list) result\\n\\nval uuid : validated t -> Uuidm.t\\nval db_id : \ + validated t -> Db_id.t\\nval title : validated t -> string \ + option\\nval journal_day : validated t -> Journal_day.t \ + option\\nend \ + #Code\",\"~:block/order\",\"b\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",16766]]],[[\"^ \ + \",\"~:db/id\",16768,\"~:block/title\",\"split block into raw & \ + validated 2 types, all fns need to operate on blocks only accept \ + `validated \ + block`\",\"~:block/order\",\"c\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",16764]]],[[\"^ \ + \",\"~:db/id\",16769,\"~:block/title\",\"-\",\"~:block/order\",\"d\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",16764]]]]" ); + ("thread-api/get-block-refs", "[[\"^ \",\"~:db/id\",16763]]"); + ( "thread-api/pull", + "[\"^ \",\"~:db/id\",16763,\"~:block/title\",\"[[type system to \ + strengthen codebase examples]]\",\"~:block/page\",[\"^ \ + \",\"~:db/id\",16760,\"~:block/title\",\"May 31st, \ + 2026\",\"~:block/name\",\"may 31st, 2026\"]]" ); + |] + in + let index = ref 0 in + let server = + invoke_server (fun body -> + if !index >= Array.length responses then + fail_test ("unexpected extra request: " ^ body); + let method_, transit = responses.(!index) in + incr index; + if not (Js.String.includes ~search:method_ body) then + fail_test (Printf.sprintf "expected %s, got %s" method_ body); + transit) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| "--graph"; "alpha"; "show"; "--id"; "16764" |] + in + ignore (expect_cli_exit_zero "show human tree" output); + if !index <> Array.length responses then + fail_promise + (Printf.sprintf "handled %d requests, expected %d" !index + (Array.length responses)) + else ( + ignore + (expect_named_not_contains "multiline raw prefix" output.stdout + "\ntype raw\n"); + ignore + (expect_named_contains "multiline tree context" output.stdout + ("\n " ^ tree_pipe ^ " type raw\n")); + ignore + (expect_named_contains "blank multiline tree guide" output.stdout + ("\n " ^ tree_pipe ^ "\n " ^ tree_pipe + ^ " type _ t\n")); + ignore + (expect_named_contains "linked reference page context" + output.stdout + ("Linked References (1)\n16760 May 31st, 2026\n16763 " + ^ tree_corner + ^ " [[type system to strengthen codebase examples]]")); + Js.Promise.resolve pass))); + + test_promise "show human tree aligns guides when ids have different widths" + (fun () -> + let responses = + [| + ( "thread-api/pull", + "[\"^ \",\"~:db/id\",1,\"~:block/title\",\"Root\"]" ); + ( "thread-api/q", + "[[[\"^ \ + \",\"~:db/id\",9,\"~:block/title\",\"Short\",\"~:block/order\",\"a\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",1]]],[[\"^ \ + \",\"~:db/id\",12345,\"~:block/title\",\"Wide\",\"~:block/order\",\"b\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",1]]],[[\"^ \ + \",\"~:db/id\",4395,\"~:block/title\",\"Nested\",\"~:block/order\",\"a\",\"~:block/parent\",[\"^ \ + \",\"~:db/id\",9]]]]" ); + |] + in + let index = ref 0 in + let server = + invoke_server (fun body -> + if !index >= Array.length responses then + fail_test ("unexpected extra request: " ^ body); + let method_, transit = responses.(!index) in + incr index; + if not (Js.String.includes ~search:method_ body) then + fail_test (Printf.sprintf "expected %s, got %s" method_ body); + transit) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "show"; + "--id"; + "1"; + "--linked-references=false"; + |] + in + ignore (expect_cli_exit_zero "show human tree aligned ids" output); + if !index <> Array.length responses then + fail_promise + (Printf.sprintf "handled %d requests, expected %d" !index + (Array.length responses)) + else ( + ignore + (expect_named_contains "aligned root" output.stdout "1 Root"); + ignore + (expect_named_contains "aligned short id branch" output.stdout + ("9 " ^ tree_middle ^ " Short")); + ignore + (expect_named_contains "aligned nested guide" output.stdout + ("\n4395 " ^ tree_pipe ^ " " ^ tree_corner ^ " Nested\n")); + ignore + (expect_named_contains "aligned wide id branch" output.stdout + ("12345 " ^ tree_corner ^ " Wide")); + Js.Promise.resolve pass))); + + test_promise "show human output writes unicode text from worker strings" + (fun () -> + let responses = + [| + ( "thread-api/pull", + "[\"^ \",\"~:db/id\",1,\"~:block/title\",\"\\u4e2d\\u6587\"]" ); + ("thread-api/q", "[]"); + |] + in + let index = ref 0 in + let server = + invoke_server (fun body -> + if !index >= Array.length responses then + fail_test ("unexpected extra request: " ^ body); + let method_, transit = responses.(!index) in + incr index; + if not (Js.String.includes ~search:method_ body) then + fail_test (Printf.sprintf "expected %s, got %s" method_ body); + transit) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "show"; + "--id"; + "1"; + "--linked-references=false"; + |] + in + ignore (expect_cli_exit_zero "show human unicode output" output); + if !index <> Array.length responses then + fail_promise + (Printf.sprintf "handled %d requests, expected %d" !index + (Array.length responses)) + else ( + ignore + (expect_named_contains "unicode stdout" output.stdout + (unicode_text [| 0x4e2d; 0x6587 |])); + ignore + (expect_named_not_contains "mojibake stdout" output.stdout + "\195\164\194\184\194\173"); + Js.Promise.resolve pass))); + + test_promise "show human properties renders task status and property values" + (fun () -> + let saw_status_selector = ref false in + let saw_wildcard_selector = ref false in + let index = ref 0 in + let static_transits = + [| + "[\"^ \ + \",\"~:db/id\",1,\"~:block/title\",\"Home\",\"~:block/name\",\"home\"]"; + ""; + "[\"^ \ + \",\"~:db/id\",7,\"~:db/ident\",\"~:user.property/owner\",\"~:block/title\",\"Owner\",\"~:logseq.property/type\",\"~:default\"]"; + "[\"^ \ + \",\"~:db/id\",8,\"~:db/ident\",\"~:user.property/agent-skills\",\"~:block/title\",\"agent-skills\",\"~:logseq.property/type\",\"~:default\"]"; + "[\"^ \ + \",\"~:db/id\",16,\"~:db/ident\",\"~:user.property/reproducible-steps\",\"~:block/title\",\"Reproducible \ + steps\",\"~:logseq.property/type\",\"~:default\"]"; + "[\"^ \ + \",\"~:db/id\",17,\"~:db/ident\",\"~:user.property/empty-list\",\"~:block/title\",\"empty-list\",\"~:logseq.property/type\",\"~:default\"]"; + "[\"^ \ + \",\"~:db/id\",15,\"~:db/ident\",\"~:logseq.property/assignee\",\"~:block/title\",\"Assignee\",\"~:logseq.property/type\",\"~:default\"]"; + "[\"^ \ + \",\"~:db/id\",19,\"~:db/ident\",\"~:logseq.property/reviewer\",\"~:block/title\",\"Reviewer\",\"~:logseq.property/type\",\"~:default\"]"; + "[\"^ \",\"~:db/id\",9,\"~:block/title\",\"Alice\"]"; + "[\"^ \",\"~:db/id\",10,\"~:block/title\",\"logseq-answer-machine\"]"; + "[\"^ \",\"~:db/id\",11,\"~:logseq.property/value\",\"Type `[[assig` \ + and only see [[Assignee(legacy)]]\"]"; + "[\"^ \",\"~:db/id\",12,\"~:logseq.property/value\",\"Expect to see \ + two entries\"]"; + "[\"^ \",\"~:db/id\",13,\"~:logseq.property/value\",\"Expected to \ + see the built-in property\"]"; + "[\"^ \",\"~:db/id\",14,\"~:block/title\",\"czys-Mac-Studio.local\"]"; + "[\"^ \",\"~:db/id\",18,\"~:block/title\",\"general-reviewer.local\"]"; + |] + in + let expected_methods = + [| + "thread-api/pull"; + "thread-api/q"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + "thread-api/pull"; + |] + in + let q_transit body = + saw_status_selector := + Js.String.includes ~search:"~:logseq.property/status" body; + saw_wildcard_selector := Js.String.includes ~search:"~$*" body; + let child_fields = + ref + (Vec.of_array + [| + "\"~:db/id\",2"; + "\"~:block/title\",\"Ship CLI\""; + "\"~:block/order\",\"a\""; + "\"~:block/parent\",[\"^ \",\"~:db/id\",1]"; + "\"~:block/tags\",[[\"^ \ + \",\"~:db/ident\",\"~:logseq.class/Task\",\"~:block/title\",\"Task\"]]"; + |]) + in + if !saw_status_selector then + child_fields := + Vec.append !child_fields + (Vec.of_array + [| + "\"~:logseq.property/status\",[\"^ \ + \",\"~:db/ident\",\"~:logseq.property/status.todo\",\"~:block/title\",\"Todo\"]"; + |]); + if !saw_wildcard_selector then + child_fields := + Vec.append !child_fields + (Vec.of_array + [| + "\"~:user.property/owner\",[\"~:db/id\",9]"; + "\"~:user.property/agent-skills\",[[ \"~:db/id\",10 ]]"; + "\"~:user.property/reproducible-steps\",[\"~:db/id\",11,[\"~:db/id\",12],[\"~:db/id\",13]]"; + "\"~:user.property/empty-list\",[]"; + "\"~:logseq.property/assignee\",[[\"~:db/id\",14]]"; + "\"~:logseq.property/reviewer\",[[\"~:db/id\",18]]"; + |]); + "[[[\"^ \"," ^ Vec.string_concat "," !child_fields ^ "]]]" + in + let server = + invoke_server (fun body -> + if !index >= Array.length expected_methods then + fail_test ("unexpected extra request: " ^ body); + let expected_method = expected_methods.(!index) in + if not (Js.String.includes ~search:expected_method body) then + fail_test + (Printf.sprintf "expected %s, got %s" expected_method body); + let current = !index in + incr index; + if current = 1 then q_transit body else static_transits.(current)) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--graph"; + "alpha"; + "show"; + "--page"; + "home"; + "--linked-references=false"; + |] + in + ignore (expect_cli_exit_zero "show human properties" output); + if not !saw_status_selector then + fail_promise "show tree selector did not request task status" + else if not !saw_wildcard_selector then + fail_promise "show tree selector did not request custom properties" + else if !index <> Array.length expected_methods then + fail_promise + (Printf.sprintf "handled %d requests, expected %d" !index + (Array.length expected_methods)) + else ( + ignore + (expect_named_contains "task status" output.stdout + ("2 " ^ tree_corner ^ " Todo Ship CLI #Task")); + ignore + (expect_named_contains "owner property" output.stdout + " Owner: Alice"); + ignore + (expect_named_contains "single cardinality-many property" + output.stdout "agent-skills: logseq-answer-machine"); + ignore + (expect_named_contains "multi-value property bullets" + output.stdout + "Reproducible steps:\n\ + \ - Type `[[assig` and only see [[Assignee(legacy)]]\n\ + \ - Expect to see two entries\n\ + \ - Expected to see the built-in property"); + ignore + (expect_named_not_contains "empty list property" output.stdout + "empty-list"); + ignore + (expect_named_contains "assignee property" output.stdout + "Assignee: czys-Mac-Studio.local"); + ignore + (expect_named_contains "reviewer property" output.stdout + "Reviewer: general-reviewer.local"); + ignore + (expect_named_not_contains "db id leak" output.stdout ":db/id"); + Js.Promise.resolve pass))); + + let sync_graph_id = "11111111-1111-1111-1111-111111111111" in + let sync_remote_graph_transit = + "[[\"^ \",\"~:graph-name\",\"alpha\",\"~:graph-id\",\"~u" ^ sync_graph_id + ^ "\",\"~:graph-e2ee?\",false]]" + in + let sync_invoke_response download_requests body = + if Js.String.includes ~search:"thread-api/set-db-sync-config" body then + Some "true" + else if + Js.String.includes ~search:"thread-api/db-sync-list-remote-graphs" body + then Some sync_remote_graph_transit + else if Js.String.includes ~search:"thread-api/q" body then Some "0" + else if + Js.String.includes ~search:"thread-api/db-sync-download-graph-by-id" body + then ( + incr download_requests; + Some "true") + else ( + fail_test ("unexpected invoke body: " ^ body); + None) + in + test_promise "invoke response decodes JSON escaped resultTransit" (fun () -> + let server = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + if req_method req <> "POST" || req_url req <> "/v1/invoke" then + write_json res 404 (error_response "not found") + else if + not + (Js.String.includes ~search:"thread-api/cli-list-pages" + !body) + then write_json res 400 (error_response "unexpected request") + else write_json res 200 "{\"resultTransit\":\"\\u005b\\u005d\"}")) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| "--graph"; "alpha"; "--output"; "json"; "list"; "page" |] + in + ignore (expect_cli_exit_zero "json escaped resultTransit" output); + if + Js.String.includes + ~search:"{\"status\":\"ok\",\"data\":{\"items\":[]}}" + output.stdout + then Js.Promise.resolve pass + else fail_promise ("expected empty list result\n" ^ output.stdout))); + + let run_sync_progress_case name root_prefix handle_events + ?(delay_download_response = false) ?(delay_events_response = false) + assert_progress = + test_promise name (fun () -> + let root = temp_dir root_prefix in + let events_requests = ref 0 in + let download_requests = ref 0 in + let server = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + if req_method req = "GET" && req_url req = "/v1/events" then ( + incr events_requests; + if delay_events_response then + set_timeout (fun () -> handle_events res) 100 + else handle_events res) + else if req_method req = "POST" && req_url req = "/v1/invoke" + then + try + match sync_invoke_response download_requests !body with + | Some result_transit + when delay_download_response + && Js.String.includes + ~search: + "thread-api/db-sync-download-graph-by-id" + !body -> + set_timeout + (fun () -> + write_json res 200 (json_response result_transit)) + 100 + | Some result_transit -> + write_json res 200 (json_response result_transit) + | None -> () + with exn -> + write_json res 400 + (error_response (Printexc.to_string exn)) + else write_json res 404 (error_response "not found"))) + in + with_server server (fun base_url -> + let* output = + run_cli_p + ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] + [| + "--root-dir"; + root; + "--graph"; + "alpha"; + "--output"; + "json"; + "sync"; + "download"; + "--progress"; + |] + in + remove_tree root; + ignore (expect_cli_exit_zero name output); + if !events_requests <> 1 then + fail_promise + (Printf.sprintf "expected one events request, got %d" + !events_requests) + else if !download_requests <> 1 then + fail_promise + (Printf.sprintf "expected one download request, got %d" + !download_requests) + else if + not + (Js.String.includes + ~search:"{\"status\":\"ok\",\"data\":{\"result\":true}}" + output.stdout) + then fail_promise ("expected success json output\n" ^ output.stdout) + else ( + assert_progress output.stdout; + Js.Promise.resolve pass))) + in + run_sync_progress_case + "sync download prints progress from closed event stream" + "logseq-cli-sync-progress-" ~delay_download_response:true + (fun res -> + res_write_head res 200 + (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); + res_end res + ("data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ + \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u" + ^ sync_graph_id + ^ "\\\",\\\"~:message\\\",\\\"downloaded 1 block\\\"]]\"}\n\n")) + (fun stdout -> + ignore + (expect_named_contains "progress output" stdout "downloaded 1 block")); + + run_sync_progress_case "sync download decodes JSON escaped progress payload" + "logseq-cli-sync-progress-escaped-" ~delay_events_response:true + (fun res -> + let payload = + "[\"~:rtc-log\",[\"^ \ + \",\"~:type\",\"~:rtc.log/download\",\"~:graph-uuid\",\"~u" + ^ sync_graph_id ^ "\",\"~:message\",\"downloaded escaped payload\"]]" + in + let payload_json = Js.Json.stringify (Js.Json.string payload) in + let escaped_payload_json = + "\"\\u005b" ^ String.sub payload_json 2 (String.length payload_json - 2) + in + res_write_head res 200 + (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); + res_end res ("data: {\"payload\":" ^ escaped_payload_json ^ "}\n\n")) + (fun stdout -> + ignore + (expect_named_contains "escaped progress output" stdout + "downloaded escaped payload")); + + run_sync_progress_case "sync download finishes while event stream stays open" + "logseq-cli-sync-progress-stream-" ~delay_download_response:true + (fun res -> + res_write_head res 200 + (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); + let large_sync_payload = + "[\"~:sync-db-changes\",[\"^ \",\"~:pages\"," ^ "[" + ^ Vec.string_concat "," + (Vec.init 12000 (fun i -> + Printf.sprintf + "[\"^ \",\"~:db/id\",%d,\"~:block/title\",\"block %d\"]" i i)) + ^ "]]]" + in + res_write res + ("data: " ^ "{\"type\":\"sync-db-changes\",\"payload\":" + ^ Js.Json.stringify (Js.Json.string large_sync_payload) + ^ "}\n\n"); + res_write res + ("data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ + \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u" + ^ sync_graph_id + ^ "\\\",\\\"~:message\\\",\\\"downloaded streaming block\\\"]]\"}\n\n")) + (fun stdout -> + ignore + (expect_named_contains "streaming progress output" stdout + "downloaded streaming block")); + + run_sync_progress_case "sync download survives event stream disconnect" + "logseq-cli-sync-progress-disconnect-" ~delay_download_response:true + (fun res -> + res_write_head res 200 + (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); + res_write res + ("data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ + \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u" + ^ sync_graph_id + ^ "\\\",\\\"~:message\\\",\\\"download started\\\"]]\"}\n\n"); + set_timeout (fun () -> res_destroy res) 20) + (fun _stdout -> ()); + + test_promise + "sync upload without password does not require stored e2ee password" + (fun () -> + let root = temp_dir "logseq-cli-sync-upload-no-password-" in + let invoke_calls = ref Vec.empty in + let server = + invoke_server (fun body -> + invoke_calls := Vec.push_front !invoke_calls body; + if Js.String.includes ~search:"thread-api/get-e2ee-password" body + then + fail_test ("sync upload unexpectedly requested password: " ^ body); + if Js.String.includes ~search:"thread-api/db-sync-upload-graph" body + then "{\"~:graph-id\":\"graph-1\"}" + else "null") + in + with_server server (fun base_url -> + let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in + let* result = + run_cli_p ~env + [| + "--root-dir"; + root; + "--graph"; + "alpha"; + "--output"; + "json"; + "sync"; + "upload"; + |] + in + remove_tree root; + ignore (expect_cli_exit_zero "sync upload no password" result); + if + Vec.exists + (Js.String.includes ~search:"thread-api/db-sync-upload-graph") + !invoke_calls + then Js.Promise.resolve pass + else fail_promise ("missing db-sync upload call\n" ^ result.stdout))); + + test_promise + "sync upload with password initializes e2ee keys before verifying password" + (fun () -> + let root = temp_dir "logseq-cli-sync-upload-password-keys-" in + let config_path = Node.Path.join [| root; "cli.edn" |] in + let invoke_calls = ref Vec.empty in + let method_names = ref Vec.empty in + let record_method body method_name = + if Js.String.includes ~search:method_name body then + method_names := Vec.push_back !method_names method_name + in + let server = + invoke_server (fun body -> + invoke_calls := Vec.push_front !invoke_calls body; + record_method body "thread-api/db-sync-ensure-user-rsa-keys"; + record_method body "thread-api/verify-and-save-e2ee-password"; + record_method body "thread-api/db-sync-upload-graph"; + if Js.String.includes ~search:"thread-api/db-sync-upload-graph" body + then "{\"~:graph-id\":\"graph-1\"}" + else "null") + in + with_server server (fun base_url -> + write_file config_path + "{:refresh-token \"refresh-token\"\n :id-token \"id-token\"}\n"; + let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in + let* result = + run_cli_p ~env + [| + "--root-dir"; + root; + "--graph"; + "alpha"; + "--output"; + "json"; + "sync"; + "upload"; + "--e2ee-password"; + "pw"; + |] + in + remove_tree root; + ignore (expect_cli_exit_zero "sync upload password" result); + if + Vec.equal String.equal !method_names + (Vec.of_array + [| + "thread-api/db-sync-ensure-user-rsa-keys"; + "thread-api/verify-and-save-e2ee-password"; + "thread-api/db-sync-upload-graph"; + |]) + then + if + Vec.exists + (fun body -> + Js.String.includes + ~search:"thread-api/db-sync-ensure-user-rsa-keys" body + && Js.String.includes ~search:"ensure-server" body + && Js.String.includes ~search:"pw" body) + !invoke_calls + then Js.Promise.resolve pass + else fail_promise "missing ensure-keys password options" + else + fail_promise + ("unexpected sync upload method order: " + ^ Vec.string_concat " -> " !method_names))); + + test_promise + "sync ensure-keys with password verifies password before ensuring local \ + keys" (fun () -> + let root = temp_dir "logseq-cli-sync-ensure-keys-password-" in + let config_path = Node.Path.join [| root; "cli.edn" |] in + let method_names = ref Vec.empty in + let record_method body method_name = + if Js.String.includes ~search:method_name body then + method_names := Vec.push_back !method_names method_name + in + let server = + invoke_server (fun body -> + record_method body "thread-api/verify-and-save-e2ee-password"; + record_method body "thread-api/db-sync-ensure-user-rsa-keys"; + "null") + in + with_server server (fun base_url -> + write_file config_path + "{:refresh-token \"refresh-token\"\n :id-token \"id-token\"}\n"; + let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in + let* result = + run_cli_p ~env + [| + "--root-dir"; + root; + "--output"; + "json"; + "sync"; + "ensure-keys"; + "--e2ee-password"; + "pw"; + |] + in + remove_tree root; + ignore (expect_cli_exit_zero "sync ensure-keys password" result); + if + Vec.equal String.equal !method_names + (Vec.of_array + [| + "thread-api/verify-and-save-e2ee-password"; + "thread-api/db-sync-ensure-user-rsa-keys"; + |]) + then Js.Promise.resolve pass + else + fail_promise + ("unexpected sync ensure-keys method order: " + ^ Vec.string_concat " -> " !method_names))); + + test_promise "sync ensure-keys resolves auth file before uploading keys" + (fun () -> + let root = temp_dir "logseq-cli-sync-ensure-keys-auth-" in + let auth_path = Node.Path.join [| root; "auth.json" |] in + let config_path = Node.Path.join [| root; "cli.edn" |] in + let invoke_calls = ref Vec.empty in + let server = + invoke_server (fun body -> + invoke_calls := Vec.push_front !invoke_calls body; + "null") + in + with_server server (fun base_url -> + write_file auth_path + "{\"provider\":\"cognito\",\"id-token\":\"id-token\",\"access-token\":\"access-token\",\"refresh-token\":\"refresh-token\",\"expires-at\":4102444800000}\n"; + write_file config_path (Printf.sprintf "{:auth-path %S}\n" auth_path); + let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in + let* result = + run_cli_p ~env + [| + "--root-dir"; + root; + "--output"; + "json"; + "sync"; + "ensure-keys"; + "--e2ee-password"; + "pw"; + "--upload-keys"; + |] + in + remove_tree root; + ignore (expect_cli_exit_zero "sync ensure-keys auth" result); + if + Vec.exists + (fun body -> + Js.String.includes ~search:"thread-api/sync-app-state" body + && Js.String.includes ~search:"auth/id-token" body + && Js.String.includes ~search:"id-token" body) + !invoke_calls + then Js.Promise.resolve pass + else fail_promise "missing sync-app-state auth payload")); + + test_promise "agent bridge keeps codex alive and forwards worker env" + (fun () -> + let tmp = temp_dir "logseq-cli-agent-" in + let config_path = Node.Path.join [| tmp; "cli.edn" |] in + let codex_bin = Node.Path.join [| tmp; "fake-codex" |] in + let marker_path = Node.Path.join [| tmp; "codex-marker.txt" |] in + let worker_script = "/tmp/logseq-cli-test-db-worker-node.js" in + let agent_name = "agent-a" ^ control_char ^ "lock" in + let fake_codex_js = + Node.Path.join [| current_dirname; "fake_codex.js" |] + in + write_file codex_bin + ("#!/bin/sh\nexec " ^ Node.Process.argv.(0) ^ " " ^ fake_codex_js + ^ " \"$@\"\n"); + chmod_sync codex_bin 0o755; + write_file config_path + (edn_map + [ + (edn_keyword "agent-name", edn_string agent_name); + (edn_keyword "codex-bin", edn_string codex_bin); + ]); + let registry_page = + "{\"~:db/id\":100,\"~:block/uuid\":\"~uaaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"~:block/name\":\"agentbridge\",\"~:block/title\":\"AgentBridge\"}" + in + let agent_page = + "{\"~:db/id\":101,\"~:block/uuid\":\"~ubbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\",\"~:block/name\":\"agent-a\",\"~:block/title\":\"agent-a\"}" + in + let master_block = + "{\"~:db/id\":110,\"~:block/uuid\":\"~ucccccccc-cccc-4ccc-8ccc-cccccccccccc\",\"~:block/title\":\"AgentBridge \ + master \ + prompt\",\"~:block/order\":1,\"~:block/_parent\":[{\"~:db/id\":111,\"~:block/title\":\"Graph \ + master \ + prompt\",\"~:block/order\":1,\"~:block/tags\":[{\"~:db/ident\":\"~:logseq.class/Code-block\"}]}]}" + in + let responses = + [| + registry_page; + agent_page; + agent_page; + master_block; + registry_page; + ""; + ""; + |] + in + let post_index = ref 0 in + let event_response = ref None in + let server = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + if req_method req = "GET" && req_url req = "/v1/events" then ( + res_write_head res 200 + (Js.Dict.fromArray + [| ("Content-Type", "text/event-stream") |]); + res_write res "\n"; + event_response := Some res) + else if req_method req <> "POST" || req_url req <> "/v1/invoke" + then + write_json res 400 + (error_response + ("unexpected " ^ req_method req ^ " " ^ req_url req)) + else if !post_index >= Array.length responses then + write_json res 400 + (error_response ("unexpected extra request: " ^ !body)) + else + let transit = responses.(!post_index) in + incr post_index; + let result_transit = + if transit = "" then "[]" else "[" ^ transit ^ "]" + in + write_json res 200 (json_response result_transit))) + in + with_server server (fun base_url -> + Js.Promise.make (fun ~resolve ~reject -> + let child = + spawn_cli_async + ~env: + [| + ("LOGSEQ_CLI_BASE_URL", base_url); + ("LOGSEQ_DB_WORKER_NODE_SCRIPT", worker_script); + ("AGENT_BRIDGE_TEST_MARKER", marker_path); + |] + [| + "--root-dir"; + tmp; + "--config"; + config_path; + "--graph"; + "alpha"; + "agent"; + "bridge"; + |] + in + let stdout = ref "" in + let stderr = ref "" in + let finished = ref false in + stream_set_encoding child##stdout "utf8"; + stream_set_encoding child##stderr "utf8"; + stream_on_data child##stdout "data" (fun[@u] chunk -> + stdout := !stdout ^ chunk); + stream_on_data child##stderr "data" (fun[@u] chunk -> + stderr := !stderr ^ chunk); + child_on_error child "error" (fun[@u] exn -> (reject exn [@u])); + let cleanup_and_resolve value = + if not !finished then ( + finished := true; + child_kill child "SIGTERM"; + Option.iter (fun res -> res_end res "") !event_response; + remove_tree tmp; + resolve value [@u]) + in + let cleanup_and_fail message = + if not !finished then ( + finished := true; + child_kill child "SIGTERM"; + Option.iter (fun res -> res_end res "") !event_response; + remove_tree tmp; + reject_with_assertion reject message) + in + let remaining_attempts = ref 50 in + let interval = ref None in + let tick () = + if Node.Fs.existsSync marker_path then ( + Option.iter clear_interval !interval; + let marker = read_file marker_path in + let owner_path = + Node.Path.join + [| + tmp; + "agent-bridge-locks"; + encode_uri_component "alpha" + ^ "--" + ^ encode_uri_component agent_name + ^ ".lock"; + "owner.edn"; + |] + in + if marker <> worker_script then + cleanup_and_fail + ("worker script env was not forwarded: " + ^ Js.Json.stringify (Js.Json.string marker)) + else if not (Node.Fs.existsSync owner_path) then + cleanup_and_fail + ("missing agent bridge owner: " ^ owner_path) + else ( + ignore + (expect_valid_edn "agent bridge owner" + (read_file owner_path)); + if + not + (Js.String.includes + ~search: + "Codex master session started: cli-live-session" + !stdout) + then + cleanup_and_fail + (Printf.sprintf + "master session log missing:\n\ + stdout:\n\ + %s\n\ + stderr:\n\ + %s" + !stdout !stderr) + else cleanup_and_resolve pass)) + else ( + decr remaining_attempts; + if !remaining_attempts <= 0 then ( + Option.iter clear_interval !interval; + cleanup_and_fail + (Printf.sprintf + "fake codex did not stay alive long enough to write \ + marker\n\ + stdout:\n\ + %s\n\ + stderr:\n\ + %s" + !stdout !stderr))) + in + interval := Some (set_interval (fun () -> tick ()) 100); + child_on_exit child "exit" (fun[@u] code -> + if (not !finished) && not (Node.Fs.existsSync marker_path) + then ( + Option.iter clear_interval !interval; + cleanup_and_fail + (Printf.sprintf + "agent bridge exited before fake codex marker; code=%d\n\ + stdout:\n\ + %s\n\ + stderr:\n\ + %s" + code !stdout !stderr)))))); + + test_promise "fetch timeout exits non-zero with timeout message" (fun () -> + Js.Promise.make (fun ~resolve ~reject -> + let request_count = ref 0 in + let server = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + incr request_count; + if + not + (Js.String.includes ~search:"thread-api/cli-list-pages" + !body) + then + write_json res 400 (error_response "unexpected request") + else + set_timeout + (fun () -> write_json res 200 (json_response "[]")) + 500)) + in + server_listen server 0 "127.0.0.1" (fun[@u] () -> + let port = (server_address server)##port in + let child = + spawn_cli_async + ~env: + [| + ( "LOGSEQ_CLI_BASE_URL", + Printf.sprintf "http://127.0.0.1:%d" port ); + |] + [| + "--timeout-ms"; + "100"; + "--graph"; + "alpha"; + "--output"; + "json"; + "list"; + "page"; + |] + in + let stdout = ref "" in + let stderr = ref "" in + stream_set_encoding child##stdout "utf8"; + stream_set_encoding child##stderr "utf8"; + stream_on_data child##stdout "data" (fun[@u] chunk -> + stdout := !stdout ^ chunk); + stream_on_data child##stderr "data" (fun[@u] chunk -> + stderr := !stderr ^ chunk); + child_on_error child "error" (fun[@u] exn -> (reject exn [@u])); + child_on_exit child "exit" (fun[@u] code -> + server_close server (fun[@u] () -> + if !request_count <> 1 then + reject_with_assertion reject + (Printf.sprintf "expected one request, got %d\n%s\n%s" + !request_count !stdout !stderr) + else if code = 0 then + reject_with_assertion reject + ("expected CLI list page to time out\n" ^ !stdout) + else if + String.contains !stdout 'r' + && Js.String.includes ~search:"request timeout" !stdout + || String.contains !stderr 'r' + && Js.String.includes ~search:"request timeout" + !stderr + then resolve pass [@u] + else + reject_with_assertion reject + (Printf.sprintf "expected timeout message\n%s\n%s" + !stdout !stderr)))))) + +let registered = () diff --git a/cli/test/test_support.ml b/cli/test/test_support.ml new file mode 100644 index 0000000..ab1c755 --- /dev/null +++ b/cli/test/test_support.ml @@ -0,0 +1,544 @@ +external tmpdir : unit -> string = "tmpdir" [@@mel.module "os"] + +external mkdir_sync : string -> Js.Json.t Js.Dict.t -> unit = "mkdirSync" +[@@mel.module "fs"] + +external mkdtemp_sync : string -> string = "mkdtempSync" [@@mel.module "fs"] + +external rm_sync : string -> Js.Json.t Js.Dict.t -> unit = "rmSync" +[@@mel.module "fs"] + +external chmod_sync : string -> int -> unit = "chmodSync" [@@mel.module "fs"] +external process_pid : int = "pid" [@@mel.module "process"] + +external promise_error_message : Js.Promise.error -> string option = "message" +[@@mel.get] [@@mel.return { undefined_to_opt }] + +let decode_uri_component = Js.Global.decodeURIComponent +let encode_uri_component = Js.Global.encodeURIComponent +let parse_json = Js.Json.parseExn +let set_timeout f ms = ignore (Js.Global.setTimeout ~f ms : Js.Global.timeoutId) + +type timer = Js.Global.intervalId + +let set_interval f ms = Js.Global.setInterval ~f ms +let clear_interval = Js.Global.clearInterval + +external current_dirname : string = "__dirname" + +type request +type response +type server +type stream +type child = < stdout : stream ; stderr : stream > Js.t +type cli_output = { code : int; stdout : string; stderr : string } + +external create_server : ((request -> response -> unit)[@u]) -> server + = "createServer" +[@@mel.module "http"] + +external req_method : request -> string = "method" [@@mel.get] +external req_url : request -> string = "url" [@@mel.get] + +external req_set_encoding : request -> string -> unit = "setEncoding" +[@@mel.send] + +external req_on_data : request -> string -> ((string -> unit)[@u]) -> unit + = "on" +[@@mel.send] + +external req_on_end : request -> string -> ((unit -> unit)[@u]) -> unit = "on" +[@@mel.send] + +external res_write_head : response -> int -> string Js.Dict.t -> unit + = "writeHead" +[@@mel.send] + +external res_write : response -> string -> unit = "write" [@@mel.send] +external res_end : response -> string -> unit = "end" [@@mel.send] +external res_destroy : response -> unit = "destroy" [@@mel.send] + +external server_listen : server -> int -> string -> ((unit -> unit)[@u]) -> unit + = "listen" +[@@mel.send] + +external server_address : server -> < port : int > Js.t = "address" [@@mel.send] + +external server_close : server -> ((unit -> unit)[@u]) -> unit = "close" +[@@mel.send] + +external spawn_async : + string -> string array -> < env : string Js.Dict.t > Js.t -> child = "spawn" +[@@mel.module "child_process"] + +external stream_set_encoding : stream -> string -> unit = "setEncoding" +[@@mel.send] + +external stream_on_data : stream -> string -> ((string -> unit)[@u]) -> unit + = "on" +[@@mel.send] + +external child_on_exit : child -> string -> ((int -> unit)[@u]) -> unit = "on" +[@@mel.send] + +external child_on_close : child -> string -> ((int -> unit)[@u]) -> unit = "on" +[@@mel.send] + +external child_on_error : child -> string -> ((exn -> unit)[@u]) -> unit = "on" +[@@mel.send] + +external child_kill : child -> string -> unit = "kill" [@@mel.send] +external string_split : string -> string -> string array = "split" [@@mel.send] +external string_trim_end : string -> string = "trimEnd" [@@mel.send] + +external string_match : string -> Js.Re.t -> < index : int > Js.t Js.null + = "match" +[@@mel.send] + +external chars_of_string : string -> string array = "from" [@@mel.scope "Array"] + +type spawn_options = < encoding : string ; env : string Js.Dict.t > Js.t + +type spawn_result = + < status : int Js.null ; stdout : string ; stderr : string > Js.t + +external spawn_sync : string -> string array -> spawn_options -> spawn_result + = "spawnSync" +[@@mel.module "child_process"] + +let test = Fest.test +let test_promise = Fest.Promise.test +let ( let* ) = Fest.Promise.( let* ) +let pass = () +let fail_test message = Fest.expect |> Fest.equal message "" + +let sleep_ms ms = + Js.Promise.make (fun ~resolve ~reject:_ -> + set_timeout + (fun () -> + let value = () in + (resolve value [@u])) + ms) + +let reject_with_assertion reject message = + try fail_test message with exn -> reject exn [@u] + +let fail_promise message = + Js.Promise.make (fun ~resolve:_ ~reject -> + reject_with_assertion reject message) + +let assert_true name condition message = + ignore name; + if condition then pass else fail_test message + +let assert_false name condition message = + assert_true name (not condition) message + +let entrypoint = + match + Js.Dict.get Node.Process.process##env "LOGSEQ_CLI_MELANGE_ENTRYPOINT" + with + | Some value -> value + | None -> + fail_test "missing LOGSEQ_CLI_MELANGE_ENTRYPOINT"; + "" + +let run_cli args = + let command = + Array.append [| "node"; entrypoint |] args + |> Vec.of_array |> Vec.string_concat " " + in + Node.Child_process.execSync command + (Node.Child_process.option ~encoding:"utf8" ()) + +let clone_env extra = + let env = Js.Dict.fromArray (Js.Dict.entries Node.Process.process##env) in + Array.iter (fun (key, value) -> Js.Dict.set env key value) extra; + env + +let spawn_cli ?(env = [||]) args = + spawn_sync Node.Process.argv.(0) + (Array.append [| entrypoint |] args) + [%obj { encoding = "utf8"; env = clone_env env }] + +let spawn_cli_async ?(env = [||]) args = + spawn_async Node.Process.argv.(0) + (Array.append [| entrypoint |] args) + [%obj { env = clone_env env }] + +let result_status result = + match Js.Null.toOption result##status with + | Some status -> status + | None -> -1 + +let assert_includes name text needle = + assert_true name + (Js.String.includes ~search:needle text) + (Printf.sprintf "%s: expected output to contain %S\n%s" name needle text) + +let assert_not_includes name text needle = + assert_false name + (Js.String.includes ~search:needle text) + (Printf.sprintf "%s: expected output not to contain %S\n%s" name needle text) + +let assert_exit_zero name result = + let status = result_status result in + assert_true name (status = 0) + (Printf.sprintf "%s: expected exit 0, got %d\nstdout:\n%s\nstderr:\n%s" name + status result##stdout result##stderr) + +let assert_exit_non_zero name result = + let status = result_status result in + assert_true name (status <> 0) + (Printf.sprintf + "%s: expected non-zero exit, got 0\nstdout:\n%s\nstderr:\n%s" name + result##stdout result##stderr) + +let temp_dir prefix = mkdtemp_sync (Node.Path.join [| tmpdir (); prefix |]) +let control_char = String.make 1 (Char.chr 1) + +let expect_valid_json name text = + try + ignore (parse_json text); + pass + with exn -> + fail_test + (Printf.sprintf "%s: expected valid JSON, got %s\n%s" name + (Printexc.to_string exn) text) + +let expect_valid_edn name text = + try + ignore (Melange_edn_melange.of_edn_string text); + pass + with exn -> + fail_test + (Printf.sprintf "%s: expected valid EDN, got %s\n%s" name + (Printexc.to_string exn) text) + +let edn_any value = Melange_edn_melange.any value +let edn_keyword value = edn_any (Melange_edn_melange.keyword value) +let edn_string value = edn_any (Melange_edn_melange.string value) + +let edn_map fields = + Melange_edn_melange.map fields |> edn_any |> Melange_edn_melange.to_edn_string + +let remove_tree path = + rm_sync path + (Js.Dict.fromArray + [| + ("recursive", Js.Json.boolean true); ("force", Js.Json.boolean true); + |]) + +let write_file path text = Node.Fs.writeFileAsUtf8Sync path text +let read_file path = Node.Fs.readFileAsUtf8Sync path + +let mkdir_p path = + mkdir_sync path (Js.Dict.fromArray [| ("recursive", Js.Json.boolean true) |]) + +let json_response result_transit = + "{\"resultTransit\":" + ^ Js.Json.stringify (Js.Json.string result_transit) + ^ "}" + +let error_response message = + "{\"error\":{\"message\":" ^ Js.Json.stringify (Js.Json.string message) ^ "}}" + +let write_json res status body = + res_write_head res status + (Js.Dict.fromArray [| ("Content-Type", "application/json") |]); + res_end res body + +let base64url text = + Node.Buffer.fromString text |> Node.Buffer.toString ~encoding:`base64url + +let jwt_token payload = + base64url "{\"alg\":\"none\",\"typ\":\"JWT\"}" + ^ "." ^ base64url payload ^ ".signature" + +let id_token ?(sub = "user-1") ?(email = "user@example.com") () = + jwt_token + (Printf.sprintf "{\"sub\":%s,\"email\":%s,\"exp\":4102444800}" + (Js.Json.stringify (Js.Json.string sub)) + (Js.Json.stringify (Js.Json.string email))) + +let token_response ?(sub = "user-1") ?(email = "user@example.com") () = + let object_ = Js.Dict.empty () in + Js.Dict.set object_ "id_token" (Js.Json.string (id_token ~sub ~email ())); + Js.Dict.set object_ "access_token" (Js.Json.string "access-token"); + Js.Dict.set object_ "refresh_token" (Js.Json.string "refresh-token"); + Js.Json.stringify (Js.Json.object_ object_) + +let query_param url key = + let parts = Vec.split_on_char '?' url in + if Vec.length parts = 2 then + let query = Vec.nth parts 1 in + query |> Vec.split_on_char '&' + |> Vec.find_map (fun part -> + let pair = Vec.split_on_char '=' part in + if Vec.length pair = 2 && Vec.nth pair 0 = key then + Some (Vec.nth pair 1) + else None) + else None + +let json_data_object stdout = + match Js.Json.decodeObject (parse_json stdout) with + | None -> + fail_test ("expected json object: " ^ stdout); + Js.Dict.empty () + | Some root -> ( + match Option.bind (Js.Dict.get root "data") Js.Json.decodeObject with + | None -> + fail_test ("expected data object: " ^ stdout); + Js.Dict.empty () + | Some data -> data) + +let json_data_string stdout key = + match + Option.bind (Js.Dict.get (json_data_object stdout) key) Js.Json.decodeString + with + | Some value -> value + | None -> + fail_test ("expected string data field " ^ key ^ ": " ^ stdout); + "" + +let json_data_bool stdout key = + match + Option.bind + (Js.Dict.get (json_data_object stdout) key) + Js.Json.decodeBoolean + with + | Some value -> value + | None -> + fail_test ("expected boolean data field " ^ key ^ ": " ^ stdout); + false + +let run_cli_p ?(env = [||]) args = + Js.Promise.make (fun ~resolve ~reject -> + let child = spawn_cli_async ~env args in + let stdout = ref "" in + let stderr = ref "" in + stream_set_encoding child##stdout "utf8"; + stream_set_encoding child##stderr "utf8"; + stream_on_data child##stdout "data" (fun[@u] chunk -> + stdout := !stdout ^ chunk); + stream_on_data child##stderr "data" (fun[@u] chunk -> + stderr := !stderr ^ chunk); + child_on_error child "error" (fun[@u] exn -> (reject exn [@u])); + child_on_close child "close" (fun[@u] code -> + (resolve { code; stdout = !stdout; stderr = !stderr } [@u]))) + +let invoke_server response_for_body = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + if req_method req <> "POST" || req_url req <> "/v1/invoke" then + write_json res 404 (error_response "not found") + else + try write_json res 200 (json_response (response_for_body !body)) + with exn -> + write_json res 400 (error_response (Printexc.to_string exn)))) + +let oauth_server () = + create_server (fun[@u] req res -> + let body = ref "" in + req_set_encoding req "utf8"; + req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk); + req_on_end req "end" (fun[@u] () -> + ignore !body; + if req_method req <> "POST" || req_url req <> "/oauth2/token" then + write_json res 404 (error_response "not found") + else write_json res 200 (token_response ()))) + +let rec fetch_with_retry attempts url = + let request = + Fetch.fetch url + |> Js.Promise.then_ (fun response -> + let status = Fetch.Response.status response in + if status >= 200 && status <= 299 then Js.Promise.resolve () + else Js.Promise.reject (Failure ("HTTP " ^ string_of_int status))) + in + Js.Promise.catch + (fun error -> + if attempts <= 0 then + let message = + Option.value + (promise_error_message error) + ~default:"callback request failed" + in + Js.Promise.reject (Failure message) + else + sleep_ms 50 + |> Js.Promise.then_ (fun () -> fetch_with_retry (attempts - 1) url)) + request + +let with_server server run = + Js.Promise.make (fun ~resolve ~reject -> + server_listen server 0 "127.0.0.1" (fun[@u] () -> + let port = (server_address server)##port in + let base_url = Printf.sprintf "http://127.0.0.1:%d" port in + let finish resolve_or_reject value = + server_close server (fun[@u] () -> (resolve_or_reject value [@u])) + in + match + try Some (run base_url) + with exn -> + server_close server (fun[@u] () -> (reject exn [@u])); + None + with + | None -> () + | Some promise -> + ignore + (Js.Promise.then_ + (fun result -> + finish resolve result; + Js.Promise.resolve ()) + promise); + ignore + (Js.Promise.catch + (fun error -> + let message = + Option.value + (promise_error_message error) + ~default:"JavaScript promise rejected" + in + server_close server (fun[@u] () -> + reject_with_assertion reject message); + Js.Promise.resolve pass) + promise))) + +let assert_cli_exit_zero name output = + assert_true name (output.code = 0) + (Printf.sprintf "%s: expected exit 0, got %d\nstdout:\n%s\nstderr:\n%s" name + output.code output.stdout output.stderr) + +let assert_line_starts_with name text prefix = + let lines = string_split (string_trim_end text) "\n" in + assert_true name + (Array.exists (fun line -> Js.String.startsWith ~prefix line) lines) + (Printf.sprintf "%s: expected a line starting with %S\n%s" name prefix text) + +let headers_from stdout = + let lines = string_split (String.trim stdout) "\n" in + if Array.length lines = 0 then [||] + else + let first = lines.(0) in + string_split (String.trim first) " " + |> Vec.of_array + |> Vec.filter (( <> ) "") + |> Vec.to_array + +let array_prefix array length = Array.sub array 0 length + +let array_suffix array length = + Array.sub array (Array.length array - length) length + +let string_array_equal left right = + Array.length left = Array.length right && Array.for_all2 ( = ) left right + +let char_width code = + if + code = 0 || code < 32 + || (code >= 0x7f && code < 0xa0) + || (code >= 0x0300 && code <= 0x036f) + || (code >= 0x1ab0 && code <= 0x1aff) + || (code >= 0x1dc0 && code <= 0x1dff) + || (code >= 0x20d0 && code <= 0x20ff) + || (code >= 0xfe20 && code <= 0xfe2f) + then 0 + else if + code >= 0x1100 + && (code <= 0x115f || code = 0x2329 || code = 0x232a + || (code >= 0x2e80 && code <= 0xa4cf && code <> 0x303f) + || (code >= 0xac00 && code <= 0xd7a3) + || (code >= 0xf900 && code <= 0xfaff) + || (code >= 0xfe10 && code <= 0xfe19) + || (code >= 0xfe30 && code <= 0xfe6f) + || (code >= 0xff00 && code <= 0xff60) + || (code >= 0xffe0 && code <= 0xffe6)) + then 2 + else 1 + +let display_width text = + chars_of_string text + |> Array.fold_left + (fun width ch -> + match Js.String.codePointAt ~index:0 ch with + | Some code -> width + char_width code + | None -> width) + 0 + +let assert_created_at_column_aligned stdout = + let lines = string_split (string_trim_end stdout) "\n" in + match Vec.pop_front (Rrbvec.of_array lines) with + | None -> fail_test "missing list page output" + | Some (header, rows) -> + let created_index = + match Js.String.indexOf ~search:"created-at" header with + | -1 -> + fail_test ("missing created-at header:\n" ^ stdout); + 0 + | index -> index + in + let expected = display_width (String.sub header 0 created_index) in + let data_rows = + match Vec.pop_back rows with + | Some (_footer, data_rows) -> data_rows + | None -> Vec.empty + in + data_rows + |> Vec.iter (fun line -> + match + Js.Null.toOption + (string_match line (Js.Re.fromString "\\d+ [A-Za-z]+ ago")) + with + | None -> + fail_test + ("missing relative created-at cell in line: " ^ line ^ "\n" + ^ stdout) + | Some match_ -> + let actual = display_width (String.sub line 0 match_##index) in + if actual <> expected then + fail_test + (Printf.sprintf + "created-at column mismatch: expected %d, got %d\n%s" + expected actual stdout)); + pass + +let extract_snapshot_path body = + let marker = "logseq_db_alpha\\\",\\\"" in + let start = Js.String.indexOf ~search:marker body in + if start < 0 then fail_test ("missing snapshot path marker in " ^ body); + let value_start = start + String.length marker in + let end_ = Js.String.indexOf ~search:"\\\"" ~start:value_start body in + if end_ < 0 then fail_test ("unterminated snapshot path in " ^ body); + Js.String.slice ~start:value_start ~end_ body + +let expect_graph_in_json_list output graph = + match Js.Json.decodeObject (parse_json output) with + | None -> fail_test ("json graph list: expected object in " ^ output) + | Some root -> ( + match Option.bind (Js.Dict.get root "data") Js.Json.decodeObject with + | None -> fail_test ("json graph list: expected data object in " ^ output) + | Some data -> ( + match Option.bind (Js.Dict.get data "graphs") Js.Json.decodeArray with + | None -> + fail_test ("json graph list: expected graphs array in " ^ output) + | Some graphs -> + let names = + graphs |> Vec.of_array |> Vec.filter_map Js.Json.decodeString + in + if Vec.mem graph names then pass + else fail_test ("json graph list: missing graph in " ^ output))) + +let expect_named_contains _name text needle = assert_includes _name text needle + +let expect_named_not_contains _name text needle = + assert_not_includes _name text needle + +let expect_exit_zero = assert_exit_zero +let expect_exit_non_zero = assert_exit_non_zero +let expect_cli_exit_zero = assert_cli_exit_zero +let expect_line_starts_with = assert_line_starts_with +let expect_created_at_column_aligned = assert_created_at_column_aligned diff --git a/cli/vite.config.mjs b/cli/vite.config.mjs new file mode 100644 index 0000000..c47caec --- /dev/null +++ b/cli/vite.config.mjs @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { builtinModules } from "node:module"; +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +const entry = resolve( + process.cwd(), + "bin/logseq_cli_melange/bin/main.js", +); + +const nodeBuiltins = [ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`), +]; + +const gitCwd = process.env.DUNE_SOURCEROOT ?? process.cwd(); + +function gitOutput(args) { + try { + return execFileSync("git", args, { + cwd: gitCwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +function gitRevision() { + const commit = gitOutput(["rev-parse", "--short", "HEAD"]); + if (!commit) { + return "dev"; + } + + const clean = gitOutput(["diff-index", "--quiet", "HEAD", "--"]) === ""; + return `${commit}${clean ? "" : "-dirty"}`; +} + +const buildTime = process.env.LOGSEQ_BUILD_TIME ?? new Date().toISOString(); +const revision = process.env.LOGSEQ_REVISION ?? gitRevision(); + +export default defineConfig({ + define: { + LOGSEQ_CLI_BUILD_TIME: JSON.stringify(buildTime), + LOGSEQ_CLI_REVISION: JSON.stringify(revision), + }, + build: { + lib: { + entry, + formats: ["cjs"], + fileName: () => "logseq-cli.js", + }, + emptyOutDir: false, + minify: true, + sourcemap: false, + target: "node22", + rollupOptions: { + external: nodeBuiltins, + output: { + exports: "auto", + codeSplitting: false, + }, + }, + commonjsOptions: { + include: [ + /\/bin\/logseq_cli_melange\//, + ], + }, + }, +}); diff --git a/clj-e2e/.clj-kondo/config.edn b/clj-e2e/.clj-kondo/config.edn new file mode 100644 index 0000000..e820c95 --- /dev/null +++ b/clj-e2e/.clj-kondo/config.edn @@ -0,0 +1,4 @@ +{:linters + {:redundant-let {:level :off} + :unresolved-var {:exclude [wally.main/click + wally.main/fill]}}} diff --git a/clj-e2e/.cljfmt.edn b/clj-e2e/.cljfmt.edn new file mode 100644 index 0000000..a32163e --- /dev/null +++ b/clj-e2e/.cljfmt.edn @@ -0,0 +1,4 @@ +{:extra-indents {missionary.core/sp [[:block 0]] + missionary.core/ap [[:block 0]] + frontend.common.missionary/run-task [[:inner 0]]} + :sort-ns-references? true} diff --git a/clj-e2e/.gitignore b/clj-e2e/.gitignore new file mode 100644 index 0000000..e684f09 --- /dev/null +++ b/clj-e2e/.gitignore @@ -0,0 +1,31 @@ +.calva/output-window/ +.calva/repl.calva-repl +.classpath +.clj-kondo/.cache +.cpcache +.DS_Store +.eastwood +.factorypath +.hg/ +.hgignore +.java-version +.lein-* +.lsp/.cache +.lsp/sqlite.db +.nrepl-history +.nrepl-port +.portal +.project +.rebel_readline_history +.settings +.socket-repl-port +.sw* +.vscode +*.class +*.jar +*.swp +*~ +/checkouts +/classes +/target +.wally/ diff --git a/clj-e2e/.lsp/config.edn b/clj-e2e/.lsp/config.edn new file mode 100644 index 0000000..4fbd688 --- /dev/null +++ b/clj-e2e/.lsp/config.edn @@ -0,0 +1,2 @@ +{:source-aliases #{:test} + :clean {:ns-inner-blocks-indentation :same-line}} diff --git a/clj-e2e/AGENTS.md b/clj-e2e/AGENTS.md new file mode 100644 index 0000000..2b4146d --- /dev/null +++ b/clj-e2e/AGENTS.md @@ -0,0 +1,23 @@ +# E2E test Guide + +- Source Layout (`src/logseq/e2e/`) + - `assert.clj`: Custom assertion functions + - `block.clj`: Block manipulation helpers + - `config.clj`: Configuration settings + - `const.clj`: Shared constants + - `graph.clj`: Graph management helpers + - `keyboard.clj`: Keyboard shortcut helpers + - `locator.clj`: Playwright locator utilities + - `page.clj`: Page operations helpers + - `playwright_page.clj`: Playwright page initialization + - `rtc.clj`: RTC testing helpers + - `settings.clj`: Logseq settings manipulation + - `util.clj`: General utilities +- Tests (`test/logseq/e2e/`) + - `fixtures.clj`: Test fixtures + - Basic Tests: `*_basic_test.clj` + - RTC Tests: `rtc_*_test.clj` +- Run tests commands (in `clj-e2e` directory) + - `clojure -X:dev-run-rtc-extra-test` + - `clojure -X:dev-run-rtc-extra-part2-test` + - `clojure -X:dev-run-all-basic-test` diff --git a/clj-e2e/README.md b/clj-e2e/README.md new file mode 100644 index 0000000..b9bd0b6 --- /dev/null +++ b/clj-e2e/README.md @@ -0,0 +1,74 @@ +# e2e + +e2e tests for Logseq app. + +## Prerequisites + +* The app's JS and CSS assets are built and located at `../static/`. +* [Babashka](https://github.com/babashka/babashka) is installed. + +## Setup + +Serve the static assets (default port 3002): + + $ bb serve + +To use a custom port: + + $ bb serve --port 3001 + $ bb serve -p 3001 + +If `pnpm watch` is already running, skip `bb serve`: shadow-cljs's dev-http +already serves the desktop app from `../static/` on port 3001, so run tests +with `bb test -p 3001 ...`. + +## Running Tests + +Run all tests (namespaces ending in `-basic-test`): + + $ bb test + +Run a single test namespace: + + $ bb test -n logseq.e2e.editor-basic-test + +Run a single test function: + + $ bb test -v logseq.e2e.editor-basic-test/toggle-between-page-and-block + +Filter by metadata tag (e.g. `^:focus`): + + $ bb test -i focus + +Combine namespace and tag filters: + + $ bb test -n logseq.e2e.editor-basic-test -i focus + +Run tests against a custom port: + + $ bb test -p 3001 + +Run tests and serve together (starts both in parallel): + + $ bb dev + $ bb dev -p 3001 + +## How `-i` Works + +The `-i`/`--include` flag is a [cognitect test-runner](https://github.com/cognitect-labs/test-runner) option. It filters tests by Clojure metadata key. Add `^:focus` to any `deftest` and pass `-i focus` to run only those tests: + +```clojure +(deftest ^:focus my-test + ...) +``` + +## RTC Tests + +Run RTC extra tests (served + tested in parallel): + + $ bb run-rtc-extra-test + $ bb run-rtc-extra-part2-test + +## Debugging + +When tests fail, `clj-e2e/e2e-dump/` contains console logs and screenshots. diff --git a/clj-e2e/bb.edn b/clj-e2e/bb.edn new file mode 100644 index 0000000..959d8b5 --- /dev/null +++ b/clj-e2e/bb.edn @@ -0,0 +1,52 @@ +{:deps {org.babashka/http-server {:mvn/version "0.1.14"} + org.babashka/cli {:mvn/version "0.8.67"}} + :tasks + {:requires ([babashka.cli :as cli]) + :init (do + (def cli-opts (cli/parse-opts *command-line-args* {:alias {:p :port :n :namespace :i :include :v :var} + :coerce {:port :int :headers :edn}})) + (defn clojure-with-port + [cmd] + (if-let [port (:port cli-opts)] + (str "-J-Dport=" port " " cmd) + cmd))) + + serve {:doc "Serve static assets" + :requires ([babashka.http-server :as server]) + :task (server/exec (merge {:port 3002 + :dir "../static/"} + cli-opts))} + + prn {:task (clojure "-X clojure.core/prn" cli-opts)} + + test {:doc "run tests (ns'es ending in '-basic-test'); use -n to run a single namespace, -v to run a single test fn, -i to filter by metadata" + :task (do (let [ns (:namespace cli-opts) + include (:include cli-opts) + var (:var cli-opts) + ns-arg (when ns (str " -n " ns)) + inc-arg (when include (str " -i " include)) + var-arg (when var (str " -v " var)) + base (if ns + (str "-M:test" ns-arg " -e fix-me") + "-M:test -r \".*\\-basic\\-test$\" -e fix-me")] + (clojure (clojure-with-port (str base inc-arg var-arg)))) + (System/exit 0))} + + rtc-extra-test {:doc "run rtc-extra-test" + :task (do (clojure (clojure-with-port "-M:test -n logseq.e2e.rtc-extra-test")) + (System/exit 0))} + + rtc-extra-part2-test {:doc "run rtc-extra-part2-test" + :task (do (clojure (clojure-with-port "-M:test -n logseq.e2e.rtc-extra-part2-test")) + (System/exit 0))} + + -run-rtc-extra-test {:depends [serve prn rtc-extra-test]} + run-rtc-extra-test {:task (run '-run-rtc-extra-test {:parallel true})} + + -run-rtc-extra-part2-test {:depends [serve prn rtc-extra-part2-test]} + run-rtc-extra-part2-test {:task (run '-run-rtc-extra-part2-test {:parallel true})} + + -dev {:depends [serve prn test]} + + dev {:doc "serve and test" + :task (run '-dev {:parallel true})}}} diff --git a/clj-e2e/build.clj b/clj-e2e/build.clj new file mode 100644 index 0000000..2bb74d8 --- /dev/null +++ b/clj-e2e/build.clj @@ -0,0 +1,18 @@ +(ns build + (:refer-clojure :exclude [test]) + (:require [clojure.tools.build.api :as b] + [clojure.tools.deps :as t])) + +(defn test "Run all the tests." + [_opts] + (println "\nRunning tests...") + (let [basis (b/create-basis {:aliases [:test]}) + combined (t/combine-aliases basis [:test]) + cmds (b/java-command + {:basis basis + :java-opts (:jvm-opts combined) + :main 'clojure.main + :main-args ["-m" "cognitect.test-runner"]}) + {:keys [exit]} (b/process cmds)] + (when-not (zero? exit) + (throw (ex-info "Tests failed" {}))))) diff --git a/clj-e2e/deps.edn b/clj-e2e/deps.edn new file mode 100644 index 0000000..93e16f4 --- /dev/null +++ b/clj-e2e/deps.edn @@ -0,0 +1,29 @@ +{:paths ["src" "resources"] + :deps {org.clojure/clojure {:mvn/version "1.12.4"} + ;; io.github.pfeodrippe/wally {:local/root "../../../wally"} + io.github.pfeodrippe/wally {:git/url "https://github.com/logseq/wally" + :sha "8571fae7c51400ac61c8b1026cbfba68279bc461" + :exclusions [com.microsoft.playwright/playwright]} + com.microsoft.playwright/playwright {:mvn/version "1.58.0"} + ;; io.github.zmedelis/bosquet {:mvn/version "2025.03.28"} + org.clj-commons/claypoole {:mvn/version "1.2.2"} + metosin/jsonista {:mvn/version "0.3.14"} + clj-time/clj-time {:mvn/version "0.15.2"}} + :aliases + {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.5"}} + :ns-default build} + :serve {:deps {org.babashka/http-server {:mvn/version "0.1.13"}} + :main-opts ["-m" "babashka.http-server"] + :exec-fn babashka.http-server/exec} + :test {:extra-paths ["test"] + :main-opts ["-m" "cognitect.test-runner"] + :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"} + io.github.cognitect-labs/test-runner + {:git/tag "v0.5.1" :git/sha "dfb30dd"}}} + :dev {:extra-paths ["dev" "test"]} + :dev-run-rtc-extra-test {:extra-paths ["dev" "test"] + :exec-fn user/run-rtc-extra-test2} + :dev-run-rtc-extra-part2-test {:extra-paths ["dev" "test"] + :exec-fn user/run-rtc-extra-part2-test2} + :dev-run-all-basic-test {:extra-paths ["dev" "test"] + :exec-fn user/run-all-basic-test}}} diff --git a/clj-e2e/dev/user.clj b/clj-e2e/dev/user.clj new file mode 100644 index 0000000..5285f25 --- /dev/null +++ b/clj-e2e/dev/user.clj @@ -0,0 +1,191 @@ +(ns user + "fns used on repl" + (:require [clojure.test :refer [run-tests run-test]] + [logseq.e2e.assets-basic-test] + [logseq.e2e.bidirectional-properties-test] + [logseq.e2e.block-property-basic-test] + [logseq.e2e.block :as b] + [logseq.e2e.cmdk-scroll-basic-test] + [logseq.e2e.commands-basic-test] + [logseq.e2e.config :as config] + [logseq.e2e.editor-basic-test] + [logseq.e2e.export-basic-test] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.flashcards-basic-test] + [logseq.e2e.graph :as graph] + [logseq.e2e.graph-navigation-basic-test] + [logseq.e2e.import-basic-test] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.multi-tabs-basic-test] + [logseq.e2e.outliner-basic-test] + [logseq.e2e.plugins-basic-test] + [logseq.e2e.plugins-marketplace-test] + [logseq.e2e.property-basic-test] + [logseq.e2e.property-config-basic-test] + [logseq.e2e.property-scoped-choices-test] + [logseq.e2e.reference-basic-test] + [logseq.e2e.right-sidebar-basic-test] + [logseq.e2e.rtc-basic-test] + [logseq.e2e.rtc-extra-part2-test] + [logseq.e2e.rtc-extra-test] + [logseq.e2e.tag-basic-test] + [logseq.e2e.util :as util] + [logseq.e2e.view-basic-test] + [wally.main :as w] + [wally.repl :as repl])) + +;; Use port 3001 for local testing +(reset! config/*port 3001) +;; show ui +(reset! config/*headless false) +(reset! config/*slow-mo 30) + +(def *futures (atom {})) + +(defn cancel + [test-name] + (some-> (get @*futures test-name) future-cancel) + (swap! *futures dissoc test-name)) + +(defn run-commands-test + [] + (->> (future (run-tests 'logseq.e2e.commands-basic-test)) + (swap! *futures assoc :commands-test))) + +(defn run-property-basic-test + [] + (->> (future (run-tests 'logseq.e2e.property-basic-test)) + (swap! *futures assoc :property-test))) + +(defn run-flashcards-basic-test + [] + (->> (future (run-tests 'logseq.e2e.flashcards-basic-test)) + (swap! *futures assoc :flashcards-test))) + +(defn run-property-scoped-choices-test + [] + (->> (future (run-tests 'logseq.e2e.property-scoped-choices-test)) + (swap! *futures assoc :property-scoped-choices-test))) + +(defn run-bidirectional-properties-test + [] + (->> (future (run-tests 'logseq.e2e.bidirectional-properties-test)) + (swap! *futures assoc :bidirectional-properties-test))) + +(defn run-outliner-test + [] + (->> (future (run-tests 'logseq.e2e.outliner-basic-test)) + (swap! *futures assoc :outliner-test))) + +(defn run-rtc-basic-test + [] + (->> (future (run-tests 'logseq.e2e.rtc-basic-test)) + (swap! *futures assoc :rtc-basic-test))) + +(defn run-multi-tabs-test + [] + (->> (future (run-tests 'logseq.e2e.multi-tabs-basic-test)) + (swap! *futures assoc :multi-tabs-test))) + +(defn run-reference-test + [] + (->> (future (run-tests 'logseq.e2e.reference-basic-test)) + (swap! *futures assoc :reference-test))) + +(defn run-plugins-test + [] + (->> (future (run-tests 'logseq.e2e.plugins-basic-test)) + (swap! *futures assoc :plugins-test))) + +(defn run-plugins-marketplace-test + [] + (->> (future (run-tests 'logseq.e2e.plugins-marketplace-test)) + (swap! *futures assoc :plugins-marketplace-test))) + +(defn run-rtc-extra-test + [] + (->> (future (run-tests 'logseq.e2e.rtc-extra-test)) + (swap! *futures assoc :rtc-extra-test))) + +(defn run-rtc-extra-test2 + [& _args] + (run-tests 'logseq.e2e.rtc-extra-test) + (System/exit 0)) + +(defn run-rtc-extra-part2-test2 + [& _args] + (run-tests 'logseq.e2e.rtc-extra-part2-test) + (System/exit 0)) + +(defn run-editor-basic-test + [] + (->> (future (run-tests 'logseq.e2e.editor-basic-test)) + (swap! *futures assoc :editor-basic-test))) + +(defn run-tag-basic-test + [] + (->> (future (run-tests 'logseq.e2e.tag-basic-test)) + (swap! *futures assoc :tag-basic-test))) + +(defn run-all-basic-test + [& _] + (run-tests 'logseq.e2e.editor-basic-test + 'logseq.e2e.assets-basic-test + 'logseq.e2e.block-property-basic-test + 'logseq.e2e.cmdk-scroll-basic-test + 'logseq.e2e.commands-basic-test + 'logseq.e2e.export-basic-test + 'logseq.e2e.flashcards-basic-test + 'logseq.e2e.graph-navigation-basic-test + 'logseq.e2e.import-basic-test + 'logseq.e2e.multi-tabs-basic-test + 'logseq.e2e.outliner-basic-test + 'logseq.e2e.plugins-basic-test + 'logseq.e2e.property-basic-test + 'logseq.e2e.property-config-basic-test + 'logseq.e2e.reference-basic-test + 'logseq.e2e.right-sidebar-basic-test + 'logseq.e2e.rtc-basic-test + 'logseq.e2e.tag-basic-test + 'logseq.e2e.view-basic-test) + (System/exit 0)) + +(defn start + [] + (future + (fixtures/open-page + repl/pause + {:headless false}))) + +(comment + + ;; You can call or put `(repl/pause)` in any test to pause the tests, + ;; this allows us to continue experimenting with the current page. + (repl/pause) + + ;; To resume the tests, close the page/context/browser + (repl/resume) + + ;; Run specific test + (future (run-test logseq.e2e.commands-test/new-property-test)) + + ;; after the test has been paused, you can do anything with the current page like this + (repl/with-page + (w/wait-for (first (util/get-edit-block-container)) + {:state :detached})) + + (run-tests 'logseq.e2e.commands-basic-test + 'logseq.e2e.multi-tabs-basic-test + 'logseq.e2e.outliner-basic-test + 'logseq.e2e.rtc-basic-test) + + (do + (reset! config/*headless true) + (reset! config/*slow-mo 10) + (run-tests 'logseq.e2e.reference-basic-test) + (dotimes [i 10] + (run-tests 'logseq.e2e.reference-basic-test))) + + ;; + ) diff --git a/clj-e2e/resources/invalid-db-export.edn b/clj-e2e/resources/invalid-db-export.edn new file mode 100644 index 0000000..71de107 --- /dev/null +++ b/clj-e2e/resources/invalid-db-export.edn @@ -0,0 +1 @@ +{:invalid-db-export diff --git a/clj-e2e/resources/large_text.txt b/clj-e2e/resources/large_text.txt new file mode 100644 index 0000000..61c63cd --- /dev/null +++ b/clj-e2e/resources/large_text.txt @@ -0,0 +1,156 @@ +- A Mother Teresa post is long overdue on r/badhistory sheerly for the vast amount of misinformation circulating around the figure on the Redditsphere. +- There are certain aspects of Mother Teresa that are taken as absolute facts online when they lack the context of Mother Teresa\u0027s work and beliefs. +- Much of these characterizations originate from Hitchen\u0027s documentary \u0027Hell\u0027s Angel\u0027 and his book \u0027The Missionary Position’[1] neither of which are academic and are hit pieces, which like a telephone game, have become more absurd online. +- I intend this neither to be a defense nor a vindication of Teresa; rather, adding some much needed nuance and assessing some bad-faith approaches to the issues. +- My major historical/ sociological research here deals with the state of medical care in Teresa\u0027s charities. +- Criticism of Mother Teresa\u0027s medical care + - "Teresa ran hospitals like prisons, particularly cruel and unhygienic prisons at that" + - It is crucial to note here that Teresa ran hospices, precisely a "home for the dying destitutes", not hospitals. + - Historically and traditionally, hospices were run by religious institutions and were places of hospitality for the sick, wounded, or dying and for travelers. + - It was not until 1967 that the first modern hospice (equipped with palliative care) was opened in England by Cicely Saunders.[2] + - It wasn\u0027t until 1974 that the term "palliative care" was even coined and not until 1986 that the WHO 3-Step Pain Ladder was even adopted as a policy[3] (the global standard for pain treatment; the policy is widely regarded as a watershed moment for the adoption of palliative programs worldwide). + - Mother Teresa began her work in 1948 and opened her "home for the dying and destitutes" Nirmal Hriday in 1952,[4] 15 years before the invention of the modern hospice and 34 years before the official medical adoption of palliative medicine. + - Mother Teresa ran a traditional hospice, not a modern medical one. + - As Sister Mary Prema Pierick, current superior general of the Missionaries of Charity, colleague and close friend of Mother Teresa said "Mother never had hospitals; we have homes for those not accepted in the hospital. We take them into our homes. Now, the medical care is very important, and we have been improving on it a lot and still are. The attention of the sisters and volunteers is a lot on the feeding and bandaging of the person. It is important to have them diagnosed well and to admit them to hospitals for treatment."[5] + - Mother Teresa\u0027s charism was not in hospitals and medicine, it was in giving comfort to the already dying and had stated that that was her mission. + - Neither is the MoC principally engaged in running hospices; they also run leper centers, homes for the mentally challenged, orphanages, schools, old age homes, nunneries among many other things around the world. + - And note, this leaves out the state of hospice care in India at the time, which is not comparable to England. +- Which brings us to: + - "Mother Teresa\u0027s withheld painkillers from the dying with the intent of getting them to suffer" + - This is one of the bigger misconceptions surrounding Mother Teresa. It originates from Hitchens lopsidedly presenting an article published by Dr. Robin Fox on the Lancet.[6] + - Dr. Fox actually prefaced his article by appreciating Mother Teresa\u0027s hospice for their open-door policy, their cleanliness, tending of wounds and loving kindness (which Hitchen\u0027s quietly ignores). + - Dr. Fox notes; "the fact that people seldom die on the street is largely thanks to the work of Mother Theresa and her mission" and that most of "the inmates eat heartily and are doing well and about two-thirds of them leave the home on their feet”. + - He also notes that Mother Teresa\u0027s inmates were so because they were refused admissions in hospitals in Bengal. + - Only then does Dr. Fox criticise the MoC for its "haphazard medical care" which were the lack of strong analgesics and the lack of proper medical investigations and treatments, with the former problem separating it from the hospice movement. + - The latter is largely due to the fact that Teresa ran hospices with nuns with limited medical training (some of them were nurses), with doctors only voluntarily visiting (doctors visited twice a week, he notes the sisters make decisions the best they can), that they didn\u0027t have efficient modern health algorithms and the fact that hospitals had refused admissions to most of their inmates. + - Most importantly, Mother Teresa did not withhold painkillers. + - Dr. Fox himself notes that weak analgesics (like acetaminophen) were used to alleviate pain; what was lacking were strong analgesics like morphine. + - The wording is important, Fox only noted \u0027a lack of painkillers\u0027 without indicating it\u0027s cause, not that Teresa was actively withholding them on principle. + - What Hitchens wouldn\u0027t talk about is the responses Dr. Fox got from other palliative care professionals. + - Three prominent palliative care professionals, Dr. David Jeffrey, Dr. Joseph O\u0027Neill and Ms. Gilly Burn, founder of Cancer Relief India, responded to Fox on the Lancet.[7] + - They note three main difficulties with respect to pain control in India: + - 1) lack of education of doctors and nurses + - 2) few drugs + - 3) very strict state government legislation, which prohibits the use of strong analgesics even to patients dying of cancer + - with about "half a million cases of unrelieved cancer pain in India" at the time. + - They respond, "If Fox were to visit the major institutions that are run by the medical profession in India he may only rarely see cleanliness, the tending of wounds and sores, or loving kindness. In addition, analgesia might not be available." + - They summarise their criticisms of Dr. Fox by stating that "the western-style hospice care is not relevant to India, The situation in India is so different from that in western countries that it requires sensitive, practical, and dynamic approaches to pain care that are relevant to the Indian perspective.” + - India and the National Congress Party had been gradually strengthening it\u0027s opium laws post-Independence (1947), restricting opium from general and quasi-medical use. + - Starting from the "All India Opium Conference 1949", there was rapid suppression of opium from between 1948 and 1951 under the Dangerous Drugs Act, 1930 and the Drugs and Cosmetics Act, 1940. + - In 1959, the sale of opium was totally prohibited except for scientific/ medical uses. Oral opium was the common-man\u0027s painkiller. + - India was a party to three United Nations drug conventions – the 1961 Single Convention on Narcotic Drugs, the 1971 Convention on Psychotropic Substances and the 1988 Convention against Illicit Traffic in Narcotic Drugs and Psychotropic Substances, which finally culminated in the 1985 Narcotic Drugs and Psychotropic Substances Act, which was ultimately responsible for the drastic reduction of medicinal opioid use in India even for a lot of hospitals. + - It is also noted that opium use in Western medical treatments in India was limited during the time (post-Independence), mostly for post-operative procedures and not palliative care. + - The first oral morphine tablets (the essential drug of palliative medicine) only arrived in India in 1988 under heavy regulations. [8][9][10][11] + - Before 1985, strong analgesics could only be bought under a duplicate prescription of a registered doctor, de facto limiting its use to hospital settings. + - Nevertheless, India had some consumed some morphine then, although well below the global mean.[12] + - Since the laws prior to 1985 weren\u0027t as strict, the Charity was able to use stronger painkillers like morphine and codeine injections at least occasionally under prescription at their homes, as witnesses have described.[13][14][15] + - This essentially rebuts critics claiming she was "against painkillers on principle", as she evidently was not. + - Also note, palliative medicine had not even taken its roots at that point. + - Palliative care only began to be taught in medical institutions worldwide in 1974. [16] + - Moreover, palliative medicine did not appear in India till the mid-1980s, with the first palliative hospice in India being Shanti Avedna Sadan in 1986. + - Palliative training for medical professionals only appeared in India in the 1990s. + - The NDPS Act came right about the time palliative care had begun in India and was a huge blow to it.[17][18] + - Post-NDPS, WHO Reports regarding the state of palliative medicine in India shows that it was sporadic and very limited, including Calcuttan hospitals.[19] + - As late as 2001, researchers could write that "pain relief is a new notion in [India]", and "palliative care training has been available only since 1997".[20] + - The Economist Intelligence Unit Report in 2015 ranked India at nearly the bottom (67) out 80 countries on the "Quality of Death Index"[21]. + - With reference to West Bengal specifically, it was only in 2012 that the state government finally amended the applicable regulations.[22] + - Even to this day, India lacks many modern palliative care methods, with reforms only as recently as 2012 by the "National Palliative Care Policy 2012" and the "Narcotic Drugs and Psychotropic Substances (Amendment) Act 2014" for medical opioid use.[23][24][25][26] + - The only academic evidence I could find for the lack of painkillers in the MoC comes from the 1994 Robin Fox paper, post-1985 NDPS act. + - Both the evidences that Hitchens provides for the lack of painkillers in their homes, Dr. Fox\u0027s article and Ms. Loudon\u0027s testimony comes post-1985. + - Regardless, It is disingenuous of Hitchens to criticise the MoC\u0027s conditions in 1994 when being ignorant of the situation and laws at the time. +- Another criticism faced by Mother Teresa was the reusing of needles in her hospices. + - Plenty articles attribute Fox\u0027s Lancet article for reusing unsterilized needles even though Fox did not indicate this in his piece (also, he also did not find anything objectionable with regard to hygiene). + - While constantly using disposable needles may seem ubiquitous today, it was not a global standard practise at the time. + - Loudon\u0027s account does not seem to be the routine. + - We know that Mother Teresa\u0027s hospice had usually used some form of disinfection on their instruments, surgical spirit[27], some accounted boiling[28] and had later switched to using disposable needles (stopping reuse) in the 90s/ early 00s.[29] + - Although disposable needles were invented in the 1950s, reuse of needles was not uncommon until the AIDS epidemic scare in the 1980s.[30] + - Back then, many Indian doctors and hospitals didn\u0027t shy away from reusing needles, sometimes without adequate sterilization.[31][32][33] + - There is also no suggestion that Mother Teresa knew or approved of the alleged negligent practice. + - India did not have any nationwide syringe program at the time. + - WHO estimates that 300,000 people die in India annually as a result of dirty syringes. + - A landmark study in 2005, \u0027Assessment of Injection Practices in India — An India-CLEN Program Evaluation Network Study\u0027 indicated that "62% of all injections in the country were unsafe, having been administered incorrectly or “had the potential” to transmit blood-borne viruses such as HIV, Hepatitis B or Hepatitis C either because a glass syringe was improperly sterilized or a plastic disposable one was reused. "[34] + - Dirty syringes were a problem in India well into the 21st century in government and private hospitals, with researchers citing lack of supplies, proper education on sterilization, lack of proper waste disposal facilities among other things. + - While the treatments were substandard to hospices in the west, Navin Chawla, a retired Indian government official and Mother Teresa’s biographer notes that in the 1940s and 1950s, “nearly all those who were admitted succumbed to illnesses. In the 1960s and 1970s, the mortality rate was roughly half those admitted. In the last ten years or so [meaning the 1980s to the early 1990s], only a fifth died.”[35] + - There are other positive accounts of their work and compassion by medical professionals as well.[36] + - The entire point here is that it is terribly unfair to impose western medical standards on a hospice that began in the 50s in India when they lacked the resources and legislation to enforce them given the standards of the country. + - To single out Mother Teresa\u0027s hospice is unfair when it was an issue not just for hospices, but hospitals too. + - Once this context is given, it becomes far less of an issue focused on the individual nuns but part of a larger problem affecting the area. +- Once this is clear, it ties into the second part of the sentence: + - " Mother Teresa withheld painkillers because suffering bought them closer to Jesus / glorified suffering and pain. ” + - A quote often floated by Hitchens was “I think it is very beautiful for the poor to accept their lot, to share it with the passion of Christ. I think the world is being much helped by the suffering of the poor people” with the implication being that Teresa was something of a sadist, actively making her inmates suffer (by “withholding painkillers” for instance). + - This is plainly r/badhistory on a theological concept that has been around for millennia. + - Hitchens relies here on a mischaracterization of a Catholic belief in “redemptive suffering”. + - Redemptive suffering is the belief that human suffering, when accepted and offered up in union with the Passion of Jesus, can remit the just punishment for one\u0027s sins or for the sins of another.[37] + - In simpler words, it is the belief that incurable suffering can have a silver spiritual lining. + - The moral value and interpretation of this belief is a matter of theology and philosophy; my contention is that neither Catholicism nor Teresa holds a religious belief in which one is asked to encourage the sufferings of the poor, especially without relieving them. + - The Mother Teresa Organization itself notes that they are “to comfort those who are suffering, to feed the hungry, to give drink to the thirsty, to care for the sick, etc. Telling someone to offer it [suffering] up without also helping him to deal with the temporal and emotional effects of whatever they are going through is not the fully Christian thing to do.”[38] + - It becomes fairly obvious to anyone that the easiest way for Teresa to let her inmates suffer is to let them be on the streets. + - Teresa was not the cause of her inmates\u0027 diseases and reports (eg. Dr. Fox) show that most inmates were refused to be treated by hospitals. + - Mother Teresa in her private writings talks of her perpetual sorrow with the miseries of the poor who in her words were "God\u0027s creatures living in unimaginable holes"; contradictory to the image of malice given by Hitchens.[39] + - Which also brings into question; why did the MoC even bother providing weaker painkillers like acetaminophen if they truly wanted them to suffer? + - They had used stronger painkillers in the past too, so this was not a principled rejection of them. + - Sister Mary Prema Pierick, current superior general of the Missionaries of Charity, colleague and close friend of Mother Teresa responds; "[Mother\u0027s] mission is not about relieving suffering? That is a contradiction; it is not correct... Now, over the years, when Mother was working, palliative treatment wasn’t known, especially in poor areas where we were working. Mother never wanted a person to suffer for suffering’s sake. On the contrary, Mother would do everything to alleviate their suffering. That statement [of not wishing to alleviate suffering] comes from an understanding of a different hospital care, and we don’t have hospitals; we have homes. But if they need hospital care, then we have to take them to the hospital, and we do that."[40] + - It is also important to note the Catholic Church\u0027s positions on the interaction of the doctrine on redemptive suffering and palliative care. + - The Catholic Church permits narcotic use in pain management. + - Pope Pius XII affirmed that it is licit to relieve pain by narcotics, even when the result is decreased consciousness and a shortening of life, "if no other means exist, and if, in the given circumstances, this [narcotics] does not prevent the carrying out of other religious and moral duties" [41], reaffirmed by Pope John Paul II responding to the growth of palliative care in Evangelium Vitae.[42] + - The Ethical and Religious Directives for Catholic Health Care Services notes that "medicines capable of alleviating or suppressing pain may be given to a dying person, even if this therapy may indirectly shorten the person\u0027s life so long as the intent is not to hasten death. Patients experiencing suffering that cannot be alleviated should be helped to appreciate the Christian understanding of redemptive suffering".[43] + - According to the Vatican\u0027s Declaration on Euthanasia "Human and Christian prudence suggest, for the majority of sick people, the use of medicines capable of alleviating or suppressing pain, even though these may cause as a secondary effect semi-consciousness and reduced lucidity." + - This declaration goes on, "It must be noted that the Catholic tradition does not present suffering or death as a human good but rather as an inevitable event which may be transformed into a spiritual benefit if accepted as a way of identifying more closely with Christ."[44] + - Inspecting the Catholic Church\u0027s positions on the matter, we can see that Hitchens is wholly ignorant and mistaken that there is a theological principle at play. +- “Mother Teresa was a hypocrite who provided substandard care at her hospices while using world-class treatments for herself” + - While a value judgement on Teresa is not so much history as it is ethics, Hitchens deliberately omits several key details about Mother Teresa’s hospital admissions to spin a bad historical narrative in conjunction with the previously mentioned misportrayals. + - Mother Teresa was often admitted to hospitals against her will by her friends and co-workers. + - Navin Chawla notes that she was admitted “against her will" and that she had been “pleading with me to take her back to her beloved Kolkata”. + - Doctors had come to visit her on their own will and former Indian Prime Minister Narasimha Rao offered her free treatment anywhere in the world.[45] + - He remembers how when she was rushed to Scripps Clinic that "so strong was her dislike for expensive hospitals that she tried escaping from there at night." + - "I was quite heavily involved at the time when she was ill in Calcutta and doctors from San Diego and New York had come to see her out of their own will... Mother had no idea who was coming to treat her. It was so difficult to even convince her to go to the hospital. The fact that we forced her to, should not be held against her like this," says 70-year-old artist Sunita Kumar, who worked closely with Mother Teresa for 36 years.[46] + - Unlike some tall internet claims, Mother Teresa did not "fly out in private jets to be treated at the finest hospitals". + - For example, her admission at Scripps, La Jolla in 1991 was at the request of her physician and Bishop Berlie of Tijuana. + - It was unplanned; she had been at Tijuana and San Diego as part of a tour setting up her homes when she suddenly contracted bacterial pneumonia.[47] + - Her other hospitalisation in Italy was due to a heart attack while visiting Pope John Paul II and in 1993 by tipping and breaking her ribs while visiting a chapel.[48][49] + - Dr. Patricia Aubanel, a physician who travelled with Mother Teresa from 1990 to her death in 1997 called her “the worst patient she ever had” and had “refused to go to the hospital”, outlining an incident where she had to protest Mother Teresa to use a ventilator.[50] + - Other news reports mention Mother Teresa was eager to leave hospitals and needed constant reminders to stay.[51] + - Her treatments and air travel were often donated free of charge. + - Mother Teresa was a recipient of the Bharat Ratna, India\u0027s highest civilian award in 1980, which has the additional benefit of getting a lifetime of free first class tickets on Air India.[52] + - Many other airlines begged and bumped her up to first-class (on principle Teresa always bought coach) because of the commotion the passengers cause at the coach.[53] + - As Jim Towey says "for decades before she became famous, Mother rode in the poorest compartments of India\u0027s trains, going about the country serving the poor. Attacking her by saying she was attached to luxury is laughable."[54] +- “Mother Teresa misused her donations and accepted fraudulent money” + - There is no hard, direct evidence that Mother Teresa had mishandled her donations other than her critics speculating so. + - Neither Teresa nor her institution have luxuries or long-term investments in their names and their vow prevents them from fund-raising. + - Hitchens\u0027 source itself asserts that the money in the bank was not available for the sisters in New York to relieve their ascetic lifestyle or for any local purpose, and that they they had no access to it. + - Her critics have no legal case to offer and haven\u0027t bothered to follow up on their private investigations. + - Cases filed by the MoC\u0027s critics in India in 2018 probing their financial records were investigated by authorities in India and have not resulted in any prosecution (to the best of my knowledge).[55] + - The case as offered rests on rumours and anecdotes with little precise details. + - Again, I am not vindicating Teresa, just pointing out how the case as offered is lacking. + - What is claimed as a misuse is but an objection as to Mother Teresa\u0027s choice of charitable objects, coupled with an allegation that she personally failed publicly to account for the donations she received. + - The former is absurdly self-referential and goes nowhere near substantiating a claim of "misuse" of charitable funds. + - Unless it can be established that the money was donated specifically for the relief of poverty (as opposed to having been given as a general accretion to the funds of MoC), the allegation is fundamentally misconceived. + - As for the latter objection, unless it can be established that Mother Teresa was in effective direct control of the finances of MoC and that MoC are under an obligation to make their accounts public, it, too, is misconceived. + - Indian charities are not obligated by the government to publish their accounts publicly and are audited and filed to the relevant authorities by law. + - If it is to be alleged that MoC are in breach of any statutory norms for publishing accounts (as distinct from lodging them with the appropriate body with oversight of charities in any given jurisdiction), then the fact should be asserted in terms. + - It also seems that most charities in Bengal do not publicly publish their accounts, again contradicting Hitchen\u0027s.[56] + - The claim of "7% fund utilisation for charity" originates from a 1998 article in Stern Magazine. + - However, no details are given how they arrived at this figure either. This figure only amounts for a single home in London from a single year, 1991. + - Wüllenweber writing in 1998, had to go back to 1991 to find even one example to provide what is more cover than support for his case. + - Fraudulence is a substantial claim which requires very good evidence. On inspection, these are at best, insinuations, and at their worst, conspiracies. + - Like Hitchens said, that what can be asserted without evidence can be dismissed without evidence. + - For example, Navin Chawla, government official/biographer, penned that Mother Teresa said “[She] needed money to use for her people,” not for investment purposes. + - “The quite remarkable sums that are donated are spent almost as quickly on medicines (particularly for leprosy and tuberculosis), on food and on milk powder”.[57] + - There are no calculations done on the cost of maintaining all her 517 homes across the world accounting for the deficiencies in resources in third-world countries. + - Hitchens also openly admits that he does not know if the Duvaliers donated any money.[58] + - There are also insinuations expressly reliant on guilt by association. + - The large donation of Charles Keating was prior to their offense. + - While her assessment of Keating is dubious, there is no suggestions that Mother Teresa knew of his thefts beforehand and there is no indication when the donations were made – the date would have been foundational for any legal claim that Teresa was accountable for the money on the ground that she knew or had constructive knowledge of a fraud. + - It\u0027s likely that the donations were spent by the time they were convicted. + - Too late for the book, the convictions against Keating were overturned on a non-technicality in April 1996,[59] nullifying Hitchens\u0027 censures against Teresa under this head, which Hitchens fails to mention elsewhere. +- Bonus r/badhistory on Mother Teresa: + - “Her nuns refused to install an elevator for the disabled and handicapped in their homeless shelter in New York to make them suffer” + - While the news itself is true, it omits a key detail. + - By refusing an elevator, the touted implication that they’d let the inmates suffer is mistaken; the nuns stated that “they would personally carry all of them up the stairs”[60] since they don\u0027t use elevators. + - While it is valid to criticise her asceticism on ethical grounds, it is dishonest to leave out the detail that they pledged to personally carry the handicapped, giving a false historical narrative implying malicious intent. + - There also were some communal issues involved in the Bronx home. + - The nuns estimated the costs to be about $500,000 in repairs and had already spent $100,000 to repair fire damages. + - There were also reports about "community opposition" and "vandals undoing the repairs", raising the price of the home beyond what they could handle. + - They found that a $50,000-150,000 elevator was above their budget. + - It seems like their asceticism might not have been the only factor as to why they left the project. +- I have also contacted some past volunteers of the charity, some who are medical professionals, to get their experiences as well. They are posted as an addendum in the comments. Fin. \ No newline at end of file diff --git a/clj-e2e/src/logseq/e2e/api.clj b/clj-e2e/src/logseq/e2e/api.clj new file mode 100644 index 0000000..354854d --- /dev/null +++ b/clj-e2e/src/logseq/e2e/api.clj @@ -0,0 +1,39 @@ +(ns logseq.e2e.api + (:require + [clojure.string :as string] + [jsonista.core :as json] + [wally.main :as w])) + +(defn- to-snake-case + "Converts a string to snake_case. Handles camelCase, PascalCase, spaces, hyphens, and existing underscores. + Examples: + 'HelloWorld' -> 'hello_world' + 'Hello World' -> 'hello_world' + 'hello-world' -> 'hello_world' + 'Hello__World' -> 'hello_world'" + [s] + (when (string? s) + (-> s + ;; Normalize input: replace hyphens/spaces with underscores, collapse multiple underscores + (string/replace #"[-\s]+" "_") + ;; Split on uppercase letters (except at start) and join with underscore + (string/replace #"(? { const args = JSON.parse(s);const o=logseq.%1$s; return o['%2$s']?.apply(null, args || []); }" ns1 name1) + args (json/write-value-as-string (vec args))] + ;; (prn "Debug: eval-js #" estr args) + (w/eval-js estr args))) diff --git a/clj-e2e/src/logseq/e2e/assert.clj b/clj-e2e/src/logseq/e2e/assert.clj new file mode 100644 index 0000000..706554e --- /dev/null +++ b/clj-e2e/src/logseq/e2e/assert.clj @@ -0,0 +1,61 @@ +(ns logseq.e2e.assert + (:import [com.microsoft.playwright.assertions PlaywrightAssertions]) + (:require [clojure.test :as t] + [logseq.e2e.locator :as loc] + [wally.main :as w])) + +(def assert-that PlaywrightAssertions/assertThat) + +(defn assert-is-visible + [q] + (-> (w/-query q) .first assert-that .isVisible) + true) + +(defn assert-is-hidden + [q] + (-> (w/-query q) assert-that .isHidden) + true) + +(defn assert-non-editor-mode + [] + (w/wait-for-not-visible + (loc/or "[data-testid='block editor']" + ;; TODO: remove this when this prop-name fixed on dom + "[datatestid='block editor']"))) + +(defn assert-in-normal-mode? + "- not editing mode + - no action bar + - no search(cmdk) modal" + [] + (assert-non-editor-mode) + (assert-is-hidden ".selection-action-bar") + (assert-is-visible "#search-button") + true) + +(defn assert-have-count + [q count] + (-> (w/-query q) assert-that (.hasCount count))) + +(defn assert-graph-loaded? + [] + ;; there's some blocks visible now + (assert-is-visible (w/get-by-test-id "page title"))) + +(defn assert-editor-mode + [] + (assert-have-count ".editor-wrapper textarea" 1)) + +(defn assert-selected-block-text + [text] + (assert-is-visible (format ".ls-block.selected :text('%s')" text))) + +(defn assert-graph-summary-equal + "`summary` is returned by `validate-graph`" + [summary1 summary2] + (let [compare-keys [:blocks :pages :classes :properties ;; :entities + ]] + + (t/is (= (select-keys summary1 compare-keys) + (select-keys summary2 compare-keys)) + [summary1 summary2]))) diff --git a/clj-e2e/src/logseq/e2e/block.clj b/clj-e2e/src/logseq/e2e/block.clj new file mode 100644 index 0000000..b04820b --- /dev/null +++ b/clj-e2e/src/logseq/e2e/block.clj @@ -0,0 +1,155 @@ +(ns logseq.e2e.block + (:require [clojure.string :as string] + [clojure.test :refer [is]] + [logseq.e2e.assert :as assert] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(defn open-last-block + "Open the last existing block or pressing add button to create a new block" + [& {:keys [in-retry?]}] + (util/double-esc) + (assert/assert-in-normal-mode?) + + (let [blocks-count (util/page-blocks-count) + last-block (-> (if (zero? blocks-count) + (w/query ".ls-page-blocks .block-add-button") + (w/query ".ls-page-blocks .page-blocks-inner .ls-block .block-content")) + (last))] + (w/click last-block) + (if in-retry? + (assert/assert-editor-mode) + (try + (assert/assert-editor-mode) + (catch Error _e + (open-last-block {:in-retry? true})))))) + +(defn save-block + [text] + (assert/assert-have-count util/editor-q 1) + (w/click util/editor-q) + (w/fill util/editor-q text) + (assert/assert-is-visible (loc/filter util/editor-q :has-text text))) + +(defn- focus-new-block! + [previous-editor-id] + (let [new-editor (loc/filter ".editor-wrapper" + :has "textarea" + :has-not (str "#" previous-editor-id))] + (try + (assert/assert-is-visible new-editor) + (catch Error _error + (open-last-block) + (when (= previous-editor-id + (.getAttribute (w/-query ".editor-wrapper textarea") "id")) + (util/move-cursor-to-end) + (k/enter)) + (assert/assert-is-visible new-editor))))) + +(defn new-block + [title] + (when-not (util/get-editor) + (open-last-block)) + (let [last-id (.getAttribute (w/-query ".editor-wrapper textarea") "id")] + (is (some? last-id)) + (util/move-cursor-to-end) + (k/enter) + (focus-new-block! last-id) + (when (seq title) + (util/press-seq title)) + (assert/assert-editor-mode) + (is (= title (util/get-edit-content))))) + +;; TODO: support tree +(defn new-blocks + [titles] + (let [editor? (util/get-editor)] + (when-not editor? (open-last-block)) + (let [value (util/get-edit-content)] + (if (string/blank? value) ; empty block + (save-block (first titles)) + (new-block (first titles)))) + (doseq [title (rest titles)] + (new-block title)))) + +(defn delete-blocks + "Delete the current block if in editing mode, otherwise, delete all the selected blocks." + [] + (let [editor (util/get-editor)] + (when editor (util/exit-edit)) + (k/backspace))) + +(defn assert-blocks-visible + "blocks - coll of :block/title" + [blocks] + (doseq [block blocks] + (assert/assert-is-visible (format ".ls-page-blocks .ls-block :text('%s')" block)))) + +(defn jump-to-block + [block-text] + (w/click (w/find-one-by-text ".ls-block .block-content" block-text))) + +(defn wait-editor-text + [text] + (assert/assert-have-count util/editor-q 1) + (w/wait-for (format ".editor-wrapper textarea:text('%s')" text))) + +(def copy #(k/press "ControlOrMeta+c" {:delay 100})) +(def paste #(k/press "ControlOrMeta+v" {:delay 100})) +(def undo #(k/press "ControlOrMeta+z" {:delay 100})) +(def redo #(k/press "ControlOrMeta+y" {:delay 100})) + +(defn- wait-for-editor-x-change + [x1 moved?] + (loop [attempts-left 40] + (if-let [editor (util/get-editor)] + (let [[x2 _] (util/bounding-xy editor)] + (if (or (moved? x1 x2) (zero? attempts-left)) + x2 + (do + (util/wait-timeout 50) + (recur (dec attempts-left))))) + (if (zero? attempts-left) + x1 + (do + (util/wait-timeout 50) + (recur (dec attempts-left))))))) + +(defn- indent-outdent + [indent?] + (let [editor (util/get-editor) + [x1 _] (util/bounding-xy editor) + moved? (if indent? < >) + _ (if indent? (k/tab) (k/shift+tab)) + x2 (wait-for-editor-x-change x1 moved?)] + (if indent? + (is (< x1 x2)) + (is (> x1 x2))))) + +(defn indent + [] + (indent-outdent true)) + +(defn outdent + [] + (indent-outdent false)) + +(defn toggle-property + [property-title property-value] + (k/press (if util/mac? "ControlOrMeta+p" "Control+Alt+p")) + (w/fill ".ls-property-dialog .cp__select-input" property-title) + (w/wait-for (format "#ac-0.menu-link:has-text('%s')" property-title)) + (k/enter) + (util/wait-timeout 100) + (w/click (w/-query ".ls-property-dialog .cp__select-input")) + (util/wait-timeout 100) + (util/input property-value) + (w/wait-for (format "#ac-0.menu-link:has-text('%s')" property-value)) + (k/enter)) + +(defn select-blocks + [n] + (util/repeat-keyboard n "Shift+ArrowUp") + (util/wait-timeout 200)) diff --git a/clj-e2e/src/logseq/e2e/config.clj b/clj-e2e/src/logseq/e2e/config.clj new file mode 100644 index 0000000..bf23349 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/config.clj @@ -0,0 +1,13 @@ +(ns logseq.e2e.config) + +(def ^:private default-port 3002) + +(defn- resolve-port + [] + (let [port (some-> (System/getProperty "port") + parse-long)] + (if (pos-int? port) port default-port))) + +(defonce *port (atom (resolve-port))) +(defonce *headless (atom true)) +(defonce *slow-mo (atom 100)) ; Set `slow-mo` lower to find more flaky tests diff --git a/clj-e2e/src/logseq/e2e/const.clj b/clj-e2e/src/logseq/e2e/const.clj new file mode 100644 index 0000000..1fe593c --- /dev/null +++ b/clj-e2e/src/logseq/e2e/const.clj @@ -0,0 +1,11 @@ +(ns logseq.e2e.const) + +(def *page1 + "this 'page' means playwright-page, not logseq-page. it points to the client1 when testing rtc" + (atom nil)) + +(def *page2 + "this 'page' means playwright-page, not logseq-page. it points to the client2 when testing rtc" + (atom nil)) + +(def ^:dynamic *graph-name* nil) diff --git a/clj-e2e/src/logseq/e2e/graph.clj b/clj-e2e/src/logseq/e2e/graph.clj new file mode 100644 index 0000000..ccd282b --- /dev/null +++ b/clj-e2e/src/logseq/e2e/graph.clj @@ -0,0 +1,145 @@ +(ns logseq.e2e.graph + (:require [logseq.e2e.assert :as assert] + [logseq.e2e.keyboard :as k] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import [com.microsoft.playwright Locator$ClickOptions])) + +(defn refresh-all-remote-graphs + [] + (let [enabled-refresh "button:not([disabled]):has-text(\"Refresh\")"] + (w/wait-for enabled-refresh {:timeout 30000}) + (w/click enabled-refresh + (-> (Locator$ClickOptions.) + (.setTimeout 30000))))) + +(defn goto-all-graphs + [] + (util/search-and-click "Go to all graphs")) + +(def ^:private e2ee-password-modal ".e2ee-password-modal-content") +(def ^:private e2ee-new-password-input (str e2ee-password-modal " input[placeholder=\"Enter password\"]")) +(def ^:private e2ee-new-password-confirm-input (str e2ee-password-modal " input[placeholder=\"Enter password again\"]")) +(def ^:private e2ee-password-input (str e2ee-password-modal " .ls-toggle-password-input input")) +(def ^:private e2ee-password-submit (str e2ee-password-modal " button:text(\"Submit\")")) +(def ^:private cloud-ready-indicator "button.cloud.on.idle") +(def ^:private new-graph-dialog ".new-graph") +(def ^:private new-graph-submit (str new-graph-dialog " button:not([disabled]):text(\"Submit\")")) +(def ^:private rtc-sync-toggle "button#rtc-sync") +(def ^:private rtc-graph-e2ee-toggle "button#rtc-graph-e2ee") +(def ^:private e2ee-password-poll-ms 250) +(def ^:private e2ee-password-prompt-grace-ms 2000) + +(defn- input-e2ee-password + [] + (if (w/visible? e2ee-new-password-confirm-input) + (do + (w/click e2ee-new-password-input) + (util/input "e2etest") + (w/click e2ee-new-password-confirm-input) + (util/input "e2etest")) + (do + (w/click (.first (w/-query e2ee-password-input))) + (util/input "e2etest"))) + (w/click e2ee-password-submit) + (w/wait-for-not-visible e2ee-password-modal)) + +(defn- maybe-input-e2ee-password + [] + ;; Password input prompt is optional for accounts with already initialized keys/password. + ;; Cloud-ready can still be visible from the previous graph right after submit, + ;; so wait for it to stay visible briefly before treating it as the terminal state. + (loop [remaining-ms 20000 + cloud-ready-ms 0] + (cond + (w/visible? e2ee-password-modal) + (input-e2ee-password) + + (and (>= cloud-ready-ms e2ee-password-prompt-grace-ms) + (w/visible? cloud-ready-indicator)) + nil + + (<= remaining-ms 0) + nil + + :else + (let [cloud-ready? (w/visible? cloud-ready-indicator)] + (util/wait-timeout e2ee-password-poll-ms) + (recur (- remaining-ms e2ee-password-poll-ms) + (if cloud-ready? + (+ cloud-ready-ms e2ee-password-poll-ms) + 0)))))) + +(defn- new-graph-helper + [graph-name enable-sync? graph-e2ee?] + (util/search-and-click "Add a DB graph") + (w/wait-for "h2:text(\"Create a new graph\")") + (w/click "input[placeholder=\"your graph name\"]") + (util/input graph-name) + (when enable-sync? + (w/wait-for rtc-sync-toggle {:timeout 3000}) + (w/click rtc-sync-toggle) + (when-not graph-e2ee? + (w/wait-for rtc-graph-e2ee-toggle {:timeout 3000}) + (w/click rtc-graph-e2ee-toggle))) + + (w/click new-graph-submit) + + (when enable-sync? + (maybe-input-e2ee-password) + (w/wait-for cloud-ready-indicator {:timeout 20000})) + + (w/wait-for-not-visible new-graph-dialog {:timeout 30000}) + (assert/assert-graph-loaded?)) + +(defn new-graph + ([graph-name enable-sync?] + (new-graph graph-name enable-sync? true)) + ([graph-name enable-sync? graph-e2ee?] + (new-graph-helper graph-name enable-sync? graph-e2ee?))) + +(defn wait-for-remote-graph + [graph-name] + (goto-all-graphs) + (util/repeat-until-visible 5 + (format "div[data-testid='logseq_db_%s']" graph-name) + refresh-all-remote-graphs)) + +(defn remove-local-graph + [graph-name] + (wait-for-remote-graph graph-name) + (let [action-btn + (.first (w/-query (format "div[data-testid='logseq_db_%s'] .graph-action-btn" graph-name)))] + (w/click action-btn) + (w/click ".delete-local-graph-menu-item") + (w/click "div[role='alertdialog'] button:text('Confirm')"))) + +(defn remove-remote-graph + [graph-name] + (wait-for-remote-graph graph-name) + (let [action-btn + (.first (w/-query (format "div[data-testid='logseq_db_%s'] .graph-action-btn" graph-name)))] + (w/click action-btn) + (w/click ".delete-remote-graph-menu-item") + (w/click "div[role='alertdialog'] button:text('Confirm')"))) + +(defn switch-graph + [to-graph-name wait-sync? need-input-password?] + (goto-all-graphs) + (w/click (.last (w/-query (format "div[data-testid='logseq_db_%1$s'] span:has-text('%1$s')" to-graph-name)))) + (when wait-sync? + (when need-input-password? (maybe-input-e2ee-password)) + (w/wait-for cloud-ready-indicator {:timeout 20000})) + (assert/assert-graph-loaded?)) + +(defn validate-graph + [] + (let [success-toast ".ui__toast:has-text('Your graph is valid')"] + (k/esc) + (k/esc) + (util/search-and-click "(Dev) Validate current graph") + (w/wait-for success-toast {:timeout 30000}) + (w/eval-js + "() => document.querySelectorAll('.ui__toast.success button') + .forEach((button) => button.click())") + {:valid? true})) diff --git a/clj-e2e/src/logseq/e2e/keyboard.clj b/clj-e2e/src/logseq/e2e/keyboard.clj new file mode 100644 index 0000000..5dc1da7 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/keyboard.clj @@ -0,0 +1,24 @@ +(ns logseq.e2e.keyboard + (:require [wally.main :as w])) + +(def ^:private mac? (= "Mac OS X" (System/getProperty "os.name"))) + +(def press w/keyboard-press) + +(def enter #(press "Enter")) +(def esc #(press "Escape")) +(def backspace #(press "Backspace")) +(def delete #(press "Delete")) +(def tab #(press "Tab")) +(def shift+tab #(press "Shift+Tab")) +(def shift+enter #(press "Shift+Enter")) +(def shift+arrow-up #(press "Shift+ArrowUp")) +(def shift+arrow-down #(press "Shift+ArrowDown")) + +(def arrow-up #(press "ArrowUp")) +(def arrow-down #(press "ArrowDown")) +(def arrow-left #(press "ArrowLeft")) +(def arrow-right #(press "ArrowRight")) + +(def meta+shift+arrow-up #(press (str (if mac? "Meta" "Alt") "+Shift+ArrowUp"))) +(def meta+shift+arrow-down #(press (str (if mac? "Meta" "Alt") "+Shift+ArrowDown"))) diff --git a/clj-e2e/src/logseq/e2e/locator.clj b/clj-e2e/src/logseq/e2e/locator.clj new file mode 100644 index 0000000..9a6bea8 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/locator.clj @@ -0,0 +1,30 @@ +(ns logseq.e2e.locator + (:refer-clojure :exclude [or and filter]) + (:require [wally.main :as w]) + (:import (com.microsoft.playwright Locator$FilterOptions))) + +(defn or + "Return locator" + [q1 q2 & qs] + (let [locators (map w/-query (concat [q1 q2] qs))] + (reduce (fn [loc1 loc2] (.or loc1 loc2)) locators))) + +(defn and + "Return locator" + [q1 q2 & qs] + (let [locators (map w/-query (concat [q1 q2] qs))] + (reduce (fn [loc1 loc2] (.and loc1 loc2)) locators))) + +(defn- ->filter-options + [& {:keys [has has-text has-not has-not-text]}] + (cond-> (Locator$FilterOptions.) + has (.setHas (w/-query has)) + has-not (.setHasNot (w/-query has-not)) + has-text (.setHasText has-text) + has-not-text (.setHasNotText has-not-text))) + +(defn filter + "Return locator" + {:arglists '([q & {:keys [has has-text has-not has-not-text]}])} + [q & {:as opts}] + (.filter (w/-query q) (->filter-options opts))) diff --git a/clj-e2e/src/logseq/e2e/page.clj b/clj-e2e/src/logseq/e2e/page.clj new file mode 100644 index 0000000..61cd8c8 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/page.clj @@ -0,0 +1,83 @@ +(ns logseq.e2e.page + (:require [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import (com.microsoft.playwright TimeoutError))) + +(defn goto-page + [page-name] + (assert (string? page-name) page-name) + (try + (util/search-and-click page-name) + (catch TimeoutError _e + ;; try one more time + (k/esc) + (util/search-and-click page-name)))) + +(defn get-page-name + [] + (util/get-text "div[data-testid='page title'] .block-title-wrap")) + +(defn new-page + [title] + ;; Question: what's the best way to close all the popups? + ;; close popup, exit editing + ;; (repl/pause) + (try + (util/search title) + (let [create-page-item (loc/filter ".search-results > div" + :has-text (str "Create page called '" title "'"))] + (w/wait-for create-page-item) + (w/click (.first create-page-item))) + (catch TimeoutError _e + (k/esc) + (util/search title) + (let [create-page-item (loc/filter ".search-results > div" + :has-text (str "Create page called '" title "'"))] + (w/wait-for create-page-item) + (w/click (.first create-page-item))))) + (util/wait-editor-visible)) + +(defn delete-page + [page-name] + (goto-page page-name) + (w/click ".toolbar-dots-btn") + (w/click "[role='menuitem'] div:text('Delete page')") + (w/click "div[role='alertdialog'] button:text('Confirm')")) + +(defn rename-page + [old-page-name new-page-name] + (goto-page old-page-name) + (w/click "div[data-testid='page title']") + (b/save-block new-page-name) + (k/esc)) + +(defn- set-tag-extends + [extends & [retry-count]] + (let [retry-count (or retry-count 20)] + (try + (util/wait-timeout 500) + (w/click (loc/filter ".property-value" :has-text "root tag")) + (let [option-selector #(format ".ui__dropdown-menu-content a.menu-link:has-text('%s')" %)] + (doseq [parent-tag extends] + (w/click (option-selector parent-tag))) + (when-not (some #{"Root Tag"} extends) + (w/click (option-selector "Root Tag")))) + (catch TimeoutError e + (if (zero? retry-count) + (throw (ex-info "parent-tag not found" {:extends extends} e)) + (do (k/esc) + (set-tag-extends extends (dec retry-count)))))))) + +(defn convert-to-tag + [page-name & {:keys [extends]}] + (goto-page page-name) + (util/right-click "div[data-testid='page title']") + (w/click (loc/filter "div[role='menuitem']" :has-text "convert to tag")) + (assert/assert-is-visible ".ls-page-icon") + (when (seq extends) + (set-tag-extends extends) + (k/esc))) diff --git a/clj-e2e/src/logseq/e2e/playwright_page.clj b/clj-e2e/src/logseq/e2e/playwright_page.clj new file mode 100644 index 0000000..34c02d9 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/playwright_page.clj @@ -0,0 +1,29 @@ +(ns logseq.e2e.playwright-page + "operations on playwright pages." + (:require [logseq.e2e.assert :as assert] + [logseq.e2e.config :as config] + [wally.main :as w])) + +(defn get-test-url + [port] + (str "http://localhost:" (or port @config/*port) "?rtc-test=true")) + +(defn get-pages + [pw-ctx] + (.pages pw-ctx)) + +(defn open-pages + "Pages in same pw-ctx share cookies and storages" + [pw-ctx n] + (let [url (get-test-url nil)] + (dotimes [_i n] + (let [page (.newPage pw-ctx)] + (.navigate page url) + ;; wait the demo graph loaded + (w/with-page page + (assert/assert-graph-loaded?)))))) + +(defn close-pages + [pages] + (doseq [p pages] + (.close p))) diff --git a/clj-e2e/src/logseq/e2e/rtc.clj b/clj-e2e/src/logseq/e2e/rtc.clj new file mode 100644 index 0000000..468d523 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/rtc.clj @@ -0,0 +1,92 @@ +(ns logseq.e2e.rtc + (:require [clojure.edn :as edn] + [logseq.e2e.assert :as assert] + [logseq.e2e.const :refer [*page1 *page2]] + [logseq.e2e.graph :as graph] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(defn get-rtc-tx + [] + (let [loc (w/get-by-test-id "rtc-tx")] + (edn/read-string (w/text-content loc)))) + +(defmacro with-wait-tx-updated + "exec body, then wait for the rtc-tx update. + Return the updated rtc-tx{:local-tx ..., :remote-tx ...}" + [& body] + `(let [m# (get-rtc-tx) + local-tx# (or (:local-tx m#) 0) + remote-tx# (or (:remote-tx m#) 0) + _# (prn :current-rtc-tx m# local-tx# remote-tx#) + tx# (max local-tx# remote-tx#)] + ~@body + (loop [i# 15] + (when (zero? i#) (throw (ex-info "wait-tx-updated failed" {:old m# :new (get-rtc-tx)}))) + (util/wait-timeout 500) + (w/wait-for "button.cloud.on.idle" {:timeout 35000}) + (util/wait-timeout 1000) + (let [new-m# (get-rtc-tx) + new-local-tx# (or (:local-tx new-m#) 0) + new-remote-tx# (or (:remote-tx new-m#) 0)] + (if (and (= new-local-tx# new-remote-tx#) + (> new-local-tx# tx#)) + (do (prn :new-rtc-tx new-m#) + {:local-tx new-local-tx# :remote-tx new-remote-tx#}) + (do (prn :current-rtc-tx new-m#) + (recur (dec i#)))))))) + +(defn wait-tx-update-to + [new-tx] + (assert (int? new-tx)) + (loop [i 15 + last-rtc-tx nil] + (when (zero? i) + (throw (ex-info "wait-tx-update-to" + {:update-to new-tx + :last-rtc-tx last-rtc-tx}))) + (util/wait-timeout 1000) + (w/wait-for "button.cloud.on.idle" {:timeout 35000}) + (let [m (get-rtc-tx) + local-tx (or (:local-tx m) 0) + ;; remote-tx (or (:remote-tx m) 0) + ] + (if (>= local-tx new-tx) + local-tx + (recur (dec i) m))))) + +(defn rtc-start + [] + (util/search-and-click "(Dev) RTC Start")) + +(defn rtc-stop + [] + (util/search-and-click "(Dev) RTC Stop")) + +(defmacro with-stop-restart-rtc + "- rtc stop on `stop-pw-pages` in order + - run `body` + - rtc start and exec `after-start-body` in order" + [stop-pw-pages start-pw-page+after-start-body & body] + (let [after-body + (cons + 'do + (for [[p body] (partition 2 start-pw-page+after-start-body)] + `(w/with-page ~p + (rtc-start) + ~body)))] + `(do + (doseq [p# ~stop-pw-pages] + (w/with-page p# (rtc-stop))) + ~@body + ~after-body))) + +(defn validate-graphs-in-2-pw-pages + [] + (let [[p1-summary p2-summary] + (map + (fn [p] + (w/with-page p + (graph/validate-graph))) + [@*page1 @*page2])] + (assert/assert-graph-summary-equal p1-summary p2-summary))) diff --git a/clj-e2e/src/logseq/e2e/settings.clj b/clj-e2e/src/logseq/e2e/settings.clj new file mode 100644 index 0000000..89f69a1 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/settings.clj @@ -0,0 +1,51 @@ +(ns logseq.e2e.settings + (:require [logseq.e2e.assert :as assert] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(def ^:private e2e-init-script + "localStorage.setItem('preferred-language', '\"en\"'); localStorage.setItem('developer-mode', '\"true\"');") + +(def ^:private refresh-ready-script + "(() => document.documentElement.lang === 'en' + && localStorage.getItem('preferred-language') === '\"en\"' + && localStorage.getItem('developer-mode') === '\"true\"')()") + +(defn install-init-script! + [ctx] + (.addInitScript ctx e2e-init-script)) + +(defn wait-test-env-ready! + [] + (loop [remaining 20] + (if (w/eval-js refresh-ready-script) + true + (if (zero? remaining) + (throw (ex-info "test env not ready after refresh" {})) + (do + (util/wait-timeout 250) + (recur (dec remaining))))))) + +(defn- test-env-ready? + [] + (try + (wait-test-env-ready!) + true + (catch Throwable _e + false))) + +(defn refresh-test-env! + [] + (loop [attempt 0] + (w/refresh) + (assert/assert-graph-loaded?) + (if (test-env-ready?) + true + (if (< attempt 2) + (recur (inc attempt)) + (wait-test-env-ready!))))) + +(defn developer-mode + [] + (w/eval-js e2e-init-script) + (assert/assert-in-normal-mode?)) diff --git a/clj-e2e/src/logseq/e2e/util.clj b/clj-e2e/src/logseq/e2e/util.clj new file mode 100644 index 0000000..deb1614 --- /dev/null +++ b/clj-e2e/src/logseq/e2e/util.clj @@ -0,0 +1,233 @@ +(ns logseq.e2e.util + (:refer-clojure :exclude [type]) + (:require [clojure.string :as string] + [clojure.test :refer [is]] + [logseq.e2e.assert :as assert] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [wally.main :as w] + [wally.repl :as repl]) + (:import [com.microsoft.playwright + Locator$PressSequentiallyOptions + Locator$FilterOptions + Locator$ClickOptions + Page$GetByTextOptions + TimeoutError] + [com.microsoft.playwright.options + MouseButton])) + +(defn repeat-until-visible + [n q repeat-fn] + (when-not (w/visible? q) + (loop [i n] + (repeat-fn) + (let [visible? + (try + (assert/assert-is-visible q) + (catch TimeoutError e + (if (zero? i) + (throw e) + false)))] + (when-not visible? + (recur (dec i))))))) + +(defn wait-timeout + [ms] + (.waitForTimeout (w/get-page) ms)) + +(defn get-active-element + [] + (w/-query "*:focus")) + +(def editor-q ".editor-wrapper textarea") + +(defn get-editor + [] + (let [editor (w/-query editor-q)] + (when (w/visible? editor-q) + ;; ensure cursor exists + ;; Sometimes when the editor exists, there isn't a blinking cursor, + ;; causing subsequent operations (like pressing Enter) to fail. + (try + (.focus editor) + editor + (catch TimeoutError error + (when (w/visible? editor-q) + (throw error))))))) + +(defn get-edit-block-container + [] + (assert/assert-have-count editor-q 1) + (first (w/query ".ls-block" {:has (w/-query editor-q)}))) + +(defn input + "Notice this will replace the existing input value with `text`" + [text] + (w/fill "*:focus" text)) + +(defn press-seq + [text & {:keys [delay] :or {delay 0}}] + (let [input-node (w/-query "*:focus")] + (.pressSequentially input-node text + (.setDelay (Locator$PressSequentiallyOptions.) delay)))) + +(defn exit-edit + [] + (dotimes [_ 2] + (when (get-editor) + (k/esc))) + (assert/assert-non-editor-mode)) + +(defn double-esc + "Exits editing mode and ensure there's no action bar" + [] + (when (w/visible? ".ui__popover-content, .ui__dropdown-menu-content, .ui__context-menu-content") + (k/esc)) + (exit-edit) + (when (w/visible? ".ui__popover-content, .ui__dropdown-menu-content, .ui__context-menu-content") + (k/esc))) + +(defn search + [text] + (if (w/visible? ".cp__cmdk-search-input") + (w/fill ".cp__cmdk-search-input" text) + (do + (k/press "ControlOrMeta+k") + (when-not (w/visible? ".cp__cmdk-search-input") + (double-esc) + (assert/assert-in-normal-mode?) + (w/click :#search-button)) + (w/wait-for ".cp__cmdk-search-input") + (w/fill ".cp__cmdk-search-input" text)))) + +(defn search-and-click + [search-text] + (search search-text) + (let [result (.first (w/get-by-test-id search-text))] + (repeat-until-visible 5 result #(do + (search search-text) + (wait-timeout 300))) + (w/click result))) + +(defn wait-editor-gone + ([] + (wait-editor-gone ".editor-wrapper textarea")) + ([editor] + (w/wait-for-not-visible editor))) + +(defn wait-editor-visible + [] + (w/wait-for ".editor-wrapper textarea")) + +(defn count-elements + [q] + (w/count* (w/-query q))) + +(defn blocks-count + "Blocks count including page title" + [] + (count-elements ".ls-block:not(.block-add-button)")) + +(defn page-blocks-count + [] + (count-elements ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)")) + +(defn get-text + [locator] + (if (string? locator) + (.textContent (w/-query locator)) + (.textContent locator))) + +(defn get-edit-content + [] + (when-let [editor (get-editor)] + (get-text editor))) + +(defn bounding-xy + [locator] + (let [box (.boundingBox locator)] + [(.-x box) (.-y box)])) + +(defn repeat-keyboard + [n shortcut] + (dotimes [_i n] + (k/press shortcut {:delay 20}))) + +(defn get-page-blocks-contents + [] + (w/all-text-contents ".ls-page-blocks .ls-block:not(.block-add-button) .block-title-wrap")) + +(def mac? (= "Mac OS X" (System/getProperty "os.name"))) + +(defn login-test-account + [& {:keys [username password] + :or {username "e2etest" + password "Logseq-e2e"}}] + (w/eval-js "localStorage.setItem(\"login-enabled\",true);") + (w/click ".toolbar-dots-btn") + (w/click "div:text(\"Login\")") + (input username) + (k/tab) + (input password) + (w/click ".cp__user-login button[type=\"submit\"]") + (w/wait-for-not-visible ".cp__user-login")) + +(defn goto-journals + [] + (search-and-click "Go to journals")) + +(defn refresh-until-graph-loaded + [] + (w/refresh) + (assert/assert-graph-loaded?)) + +(defn move-cursor-to-end + [] + (k/press ["ControlOrMeta+a" "ArrowRight"] + {:delay 20})) + +(defn move-cursor-to-start + [] + (k/press ["ControlOrMeta+a" "ArrowLeft"] + {:delay 20})) + +(defn input-command + [command] + (let [content (get-edit-content)] + (when (and (not= (str (last content)) " ") + (not= content "")) + (press-seq " "))) + (press-seq "/" {:delay 20}) + (w/wait-for ".ui__popover-content") + (press-seq command {:delay 20}) + (let [command-item (w/-query "a.menu-link.chosen")] + (assert/assert-is-visible command-item) + (w/click command-item))) + +(defn set-tag + "`hidden?`: some tags may be hidden from the UI, e.g. Page" + [tag & {:keys [hidden?] + :or {hidden? false}}] + (press-seq " #" {:delay 20}) + (press-seq tag) + (w/click (first (w/query (format "a.menu-link:has-text(\"%s\")" tag)))) + (when (and (not= (string/lower-case tag) "task") (not hidden?)) + ;; wait tag added on ui + (assert/assert-is-visible + (-> ".ls-block:not(.block-add-button)" + (loc/filter :has ".editor-wrapper textarea") + (loc/filter :has (format ".block-tag :text('%s')" tag)))))) + +(defn -query-last + [q] + (.last (w/-query q))) + +(defn get-by-text + [text exact?] + (if exact? + (.getByText (w/get-page) text (.setExact (Page$GetByTextOptions.) true)) + (.getByText (w/get-page) text))) + +(defn right-click + [q] + (w/click q (-> (Locator$ClickOptions.) (.setButton MouseButton/RIGHT)))) diff --git a/clj-e2e/test/logseq/e2e/assets_basic_test.clj b/clj-e2e/test/logseq/e2e/assets_basic_test.clj new file mode 100644 index 0000000..0985d82 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/assets_basic_test.clj @@ -0,0 +1,78 @@ +(ns logseq.e2e.assets-basic-test + (:require + [clojure.string :as string] + [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import + (java.nio.file Paths) + (java.util.function Consumer))) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(defn- asset-path + [relative-path] + (-> (Paths/get (str "../" relative-path) (into-array String [])) + (.toAbsolutePath) + (.normalize))) + +(deftest image-upload-lightbox-and-resize-test + (testing "uploaded images create stable asset blocks and retain resized metadata" + (let [page (w/get-page) + files [(asset-path "assets/icon.png") + (asset-path "assets/splash.png") + (asset-path "resources/img/logo.png")] + pending-files (atom files)] + (.onFileChooser + page + (reify Consumer + (accept [_ chooser] + (let [file (first @pending-files)] + (swap! pending-files rest) + (.setFiles chooser + (into-array java.nio.file.Path [file])))))) + (b/new-block "image uploads") + (doseq [expected-count (range 1 (inc (count files)))] + (util/input-command "Upload an asset") + (assert/assert-have-count + ".ls-page-blocks .asset-container img" + expected-count)) + (assert/assert-have-count ".ls-page-blocks .asset-container img" 3) + (doseq [image (.all (w/-query ".ls-page-blocks .asset-container img"))] + (is (not (string/blank? (.getAttribute image "src")))) + (is (pos? (.evaluate image "image => image.naturalWidth")))) + (let [first-image (.first (w/-query ".ls-page-blocks .asset-container img")) + first-block (.first (w/-query ".ls-page-blocks .ls-block:has(.asset-container img)")) + block-uuid (.getAttribute first-block "blockid")] + (w/click first-image) + (assert/assert-is-visible ".pswp.pswp--open") + (.waitForFunction page "() => window.pswp?.opener?.isOpen" nil) + (w/click (w/get-by-label "Close")) + (assert/assert-is-hidden ".pswp.pswp--open") + (.hover (.first (w/-query ".ls-page-blocks .ls-resize-image"))) + (let [handle (.first (w/-query ".ls-page-blocks .image-resize.handle-right")) + target (.first (w/-query ".ls-page-blocks .block-content"))] + (assert/assert-is-visible handle) + (.dragTo handle target)) + (util/wait-timeout 250) + (let [block (ls-api-call! :editor.getBlock block-uuid) + metadata (get block "properties")] + (is (some? metadata))) + (w/refresh) + (assert/assert-have-count ".ls-page-blocks .asset-container img" 3)) + (let [asset-tag (ls-api-call! :editor.getTag "logseq.class/Asset")] + (ls-api-call! :app.pushState + "page" + {"name" (get asset-tag "uuid")} + nil)) + (w/wait-for ".ls-view-body .ls-table-row") + (assert/assert-have-count ".ls-view-body .ls-table-row" 3) + (assert/assert-is-visible + ".ls-table-header-cell:has-text('File')") + (assert/assert-is-hidden + ".ls-table-header-cell:has-text('checksum')")))) diff --git a/clj-e2e/test/logseq/e2e/bidirectional_properties_test.clj b/clj-e2e/test/logseq/e2e/bidirectional_properties_test.clj new file mode 100644 index 0000000..76c9d71 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/bidirectional_properties_test.clj @@ -0,0 +1,51 @@ +(ns logseq.e2e.bidirectional-properties-test + (:require [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.page :as page] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(deftest bidirectional-properties-test + (testing "shows reverse property references when a class enables bidirectional properties" + (let [friend-prop "friend" + person-tag "Person" + project-tag "Project" + target "Bob" + container-page "Bidirectional Props"] + (ls-api-call! :editor.createTag person-tag + {:tagProperties [{:name friend-prop + :schema {:type "node"}}]}) + (ls-api-call! :editor.createTag project-tag) + (let [person (ls-api-call! :editor.getTag person-tag) + person-uuid (get person "uuid") + friend (ls-api-call! :editor.getPage friend-prop)] + (ls-api-call! :editor.upsertBlockProperty (get friend "id") + "logseq.property/classes" + (get person "id")) + (is (string? person-uuid)) + (ls-api-call! :editor.upsertBlockProperty person-uuid + "logseq.property.class/bidirectional-property-title" + "People") + (ls-api-call! :editor.upsertBlockProperty person-uuid + "logseq.property.class/enable-bidirectional?" + true)) + (ls-api-call! :editor.createPage target) + (ls-api-call! :editor.createPage container-page) + (let [bob (ls-api-call! :editor.getPage target) + bob-id (get bob "id")] + (ls-api-call! :editor.insertBlock container-page (str "Alice #" person-tag) + {:properties {friend-prop bob-id}}) + (ls-api-call! :editor.insertBlock container-page (str "Charlie #" project-tag) + {:properties {friend-prop bob-id}})) + + (page/goto-page target) + (w/wait-for ".property-k:text('People')") + (assert/assert-is-visible ".property-value .block-title-wrap:text('Alice')") + (assert/assert-have-count ".property-k:text('Projects')" 0)))) diff --git a/clj-e2e/test/logseq/e2e/block_property_basic_test.clj b/clj-e2e/test/logseq/e2e/block_property_basic_test.clj new file mode 100644 index 0000000..9653944 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/block_property_basic_test.clj @@ -0,0 +1,507 @@ +(ns logseq.e2e.block-property-basic-test + (:require + [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(defn- current-page-name + [] + (page/get-page-name)) + +(defn- add-property-through-ui! + [property-name property-type] + (util/input-command "Add property") + (w/click "input[placeholder]") + (util/input property-name) + (w/click (w/get-by-text "New option:")) + (w/click (loc/and "span" (util/get-by-text property-type true)))) + +(defn- block-value + [uuid property-name] + (let [result (ls-api-call! :editor.getBlockProperty uuid property-name)] + (if (instance? java.util.Map result) + (get result "value") + result))) + +(defn- open-flashcards! + [] + (util/double-esc) + (when-not (w/visible? "#left-sidebar.is-open") + (w/click "#left-menu") + (w/wait-for "#left-sidebar.is-open")) + (w/wait-for ".flashcards-nav") + (w/click ".flashcards-nav a") + (assert/assert-is-visible "#cards-modal")) + +(defn- wait-for-property-type + [property-name expected-type] + (loop [remaining-attempts 20] + (let [actual-type (get (ls-api-call! :editor.getProperty property-name) + "type")] + (if (or (= expected-type actual-type) + (zero? remaining-attempts)) + actual-type + (do + (util/wait-timeout 50) + (recur (dec remaining-attempts))))))) + +(deftest references-embeds-and-mounted-instance-refresh-test + (testing "page refs, embeds, linked refs and mounted copies share canonical updates" + (let [target-page "block sample target" + source-page "block sample source"] + (page/new-page target-page) + (let [target (ls-api-call! :editor.appendBlockInPage + target-page + "target parent") + target-uuid (get target "uuid")] + (ls-api-call! :editor.insertBlock + target-uuid + "target child" + {:sibling false}) + (page/new-page source-page) + (b/new-block (str "[[" target-page "]]")) + (b/new-block "") + (util/input-command "Node embed") + (util/press-seq target-page) + (assert/assert-is-visible + (loc/filter ".ui__popover-content a.menu-link.chosen" + :has-text target-page)) + (k/enter) + (assert/assert-is-visible + (loc/filter ".embed-block" :has-text "target child")) + (ls-api-call! :editor.openInRightSidebar target-uuid) + (w/click + (format ".cp__right-sidebar #block-content-%s" target-uuid)) + (w/fill util/editor-q "target parent updated") + (util/exit-edit) + (doseq [container ["#main-content-container" ".cp__right-sidebar"]] + (assert/assert-is-visible + (loc/filter container :has-text "target parent updated"))) + (page/goto-page target-page) + (assert/assert-is-visible + (loc/filter ".references" :has-text source-page)))))) + +(deftest unlinked-reference-filter-and-breadcrumb-test + (testing "unlinked-reference search updates results and breadcrumbs track renames" + (let [target "reference sample target" + include-page "reference include" + exclude-page "reference exclude"] + (page/new-page target) + (let [parent (ls-api-call! :editor.appendBlockInPage + target + "target nested parent") + parent-uuid (get parent "uuid") + child (ls-api-call! :editor.insertBlock + parent-uuid + "target nested child" + {:sibling false}) + child-uuid (get child "uuid")] + (page/new-page include-page) + (b/new-block (str target " plain mention")) + (page/new-page exclude-page) + (b/new-block (str target " another mention")) + (page/goto-page target) + (w/click ".unlinked-references .ls-foldable-title-control") + (assert/assert-is-visible + (loc/filter ".unlinked-references" :has-text include-page)) + (w/click + ".unlinked-references button:has(.ls-icon-search)") + (w/fill ".unlinked-references .view-action-search input" include-page) + (assert/assert-have-count + (loc/filter ".unlinked-references .ls-view-body" :has-text exclude-page) + 0) + (w/click (format "#dot-%s" child-uuid)) + (assert/assert-is-visible + (loc/filter ".breadcrumb" :has-text "target nested parent")) + (ls-api-call! :editor.updateBlock parent-uuid "target renamed parent") + (assert/assert-is-visible + (loc/filter ".breadcrumb" :has-text "target renamed parent")))))) + +(deftest flashcard-rating-advances-once-test + (testing "answer reveal and one rating advance to the next card and update due count" + (let [page-name (current-page-name)] + (ls-api-call! :editor.appendBlockInPage page-name "sample card one #Card") + (ls-api-call! :editor.appendBlockInPage page-name "sample card two #Card") + (open-flashcards!) + (w/click "#card-answers") + (assert/assert-is-visible + (loc/filter "#cards-modal" :has-text "Again")) + (let [before (util/get-text "#cards-modal")] + (w/click "#card-good") + (let [after (util/get-text "#cards-modal")] + (is (not= before after)))) + (assert/assert-have-count "#cards-modal .card-rating-loading" 0)))) + +(deftest multi-target-comment-draft-edit-delete-test + (testing "multi-target comments preserve drafts and support edit/delete lifecycle" + (b/new-blocks ["comment sample a" "comment sample b"]) + (b/select-blocks 2) + (util/search-and-click "Add comment") + (assert/assert-is-visible + (loc/filter ".ls-comments-area" :has-text "those blocks")) + (w/fill ".ls-comment-add textarea" "draft sample comment") + (k/esc) + (assert/assert-have-count + (loc/filter ".ls-comment-row" :has-text "draft sample comment") + 0) + (let [reply-placeholder + (loc/filter ".ls-comment-reply-placeholder" + :has-text "draft sample comment")] + (assert/assert-is-visible reply-placeholder) + (w/click reply-placeholder)) + (is (= "draft sample comment" + (.inputValue (w/-query ".ls-comment-add textarea")))) + (w/fill ".ls-comment-add textarea" "saved sample comment") + (w/click ".ls-comment-submit") + (assert/assert-is-visible + (loc/filter ".ls-comment-row" :has-text "saved sample comment")) + (w/click ".ls-comment-row button[aria-label='Click to edit']") + (w/fill ".ls-comment-row textarea" "edited sample comment") + (k/enter) + (assert/assert-is-visible + (loc/filter ".ls-comment-row" :has-text "edited sample comment")) + (w/click ".ls-comment-row button[title='Delete']") + (assert/assert-have-count ".ls-comment-row" 0))) + +(deftest block-and-comment-reaction-toggle-test + (testing "block and comment reactions use their own targets and toggle without duplicates" + (b/new-blocks ["reaction sample a" "reaction sample b"]) + (b/select-blocks 2) + (util/search-and-click "Add reaction") + (w/fill ".ls-icon-picker input" "thumbs up") + (w/click ".ls-icon-picker button:has(em-emoji[id='+1'])") + (assert/assert-have-count + ".ls-page-blocks .ls-block-reactions button:has(em-emoji[id='+1'])" + 2) + (w/click + ".ls-page-blocks .ls-block:has-text('reaction sample a') .ls-block-reactions button:has(em-emoji[id='+1'])") + (assert/assert-have-count + ".ls-page-blocks .ls-block:has-text('reaction sample a') .ls-block-reactions button:has(em-emoji[id='+1'])" + 0) + (w/click (loc/filter ".block-title-wrap" :has-text "reaction sample b")) + (util/search-and-click "Add comment") + (w/fill ".ls-comment-add textarea" "reaction comment") + (w/click ".ls-comment-submit") + (w/click ".ls-comment-row button[title='Add reaction']") + (w/fill ".ls-icon-picker input" "red heart") + (w/click ".ls-icon-picker button:has(em-emoji[id='heart'])") + (assert/assert-have-count + ".ls-comment-row .ls-block-reactions button:has(em-emoji[id='heart'])" + 1) + (assert/assert-have-count + ".ls-page-blocks .ls-block-reactions button:has(em-emoji[id='heart'])" + 1))) + +(deftest icon-and-structural-tag-visibility-test + (testing "icons update mounted references and internal tags stay hidden from user tags" + (let [page-name "icon sample page"] + (page/new-page page-name) + (util/exit-edit) + (w/click "button:text('Add icon')") + (w/fill ".cp__emoji-icon-picker input" "books") + (w/click ".cp__emoji-icon-picker button:has(em-emoji[id='books'])") + (assert/assert-is-visible ".ls-page-icon") + (b/new-block "icon sample block") + (util/set-tag "task") + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".block-tag" :has-text "task")) + (assert/assert-have-count + (loc/filter ".block-tag" :has-text "Page") + 0) + (ls-api-call! :editor.openInRightSidebar + (get (ls-api-call! :editor.getPage page-name) "uuid")) + (assert/assert-is-visible + ".cp__right-sidebar .page-title em-emoji[id='books']") + (w/click ".ls-page-icon button") + (w/click ".cp__emoji-icon-picker button[data-action='del']") + (assert/assert-have-count "em-emoji[id='books']" 0)))) + +(deftest property-create-and-name-validation-test + (testing "property creation is unique and invalid names cannot create ghost nodes" + (b/new-block "property validation owner") + (add-property-through-ui! "valid-property" "Text") + (assert/assert-have-count + (loc/filter ".property-k" :has-text "valid-property") + 1) + (b/open-last-block) + (util/input-command "Add property") + (w/click "input[placeholder]") + (assert/assert-have-count (util/get-by-text "New option:" false) 0) + (k/esc) + (doseq [invalid-name ["[[bad" "#bad"]] + (b/open-last-block) + (util/input-command "Add property") + (w/click "input[placeholder]") + (util/input invalid-name) + (w/click (w/get-by-text "New option:")) + (w/click (loc/and "span" (util/get-by-text "Text" true))) + (assert/assert-is-visible + (loc/filter ".ui__toast.error" :has-text "invalid property name")) + (doseq [message ["Property failed to create" + "invalid property name"]] + (let [toast (loc/filter ".ui__toast.error" :has-text message)] + (when (w/visible? toast) + (w/click (.locator toast ".ui__toast-close"))))) + (k/esc)) + (is (nil? (ls-api-call! :editor.getProperty "[[bad"))) + (is (nil? (ls-api-call! :editor.getProperty "#bad"))))) + +(deftest scalar-property-value-validation-test + (testing "number/date reject incompatible text and URL values preserve valid edits" + (let [page-name (current-page-name) + block (ls-api-call! :editor.appendBlockInPage page-name "scalar property owner") + uuid (get block "uuid") + journal (ls-api-call! :editor.createJournalPage + "2026-07-28T12:00:00") + journal-id (get journal "id")] + (doseq [[property-name property-type valid-value invalid-value] + [["sample-number" "number" -12.5 "not-a-number"] + ["sample-date" "date" journal-id "not-a-date"]]] + (ls-api-call! :editor.upsertProperty property-name {:type property-type}) + (ls-api-call! :editor.upsertBlockProperty uuid property-name valid-value) + (is (some? (block-value uuid property-name))) + (let [property-row + (loc/filter (format "#ls-block-%s .bottom-property-pill" uuid) + :has-text property-name)] + (w/click + (.locator property-row ".bottom-property-content .jtrigger"))) + (k/press "ControlOrMeta+a") + (util/press-seq invalid-value) + (k/enter) + (is (not= invalid-value (block-value uuid property-name)))) + (let [property-name "sample-url" + initial-value "https://logseq.com" + updated-value "https://docs.logseq.com"] + (ls-api-call! :editor.upsertProperty property-name {:type "url"}) + (ls-api-call! :editor.upsertBlockProperty uuid property-name initial-value) + (is (= initial-value (block-value uuid property-name))) + (let [property-row + (loc/filter (format "#ls-block-%s .property-pair" uuid) + :has-text property-name)] + (w/click + (.locator property-row ".property-block-container.jtrigger"))) + (util/input updated-value) + (k/enter) + (is (= updated-value (block-value uuid property-name)))) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible (format "#ls-block-%s" uuid))))) + +(deftest property-type-cardinality-and-checkbox-choice-test + (testing "schema controls expose cardinality and allow unused properties to change type" + (let [property-name "sample-schema-property" + checkbox-property-name "sample-checkbox-choice" + page-name (current-page-name) + block (ls-api-call! :editor.appendBlockInPage page-name "schema property owner") + uuid (get block "uuid")] + (ls-api-call! :editor.upsertProperty property-name + {:type "default" :cardinality "many"}) + (ls-api-call! :editor.upsertBlockProperty uuid property-name ["one" "two"]) + (is (= 2 (count (block-value uuid property-name)))) + (page/goto-page property-name) + (assert/assert-is-visible + (loc/filter ".ls-table-header-cell" :has-text property-name)) + (w/click + (.first + (loc/filter ".ls-table-header-cell" :has-text property-name))) + (assert/assert-is-visible + (loc/filter ".ls-property-dropdown" :has-text "Multiple values")) + (assert/assert-is-visible + (loc/filter ".ls-property-dropdown" :has-text "Property type")) + (k/esc) + (ls-api-call! :editor.upsertProperty + checkbox-property-name + {:type "default"}) + (page/goto-page checkbox-property-name) + (assert/assert-is-visible + (loc/filter ".ls-table-header-cell" :has-text checkbox-property-name)) + (w/click + (.first + (loc/filter ".ls-table-header-cell" :has-text checkbox-property-name))) + (w/click + (loc/filter "[role='menuitem']" :has-text "Property type")) + (w/click + (util/get-by-text "Checkbox" true)) + (is (= "checkbox" + (wait-for-property-type checkbox-property-name "checkbox")))))) + +(deftest property-default-description-position-and-hidden-state-test + (testing "property presentation/default settings apply to new objects and hidden values" + (let [property-name "sample-configured-property" + tag-name "sample configured class" + property (ls-api-call! :editor.upsertProperty + property-name + {:type "default"}) + property-uuid (get property "uuid")] + (ls-api-call! :editor.upsertBlockProperty + property-uuid + "logseq.property/default-value" + "sample default") + (ls-api-call! :editor.upsertBlockProperty + property-uuid + "logseq.property/description" + "sample description") + (ls-api-call! :editor.createTag + tag-name + {:tagProperties [{:name property-name}]}) + (page/new-page "configured object host") + (b/new-block "configured object") + (util/set-tag tag-name) + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".property-pair" :has-text "sample default")) + (is (= "sample description" + (block-value property-uuid + "logseq.property/description"))) + (ls-api-call! :editor.upsertBlockProperty + property-uuid + "logseq.property/hide-empty-value" + true) + (assert/assert-have-count + (loc/filter ".property-pair" :has-text property-name) + 0) + (k/press "p") + (k/press "a") + (assert/assert-is-visible + (loc/filter ".property-pair" :has-text "sample default"))))) + +(deftest property-delete-and-bidirectional-refresh-test + (testing "bidirectional values refresh both sides and deleting the definition removes usages" + (let [property-name "sample bidirectional" + owner-tag "SampleOwner" + container-page "bidirectional refresh host" + owner-class (ls-api-call! :editor.createTag + owner-tag + {:tagProperties [{:name property-name + :schema {:type "node"}}]}) + property (ls-api-call! :editor.getProperty property-name)] + (ls-api-call! :editor.upsertBlockProperty + (get property "id") + "logseq.property/classes" + (get owner-class "id")) + (ls-api-call! :editor.upsertBlockProperty + (get owner-class "uuid") + "logseq.property.class/enable-bidirectional?" + true) + (ls-api-call! :editor.createPage container-page) + (let [owner (ls-api-call! :editor.insertBlock + container-page + (str "sample owner object #" owner-tag)) + target (ls-api-call! :editor.createPage "sample target object")] + (ls-api-call! :editor.upsertBlockProperty + (get owner "uuid") property-name (get target "id")) + (page/goto-page "sample target object") + (assert/assert-is-visible + (loc/filter ".ls-bidirectional-properties" :has-text "sample owner object")) + (ls-api-call! :editor.removeBlockProperty (get owner "uuid") property-name) + (assert/assert-have-count + (loc/filter ".ls-bidirectional-properties" :has-text "sample owner object") + 0) + (ls-api-call! :editor.removeProperty property-name) + (is (nil? (ls-api-call! :editor.getProperty property-name))))))) + +(deftest tag-inheritance-schema-and-object-view-test + (testing "class inheritance and schema mutations update object views without losing values" + (let [parent-tag-name "SampleParent" + child-tag-name "SampleChild" + inherited-property "sample-inherited-property" + child-property "sample-child-property" + parent-tag (ls-api-call! :editor.createTag + parent-tag-name + {:tagProperties [{:name inherited-property}]}) + child-tag (ls-api-call! :editor.createTag + child-tag-name + {:tagProperties [{:name child-property}]})] + (ls-api-call! :editor.addTagExtends + (get child-tag "id") + (get parent-tag "id")) + (page/new-page "tag object host") + (let [object (ls-api-call! :editor.appendBlockInPage + (current-page-name) + (str "sample child object #" child-tag-name) + {:properties {inherited-property "kept"}})] + (page/goto-page parent-tag-name) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text "sample child object")) + (assert/assert-is-visible + (loc/filter ".ls-table-header-cell" :has-text inherited-property)) + (ls-api-call! :editor.upsertBlockProperty + (get object "uuid") child-property "child value") + (page/goto-page child-tag-name) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text "child value")) + (is (= "kept" + (block-value (get object "uuid") + inherited-property))))))) + +(deftest tag-template-dynamic-values-test + (testing "a tag template is applied once and resolves dynamic date references" + (let [tag-name "sample template class" + template-page "sample tag template" + template-root "sample template root"] + (page/new-page template-page) + (let [template (ls-api-call! :editor.appendBlockInPage + template-page + template-root) + template-uuid (get template "uuid") + template-tag (ls-api-call! :editor.getTag "Template") + tag (ls-api-call! :editor.createTag tag-name)] + (ls-api-call! :editor.upsertBlockProperty + template-uuid + "block/tags" + (get template-tag "id")) + (ls-api-call! :editor.insertBatchBlock + template-uuid + [{:content "template static child"} + {:content "template date <% today %>"}] + {:sibling false}) + (ls-api-call! :editor.upsertBlockProperty + template-uuid + "logseq.property/template-applied-to" + (get tag "id")) + (page/new-page "sample templated object") + (let [object (ls-api-call! :editor.appendBlockInPage + (current-page-name) + "templated object")] + (ls-api-call! :editor.upsertBlockProperty + (get object "uuid") + "block/tags" + (get tag "id"))) + (assert/assert-have-count + (loc/filter ".ls-page-blocks" :has-text "template static child") + 1) + (assert/assert-is-visible ".page-reference") + (util/refresh-until-graph-loaded) + (assert/assert-have-count + (loc/filter ".ls-page-blocks" :has-text "template static child") + 1))))) + +(deftest checkbox-property-toggle-persists-test + (testing "checkbox property toggles immediately and survives refresh" + (let [property-name "e2e checkbox" + page-name (page/get-page-name)] + (ls-api-call! :editor.upsertProperty property-name {:type "checkbox"}) + (let [block (ls-api-call! :editor.appendBlockInPage + page-name + "checkbox owner" + {:properties {property-name false}}) + uuid (get block "uuid") + checkbox (w/-query + (format "#ls-block-%s .bottom-property-pill" uuid))] + (w/click (.locator checkbox "button[role='checkbox']")) + (assert/assert-is-visible + (.locator checkbox "button[role='checkbox'][data-checked]")) + (is (true? (block-value uuid property-name))) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible + (format "#ls-block-%s [role='checkbox'][data-checked]" uuid)))))) diff --git a/clj-e2e/test/logseq/e2e/cmdk_scroll_basic_test.clj b/clj-e2e/test/logseq/e2e/cmdk_scroll_basic_test.clj new file mode 100644 index 0000000..79ec9af --- /dev/null +++ b/clj-e2e/test/logseq/e2e/cmdk_scroll_basic_test.clj @@ -0,0 +1,163 @@ +(ns logseq.e2e.cmdk-scroll-basic-test + "E2E tests for Cmd+K scroll & highlight behavior." + (:require + [clojure.test :refer [deftest testing is use-fixtures]] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.page :as p] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(def ^:private scroll-container ".cp__cmdk .overflow-y-auto") +(def ^:private kbd-highlight (str scroll-container " [data-kb-highlighted]")) +(def ^:private mouse-item (str scroll-container " .transition-colors.cursor-pointer")) + +(defn- setup-search + "Creates `n` pages with `prefix` and `suffix`, opens cmdk and types the `prefix`." + ([prefix n] (setup-search prefix n "")) + ([prefix n suffix] + [prefix n suffix] + (dotimes [i n] (p/new-page (str prefix i suffix))) + (util/search prefix) + (util/wait-timeout 500))) + +(defn- simulate-mouse-move-into-results! + "Dispatches a synthetic mousemove with non-zero movementX/Y on a result item. + Playwright's CDP mouse.move does not populate movementX/Y, so we must + construct the event in JS and patch those read-only fields." + [] + (w/eval-js + (str "(() => { + const c = document.querySelector('" scroll-container "'); + if (!c) return; + const items = c.querySelectorAll('.transition-colors'); + const target = items[2] || items[0] || c; + const rect = target.getBoundingClientRect(); + const evt = new MouseEvent('mousemove', { + bubbles: true, cancelable: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2 + }); + Object.defineProperty(evt, 'movementX', {value: 5}); + Object.defineProperty(evt, 'movementY', {value: 3}); + target.dispatchEvent(evt); + })()"))) + +(defn- kbd-highlight-in-viewport? + "Returns true when the keyboard-highlighted element is fully within + the visible scroll area. Allows ±5 px tolerance for borders/padding." + [] + (w/eval-js + (str "(() => { + const el = document.querySelector('" kbd-highlight "'); + if (!el) return false; + const c = document.querySelector('" scroll-container "'); + const cr = c.getBoundingClientRect(); + const er = el.getBoundingClientRect(); + return er.top >= cr.top - 5 && er.bottom <= cr.bottom + 5; + })()"))) + +(deftest cmdk-keeps-results-visible-while-searching + (testing "typing and deleting do not clear the visible results before the debounced search finishes" + (let [prefix (str "cmdkflicker" (random-uuid)) + result-selector (format "[data-testid^='%s']" prefix)] + (setup-search prefix 2 "a") + (let [result-count (util/count-elements result-selector)] + (is (pos? result-count) "initial search results are visible") + + (util/press-seq "a") + (is (= result-count (util/count-elements result-selector)) + "typing keeps the previous results visible") + (util/wait-timeout 400) + (is (= result-count (util/count-elements result-selector)) + "matching results remain visible after search settles") + + (k/press "Backspace") + (is (= result-count (util/count-elements result-selector)) + "deleting keeps the previous results visible") + (util/wait-timeout 400) + (is (= result-count (util/count-elements result-selector)) + "matching results remain visible after deletion search settles"))))) + +;; --------------------------------------------------------------------------- +;; Highlight mode switching +;; +;; Cycles keyboard → mouse → keyboard → mouse and verifies: +;; • keyboard mode: [data-kb-highlighted] = 1, mouse-item count = 0 +;; • mouse mode: [data-kb-highlighted] = 0, mouse-item count > 0 +;; --------------------------------------------------------------------------- + +(deftest cmdk-highlight-mode-switching + (testing "keyboard & mouse highlight mode toggles correctly" + (setup-search "cmdkhighlight" 8) + + (dotimes [_ 3] (k/arrow-down)) + (util/wait-timeout 100) + (is (= 1 (util/count-elements kbd-highlight)) + "keyboard mode → keyboard-highlight = 1") + (is (= 0 (util/count-elements mouse-item)) + "keyboard mode → mouse-mode items = 0") + + (simulate-mouse-move-into-results!) + (util/wait-timeout 200) + (is (= 0 (util/count-elements kbd-highlight)) + "mouse mode → keyboard-highlight = 0") + (is (pos? (util/count-elements mouse-item)) + "mouse mode → mouse-mode items > 0") + + (k/arrow-down) + (util/wait-timeout 100) + (is (= 1 (util/count-elements kbd-highlight)) + "keyboard mode again → keyboard-highlight = 1") + (is (= 0 (util/count-elements mouse-item)) + "keyboard mode again → mouse-mode items = 0") + + (simulate-mouse-move-into-results!) + (util/wait-timeout 200) + (is (= 0 (util/count-elements kbd-highlight)) + "mouse mode again → keyboard-highlight = 0") + (is (pos? (util/count-elements mouse-item)) + "mouse mode again → mouse-mode items > 0"))) + +;; --------------------------------------------------------------------------- +;; Lazy-visible keyboard scroll verification +;; +;; Goal: +;; Verify that keyboard navigation scrolls correctly through :nodes items +;; that are wrapped in lazy-visible (always active for :nodes group). +;; This exercises the data-item-index wrapper-div DOM query path in +;; move-highlight, which finds the wrapper even when the inner list-item +;; has not yet been mounted by IntersectionObserver. +;; +;; Layout: +;; viewport_height = 65dvh × 720 px (Playwright default) = 468 px +;; item_height ≈ 32 px, group_header = 32 px +;; Create (32) + nodes header (32) + 30 nodes items (30 × 32 = 960) +;; = 1024 px total — significantly exceeds viewport +;; Navigating 20 items → top ≈ 32 + 32 + 20×32 = 704 px +;; → far below viewport bottom (468 px), deep into lazy territory +;; --------------------------------------------------------------------------- + +(deftest cmdk-lazy-visible-keyboard-scroll + (testing "keyboard navigation scrolls through lazy-visible :nodes items" + (setup-search "cmdklazy" 30) + + ;; Expand :nodes group — sets :show :more. + (k/arrow-down) + (util/wait-timeout 100) + (k/press "ControlOrMeta+ArrowDown") + (util/wait-timeout 300) + + ;; Navigate down 20 steps — well past the viewport into lazy items. + (k/press (vec (repeat 20 "ArrowDown")) {:delay 30}) + (util/wait-timeout 300) + + ;; Verify keyboard highlight exists. + (is (= 1 (util/count-elements kbd-highlight)) + "keyboard highlight present after navigating through lazy items") + + ;; Verify the highlighted element is within the visible scroll area. + (is (kbd-highlight-in-viewport?) "highlight is in viewport after navigating through lazy-visible items"))) diff --git a/clj-e2e/test/logseq/e2e/commands_basic_test.clj b/clj-e2e/test/logseq/e2e/commands_basic_test.clj new file mode 100644 index 0000000..1b372e1 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/commands_basic_test.clj @@ -0,0 +1,416 @@ +(ns logseq.e2e.commands-basic-test + (:require + [clojure.string :as string] + [clojure.test :refer [deftest testing is use-fixtures]] + [jsonista.core :as json] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(def ^:private date-picker-day-selector + ".ui__calendar [role='gridcell'] button, .ui__calendar button[role='gridcell']") + +(defn- focused-date-picker-day + [] + (json/read-value + (w/eval-js + (format + "(() => { + const active = document.activeElement; + const dayButton = active?.closest?.(%s); + return JSON.stringify({ + focused: !!dayButton, + text: dayButton?.textContent ?? null, + label: dayButton?.getAttribute('aria-label') ?? dayButton?.textContent ?? null + }); + })()" + (json/write-value-as-string date-picker-day-selector))) + json/keyword-keys-object-mapper)) + +(defn- assert-date-picker-keyboard-navigation + [command] + (b/new-block (str command " keyboard test")) + (util/input-command command) + (w/wait-for date-picker-day-selector) + (let [initial (focused-date-picker-day)] + (is (:focused initial) + (str command " should focus a calendar day when opened.")) + (k/arrow-right) + (let [right (focused-date-picker-day)] + (is (:focused right) + (str command " should keep calendar focus after ArrowRight.")) + (is (not= (:label initial) (:label right)) + (str command " should move focused date with ArrowRight.")) + (k/arrow-left) + (is (= (:label initial) (:label (focused-date-picker-day))) + (str command " should move focused date back with ArrowLeft."))) + (k/arrow-down) + (let [down (focused-date-picker-day)] + (is (:focused down) + (str command " should keep calendar focus after ArrowDown.")) + (is (not= (:label initial) (:label down)) + (str command " should move focused date with ArrowDown.")) + (k/arrow-up) + (is (= (:label initial) (:label (focused-date-picker-day))) + (str command " should move focused date back with ArrowUp."))) + (k/enter) + (if (= command "date picker") + (w/wait-for-not-visible ".ui__calendar") + (assert/assert-is-visible ".ui__calendar")))) + +(deftest command-trigger-test + (testing "/command trigger popup" + (b/new-block "b2") + (util/press-seq " /") + (w/wait-for "a.menu-link.chosen:has-text('Node reference')") + (k/backspace) + (w/wait-for-not-visible ".ui__popover-content"))) + +(deftest page-reference-test + (testing "Page reference" + (b/new-blocks ["b1" ""]) + (util/input-command "Node reference") + (util/press-seq "Another page") + (k/enter) + (is (= "[[Another page]]" (util/get-edit-content))) + (util/exit-edit) + (is (= "Another page" (util/get-text "a.page-ref"))))) + +(deftest block-reference-test + (testing "Block reference" + (b/new-blocks ["block test" ""]) + (util/input-command "Node reference") + (util/press-seq "block test") + (util/wait-timeout 300) + (k/enter) + (is (string/includes? (util/get-edit-content) "[[")) + (util/exit-edit) + (is (= "block test" (util/get-text "a.page-ref"))))) + +(deftest link-test + (testing "/link" + (let [add-logseq-link (fn [] + (util/press-seq "https://logseq.com") + (k/tab) + (util/press-seq "Logseq") + (k/tab) + (k/enter))] + (b/new-block "") + (util/input-command "link") + (add-logseq-link) + (is (= "[Logseq](https://logseq.com)" (util/get-edit-content))) + (util/press-seq " some content ") + (util/input-command "link") + (add-logseq-link) + (is (= (str "[Logseq](https://logseq.com)" + " some content " + "[Logseq](https://logseq.com)") (util/get-edit-content)))))) + +(deftest link-image-test + (testing "/image link" + (b/new-block "") + (util/input-command "image link") + (util/press-seq "https://logseq.com/test.png") + (k/tab) + (util/press-seq "Logseq") + (k/tab) + (k/enter) + (is (= "![Logseq](https://logseq.com/test.png)" (util/get-edit-content))))) + +(deftest underline-test + (testing "/underline" + (b/new-block "") + (util/input-command "underline") + (is (= "" (util/get-edit-content))) + (util/press-seq "test") + (is (= "test" (util/get-edit-content))) + (util/move-cursor-to-end))) + +(deftest code-block-test + (testing "/code block" + (b/new-block "") + (util/input-command "code block") + (w/wait-for ".CodeMirror") + (util/wait-timeout 100) + ;; create another block + (k/shift+enter) + (assert/assert-is-hidden ".ls-page-blocks .block-tags"))) + +(deftest math-block-test + (testing "/math block" + (b/new-block "") + (util/input-command "math block") + (util/press-seq "1 + 2 = 3") + (util/exit-edit) + (w/wait-for ".katex") + (assert/assert-is-hidden ".ls-page-blocks .block-tags"))) + +(deftest quote-test + (testing "/quote" + (b/new-block "") + (util/input-command "quote") + (w/wait-for "div[data-node-type='quote']") + (assert/assert-is-hidden ".ls-page-blocks .block-tags"))) + +(deftest quote-heading-test + (testing "quote headings render consistently" + (b/new-block "Property quote heading") + (util/input-command "quote") + (util/input-command "h1") + (util/exit-edit) + (b/new-block "# Markdown quote heading") + (util/input-command "quote") + (util/exit-edit) + (assert/assert-is-visible + "div[data-node-type='quote']:has(h1.block-title-wrap.as-heading:has-text('Property quote heading'))") + (assert/assert-is-visible + "div[data-node-type='quote']:has(h1.block-title-wrap.as-heading:has-text('Markdown quote heading'))") + (b/jump-to-block "Markdown quote heading") + (assert/assert-editor-mode) + (is (= "Markdown quote heading" (util/get-edit-content))) + (util/exit-edit) + (b/new-block "Plain quote") + (util/input-command "quote") + (util/exit-edit) + (assert/assert-is-visible + "div[data-node-type='quote']:has(span.block-title-wrap:has-text('Plain quote'))"))) + +(deftest headings-test + (testing "/heading" + (dotimes [i 6] + (let [heading (str "h" (inc i)) + text (str heading " test ")] + (b/new-block text) + (util/input-command heading) + (is (= text (util/get-edit-content))) + (util/exit-edit) + (w/wait-for heading))))) + +(deftest status-test + (testing "task status commands" + (let [status->icon {"Doing" "InProgress50" + "In review" "InReview" + "Canceled" "Cancelled"}] + (doseq [status ["Backlog" "Todo" "Doing" "In review" "Done" "Canceled"]] + (let [text (str status " test ")] + (b/new-block text) + (util/input-command status) + (is (= text (util/get-edit-content))) + (util/exit-edit) + (k/esc) + (w/wait-for (str ".ls-icon-" (get status->icon status status)))))))) + +(deftest priority-test + (testing "task priority commands" + (let [priority->icon {"No priority" "line-dashed"}] + (doseq [priority ["No priority" "Low" "Medium" "High" "Urgent"]] + (let [text (str priority " test ")] + (b/new-block text) + (util/input-command priority) + (is (= text (util/get-edit-content))) + (util/exit-edit) + (w/wait-for (str ".ls-icon-" (get priority->icon priority + (str "priorityLvl" priority))))))))) + +(deftest scheduled-deadline-test + (testing "task scheduled and deadline commands" + (doseq [command ["Scheduled" "Deadline"]] + (fixtures/create-page) + (let [text (str command " test ")] + (b/new-block text) + (util/input-command command) + (k/enter) + (assert/assert-editor-mode) + (util/exit-edit) + (is (= command (util/get-text ".property-k"))) + (is (= "Today" (util/get-text ".ls-datetime a.page-ref"))))))) + +(deftest date-command-keyboard-navigation-test + (testing "date commands focus the calendar and support keyboard navigation" + (doseq [command ["date picker" "Scheduled" "Deadline"]] + (fixtures/create-page) + (assert-date-picker-keyboard-navigation command)))) + +;; TODO: java "MMMM d, yyyy" vs js "MMM do, yyyy" +(deftest date-time-test + (testing "date time commands" + (util/input-command "today") + (let [text (util/get-edit-content)] + (and (string/starts-with? text "[[") + (string/ends-with? text "]]"))) + (b/new-block "") + (util/input-command "yesterday") + (let [text (util/get-edit-content)] + (and (string/starts-with? text "[[") + (string/ends-with? text "]]"))) + (b/new-block "") + (util/input-command "tomorrow") + (let [text (util/get-edit-content)] + (and (string/starts-with? text "[[") + (string/ends-with? text "]]"))) + ;; FIXME: + ;; (b/new-block "") + ;; (util/input-command "time") + ;; (let [text (util/get-edit-content) + ;; t (tl/local-now)] + ;; (is (= text (str (t/hour t) ":" (t/minute t))))) + (b/new-block "") + (util/input-command "date picker") + (let [text (util/get-edit-content)] + (and (string/starts-with? text "[[") + (string/ends-with? text "]]"))))) + +(deftest number-list-test + (testing "number list commands" + (util/input-command "number list") + (b/new-blocks ["a" "b" "c"]) + (assert/assert-have-count "span.typed-list" 3) + (is (= ["1." "2." "3."] (w/all-text-contents "span.typed-list"))) + ;; double `enter` convert the next block to bullet block + (k/enter) + (assert/assert-have-count "span.typed-list" 4) + (util/wait-timeout 60) + (k/enter) + (assert/assert-have-count "span.typed-list" 3) + (is (= ["1." "2." "3."] (w/all-text-contents "span.typed-list"))))) + +(deftest number-children-test + (testing "number children commands" + (b/new-blocks ["a" "a1" "a2" "a3" "b"]) + (k/arrow-up) + (w/wait-for "textarea:text('a3')") + (util/repeat-keyboard 3 "Shift+ArrowUp") + (k/tab) + (b/jump-to-block "a") + (util/input-command "number children") + (assert/assert-have-count "span.typed-list" 3) + (is (= ["1." "2." "3."] (w/all-text-contents "span.typed-list"))))) + +(deftest query-test + (testing "query" + (b/new-blocks ["[[foo]] block" "[[foo]] another" ""]) + (util/input-command "query") + (let [btn (util/-query-last "button:text('filter')")] + (w/click btn) + (util/input "page reference") + (w/click "a.menu-link:has-text('page reference')") + (w/click (first (w/query "a.menu-link:has-text('foo')"))) + (assert/assert-is-visible "div:text('Live query (2)')")))) + +(deftest query-view-membership-updates-live + (testing "a mounted query view inserts and removes matching rows" + (let [tag "live-query-membership" + candidate-title "query membership candidate"] + (b/new-blocks [(format "[[%s]] query seed" tag) + candidate-title + ""]) + (let [candidate-uuid (.getAttribute + (.first + (w/-query + (format ".ls-block[data-block-title='%s']" + candidate-title))) + "blockid") + candidate-row (format ".custom-query-results :text('%s')" + candidate-title)] + (is (string? candidate-uuid)) + (util/input-command "query") + (w/click (util/-query-last "button:text('filter')")) + (util/input "page reference") + (w/click "a.menu-link:has-text('page reference')") + (w/click (first (w/query (format "a.menu-link:has-text('%s')" tag)))) + (w/wait-for "div:text('Live query (1)')") + + (ls-api-call! :editor.updateBlock + candidate-uuid + (format "[[%s]] %s" tag candidate-title)) + (w/wait-for "div:text('Live query (2)')") + (w/wait-for candidate-row) + + (ls-api-call! :editor.updateBlock candidate-uuid candidate-title) + (w/wait-for "div:text('Live query (1)')") + (w/wait-for-not-visible candidate-row))))) + +(deftest advanced-query-test + (testing "query" + (b/new-blocks ["[[bar]] block" "[[bar]] another" ""]) + (util/input-command "advanced query") + (w/click ".ls-query-setting") + (w/click (.first (w/-query "pre.CodeMirror-line"))) + (util/input "{:query [:find (pull ?b [*]) +:where [?b :block/refs ?r] +[?r :block/title \"bar\"]]}") + (k/esc) + (is (some? (w/find-one-by-text "div" "Live query (2)"))))) + +(deftest calculator-test + (testing "calculator" + (b/new-block "") + (util/input-command "calculator") + (util/input "1 + 2") + (w/wait-for "div.extensions__code-calc-output-line") + (is (= "3" (util/get-text "div.extensions__code-calc-output-line"))) + (assert/assert-is-hidden ".ls-page-blocks .block-tags"))) + +(deftest template-test + (testing "template" + (b/new-block "template 1") + (util/set-tag "Template" :hidden? true) + (b/new-blocks ["block 1" "block 2" "block 3" "test"]) + (k/arrow-up) + (w/wait-for "textarea:text('block 3')") + (util/repeat-keyboard 3 "Shift+ArrowUp") + (k/tab) + (b/jump-to-block "test") + (util/input-command "template") + (util/input "template 1") + (w/wait-for "a.menu-link.chosen:has-text('template 1')") + (k/enter) + (doseq [text ["block 1" "block 2" "block 3"]] + (assert/assert-have-count + (loc/or (format ".ls-block .block-title-wrap:text('%s')" text) + (format ".ls-block textarea:text('%s')" text)) + 2)))) + +(deftest embed-html-test + (testing "embed html" + (b/new-block "") + (util/input-command "embed html") + (util/press-seq "
test
") + (util/exit-edit) + (is (= "test" (util/get-text "#embed-test"))))) + +(deftest embed-video-test + (testing "embed video" + (b/new-block "") + (util/input-command "embed video") + (util/press-seq "https://www.youtube.com/watch?v=7xTGNNLPyMI") + (util/exit-edit) + (w/wait-for "iframe"))) + +(deftest embed-tweet-test + (testing "embed tweet" + (b/new-block "") + (util/input-command "embed tweet") + (util/press-seq "https://x.com/logseq/status/1784914564083314839") + (util/exit-edit) + (w/wait-for "iframe"))) + +(deftest cloze-test + (testing "cloze" + (b/new-block "") + (util/input-command "cloze") + (util/press-seq "hidden answer") + (util/exit-edit) + (w/click "span.cloze") + (w/wait-for "span.cloze-revealed"))) diff --git a/clj-e2e/test/logseq/e2e/custom_report.clj b/clj-e2e/test/logseq/e2e/custom_report.clj new file mode 100644 index 0000000..7d38800 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/custom_report.clj @@ -0,0 +1,70 @@ +(ns logseq.e2e.custom-report + (:require [clojure.pprint :as pp] + [clojure.stacktrace :as stack] + [clojure.string :as string] + [clojure.test :as t] + [logseq.e2e.playwright-page :as pw-page]) + (:import (com.microsoft.playwright Page$ScreenshotOptions))) + +(def ^:dynamic *pw-contexts* + "Set of pw-contexts. + record all playwright contexts in this dynamic var" + nil) + +(def ^:dynamic *pw-page->console-logs* nil) + +(def ^:dynamic *preserve-graph* nil) + +(defn screenshot + [page test-name] + (println :screenshot test-name) + (.screenshot + page + (-> (Page$ScreenshotOptions.) + (.setPath (java.nio.file.Paths/get "e2e-dump/" + (into-array [(format "./screenshot-%s-%s.png" test-name (System/currentTimeMillis))])))))) + +(defn- collect-info-when-error-or-failed + [] + ;; screenshot for all pw pages when :error + (when-let [all-contexts (seq *pw-contexts*)] + (doseq [page (mapcat pw-page/get-pages all-contexts)] + (screenshot page (string/join "-" (map (comp str :name meta) t/*testing-vars*))))) + + ;; dump console logs + (when-let [pw-page->console-logs (some-> *pw-page->console-logs* deref)] + (doseq [[_pw-page logs] pw-page->console-logs] + (spit (format "e2e-dump/console-logs-%s.txt" (System/currentTimeMillis)) + (with-out-str (pp/pprint logs))))) + + (when (some? *preserve-graph*) + (set! *preserve-graph* true))) + +(defmethod t/report :error + [m] + ;; copy from default impl + (t/with-test-out + (t/inc-report-counter :error) + (println "\nERROR in" (t/testing-vars-str m)) + (when (seq t/*testing-contexts*) (println (t/testing-contexts-str))) + (when-let [message (:message m)] (println message)) + (println "expected:" (pr-str (:expected m))) + (print " actual: ") + (let [actual (:actual m)] + (if (instance? Throwable actual) + (stack/print-cause-trace actual t/*stack-trace-depth*) + (prn actual)))) + + (collect-info-when-error-or-failed)) + +(defmethod t/report :fail + [m] + (t/with-test-out + (t/inc-report-counter :fail) + (println "\nFAIL in" (t/testing-vars-str m)) + (when (seq t/*testing-contexts*) (println (t/testing-contexts-str))) + (when-let [message (:message m)] (println message)) + (println "expected:" (pr-str (:expected m))) + (println " actual:" (pr-str (:actual m)))) + + (collect-info-when-error-or-failed)) diff --git a/clj-e2e/test/logseq/e2e/editor_basic_test.clj b/clj-e2e/test/logseq/e2e/editor_basic_test.clj new file mode 100644 index 0000000..4abb802 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/editor_basic_test.clj @@ -0,0 +1,1627 @@ +(ns logseq.e2e.editor-basic-test + (:require + [clojure.set :as set] + [clojure.string :as string] + [clojure.test :refer [deftest testing is use-fixtures]] + [jsonista.core :as json] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.custom-report :as custom-report] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as p] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import + (com.microsoft.playwright Locator$ClickOptions) + (com.microsoft.playwright.options KeyboardModifier) + (java.util.function Consumer))) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(defn- open-recycle! + [] + (w/click ".toolbar-dots-btn") + (w/click "[role='menuitem'] div:text('Recycle')")) + +(defn- recycle-root + [page-name] + (loc/filter ".ls-recycle-page-content section > div > div" + :has-text page-name)) + +(defn- open-left-sidebar! + [] + (when-not (w/visible? "#left-sidebar.is-open") + (w/click "#left-menu") + (w/wait-for "#left-sidebar.is-open"))) + +(deftest recycle-restore-removes-row-immediately-test + (let [page-name (str "recycle-restore-" (random-uuid))] + (p/new-page page-name) + (p/delete-page page-name) + (open-recycle!) + (let [root (recycle-root page-name)] + (assert/assert-is-visible root) + (w/click (.locator root "button:text('Restore')")) + (assert/assert-have-count root 0) + (w/wait-for-not-visible + (loc/filter ".ui__toast.success" :has-text page-name))))) + +(deftest recycle-delete-removes-row-and-recent-entry-test + (let [page-name (str "recycle-delete-" (random-uuid)) + recent-item (loc/filter ".recent .recent-item" :has-text page-name)] + (open-left-sidebar!) + (p/new-page page-name) + (b/save-block "recycle delete content") + (p/delete-page page-name) + (open-recycle!) + (let [root (recycle-root page-name)] + (assert/assert-is-visible root) + (let [dialog* (atom nil) + handler (reify Consumer + (accept [_ dialog] + (reset! dialog* {:type (.type dialog) + :message (.message dialog)}) + (.accept dialog)))] + (.onDialog (w/get-page) handler) + (try + (w/click (.locator root "button:text('Delete')")) + (finally + (.offDialog (w/get-page) handler))) + (is (= "confirm" (:type @dialog*))) + (is (string/includes? (:message @dialog*) "cannot be undone"))) + (assert/assert-have-count root 0) + (assert/assert-have-count recent-item 0) + (w/wait-for-not-visible + (loc/filter ".ui__toast.success" :has-text page-name))))) + +(defn- open-block-context-menu! + [] + (b/new-blocks ["hover target"]) + (util/exit-edit) + (util/right-click + ".ls-page-blocks .ls-block:not(.block-add-button) .bullet-container") + (w/wait-for ".ls-context-menu-content")) + +(deftest block-context-menu-clickable-controls-use-pointer-test + (open-block-context-menu!) + (let [heading-button (w/-query "button[title='Auto heading']") + item (loc/filter "[role='menuitem']" :has-text "Add comment") + sub-trigger (loc/filter "[role='menuitem']" :has-text "Add reaction")] + (.hover heading-button) + (is (= "pointer" + (.evaluate heading-button "element => getComputedStyle(element).cursor"))) + (.hover item) + (is (= "pointer" + (.evaluate item "element => getComputedStyle(element).cursor"))) + (.hover sub-trigger) + (is (= "pointer" + (.evaluate sub-trigger "element => getComputedStyle(element).cursor"))))) + +(deftest block-context-menu-color-hover-shows-ring-test + (open-block-context-menu!) + (let [color (w/-query "a[title='Yellow'] .heading-bg") + before (.evaluate color "element => getComputedStyle(element).boxShadow")] + (.hover color) + (let [after (.evaluate color "element => getComputedStyle(element).boxShadow")] + (is (not= before after)) + (is (not= "none" after))))) + +(deftest notification-appears-at-top-right-test + (let [message-key "notification-position-test" + toast (loc/filter ".ui__toast" :has-text "notification position test")] + (ls-api-call! :show_msg + "notification position test" + "success" + {"key" message-key + "timeout" 0}) + (try + (w/wait-for toast) + (is (= "top-right" + (w/eval-js + "(() => { + const toast = Array.from(document.querySelectorAll('.ui__toast')) + .find((element) => element.textContent.includes('notification position test')); + const rect = toast.getBoundingClientRect(); + const vertical = rect.top < window.innerHeight / 2 ? 'top' : 'bottom'; + const horizontal = window.innerWidth - rect.right <= 48 ? 'right' : 'left'; + return `${vertical}-${horizontal}`; + })()"))) + (finally + (ls-api-call! :ui.close_msg message-key) + (w/wait-for-not-visible toast))))) + +(deftest favorites-and-recents-load-after-refresh-test + (let [page-name (str "sidebar-startup-" (random-uuid)) + favorite-item (loc/filter ".favorites .favorite-item" :has-text page-name) + recent-item (loc/filter ".recent .recent-item" :has-text page-name)] + (p/new-page page-name) + (k/press "ControlOrMeta+Shift+f") + (assert/assert-is-visible favorite-item) + (assert/assert-is-visible recent-item) + + (util/refresh-until-graph-loaded) + + (util/wait-timeout 500) + (is (= [1 1] + [(.count favorite-item) + (.count recent-item)])))) + +(deftest favorite-menu-and-sidebar-follow-page-updates-test + (let [page-name (str "favorite-reactivity-" (random-uuid)) + renamed-page (str "renamed-favorite-" (random-uuid)) + favorite-item #(loc/filter ".favorites .favorite-item" :has-text %) + recent-item #(loc/filter ".recent .recent-item" :has-text %)] + (p/new-page page-name) + (util/exit-edit) + (open-left-sidebar!) + (k/press "ControlOrMeta+Shift+f") + (assert/assert-is-visible (favorite-item page-name)) + + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Unfavorite page")) + (assert/assert-have-count (favorite-item page-name) 0) + + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Add to Favorites")) + (assert/assert-is-visible (favorite-item page-name)) + + (p/rename-page page-name renamed-page) + (assert/assert-is-visible (favorite-item renamed-page)) + (assert/assert-is-visible (recent-item renamed-page)) + (assert/assert-have-count (favorite-item page-name) 0) + (assert/assert-have-count (recent-item page-name) 0))) + +(deftest page-alias-can-be-added-and-removed-from-the-property-picker-test + (let [target-page (str "alias-target-" (random-uuid)) + source-page (str "alias-source-" (random-uuid)) + target-result (format ".property-select :text-is('%s')" target-page) + target-value (loc/filter ".ls-page-properties .property-value" + :has-text target-page)] + (p/new-page target-page) + (b/save-block "alias target content") + (util/exit-edit) + (p/new-page source-page) + (b/save-block "alias source content") + (util/exit-edit) + + (w/click "button:text('Set property')") + (w/click (loc/and "strong" (util/get-by-text "Alias" true))) + (w/fill "input[placeholder='Set Alias']" target-page) + (w/click target-result) + (k/esc) + (is (= [target-page] + (mapv #(get % "title") + (get (ls-api-call! :editor.getPage source-page) "alias")))) + (assert/assert-is-visible target-value) + + (.press (.locator target-value ".multi-values.jtrigger") "Enter") + (w/fill "input[placeholder='Set Alias']" target-page) + (w/click target-result) + (assert/assert-have-count target-value 0) + (w/click (w/get-by-test-id "page title")))) + +(deftest theme-preview-images-load-test + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Settings")) + (assert/assert-is-visible ".cp__theme-modes-options") + (is + (true? + (w/eval-js + "(async () => { + const previews = Array.from( + document.querySelectorAll('.cp__theme-modes-options > li > i') + ); + const urls = previews.map((preview) => { + const match = getComputedStyle(preview).backgroundImage.match(/^url\\([\"']?(.*?)[\"']?\\)$/); + return match?.[1]; + }); + if (urls.length !== 3 || urls.some((url) => !url)) { + return false; + } + const loaded = await Promise.all(urls.map((url) => new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image.naturalWidth > 0); + image.onerror = () => resolve(false); + image.src = url; + }))); + return loaded.every(Boolean); + })()")))) + +(deftest language-select-shows-dropdown-indicator-test + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Settings")) + (assert/assert-is-visible ".ui__select-trigger") + (assert/assert-have-count + ".ui__select-trigger .ui__select-icon svg" + 1)) + +(deftest main-scrollbar-track-uses-main-background-test + (is (true? + (w/eval-js + "(() => { + const main = document.querySelector('#main-content-container'); + const style = getComputedStyle(main); + const probe = document.createElement('span'); + probe.style.color = style.getPropertyValue('--ls-primary-background-color'); + document.body.appendChild(probe); + const mainBackground = getComputedStyle(probe).color; + probe.remove(); + return style.scrollbarColor.endsWith(mainBackground); + })()")))) + +(defn- choose-move-target! + [target] + (w/fill "input[placeholder=\"Move blocks to\"]" target) + (let [result (.first (w/get-by-test-id target))] + (assert/assert-is-visible result) + (w/click result))) + +(defn- drag-and-drop-file! + [file-name file-type] + (w/eval-js + (format "(() => { + const container = document.querySelector('#main-content-container'); + if (!container) { + throw new Error('main-content-container not found'); + } + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(['logseq-e2e-drag-drop'], %s, { type: %s })); + container.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true, cancelable: true })); + container.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true, cancelable: true })); + })();" + (pr-str file-name) + (pr-str file-type)))) + +(defn- enable-virtualized-rendering! + [] + (w/eval-js + "() => { + const url = new URL(location.href); + url.searchParams.set('virtualized', 'true'); + history.replaceState(null, '', url.pathname + url.search + url.hash); + }") + (w/refresh) + (w/wait-for (.first (w/get-by-test-id "page title")) {:timeout 15000}) + (assert/assert-graph-loaded?)) + +(defn- js-json + [script] + (json/read-value (w/eval-js script) json/keyword-keys-object-mapper)) + +(defn- start-edit-exit-frame-capture! + [] + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + const block = editor?.closest('.ls-block'); + if (!block) throw new Error('Expected an editing block'); + + window.__e2eEditExitFrames = []; + const startedAt = performance.now(); + const sample = () => { + const currentEditor = block.querySelector('.editor-wrapper textarea'); + const title = block.querySelector('.block-title-wrap'); + const pageRef = block.querySelector('.page-reference .page-ref'); + window.__e2eEditExitFrames.push({ + editing: Boolean(currentEditor), + text: title?.textContent || '', + pageRefText: pageRef?.textContent || '' + }); + if (performance.now() - startedAt < 400) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + return true; + })()")) + +(defn- edit-exit-read-frames + [] + (util/wait-timeout 450) + (->> (js-json "JSON.stringify(window.__e2eEditExitFrames)") + (remove :editing) + vec)) + +(defn- insert-current-page-blocks! + [blocks] + (let [page (ls-api-call! :editor.getCurrentPage) + page-uuid (get page "uuid")] + (ls-api-call! :editor.insertBatchBlock + page-uuid + (mapv #(hash-map :content %) blocks) + {:sibling false}) + (ls-api-call! :editor.exitEditingMode false) + (ls-api-call! :app.pushState "page" {:name page-uuid} nil))) + +(deftest click-rendered-block-focuses-editor + (testing "clicking a rendered block leaves the editor textarea focused" + (let [title "click rendered block focuses editor"] + (insert-current-page-blocks! [title]) + (w/click (format ".ls-block .block-content:has-text('%s')" title)) + (assert/assert-editor-mode) + (let [{:keys [activeId activeTag editorId editorFocused]} + (json/read-value + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + return JSON.stringify({ + activeId: document.activeElement && document.activeElement.id, + activeTag: document.activeElement && document.activeElement.tagName, + editorId: editor && editor.id, + editorFocused: editor === document.activeElement + }); + })();") + json/keyword-keys-object-mapper)] + (is editorFocused + {:active-id activeId + :active-tag activeTag + :editor-id editorId}))))) + +(defn- select-block-range-with-fast-scroll! + [blocks] + (w/eval-js + (format + "(async () => { + const blockTitles = %s; + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const scrollContainer = document.querySelector('#main-content-container'); + + const blockByTitle = (title) => Array.from(document.querySelectorAll('.ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)')) + .find((block) => block.textContent.includes(title)); + + const scrollToBlock = async (title, step) => { + for (let i = 0; i < 120; i++) { + const block = blockByTitle(title); + if (block) { + block.scrollIntoView({ block: 'center' }); + await nextFrame(); + return block; + } + scrollContainer.scrollTop += step; + await nextFrame(); + } + throw new Error(`Could not find mounted block ${title}`); + }; + + if (!document.querySelector('[data-virtuoso-scroller]')) { + throw new Error('Expected virtualized list scroller'); + } + + scrollContainer.scrollTop = 0; + await nextFrame(); + + const firstBlock = await scrollToBlock(blockTitles[0], -1000); + const firstContent = firstBlock.querySelector('.block-content'); + const firstRect = firstContent.getBoundingClientRect(); + const clientX = Math.floor(firstRect.left + 24); + const clientY = Math.floor(firstRect.top + Math.min(20, firstRect.height / 2)); + const pointerInit = { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX, + clientY + }; + + firstContent.dispatchEvent(new PointerEvent('pointerdown', pointerInit)); + await delay(100); + + await scrollToBlock(blockTitles[blockTitles.length - 1], 1400); + await delay(200); + + document.querySelector('#app-container-wrapper')?.dispatchEvent(new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + clientX, + clientY + })); + + return ((await window.logseq.api.get_selected_blocks()) || []) + .map((block) => block.title || block.content); + })();" + (json/write-value-as-string blocks)))) + +(defn- wait-for-copied-blocks! + [blocks] + (loop [remaining-attempts 50] + (let [clipboard (w/clipboard-text) + missing-blocks (filterv #(not (string/includes? clipboard %)) blocks)] + (if (or (empty? missing-blocks) + (zero? remaining-attempts)) + {:clipboard-length (count clipboard) + :missing-blocks missing-blocks} + (do + (util/wait-timeout 100) + (recur (dec remaining-attempts))))))) + +(defn- seed-journals! + [journals] + (doseq [{:keys [date blocks]} journals] + (let [page (ls-api-call! :editor.createJournalPage date)] + (ls-api-call! :editor.insertBatchBlock + (get page "uuid") + (mapv #(hash-map :content %) blocks) + {:sibling false}))) + (ls-api-call! :editor.exitEditingMode false) + (util/goto-journals) + (w/wait-for "#journals")) + +(defn- seed-journals-with-linked-ref! + [] + (let [target (ls-api-call! :editor.createJournalPage + "2026-03-01T12:00:00") + source (ls-api-call! :editor.createJournalPage + "2026-02-28T12:00:00")] + (ls-api-call! :editor.insertBatchBlock + (get target "uuid") + [{:content "journals linked refs visible target"}] + {:sibling false}) + (ls-api-call! :editor.insertBatchBlock + (get source "uuid") + [{:content (format + "journals linked refs visible source [[%s]]" + (get target "name"))}] + {:sibling false}) + (ls-api-call! :editor.exitEditingMode false) + (util/goto-journals) + (w/wait-for "#journals"))) + +(defn- scroll-journals-to-text! + [text] + (w/eval-js + (format + "(async () => { + const text = %s; + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const scrollContainer = document.querySelector('#main-content-container'); + + if (!scrollContainer) { + throw new Error('Expected main content scroller'); + } + + const findText = () => Array.from(document.querySelectorAll('#journals .journal-item')) + .find((item) => item.textContent.includes(text)); + + scrollContainer.scrollTop = 0; + await nextFrame(); + + for (let i = 0; i < 240; i++) { + for (let settle = 0; settle < 40; settle++) { + const item = findText(); + if (item) { + item.scrollIntoView({ block: 'center' }); + await nextFrame(); + return true; + } + + const visibleItems = Array.from(document.querySelectorAll('#journals .journal-item')) + .filter((journal) => { + const rect = journal.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + return rect.bottom > containerRect.top && rect.top < containerRect.bottom; + }); + if (visibleItems.length > 0 && visibleItems.every((journal) => journal.textContent.trim())) { + break; + } + await delay(50); + } + + scrollContainer.scrollTop += Math.max(280, Math.floor(scrollContainer.clientHeight * 0.7)); + await delay(80); + } + + throw new Error(`Could not find mounted journal text ${text}`); + })();" + (json/write-value-as-string text)))) + +(defn- journals-layout-metrics + [] + (js-json + "(async () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + for (let i = 0; i < 40; i++) { + if (document.querySelector('#journals .journal-item')) { + break; + } + await nextFrame(); + } + + const journalItem = document.querySelector('#journals .journal-item'); + if (!journalItem) { + throw new Error('Expected a mounted journal item'); + } + + const style = getComputedStyle(journalItem); + return JSON.stringify({ + 'journal-item-margin-bottom': Number.parseFloat(style.marginBottom), + 'journal-item-padding-bottom': Number.parseFloat(style.paddingBottom), + 'journals-scroller-count': document.querySelectorAll('#journals [data-virtuoso-scroller]').length + }); + })();")) + +(defn- journals-linked-refs-metrics + [] + (js-json + "(async () => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + let references = null; + let foldableContent = null; + let viewBody = null; + + for (let i = 0; i < 80; i++) { + references = document.querySelector('#journals .references'); + foldableContent = references?.querySelector('.ls-foldable-content'); + viewBody = references?.querySelector('.ls-view-body'); + const expanded = foldableContent?.getAttribute('aria-hidden') !== 'true'; + const bodyHeight = viewBody?.getBoundingClientRect().height || 0; + + if (references && expanded && bodyHeight > 0) { + break; + } + await delay(100); + } + + if (!references) { + throw new Error('Expected linked references in journals'); + } + + return JSON.stringify({ + 'collapsed': foldableContent?.getAttribute('aria-hidden') === 'true', + 'body-mounted': Boolean(viewBody), + 'body-height': viewBody?.getBoundingClientRect().height || 0 + }); + })();")) + +(defn- set-journals-scroll-position! + [position] + (w/eval-js + (format + "(() => { + const scrollContainer = document.querySelector('#main-content-container'); + if (!scrollContainer) { + throw new Error('Expected main content scroller'); + } + scrollContainer.scrollTop = %s; + })();" + (case position + :start "0" + :end "scrollContainer.scrollHeight")))) + +(defn- mounted-journal-height + [block-title] + (w/eval-js + "title => { + const journal = Array.from(document.querySelectorAll('#journals .journal-item')) + .find((item) => item.textContent.includes(title)); + if (!journal) { + throw new Error(`Expected mounted journal containing ${title}`); + } + return Math.round(journal.getBoundingClientRect().height); + }" + block-title)) + +(defn- multiline-heading-bullet-alignment + [title] + (-> (w/eval-js + (format + "(async () => { + const title = %s; + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const block = Array.from(document.querySelectorAll('.ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)')) + .find((block) => block.textContent.includes(title)); + + if (!block) { + throw new Error(`Block not found: ${title}`); + } + + const wrapper = block.querySelector('.block-content-wrapper'); + const bullet = block.querySelector('.bullet-container'); + const heading = block.querySelector('.block-title-wrap.as-heading'); + + if (!wrapper || !bullet || !heading) { + throw new Error('Expected heading block with bullet controls'); + } + + wrapper.style.maxWidth = '160px'; + await nextFrame(); + + const bulletRect = bullet.getBoundingClientRect(); + const headingRect = heading.getBoundingClientRect(); + const lineHeight = Number.parseFloat(window.getComputedStyle(heading).lineHeight); + const firstLineCenterY = headingRect.top + (lineHeight / 2); + const bulletCenterY = bulletRect.top + (bulletRect.height / 2); + + return JSON.stringify({ + bulletCenterY, + firstLineCenterY, + delta: Math.abs(bulletCenterY - firstLineCenterY) + }); + })();" + (json/write-value-as-string title))) + (json/read-value json/keyword-keys-object-mapper))) + +(deftest multiline-heading-keeps-bullet-on-first-line + (testing "heading block bullet aligns with the first line when the heading wraps" + (doseq [heading ["h1" "h6"]] + (let [title (format "Multiline %s heading bullet should stay on the first visual line" heading)] + (b/new-block title) + (util/input-command heading) + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".block-title-wrap.as-heading" :has-text title)) + (let [{:keys [delta] :as alignment} (multiline-heading-bullet-alignment title)] + (is (<= delta 3) (pr-str (assoc alignment :heading heading)))))))) + +(deftest copy-blocks-selected-after-fast-scroll-virtualized-list + (testing "copy includes virtualized blocks selected after fast scrolling a long page" + (let [blocks (mapv #(format "fast-scroll-copy-block-%03d" %) (range 1 31))] + (insert-current-page-blocks! blocks) + (enable-virtualized-rendering!) + (is (set/subset? (set blocks) + (set (select-block-range-with-fast-scroll! blocks)))) + (b/copy) + (let [{:keys [missing-blocks] :as copy-result} + (wait-for-copied-blocks! blocks)] + (is (empty? missing-blocks) (pr-str copy-result)))))) + +(deftest journals-list-uses-measured-spacing-without-item-margins + (testing "journals list spacing does not use item margins that destabilize Virtuoso measurement" + (seed-journals! + [{:date "2026-03-05T12:00:00" + :blocks ["journals measured spacing first"]} + {:date "2026-03-04T12:00:00" + :blocks ["journals measured spacing second"]}]) + (w/wait-for "#journals .journal-item") + (let [{:keys [journal-item-margin-bottom journal-item-padding-bottom] :as metrics} (journals-layout-metrics)] + (is (zero? journal-item-margin-bottom) metrics) + (is (pos? journal-item-padding-bottom) metrics)))) + +(deftest journals-list-does-not-nest-virtualized-scrollers-in-long-journal + (testing "long journals keep a single virtualized measurement owner" + (let [blocks (mapv #(format "journals long stable block %03d" %) (range 1 13))] + (seed-journals! + [{:date "2026-03-06T12:00:00" + :blocks blocks}]) + (w/wait-for (format "#journals .journal-item:has-text('%s')" + (first blocks))) + (enable-virtualized-rendering!) + (w/wait-for "#journals [data-virtuoso-scroller]") + (scroll-journals-to-text! (first blocks)) + (let [{:keys [journals-scroller-count] :as metrics} (journals-layout-metrics)] + (is (= 1 journals-scroller-count) metrics))))) + +(deftest journals-list-remounts-complete-long-journal-with-one-scroller + (testing "an outer journal remount restores all content without a nested virtualizer" + (let [first-block-title "journals remount stable block 001" + last-block-title "journals remount stable block 012" + long-blocks (mapv #(format "journals remount stable block %03d %s" + % + (string/join " " (repeat 24 "wrapped-content"))) + (range 1 13)) + older-journals (mapv (fn [idx] + {:date (format "2026-03-%02dT12:00:00" idx) + :blocks [(format "journals remount spacer block %02d" idx)]}) + (range 1 7))] + (seed-journals! + (into [{:date "2026-03-20T12:00:00" + :blocks long-blocks}] + older-journals)) + (enable-virtualized-rendering!) + (let [journal-selector (format "#journals .journal-item:has-text('%s')" + first-block-title) + last-block-selector (format "%s .ls-block:has-text('%s')" + journal-selector last-block-title)] + (w/wait-for last-block-selector) + (let [initial-height (mounted-journal-height first-block-title)] + (set-journals-scroll-position! :end) + (w/wait-for-not-visible journal-selector) + (set-journals-scroll-position! :start) + (w/wait-for last-block-selector) + (let [{:keys [journals-scroller-count] :as metrics} + (journals-layout-metrics) + remounted-height (mounted-journal-height first-block-title)] + (is (>= remounted-height (dec initial-height)) + (str (assoc metrics + :initial-height initial-height + :remounted-height remounted-height))) + (is (= 1 journals-scroller-count) metrics) + (assert/assert-have-count + (format "%s [data-virtuoso-scroller]" journal-selector) + 0))))))) + +(deftest journals-linked-refs-remain-visible + (testing "journals linked refs stay visible while journals layout owns the outer measurement" + (seed-journals! + (mapv (fn [day] + {:date (format "2026-03-%02dT12:00:00" day) + :blocks [(format "journals linked refs spacer %02d" day)]}) + (range 2 6))) + (seed-journals-with-linked-ref!) + (scroll-journals-to-text! "journals linked refs visible target") + (let [{:keys [collapsed body-mounted body-height] :as metrics} (journals-linked-refs-metrics)] + (is (false? collapsed) metrics) + (is (true? body-mounted) metrics) + (is (pos? body-height) metrics)))) + +(defn- console-logs + [] + (->> (some-> custom-report/*pw-page->console-logs* deref vals) + (mapcat identity) + vec)) + +(defn- worker-op-logs + [logs op-names] + (->> logs + (filter #(and (string/includes? % ":db-worker/outliner-op-perf") + (string/includes? % (str ":op-names " op-names)))))) + +(defn- editor-input-state + [] + (json/read-value + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + return JSON.stringify({ + value: editor?.value ?? null, + focused: editor === document.activeElement, + selectionStart: editor?.selectionStart ?? null, + selectionEnd: editor?.selectionEnd ?? null, + blockTitles: Array.from(document.querySelectorAll('.ls-page-blocks .block-title-wrap')) + .map((node) => node.textContent.trim()) + }); + })();") + json/keyword-keys-object-mapper)) + +(deftest consecutive-enter-keeps-text-and-cursor-on-the-new-block + (b/new-block "rapid enter start") + (k/enter) + (util/press-seq "rapid enter alpha") + (k/enter) + (util/press-seq "rapid enter beta") + (util/wait-timeout 800) + (let [{:keys [value focused selectionStart selectionEnd]} + (editor-input-state)] + (is (= "rapid enter beta" value)) + (is focused) + (is (= (count value) selectionStart selectionEnd))) + (util/exit-edit) + (let [block-titles (:blockTitles (editor-input-state))] + (is (some #{"rapid enter alpha"} block-titles) block-titles) + (is (some #{"rapid enter beta"} block-titles) block-titles))) + +(deftest enter-delete-keeps-text-and-cursor-on-the-previous-block + (b/new-block "rapid delete start") + (k/enter) + (k/backspace) + (util/press-seq " tail") + (util/wait-timeout 800) + (let [{:keys [value focused selectionStart selectionEnd]} + (editor-input-state)] + (is (= "rapid delete start tail" value)) + (is focused) + (is (= (count value) selectionStart selectionEnd))) + (util/exit-edit) + (let [block-titles (:blockTitles (editor-input-state))] + (is (some #{"rapid delete start tail"} block-titles) block-titles))) + +(deftest parent-and-child-rapid-edits-keep-the-latest-child-title + (p/new-page "parent child pending edits") + (let [page-uuid (get (ls-api-call! :editor.getBlock "parent child pending edits") "uuid") + [parent child] + (ls-api-call! :editor.insertBatchBlock + page-uuid + [{:content "a" + :children [{:content "b"}]}]) + parent-uuid (get parent "uuid") + child-uuid (get child "uuid")] + (w/click (str "#block-content-" parent-uuid)) + (w/fill util/editor-q "ax") + (w/click (str "#block-content-" child-uuid)) + (w/fill util/editor-q "bx") + (w/fill util/editor-q "b") + (k/arrow-up) + (util/wait-timeout 800) + (is (= "b" (get (ls-api-call! :editor.getBlock child-uuid) "content"))))) + +(deftest page-level-node-reference-renders-linked-references + (let [target-name (str "linked-reference-target-" (random-uuid)) + source-name (str "linked-reference-source-" (random-uuid))] + (p/new-page target-name) + (let [target-uuid (get (ls-api-call! :editor.getBlock target-name) "uuid")] + (p/new-page source-name) + (b/save-block (str "[[" target-name "]]")) + (util/exit-edit) + (let [source-uuid (get (ls-api-call! :editor.getBlock source-name) "uuid")] + (ls-api-call! :editor.upsertBlockProperty + source-uuid + "linked-reference-node" + target-uuid + {:schema {:type "node"}}) + (let [source (ls-api-call! :editor.getBlock source-uuid)] + (is (some (fn [[key value]] + (when (and (string/ends-with? key "/linked-reference-node") + value) + value)) + source) + source)) + (p/goto-page target-name) + (assert/assert-is-visible + (loc/filter ".references" :has-text source-name)) + (assert/assert-have-count + (loc/filter ".references" :has-text "Unexpected error") + 0))))) + +(deftest consecutive-enter-and-delete-ops-complete-without-worker-errors + (util/wait-timeout 500) + (let [old-logs (set (console-logs))] + (doseq [idx (range 3)] + (b/new-block (str "render budget " idx)) + (let [block-uuid (.getAttribute (util/get-edit-block-container) "blockid")] + (b/delete-blocks) + (assert/assert-have-count (str "#ls-block-" block-uuid) 0))) + (util/wait-timeout 800) + (let [logs (remove old-logs (console-logs)) + enter-logs (concat + (worker-op-logs logs "[:insert-blocks]") + (worker-op-logs logs "[:save-block :insert-blocks]")) + delete-logs (worker-op-logs logs "[:delete-blocks]")] + (is (= 3 (count enter-logs)) (pr-str enter-logs)) + (is (= 3 (count delete-logs)) (pr-str delete-logs)) + (is (not-any? #(some (fn [message] (string/includes? % message)) + ["DB worker API failed" + "Missing renderer resource entity" + "Unsupported view resource row" + "Invalid renderer resource UUID"]) + logs))))) + +(deftest backspace-at-start-removes-pending-block-dom-test + (b/new-blocks ["source"]) + (b/new-block "") + (b/save-block "pending block") + (let [block-uuid (.getAttribute (util/get-edit-block-container) "blockid")] + (util/move-cursor-to-start) + (k/backspace) + (is (nil? (ls-api-call! :editor.getBlock block-uuid))) + (assert/assert-have-count (str "#ls-block-" block-uuid) 0))) + +(deftest today-queries-render-without-resource-errors + (let [page (ls-api-call! :editor.createJournalPage + (str (java.time.Instant/now)))] + (ls-api-call! :app.pushState "page" {:name (get page "uuid")} nil)) + (util/wait-timeout 1500) + (assert/assert-have-count "#today-queries" 1) + (assert/assert-have-count "#today-queries .block-content-fallback-ui" 0)) + +(deftest drag-and-drop-asset-does-not-create-blank-asset + (testing "dragging and dropping a file should keep non-empty asset title" + (let [asset-title "drag-drop-regression" + file-name (str asset-title ".png")] + (b/new-block "") + (drag-and-drop-file! file-name "image/png") + (w/wait-for ".ls-page-blocks .ls-block .asset-container img") + ;; Exit edit mode to trigger a save; this used to overwrite the new asset with blank content. + (util/exit-edit) + (assert/assert-have-count ".ls-page-blocks .ls-block .asset-container img" 1) + (assert/assert-is-visible + (format ".ls-page-blocks .ls-block .block-title-wrap:text('%s')" asset-title))))) + +(deftest toggle-between-page-and-block + (testing "Convert block to page and back" + (b/new-block "b1") + (util/set-tag "Page" {:hidden? true}) + (assert/assert-is-visible ".ls-page-blocks .ls-block .ls-icon-file") + (b/toggle-property "Tags" "Page") + (assert/assert-is-hidden ".ls-page-blocks .ls-block .ls-icon-file"))) + +(deftest toggle-between-page-and-block-for-selected-blocks + (testing "Convert selected blocks to pages and back" + (b/new-blocks ["b1" "b2" "b3"]) + (b/select-blocks 3) + (b/toggle-property "Tags" "Page") + (assert/assert-is-visible ".ls-page-blocks .ls-block .ls-icon-file") + (w/wait-for (format ".menu-link:has-text('%s')" "Page")) + (k/esc) + (b/toggle-property "Tags" "Page") + (w/wait-for-not-visible ".ls-page-blocks .ls-block .ls-icon-file"))) + +(deftest disallow-adding-page-tag-to-normal-pages + (testing "Disallow adding #Page to normal pages" + (k/arrow-up) + (util/move-cursor-to-end) + (let [editor (w/-query "*:focus") + original-title (.inputValue editor)] + (util/press-seq " #" {:delay 20}) + (util/press-seq "Page") + (assert/assert-is-hidden (format "#ac-0.menu-link:has-text('%s')" "Page")) + (w/fill editor original-title) + (is (= original-title (.inputValue editor)))) + (util/exit-edit))) + +(deftest move-blocks-mod+shift+m + (testing "move blocks using `mod+shift+m`" + (p/new-page "Target page") + (p/new-page "Source page") + (b/new-blocks ["b1" "b2" "b3"]) + (b/select-blocks 3) + (k/press "ControlOrMeta+Shift+m") + (choose-move-target! "Target page") + (assert/assert-have-count ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)" 0))) + +(deftest move-blocks-cmdk + (testing "move blocks using cmdk" + (let [target-page (str "Target page " (random-uuid)) + source-page (str "Source page " (random-uuid))] + (p/new-page target-page) + (p/new-page source-page) + (b/new-blocks ["b1" "b2" "b3"]) + (b/select-blocks 3) + (util/search-and-click "Move blocks to") + (choose-move-target! target-page) + (assert/assert-have-count ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)" 0)))) + +(deftest move-editing-block-cmdk + (testing "move the current editing block using cmdk" + (let [target-page (str "Editing block target " (random-uuid)) + source-page (str "Editing block source " (random-uuid))] + (p/new-page target-page) + (p/new-page source-page) + (b/new-blocks ["editing block"]) + (util/search-and-click "Move blocks to") + (choose-move-target! target-page) + (assert/assert-have-count ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)" 0) + (p/goto-page target-page) + (w/wait-for ".ls-page-blocks .ls-block:has-text('editing block')") + (is (contains? (set (util/get-page-blocks-contents)) "editing block"))))) + +(deftest shift-open-page-in-sidebar + (testing "Shift+Enter opens an ordinary page search result in the sidebar" + (p/new-page "Ordinary sidebar page") + (b/new-blocks ["ordinary page block"]) + (p/new-page "Sidebar search source") + (util/search "Ordinary sidebar page") + (let [result (.first (w/get-by-test-id "Ordinary sidebar page"))] + (assert/assert-is-visible result) + (.hover result)) + (.focus (w/-query ".cp__cmdk-search-input")) + (k/shift+enter) + (assert/assert-is-visible + ".cp__right-sidebar .sidebar-item :text('Ordinary sidebar page')"))) + +(deftest shift-click-page-title-opens-in-sidebar + (testing "Shift+click opens an ordinary page title in the sidebar" + (p/new-page "Shift click sidebar page") + (util/exit-edit) + (w/click "div[data-testid='page title'] .block-title-wrap" + (doto (Locator$ClickOptions.) + (.setModifiers [KeyboardModifier/SHIFT]))) + (assert/assert-is-visible + ".cp__right-sidebar .sidebar-item :text('Shift click sidebar page')"))) + +(deftest cmdk-block-results-render-breadcrumbs-test + (testing "CmdK block results include their ready breadcrumb" + (let [page-name (p/get-page-name) + title (str "cmdk breadcrumb target " (random-uuid))] + (b/new-block title) + (util/exit-edit) + (util/search title) + (assert/assert-is-visible + (loc/filter ".cp__cmdk .breadcrumb" :has-text page-name))))) + +(deftest comments-update-and-title-edit + (testing "a submitted comment renders immediately and its thread title is editable" + (p/new-page "Comments reactivity") + (b/new-blocks ["comment target"]) + (util/search-and-click "Add comment") + (assert/assert-is-visible ".ls-comments-area") + (assert/assert-is-hidden ".ls-block.is-comments-area .block-tags") + (w/fill ".ls-comment-add textarea" "first comment") + (w/click ".ls-comment-submit") + (assert/assert-is-visible ".ls-comment-row :text('first comment')") + (w/click ".ls-comments-label") + (assert/assert-is-visible ".ls-comments-title-editor textarea") + (assert/assert-is-hidden ":text('Something went wrong')"))) + +(deftest first-comment-actions-stay-inside-scroll-container + (p/new-page "Comment actions clipping") + (b/new-blocks ["comment target"]) + (util/search-and-click "Add comment") + (w/fill ".ls-comment-add textarea" "first comment") + (w/click ".ls-comment-submit") + (assert/assert-is-visible ".ls-comment-row") + (is + (w/eval-js + "(() => { + const list = document.querySelector('.ls-comments-list'); + const actions = document.querySelector('.ls-comment-row .ls-comment-actions'); + const listRect = list.getBoundingClientRect(); + const actionsRect = actions.getBoundingClientRect(); + return actionsRect.top >= listRect.top && actionsRect.bottom <= listRect.bottom; + })()"))) + +(deftest move-pages-to-library + (testing "move pages using `mod+shift+m`" + (p/goto-page "Library") + (p/new-page "test page") + (b/new-blocks ["block1" "block2" "block3"]) + (b/select-blocks 3) + (b/toggle-property "Tags" "Page") + (assert/assert-is-visible ".ls-page-blocks .ls-block .ls-icon-file") + (k/press "ControlOrMeta+Shift+m") + (w/fill "input[placeholder=\"Move blocks to\"]" "Library") + (w/wait-for (w/get-by-test-id "Library")) + (w/click (w/get-by-test-id "Library")) + (assert/assert-have-count + (loc/filter ".ls-page-blocks .ls-block" :has-text "block1") + 0) + (p/goto-page "Library") + (assert/assert-is-visible + (loc/filter ".ls-page-blocks .block-title-wrap" :has-text "block1")) + (let [contents (set (util/get-page-blocks-contents))] + (is (set/subset? (set ["block1" "block2" "block3"]) contents))))) + +(deftest create-nested-pages-in-library + (testing "create nested pages in Library" + (p/goto-page "Library") + (b/new-blocks ["page parent" "page child"]) + (b/indent) + (b/new-block "another nested child") + (b/indent))) + +(defn- selection-range + [] + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + return `${editor.selectionStart}:${editor.selectionEnd}`; + })()")) + +(defn- assert-editor-value! + [value] + (-> (w/-query util/editor-q) + assert/assert-that + (.hasValue value))) + +(defn- clipboard-text! + [text] + (w/eval-js "text => navigator.clipboard.writeText(text)" text)) + +(deftest editor-exit-and-unicode-persistence-test + (testing "Esc, outside click and navigation save Unicode once and clear transient editor state" + (let [content "中文🙂 e\u0301 editor persistence" + page-name (p/get-page-name)] + (b/new-block content) + (let [block-uuid (.getAttribute (util/get-edit-block-container) "blockid")] + (k/esc) + (assert/assert-have-count util/editor-q 0) + (assert/assert-is-visible + (loc/filter ".ls-page-blocks" :has-text content)) + (w/click "#main-content-container") + (assert/assert-have-count ".ui__popover-content, .autocomplete" 0) + (p/new-page "editor exit destination") + (p/goto-page page-name) + (assert/assert-is-visible + (loc/filter ".ls-page-blocks" :has-text content)) + (util/refresh-until-graph-loaded) + (is (= content + (get (ls-api-call! :editor.getBlock block-uuid) "content"))))))) + +(deftest empty-enter-and-soft-line-break-test + (testing "empty Enter obeys tree rules while Shift+Enter stays inside one block" + (b/new-block "") + (let [before (util/page-blocks-count)] + (k/enter) + (is (= (inc before) (util/page-blocks-count))) + (assert/assert-have-count util/editor-q 1)) + (b/save-block "first line second line") + (k/press "Home") + (dotimes [_ 10] (k/arrow-right)) + (k/press "Shift+Enter") + (is (= "first line\n second line" (util/get-edit-content))) + (util/exit-edit) + (is (= 1 + (count (filter #(string/includes? % "first line") + (util/get-page-blocks-contents))))) + (b/new-blocks ["empty parent" ""]) + (b/indent) + (let [before-count (util/page-blocks-count) + [before-x _] (util/bounding-xy (util/get-editor))] + (k/enter) + (let [[after-x _] (util/bounding-xy (util/get-editor))] + (is (= before-count (util/page-blocks-count))) + (is (< after-x before-x)))))) + +(deftest cursor-boundaries-word-motion-and-kill-test + (testing "cursor motion follows boundaries and range deletion is one undoable edit" + (let [word-modifier (if util/mac? "Alt" "Control")] + (b/new-blocks ["first cursor line" "second cursor line"]) + (k/shift+enter) + (util/press-seq "continued") + (let [multiline "second cursor line\ncontinued" + second-line-start (inc (count "second cursor line"))] + (is (= multiline (util/get-edit-content))) + (k/press "Home") + (is (= (str second-line-start ":" second-line-start) + (selection-range))) + (k/arrow-up) + (is (= multiline (util/get-edit-content)))) + (k/press "Home") + (is (= "0:0" (selection-range))) + (k/arrow-up) + (b/wait-editor-text "first cursor line") + (is (= "first cursor line" (util/get-edit-content))) + (util/move-cursor-to-end) + (let [before (util/get-edit-content)] + (k/press (str word-modifier "+ArrowLeft")) + (is (not= (str (count before) ":" (count before)) + (selection-range)))) + (k/press (str word-modifier "+Backspace")) + (assert-editor-value! "first line") + (b/undo) + (assert-editor-value! "first cursor line") + (k/press "ControlOrMeta+a") + (k/backspace) + (assert-editor-value! "") + (b/undo) + (assert-editor-value! "first cursor line")))) + +(deftest text-format-shortcuts-and-source-roundtrip-test + (testing "format shortcuts wrap only the selection and rendered text round-trips" + (doseq [[shortcut source expected] + [["ControlOrMeta+b" "bold" "**bold**"] + ["ControlOrMeta+i" "italic" "*italic*"] + ["ControlOrMeta+Shift+h" "highlight" "==highlight=="]]] + (b/new-block source) + (k/press "ControlOrMeta+a") + (k/press shortcut) + (let [formatted (util/get-edit-content)] + (is (= expected formatted)) + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".block-title-wrap" :has-text source)) + (w/click (loc/filter ".block-title-wrap" :has-text source)) + (is (= formatted (util/get-edit-content))))) + (b/new-block "escape \\* literal 🙂 longwordwithoutbreak0123456789") + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".block-title-wrap" :has-text "longwordwithoutbreak")))) + +(deftest escape-save-never-paints-stale-block-content-test + (testing "the first rendered frame after Escape contains the saved text" + (let [title (str "escape-save-frame-" (random-uuid))] + (b/new-block "") + (w/fill util/editor-q title) + (start-edit-exit-frame-capture!) + (k/esc) + (assert/assert-non-editor-mode) + (let [frames (edit-exit-read-frames)] + (is (seq frames)) + (is (every? #(= title (:text %)) frames) + frames))))) + +(deftest new-page-reference-renders-on-the-first-frame-after-save-test + (testing "a newly created page reference is never blank after Escape" + (let [page-title (str "reference-first-frame-" (random-uuid))] + (b/new-block "") + (w/fill util/editor-q (str "[[" page-title "]]")) + (start-edit-exit-frame-capture!) + (k/esc) + (assert/assert-non-editor-mode) + (let [frames (edit-exit-read-frames)] + (is (seq frames)) + (is (every? #(= page-title (:pageRefText %)) frames) + frames))))) + +(deftest saved-page-reference-reopens-with-page-title-test + (testing "keyboard editing uses the page title instead of its UUID" + (let [page-title (str "reference-reedit-" (random-uuid)) + source (format "[[%s]]" page-title)] + (b/new-block "") + (w/fill util/editor-q source) + (k/esc) + (assert/assert-non-editor-mode) + (assert/assert-is-visible + (loc/filter ".page-reference .page-ref" :has-text page-title)) + (k/enter) + (assert/assert-editor-mode) + (is (= source (util/get-edit-content)))))) + +(deftest page-and-tag-autocomplete-test + (testing "page and tag autocomplete update, insert and open existing/new targets" + (p/new-page "autocomplete existing page") + (p/new-page "autocomplete host") + (b/new-block "") + (util/press-seq "[[autocomplete existing page") + (assert/assert-is-visible + (loc/filter ".ui__popover-content a.menu-link.chosen" + :has-text "autocomplete existing page")) + (k/enter) + (is (string/includes? (util/get-edit-content) + "[[autocomplete existing page]]")) + (util/exit-edit) + (w/click + (loc/filter ".page-reference .page-ref" + :has-text "autocomplete existing page")) + (is (= "autocomplete existing page" (p/get-page-name))) + (p/goto-page "autocomplete host") + (b/new-block "") + (util/press-seq "#autocomplete-new-tag") + (assert/assert-is-visible ".ui__popover-content") + (k/enter) + (assert/assert-is-visible + (loc/filter ".block-tag" :has-text "autocomplete-new-tag")))) + +(deftest slash-menu-filter-scroll-and-cleanup-test + (testing "slash search keeps one highlighted result and Esc clears its state" + (b/new-block "") + (util/press-seq "/") + (assert/assert-is-visible ".ui__popover-content") + (util/press-seq "property") + (assert/assert-is-visible + (loc/filter ".ui__popover-content" :has-text "property")) + (k/arrow-down) + (k/arrow-up) + (assert/assert-have-count + ".ui__popover-content a.menu-link.chosen, .ui__popover-content [data-kb-highlighted]" + 1) + (k/esc) + (assert/assert-is-hidden ".ui__popover-content") + (is (= "/property" (util/get-edit-content))) + (util/press-seq "/") + (assert/assert-is-visible ".ui__popover-content"))) + +(deftest task-date-and-priority-slash-lifecycle-test + (testing "task commands add, update and clear visible properties immediately" + (b/new-block "sample task") + (util/input-command "TODO") + (util/exit-edit) + (let [block (loc/filter ".ls-page-blocks .ls-block" + :has-text "sample task") + uuid (.getAttribute block "blockid") + block-selector (format "#ls-block-%s" uuid)] + (assert/assert-is-visible + (loc/filter (str block-selector " .block-tag") :has-text "Task")) + (w/click (str block-selector " .block-title-wrap")) + (util/move-cursor-to-end) + (util/input-command "Priority High") + (util/exit-edit) + (assert/assert-have-count + (str block-selector " .positioned-properties.block-left .property-value-inner") + 2) + (is (= "High" + (get-in (ls-api-call! :editor.getBlock uuid) + [":logseq.property/priority" "title"]))) + (w/click (str block-selector " .block-title-wrap")) + (util/move-cursor-to-end) + (util/input-command "Scheduled") + (w/click + "[role='gridcell'][aria-selected='true'] button") + (util/exit-edit) + (assert/assert-is-visible + (loc/filter (str block-selector " .bottom-property-pill") + :has-text "Scheduled")) + (w/click (str block-selector " .block-title-wrap")) + (util/move-cursor-to-end) + (util/input-command "No priority") + (util/exit-edit) + (is (= ":logseq.property/empty-placeholder" + (get-in (ls-api-call! :editor.getBlock uuid) + [":logseq.property/priority" "ident"])))))) + +(deftest virtualized-late-editor-and-code-editor-test + (testing "late-mounted rows and CodeMirror keep separate targets and persisted content" + (let [page-name (p/get-page-name) + page-uuid (get (ls-api-call! :editor.getPage page-name) "uuid")] + (ls-api-call! :editor.insertBatchBlock + page-uuid + (mapv #(hash-map :content (str "late editor row " %)) + (range 30))) + (w/click (loc/filter ".block-title-wrap" :has-text "late editor row 29")) + (util/move-cursor-to-end) + (util/press-seq " edited") + (is (= "late editor row 29 edited" (util/get-edit-content)))) + (b/new-block "") + (util/input-command "Code block") + (assert/assert-is-visible ".CodeMirror, .cm-editor") + (let [code-block-uuid + (.getAttribute (w/-query ".ls-block:has(.CodeMirror)") "blockid")] + (w/click (.first (w/-query "pre.CodeMirror-line"))) + (util/input "const value = 1;\nvalue + 1;") + (k/esc) + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".extensions__code" :has-text "const value = 1")) + (util/refresh-until-graph-loaded) + (is (string/includes? + (get (ls-api-call! :editor.getBlock code-block-uuid) "content") + "const value = 1"))))) + +(deftest multi-selection-indent-roundtrip-test + (testing "multi-indent preserves order/subtrees and is one undo step" + (let [page-name (p/get-page-name)] + (b/new-blocks ["multi a" "multi a child" "multi b" "multi b child"]) + (k/tab) + (k/arrow-up) + (k/arrow-up) + (k/tab) + (is (= 2 (count (ls-api-call! :editor.getPageBlocksTree page-name)))) + (k/arrow-down) + (k/arrow-down) + (b/select-blocks 2) + (k/tab) + (let [indented (ls-api-call! :editor.getPageBlocksTree page-name)] + (is (= 1 (count indented)))) + (b/undo) + (is (= 2 (count (ls-api-call! :editor.getPageBlocksTree page-name)))) + (b/redo) + (is (= 1 (count (ls-api-call! :editor.getPageBlocksTree page-name))))))) + +(deftest collapse-single-multiple-and-sidebar-test + (testing "single and selected collapse states update mounted containers predictably" + (let [page-name (p/get-page-name) + parent (ls-api-call! :editor.appendBlockInPage page-name "collapse parent") + uuid (get parent "uuid")] + (ls-api-call! :editor.insertBlock uuid "collapse child" {:sibling false}) + (w/click (format ".ls-page-blocks #control-%s" uuid)) + (assert/assert-is-hidden + (loc/filter (format "#ls-block-%s" uuid) :has-text "collapse child")) + (ls-api-call! :editor.openInRightSidebar uuid) + (assert/assert-is-visible (format ".cp__right-sidebar #ls-block-%s" uuid)) + (assert/assert-is-visible + (loc/filter ".cp__right-sidebar" :has-text "collapse child")) + (w/click + (format ".cp__right-sidebar #control-%s" uuid)) + (assert/assert-is-hidden + (loc/filter ".cp__right-sidebar" :has-text "collapse child")) + (w/click + (format ".cp__right-sidebar #control-%s" uuid)) + (assert/assert-is-visible + (loc/filter ".cp__right-sidebar" :has-text "collapse child")) + (w/refresh) + (assert/assert-is-hidden ".ui__loading, .loading-graph") + (assert/assert-is-visible + (format ".ls-page-blocks #ls-block-%s" uuid))))) + +(deftest selection-direction-and-hierarchical-select-all-test + (testing "range and select-all remain inside the current visible container" + (b/new-blocks ["select a" "select b" "select c" "select d"]) + (b/select-blocks 3) + (assert/assert-have-count ".ls-page-blocks .ls-block.selected" 3) + (is (= ["select b" "select c" "select d"] + (mapv string/trim + (w/all-text-contents + ".ls-page-blocks .ls-block.selected .block-title-wrap")))) + (k/press "ControlOrMeta+a") + (assert/assert-have-count ".ls-page-blocks .ls-block.selected" 4) + (assert/assert-have-count ".cp__right-sidebar .ls-block.selected" 0))) + +(deftest structured-and-plain-text-copy-test + (testing "structured copy preserves trees while plain copy exposes readable indentation" + (let [target-page "copy target"] + (b/new-blocks ["copy parent" "copy child" "copy sibling"]) + (k/arrow-up) + (b/indent) + (k/arrow-down) + (b/select-blocks 3) + (b/copy) + (p/new-page target-page) + (b/paste) + (util/exit-edit) + (let [tree (ls-api-call! :editor.getPageBlocksTree target-page)] + (is (= ["copy parent" "copy sibling"] + (mapv #(get % "content") tree))) + (is (= ["copy child"] + (mapv #(get % "content") (get (first tree) "children"))))) + (w/click (loc/filter ".block-title-wrap" :has-text "copy sibling")) + (b/select-blocks 3) + (k/press "ControlOrMeta+Shift+c") + (let [{:keys [missing-blocks] :as copy-result} + (wait-for-copied-blocks! ["copy parent" "copy child"])] + (is (empty? missing-blocks) (pr-str copy-result)))))) + +(deftest plain-multiline-and-html-paste-test + (testing "paste mode preserves cursor text, multiline structure and safe rich content" + (b/new-block "before-after") + (k/press "Home") + (dotimes [_ 7] (k/arrow-right)) + (clipboard-text! "middle") + (b/paste) + (is (= "before-middleafter" (util/get-edit-content))) + (clipboard-text! "root\n child\nsibling") + (b/new-block "") + (b/paste) + (util/exit-edit) + (assert/assert-is-visible (loc/filter ".ls-page-blocks" :has-text "child")) + (b/new-block "") + (w/eval-js + "(() => { + const transfer = new DataTransfer(); + transfer.setData('text/html', + '

Rich title

  • Bold item
'); + transfer.setData('text/plain', 'Rich title\\nBold item'); + document.querySelector('.editor-wrapper textarea').dispatchEvent( + new ClipboardEvent('paste', {bubbles: true, cancelable: true, clipboardData: transfer}) + ); + })()") + (assert/assert-is-visible + (loc/filter ".ls-page-blocks" :has-text "Rich title")) + (k/esc) + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".block-title-wrap" :has-text "Rich title")) + (is (not (true? (w/eval-js "window.__e2eInjected === true")))))) + +(deftest mixed-height-virtual-page-keeps-blocks-separated-test + (testing "mixed text, code and headings do not overlap after fast scrolling" + (let [long-text (apply str (repeat 8 "long mixed-height content ")) + blocks (vec + (mapcat + (fn [index] + [(format "mixed plain %03d" index) + (str "## mixed heading " index) + (str "```clojure\n(+ " index " 1)\n```") + (str long-text index)]) + (range 8)))] + (insert-current-page-blocks! blocks) + (enable-virtualized-rendering!) + (assert/assert-is-visible + (util/-query-last ".ls-page-blocks [data-virtuoso-scroller]")) + (doseq [position [1.0 0.0 0.5 0.9 0.1]] + (w/eval-js + "position => { + const scroller = Array.from(document.querySelectorAll( + '.ls-page-blocks [data-virtuoso-scroller]' + )).find((node) => node.getClientRects().length > 0); + scroller.scrollTop = + Math.max(0, (scroller.scrollHeight - scroller.clientHeight) * position); + scroller.dispatchEvent(new Event('scroll')); + }" + position) + (util/wait-timeout 100) + (let [{:strs [overlap visibleCount]} + (w/eval-js + "(() => { + const blocks = Array.from( + document.querySelectorAll('.ls-page-blocks .ls-block') + ).filter((node) => { + const rect = node.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < innerHeight; + }); + const rects = blocks.map((node) => node.getBoundingClientRect()); + return { + visibleCount: rects.length, + overlap: rects.some( + (rect, index) => index > 0 && rect.top < rects[index - 1].bottom + ) + }; + })()")] + (is (pos? visibleCount)) + (is (false? overlap))))))) + +(deftest journals-consecutive-input-test + (testing "consecutive journal input creates one stable editor and distinct blocks" + (util/goto-journals) + (b/new-blocks ["journal e2e first" "journal e2e second" "journal e2e third"]) + (assert/assert-have-count util/editor-q 1) + (is (= "journal e2e third" (util/get-edit-content))) + (util/exit-edit) + (let [journal-selector "#journals .journal-item:has-text('journal e2e third')" + block-selector (str journal-selector + " .ls-block:not(.block-add-button) .block-title-wrap")] + (w/wait-for journal-selector) + (is (= ["journal e2e first" "journal e2e second" "journal e2e third"] + (take-last 3 (w/all-text-contents block-selector))))))) + +(deftest worker-missing-read-is-recoverable-test + (testing "a missing worker entity returns nil without poisoning a later read" + (let [missing-uuid (str (random-uuid))] + (is (nil? (ls-api-call! :editor.getBlock missing-uuid)))) + (assert/assert-is-hidden ".ui__loading, .loading-graph") + (b/new-block "worker recovery target") + (let [uuid (.getAttribute (util/get-edit-block-container) "blockid")] + (util/exit-edit) + (is (string? uuid)) + (is (= "worker recovery target" + (get (ls-api-call! :editor.getBlock uuid) "content")))))) + +(deftest enter-splits-block-at-cursor-test + (testing "Enter in the middle preserves both halves and moves focus to the second" + (b/new-block "alphaomega") + (k/press "Home") + (dotimes [_ 5] (k/arrow-right)) + (k/enter) + (util/press-seq "middle-") + (assert/assert-have-count util/editor-q 1) + (is (= "middle-omega" (util/get-edit-content))) + (util/exit-edit) + (is (= ["alpha" "middle-omega"] + (take-last 2 (util/get-page-blocks-contents)))))) + +(deftest node-reference-autocomplete-test + (testing "node reference search inserts a reference and rerenders target updates" + (let [source-page (p/get-page-name)] + (b/new-block "reference autocomplete unique target") + (let [target-uuid (.getAttribute (util/get-edit-block-container) "blockid")] + (b/new-block "") + (util/press-seq "[[reference autocomplete unique") + (assert/assert-is-visible ".ui__popover-content") + (assert/assert-is-visible + (loc/filter ".ui__popover-content .breadcrumb" :has-text source-page)) + (w/click (.first (loc/filter ".ui__popover-content a" :has-text + "reference autocomplete unique target"))) + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".page-reference" + :has-text "reference autocomplete unique target")) + (b/jump-to-block "reference autocomplete unique target") + (k/press "ControlOrMeta+a") + (util/press-seq "reference autocomplete updated target") + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".page-reference" + :has-text "reference autocomplete updated target")) + (is (= source-page (p/get-page-name))) + (w/click (.first (loc/filter ".page-reference .page-ref" + :has-text "reference autocomplete updated target"))) + (is (string/includes? (.url (w/get-page)) target-uuid)) + (assert/assert-is-visible + (loc/filter ".ls-page-blocks .block-title-wrap" + :has-text "reference autocomplete updated target")))))) + +(deftest quick-add-moves-all-blocks-to-today-test + (testing "Quick add moves every temporary block to today's journal exactly once" + (k/press (if util/mac? "Meta+e" "Control+Alt+e")) + (assert/assert-is-visible ".ls-dialog-quick-add") + (w/wait-for util/editor-q) + (b/new-blocks ["quick add first" "quick add second"]) + (w/click + (loc/filter ".ls-dialog-quick-add button" :has-text "Add to today")) + (w/wait-for-not-visible ".ls-dialog-quick-add") + (util/goto-journals) + (assert/assert-have-count + (loc/filter "#journals .block-title-wrap" :has-text "quick add first") + 1) + (assert/assert-have-count + (loc/filter "#journals .block-title-wrap" :has-text "quick add second") + 1))) + +(deftest external-property-update-preserves-edit-buffer-test + (testing "an external property/child delta does not replace unrelated active text" + (b/new-block "active editor text") + (let [uuid (.getAttribute (util/get-edit-block-container) "blockid")] + (util/move-cursor-to-end) + (util/press-seq " local draft") + (ls-api-call! :editor.upsertBlockProperty uuid "external-property" "updated") + (ls-api-call! :editor.insertBlock uuid "external child" {:sibling false}) + (is (= "active editor text local draft" (util/get-edit-content))) + (assert/assert-is-visible + (loc/filter ".property-pair" :has-text "external-property")) + (assert/assert-is-visible + (loc/filter ".ls-block" :has-text "external child"))))) + +(deftest operation-completion-restores-mounted-focus-test + (testing "completed insert/update/delete operations expose their DOM result immediately" + (let [page-name (p/get-page-name) + inserted (ls-api-call! :editor.appendBlockInPage page-name "completion inserted") + uuid (get inserted "uuid")] + (assert/assert-have-count (str "#ls-block-" uuid) 1) + (ls-api-call! :editor.updateBlock uuid "completion updated") + (assert/assert-is-visible + (format "#ls-block-%s .block-title-wrap:text('completion updated')" uuid)) + (w/click (format "#ls-block-%s .block-content" uuid)) + (util/move-cursor-to-end) + (util/press-seq " and focused") + (is (= "completion updated and focused" (util/get-edit-content))) + (ls-api-call! :editor.removeBlock uuid) + (assert/assert-have-count (str "#ls-block-" uuid) 0)))) diff --git a/clj-e2e/test/logseq/e2e/export_basic_test.clj b/clj-e2e/test/logseq/e2e/export_basic_test.clj new file mode 100644 index 0000000..61cf378 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/export_basic_test.clj @@ -0,0 +1,67 @@ +(ns logseq.e2e.export-basic-test + (:require + [clojure.string :as string] + [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.locator :as loc] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import + (com.microsoft.playwright Page$WaitForDownloadOptions) + (java.nio.file Files) + (java.util.zip ZipFile))) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(defn- open-export! + [] + (util/double-esc) + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Export graph")) + (w/wait-for ".export")) + +(defn- download! + ([label] + (.waitForDownload + (w/get-page) + (reify Runnable + (run [_] + (w/click (loc/filter ".export a" :has-text label)))))) + ([label timeout] + (.waitForDownload + (w/get-page) + (-> (Page$WaitForDownloadOptions.) + (.setTimeout timeout)) + (reify Runnable + (run [_] + (w/click (loc/filter ".export a" :has-text label))))))) + +(defn- nonempty-download? + [download] + (and (not (string/blank? (.suggestedFilename download))) + (pos? (Files/size (.path download))))) + +(deftest graph-export-downloads-browser-artifacts-test + (testing "browser graph export produces nonempty DB, zip, EDN, Markdown and transit files" + (b/new-blocks ["export root" "export child"]) + (util/set-tag "export-tag") + (b/indent) + (open-export!) + (assert/assert-is-visible + (loc/filter ".export a" :has-text "Export SQLite DB")) + (doseq [label ["Export EDN file" + "Export as standard Markdown" + "Export debug transit file"]] + (let [download (download! label)] + (is (nonempty-download? download) label))) + (let [download (download! "Export both SQLite DB and assets" 60000) + path (.path download)] + (is (nonempty-download? download)) + (with-open [zip (ZipFile. (.toFile path))] + (let [entries (map #(.getName %) (enumeration-seq (.entries zip)))] + (is (some #(string/ends-with? % ".sqlite") entries)) + (is (every? #(not (string/blank? %)) entries))))) + (assert/assert-is-hidden ".ui__loading, .loading-graph"))) diff --git a/clj-e2e/test/logseq/e2e/fixtures.clj b/clj-e2e/test/logseq/e2e/fixtures.clj new file mode 100644 index 0000000..2d19d65 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/fixtures.clj @@ -0,0 +1,169 @@ +(ns logseq.e2e.fixtures + (:require [com.climate.claypoole :as cp] + [logseq.e2e.assert :as assert] + [logseq.e2e.config :as config] + [logseq.e2e.const :refer [*page1 *page2 *graph-name*]] + [logseq.e2e.custom-report :as custom-report] + [logseq.e2e.graph :as graph] + [logseq.e2e.page :as page] + [logseq.e2e.playwright-page :as pw-page] + [logseq.e2e.rtc :as rtc] + [logseq.e2e.settings :as settings] + [logseq.e2e.util :as util] + [wally.main :as w])) + +;; TODO: save trace +;; TODO: parallel support +(defn open-page + [f & {:keys [headless port]}] + (w/with-page-open + (w/make-page {:headless (or headless @config/*headless) + :persistent false + :slow-mo @config/*slow-mo}) + (w/grant-permissions :clipboard-write :clipboard-read) + (binding [custom-report/*pw-contexts* #{(.context (w/get-page))} + custom-report/*pw-page->console-logs* (atom {})] + (settings/install-init-script! (.context (w/get-page))) + (w/grant-permissions :clipboard-write :clipboard-read) + (w/navigate (pw-page/get-test-url port)) + (settings/developer-mode) + (settings/refresh-test-env!) + (let [p (w/get-page)] + (.onConsoleMessage p (fn [msg] + (when custom-report/*pw-page->console-logs* + (swap! custom-report/*pw-page->console-logs* update p conj (.text msg)))))) + (f)))) + +(defn open-2-pages + "Use `*page1` and `*page2` in `f`" + [f & {:keys [headless port]}] + (let [headless (or headless @config/*headless) + page-opts {:headless headless + :persistent false + :slow-mo @config/*slow-mo} + p1 (w/make-page page-opts) + p2 (w/make-page page-opts)] + (run! #(settings/install-init-script! (.context @%)) [p1 p2]) + (reset! *page1 p1) + (reset! *page2 p2) + (binding [custom-report/*pw-contexts* (set [(.context @p1) (.context @p2)]) + custom-report/*pw-page->console-logs* (atom {}) + w/*page* (delay (throw (ex-info "Don't use *page*, use *page1* and *page2* instead" {})))] + (run! + #(w/with-page % + (w/grant-permissions :clipboard-write :clipboard-read) + (w/navigate (pw-page/get-test-url (or port @config/*port))) + (settings/developer-mode) + (settings/refresh-test-env!) + (let [p (w/get-page)] + (.onConsoleMessage + p + (fn [msg] + (when custom-report/*pw-page->console-logs* + (swap! custom-report/*pw-page->console-logs* update p conj (.text msg))))))) + [p1 p2]) + (f)) + + ;; use with-page-open to release resources + (w/with-page-open p1) + (w/with-page-open p2) + (reset! *page1 nil) + (reset! *page2 nil))) + +(def ^:dynamic *pw-ctx* nil) +(defn open-new-context + "create a new playwright-context in `*pw-ctx*`" + [f] + (let [page-opts {:headless @config/*headless + :persistent false + :slow-mo @config/*slow-mo} + p @(w/make-page page-opts) + ctx (.newContext (.browser (.context p)))] + ;; context for p is no longer needed + (.close (.context p)) + (w/with-page-open p) ; use with-page-open to close playwright instance + (binding [custom-report/*pw-contexts* #{ctx} + *pw-ctx* ctx] + (settings/install-init-script! ctx) + (f) + (.close (.browser *pw-ctx*))))) + +(defonce *page-number (atom 0)) + +(defn create-page + [& [page-name]] + (let [page-name (or page-name (str "page " (swap! *page-number inc)))] + (page/new-page page-name) + page-name)) + +(defn new-logseq-page + [f] + (when (w/visible? ".cp__right-sidebar.open") + (w/click ".toggle-right-sidebar") + (w/wait-for-not-visible ".cp__right-sidebar.open")) + (w/eval-js + "() => { + const url = new URL(location.href); + url.searchParams.delete('virtualized'); + history.replaceState(null, '', url.pathname + url.search + url.hash); + }") + (create-page) + (f)) + +(defn new-logseq-page-in-rtc* + "create a logseq page and switch to this page on both `*page1` and `*page2`" + [& [page-name]] + (let [*page-name (atom nil) + {:keys [_local-tx remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (reset! *page-name (create-page page-name))))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx) + (page/goto-page @*page-name)))) + +(defn new-logseq-page-in-rtc + [f] + (new-logseq-page-in-rtc*) + (f)) + +(defn validate-graph + [f] + (f) + (if (and @*page1 @*page2) + (doseq [p [@*page1 @*page2]] + (w/with-page p + (graph/validate-graph))) + + (graph/validate-graph))) + +(def ^:private formatter (java.time.format.DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH-mm-ss")) +(defn- inst-string + [inst] + (.format formatter (.atZone inst (java.time.ZoneId/of "UTC")))) + +(defn prepare-rtc-graph-fixture + "open 2 app instances, add a rtc graph, check this graph available on other instance" + [graph-name-prefix f] + (let [graph-name (str graph-name-prefix "-" (inst-string (java.time.Instant/now)))] + (cp/prun! + 2 + #(w/with-page % + (settings/developer-mode) + (settings/refresh-test-env!) + (util/login-test-account)) + [@*page1 @*page2]) + (w/with-page @*page1 + (graph/new-graph graph-name true false)) + (w/with-page @*page2 + (graph/wait-for-remote-graph graph-name) + (graph/switch-graph graph-name true true)) + + (binding [custom-report/*preserve-graph* false + *graph-name* graph-name] + (f) + ;; cleanup + (if custom-report/*preserve-graph* + (println "Don't remove graph: " graph-name) + (w/with-page @*page2 + (graph/remove-remote-graph graph-name)))))) diff --git a/clj-e2e/test/logseq/e2e/flashcards_basic_test.clj b/clj-e2e/test/logseq/e2e/flashcards_basic_test.clj new file mode 100644 index 0000000..3734181 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/flashcards_basic_test.clj @@ -0,0 +1,84 @@ +(ns logseq.e2e.flashcards-basic-test + (:require [clojure.test :refer [deftest testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.locator :as loc] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/validate-graph) + +(defn- open-flashcards + [] + (util/double-esc) + (when-not (w/visible? ".flashcards-nav") + (w/click "#left-menu") + (w/wait-for ".flashcards-nav")) + (w/eval-js "selector => document.querySelector(selector)?.click()" ".flashcards-nav a") + (assert/assert-is-visible "#cards-modal")) + +(defn- select-cards-option + [label] + (w/click "#cards-modal [role='combobox']") + (w/click (loc/filter "[role='option']" :has-text label))) + +(defn- click-flashcards-plus + [] + (w/click "#ls-cards-add")) + +(defn- setup-flashcards-data! + [{:keys [page-name tag-a tag-b card-a card-b query-a query-b]}] + (ls-api-call! :editor.appendBlockInPage page-name (str card-a " #Card #" tag-a)) + (ls-api-call! :editor.appendBlockInPage page-name (str card-b " #Card #" tag-b)) + (let [cards (ls-api-call! :editor.getTag "logseq.class/Cards") + cards-id (get cards "id") + cards-a (ls-api-call! :editor.appendBlockInPage page-name "Cards A" + {:properties {:block/tags #{cards-id}}}) + cards-b (ls-api-call! :editor.appendBlockInPage page-name "Cards B" + {:properties {:block/tags #{cards-id}}}) + query-a-id (get cards-a ":logseq.property/query") + query-b-id (get cards-b ":logseq.property/query")] + (ls-api-call! :editor.updateBlock query-a-id query-a) + (ls-api-call! :editor.updateBlock query-b-id query-b))) + +(deftest flashcards-plus-and-switching-test + (testing "create #Cards blocks from flashcards dialog and switch card sets" + (let [tag-a "fc-tag-a" + tag-b "fc-tag-b" + card-a "Card A" + card-b "Card B" + query-a (str "[[" tag-a "]]") + query-b (str "[[" tag-b "]]")] + (util/goto-journals) + (let [page (ls-api-call! :editor.getCurrentPage) + page-name (get page "name")] + (setup-flashcards-data! + {:page-name page-name + :tag-a tag-a + :tag-b tag-b + :card-a card-a + :card-b card-b + :query-a query-a + :query-b query-b})) + + (open-flashcards) + (click-flashcards-plus) + (w/wait-for ".ls-block .tag:has-text('Cards')") + + (open-flashcards) + (select-cards-option "Cards A") + (assert/assert-is-visible (format "#cards-modal .ls-card :text('%s')" card-a)) + (assert/assert-have-count (format "#cards-modal .ls-card :text('%s')" card-b) 0) + (assert/assert-is-visible (loc/filter "#cards-modal .text-sm.opacity-50" :has-text "1/1")) + + (select-cards-option "Cards B") + (assert/assert-is-visible (format "#cards-modal .ls-card :text('%s')" card-b)) + (assert/assert-have-count (format "#cards-modal .ls-card :text('%s')" card-a) 0) + (assert/assert-is-visible (loc/filter "#cards-modal .text-sm.opacity-50" :has-text "1/1")) + + (select-cards-option "All cards") + (assert/assert-is-visible (loc/filter "#cards-modal .text-sm.opacity-50" :has-text "1/2"))))) diff --git a/clj-e2e/test/logseq/e2e/graph_navigation_basic_test.clj b/clj-e2e/test/logseq/e2e/graph_navigation_basic_test.clj new file mode 100644 index 0000000..f3f960c --- /dev/null +++ b/clj-e2e/test/logseq/e2e/graph_navigation_basic_test.clj @@ -0,0 +1,259 @@ +(ns logseq.e2e.graph-navigation-basic-test + (:require + [clojure.string :as string] + [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.graph :as graph] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(deftest graph-empty-state-and-name-validation-test + (testing "graph creation exposes an actionable empty state and rejects invalid/duplicate names" + (let [graph-name (str "sample-valid-graph-" (random-uuid))] + (graph/goto-all-graphs) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text "Create a new graph")) + (util/search-and-click "Add a DB graph") + (let [input (w/-query "input[placeholder='your graph name']")] + (w/fill input "") + (w/click "button:text('Submit')") + (assert/assert-is-visible ".new-graph") + (doseq [invalid-name ["bad/name" "bad\\name"]] + (w/fill input invalid-name) + (w/click "button:text('Submit')") + (assert/assert-is-visible + ".ui__toast.warning:has-text(\"Graph name can't contain\")") + (w/click (.last (w/-query ".ui__toast.warning button")))) + (w/fill input graph-name) + (w/click "button:text('Submit')") + (assert/assert-graph-loaded?) + (graph/goto-all-graphs) + (util/search-and-click "Add a DB graph") + (w/fill "input[placeholder='your graph name']" graph-name) + (w/click "button:text('Submit')") + (assert/assert-is-visible + ".ui__toast.error:has-text('already exists')") + (k/esc) + (graph/switch-graph graph-name false false))))) + +(deftest graph-refresh-and-browser-history-test + (testing "refresh and back/forward restore current graph/page without duplicate history" + (let [page-a "graph history a" + page-b "graph history b"] + (page/new-page page-a) + (b/new-block "history a content") + (util/exit-edit) + (page/new-page page-b) + (b/new-block "history b content") + (util/exit-edit) + (.goBack (w/get-page)) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text "history a content")) + (.goForward (w/get-page)) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text "history b content")) + (util/refresh-until-graph-loaded) + (is (= page-b (page/get-page-name))) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text "history b content"))))) + +(deftest direct-page-and-block-route-test + (testing "direct page/block URLs load exact worker entities and missing routes recover" + (let [suffix (str (random-uuid)) + page-name (str "direct-route-page-" suffix) + block-content (str "direct route block " suffix)] + (page/new-page page-name) + (b/new-block block-content) + (let [block-uuid (.getAttribute + (util/get-edit-block-container) + "blockid")] + (util/exit-edit) + (util/refresh-until-graph-loaded) + (let [page-uuid (get (ls-api-call! :editor.getPage page-name) "uuid") + block (ls-api-call! :editor.getBlock block-uuid) + current-url (.url (w/get-page)) + base (-> current-url + (string/replace #"[?#].*$" "") + (string/replace #"/$" "")) + graph-id (some-> (re-find #"[?&]graph-id=([^&]+)" current-url) + second) + direct-url (fn [kind uuid] + (str base + "/#/" + kind + "/" + uuid + "?graph-id=" + graph-id)) + navigate-direct! (fn [url] + (w/navigate "about:blank") + (w/navigate url))] + (is (= block-content (get block "content"))) + (is (some? block-uuid)) + (is (some? graph-id)) + (navigate-direct! (direct-url "page" page-uuid)) + (assert/assert-is-visible + (loc/filter "[data-testid='page title']" :has-text page-name)) + (is (= page-name (page/get-page-name))) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text block-content)) + (is (= block-content + (get (ls-api-call! :editor.getBlock block-uuid) "content"))) + (navigate-direct! (direct-url "block" block-uuid)) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text block-content)) + (navigate-direct! (direct-url "page" (random-uuid))) + (assert/assert-is-visible + (loc/filter "#main-content-container" :has-text "Page not found")) + (util/goto-journals) + (assert/assert-is-visible "#journals")))))) + +(deftest local-graph-delete-and-list-metadata-test + (testing "graph list metadata is current and local deletion cannot leak its route/data" + (let [graph-name (str "graph-delete-" (random-uuid))] + (graph/new-graph graph-name false) + (page/new-page "local graph deletion page") + (b/new-block "local graph deletion content") + (graph/goto-all-graphs) + (let [card (w/-query (format "div[data-testid='logseq_db_%s']" graph-name))] + (assert/assert-is-visible card) + (assert/assert-is-visible + (loc/filter card :has-text "Last opened at:")) + (w/click (.locator card ".graph-action-btn")) + (w/click ".delete-local-graph-menu-item") + (w/click "div[role='alertdialog'] button:text('Confirm')") + (assert/assert-have-count card 0)) + (assert/assert-have-count + (loc/filter "#main-content-container" :has-text "local graph deletion content") + 0) + (graph/switch-graph "Demo" false false) + (page/new-page "post deletion validation") + (util/exit-edit) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible "#search-button")))) + +(deftest default-home-route-test + (testing "configured default home opens on startup and invalid home falls back explicitly" + (let [set-default-home! + (fn [page-name] + (ls-api-call! :app.setCurrentGraphConfigs + {"default-home" (cond-> {} + page-name + (assoc "page" page-name))}) + (loop [remaining 40] + (let [stored-page + (ls-api-call! :app.getCurrentGraphConfigs + "default-home" + "page")] + (when-not (= page-name stored-page) + (when (zero? remaining) + (throw (ex-info "Default home config did not persist" + {:expected page-name + :actual stored-page}))) + (util/wait-timeout 250) + (recur (dec remaining))))))] + (page/new-page "sample default home") + (b/new-block "default home content") + (util/exit-edit) + (let [current-url (.url (w/get-page)) + base (-> current-url + (string/replace #"[?#].*$" "") + (string/replace #"/$" "")) + graph-id (some-> (re-find #"[?&]graph-id=([^&]+)" current-url) + second) + home-url (str base "/#/?graph-id=" graph-id) + navigate-home! (fn [] + (w/navigate "about:blank") + (w/navigate home-url))] + (is (some? graph-id)) + (set-default-home! "sample default home") + (navigate-home!) + (assert/assert-is-visible + (loc/filter "[data-testid='page title']" + :has-text "sample default home")) + (is (= "sample default home" (page/get-page-name))) + (set-default-home! "missing sample home") + (navigate-home!) + (assert/assert-is-visible "#journals") + (assert/assert-is-hidden ".loading-graph") + (set-default-home! nil) + (page/new-page "post default home validation") + (util/exit-edit) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible "#search-button"))))) + +(deftest graph-view-mode-settings-test + (testing "Graph view modes and settings rebuild the canvas" + (doseq [title ["graph view alpha" "graph view beta" "graph view gamma"]] + (page/new-page title) + (b/new-block "[[graph view alpha]]") + (util/set-tag title) + (util/exit-edit)) + (util/search-and-click "Go to graph view") + (assert/assert-is-visible "#global-graph.graph-root") + (assert/assert-is-visible + "[role='application'][aria-label='Graph canvas']") + (w/click ".graph-settings-toggle") + (w/click (loc/filter ".graph-mode-tab" :has-text "Tags")) + (assert/assert-is-visible + (loc/filter ".graph-mode-tab[aria-selected='true']" :has-text "Tags")) + (w/click (loc/filter ".graph-mode-tab" :has-text "All pages")) + (assert/assert-is-visible + (loc/filter ".graph-mode-tab[aria-selected='true']" :has-text "All pages")) + (w/refresh) + (assert/assert-is-visible "#global-graph.graph-root") + (assert/assert-is-visible + "[role='application'][aria-label='Graph canvas']") + (assert/assert-is-hidden ".graph-error") + (page/goto-page "graph view beta") + (assert/assert-graph-loaded?))) + +(deftest graph-time-travel-playback-test + (testing "time travel limits nodes and returning to Now restores the complete graph" + (page/new-page "time travel old") + (util/wait-timeout 20) + (page/new-page "time travel new") + (util/search-and-click "Go to graph view") + (w/click "button[title*='Time'], button:has-text('Time travel')") + (let [slider (w/-query "input[type='range']")] + (assert/assert-is-visible slider) + (let [full-count (util/count-elements ".graph-node, [data-node-id]")] + (w/click slider) + (k/press "Home") + (is (<= (util/count-elements ".graph-node, [data-node-id]") + full-count)) + (w/click ".graph-time-travel-reset[title='Now']") + (assert/assert-is-visible + (loc/filter ".graph-time-travel-label" :has-text "Now")) + (is (= full-count + (util/count-elements ".graph-node, [data-node-id]"))))))) + +(deftest restoring-graph-gates-and-recovers-interaction-test + (testing "reload/restoring never accepts half-mounted edits and restores all controls" + (b/new-block "restore interaction target") + (util/exit-edit) + (w/refresh) + (assert/assert-is-visible + (loc/or ".loading-graph, .ui__loading" + "[data-testid='page title']")) + (when (w/visible? ".loading-graph, .ui__loading") + (is (or (not (w/visible? ".block-add-button")) + (.isDisabled (w/-query ".block-add-button"))))) + (assert/assert-graph-loaded?) + (assert/assert-is-visible ".toolbar-dots-btn") + (w/click ".toolbar-dots-btn") + (assert/assert-is-visible "[role='menuitem']") + (k/esc) + (b/jump-to-block "restore interaction target") + (util/move-cursor-to-end) + (util/press-seq " after restore") + (is (= "restore interaction target after restore" (util/get-edit-content))))) diff --git a/clj-e2e/test/logseq/e2e/graph_test.clj b/clj-e2e/test/logseq/e2e/graph_test.clj new file mode 100644 index 0000000..ce4095c --- /dev/null +++ b/clj-e2e/test/logseq/e2e/graph_test.clj @@ -0,0 +1,46 @@ +(ns logseq.e2e.graph-test + (:require [clojure.test :refer [deftest is]] + [logseq.e2e.graph] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(deftest maybe-input-e2ee-password-skips-when-cloud-ready-test + (let [wait-calls (atom 0) + input-calls (atom 0)] + (with-redefs-fn {#'w/visible? (fn [q] (= q "button.cloud.on.idle")) + #'util/wait-timeout (fn [_] (swap! wait-calls inc)) + #'logseq.e2e.graph/input-e2ee-password (fn [] (swap! input-calls inc))} + (fn [] + ((var logseq.e2e.graph/maybe-input-e2ee-password)) + (is (= 8 @wait-calls)) + (is (zero? @input-calls)))))) + +(deftest maybe-input-e2ee-password-inputs-when-modal-appears-test + (let [ticks (atom 0) + input-calls (atom 0)] + (with-redefs-fn {#'w/visible? (fn [q] + (case q + ".e2ee-password-modal-content" (>= @ticks 2) + "button.cloud.on.idle" false + false)) + #'util/wait-timeout (fn [_] (swap! ticks inc)) + #'logseq.e2e.graph/input-e2ee-password (fn [] (swap! input-calls inc))} + (fn [] + ((var logseq.e2e.graph/maybe-input-e2ee-password)) + (is (= 2 @ticks)) + (is (= 1 @input-calls)))))) + +(deftest maybe-input-e2ee-password-does-not-exit-on-stale-cloud-ready-test + (let [ticks (atom 0) + input-calls (atom 0)] + (with-redefs-fn {#'w/visible? (fn [q] + (case q + ".e2ee-password-modal-content" (>= @ticks 2) + "button.cloud.on.idle" true + false)) + #'util/wait-timeout (fn [_] (swap! ticks inc)) + #'logseq.e2e.graph/input-e2ee-password (fn [] (swap! input-calls inc))} + (fn [] + ((var logseq.e2e.graph/maybe-input-e2ee-password)) + (is (= 2 @ticks)) + (is (= 1 @input-calls)))))) diff --git a/clj-e2e/test/logseq/e2e/import_basic_test.clj b/clj-e2e/test/logseq/e2e/import_basic_test.clj new file mode 100644 index 0000000..8e156fe --- /dev/null +++ b/clj-e2e/test/logseq/e2e/import_basic_test.clj @@ -0,0 +1,49 @@ +(ns logseq.e2e.import-basic-test + (:require + [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.graph :as graph] + [logseq.e2e.locator :as loc] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import + (java.nio.file Paths))) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(defn- open-import! + [] + (util/double-esc) + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Import")) + (w/wait-for ".importer")) + +(deftest import-options-and-invalid-edn-preserve-current-graph-test + (testing "browser import exposes supported sources and rejects invalid EDN atomically" + (let [current-graph (ls-api-call! :app.getCurrentGraph) + invalid-file (Paths/get "resources/invalid-db-export.edn" + (into-array String [])) + graph-name (str "invalid-import-" (random-uuid))] + (open-import!) + (doseq [selector ["#import-sqlite-db" + "#import-sqlite-zip" + "#import-file-graph" + "#import-debug-transit" + "#import-db-edn"]] + (assert/assert-have-count selector 1)) + (.setInputFiles (w/-query "#import-db-edn") invalid-file) + (w/wait-for "#modal-headline") + (w/click (loc/filter "button" :has-text "Submit")) + (assert/assert-is-visible ".ui__toast") + (assert/assert-is-visible "#modal-headline") + (w/fill ".form-input" graph-name) + (w/click (loc/filter "button" :has-text "Submit")) + (w/wait-for ".ui__toast") + (assert/assert-is-hidden ".ui__loading, .loading-graph") + (is (= current-graph (ls-api-call! :app.getCurrentGraph))) + (graph/goto-all-graphs) + (assert/assert-is-hidden + (loc/filter "#main-content-container" :has-text graph-name))))) diff --git a/clj-e2e/test/logseq/e2e/multi_tabs_basic_test.clj b/clj-e2e/test/logseq/e2e/multi_tabs_basic_test.clj new file mode 100644 index 0000000..8b063fc --- /dev/null +++ b/clj-e2e/test/logseq/e2e/multi_tabs_basic_test.clj @@ -0,0 +1,57 @@ +(ns logseq.e2e.multi-tabs-basic-test + (:require [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.graph :as graph] + [logseq.e2e.playwright-page :as pw-page] + [logseq.e2e.util :as util] + [wally.main :as w] + [wally.repl :as repl])) + +(use-fixtures :once fixtures/open-new-context) + +(defn- add-blocks-and-check-on-other-tabs + [new-blocks add-blocks-tab check-blocks-tabs] + (w/with-page add-blocks-tab + (b/new-blocks new-blocks)) + (run! + #(w/with-page % + (b/assert-blocks-visible new-blocks)) + check-blocks-tabs)) + +(deftest multi-tabs-test + (testing "edit on one tab, check all tab's blocks are same" + (pw-page/open-pages fixtures/*pw-ctx* 3) + (let [[p1 p2 p3 :as pages] (pw-page/get-pages fixtures/*pw-ctx*) + blocks-to-add (map #(str "b" %) (range 10))] + (is (= 3 (count pages))) + (add-blocks-and-check-on-other-tabs blocks-to-add p1 [p2 p3]))) + + (testing "add new graphs and switch graphs + edit + check on tabs" + (let [[p1 p2 p3] (pw-page/get-pages fixtures/*pw-ctx*)] + (letfn [(switch-to-graph-then-edit-and-check [graph-name] + (w/with-page p2 + (util/goto-journals) + (assert/assert-in-normal-mode?) + (graph/switch-graph graph-name false false)) + (w/with-page p3 + (util/goto-journals) + (assert/assert-in-normal-mode?) + (graph/switch-graph graph-name false false)) + (w/with-page p1 + (util/goto-journals) + (assert/assert-in-normal-mode?) + (graph/switch-graph graph-name false false)) + (let [graph-new-blocks (map #(str graph-name "-b1-" %) (range 5))] + (add-blocks-and-check-on-other-tabs graph-new-blocks p1 [p2 p3])))] + (w/with-page p1 + (graph/new-graph "graph1" false) + (graph/new-graph "graph2" false) + (graph/new-graph "graph3" false)) + ;; FIXME: since all-graphs isn't auto-update when other tabs add new graphs, so refresh here + (w/with-page p2 (util/refresh-until-graph-loaded)) + (w/with-page p3 (util/refresh-until-graph-loaded)) + (switch-to-graph-then-edit-and-check "graph1") + (switch-to-graph-then-edit-and-check "graph2") + (switch-to-graph-then-edit-and-check "graph3"))))) diff --git a/clj-e2e/test/logseq/e2e/outliner_basic_test.clj b/clj-e2e/test/logseq/e2e/outliner_basic_test.clj new file mode 100644 index 0000000..424712b --- /dev/null +++ b/clj-e2e/test/logseq/e2e/outliner_basic_test.clj @@ -0,0 +1,664 @@ +(ns logseq.e2e.outliner-basic-test + (:require + [clojure.string :as string] + [clojure.test :refer [deftest testing is use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.graph :as graph] + [logseq.e2e.keyboard :as k] + [logseq.e2e.page :as p] + [logseq.e2e.util :as util] + [wally.main :as w]) + (:import + (com.microsoft.playwright Locator$DragToOptions))) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(defn- block-text-position + [text] + (let [locator (w/find-one-by-text "span" text)] + (assert/assert-is-visible locator) + (first (util/bounding-xy locator)))) + +(defn create-test-page-and-insert-blocks [] + ;; a page block and a child block + (is (= 2 (util/blocks-count))) + (b/new-blocks ["first block" "second block"]) + (util/exit-edit) + (is (= 3 (util/blocks-count)))) + +(defn indent-and-outdent [] + (b/new-blocks ["b1" "b2"]) + (testing "simple indent and outdent" + (b/indent) + (b/outdent)) + + (testing "indent a block with its children" + (b/new-block "b3") + (b/indent) + (k/arrow-up) + (b/indent) + (util/exit-edit) + (let [[x1 x2 x3] (map block-text-position ["b1" "b2" "b3"])] + (is (< x1 x2 x3)))) + + (testing "unindent a block with its children" + (b/open-last-block) + (b/new-blocks ["b4" "b5"]) + (b/indent) + (k/arrow-up) + (b/outdent) + (util/exit-edit) + (let [[x2 x3 x4 x5] (map block-text-position ["b2" "b3" "b4" "b5"])] + (is (and (= x2 x4) (= x3 x5) (< x2 x3)))))) + +(defn indent-outdent-embed-page [] + (p/new-page "Page embed") + (b/new-blocks ["b1" "b2"]) + (p/new-page "Page testing") + (b/new-blocks ["b3" ""]) + (util/input-command "Node embed") + (util/press-seq "Page embed" {:delay 60}) + (w/wait-for "#ac-0.menu-link:has-text('Page embed')") + (k/press "Enter" {:delay 60}) + (util/exit-edit) + (b/new-blocks ["b4"]) + (b/outdent) + (b/indent) + (util/exit-edit) + (let [[x2 x3 x4] (map block-text-position ["b2" "b3" "b4"])] + (is (= x2 x4)) + (is (< x3 x2)))) + +(defn move-up-down [] + (b/new-blocks ["b1" "b2" "b3" "b4"]) + (util/repeat-keyboard 2 "Shift+ArrowUp") + (let [contents (util/get-page-blocks-contents)] + (is (= contents ["b1" "b2" "b3" "b4"]))) + (util/repeat-keyboard 2 (str (if util/mac? "Meta" "Alt") "+Shift+ArrowUp")) + (let [contents (util/get-page-blocks-contents)] + (is (= contents ["b3" "b4" "b1" "b2"]))) + (util/repeat-keyboard 2 (str (if util/mac? "Meta" "Alt") "+Shift+ArrowDown")) + (let [contents (util/get-page-blocks-contents)] + (is (= contents ["b1" "b2" "b3" "b4"])))) + +(defn- zoom-in-shortcut [] + (k/press (if util/mac? "Meta+Shift+." "Alt+ArrowRight"))) + +(defn- current-location-hash [] + (w/eval-js "window.location.hash")) + +(defn- current-editing-block-id [] + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + return editor?.closest('[blockid]')?.getAttribute('blockid') ?? null; + })();")) + +(deftest focused-root-block-cannot-indent-or-move-test + (testing "Focused root block ignores indent/outdent/move-up/move-down commands" + (b/new-blocks ["focused-root" "focused-child"]) + (k/arrow-up) + (let [root-id (current-editing-block-id)] + (is (string? root-id)) + (zoom-in-shortcut) + (util/wait-timeout 400) + ;; Retry once in case the first key event gets swallowed by the editor. + (when-not (string/includes? (or (current-location-hash) "") root-id) + (zoom-in-shortcut) + (util/wait-timeout 400)) + (is (string/includes? (or (current-location-hash) "") root-id)) + (util/wait-editor-visible) + (is (= "focused-root" (util/get-edit-content))) + (let [before-hash (current-location-hash) + before-block-contents (util/get-page-blocks-contents)] + (k/tab) + (util/wait-timeout 100) + (k/shift+tab) + (util/wait-timeout 100) + (k/meta+shift+arrow-up) + (util/wait-timeout 100) + (k/meta+shift+arrow-down) + (util/wait-timeout 100) + (is (= "focused-root" (util/get-edit-content))) + (is (= before-hash (current-location-hash))) + (is (= before-block-contents (util/get-page-blocks-contents))))))) + +(defn delete [] + (testing "Delete blocks case 1" + (b/new-blocks ["b1" "b2" "b3" "b4"]) + (b/delete-blocks) ; delete b4 + (util/repeat-keyboard 2 "Shift+ArrowUp") ; select b3 and b2 + (b/delete-blocks) + (is (= "b1" (util/get-edit-content))) + (is (= 1 (util/page-blocks-count))))) + +(defn delete-end [] + (testing "Delete at end" + (b/new-blocks ["b1" "b2" "b3"]) + (k/arrow-up) + (k/delete) + (is (= "b2b3" (util/get-edit-content))) + (is (= 2 (util/page-blocks-count))))) + +(defn delete-test-with-children [] + (testing "Delete block with its children" + (b/new-blocks ["b1" "b2" "b3" "b4"]) + (b/indent) + (k/arrow-up) + (b/indent) + (k/arrow-up) + (b/delete-blocks) + (util/wait-editor-visible) + (is (= "b1" (util/get-edit-content))) + (is (= 1 (util/page-blocks-count))))) + +(deftest create-test-page-and-insert-blocks-test + (create-test-page-and-insert-blocks)) + +(deftest indent-and-outdent-test + (indent-and-outdent)) + +(deftest indent-outdent-embed-page-test + (indent-outdent-embed-page)) + +(deftest move-up-down-test + (move-up-down)) + +(deftest delete-test + (delete)) + +(deftest delete-end-test + (delete-end)) + +(deftest delete-test-with-children-test + (delete-test-with-children)) + +(deftest delete-concat-test-2-blocks + (testing "Delete concat with empty block" + (b/new-blocks ["" "b2"]) + (b/indent) + (k/arrow-up) + (k/delete) + (util/wait-editor-visible) + (is (= "b2" (util/get-edit-content))) + (util/exit-edit) + (is (= ["b2"] (util/get-page-blocks-contents))))) + +(deftest delete-concat-test-3-blocks + (testing "Delete concat with empty block" + (b/new-blocks ["" "b2" "b3"]) + (b/indent) + (k/arrow-up) + (k/arrow-up) + (k/delete) + (util/wait-editor-visible) + (is (= "b2" (util/get-edit-content))) + (util/exit-edit) + (is (= ["b2" "b3"] (util/get-page-blocks-contents))))) + +(deftest delete-concat-test-with-children + (testing "Delete concat with children blocks" + (b/new-blocks ["" "b2" "b3"]) + (b/indent) + (k/arrow-up) + (b/indent) + (k/arrow-up) + (k/delete) + (util/wait-editor-visible) + (is (= "" (util/get-edit-content))) + (is (= 3 (util/page-blocks-count))))) + +(deftest delete-concat-test-with-tag + (testing "Delete concat with tag" + (b/new-blocks ["" "b2"]) + (b/indent) + (util/set-tag "tag1") + (k/arrow-up) + (k/delete) + (util/wait-editor-visible) + (is (= "b2" (util/get-edit-content))) + (util/exit-edit) + (assert/assert-is-visible + ".ls-block a.tag:has-text('tag1')") + (is (= ["b2"] (util/get-page-blocks-contents))))) + +(deftest backspace-empty-first-child-keeps-empty-parent-subtree-test + (testing "Backspace in the first empty child of an empty parent deletes only the child" + (p/new-page "backspace empty first child") + (let [page-uuid (get (ls-api-call! :editor.getBlock "backspace empty first child") "uuid") + [parent first-child child2 child3] + (ls-api-call! :editor.insertBatchBlock + page-uuid + [{:content "" + :children [{:content ""} + {:content "child2"} + {:content "child3"}]}]) + block-visible? #(pos? (util/count-elements (str "#ls-block-" %)))] + (w/click (str "#ls-block-" (get first-child "uuid") " .block-content")) + (util/wait-editor-visible) + (is (= "" (util/get-edit-content))) + (k/backspace) + (util/wait-timeout 100) + (is (not (block-visible? (get first-child "uuid")))) + (is (block-visible? (get parent "uuid"))) + (is (block-visible? (get child2 "uuid"))) + (is (block-visible? (get child3 "uuid")))))) + +(deftest backspace-at-parent-start-keeps-children-test + (doseq [[page-name parent-content] + [["backspace non-empty parent start" "a"] + ["backspace empty parent start" ""]]] + (testing (str "Backspace at the start of parent " (pr-str parent-content) + " preserves its child") + (p/new-page page-name) + (let [page-uuid (get (ls-api-call! :editor.getBlock page-name) "uuid") + [parent child] + (ls-api-call! :editor.insertBatchBlock + page-uuid + [{:content parent-content + :children [{:content "b"}]}]) + parent-uuid (get parent "uuid") + child-uuid (get child "uuid") + block-visible? #(pos? (util/count-elements (str "#ls-block-" %)))] + (w/click (str "#block-content-" parent-uuid)) + (util/wait-editor-visible) + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + editor.focus(); + editor.setSelectionRange(0, 0); + })();") + (k/backspace) + (util/wait-timeout 200) + (is (= parent-content (util/get-edit-content))) + (is (block-visible? parent-uuid)) + (is (block-visible? child-uuid)))))) + +(deftest consecutive-backspace-does-not-restore-deleted-blocks-test + (testing "Deleting b and then a never renders stale block content" + (p/new-page "consecutive backspace deletion") + (let [page-uuid (get (ls-api-call! :editor.getBlock "consecutive backspace deletion") "uuid") + [_a b] (ls-api-call! :editor.insertBatchBlock + page-uuid + [{:content "a"} + {:content "b"}]) + editor-state #(w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + return [ + editor?.value ?? null, + Array.from(document.querySelectorAll( + '.ls-page-blocks .block-title-wrap')) + .map((node) => node.textContent.trim()) + .filter(Boolean) + ]; + })();")] + (w/click (str "#block-content-" (get b "uuid"))) + (util/wait-editor-visible) + (w/eval-js + "(() => { + const editor = document.querySelector('.editor-wrapper textarea'); + editor.focus(); + editor.setSelectionRange(editor.value.length, editor.value.length); + })();") + (dotimes [_ 4] + (k/backspace)) + (util/wait-timeout 50) + (let [immediate (editor-state)] + (is (= [] (second immediate))) + (util/wait-timeout 500) + (is (= immediate (editor-state))))))) + +(deftest held-backspace-does-not-duplicate-merged-content-test + (testing "Repeated Backspace cannot merge the same block twice" + (p/new-page "held backspace merge") + (b/new-blocks ["Foo" "" "Foo" "" "Foo" ""]) + (util/repeat-keyboard 75 "Backspace") + (util/wait-timeout 500) + (let [editor-content (util/get-edit-content) + block-contents (util/get-page-blocks-contents)] + (is (not= "FooFoo" editor-content)) + (is (not-any? #{"FooFoo"} block-contents))))) + +(deftest rapid-retype-before-enter-keeps-the-edit-test + (testing "Backspace, retype, and Enter keep the retyped content" + (b/open-last-block) + (k/press "a") + (k/press "Backspace") + (k/press "a") + (k/press "Enter") + (util/wait-timeout 700) + (util/exit-edit) + (is (= ["a"] (remove string/blank? + (util/get-page-blocks-contents)))))) + +(defn- block-tree + [page-name] + (ls-api-call! :editor.getPageBlocksTree page-name)) + +(defn- content-tree + [nodes] + (mapv (fn [node] + [(get node "content") + (content-tree (get node "children"))]) + nodes)) + +(defn- wait-for-block-content! + [block-id expected] + (loop [attempts-left 40] + (let [actual (get (ls-api-call! :editor.getBlock block-id) "content")] + (if (or (= expected actual) (zero? attempts-left)) + (is (= expected actual)) + (do + (util/wait-timeout 100) + (recur (dec attempts-left))))))) + +(defn- visible-outline-content-tree + [] + (let [relations + (w/eval-js + "(() => { + const nodes = [...document.querySelectorAll('.ls-page-blocks .ls-block')] + .filter(node => !node.closest('.is-comments-area, .ls-comments-area, .ls-comment-row')); + return nodes + .map((node, index) => { + const own = selector => [...node.querySelectorAll(selector)] + .find(child => child.closest('.ls-block') === node); + const editor = own('.editor-wrapper textarea'); + const content = own('.block-content'); + const title = (editor && editor.offsetParent !== null + ? editor.value + : content?.innerText || node.dataset.blockTitle || '').trim(); + const parent = node.parentElement.closest('.ls-block'); + return [index, title, parent ? nodes.indexOf(parent) : null]; + }) + .filter(([_index, title]) => title); + })()") + children-by-parent (group-by #(nth % 2) relations) + build-tree (fn build-tree [parent] + (mapv (fn [[index title]] + [title (build-tree index)]) + (get children-by-parent parent)))] + (build-tree nil))) + +(defn- wait-for-content-tree! + [_page-name expected] + (util/wait-timeout 300) + (loop [attempts 40] + (let [actual (visible-outline-content-tree)] + (if (= expected actual) + (do + (util/wait-timeout 250) + (is (= expected (visible-outline-content-tree)))) + (if (zero? attempts) + (is (= expected actual)) + (do + (util/wait-timeout 100) + (recur (dec attempts)))))))) + +(defn- undo-and-wait-for-content-tree! + [page-name expected] + (b/undo) + (util/wait-timeout 1000) + (wait-for-content-tree! page-name expected)) + +(defn- redo-and-wait-for-content-tree! + [page-name expected] + (b/redo) + (util/wait-timeout 1000) + (wait-for-content-tree! page-name expected)) + +(defn- click-block-by-uuid! + [uuid] + (w/click (.first (w/-query (str "#block-content-" uuid ":visible"))))) + +(defn- click-block-by-title! + [title] + (let [target-selector + (w/eval-js + (format + "(() => { + const root = document.querySelector(\".ls-block[data-block-title='%s']\"); + const own = selector => [...root.querySelectorAll(selector)] + .find(node => node.closest('.ls-block') === root); + const editor = own('textarea'); + const target = editor && editor.offsetParent !== null + ? editor + : own('.block-content'); + return `#${CSS.escape(target.id)}`; + })()" + title))] + (w/click target-selector) + (w/eval-js + (format + "(() => new Promise((resolve, reject) => { + let attempts = 40; + const waitForTarget = () => { + const root = document.querySelector(\".ls-block[data-block-title='%s']\"); + const target = [...root.querySelectorAll('textarea')] + .find(node => node.closest('.ls-block') === root && node.offsetParent !== null); + if (target) { + target.focus(); + resolve(true); + } else if (attempts-- === 0) { + reject(new Error('Target block editor did not become active')); + } else { + setTimeout(waitForTarget, 50); + } + }; + waitForTarget(); + }))()" + title)))) + +(defn- move-editor-cursor-to-start! + [] + (w/eval-js + "(() => { + const editor = document.activeElement?.matches('.editor-wrapper textarea') + ? document.activeElement + : [...document.querySelectorAll('.editor-wrapper textarea')] + .find(node => node.offsetParent !== null); + editor.focus(); + editor.setSelectionRange(0, 0); + })();")) + +(defn- move-editor-cursor-to! + [position] + (w/eval-js + (format + "(() => { + const editor = document.activeElement?.matches('.editor-wrapper textarea') + ? document.activeElement + : [...document.querySelectorAll('.editor-wrapper textarea')] + .find(node => node.offsetParent !== null); + editor.focus(); + editor.setSelectionRange(%d, %d); + })();" + position + position))) + +(defn- move-editor-cursor-to-end! + [] + (w/eval-js + "(() => { + const editor = document.activeElement?.matches('.editor-wrapper textarea') + ? document.activeElement + : [...document.querySelectorAll('.editor-wrapper textarea')] + .find(node => node.offsetParent !== null); + editor.focus(); + editor.setSelectionRange(editor.value.length, editor.value.length); + })();")) + +(deftest boundary-delete-and-backspace-merge-contract-test + (testing "boundary edits preserve structure through per-step and full-history undo/redo" + (let [page-name "boundary edit history" + page-uuid (do + (p/new-page page-name) + (get (ls-api-call! :editor.getBlock page-name) "uuid")) + [_left right _child _source _source-child leaf] + (ls-api-call! :editor.insertBatchBlock + page-uuid + [{:content "b"} + {:content "c" + :children [{:content "d"}]} + {:content "e" + :children [{:content "f"}]} + {:content "g"}]) + initial [["b" []] + ["c" [["d" []]]] + ["e" [["f" []]]] + ["g" []]] + after-backspace [["bc" [["d" []]]] + ["e" [["f" []]]] + ["g" []]] + after-enter [["b" [["c" []] + ["d" []]]] + ["e" [["f" []]]] + ["g" []]] + after-delete [["b" [["c" []] + ["d" []]]] + ["e" [["fg" []]]]] + assert-tree! (partial wait-for-content-tree! page-name)] + (click-block-by-uuid! (get right "uuid")) + (util/wait-editor-visible) + (move-editor-cursor-to-start!) + (k/backspace) + (assert-tree! after-backspace) + (is (= "bc" (util/get-edit-content))) + (is (= 1 + (w/eval-js + "document.querySelector('.editor-wrapper textarea').selectionStart"))) + (undo-and-wait-for-content-tree! page-name initial) + (redo-and-wait-for-content-tree! page-name after-backspace) + + (click-block-by-title! "bc") + (util/wait-editor-visible) + (move-editor-cursor-to! 1) + (is (= ["bc" 1] + (w/eval-js + "(() => { + const editor = document.activeElement; + return [editor.value, editor.selectionStart]; + })()"))) + (k/enter) + (util/wait-timeout 1000) + (assert-tree! after-enter) + (undo-and-wait-for-content-tree! page-name after-backspace) + (redo-and-wait-for-content-tree! page-name after-enter) + + (click-block-by-title! "e") + (util/wait-editor-visible) + (move-editor-cursor-to-start!) + (k/backspace) + (assert-tree! after-enter) + + (click-block-by-title! "d") + (util/wait-editor-visible) + (move-editor-cursor-to-end!) + (k/delete) + (assert-tree! after-enter) + + (click-block-by-title! "f") + (util/wait-editor-visible) + (move-editor-cursor-to-end!) + (k/delete) + (assert-tree! after-delete) + (is (nil? (ls-api-call! :editor.getBlock (get leaf "uuid")))) + (undo-and-wait-for-content-tree! page-name after-enter) + (redo-and-wait-for-content-tree! page-name after-delete) + + (undo-and-wait-for-content-tree! page-name after-enter) + (undo-and-wait-for-content-tree! page-name after-backspace) + (undo-and-wait-for-content-tree! page-name initial)))) + +(defn- drag-block! + [source-title target-title placement] + (let [source-block (w/-query + (format ".ls-page-blocks .ls-block[data-block-title='%s']" + source-title)) + target-block (w/-query + (format ".ls-page-blocks .ls-block[data-block-title='%s']" + target-title)) + target-height (.-height (.boundingBox target-block)) + target-x (if (= placement "inside") 80 30) + target-y (case placement + "before" 2 + "after" (- target-height 2) + "inside" (- target-height 2)) + options (doto (Locator$DragToOptions.) + (.setTargetPosition target-x target-y) + (.setSteps 12))] + (.dragTo (.first (.locator source-block ".bullet-container")) + target-block + options) + (util/wait-timeout 250))) + +(deftest drag-reorders-once-and-is-undoable-test + (testing "same-level drag changes order once and undo restores the original order" + (let [page-name (p/get-page-name) + initial [["drag first" []] + ["drag second" []] + ["drag third" []]] + reordered [["drag third" []] + ["drag first" []] + ["drag second" []]]] + (b/new-blocks ["drag first" "drag second" "drag third"]) + (util/exit-edit) + (wait-for-content-tree! page-name initial) + (drag-block! "drag third" "drag first" "before") + (wait-for-content-tree! page-name reordered) + (undo-and-wait-for-content-tree! page-name initial)))) + +(deftest drag-indents-and-outdents-test + (testing "dragging into and out of a parent updates both sides of the tree" + (let [page-name (p/get-page-name) + initial [["drag parent" []] + ["drag child candidate" []] + ["drag tail" []]] + indented [["drag parent" [["drag child candidate" []]]] + ["drag tail" []]] + outdented [["drag parent" []] + ["drag tail" []] + ["drag child candidate" []]]] + (b/new-blocks ["drag parent" "drag child candidate" "drag tail"]) + (util/exit-edit) + (wait-for-content-tree! page-name initial) + (drag-block! "drag child candidate" "drag parent" "inside") + (wait-for-content-tree! page-name indented) + (drag-block! "drag child candidate" "drag tail" "after") + (wait-for-content-tree! page-name outdented)))) + +(deftest drag-rejects-parent-into-descendant-test + (testing "dragging a parent into its descendant leaves the tree unchanged" + (let [page-name (p/get-page-name)] + (b/new-blocks ["cycle parent" "cycle child"]) + (b/indent) + (util/exit-edit) + (let [before (content-tree (block-tree page-name))] + (drag-block! "cycle parent" "cycle child" "inside") + (is (= before (content-tree (block-tree page-name)))))))) + +(deftest undo-history-is-scoped-to-current-graph-test + (testing "Undo in a newly switched graph cannot consume the previous graph history" + (let [graph-a (str "undo-a-" (random-uuid)) + graph-b (str "undo-b-" (random-uuid))] + (graph/new-graph graph-a false) + (p/new-page "undo graph a page") + (b/new-block "graph a history") + (let [graph-a-block-id (current-editing-block-id)] + (wait-for-block-content! graph-a-block-id "graph a history") + (graph/new-graph graph-b false) + (p/new-page "undo graph b page") + (b/new-block "graph b history") + (let [graph-b-block-id (current-editing-block-id)] + (wait-for-block-content! graph-b-block-id "graph b history") + (b/undo) + (wait-for-block-content! graph-b-block-id "") + (graph/switch-graph graph-a false false) + (wait-for-block-content! graph-a-block-id "graph a history")))))) diff --git a/clj-e2e/test/logseq/e2e/plugins_basic_test.clj b/clj-e2e/test/logseq/e2e/plugins_basic_test.clj new file mode 100644 index 0000000..5996944 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/plugins_basic_test.clj @@ -0,0 +1,399 @@ +(ns logseq.e2e.plugins-basic-test + (:require + [clojure.set :as set] + [clojure.test :refer [deftest testing is use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w] + [wally.repl :as repl])) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page) + +(defn ->plugin-ident + [property-name] + (str ":plugin.property._test_plugin/" property-name)) + +(defonce ^:private *property-idx (atom 0)) +(defn- new-property + [] + (str "test-property-" (swap! *property-idx inc))) + +(defn- assert-api-ls-block! + ([ret] (assert-api-ls-block! ret 1)) + ([ret-or-uuid count] + (let [uuid' (or (get ret-or-uuid "uuid") ret-or-uuid)] + (is (string? uuid')) + (assert/assert-have-count (str "#ls-block-" uuid') count) + uuid'))) + +(deftest editor-apis-test + (testing "editor related apis" + (page/new-page "test-block-apis") + (ls-api-call! :ui.showMsg "hello world" "info") + (let [ret (ls-api-call! :editor.appendBlockInPage "test-block-apis" "append-block-in-page-0") + ret1 (ls-api-call! :editor.appendBlockInPage "append-block-in-current-page-0") + uuid' (assert-api-ls-block! ret)] + (assert-api-ls-block! ret1) + (-> (ls-api-call! :editor.insertBlock uuid' "insert-0") + (assert-api-ls-block!)) + (ls-api-call! :editor.updateBlock uuid' "append-but-updated-0") + (k/esc) + (w/wait-for ".block-title-wrap:text('append-but-updated-0')") + (ls-api-call! :editor.removeBlock uuid') + (assert-api-ls-block! uuid' 0)))) + +(deftest append-block-in-page-stays-at-page-root-test + (page/new-page "test-append-block-root") + (let [page (ls-api-call! :editor.getPage "test-append-block-root") + root (ls-api-call! :editor.appendBlockInPage "test-append-block-root" "root") + nested (ls-api-call! :editor.insertBlock (get root "uuid") "nested-1" {:sibling false}) + _ (ls-api-call! :editor.insertBlock (get nested "uuid") "nested-2" {:sibling true}) + appended (ls-api-call! :editor.appendBlockInPage "test-append-block-root" "appended") + appended-block (ls-api-call! :editor.getBlock (get appended "uuid"))] + (is (= (get page "id") + (get-in appended-block ["parent" "id"]))))) + +(deftest block-properties-test + (testing "block properties related apis" + (page/new-page "test-block-properties-apis") + (let [ret (ls-api-call! :editor.appendBlockInPage "test-block-properties-apis" "block-in-page-0" {:properties {:new-p1 1}}) + uuid' (assert-api-ls-block! ret) + prop1 (ls-api-call! :editor.getBlockProperty uuid' "new-p1") + props1 (ls-api-call! :editor.getBlockProperties uuid' "new-p1") + props2 (ls-api-call! :editor.getPageProperties "test-block-properties-apis")] + (w/wait-for ".property-k:text('new-p1')") + (is (= 1 (get prop1 "value"))) + (is (= (get prop1 "ident") ":plugin.property._test_plugin/new-p1")) + (is (= 1 (get props1 ":plugin.property._test_plugin/new-p1"))) + (is (= ["Page"] (get props2 ":block/tags"))) + (ls-api-call! :editor.upsertBlockProperty uuid' "p2" "p2") + (ls-api-call! :editor.upsertBlockProperty uuid' "p3" true) + (ls-api-call! :editor.upsertBlockProperty uuid' "p4" {:a 1, :b [2, 3]}) + (let [prop2 (ls-api-call! :editor.getBlockProperty uuid' "p2") + prop3 (ls-api-call! :editor.getBlockProperty uuid' "p3") + prop4 (ls-api-call! :editor.getBlockProperty uuid' "p4")] + (w/wait-for ".property-k:text('p2')") + (is (= "p2" (get prop2 "value"))) + (is (true? prop3)) + (is (= prop4 {"a" 1, "b" [2 3]}))) + (ls-api-call! :editor.removeBlockProperty uuid' "p4") + ;; wait for react re-render + (util/wait-timeout 16) + (is (nil? (w/find-one-by-text ".property-k" "p4"))) + (ls-api-call! :editor.upsertBlockProperty uuid' "p3" false) + (ls-api-call! :editor.upsertBlockProperty uuid' "p2" "p2-updated") + (w/wait-for ".block-title-wrap:text('p2-updated')") + (let [props (ls-api-call! :editor.getBlockProperties uuid')] + (is (= (get props ":plugin.property._test_plugin/p3") false)) + (is (= (get props ":plugin.property._test_plugin/p2") "p2-updated")))))) + +(deftest property-upsert-test + (testing "property with default settings" + (let [p (new-property)] + (ls-api-call! :editor.upsertProperty p) + (let [property (ls-api-call! :editor.getProperty p)] + (is (= "default" (get property "type"))) + (is (= ":db.cardinality/one" (get property "cardinality")))))) + (testing "property with specified cardinality && type" + (let [p (new-property)] + (ls-api-call! :editor.upsertProperty p {:type "number" + :cardinality "one"}) + (let [property (ls-api-call! :editor.getProperty p)] + (is (= "number" (get property "type"))) + (is (= ":db.cardinality/one" (get property "cardinality"))))) + (let [p (new-property)] + (ls-api-call! :editor.upsertProperty p {:type "number" + :cardinality "many"}) + (let [property (ls-api-call! :editor.getProperty p)] + (is (= "number" (get property "type"))) + (is (= ":db.cardinality/many" (get property "cardinality")))) + (ls-api-call! :editor.upsertProperty p {:type "default"}) + (let [property (ls-api-call! :editor.getProperty p)] + (is (= "default" (get property "type")))))) + ;; TODO: How to test against eval-js errors on playwright? + #_(testing ":checkbox property doesn't allow :many cardinality" + (let [p (new-property)] + (ls-api-call! :editor.upsertProperty p {:type "checkbox" + :cardinality "many"})))) + +(deftest property-related-test + (testing "properties management related apis" + (doseq [property-type ["default" "number" "date" "datetime" "checkbox" "url" "node" "json" "string"]] + (let [property-name (new-property) + _ (ls-api-call! :editor.upsertProperty property-name {:type property-type}) + property (ls-api-call! :editor.getProperty property-name)] + (is (= (get property "ident") (str ":plugin.property._test_plugin/" property-name))) + (is (= (get property "type") property-type)) + (ls-api-call! :editor.removeProperty property-name) + (is (nil? (ls-api-call! :editor.getProperty property-name))))))) + +(deftest insert-block-with-properties + (testing "insert block with properties" + (let [page "insert-block-properties-test" + _ (page/new-page page) + ;; :checkbox, :number, :url, :json can be inferred and default to :default, but not for :page + b1 (ls-api-call! :editor.insertBlock page "b1" {:properties {"x1" true + "x2" "https://logseq.com" + "x3" 1 + "x4" [1] + "x5" {:foo "bar"} + "x6" "Page x" + "x7" ["Page y" "Page z"] + "x8" "some content"} + :schema {"x6" {:type "page"} + "x7" {:type "page"}}})] + (is (true? (get b1 (->plugin-ident "x1")))) + (is (= "https://logseq.com" (-> (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x2"))) + (get "title")))) + (is (= 1 (-> (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x3"))) + (get ":logseq.property/value")))) + (is (= 1 (-> (ls-api-call! :editor.getBlock (first (get b1 (->plugin-ident "x4")))) + (get ":logseq.property/value")))) + (is (= "{\"foo\":\"bar\"}" (get b1 (->plugin-ident "x5")))) + (let [page-x (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x6")))] + (is (= "page x" (get page-x "name")))) + (is (= ["page y" "page z"] (map #(-> (ls-api-call! :editor.getBlock %) + (get "name")) (get b1 (->plugin-ident "x7"))))) + (let [x8-block-value (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x8")))] + (is (= "some content" (get x8-block-value "title"))) + (is (some? (get x8-block-value "page"))))))) + +(deftest update-block-with-properties + (testing "update block with properties" + (let [page "update-block-properties-test" + _ (page/new-page page) + block (ls-api-call! :editor.insertBlock page "b1") + _ (ls-api-call! :editor.updateBlock (get block "uuid") + "b1-new-content" + {:properties {"y1" true + "y2" "https://logseq.com" + "y3" 1 + "y4" [1] + "y5" {:foo "bar"} + "y6" "Page x" + "y7" ["Page y" "Page z"] + "y8" "some content"} + :schema {"y6" {:type "page"} + "y7" {:type "page"}}}) + b1 (ls-api-call! :editor.getBlock (get block "uuid"))] + (is (true? (get b1 (->plugin-ident "y1")))) + (is (= "https://logseq.com" (-> (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y2") "id"])) + (get "title")))) + (is (= 1 (-> (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y3") "id"])) + (get ":logseq.property/value")))) + (is (= 1 (-> (ls-api-call! :editor.getBlock (get (first (get b1 (->plugin-ident "y4"))) "id")) + (get ":logseq.property/value")))) + (is (= "{\"foo\":\"bar\"}" (get b1 (->plugin-ident "y5")))) + (let [page-x (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y6") "id"]))] + (is (= "page x" (get page-x "name")))) + (is (= ["page y" "page z"] (map #(-> (ls-api-call! :editor.getBlock %) + (get "name")) + (map #(get % "id") (get b1 (->plugin-ident "y7")))))) + (let [y8-block-value (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y8") "id"]))] + (is (= "some content" (get y8-block-value "title"))) + (is (some? (get y8-block-value "page"))))))) + +(deftest insert-batch-blocks-test + (testing "insert batch blocks" + (let [page "insert batch blocks" + _ (page/new-page page) + page-uuid (get (ls-api-call! :editor.getBlock page) "uuid") + result (ls-api-call! :editor.insertBatchBlock page-uuid + [{:content "b1" + :children [{:content "b1.1" + :children [{:content "b1.1.1"} + {:content "b1.1.2"}]} + {:content "b1.2"}]} + {:content "b2"}]) + contents (util/get-page-blocks-contents)] + (is (= contents ["b1" "b1.1" "b1.1.1" "b1.1.2" "b1.2" "b2"])) + (is (= (map #(get % "title") result) ["b1" "b1.1" "b1.1.1" "b1.1.2" "b1.2" "b2"])))) + (testing "insert batch blocks with properties" + (let [page "insert batch blocks with properties" + _ (page/new-page page) + page-uuid (get (ls-api-call! :editor.getBlock page) "uuid") + result (ls-api-call! :editor.insertBatchBlock page-uuid + [{:content "b1" + :children [{:content "b1.1" + :children [{:content "b1.1.1" + :properties {"z3" "Page 1" + "z4" ["Page 2" "Page 3"]}} + {:content "b1.1.2"}]} + {:content "b1.2"}] + :properties {"z1" "test" + "z2" true}} + {:content "b2"}] + {:schema {"z3" "page" + "z4" "page"}}) + _ (assert/assert-is-visible ".block-title-wrap:text('Page 3')") + contents (util/get-page-blocks-contents)] + (is (= contents + ["b1" "test" "b1.1" "b1.1.1" "Page 1" "Page 2" "Page 3" "b1.1.2" "b1.2" "b2"])) + (is (true? (get (first result) (->plugin-ident "z2"))))))) + +(deftest create-page-test + (testing "create page" + (let [result (ls-api-call! :editor.createPage "Test page 1")] + (is (= "Test page 1" (get result "title"))) + (is + (= + ":logseq.class/Page" + (-> (ls-api-call! :editor.getBlock (first (get result "tags"))) + (get "ident")))))) + + (testing "create page with properties" + (let [result (ls-api-call! :editor.createPage "Test page 2" + {:px1 "test" + :px2 1 + :px3 "Page 1" + :px4 ["Page 2" "Page 3"]} + {:schema {:px3 {:type "page"} + :px4 {:type "page"}}}) + page (ls-api-call! :editor.getBlock "Test page 2")] + (is (= "Test page 2" (get result "title"))) + (is + (= + ":logseq.class/Page" + (-> (ls-api-call! :editor.getBlock (first (get result "tags"))) + (get "ident")))) + ;; verify properties + (is (= "test" (-> (ls-api-call! :editor.getBlock (get-in page [(->plugin-ident "px1") "id"])) + (get "title")))) + (is (= 1 (-> (ls-api-call! :editor.getBlock (get-in page [(->plugin-ident "px2") "id"])) + (get ":logseq.property/value")))) + (let [page-1 (ls-api-call! :editor.getBlock (get-in page [(->plugin-ident "px3") "id"]))] + (is (= "page 1" (get page-1 "name")))) + (is (= ["page 2" "page 3"] (map #(-> (ls-api-call! :editor.getBlock %) + (get "name")) + (map #(get % "id") (get page (->plugin-ident "px4")))))))) + + (testing "create tag page" + (let [result (ls-api-call! :editor.createPage "Tag new" + {} + {:class true})] + (is + (= + ":logseq.class/Tag" + (-> (ls-api-call! :editor.getBlock (first (get result "tags"))) + (get "ident"))))))) + +(deftest get-all-tags-test + (testing "get_all_tags" + (let [result (ls-api-call! :editor.get_all_tags) + built-in-tags #{":logseq.class/Template" + ":logseq.class/Query" + ":logseq.class/Math-block" + ":logseq.class/Task" + ":logseq.class/Code-block" + ":logseq.class/Card" + ":logseq.class/Quote-block" + ":logseq.class/Cards"}] + (is (set/subset? built-in-tags (set (map #(get % "ident") result))))))) + +(deftest get-all-properties-test + (testing "get_all_properties" + (let [result (ls-api-call! :editor.get_all_properties)] + (is (>= (count result) 20))))) + +(deftest get-tag-objects-test + (testing "get_tag_objects" + (let [page "tag objects test" + _ (page/new-page page) + _ (ls-api-call! :editor.insertBlock page "task 1" + {:properties {"logseq.property/status" "Doing"}}) + result (ls-api-call! :editor.get_tag_objects "logseq.class/Task")] + (is (= (count result) 1)) + (is (= "task 1" (get (first result) "title")))))) + +(deftest create-and-get-tag-test + (testing "create and get tag with title or ident" + (let [title "book1" + title-ident (str :plugin.class._test_plugin/book1) + tag1 (ls-api-call! :editor.createTag title) + tag2 (ls-api-call! :editor.getTag title) + tag3 (ls-api-call! :editor.getTag title-ident) + tag4 (ls-api-call! :editor.getTag (get tag1 "uuid"))] + (is (= (get tag1 "ident") title-ident) "create tag with title from test as plugin") + (is (= (get tag2 "ident") title-ident) "get tag with title") + (is (= (get tag3 "title") title) "get tag with ident") + (is (= (get tag4 "title") title) "get tag with uuid"))) + + (testing "create tag with tagProperties" + (let [tag-props [{:name "prop1"} + {:name "prop2" :schema {:type "number"}} + {:name "prop3" :schema {:type "checkbox"}}] + tag (ls-api-call! :editor.createTag "tag-with-props" {:tagProperties tag-props}) + props (get tag ":logseq.property.class/properties")] + (is (= 3 (count props)) "tag has 3 properties"))) + + (testing "add and remove tag extends" + (let [tag1 (ls-api-call! :editor.createTag "tag1") + tag2 (ls-api-call! :editor.createTag "tag2") + tag3 (ls-api-call! :editor.createTag "tag3") + id1 (get tag1 "id") + id2 (get tag2 "id") + id3 (get tag3 "id") + _ (ls-api-call! :editor.addTagExtends id1 id2) + tag1 (ls-api-call! :editor.getTag id1)] + (is (= (get tag1 ":logseq.property.class/extends") [id2]) "tag1 extends tag2 with db id") + (let [_ (ls-api-call! :editor.addTagExtends id1 id3) + tag1 (ls-api-call! :editor.getTag id1)] + (is (= (get tag1 ":logseq.property.class/extends") [id2 id3]) "tag1 extends tag2,tag3 with db ids"))))) + +(deftest get-tags-by-name-test + (testing "get tags by exact name" + (let [tag-name "product" + tag1 (ls-api-call! :editor.createTag tag-name) + result (ls-api-call! :editor.getTagsByName tag-name)] + (is (= 1 (count result)) "should return exactly one tag") + (is (= (get tag1 "uuid") (get (first result) "uuid")) "should return the created tag") + (is (= (get tag1 "title") (get (first result) "title")) "tag title should match"))) + + (testing "get tags by name is case-insensitive" + (let [tag-name "TestTag123" + _ (ls-api-call! :editor.createTag tag-name) + result-lower (ls-api-call! :editor.getTagsByName "testtag123") + result-upper (ls-api-call! :editor.getTagsByName "TESTTAG123") + result-mixed (ls-api-call! :editor.getTagsByName "TeStTaG123")] + (is (= 1 (count result-lower)) "should find tag with lowercase search") + (is (= 1 (count result-upper)) "should find tag with uppercase search") + (is (= 1 (count result-mixed)) "should find tag with mixed case search") + (is (= (get (first result-lower) "uuid") (get (first result-upper) "uuid")) "all searches should return same tag") + (is (= (get (first result-lower) "uuid") (get (first result-mixed) "uuid")) "all searches should return same tag"))) + + (testing "get tags by name returns empty array for non-existent tag" + (let [result (ls-api-call! :editor.getTagsByName "NonExistentTag12345")] + (is (empty? result) "should return empty array for non-existent tag"))) + + (testing "get tags by name filters out non-tag pages" + (let [page-name "regular-page" + _ (page/new-page page-name) + result (ls-api-call! :editor.getTagsByName page-name)] + (is (empty? result) "should not return regular pages, only tags"))) + + (testing "get tags by name with multiple tags having similar names" + (let [_tag1 (ls-api-call! :editor.createTag "category") + _tag2 (ls-api-call! :editor.createTag "Category") + result (ls-api-call! :editor.getTagsByName "category")] + ;; Due to case-insensitive name normalization, both tags should be the same + (is (>= (count result) 1) "should return at least one tag") + ;; Verify the result contains valid tag structure + (is (string? (get (first result) "uuid")) "returned tag should have uuid") + (is (string? (get (first result) "title")) "returned tag should have title")))) + +(deftest set-property-node-tags + (testing "set property node tags" + (let [property-name (new-property) + _ (ls-api-call! :editor.upsertProperty property-name {:type "node"}) + tag1 (ls-api-call! :editor.createTag "Tag A") + tag2 (ls-api-call! :editor.createTag "Tag B") + tags [(get tag1 "id") (get tag2 "id")] + _ (ls-api-call! :editor.setPropertyNodeTags property-name tags) + property (ls-api-call! :editor.getProperty property-name) + node-tags (get property ":logseq.property/classes")] + (is (= (set node-tags) (set tags)) "property node tags should match the set tags")))) diff --git a/clj-e2e/test/logseq/e2e/plugins_marketplace_test.clj b/clj-e2e/test/logseq/e2e/plugins_marketplace_test.clj new file mode 100644 index 0000000..3c953a7 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/plugins_marketplace_test.clj @@ -0,0 +1,199 @@ +(ns logseq.e2e.plugins-marketplace-test + (:require [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.assert :as assert] + [logseq.e2e.keyboard :as k] + [logseq.e2e.util :as util] + [clojure.test :refer [deftest testing use-fixtures]] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page) + +(defn- open-plugins-dialog + "Opens the plugins dialog via the More menu" + [] + (util/double-esc) + (w/click ".toolbar-dots-btn") + (w/click ".ui__dropdown-menu-item:has-text('Plugins')") + (w/wait-for ".cp__plugins-page")) + +(defn- switch-to-marketplace + "Switches to the Marketplace tab in the plugins dialog" + [] + (w/click "button:has-text('Marketplace')") + ;; Wait for plugins to load from marketplace + (w/wait-for ".cp__plugins-marketplace-cnt" {:timeout 15000})) + +(defn- search-plugin + "Search for a plugin by name" + [search-term] + (w/fill ".cp__plugins-page input[placeholder*='Search']" search-term)) + +(defn- click-install-button + "Clicks the installation button for the first visible plugin card" + [] + (let [install-btn (.first (w/-query ".cp__plugins-item-card .ctl a.btn:has-text('Install')"))] + (w/click install-btn))) + +(defn- wait-for-plugin-installed + "Waits for the plugin to show as installed" + [] + (w/wait-for ".cp__plugins-item-card .ctl a.btn:has-text('Installed')" {:timeout 30000})) + +(defn- switch-to-installed + "Switches to the Installed tab in the plugins dialog" + [] + (w/click "button:has-text('Installed')") + ;; Wait for installed plugins view + (w/wait-for ".cp__plugins-installed")) + +(defn- close-plugins-dialog + "Closes the plugins dialog" + [] + (k/esc)) + +(defn- ensure-journals-calendar-installed! + [] + (open-plugins-dialog) + (switch-to-marketplace) + (search-plugin "Journals calendar") + (w/wait-for ".cp__plugins-item-card h3:has-text('Journals calendar')" {:timeout 10000}) + (when (w/visible? ".cp__plugins-item-card .ctl a.btn:has-text('Install')") + (click-install-button) + (wait-for-plugin-installed)) + (switch-to-installed) + (search-plugin "Journals calendar") + (w/wait-for ".cp__plugins-item-card h3:has-text('Journals calendar')")) + +(defn- journals-calendar-card + [] + (.first + (w/-query + ".cp__plugins-item-card:has(h3:has-text('Journals calendar'))"))) + +(defn- set-journals-calendar-enabled! + [enabled?] + (let [card (journals-calendar-card) + toggle (.locator card "button[role='switch']")] + (when (not= enabled? (= "true" (.getAttribute toggle "aria-checked"))) + (w/click toggle)) + (assert/assert-is-visible card))) + +(deftest marketplace-tabs-search-and-state-test + (testing "installed and marketplace tabs keep their own filters and visible results" + (open-plugins-dialog) + (assert/assert-is-visible "button:has-text('Installed')") + (assert/assert-is-visible "button:has-text('Marketplace')") + (switch-to-marketplace) + (assert/assert-is-visible "button:has-text('Plugins')") + (assert/assert-is-visible "button:has-text('Themes')") + (search-plugin "Journals calendar") + (w/wait-for + ".cp__plugins-item-card h3:has-text('Journals calendar')" + {:timeout 10000}) + (w/click "button:has-text('Themes')") + (assert/assert-is-hidden + ".cp__plugins-item-card h3:has-text('Journals calendar')") + (w/click "button:has-text('Plugins')") + (assert/assert-is-visible + ".cp__plugins-item-card h3:has-text('Journals calendar')") + (switch-to-installed) + (assert/assert-is-visible ".cp__plugins-installed") + (switch-to-marketplace) + (assert/assert-is-visible + ".cp__plugins-item-card h3:has-text('Journals calendar')") + (close-plugins-dialog) + (assert/assert-is-hidden ".cp__plugins-page"))) + +(deftest install-plugin-from-marketplace + (testing "Install a plugin from the marketplace" + ;; Open plugins dialog + (open-plugins-dialog) + + ;; Switch to marketplace tab + (switch-to-marketplace) + + ;; Search for a specific plugin (using Journals calendar as it's a well-known plugin) + (search-plugin "Journals calendar") + + ;; Wait for search results + (w/wait-for ".cp__plugins-item-card h3:has-text('Journals calendar')" {:timeout 10000}) + + ;; Click install on the plugin + (click-install-button) + + ;; Wait for installation to complete + (wait-for-plugin-installed) + + ;; Verify the plugin is now marked as installed in marketplace + (assert/assert-is-visible ".cp__plugins-item-card .ctl a.btn.disabled:has-text('Installed')") + + ;; Switch to installed tab and verify plugin appears there + (switch-to-installed) + (assert/assert-is-visible ".cp__plugins-item-card h3:has-text('Journals calendar')") + + ;; Close the dialog + (close-plugins-dialog) + + ;; Check that the plugin is active in the main UI (e.g., check for a UI element added by the plugin + (w/wait-for ".toolbar-plugins-manager-trigger") + (w/click ".toolbar-plugins-manager-trigger") + (assert/assert-is-visible "a.button[data-on-click=goToToday]") + (w/click ".ui__dropdown-menu-content a.button[data-on-click=goToToday]") + (assert/assert-is-visible ".is-today-page"))) + +(deftest plugin-command-registration-follows-lifecycle-test + (testing "a plugin command is registered once, removed on disable, and restored on enable" + (ensure-journals-calendar-installed!) + (set-journals-calendar-enabled! true) + (close-plugins-dialog) + (w/click ".toolbar-plugins-manager-trigger") + (assert/assert-have-count + ".ui__dropdown-menu-content a.button[data-on-click=goToToday]" + 1) + (k/esc) + + (open-plugins-dialog) + (switch-to-installed) + (search-plugin "Journals calendar") + (set-journals-calendar-enabled! false) + (close-plugins-dialog) + (w/click ".toolbar-plugins-manager-trigger") + (assert/assert-have-count + ".ui__dropdown-menu-content a.button[data-on-click=goToToday]" + 0) + (k/esc) + + (open-plugins-dialog) + (switch-to-installed) + (search-plugin "Journals calendar") + (set-journals-calendar-enabled! true) + (close-plugins-dialog) + (w/click ".toolbar-plugins-manager-trigger") + (assert/assert-have-count + ".ui__dropdown-menu-content a.button[data-on-click=goToToday]" + 1))) + +(deftest plugin-disable-enable-and-reload-test + (testing "plugin UI contributions are completely removed and restored without duplicates" + (ensure-journals-calendar-installed!) + (set-journals-calendar-enabled! false) + (close-plugins-dialog) + (assert/assert-have-count "a.button[data-on-click=goToToday]" 0) + + (open-plugins-dialog) + (switch-to-installed) + (search-plugin "Journals calendar") + (set-journals-calendar-enabled! true) + (close-plugins-dialog) + (w/click ".toolbar-plugins-manager-trigger") + (assert/assert-have-count + ".ui__dropdown-menu-content a.button[data-on-click=goToToday]" + 1) + (k/esc) + + (util/refresh-until-graph-loaded) + (w/click ".toolbar-plugins-manager-trigger") + (assert/assert-have-count + ".ui__dropdown-menu-content a.button[data-on-click=goToToday]" + 1))) diff --git a/clj-e2e/test/logseq/e2e/property_basic_test.clj b/clj-e2e/test/logseq/e2e/property_basic_test.clj new file mode 100644 index 0000000..7288b66 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/property_basic_test.clj @@ -0,0 +1,95 @@ +(ns logseq.e2e.property-basic-test + (:require [clojure.test :refer [deftest use-fixtures]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(def ^:private property-types ["Text" "Number" "Date" "DateTime" "Checkbox" "URL" "Node"]) + +(defn add-new-properties + [title-prefix] + (b/new-blocks (map #(str title-prefix "-" %) property-types)) + (doseq [property-type property-types] + (let [property-name (str "p-" title-prefix "-" property-type)] + (w/click (util/get-by-text (str title-prefix "-" property-type) true)) + (k/press "Control+e") + (util/input-command "Add property") + (w/click "input[placeholder]") + (util/input property-name) + (w/click (util/get-by-text "New option:" false)) + (assert/assert-is-visible (w/get-by-text "Select a property type")) + (w/click (loc/and "span" (util/get-by-text property-type true))) + (case property-type + "Text" (do + (w/click (format ".property-pair:has-text('%s') > .ls-block" property-name)) + (util/input "Text")) + "Number" (do (assert/assert-is-visible (format "input[placeholder='%s']" (str "Set " property-name))) + (util/input "111") + (w/click (w/get-by-text "New option:"))) + ("DateTime" "Date") (do + (assert/assert-is-visible ".ls-property-dialog") + (k/enter) + (k/esc)) + "Checkbox" nil + "URL" nil + "Node" (do + (w/click (w/get-by-text "Skip choosing tag")) + (util/input (str title-prefix "-Node-value")) + (w/click (w/get-by-text "New option:"))))))) + +(deftest new-property-test + (let [title-prefix "new-property-test"] + (add-new-properties title-prefix))) + +(deftest property-value-lifecycle-and-object-view-persistence-test + (let [property-name "property-value-lifecycle" + target-title "property value target" + owner-page (page/get-page-name)] + (b/new-block target-title) + (util/input-command "Add property") + (w/click "input[placeholder]") + (util/input property-name) + (w/click (w/get-by-text "New option:")) + (w/click (loc/and "span" (util/get-by-text "Text" true))) + (w/click (format ".property-pair:has-text('%s') > .ls-block" property-name)) + (util/input "Initial value") + (k/esc) + (assert/assert-is-visible + (format ".property-pair:has-text('%s'):has-text('Initial value')" property-name)) + + (page/goto-page property-name) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text target-title)) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text target-title)) + + (page/goto-page owner-page) + (w/click (format ".property-pair:has-text('%s') > .ls-block" property-name)) + (util/input "Updated value") + (k/esc) + (assert/assert-is-visible + (format ".property-pair:has-text('%s'):has-text('Updated value')" property-name)) + + (w/click (loc/filter ".property-k" :has-text property-name)) + (w/click (loc/filter "[role='menuitem']" :has-text "Delete property from node")) + (w/click "div[role='alertdialog'] button:text('Confirm')") + (assert/assert-have-count + (format ".property-pair:has-text('%s')" property-name) + 0) + + (page/goto-page property-name) + (assert/assert-have-count + (loc/filter ".ls-view-body" :has-text target-title) + 0))) diff --git a/clj-e2e/test/logseq/e2e/property_config_basic_test.clj b/clj-e2e/test/logseq/e2e/property_config_basic_test.clj new file mode 100644 index 0000000..35d9416 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/property_config_basic_test.clj @@ -0,0 +1,106 @@ +(ns logseq.e2e.property-config-basic-test + (:require [clojure.test :refer [deftest use-fixtures]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(defn- add-text-property + [property-name] + (b/new-block "property target") + (util/input-command "Add property") + (w/click "input[placeholder]") + (util/input property-name) + (w/click (w/get-by-text "New option:")) + (w/click (loc/and "span" (util/get-by-text "Text" true))) + (k/esc) + (assert/assert-is-visible (format ".property-k:text('%s')" property-name))) + +(defn- open-choices-pane + [property-name] + (w/click (loc/filter ".property-k" :has-text property-name)) + (w/click (loc/filter "div[role='menuitem']" :has-text "Available choices"))) + +(defn- add-choice + [choice] + (w/click (loc/filter "div[role='menuitem']" :has-text "Add choice")) + (w/fill "input[placeholder='title']" choice) + (w/click "button:has-text('Save')") + (assert/assert-is-visible (format ".choices-list li:has-text('%s')" choice))) + +(deftest property-choices-configuration-and-mod-p-stay-reactive-test + (let [property-name "reactive-priority" + choice-before "Choice before" + choice-after "Choice after" + removable-choice "Choice to delete"] + (add-text-property property-name) + (open-choices-pane property-name) + + (add-choice choice-before) + (w/click (format ".choices-list li:has-text('%s') strong" choice-before)) + (w/fill "input[placeholder='title']" choice-after) + (w/click "button:has-text('Save')") + (assert/assert-is-visible (format ".choices-list li:has-text('%s')" choice-after)) + (assert/assert-have-count (format ".choices-list li:has-text('%s')" choice-before) 0) + + (w/click + (format ".choices-list li:has-text('%s') button[title='More settings']" + choice-after)) + (w/click (loc/filter "div[role='menuitem']" :has-text "Set as default choice")) + + (open-choices-pane property-name) + (add-choice removable-choice) + (w/click + (format ".choices-list li:has-text('%s') button[title='More settings']" + removable-choice)) + (w/click "div[role='menuitem'].del") + (assert/assert-have-count + (format ".choices-list li:has-text('%s')" removable-choice) + 0) + + (util/double-esc) + (b/new-block "closed choice target") + (k/press (if util/mac? "ControlOrMeta+p" "Control+Alt+p")) + (w/fill ".ls-property-dialog .cp__select-input" property-name) + (w/click (loc/filter "a.menu-link" :has-text property-name)) + (assert/assert-is-visible + (format ".ls-property-dialog input[placeholder='Set %s']" property-name)) + (assert/assert-have-count ".ls-property-dialog :text('Empty')" 0) + (w/click + (loc/filter ".ls-property-dialog .cp__select-results" :has-text choice-after)) + (assert/assert-is-visible + (format ".ls-block :text('%s')" choice-after)))) + +(deftest mod-p-creates-and-sets-text-property-test + (let [property-name "mod-p-text" + property-value "created from mod p"] + (b/new-block "mod p target") + (k/press (if util/mac? "ControlOrMeta+p" "Control+Alt+p")) + (w/fill ".ls-property-dialog .cp__select-input" property-name) + (w/click (w/get-by-text "New option:")) + (w/click (loc/and "span" (util/get-by-text "Text" true))) + (assert/assert-is-visible + (format ".property-pair:has-text('%s') textarea" property-name)) + (util/input property-value) + (k/esc) + (assert/assert-is-visible (format ".property-k:text('%s')" property-name)) + (assert/assert-is-visible + (format ".property-pair:has-text('%s'):has-text('%s')" property-name property-value)))) + +(deftest property-table-hides-internal-id-column-test + (let [property-name "table-without-internal-id"] + (add-text-property property-name) + (util/double-esc) + (page/goto-page property-name) + (assert/assert-is-visible ".ls-view-body .ls-table-header-cell") + (assert/assert-have-count ".ls-view-body .ls-table-header-cell:text('#')" 0))) diff --git a/clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj b/clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj new file mode 100644 index 0000000..a91278d --- /dev/null +++ b/clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj @@ -0,0 +1,131 @@ +(ns logseq.e2e.property-scoped-choices-test + (:require [clojure.test :refer [deftest use-fixtures]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w] + [wally.repl :as repl])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(defn- add-property + [property-name] + (b/new-blocks ["setup"]) + (w/click (util/get-by-text "setup" true)) + (k/press "Control+e") + (util/input-command "Add property") + (w/click "input[placeholder]") + (util/input property-name) + (w/click (w/get-by-text "New option:")) + (w/click (loc/and "span" (util/get-by-text "Text" true))) + (k/esc) + (assert/assert-is-visible (format ".property-k:text('%s')" property-name))) + +(defn- add-tag-property + [property-name] + (w/click "button:has-text('Add tag property')") + (w/click "input[placeholder='Add or change property']") + (util/input property-name) + (w/click (loc/filter "a.menu-link" :has-text property-name)) + (assert/assert-is-visible (format ".property-k:text('%s')" property-name))) + +(defn- open-choices-pane + [property-name] + (w/click (loc/filter ".property-k" :has-text property-name)) + (w/click (loc/filter "div[role='menuitem']" :has-text "Available choices"))) + +(defn- add-choice + [property-name choice] + (open-choices-pane property-name) + (w/click (loc/filter "div[role='menuitem']" :has-text "Add choice")) + (w/fill "input[placeholder='title']" choice) + (w/click "button:has-text('Save')") + (k/esc)) + +(defn- hide-choice-for-tag + [property-name choice tag] + (open-choices-pane property-name) + (util/wait-timeout 100) + (w/click (format ".choices-list li:has-text('%s') button[title='More settings']" choice)) + (util/wait-timeout 100) + (w/click (loc/filter "div[role='menuitem']" :has-text (str "Hide for #" tag))) + (k/esc)) + +(defn- open-property-value-select + [property-name] + (w/click (format ".bottom-property-pill:has(.property-k:has-text('%s')) .property-value-container .jtrigger" + property-name)) + (assert/assert-is-visible (format "input[placeholder='Set %s']" property-name)) + (w/click (format "input[placeholder='Set %s']" property-name)) + (assert/assert-is-visible ".cp__select-results")) + +(deftest tag-scoped-property-choices-test + (let [setup-page (page/get-page-name) + tag "Device" + property-name "device-type" + scoped-choice "wired" + global-choice "wireless"] + (add-property property-name) + (page/new-page tag) + (page/convert-to-tag tag) + (add-tag-property property-name) + (add-choice property-name scoped-choice) + (util/wait-timeout 100) + (k/esc) + (page/goto-page setup-page) + (add-choice property-name global-choice) + (util/wait-timeout 100) + (k/esc) + (page/goto-page tag) + ;; open tag properties + (w/click (.first (w/-query "a.block-control"))) + (hide-choice-for-tag property-name global-choice tag) + (util/wait-timeout 100) + (k/esc) + (page/new-page "scoped-choices-test") + (b/new-block "Device item") + (util/set-tag tag) + (open-property-value-select property-name) + (assert/assert-is-visible (loc/filter ".cp__select-results" :has-text scoped-choice)) + (assert/assert-have-count (loc/filter ".cp__select-results" :has-text global-choice) 0))) + +(deftest tag-scoped-property-choices-isolated-test + (let [tag-a "Device2" + tag-b "Vehicle" + property-name "device2-type" + choice-a "wired2" + choice-b "gas"] + (add-property property-name) + (page/new-page tag-a) + (page/convert-to-tag tag-a) + (add-tag-property property-name) + (add-choice property-name choice-a) + (util/wait-timeout 100) + (k/esc) + (page/new-page tag-b) + (page/convert-to-tag tag-b) + (add-tag-property property-name) + (add-choice property-name choice-b) + (util/wait-timeout 100) + (k/esc) + (page/new-page "scoped-choices-device2") + (b/new-block "Device2 item") + (util/set-tag tag-a) + (open-property-value-select property-name) + (assert/assert-is-visible (loc/filter ".cp__select-results" :has-text choice-a)) + (assert/assert-have-count (loc/filter ".cp__select-results" :has-text choice-b) 0) + (k/esc) + (page/new-page "scoped-choices-vehicle") + (b/new-block "Vehicle item") + (util/set-tag tag-b) + (open-property-value-select property-name) + (assert/assert-is-visible (loc/filter ".cp__select-results" :has-text choice-b)) + (assert/assert-have-count (loc/filter ".cp__select-results" :has-text choice-a) 0))) diff --git a/clj-e2e/test/logseq/e2e/reference_basic_test.clj b/clj-e2e/test/logseq/e2e/reference_basic_test.clj new file mode 100644 index 0000000..cbb8659 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/reference_basic_test.clj @@ -0,0 +1,88 @@ +(ns logseq.e2e.reference-basic-test + (:require + [clojure.test :refer [deftest testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +;; block references +(deftest self-reference + (testing "self reference" + (b/new-block "b2") + (b/copy) + (b/paste) + (util/exit-edit) + (assert/assert-selected-block-text "b2"))) + +(deftest self-tag-block-reference + (testing "self reference" + (b/new-block "b2") + (util/set-tag "task") + (b/copy) + (b/paste) + (util/exit-edit) + (assert/assert-selected-block-text "b2"))) + +(deftest mutual-reference + (testing "mutual reference" + (b/new-blocks ["b1" "b2"]) + (util/set-tag "task") + (b/copy) + (k/arrow-up) + (b/wait-editor-text "b1") + (b/paste) + (b/copy) + (k/arrow-down) + (b/wait-editor-text "b2") + (b/paste) + (util/exit-edit) + (b/assert-blocks-visible ["b1[[b2]]" "b2[[b1]]"]))) + +(deftest parent-reference + (testing "parent reference" + (b/new-blocks ["b1" "b2"]) + (util/set-tag "task") + (b/indent) + (b/copy) + (k/arrow-up) + (b/wait-editor-text "b1") + (b/paste) + (b/copy) + (k/arrow-down) + (b/wait-editor-text "b2") + (b/paste) + (util/exit-edit) + (b/assert-blocks-visible ["b1[[b2]]" "b2[[b1]]"]))) + +(deftest cycle-reference + (testing "cycle reference" + (b/new-blocks ["b1" "b2" "b3"]) + (util/set-tag "task") + (b/jump-to-block "b1") + (assert/assert-editor-mode) + (b/copy) + (k/arrow-down) + (b/wait-editor-text "b2") + (b/paste) + (b/copy) + (k/arrow-down) + (b/wait-editor-text "b3") + (b/paste) + (b/copy) + (b/jump-to-block "b1") + (assert/assert-editor-mode) + (b/paste) + (util/exit-edit) + (b/assert-blocks-visible ["b1[[b3[[b2]]]]" "b2[[b1[[b3]]]]" "b3[[b2[[b1]]]]"]))) + +;; TODO: page references diff --git a/clj-e2e/test/logseq/e2e/right_sidebar_basic_test.clj b/clj-e2e/test/logseq/e2e/right_sidebar_basic_test.clj new file mode 100644 index 0000000..7454e83 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/right_sidebar_basic_test.clj @@ -0,0 +1,99 @@ +(ns logseq.e2e.right-sidebar-basic-test + (:require + [clojure.string :as string] + [clojure.test :refer [deftest is testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as p] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) +(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph) + +(defn- use-dark-neutral-theme! + [] + (w/eval-js + "window.logseq.api.set_theme_mode('dark'); + window.logseq.api.set_state_from_store(['ui/system-theme?'], false); + window.logseq.api.set_state_from_store(['ui/radix-color'], 'none');") + (util/wait-timeout 100)) + +(defn- right-sidebar-backgrounds + [] + (-> (w/eval-js + "(() => { + const topbar = document.querySelector('.cp__right-sidebar-topbar'); + const inner = document.querySelector('.cp__right-sidebar-inner'); + return [ + getComputedStyle(topbar).backgroundColor, + getComputedStyle(inner).backgroundColor, + document.documentElement.dataset.theme, + document.documentElement.dataset.color + ].join('|'); + })()") + (string/split #"\|"))) + +(deftest right-sidebar-topbar-uses-dark-neutral-background + (testing "right sidebar header stays dark with neutral accent color" + (use-dark-neutral-theme!) + (w/click ".toggle-right-sidebar") + (assert/assert-is-visible ".cp__right-sidebar.open .cp__right-sidebar-topbar") + (assert/assert-is-visible ".cp__right-sidebar .sidebar-item") + (let [[topbar-bg inner-bg theme color] (right-sidebar-backgrounds)] + (is (= "dark" theme)) + (is (= "none" color)) + (is (= inner-bg topbar-bg))))) + +(deftest right-sidebar-uses-only-content-scrollbar + (testing "the outer sidebar does not reserve a second scrollbar" + (w/click ".toggle-right-sidebar") + (assert/assert-is-visible ".cp__right-sidebar.open .sidebar-item-list") + (let [outer-overflow + (w/eval-js + "getComputedStyle(document.querySelector('.cp__right-sidebar-scrollable')).overflowY") + content-overflow + (w/eval-js + "getComputedStyle(document.querySelector('.sidebar-item-list')).overflowY")] + (is (= "visible" outer-overflow)) + (is (= "auto" content-overflow))))) + +(deftest same-block-updates-in-main-and-right-sidebar + (testing "one mounted UUID rerenders content and properties in both containers" + (let [page-name (p/get-page-name) + initial-title "sidebar live block before" + updated-title "sidebar live block after" + property-name "sidebar-live-property" + initial-property-value "sidebar property before" + updated-property-value "sidebar property after" + block (ls-api-call! :editor.appendBlockInPage + initial-title + {:properties {property-name initial-property-value}}) + block-uuid (get block "uuid") + main-block (format ".ls-page-blocks #ls-block-%s" block-uuid) + sidebar-block (format ".cp__right-sidebar #ls-block-%s" block-uuid)] + (w/wait-for (format "%s .block-title-wrap:text('%s')" main-block initial-title)) + (ls-api-call! :editor.openInRightSidebar block-uuid) + (w/wait-for sidebar-block) + (assert/assert-is-visible + (loc/filter ".cp__right-sidebar .sidebar-item-header .breadcrumb" + :has-text page-name)) + (assert/assert-have-count (format "#ls-block-%s" block-uuid) 2) + + (ls-api-call! :editor.updateBlock block-uuid updated-title) + (ls-api-call! :editor.upsertBlockProperty + block-uuid property-name updated-property-value) + + (doseq [block-selector [main-block sidebar-block]] + (w/wait-for + (format "%s .block-title-wrap:text('%s')" block-selector updated-title)) + (w/wait-for + (format "%s .property-k:text('%s')" block-selector property-name)) + (w/wait-for + (format "%s .property-value :text('%s')" + block-selector updated-property-value))) + (w/wait-for-not-visible + (format "#ls-block-%s .block-title-wrap:text('%s')" + block-uuid initial-title))))) diff --git a/clj-e2e/test/logseq/e2e/rtc_basic_test.clj b/clj-e2e/test/logseq/e2e/rtc_basic_test.clj new file mode 100644 index 0000000..79c413a --- /dev/null +++ b/clj-e2e/test/logseq/e2e/rtc_basic_test.clj @@ -0,0 +1,99 @@ +(ns logseq.e2e.rtc-basic-test + (:require + [clojure.test :refer [deftest testing is use-fixtures]] + [com.climate.claypoole :as cp] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.const :refer [*page1 *page2]] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.graph :as graph] + [logseq.e2e.page :as page] + [logseq.e2e.rtc :as rtc] + [logseq.e2e.util :as util] + [wally.main :as w] + [wally.repl :as repl])) + +(use-fixtures :once fixtures/open-2-pages) + +(use-fixtures :each fixtures/validate-graph) + +(deftest rtc-basic-test + (let [graph-name (str "rtc-graph-" (.toEpochMilli (java.time.Instant/now))) + page-names (map #(str "rtc-test-page" %) (range 4))] + (testing "open 2 app instances, add a rtc graph, check this graph available on other instance" + (cp/prun! + 2 + #(w/with-page % + (util/login-test-account)) + [@*page1 @*page2]) + (testing "remote graph refresh waits until the button is enabled" + (w/with-page @*page2 + (graph/goto-all-graphs) + (w/wait-for "button:not([disabled]):has-text(\"Refresh\")" {:timeout 30000}) + (.setDefaultTimeout (w/get-page) 50) + (try + (w/eval-js + "() => { + const span = Array.from(document.querySelectorAll('span')) + .find((node) => node.textContent.trim() === 'Refresh'); + const button = span.closest('button'); + button.disabled = true; + setTimeout(() => { button.disabled = false; }, 500); + }") + (graph/refresh-all-remote-graphs) + (finally + (.setDefaultTimeout (w/get-page) 10000))))) + (w/with-page @*page1 + (graph/new-graph graph-name true false)) + (w/with-page @*page2 + (graph/wait-for-remote-graph graph-name) + (graph/switch-graph graph-name true true))) + (testing "logseq pages add/delete" + (doseq [page-name page-names] + (let [{:keys [_local-tx remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (page/new-page page-name)))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx) + (util/search-and-click page-name)))) + (let [*last-remote-tx (atom nil)] + (doseq [page-name page-names] + (let [{:keys [_local-tx remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (page/delete-page page-name)))] + (reset! *last-remote-tx remote-tx))) + (w/with-page @*page2 + (rtc/wait-tx-update-to @*last-remote-tx) + (doseq [page-name page-names] + (let [deleted-page (ls-api-call! :editor.getPage page-name)] + (is (number? (get deleted-page ":logseq.property/deleted-at"))) + (is (= "Recycle" (get-in deleted-page ["parent" "title"])))))))) + (testing "Page reference created" + (let [page-name "test-page-reference" + {:keys [_local-tx remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (page/new-page page-name)))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx))) + (let [test-page (str "random page " (random-uuid)) + block-title (format "test ref [[%s]]" test-page) + {:keys [_local-tx remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/new-block block-title) + (b/new-block "add new-block to ensure last block saved")))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx) + (util/search-and-click test-page) + (w/wait-for ".references .ls-block") + ;; ensure ref exists + (let [refs (w/all-text-contents ".references .ls-block .block-title-wrap")] + (is (= refs [block-title])))))) + + (testing "cleanup" + (w/with-page @*page2 + (graph/remove-remote-graph graph-name))))) diff --git a/clj-e2e/test/logseq/e2e/rtc_extra_part2_test.clj b/clj-e2e/test/logseq/e2e/rtc_extra_part2_test.clj new file mode 100644 index 0000000..a98e1fe --- /dev/null +++ b/clj-e2e/test/logseq/e2e/rtc_extra_part2_test.clj @@ -0,0 +1,461 @@ +(ns logseq.e2e.rtc-extra-part2-test + (:require [clojure.java.io :as io] + [clojure.string :as string] + [clojure.test :refer [deftest testing is use-fixtures]] + [jsonista.core :as json] + [logseq.e2e.block :as b] + [logseq.e2e.const :refer [*page1 *page2 *graph-name*]] + [logseq.e2e.custom-report :as custom-report] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.graph :as graph] + [logseq.e2e.keyboard :as k] + [logseq.e2e.page :as page] + [logseq.e2e.rtc :as rtc] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once + fixtures/open-2-pages + (partial fixtures/prepare-rtc-graph-fixture "rtc-extra-part2-test-graph")) + +(use-fixtures :each + fixtures/new-logseq-page-in-rtc) + +(def ^:private stress-default-rounds 1) +(def ^:private stress-default-ops-per-client 50) +(def ^:private stress-default-seed-blocks 20) +(def ^:private stress-default-seed 20260330) +(def ^:private stress-max-seed-depth 4) +(def ^:private severe-sync-log-patterns + ["db-sync/checksum-mismatch" + "db-sync/tx-rejected" + "db-sync/apply-remote-txs-failed"]) +(def ^:private random-edit-actions + [:new :save :indent-outdent :delete-existing :undo :redo]) + +(defn- env-int + [k default] + (let [raw (System/getenv k)] + (if-not (string/blank? raw) + (try + (Integer/parseInt raw) + (catch Throwable _ + default)) + default))) + +(defn- recent-console-logs + [] + (->> (some-> custom-report/*pw-page->console-logs* deref vals) + (mapcat identity) + vec)) + +(defn- assert-no-severe-sync-errors! + [] + (let [logs (recent-console-logs) + matched (->> logs + (filter (fn [line] + (some #(string/includes? line %) severe-sync-log-patterns))) + vec)] + (is (empty? matched) + (str "found severe sync errors in console logs: " + (pr-str (take-last 20 matched)))))) + +(defn- page-sync-state + [pw-page] + (w/with-page pw-page + (util/exit-edit) + {:rtc-tx (rtc/get-rtc-tx) + :blocks (util/get-page-blocks-contents)})) + +(defn- assert-two-pages-synced! + [] + (let [s1 (page-sync-state @*page1) + s2 (page-sync-state @*page2) + tx1 (:rtc-tx s1) + tx2 (:rtc-tx s2)] + (is (= (:blocks s1) (:blocks s2)) + (str "page blocks diverged: " + (pr-str {:page1-count (count (:blocks s1)) + :page2-count (count (:blocks s2)) + :page1-tail (take-last 8 (:blocks s1)) + :page2-tail (take-last 8 (:blocks s2))}))) + (is (= (:local-tx tx1) (:remote-tx tx1)) + (str "page1 rtc-tx not converged: " (pr-str tx1))) + (is (= (:local-tx tx2) (:remote-tx tx2)) + (str "page2 rtc-tx not converged: " (pr-str tx2))))) + +(defn- try-indent! + [] + (if-let [editor (util/get-editor)] + (let [[x1 _] (util/bounding-xy editor)] + (k/tab) + (if-let [editor' (util/get-editor)] + (let [[x2 _] (util/bounding-xy editor')] + (> x2 x1)) + false)) + false)) + +(defn- try-outdent! + [] + (if-let [editor (util/get-editor)] + (let [[x1 _] (util/bounding-xy editor)] + (k/shift+tab) + (if-let [editor' (util/get-editor)] + (let [[x2 _] (util/bounding-xy editor')] + (> x1 x2)) + false)) + false)) + +(defn- align-depth! + [depth target] + (loop [d depth] + (cond + (< d target) (if (try-indent!) + (recur (inc d)) + d) + (> d target) (if (try-outdent!) + (recur (dec d)) + d) + :else d))) + +(defn- new-block-safe! + [title] + (loop [attempt 4] + (let [created? + (try + (b/new-block "") + (b/save-block title) + true + (catch Throwable _ + false))] + (if created? + true + (if (zero? attempt) + (throw (ex-info "new-block-safe failed" {:title title})) + (do + (util/exit-edit) + (util/wait-timeout 80) + (try + (b/open-last-block) + (catch Throwable _ + nil)) + (util/wait-timeout 80) + (recur (dec attempt)))))))) + +(defn- sync-by-trigger! + ([tag] + (sync-by-trigger! tag nil)) + ([tag checkpoints] + (let [target-tx (some->> checkpoints + vals + (filter integer?) + seq + (apply max))] + ;; Ensure both pages have observed all prior edit/undo-redo txs first. + (when target-tx + (w/with-page @*page1 + (rtc/wait-tx-update-to target-tx)) + (w/with-page @*page2 + (rtc/wait-tx-update-to target-tx))) + (let [{:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (new-block-safe! (str "sync-trigger-" tag))))] + (w/with-page @*page1 + (rtc/wait-tx-update-to remote-tx)) + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx)))))) + +(defn- seed-long-nested-page! + [seed] + (let [seed-blocks (max 20 (env-int "DB_SYNC_E2E_STRESS_SEED_BLOCKS" stress-default-seed-blocks)) + rng (java.util.Random. (long (+ seed 97))) + titles (w/with-page @*page1 + (util/exit-edit) + (loop [i 0 + depth 0 + titles #{}] + (if (< i seed-blocks) + (let [title (format "seed-r%s-%03d" seed i) + target-depth (.nextInt rng (inc stress-max-seed-depth))] + (new-block-safe! title) + (recur (inc i) + (align-depth! depth target-depth) + (conj titles title))) + (do + (util/exit-edit) + titles))))] + (sync-by-trigger! (str "seed-" seed)) + titles)) + +(defn- next-action + [rng] + (nth random-edit-actions + (.nextInt rng (count random-edit-actions)))) + +(defn- delete-existing-random-block! + [rng known-titles] + (loop [attempt 8] + (if (zero? attempt) + 0 + (let [titles (vec @known-titles)] + (if (empty? titles) + 0 + (let [title (nth titles (.nextInt rng (count titles))) + deleted? + (try + (b/jump-to-block title) + (b/delete-blocks) + true + (catch Throwable _ + false))] + (if deleted? + (do + (swap! known-titles disj title) + 1) + (recur (dec attempt))))))))) + +(defn- random-edit-op! + [rng known-titles client-prefix round op-idx] + (let [base (format "%s-r%s-op%s" client-prefix round op-idx)] + (case (next-action rng) + :new + (let [title (str base "-new")] + (new-block-safe! title) + (swap! known-titles conj title) + 1) + + :save + (let [save-title (str base "-save-updated")] + (new-block-safe! (str base "-save")) + (b/save-block save-title) + (swap! known-titles conj save-title) + 2) + + :indent-outdent + (let [title (str base "-nest")] + (new-block-safe! title) + (swap! known-titles conj title) + (+ 1 + (if (try-indent!) 1 0) + (if (try-outdent!) 1 0))) + + :delete-existing + (delete-existing-random-block! rng known-titles) + + :undo + (do + (b/undo) + 0) + + :redo + (do + (b/redo) + 0)))) + +(defn- local-random-edit-batch! + [rng known-titles client-prefix round] + (let [ops (max 1 (env-int "DB_SYNC_E2E_STRESS_OPS_PER_CLIENT" stress-default-ops-per-client))] + (loop [i 0 + undo-steps 0] + (if (< i ops) + (recur (inc i) + (+ undo-steps + (random-edit-op! rng known-titles client-prefix round i))) + (do + (util/exit-edit) + undo-steps))))) + +(defn- local-undo-redo-batch! + [undo-steps] + (let [steps (max 1 undo-steps)] + ;; Undo and redo exactly what this client edited in the current round. + (b/open-last-block) + (dotimes [_ steps] + (b/undo)) + (dotimes [_ steps] + (b/redo)) + (util/exit-edit))) + +(defn- run-two-clients-without-sync! + [p1-fn p2-fn] + [(p1-fn) + (p2-fn)]) + +(deftest online-two-clients-undo-redo-stress-test + (testing "two online RTC clients survive random edits + undo/redo loops on a long nested page" + (let [rounds (max 1 (env-int "DB_SYNC_E2E_STRESS_ROUNDS" stress-default-rounds)) + seed (long (env-int "DB_SYNC_E2E_STRESS_SEED" stress-default-seed)) + p1-rng (java.util.Random. (long (+ seed 101))) + p2-rng (java.util.Random. (long (+ seed 202))) + known-titles (atom (seed-long-nested-page! seed))] + (dotimes [round rounds] + (let [p1-undo-steps (atom 0) + p2-undo-steps (atom 0) + ;; Phase 1: edit both online clients without forcing sync between them. + [_ _] + (run-two-clients-without-sync! + #(w/with-page @*page1 + (reset! p1-undo-steps + (local-random-edit-batch! p1-rng known-titles "p1" round))) + #(w/with-page @*page2 + (reset! p2-undo-steps + (local-random-edit-batch! p2-rng known-titles "p2" round)))) + p1-edit-remote-tx (w/with-page @*page1 + (-> (rtc/get-rtc-tx) :local-tx)) + p2-edit-remote-tx (w/with-page @*page2 + (-> (rtc/get-rtc-tx) :local-tx)) + ;; Phase 2: undo+redo both online clients without forcing sync between them. + [_ _] + (run-two-clients-without-sync! + #(w/with-page @*page1 + (local-undo-redo-batch! @p1-undo-steps)) + #(w/with-page @*page2 + (local-undo-redo-batch! @p2-undo-steps))) + p1-undo-remote-tx (w/with-page @*page1 + (-> (rtc/get-rtc-tx) :local-tx)) + p2-undo-remote-tx (w/with-page @*page2 + (-> (rtc/get-rtc-tx) :local-tx))] + + (sync-by-trigger! + round + {:p1-edit p1-edit-remote-tx + :p2-edit p2-edit-remote-tx + :p1-undo p1-undo-remote-tx + :p2-undo p2-undo-remote-tx}) + (assert-two-pages-synced!) + (assert-no-severe-sync-errors!)))))) + +;;; https://github.com/logseq/db-test/issues/651 +(deftest issue-651-block-title-double-transit-encoded-test + (testing " +1. create pages named \"bbb\", \"aaa\", and turn these pages into tag +2. set \"bbb\" parent to \"aaa\" +3. create a new page \"ccc\", and create a simple query with filter tags = aaa/bbb +wait for 5-10 seconds, will found that \"aaa/bbb\" became \"aaa/\" +" + (w/with-page @*page1 + (page/new-page "aaa") + (page/convert-to-tag "aaa") + (page/new-page "bbb") + (page/convert-to-tag "bbb" :extends ["aaa"]) + (page/new-page "ccc") + (b/new-block "") + (util/input-command "query") + (w/click (util/-query-last "button:text('filter')")) + (util/input "tags") + (w/click "a.menu-link:has-text('tags')") + (w/click "a.menu-link:has-text('bbb')") + (util/wait-timeout 5000) ;as described in issue-url + ) + (let [{:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/new-block "done")))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx))) + +;; check the selected tag still exists after the query waits and syncs + (w/with-page @*page1 + (page/goto-page "bbb")) + (w/with-page @*page2 + (page/goto-page "bbb")) + + (rtc/validate-graphs-in-2-pw-pages))) + +(deftest paste-multiple-blocks-test + (testing " +1. create 3 blocks + - block1 + - block2 + - block3 +2. copy these 3 blocks +3. when cursor at block3, press to create a new block +4. paste them at current position 5 times +5. validate blocks are same on both clients" + (w/with-page @*page1 + (b/new-blocks ["block1" "block2" "block3"]) + (util/exit-edit) + (b/select-blocks 2) + (b/copy) + (b/jump-to-block "block3") + (util/repeat-keyboard 1 "Enter")) + + (dotimes [_ 5] + (let [{:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/paste)))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx)))) + + (let [{:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/new-block "sync-trigger")))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx))) + + (let [expected (vec (concat ["block1" "block2" "block3"] + (take (* 3 5) (cycle ["block1" "block2" "block3"])) + ["sync-trigger"]))] + (w/with-page @*page1 + (util/exit-edit) + (is (= expected + (util/get-page-blocks-contents)))) + + (w/with-page @*page2 + (util/exit-edit) + (is (= expected + (util/get-page-blocks-contents))))) + + (rtc/validate-graphs-in-2-pw-pages))) + +(deftest asset-blocks-validate-after-init-downloaded-test + (testing " +- add some assets in client1 +- remove local graph in client2 +- re-download the remote graph in client2 +- compare asset-blocks data in both clients" + (let [asset-file "../assets/icon.png" + page-title (w/with-page @*page1 (page/get-page-name))] + (w/with-page @*page1 + (let [p (w/get-page)] + (.onFileChooser p (reify java.util.function.Consumer + (accept [_ fc] + (.setFiles fc (into-array java.nio.file.Path [(java.nio.file.Paths/get asset-file (into-array String []))]))))) + (b/new-block "asset block") + (util/input-command "Upload an asset") + (w/wait-for ".ls-block img"))) + + (let [{:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/new-block "sync done")))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx))) + + (w/with-page @*page2 + (graph/remove-local-graph *graph-name*) + (graph/wait-for-remote-graph *graph-name*) + (graph/switch-graph *graph-name* true false) + (page/goto-page page-title) + (w/wait-for ".ls-block img") + (is (some? (.getAttribute (w/-query ".ls-block img") "src")))) + + (rtc/validate-graphs-in-2-pw-pages)))) + +(deftest issue-683-paste-large-block-test + (testing "Copying and pasting a large block of text into sync-ed graph causes sync to fail" + (let [large-text (slurp (io/resource "large_text.txt"))] + (w/with-page @*page1 + (w/eval-js (str "navigator.clipboard.writeText(" (json/write-value-as-string large-text) ")"))) + (let [{:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/new-block "") + (b/paste)))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx))) + + (rtc/validate-graphs-in-2-pw-pages)))) diff --git a/clj-e2e/test/logseq/e2e/rtc_extra_test.clj b/clj-e2e/test/logseq/e2e/rtc_extra_test.clj new file mode 100644 index 0000000..2fc8c6b --- /dev/null +++ b/clj-e2e/test/logseq/e2e/rtc_extra_test.clj @@ -0,0 +1,333 @@ +(ns logseq.e2e.rtc-extra-test + (:require + [clojure.test :refer [deftest testing use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.const :refer [*page1 *page2]] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.outliner-basic-test :as outliner-basic-test] + [logseq.e2e.page :as page] + [logseq.e2e.property-basic-test :as property-basic-test] + [logseq.e2e.rtc :as rtc :refer [validate-graphs-in-2-pw-pages]] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once + fixtures/open-2-pages + (partial fixtures/prepare-rtc-graph-fixture "rtc-extra-test-graph")) + +(use-fixtures :each + fixtures/new-logseq-page-in-rtc) + +(defn- with-stop-restart-rtc + [pw-page f] + (w/with-page pw-page + (rtc/rtc-stop)) + (f) + (w/with-page pw-page + (rtc/rtc-start))) + +(def status->icon-name + {"Backlog" "Backlog" + "Todo" "Todo" + "Doing" "InProgress50" + "In review" "InReview" + "Done" "Done" + "Canceled" "Cancelled"}) + +(def priorities ["No priority" "Low" "Medium" "High" "Urgent"]) + +(defn- validate-task-blocks + [] + (let [icon-names (vals status->icon-name) + icon-name->count + (w/with-page @*page2 + (into + {} + (map + (fn [icon-name] + [icon-name (.count (w/-query (str ".ls-icon-" icon-name)))]) + icon-names)))] + (prn :validate-task-blocks icon-name->count) + (w/with-page @*page1 + (doseq [[icon-name count*] icon-name->count] + (assert/assert-have-count (str ".ls-icon-" icon-name) count*))))) + +(defn- insert-task-blocks + [title-prefix] + (doseq [status (keys status->icon-name) + priority priorities] + (b/new-block (str title-prefix "-" status "-" priority)) + (util/input-command status) + (util/input-command priority))) + +(defn- update-task-blocks + [] + (let [qs-partitions (partition-all 5 (seq (.all (loc/filter ".ls-block" :has ".ui__icon"))))] + (doseq [q-seq qs-partitions] + (doseq [q q-seq] + (w/click q) + (util/input-command (rand-nth (keys status->icon-name))) + (util/input-command (rand-nth priorities)))))) + +(deftest rtc-task-blocks-test + (let [insert-task-blocks-in-page2 + (fn [*latest-remote-tx] + (w/with-page @*page2 + (let [{:keys [_local-tx remote-tx]} + (rtc/with-wait-tx-updated + (insert-task-blocks "t1"))] + (reset! *latest-remote-tx remote-tx)) + (util/exit-edit))) + update-task-blocks-in-page2 + (fn [*latest-remote-tx] + (w/with-page @*page2 + (let [{:keys [_local-tx remote-tx]} + (rtc/with-wait-tx-updated + (update-task-blocks))] + (reset! *latest-remote-tx remote-tx))))] + (testing "add some task blocks while rtc disconnected on page1" + (let [*latest-remote-tx (atom nil)] + (rtc/with-stop-restart-rtc + [@*page1] + [@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)] + (insert-task-blocks-in-page2 *latest-remote-tx)) + (validate-task-blocks) + (validate-graphs-in-2-pw-pages))) + + (testing "update task blocks while rtc disconnected on page1" + (let [*latest-remote-tx (atom nil)] + (rtc/with-stop-restart-rtc + [@*page1] + [@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)] + (update-task-blocks-in-page2 *latest-remote-tx)) + (validate-task-blocks) + (validate-graphs-in-2-pw-pages))) + + (fixtures/new-logseq-page-in-rtc*) + + (testing "perform same operations on page2 while keeping rtc connected on page1" + (let [*latest-remote-tx (atom nil)] + (insert-task-blocks-in-page2 *latest-remote-tx) + (w/with-page @*page1 + (rtc/wait-tx-update-to @*latest-remote-tx)) + (validate-task-blocks) + (validate-graphs-in-2-pw-pages))) + + (testing "update task blocks while rtc connected on page1" + (let [*latest-remote-tx (atom nil)] + (update-task-blocks-in-page2 *latest-remote-tx) + (w/with-page @*page1 + (rtc/wait-tx-update-to @*latest-remote-tx)) + (validate-task-blocks) + (validate-graphs-in-2-pw-pages))))) + +(deftest rtc-property-test + (let [insert-new-property-blocks-in-page2 + (fn [*latest-remote-tx title-prefix] + (w/with-page @*page2 + (let [{:keys [_local-tx remote-tx]} + (rtc/with-wait-tx-updated + (property-basic-test/add-new-properties title-prefix))] + (reset! *latest-remote-tx remote-tx))))] + (testing "add different types user properties on page2 while keeping rtc connected on page1" + (let [*latest-remote-tx (atom nil)] + (rtc/with-stop-restart-rtc + [@*page1] + [@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)] + (insert-new-property-blocks-in-page2 *latest-remote-tx "rtc-property-test-1")) + (validate-graphs-in-2-pw-pages))) + + (fixtures/new-logseq-page-in-rtc*) + + (testing "perform same operations on page2 while keeping rtc connected on page1" + (let [*latest-remote-tx (atom nil)] + (insert-new-property-blocks-in-page2 *latest-remote-tx "rtc-property-test-2") + (w/with-page @*page1 + (rtc/wait-tx-update-to @*latest-remote-tx)) + (validate-graphs-in-2-pw-pages))))) + +(deftest rtc-property-update-rerenders-mounted-block + (testing "a remote property transaction rerenders the mounted block" + (let [title "rtc live property block" + property-name "rtc-live-property" + property-value "rtc remote property value" + *block-uuid (atom nil) + {:keys [remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (let [block (ls-api-call! :editor.appendBlockInPage title)] + (reset! *block-uuid (get block "uuid")))))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx) + (w/wait-for + (format "#ls-block-%s .block-title-wrap:text('%s')" + @*block-uuid title))) + + (let [{:keys [remote-tx]} + (w/with-page @*page2 + (rtc/with-wait-tx-updated + (ls-api-call! :editor.upsertBlockProperty + @*block-uuid property-name property-value)))] + (w/with-page @*page1 + (rtc/wait-tx-update-to remote-tx) + (w/wait-for + (format "#ls-block-%s .property-k:text('%s')" + @*block-uuid property-name)) + (assert/assert-is-visible + (format "#ls-block-%s .property-value :text('%s')" + @*block-uuid property-value)))) + (validate-graphs-in-2-pw-pages)))) + +(deftest rtc-outliner-test + (doseq [test-fn [outliner-basic-test/create-test-page-and-insert-blocks + outliner-basic-test/indent-and-outdent + outliner-basic-test/move-up-down + outliner-basic-test/delete + outliner-basic-test/delete-test-with-children]] + (let [test-fn-in-page2 (fn [*latest-remote-tx] + (w/with-page @*page2 + (let [{:keys [_local-tx remote-tx]} + (rtc/with-wait-tx-updated + (test-fn))] + (reset! *latest-remote-tx remote-tx)))) + *latest-remote-tx (atom nil)] + (fixtures/new-logseq-page-in-rtc*) + (rtc/with-stop-restart-rtc + [@*page1] + [@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)] + (test-fn-in-page2 *latest-remote-tx)) + (validate-graphs-in-2-pw-pages)))) + +(deftest rtc-outliner-conflict-update-test + (let [title-prefix "rtc-outliner-conflict-update-test"] + (testing "add some blocks, ensure them synced" + (let [*latest-remote-tx (atom nil)] + (w/with-page @*page1 + (let [{:keys [_local-tx remote-tx]} + (rtc/with-wait-tx-updated + (b/new-blocks (map #(str title-prefix "-" %) (range 10))))] + (reset! *latest-remote-tx remote-tx))) + (w/with-page @*page2 + (rtc/wait-tx-update-to @*latest-remote-tx)) + (validate-graphs-in-2-pw-pages))) + (testing "page1: indent block1 as child of block0, page2: delete block0" + (rtc/with-stop-restart-rtc + [@*page1 @*page2] + [@*page1 (rtc/with-wait-tx-updated + (k/esc) + (assert/assert-in-normal-mode?) + (b/new-block "page1-done-1")) + @*page2 (rtc/with-wait-tx-updated + (k/esc) + (assert/assert-in-normal-mode?) + (b/new-block "page2-done-1"))] + (w/with-page @*page1 + (w/click (format ".ls-block :text('%s')" (str title-prefix "-" 1))) + (b/indent)) + (w/with-page @*page2 + (w/click (format ".ls-block :text('%s')" (str title-prefix "-" 0))) + (b/delete-blocks))) + (validate-graphs-in-2-pw-pages)) + (testing " +origin: +- block2 +- block3 +- block4 +page1: +- block2 + - block3 + - block4 +page2: +;; block2 deleted +- block4 + - block3" + (rtc/with-stop-restart-rtc + [@*page1 @*page2] + [@*page1 (rtc/with-wait-tx-updated (b/new-block "page1-done-2")) + @*page2 (rtc/with-wait-tx-updated (b/new-block "page2-done-2"))] + (w/with-page @*page1 + (w/click (format ".ls-block :text('%s')" (str title-prefix "-" 3))) + (b/indent) + (k/arrow-down) + (b/indent) + (b/indent)) + (w/with-page @*page2 + (w/click (format ".ls-block :text('%s')" (str title-prefix "-" 2))) + (b/delete-blocks) + (w/click (format ".ls-block :text('%s')" (str title-prefix "-" 3))) + (k/shift+arrow-down) + (k/meta+shift+arrow-down) + (k/enter) + (b/indent))) + (validate-graphs-in-2-pw-pages)))) + +(deftest rtc-page-test + (let [prefix "rtc-page-test-"] + (testing "create same name page in different clients. +- client1: offline, create page1 +- client2: offline, create page1 +- restart rtc" + (rtc/with-stop-restart-rtc + [@*page1 @*page2] + [@*page1 (rtc/with-wait-tx-updated (b/new-block "pw1-done-1")) + @*page2 (rtc/with-wait-tx-updated (b/new-block "pw2-done-1"))] + (w/with-page @*page1 + (page/new-page (str prefix 1))) + (w/with-page @*page2 + (page/new-page (str prefix 1)))) + (validate-graphs-in-2-pw-pages)) + + (testing " +- client1: offline, add blocks on page-2 +- client2: offline, delete page-2" + (let [page-name (str prefix 2)] + (let [*latest-remote-tx (atom nil)] + (w/with-page @*page1 + (let [{:keys [_local-tx remote-tx]} + (rtc/with-wait-tx-updated + (page/new-page page-name))] + (reset! *latest-remote-tx remote-tx))) + (w/with-page @*page2 + (rtc/wait-tx-update-to @*latest-remote-tx))) + (validate-graphs-in-2-pw-pages) + + (rtc/with-stop-restart-rtc + [@*page1 @*page2] + [@*page1 (rtc/with-wait-tx-updated (b/new-block "pw1-done-2")) + @*page2 (rtc/with-wait-tx-updated (b/new-block "pw2-done-2"))] + (w/with-page @*page1 + (b/new-blocks (map #(str "block-" %) (range 5)))) + (w/with-page @*page2 + (page/delete-page page-name))) + (validate-graphs-in-2-pw-pages))) + (testing "page rename" + (let [page-name (str prefix 3)] + (fixtures/new-logseq-page-in-rtc* page-name) + (validate-graphs-in-2-pw-pages) + (rtc/with-stop-restart-rtc + [@*page1 @*page2] + [@*page1 (rtc/with-wait-tx-updated (b/new-block "pw1-done-3")) + @*page2 (rtc/with-wait-tx-updated (b/new-block "pw2-done-3"))] + (w/with-page @*page1 + (page/rename-page page-name (str page-name "-rename1"))) + (w/with-page @*page2 + (page/rename-page page-name (str page-name "-rename2")))) + (validate-graphs-in-2-pw-pages))))) + +(deftest long-block-title-test + (testing "insert >4KB block/title" + (let [long-block-title (apply str (repeat 5000 "a")) + {:keys [_local-tx remote-tx]} + (w/with-page @*page1 + (rtc/with-wait-tx-updated + (b/new-block "") + (b/save-block long-block-title)))] + (w/with-page @*page2 + (rtc/wait-tx-update-to remote-tx)) + (validate-graphs-in-2-pw-pages) + (w/with-page @*page2 + (assert/assert-is-visible (loc/filter ".block-title-wrap" :has-text long-block-title)))))) diff --git a/clj-e2e/test/logseq/e2e/tag_basic_test.clj b/clj-e2e/test/logseq/e2e/tag_basic_test.clj new file mode 100644 index 0000000..642aca9 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/tag_basic_test.clj @@ -0,0 +1,57 @@ +(ns logseq.e2e.tag-basic-test + (:require [clojure.test :refer [deftest use-fixtures]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(defn add-new-tags + [title-prefix] + (b/new-block (str title-prefix 1 " #" title-prefix "1")) + (util/double-esc) + (b/new-block (str title-prefix 2)) + (util/set-tag (str title-prefix 2))) + +(deftest new-tag-test + (add-new-tags "tag-test-")) + +(deftest page-tag-conversion-persists-and-removes-tag-from-objects-test + (let [tag-name "page-tag-conversion" + object-page "page-tag-object"] + (page/new-page tag-name) + (k/esc) + (page/convert-to-tag tag-name) + (assert/assert-is-visible "div[data-testid='page title'] :text('Tag')") + + (page/new-page object-page) + (b/save-block "Tagged object") + (util/set-tag tag-name) + (k/esc) + + (page/goto-page tag-name) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text "Tagged object")) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text "Tagged object")) + + (w/click ".toolbar-dots-btn") + (w/click (loc/filter "[role='menuitem']" :has-text "Convert Tag to Page")) + (w/click "div[role='alertdialog'] button:text('Confirm')") + (assert/assert-have-count "button:text('Add tag property')" 0) + (assert/assert-have-count ".ls-view-body" 0) + + (page/goto-page object-page) + (assert/assert-have-count + (format ".block-tag :text('%s')" tag-name) + 0))) diff --git a/clj-e2e/test/logseq/e2e/undo_redo_test.clj b/clj-e2e/test/logseq/e2e/undo_redo_test.clj new file mode 100644 index 0000000..abd1253 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/undo_redo_test.clj @@ -0,0 +1,94 @@ +(ns logseq.e2e.undo-redo-test + (:require + [clojure.set :as set] + [clojure.string :as string] + [clojure.test :refer [deftest testing is use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.block :as b] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.page :as p] + [logseq.e2e.util :as util] + [wally.main :as w] + [wally.repl :as repl])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(deftest undo-redo-paste + (testing "Undo redo paste blocks" + (b/new-blocks ["b1" "b2"]) + (b/select-blocks 2) + (b/copy) + (b/new-block "") + (b/paste) + (util/exit-edit) + (is (= ["b1" "b2" "b1" "b2"] (util/get-page-blocks-contents))) + (b/undo) + (util/exit-edit) + (is (= ["b1" "b2" ""] (util/get-page-blocks-contents))) + (b/redo) + (util/exit-edit) + (is (= ["b1" "b2" "b1" "b2"] (util/get-page-blocks-contents))))) + +(deftest undo-latest-saved-block-content-once + (testing "Undo reverts the latest saved block content on the first attempt" + (b/new-blocks ["b1"]) + (util/wait-timeout 2000) + (util/move-cursor-to-end) + (util/press-seq " new text" :delay 20) + (util/wait-timeout 1000) + (b/undo) + (util/exit-edit) + (is (= ["b1"] (util/get-page-blocks-contents))))) + +(deftest cut-and-paste-preserves-multiple-block-trees + (testing "Cut waits for structured clipboard data before deleting nested blocks" + (let [page-name (p/get-page-name) + page-uuid (get (ls-api-call! :editor.getBlock page-name) "uuid") + target-uuid (get (first (ls-api-call! :editor.getPageBlocksTree page-name)) + "uuid")] + (util/exit-edit) + (ls-api-call! :editor.insertBatchBlock + page-uuid + [{:content "parent a" + :children [{:content "child a1"} + {:content "child a2"}]} + {:content "parent b" + :children [{:content "child b1"} + {:content "child b2"}]}]) + (w/click (util/get-by-text "child b2" true)) + (b/select-blocks 6) + (k/press "ControlOrMeta+x" {:delay 100}) + (loop [remaining 50] + (let [blocks (ls-api-call! :editor.getPageBlocksTree page-name)] + (when (some #(not (string/blank? (get % "content"))) blocks) + (when (zero? remaining) + (throw (ex-info "Cut did not remove the selected block trees" {}))) + (util/wait-timeout 100) + (recur (dec remaining))))) + (w/click (format "#ls-block-%s .block-content" target-uuid)) + (b/paste) + (util/exit-edit) + (let [tree (loop [remaining 50] + (let [blocks (remove #(string/blank? (get % "content")) + (ls-api-call! :editor.getPageBlocksTree page-name))] + (if (or (= 2 (count blocks)) (zero? remaining)) + blocks + (do + (util/wait-timeout 100) + (recur (dec remaining)))))) + titles (fn [blocks] + (mapv #(get % "content") blocks)) + children (fn [block] + (titles (get block "children")))] + (is (= ["parent a" "parent b"] (titles tree))) + (is (= ["child a1" "child a2"] (children (first tree)))) + (is (= ["child b1" "child b2"] (children (second tree))))) + (util/refresh-until-graph-loaded) + (assert/assert-is-visible (util/get-by-text "parent a" true)) + (assert/assert-is-visible (util/get-by-text "parent b" true))))) diff --git a/clj-e2e/test/logseq/e2e/view_basic_test.clj b/clj-e2e/test/logseq/e2e/view_basic_test.clj new file mode 100644 index 0000000..43caa41 --- /dev/null +++ b/clj-e2e/test/logseq/e2e/view_basic_test.clj @@ -0,0 +1,229 @@ +(ns logseq.e2e.view-basic-test + (:require [clojure.string :as string] + [clojure.test :refer [deftest is use-fixtures]] + [logseq.e2e.api :refer [ls-api-call!]] + [logseq.e2e.assert :as assert] + [logseq.e2e.fixtures :as fixtures] + [logseq.e2e.keyboard :as k] + [logseq.e2e.locator :as loc] + [logseq.e2e.page :as page] + [logseq.e2e.util :as util] + [wally.main :as w])) + +(use-fixtures :once fixtures/open-page) + +(use-fixtures :each + fixtures/new-logseq-page + fixtures/validate-graph) + +(defn- select-view-type + [view-type] + (w/click ".view-action-type") + (w/click (util/get-by-text view-type true))) + +(defn- seed-table-view! + [tag-name] + (let [container-page (page/get-page-name)] + (ls-api-call! :editor.createTag + tag-name + {:tagProperties [{:name "Status"} + {:name "Priority" + :schema {:type "number"}}]}) + (doseq [[title status priority] + [["Alpha table object" "Open" 2] + ["Beta table object" "Closed" 1]]] + (ls-api-call! :editor.insertBlock + container-page + (str title " #" tag-name) + {:properties {"Status" status + "Priority" priority}})) + (page/goto-page tag-name) + (assert/assert-is-visible ".ls-view-body .ls-table-header-cell") + (w/click ".views button[title='Add new view']") + (assert/assert-is-visible + (loc/filter ".views > button" :has-text "New view")) + (select-view-type "List View") + (assert/assert-is-visible ".view-action-type .ls-icon-list") + (select-view-type "Table View") + (assert/assert-is-visible ".view-action-type .ls-icon-table") + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object")) + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object")))) + +(defn- view-action-button + [icon] + (.first (.locator (w/get-page) + (str ".view-actions button:has(.ls-icon-" icon ")")))) + +(defn- open-view-more-actions! + [] + (w/click (view-action-button "dots"))) + +(defn- open-view-submenu! + [label] + (let [item-index ({"Columns visibility" 0 + "Group by" 1 + "Sort groups by" 2 + "Sort groups order" 3} + label)] + (k/press "Home") + (dotimes [_ item-index] + (k/arrow-down)) + (k/arrow-right))) + +(deftest table-row-selection-shows-action-bar-test + (seed-table-view! "table-row-selection-actions") + (doseq [title ["Alpha table object" "Beta table object"]] + (let [row (loc/filter ".ls-view-body .ls-table-row" :has-text title)] + (w/click (.locator row "[data-table-row-select]")))) + (assert/assert-is-visible ".ls-table-actions") + (assert/assert-is-visible + (loc/filter ".ls-table-actions .selection-count" :has-text "2"))) + +(deftest view-lifecycle-and-display-type-persistence-test + (let [tag-name "view-lifecycle" + container-page (page/get-page-name) + object-title "view lifecycle object" + view-title "Persistent view"] + (ls-api-call! :editor.createTag tag-name) + (ls-api-call! :editor.insertBlock + container-page + (str object-title " #" tag-name)) + (page/goto-page tag-name) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text object-title)) + + (w/click ".views button[title='Add new view']") + (w/click (loc/filter ".views > button" :has-text "New view")) + (w/click (loc/filter "[role='menuitem']" :has-text "Rename")) + (w/click "[role='menu'] .block-title-wrap") + (util/press-seq view-title) + (k/enter) + (assert/assert-is-visible + (loc/filter ".views > button" :has-text view-title)) + (util/double-esc) + + (select-view-type "List View") + (assert/assert-is-visible ".view-action-type .ls-icon-list") + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-block" :has-text object-title)) + + (select-view-type "Gallery View") + (assert/assert-is-visible ".view-action-type .ls-icon-layout-grid") + (assert/assert-is-visible + (loc/filter ".ls-card-item" :has-text object-title)) + + (util/refresh-until-graph-loaded) + (assert/assert-is-visible + (loc/filter ".views > button" :has-text view-title)) + (assert/assert-is-visible ".view-action-type .ls-icon-table") + (w/click (loc/filter ".views > button" :has-text view-title)) + (assert/assert-is-visible ".view-action-type .ls-icon-layout-grid") + (assert/assert-is-visible + (loc/filter ".ls-card-item" :has-text object-title)) + + (w/click (loc/filter ".views > button" :has-text view-title)) + (w/click (loc/filter "[role='menuitem']" :has-text "Delete")) + (assert/assert-have-count + (loc/filter ".views > button" :has-text view-title) + 0) + (assert/assert-is-visible + (loc/filter ".views > button" :has-text "All")))) + +(deftest table-view-search-filter-and-new-record-actions-test + (seed-table-view! "table-search-filter-actions") + + (w/click (view-action-button "search")) + (w/fill "input[placeholder='Type to search']" "Alpha") + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object")) + (assert/assert-have-count + (loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object") + 0) + (w/click (view-action-button "search")) + (w/fill "input[placeholder='Type to search']" "") + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object")) + + (w/click (view-action-button "filter")) + (w/click (loc/filter ".cp__select-results a" :has-text "Status")) + (assert/assert-is-visible ".cp__select-input[placeholder='Status']") + (w/click (util/get-by-text "Is Not Empty" true)) + (assert/assert-is-visible ".filters-row") + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object")) + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object")) + (util/double-esc) + (w/click ".filters-row button:has(.ls-icon-x)") + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object")) + + (w/click (view-action-button "plus")) + (assert/assert-is-visible ".cp__right-sidebar.open") + (util/wait-editor-visible) + (util/press-seq "New table object") + (util/exit-edit) + (assert/assert-is-visible + (loc/filter ".ls-view-body .ls-table-row" :has-text "New table object"))) + +(deftest table-view-column-visibility-action-test + (seed-table-view! "table-column-actions") + (open-view-more-actions!) + (open-view-submenu! "Columns visibility") + (w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Status")) + (assert/assert-have-count + (loc/filter ".ls-table-header-cell" :has-text "Status") + 0) + (util/double-esc) + + (open-view-more-actions!) + (open-view-submenu! "Columns visibility") + (w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Status")) + (assert/assert-is-visible + (loc/filter ".ls-table-header-cell" :has-text "Status")) + (util/double-esc) + + (util/refresh-until-graph-loaded) + (assert/assert-is-visible + (loc/filter ".ls-table-header-cell" :has-text "Status"))) + +(deftest table-view-group-and-export-actions-test + (seed-table-view! "table-group-export-actions") + + (open-view-more-actions!) + (open-view-submenu! "Group by") + (w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Status")) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text "Open")) + (assert/assert-is-visible + (loc/filter ".ls-view-body" :has-text "Closed")) + (util/double-esc) + + (open-view-more-actions!) + (open-view-submenu! "Sort groups by") + (w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Page name")) + (util/double-esc) + (open-view-more-actions!) + (open-view-submenu! "Sort groups by") + (assert/assert-is-visible + (loc/filter "[role='menuitemcheckbox'][aria-checked='true']" + :has-text "Page name")) + (util/double-esc) + + (open-view-more-actions!) + (open-view-submenu! "Sort groups order") + (w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Descending")) + (util/double-esc) + (let [body-text (.innerText (.locator (w/get-page) ".ls-view-body"))] + (is (< (string/index-of body-text "Open") + (string/index-of body-text "Closed")))) + + (open-view-more-actions!) + (w/click (.last (loc/filter "[role='menuitem']" :has-text "Export EDN"))) + (assert/assert-is-visible + (util/get-by-text "Copied view nodes" false)) + (let [content (w/eval-js "navigator.clipboard.readText()")] + (is (string/includes? content "Alpha table object")) + (is (string/includes? content "Beta table object")))) diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..b4b798f --- /dev/null +++ b/deps.edn @@ -0,0 +1,83 @@ +{:paths ["src/main" "src/electron" "src/resources"] + :deps + {org.clojure/clojure {:mvn/version "1.12.4"} + org.clojure/clojurescript {:mvn/version "1.12.134"} + io.factorhouse/hsx {:git/url "https://github.com/logseq/hsx" + :sha "21615ffa2aa530cc830e3464b793a53ef4fdd18c"} + io.github.logseq/rfx {:git/url "https://github.com/logseq/rfx.git" + :git/sha "d37aaceb37fcaf969c5cd04646dfdb985d65d3c2"} + + datascript/datascript {:git/url "https://github.com/logseq/datascript" ;; fork + :sha "3f141af97b70e1f14c65eaa119acd822ebece37e"} + ;; datascript/datascript {:local/root "../../datascript"} + + datascript-transit/datascript-transit {:mvn/version "0.3.0"} + borkdude/rewrite-edn {:mvn/version "0.5.9"} + funcool/promesa {:mvn/version "11.0.678"} + medley/medley {:mvn/version "1.4.0"} + metosin/reitit-frontend {:mvn/version "0.10.1" + :exclusions [metosin/malli]} + cljs-bean/cljs-bean {:mvn/version "1.9.0"} + prismatic/dommy {:mvn/version "1.1.0"} + org.clojure/core.match {:mvn/version "1.1.0"} + com.andrewmcveigh/cljs-time {:git/url "https://github.com/logseq/cljs-time" ;; fork + :sha "5704fbf48d3478eedcf24d458c8964b3c2fd59a9"} + ;; TODO: delete cljs-drag-n-drop and use dnd-kit + cljs-drag-n-drop/cljs-drag-n-drop {:mvn/version "0.1.0"} + cljs-http/cljs-http {:mvn/version "0.1.49"} + org.babashka/sci {:mvn/version "0.12.51"} + org.clj-commons/hickory {:mvn/version "0.7.7"} + org.clj-commons/humanize {:mvn/version "1.2"} + org.babashka/cli {:mvn/version "0.8.67"} + hiccups/hiccups {:mvn/version "0.3.0"} + tongue/tongue {:mvn/version "0.4.4"} + org.clojure/core.async {:mvn/version "1.8.741"} + thheller/shadow-cljs {:mvn/version "3.4.4"} + expound/expound {:mvn/version "0.9.0"} + com.lambdaisland/glogi {:git/url "https://github.com/lambdaisland/glogi" + :git/sha "30328a045141717aadbbb693465aed55f0904976"} + camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} + instaparse/instaparse {:mvn/version "1.5.0"} + org.clojars.mmb90/cljs-cache {:mvn/version "0.1.4"} + fipp/fipp {:mvn/version "0.6.29"} + logseq/common {:local/root "deps/common"} + logseq/graph-parser {:local/root "deps/graph-parser"} + logseq/outliner {:local/root "deps/outliner"} + logseq/db-sync {:local/root "deps/db-sync"} + logseq/publishing {:local/root "deps/publishing"} + logseq/shui {:local/root "deps/shui"} + metosin/malli {:git/url "https://github.com/metosin/malli" + :sha "52ea58a36ff5172b38dfc526ca638afa7226a4a0"} + com.cognitect/transit-cljs {:mvn/version "0.8.280"} + + io.github.open-spaced-repetition/cljc-fsrs {:git/sha "eeef3520df664e51c3d0ba2031ec2ba071635442" + :git/url "https://github.com/open-spaced-repetition/cljc-fsrs"} + tick/tick {:mvn/version "1.0"} + + logseq/logseq-schema {:git/url "https://github.com/logseq/logseq-schema" + :git/sha "6eeb51cd6d80bbffa0873c1e79790dc1f4ff68cf"}} + + :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"] + :extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.0"} + cider/cider-nrepl {:mvn/version "0.59.0"} + org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} + tortue/spy {:mvn/version "2.15.0"} + binaryage/devtools {:mvn/version "1.0.7"}} + :jvm-opts ["-Xmx2g"] + :main-opts ["-m" "shadow.cljs.devtools.cli"]} + :test {:extra-paths ["src/dev-cljs/" "src/test/"] + :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"} + pjstadig/humane-test-output {:mvn/version "0.11.0"} + org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} + tortue/spy {:mvn/version "2.15.0"} + cider/cider-nrepl {:mvn/version "0.59.0"}} + :main-opts ["-m" "shadow.cljs.devtools.cli"]} + + :bench {:extra-paths ["src/bench/"] + :extra-deps {olical/cljs-test-runner {:mvn/version "3.8.1"} + fipp/fipp {:mvn/version "0.6.29"}} + :main-opts ["-m" "cljs-test-runner.main" "-d" "src/bench" "-n" "frontend.benchmark-test-runner"]} + + ;; Use :replace-deps for tools. See https://github.com/clj-kondo/clj-kondo/issues/1536#issuecomment-1013006889 + :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/common/.carve/config.edn b/deps/common/.carve/config.edn new file mode 100644 index 0000000..9e9674b --- /dev/null +++ b/deps/common/.carve/config.edn @@ -0,0 +1,16 @@ +{:paths ["src"] + :api-namespaces [logseq.common.path + logseq.common.uuid + logseq.common.util.namespace + logseq.common.util.page-ref + logseq.common.util.block-ref + logseq.common.util + logseq.common.util.date-time + logseq.common.date + logseq.common.util.macro + logseq.common.cognito-config + logseq.common.config + logseq.common.defkeywords + logseq.common.graph-dir + logseq.common.graph-registry] + :report {:format :ignore}} diff --git a/deps/common/.carve/ignore b/deps/common/.carve/ignore new file mode 100644 index 0000000..ef1b84c --- /dev/null +++ b/deps/common/.carve/ignore @@ -0,0 +1,27 @@ +;; API fn +logseq.common.graph/get-files +;; API fn +logseq.common.graph/read-directories +;; API fn +logseq.common.graph/get-default-graphs-dir +;; API fn +logseq.common.graph/expand-home +;; API fn +logseq.common.graph/get-db-graphs-dir +;; API fn +logseq.common.graph/get-db-based-graphs +;; API fn +logseq.common.authorization/verify-jwt + +;; Profile utils +logseq.common.profile/profile-fn! +logseq.common.profile/*key->call-count +logseq.common.profile/*key->time-sum + +;; API fn +logseq.common.plural/is-plural? +logseq.common.plural/is-singular? +logseq.common.plural/pluralize + +;; API fn +logseq.common.version/format-version diff --git a/deps/common/.clj-kondo/config.edn b/deps/common/.clj-kondo/config.edn new file mode 100644 index 0000000..1e5862e --- /dev/null +++ b/deps/common/.clj-kondo/config.edn @@ -0,0 +1,11 @@ +{: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}}} + :lint-as {promesa.core/let clojure.core/let} + :skip-comments true + :output {:progress true}} diff --git a/deps/common/.gitignore b/deps/common/.gitignore new file mode 100644 index 0000000..4c8e645 --- /dev/null +++ b/deps/common/.gitignore @@ -0,0 +1 @@ +.clj-kondo/.cache diff --git a/deps/common/README.md b/deps/common/README.md new file mode 100644 index 0000000..9046b5e --- /dev/null +++ b/deps/common/README.md @@ -0,0 +1,52 @@ +## Description + +This library provides common util namespaces and resources to share between the +frontend and other non-frontend contexts. This library is not supposed to depend +on other logseq libraries. This library is compatible with ClojureScript and +with node/[nbb-logseq](https://github.com/logseq/nbb-logseq) to respectively +provide frontend and Electron/commandline functionality. + + +## API + +This library is under the parent namespace `logseq.common`. + +## 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-common.yml) for linting examples. + +### Setup + +To run linters and tests, you'll want to install pnpm dependencies once: +``` +pnpm install +``` +### Testing + +To run nbb-logseq tests: + +``` +# Run all tests +$ pnpm test +# List available options +$ pnpm test -H +# Run tests with :focus metadata flag +$ pnpm test -i focus +``` + +To run ClojureScript tests: +``` +clojure -M:test +``` + +To auto-run ClojureScript tests while writing tests: + +``` +clojure -M:test -w src +``` +### Managing dependencies + +See [standard nbb/cljs library advice in graph-parser](/deps/graph-parser/README.md#managing-dependencies). \ No newline at end of file diff --git a/deps/common/bb.edn b/deps/common/bb.edn new file mode 100644 index 0000000..7aa4a74 --- /dev/null +++ b/deps/common/bb.edn @@ -0,0 +1,27 @@ +{: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} + + :tasks/config + {:large-vars + {:metadata-exceptions #{:large-vars/cleanup-todo} + :max-lines-count 45}}} diff --git a/deps/common/deps.edn b/deps/common/deps.edn new file mode 100644 index 0000000..605f630 --- /dev/null +++ b/deps/common/deps.edn @@ -0,0 +1,12 @@ +{:paths ["src" "resources"] + :deps {com.andrewmcveigh/cljs-time {:git/url "https://github.com/logseq/cljs-time" ;; fork + :sha "5704fbf48d3478eedcf24d458c8964b3c2fd59a9"} + funcool/promesa {:mvn/version "11.0.678"}} + :aliases + {:test {:extra-paths ["test"] + :extra-deps {olical/cljs-test-runner {:mvn/version "3.8.1"} + org.clojure/clojurescript {:mvn/version "1.12.134"}} + :main-opts ["-m" "cljs-test-runner.main"]} + :clj-kondo + {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/common/nbb.edn b/deps/common/nbb.edn new file mode 100644 index 0000000..641514d --- /dev/null +++ b/deps/common/nbb.edn @@ -0,0 +1,4 @@ +{:paths ["src" "resources"] + :deps + {io.github.nextjournal/nbb-test-runner + {:git/sha "b379325cfa5a3306180649da5de3bf5166414e71"}}} diff --git a/deps/common/package.json b/deps/common/package.json new file mode 100644 index 0000000..784bdf7 --- /dev/null +++ b/deps/common/package.json @@ -0,0 +1,12 @@ +{ + "name": "@logseq/common", + "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" + }, + "scripts": { + "test": "pnpm exec nbb-logseq -cp test -m nextjournal.test-runner" + } +} diff --git a/deps/common/pnpm-lock.yaml b/deps/common/pnpm-lock.yaml new file mode 100644 index 0000000..db58244 --- /dev/null +++ b/deps/common/pnpm-lock.yaml @@ -0,0 +1,32 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + 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 + + import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + +snapshots: + + '@logseq/nbb-logseq@https://codeload.github.com/logseq/nbb-logseq/tar.gz/4d9f1382ce2cec6f39bf12df49ba425f1bd5f3fb': + dependencies: + import-meta-resolve: 4.1.0 + + import-meta-resolve@4.1.0: {} diff --git a/deps/common/resources/templates/config.edn b/deps/common/resources/templates/config.edn new file mode 100644 index 0000000..36396fd --- /dev/null +++ b/deps/common/resources/templates/config.edn @@ -0,0 +1,237 @@ +{:meta/version 1 + + ;; Hide empty block properties + ;; Default value: false + ;; :ui/hide-empty-properties? false + + ;; Enable tooltip preview on hover. + ;; Default value: true + :ui/enable-tooltip? true + + ;; Display brackets [[]] around page references. + ;; Default value: true + ;; :ui/show-brackets? true + + ;; Display all lines of a block when referencing ((block)). + ;; Default value: false + :ui/show-full-blocks? false + + ;; Automatically expand block references when zooming in. + ;; Default value: true + :ui/auto-expand-block-refs? true + + ;; Disable accent marks when searching. + ;; After changing this setting, rebuild the search index by pressing (^C ^S). + ;; Default value: true + :feature/enable-search-remove-accents? true + + ;; Enable flashcards. + ;; Default value: true + ;; :feature/enable-flashcards? true + + ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query. + ;; Default value: false + ;; :feature/disable-scheduled-and-deadline-query? false + + ;; Specify the number of days displayed in the future for + ;; the 'scheduled tasks and deadlines' query. + ;; Example usage: + ;; Display all scheduled and deadline blocks for the next 14 days: + ;; :scheduled/future-days 14 + ;; Default value: 7 + ;; :scheduled/future-days 7 + + ;; Specify the first day of the week. + ;; Available options: + ;; - integer from 0 to 6 (Monday to Sunday) + ;; Default value: 6 (Sunday) + :start-of-week 6 + + ;; Specify a custom CSS import. + ;; This option takes precedence over the local `logseq/custom.css` file. + ;; Example usage: + ;; :custom-css-url "@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');" + + ;; Specify a custom JS import. + ;; This option takes precedence over the local `logseq/custom.js` file. + ;; Example usage: + ;; :custom-js-url "https://cdn.logseq.com/custom.js" + + ;; Set bullet indentation when exporting + ;; Available options: + ;; - `:eight-spaces` as eight spaces + ;; - `:four-spaces` as four spaces + ;; - `:two-spaces` as two spaces + ;; - `:tab` as a tab character (default) + ;; :export/bullet-indentation :tab + + ;; Publish all pages within the Graph + ;; Regardless of whether individual pages have been marked as public. + ;; Default value: false + ;; :publishing/all-pages-public? false + + ;; Define the default home page and sidebar status. + ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden. + ;; The `:page` value represents the name of the page displayed at startup. + ;; Available options for `:sidebar` are: + ;; - "Contents" to display the Contents page in the right sidebar. + ;; - A specific page name to display in the right sidebar. + ;; - An array of multiple pages, e.g., ["Contents" "Page A" "Page B"]. + ;; If `:sidebar` remains unset, the right sidebar will stay hidden. + ;; Examples: + ;; 1. Set "Changelog" as the home page and display "Contents" in the right sidebar: + ;; :default-home {:page "Changelog", :sidebar "Contents"} + ;; 2. Set "Jun 3rd, 2021" as the home page without the right sidebar: + ;; :default-home {:page "Jun 3rd, 2021"} + ;; 3. Set "home" as the home page and display multiple pages in the right sidebar: + ;; :default-home {:page "home", :sidebar ["Page A" "Page B"]} + + ;; Configure custom shortcuts. + ;; Syntax: + ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`. + ;; 2. A space between keys represents key chords, e.g., `t s` means + ;; pressing `t` followed by `s`. + ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac. + ;; 4. Use false to disable a specific shortcut. + ;; 5. You can define multiple bindings for a single action, e.g., ["ctrl+j" "down"]. + ;; The full list of configurable shortcuts is available at: + ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs + ;; Example: + ;; :shortcuts + ;; {:editor/new-block "enter" + ;; :editor/new-line "shift+enter" + ;; :editor/insert-link "mod+shift+k" + ;; :editor/highlight false + ;; :ui/toggle-settings "t s" + ;; :editor/up ["ctrl+k" "up"] + ;; :editor/down ["ctrl+j" "down"] + ;; :editor/left ["ctrl+h" "left"] + ;; :editor/right ["ctrl+l" "right"]} + :shortcuts {} + + ;; Configure the behavior of pressing Enter in document mode. + ;; if set to true, pressing Enter will create a new block. + ;; Default value: false + :shortcut/doc-mode-enter-for-new-block? false + + ;; Block content larger than `block/title-max-length` will not be searchable + ;; or editable for performance. + ;; Default value: 10000 + :block/title-max-length 10000 + + ;; Display command documentation on hover. + ;; Default value: true + :ui/show-command-doc? true + + ;; Display empty bullet points. + ;; Default value: false + :ui/show-empty-bullets? false + + ;; Pre-defined :view function to use with advanced queries. + :query/views + {:pprint + (fn [r] [:pre.code (pprint r)])} + + ;; Advanced queries `:result-transform` function. + ;; Transform the query result before displaying it. + ;; Example usage for DB graphs: +;; :query/result-transforms +;; {:sort-by-priority +;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority "Z")) result))} + +;; Queries will be displayed at the bottom of today's journal page. +;; Example usage: +;; :default-queries +;; {:journals []} + + ;; Add custom commands to the command palette + ;; Example usage: + ;; :commands + ;; [ + ;; ["js" "Javascript"] + ;; ["md" "Markdown"] + ;; ] + :commands [] + + ;; Enable collapsing blocks with titles but no children. + ;; By default, only blocks with children can be collapsed. + ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing + ;; blocks with titles (multiple lines) and content. For example: + ;; - block title + ;; block content + ;; Default value: false + :outliner/block-title-collapse-enabled? false + + ;; Macros replace texts and will make you more productive. + ;; Example usage: + ;; Change the :macros value below to: + ;; {"poem" "Rose is $1, violet's $2. Life's ordered: Org assists you."} + ;; input "{{poem red,blue}}" + ;; becomes + ;; Rose is red, violet's blue. Life's ordered: Org assists you. + :macros {} + + ;; Configure the default expansion level for linked references. + ;; For example, consider the following block hierarchy: + ;; - a [[page]] (level 1) + ;; - b (level 2) + ;; - c (level 3) + ;; - d (level 4) + ;; + ;; With the default value of level 2, block b will be collapsed. + ;; If the level's value is set to 3, block c will be collapsed. + ;; Default value: 2 + :ref/default-open-blocks-level 2 + + ;; Mobile photo upload configuration. + ;; :mobile/photo + ;; {:allow-editing? true + ;; :quality 80} + + ;; Mobile features options + ;; Gestures + ;; Example usage: + ;; :mobile + ;; {:gestures/disabled-in-block-with-tags ["kanban"]} + + ;; Extra CodeMirror options + ;; See https://codemirror.net/5/doc/manual.html#config for possible options + ;; Example usage: + ;; :editor/extra-codemirror-options + ;; {:lineWrapping false ; Default value: false + ;; :lineNumbers true ; Default value: true + ;; :readOnly false} ; Default value: false + + ;; Enable logical outdenting + ;; Default value: false + ;; :editor/logical-outdenting? false + + ;; Prefer pasting the file when text and a file are in the clipboard. + ;; Default value: false + ;; :editor/preferred-pasting-file? false + + ;; Quick capture templates for receiving content from other apps. + ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded + ;; by receiving content from other apps. Note: the {} cannot be omitted. + ;; - {time}: capture time + ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference + ;; - {text}: text that users selected before sharing. + ;; - {url}: URL or assets path for media files stored in Logseq. + ;; You can also reorder them or use only one or two of them in the template. + ;; You can also insert or format any text in the template, as shown in the following examples. + ;; :quick-capture-templates + ;; {:text "[[quick capture]] **{time}**: {text} from {url}" + ;; :media "[[quick capture]] **{time}**: {url}"} + + ;; Quick capture options. + ;; - insert-today? Insert the capture at the end of today's journal page (boolean). + ;; - redirect-page? Redirect to the quick capture page after capturing (boolean). + ;; - default-page The default page to capture to if insert-today? is false (string). + ;; - prettify-url? Whether urls to YouTube or X should be wrapped with {{video ...}} or {{twitter ...}} + ;; :quick-capture-options + ;; {:insert-today? false ;; Default value: true + ;; :redirect-page? false ;; Default value: false + ;; :default-page "quick capture" ;; Default page: "quick capture" + ;; :prettify-url? true} ;; Default value: true + + } diff --git a/deps/common/src/logseq/common/authorization.cljs b/deps/common/src/logseq/common/authorization.cljs new file mode 100644 index 0000000..1e94c76 --- /dev/null +++ b/deps/common/src/logseq/common/authorization.cljs @@ -0,0 +1,137 @@ +(ns logseq.common.authorization + "jwt token authorization" + (:require [clojure.string :as string] + [promesa.core :as p])) + +(def text-decoder (js/TextDecoder.)) +(def text-encoder (js/TextEncoder.)) + +(def ^:private jwks-ttl-ms (* 6 60 60 1000)) +(def ^:private token-ttl-ms (* 60 60 1000)) +(def ^:private token-capacity 200) + +(defonce ^:private *jwks-cache (atom {:url nil :keys nil :fetched-at 0})) +(defonce ^:private *token-cache (atom {})) + +(defn- get-now-ms [] + (.now js/Date)) + +(defn- cached-token + [token now-s now-ms] + (when-let [{:keys [payload exp cached-at]} (get @*token-cache token)] + (when (and (number? exp) + (> exp now-s) + (< (- now-ms cached-at) token-ttl-ms)) + payload))) + +(defn- cache-token! + [token payload now-ms] + (let [exp (aget payload "exp")] + (when (number? exp) + (swap! *token-cache assoc token {:payload payload :exp exp :cached-at now-ms})) + (when (> (count @*token-cache) token-capacity) + (swap! *token-cache + (fn [cache] + (into {} + (remove (fn [[_ {:keys [exp cached-at]}]] + (or (not (number? exp)) + (<= exp (js/Math.floor (/ now-ms 1000))) + (>= (- now-ms cached-at) token-ttl-ms)))) + cache)))))) + +(defn- get-jwks-keys + [url & {:keys [force?]}] + (let [now (get-now-ms) + {:keys [url cached-url jwks-keys fetched-at]} {:cached-url (:url @*jwks-cache) + :url url + :jwks-keys (:keys @*jwks-cache) + :fetched-at (:fetched-at @*jwks-cache)} + fresh? (and (not force?) + (= cached-url url) + jwks-keys + (< (- now fetched-at) jwks-ttl-ms))] + (if fresh? + (p/resolved jwks-keys) + (p/let [jwks-resp (js/fetch url) + _ (when-not (.-ok jwks-resp) (throw (ex-info "jwks" {}))) + jwks (.json jwks-resp) + jwks-keys (or (aget jwks "keys") #js [])] + (reset! *jwks-cache {:url url :keys jwks-keys :fetched-at now}) + jwks-keys)))) + +(defn- base64url->uint8array [input] + (let [pad (if (pos? (mod (count input) 4)) + (apply str (repeat (- 4 (mod (count input) 4)) "=")) + "") + base64 (-> (str input pad) + (string/replace "-" "+") + (string/replace "_" "/")) + raw (js/atob base64) + data (js/Uint8Array. (.-length raw))] + (dotimes [i (.-length raw)] + (aset data i (.charCodeAt raw i))) + data)) + +(defn- decode-jwt-part [part] + (let [data (base64url->uint8array part)] + (js/JSON.parse (.decode text-decoder data)))) + +(defn client-id-allowed? [env client-id] + (let [primary-client-id (aget env "COGNITO_CLIENT_ID") + additional-client-ids (->> (string/split (or (aget env "COGNITO_CLIENT_IDS") "") #",") + (map string/trim) + (remove string/blank?)) + allowed-client-ids (cond-> (set additional-client-ids) + (and (string? primary-client-id) (not (string/blank? primary-client-id))) + (conj primary-client-id))] + (and (string? client-id) + (not (string/blank? client-id)) + (contains? allowed-client-ids client-id)))) + +(defn- import-rsa-key [jwk] + (.importKey js/crypto.subtle + "jwk" + jwk + #js {:name "RSASSA-PKCS1-v1_5" :hash "SHA-256"} + false + #js ["verify"])) + +(defn verify-jwt [token env] + (let [parts (string/split token #"\.")] + (when (not= 3 (count parts)) (throw (ex-info "invalid" {}))) + (let [header-part (nth parts 0) + payload-part (nth parts 1) + signature-part (nth parts 2) + now-ms (get-now-ms) + now-s (js/Math.floor (/ now-ms 1000))] + (if-let [cached (cached-token token now-s now-ms)] + (p/resolved cached) + (p/let [header (decode-jwt-part header-part) + payload (decode-jwt-part payload-part) + issuer (aget env "COGNITO_ISSUER") + client-id-claim (or (aget payload "aud") + (aget payload "client_id")) + _ (when (not= (aget payload "iss") issuer) (throw (ex-info "iss not found" {}))) + _ (when-not (client-id-allowed? env client-id-claim) (throw (ex-info "aud not found" {}))) + _ (when (and (aget payload "exp") (< (aget payload "exp") now-s)) + (throw (ex-info "exp" {}))) + jwks-url (aget env "COGNITO_JWKS_URL") + jwks-keys (get-jwks-keys jwks-url) + matching-key (.find jwks-keys (fn [k] (= (aget k "kid") (aget header "kid")))) + matching-key (if matching-key + matching-key + (p/let [jwks-keys (get-jwks-keys jwks-url :force? true) + matching-key (.find jwks-keys (fn [k] (= (aget k "kid") (aget header "kid"))))] + matching-key)) + _ (when-not matching-key (throw (ex-info "kid" {}))) + crypto-key (import-rsa-key matching-key) + data (.encode text-encoder (str header-part "." payload-part)) + signature (base64url->uint8array signature-part) + ok (.verify js/crypto.subtle + "RSASSA-PKCS1-v1_5" + crypto-key + signature + data)] + (when ok + (cache-token! token payload now-ms) + payload)))))) diff --git a/deps/common/src/logseq/common/cognito_config.cljs b/deps/common/src/logseq/common/cognito_config.cljs new file mode 100644 index 0000000..d4f7d19 --- /dev/null +++ b/deps/common/src/logseq/common/cognito_config.cljs @@ -0,0 +1,13 @@ +(ns logseq.common.cognito-config + "Shared Cognito configuration for frontend and CLI-safe consumers.") + +(def COGNITO-CLIENT-ID + "69cs1lgme7p8kbgld8n5kseii6") + +(def CLI-COGNITO-CLIENT-ID + "69cs1lgme7p8kbgld8n5kseii6") + +(def OAUTH-DOMAIN + "logseq-prod.auth.us-east-1.amazoncognito.com") + +(def OAUTH-SCOPE "email openid phone") diff --git a/deps/common/src/logseq/common/config.cljs b/deps/common/src/logseq/common/config.cljs new file mode 100644 index 0000000..5e946b2 --- /dev/null +++ b/deps/common/src/logseq/common/config.cljs @@ -0,0 +1,139 @@ +(ns logseq.common.config + "Common config constants and fns that are shared between deps and app" + (:require [clojure.string :as string])) + +(goog-define PUBLISHING false) + +(defn hidden? + [path patterns] + (let [path (if (and (string? path) + (= \/ (first path))) + (subs path 1) + path)] + (some (fn [pattern] + (let [pattern (if (and (string? pattern) + (not= \/ (first pattern))) + (str "/" pattern) + pattern)] + (string/starts-with? (str "/" path) pattern))) patterns))) + +(defn remove-hidden-files + "Removes files that match a pattern specified by :hidden config" + [files config get-path-fn] + (if-let [patterns (seq (:hidden config))] + (remove (fn [file] + (let [path (get-path-fn file)] + (hidden? path patterns))) files) + files)) + +(def app-name "logseq") + +(defonce asset-protocol "assets://") + +(defonce db-version-prefix "logseq_db_") +(defonce file-version-prefix "logseq_local_") + +(defn strip-leading-db-version-prefix + "Strip exactly one leading db prefix for user-facing display values." + [s] + (if (and (string? s) + (string/starts-with? s db-version-prefix)) + (subs s (count db-version-prefix)) + s)) + +(defn canonicalize-db-version-repo + "Normalize any repo/graph name to exactly one leading db prefix." + [s] + (when (seq s) + (let [s (str s) + stripped (loop [name' s] + (if (string/starts-with? name' db-version-prefix) + (recur (subs name' (count db-version-prefix))) + name'))] + (str db-version-prefix stripped)))) + +(defonce default-graphs-dir "~/logseq/graphs") +(defonce local-assets-dir "assets") +(defonce unlinked-graphs-dir "Unlinked graphs") + +(defonce favorites-page-name "$$$favorites") +(defonce views-page-name "$$$views") +(defonce library-page-name "Library") +(defonce quick-add-page-name "Quick add") +(defonce recycle-page-name "Recycle") + +(defn local-relative-asset? + [s] + (and (string? s) + (not (string/includes? s "://")) + (re-find (re-pattern (str "^[./]*" local-assets-dir)) s))) + +(defn local-protocol-asset? + [s] + (when (string? s) + (string/starts-with? s asset-protocol))) + +(defn protocol-path? + [s] + (try + (let [url (js/URL. s)] + (some? (.-protocol url))) + (catch :default _ + false))) + +(defn remove-asset-protocol + [s] + (if (local-protocol-asset? s) + (-> s + (string/replace-first asset-protocol "file://")) + s)) + +(defn text-formats + [] + #{:json :org :md :yml :dat :asciidoc :rst :txt :markdown :adoc :html :js :ts :edn :clj :ml :rb :ex :erl :java :php :c :css + :tldr :sh}) + +(defn img-formats + [] + #{:gif :svg :jpeg :ico :png :jpg :bmp :webp :avif :cr2}) + +(defonce block-pattern "-") + +(def unused-in-db-graphs-deprecation + "is not used in DB graphs") + +(def file-only-config + "File only config keys that are deprecated in DB graphs along with + descriptions for their deprecation." + (merge + (zipmap + [:file/name-format + :file-sync/ignore-files + :hidden + :ignored-page-references-keywords + :journal/file-name-format + :journal/page-title-format + :journals-directory + :logbook/settings + :org-mode/insert-file-link? + :pages-directory + :preferred-workflow + :property/separated-by-commas + :property-pages/excludelist + :srs/learning-fraction + :srs/initial-interval + :whiteboards-directory + :feature/enable-whiteboards?] + (repeat unused-in-db-graphs-deprecation)) + {:preferred-format + "is not used in DB graphs as there is only markdown mode." + :property-pages/enabled? + "is not used in DB graphs as all properties have pages" + :block-hidden-properties + "is not used in DB graphs as hiding a property is done in its configuration" + :feature/enable-block-timestamps? + "is not used in DB graphs as it is always enabled" + :favorites + "is not stored in config for DB graphs" + :default-templates + "is replaced by #Template and the `Apply template to tags` property"})) diff --git a/deps/common/src/logseq/common/date.cljs b/deps/common/src/logseq/common/date.cljs new file mode 100644 index 0000000..273e821 --- /dev/null +++ b/deps/common/src/logseq/common/date.cljs @@ -0,0 +1,81 @@ +(ns logseq.common.date + "Date related fns shared by worker and frontend namespaces. Eventually some + of this should go to logseq.common.util.date-time" + (:require [cljs-time.format :as tf] + [clojure.string :as string] + [logseq.common.util :as common-util])) + +(defonce built-in-journal-title-formatters + (list + "do MMM yyyy" + "do MMMM yyyy" + "MMM do, yyyy" + "MMMM do, yyyy" + "E, dd-MM-yyyy" + "E, dd.MM.yyyy" + "E, MM/dd/yyyy" + "E, yyyy/MM/dd" + "EEE, dd-MM-yyyy" + "EEE, dd.MM.yyyy" + "EEE, MM/dd/yyyy" + "EEE, yyyy/MM/dd" + "EEEE, dd-MM-yyyy" + "EEEE, dd.MM.yyyy" + "EEEE, MM/dd/yyyy" + "EEEE, yyyy/MM/dd" + "dd-MM-yyyy" + ;; This tyle will mess up other date formats like "2022-08" "2022Q4" "2022/10" + ;; "dd.MM.yyyy" + "MM/dd/yyyy" + "MM-dd-yyyy" + "MM_dd_yyyy" + "yyyy/MM/dd" + "yyyy-MM-dd" + "yyyy-MM-dd EEE" + "yyyy-MM-dd EEEE" + "yyyy_MM_dd" + "yyyyMMdd" + "yyyy年MM月dd日")) + +(defonce slash-journal-title-formatters + (filter #(string/includes? % "/") built-in-journal-title-formatters)) + +(defn journal-title-formatters + [date-formatter] + (-> + (cons + date-formatter + built-in-journal-title-formatters) + (distinct))) + +(defn normalize-date + "Given raw date string, return a normalized date string at best effort. + Warning: this is a function with heavy cost (likely 50ms). Use with caution" + [s date-formatter] + (some + (fn [formatter] + (try + (tf/parse (tf/formatter formatter) s) + (catch :default _e + false))) + (journal-title-formatters date-formatter))) + +(defn normalize-journal-title + "Normalize journal title at best effort. Return nil if title is not a valid date. + Return goog.date.Date. + + Return format: 20220812T000000" + [title date-formatter] + (and title + (normalize-date (common-util/capitalize-all title) date-formatter))) + +(defn ^:api valid-journal-title? + "This is a loose rule, requires double check by journal-title->custom-format. + + BUG: This also accepts strings like 3/4/5 as journal titles" + [title date-formatter] + (boolean (normalize-journal-title title date-formatter))) + +(defn ^:api valid-journal-title-with-slash? + [title] + (some #(valid-journal-title? title %) slash-journal-title-formatters)) \ No newline at end of file diff --git a/deps/common/src/logseq/common/defkeywords.cljc b/deps/common/src/logseq/common/defkeywords.cljc new file mode 100644 index 0000000..82130db --- /dev/null +++ b/deps/common/src/logseq/common/defkeywords.cljc @@ -0,0 +1,31 @@ +(ns logseq.common.defkeywords + "Macro 'defkeywords' to def keyword with config" + #?(:cljs (:require-macros [logseq.common.defkeywords]))) + +(def ^:private *defined-kws (volatile! {})) + +#_:clj-kondo/ignore +(defmacro defkeyword + "Define keyword with docstring. + How 'find keyword definition' works? + clojure-lsp treat keywords defined by `cljs.spec.alpha/def` as keyword-definition. + Adding a :lint-as `defkeyword` -> `cljs.spec.alpha/def` in clj-kondo config make it works." + [& _args]) + +(defmacro defkeywords + "impl at hooks.defkeywords in .clj-kondo +(defkeywords ::a ::b )" + [& keyvals] + (let [kws (take-nth 2 keyvals) + current-meta (meta &form)] + (doseq [kw kws] + (when-let [info (get @*defined-kws kw)] + (when (not= (:file current-meta) (:file info)) + (vswap! *defined-kws assoc kw current-meta) + (throw (ex-info "keyword already defined somewhere else" {:kw kw :info info})))) + (vswap! *defined-kws assoc kw current-meta)) + ;; (let [kw->config (partition 2 keyvals)] + ;; (doseq [[kw config] kw->config] + ;; (vswap! *defined-kw->config assoc kw config))) + ) + `(vector ~@keyvals)) diff --git a/deps/common/src/logseq/common/graph.cljs b/deps/common/src/logseq/common/graph.cljs new file mode 100644 index 0000000..63b9abc --- /dev/null +++ b/deps/common/src/logseq/common/graph.cljs @@ -0,0 +1,140 @@ +(ns ^:node-only logseq.common.graph + "This ns provides common fns for a graph directory and only runs in a node environment" + (:require ["fs" :as fs] + ["os" :as os] + ["path" :as node-path] + [clojure.string :as string] + [logseq.common.config :as common-config] + [logseq.common.graph-dir :as graph-dir] + [logseq.common.path :as path])) + +(def ^:private win32? + "Copy of electron.utils/win32? . Too basic to couple the two libraries" + (= (.-platform js/process) "win32")) + +(defn- fix-win-path! + "Copy of electron.utils/fix-win-path!. Too basic to couple the two libraries" + [path] + (when (not-empty path) + (if win32? + (string/replace path "\\" "/") + path))) + +(defn readdir + "Reads given directory recursively and returns all filenames. Also applies + some graph-specific filtering e.g. symbolic links and files starting with '.' + are removed" + [root-dir] + (->> (tree-seq + (fn [[is-dir _fpath]] + is-dir) + (fn [[_is-dir dir]] + (let [files (fs/readdirSync dir #js {:withFileTypes true})] + (->> files + (remove #(.isSymbolicLink ^js %)) + (remove #(string/starts-with? (.-name ^js %) ".")) + (map #(do + [(.isDirectory %) + (node-path/join dir (.-name %))]))))) + [true root-dir]) + (filter (complement first)) + (map second) + (map fix-win-path!) + (vec))) + +(defn read-directories + "Given a dir, returns all the sub-directories" + [root-dir] + (let [files (fs/readdirSync root-dir #js {:withFileTypes true})] + (->> files + (remove #(.isSymbolicLink ^js %)) + (remove #(string/starts-with? (.-name ^js %) ".")) + (filter #(.isDirectory %)) + (map #(.-name %))))) + +(defn- path-at-or-under? + [path dir] + (or (= path dir) + (string/starts-with? path (str dir "/")))) + +(defn ignored-path? + "Given a graph directory and path, returns truthy value on whether the path is + ignored. Useful for contexts like reading a graph's directory and file watcher + notifications + +Rules: + +- Paths starting with '.' are ignored +- Paths ending with '.DS_Store' are ignored +- Dynamic caches used by Logseq are ignored: graph-txid.edn and pages-metadata.edn +- Contents in '**/node_modules/' are ignored +- Contents in '/logseq/.recycle/' are ignored +- Contents in '/logseq/bak/' are ignored +- Contents in with '/logseq/version-files/' are ignored +- Contents in '/mirror/markdown/' are ignored +" + [dir path] + (let [dir (path/path-normalize dir) + path (path/path-normalize path) + rpath (path/trim-dir-prefix dir path)] + (when (string? path) + (or + (string/starts-with? rpath ".") + (some #(path-at-or-under? rpath %) + ["logseq/.recycle" "logseq/bak" "logseq/version-files" "mirror/markdown"]) + (contains? #{"logseq/graphs-txid.edn" "logseq/pages-metadata.edn"} rpath) + (some #(string/includes? rpath (str "/" % "/")) + ["node_modules"]) + (some #(string/ends-with? rpath %) + [".DS_Store"]) + ;; hidden directory or file + (or (re-find #"/\.[^.]+" rpath) + (re-find #"^\.[^.]+" rpath)))))) + +(def ^:private allowed-formats + #{:org :markdown :md :edn :json :js :css}) + +(defn- get-ext + [p] + (-> (node-path/extname p) + (subs 1) + keyword)) + +(defn get-files + "Given a graph's root dir, returns a list of all files that it recognizes. + Graph dir must be an absolute path in order for ignoring to work correctly" + [graph-dir] + (->> (readdir graph-dir) + (remove (partial ignored-path? graph-dir)) + (filter #(contains? allowed-formats (get-ext %))))) + +(defn get-default-graphs-dir + "Get default dir for storing graphs by first looking in env var." + [] + (or js/process.env.LOGSEQ_GRAPHS_DIR common-config/default-graphs-dir)) + +(defn expand-home + "Expands path if it starts with '~'" + [path] + (if (and (seq path) (string/starts-with? path "~")) + (node-path/join (os/homedir) (subs path 1)) + path)) + +(defn get-db-graphs-dir + "Returns the directory where DB graphs are stored." + [] + (expand-home (get-default-graphs-dir))) + +(defn get-db-based-graphs + "Returns canonical DB graph repo names from the default DB graph directory." + [] + (let [dir (get-db-graphs-dir)] + (fs/mkdirSync dir #js {:recursive true}) + (->> (read-directories dir) + (remove (fn [s] (= s common-config/unlinked-graphs-dir))) + (map graph-dir/decode-graph-dir-name) + (keep (fn [s] + (when (and (string? s) + (not (string/starts-with? s common-config/file-version-prefix))) + (common-config/canonicalize-db-version-repo s)))) + distinct))) diff --git a/deps/common/src/logseq/common/graph_dir.cljs b/deps/common/src/logseq/common/graph_dir.cljs new file mode 100644 index 0000000..2ca6abf --- /dev/null +++ b/deps/common/src/logseq/common/graph_dir.cljs @@ -0,0 +1,75 @@ +(ns logseq.common.graph-dir + "Platform-agnostic graph directory naming helpers." + (:require [clojure.string :as string] + [logseq.common.config :as common-config])) + +(defn encode-graph-dir-name + [graph-name] + (let [encoded (js/encodeURIComponent (or graph-name ""))] + (-> encoded + (string/replace "%20" " ") + (string/replace "~" "%7E") + (string/replace "%" "~")))) + +(defn decode-graph-dir-name + [dir-name] + (when-not (and (string? dir-name) + (or (string/includes? dir-name "++") + (string/includes? dir-name "+3A+"))) + (when (some? dir-name) + (try + (js/decodeURIComponent (string/replace dir-name "~" "%")) + (catch :default _ + nil))))) + +(def ^:private legacy-dir-pattern #"(?:\+\+|\+3A\+|%)") + +(defn decode-legacy-graph-dir-name + [dir-name] + (when (and (string? dir-name) + (re-find legacy-dir-pattern dir-name)) + (let [compat-name (-> dir-name + (string/replace "+3A+" ":") + (string/replace "++" "/"))] + (try + (let [decoded (js/decodeURIComponent compat-name)] + (when (seq decoded) + decoded)) + (catch :default _ + nil))))) + +(defn repo->graph-dir-key + [repo] + (when (seq repo) + (if (string/starts-with? repo common-config/db-version-prefix) + (subs repo (count common-config/db-version-prefix)) + repo))) + +(defn repo-identity + "Return the canonical value used for repo identity comparison. + + Repo identity comparison is based on the graph directory key, so `demo` and + `logseq_db_demo` identify the same graph. Use this helper, or `same-repo?`, + whenever code needs to decide whether two repo names represent the same graph." + [repo] + (repo->graph-dir-key repo)) + +(defn same-repo? + "Return true when two repo names identify the same graph." + [a b] + (let [a' (repo-identity a) + b' (repo-identity b)] + (and (some? a') + (some? b') + (= a' b')))) + +(defn graph-dir-key->encoded-dir-name + [graph-dir-key] + (when (some? graph-dir-key) + (encode-graph-dir-name graph-dir-key))) + +(defn repo->encoded-graph-dir-name + [repo] + (some-> repo + repo->graph-dir-key + graph-dir-key->encoded-dir-name)) diff --git a/deps/common/src/logseq/common/graph_registry.cljc b/deps/common/src/logseq/common/graph_registry.cljc new file mode 100644 index 0000000..882e00f --- /dev/null +++ b/deps/common/src/logseq/common/graph_registry.cljc @@ -0,0 +1,72 @@ +(ns logseq.common.graph-registry + "Graph registry normalization and lookup helpers." + (:require [clojure.string :as string])) + +(def ^:private db-version-prefix + "logseq_db_") + +(defn- present-string? + [s] + (and (string? s) (not (string/blank? s)))) + +(defn normalize-entry + [entry] + (let [local-graph-id (:local-graph-id entry) + graph-id (or (when (present-string? (:graph-id entry)) (:graph-id entry)) + (when (present-string? local-graph-id) local-graph-id))] + (when-not graph-id + (throw (ex-info "Missing graph identity" + {:entry entry}))) + (-> entry + (assoc :graph-id graph-id) + (dissoc :rtc-graph-id)))) + +(defn ^:api upsert-entry + [registry entry] + (let [entry' (normalize-entry entry)] + (->> (or registry []) + (remove #(or (= (:graph-id entry') (:graph-id %)) + (and (present-string? (:repo entry')) + (= (:repo entry') (:repo %))) + (and (present-string? (:local-graph-id entry')) + (= (:local-graph-id entry') (:local-graph-id %))))) + (cons entry') + vec))) + +(defn- normalize-comparable + [s] + (some-> s str string/lower-case)) + +(defn- canonical-repo + [s] + (when (seq s) + (let [s (str s) + stripped (loop [name' s] + (if (string/starts-with? name' db-version-prefix) + (recur (subs name' (count db-version-prefix))) + name'))] + (str db-version-prefix stripped)))) + +(defn- identifier-match? + [entry graph-identifier] + (let [identifier (normalize-comparable graph-identifier) + repo (normalize-comparable (:repo entry)) + graph-name (normalize-comparable (:graph-name entry)) + graph-id (normalize-comparable (:graph-id entry)) + canonical-repo-name (normalize-comparable (canonical-repo graph-identifier))] + (or (= identifier repo) + (= identifier graph-name) + (= identifier graph-id) + (= canonical-repo-name repo)))) + +(defn ^:api resolve-target + [registry {:keys [graph-id graph-identifier]}] + (cond + (present-string? graph-id) + (some #(when (= graph-id (:graph-id %)) %) registry) + + (present-string? graph-identifier) + (some #(when (identifier-match? % graph-identifier) %) registry) + + :else + nil)) diff --git a/deps/common/src/logseq/common/log.cljs b/deps/common/src/logseq/common/log.cljs new file mode 100644 index 0000000..3e0d6fd --- /dev/null +++ b/deps/common/src/logseq/common/log.cljs @@ -0,0 +1,6 @@ +(ns logseq.common.log + "Minimal, logging ns that shims lambdaisland.glogi fns for nbb. Could port + glogi to nbb later if this shim gets too big") + +(defn error [& msgs] + (apply js/console.error (map clj->js msgs))) diff --git a/deps/common/src/logseq/common/path.cljs b/deps/common/src/logseq/common/path.cljs new file mode 100644 index 0000000..aaa24d3 --- /dev/null +++ b/deps/common/src/logseq/common/path.cljs @@ -0,0 +1,296 @@ +(ns logseq.common.path + "Path manipulation functions, use '/' sep on all platforms. + Also handles URL paths." + (:require [clojure.string :as string])) + +(defn- safe-decode-uri-component + [uri] + (try + (.normalize (js/decodeURIComponent uri) "NFC") + (catch :default _ + (js/console.error "decode-uri-component-failed" uri) + uri))) + +(defn is-file-url? + [s] + (and (string? s) + (or + (string/starts-with? s "memory://") ;; special memory fs + (string/starts-with? s "assets://") ;; Electron asset, urlencoded + (string/starts-with? s "file://") ;; Electron files + ))) + +(defn filename + "File name of a path or URL. + Returns nil when it's a directory that ends with '/'." + [path] + (let [fname (if (string/ends-with? path "/") + nil + (last (string/split path #"/")))] + (if (and (seq fname) (is-file-url? path)) + (safe-decode-uri-component fname) + fname))) + +(defn split-ext + "Split file name into stem and extension, for both path and URL" + [path] + (let [fname (filename path) + pos (string/last-index-of fname ".")] + (if-not (or (nil? pos) (zero? pos)) + [(subs fname 0 pos) + (string/lower-case (subs fname (+ pos 1)))] + [fname ""]))) + +(defn file-stem + "File name without extension" + [path] + (first (split-ext path))) + +(defn file-ext + "File extension, lowercased" + [path] + (second (split-ext path))) + +(defn- path-join-internal + "Joins the given path segments into a single path, handling relative paths, + '..' and '.' normalization." + [& segments] + (let [segments (remove string/blank? segments) ;; handle (path-join nil path) + segments (map #(string/replace % #"[/\\]+" "/") segments) + ;; a fix for clojure.string/split + split-fn (fn [s] + (if (= s "/") + [""] + (string/split s #"/"))) + join-fn (fn [segs] + (case segs + [] "." + [""] "/" + #_{:clj-kondo/ignore [:path-invalid-construct/string-join]} + (string/join "/" segs)))] + (->> (filter seq segments) + (mapcat split-fn) + (reduce (fn [acc segment] + (cond + (= "" segment) + [segment] + + (= ".." segment) + (case (last acc) + ".." (conj acc segment) + "" acc + nil [".."] + (pop acc)) + + (= "." segment) + acc + + :else + (conj acc segment))) + []) + (join-fn)))) + +(defn- uri-path-join-internal + "Joins the given URI path segments into a single path, handling relative paths, + '..' and '.' normalization." + [& segments] + (let [segments (remove nil? segments) ;; handle (path-join nil path) + segments (map #(string/replace % #"[/\\]+" "/") segments) + ;; a fix for clojure.string/split + split-fn (fn [s] + (if (= s "/") + [""] + (string/split s #"/"))) + join-fn (fn [segs] + (case segs + [] "." + [""] "/" + #_{:clj-kondo/ignore [:path-invalid-construct/string-join]} + (string/join "/" segs)))] + (->> (filter seq segments) + (mapcat split-fn) + (map #(js/encodeURIComponent %)) + (reduce (fn [acc segment] + (cond + (= "" segment) + [segment] + + (= ".." segment) + (case (last acc) + ".." (conj acc segment) + "" acc + nil [".."] + (pop acc)) + + (= "." segment) + acc + + :else + (conj acc segment))) + []) + (join-fn)))) + +(defn- preserve-file-url-win-drive + [scheme encoded-path] + (cond-> encoded-path + (= scheme "file:") + (string/replace #"^/([a-zA-Z])%3[Aa](?=/|$)" "/$1:"))) + +(defn url-join + "Segments are not URL-encoded" + [base-url & segments] + (let [custom-scheme (re-find #"^[a-zA-Z0-9_+\-\.]+://" base-url) + custom-scheme? (and custom-scheme (not= custom-scheme "file://")) + base-url (cond-> base-url + custom-scheme? (string/replace-first custom-scheme "file://")) + ^js url (try + (js/URL. (safe-decode-uri-component base-url)) + (catch :default e + (js/console.error "Failed to construct URL in url-join:" base-url e) + (js/URL. "file:///"))) + scheme (if custom-scheme? + (string/replace custom-scheme "//" "") + (.-protocol url)) + path (.-pathname url) + domain (or (not-empty (.-host url)) + (if (or custom-scheme? (string/starts-with? path "/")) "" "/")) + encoded-new-path (->> (apply uri-path-join-internal path segments) + (preserve-file-url-win-drive scheme))] + (str scheme "//" domain encoded-new-path))) + +(defn path-join + "Join path segments, or URL base and path segments" + [base & segments] + (if (is-file-url? base) + (apply url-join base segments) + (let [rejoined-path (apply path-join-internal base segments)] + (if (and (not-empty base) + (string/starts-with? base "//")) ;; Win path fix + (str "/" rejoined-path) + rejoined-path)))) + +(defn prepend-protocol + "Prepend protocol to path. Handle UNC path. aka. path-to-url + + protocol is one of file: http: https: assets:" + [protocol path] + (cond + (string/starts-with? path protocol) + (do + (js/console.error "BUG: should not prepend protocol to path with protocol" protocol path) + path) + + (string/starts-with? path "//") ;; Windows UNC path + (str protocol path) + + :else + (path-join (str protocol "//") path))) + +(defn- path-normalize-internal + "Normalize path using path-join, break into segment and re-join" + [path] + (path-join path)) + +(defn url-normalize + [origin-url] + (let [^js url (try + (js/URL. (safe-decode-uri-component origin-url)) + (catch :default e + (js/console.error "Failed to construct URL in url-normalize:" origin-url e) + nil)) + scheme (when url (.-protocol url)) + domain (when url (or (not-empty (.-host url)) "/")) + path (when url (.-pathname url)) + encoded-new-path (when url + (->> (uri-path-join-internal path) + (preserve-file-url-win-drive scheme)))] + (if url + (str scheme "//" domain encoded-new-path) + origin-url))) + +(defn path-normalize + "Normalize path or URL" + [path] + (-> (if (is-file-url? path) + (url-normalize path) + (path-normalize-internal path)) + (.normalize "NFC"))) + +(defn url-to-path + "Extract path part of a URL, decoded. + + The reverse operation is (path-join protocol:// path)" + [original-url] + (if (is-file-url? original-url) + ;; NOTE: URL type is not consistent across all protocols + ;; Check file:// and assets://, pathname behavior is different + (if-let [^js url (try + (js/URL. (string/replace (safe-decode-uri-component original-url) "assets://" "file://")) + (catch :default e + (js/console.error "Failed to construct URL in url-to-path:" original-url e) + nil))] + (let [path (.-pathname url) + host (.-host url) + path (if (string/starts-with? path "///") + (subs path 2) + path) + path (if (re-find #"(?i)^/[a-zA-Z]:" path) ;; Win path fix + (subs path 1) + path)] + (if (string/blank? host) + path + (str "//" host path))) + original-url) + original-url)) + +(defn file-url-or-path->path + "Converts a file URL to a local filesystem path." + [s] + (if (is-file-url? s) + (url-to-path s) + s)) + +(defn trim-dir-prefix + "Trim dir prefix from path" + [base-path sub-path] + (let [base-path (path-normalize base-path) + sub-path (path-normalize sub-path) + is-url? (is-file-url? base-path)] + (if (string/starts-with? sub-path base-path) + (if is-url? + (safe-decode-uri-component (string/replace (subs sub-path (count base-path)) #"^/+", "")) + (string/replace (subs sub-path (count base-path)) #"^/+", "")) + (do + (js/console.error "unhandled trim-base" base-path sub-path) + nil)))) + +(defn parent + "Parent, containing directory" + [path] + (if (string/includes? path "/") + ;; ugly but works + (path-normalize (str path "/..")) + nil)) + +;; compat +(defn basename + [path] + (let [path (string/replace path #"/+$" "")] + (filename path))) + +(defn absolute? + "Whether path `p` is absolute." + [p] + (let [p (path-normalize p)] + (boolean (or (is-file-url? p) + (string/starts-with? p "/") + ;; is windows dir + (re-find #"^[a-zA-Z]:[/\\]" p))))) + +(defn protocol-url? + "Whether path `p` is a protocol URL. + + This is a loose check, it only checks if there is a valid protocol prefix." + [p] + (boolean (and (re-find #"^[a-zA-Z0-9_+\-\.]{2,}:" p) ;; HACK: avoid matching windows drive + (not (string/includes? p " "))))) diff --git a/deps/common/src/logseq/common/plural.cljs b/deps/common/src/logseq/common/plural.cljs new file mode 100644 index 0000000..38ea17c --- /dev/null +++ b/deps/common/src/logseq/common/plural.cljs @@ -0,0 +1,333 @@ +(ns logseq.common.plural + "ClojureScript port of pluralize.js core (rules + API). + + Usage: + (pluralize \"duck\" 2 true) ;; => \"2 ducks\" + (plural \"person\") ;; => \"people\" + (singular \"people\") ;; => \"person\" + (is-plural? \"ducks\") ;; => true + (is-singular? \"duck\") ;; => true + + You can add rules at runtime: + (add-plural-rule! #\"(ox)$\" \"$1en\") + (add-uncountable-rule! \"metadata\")" + (:require [clojure.string :as string])) + +;; ----------------------------------------------------------------------------- +;; Rule storage (mirrors original semantics) +;; pluralize and singularize must run rules sequentially. +;; ----------------------------------------------------------------------------- + +(defonce ^:private plural-rules (atom [])) ;; vector of [js/RegExp replacement] +(defonce ^:private singular-rules (atom [])) ;; vector of [js/RegExp replacement] +(defonce ^:private uncountables (atom {})) ;; token -> true +(defonce ^:private irregular-plurals (atom {})) ;; plural -> singular +(defonce ^:private irregular-singles (atom {})) ;; singular -> plural + +;; ----------------------------------------------------------------------------- +;; Helpers +;; ----------------------------------------------------------------------------- + +(defn- sanitize-rule + "If rule is a string, compile to case-insensitive regexp that matches the whole string. + Else keep it (assumed to be js/RegExp)." + [rule] + (if (string? rule) + (js/RegExp. (str "^" rule "$") "i") + rule)) + +(defn- restore-case + "Replicate casing of `word` onto `token`." + [word token] + (cond + (= word token) + token + + (= word (string/lower-case word)) + (string/lower-case token) + + (= word (string/upper-case word)) + (string/upper-case token) + + (and (seq word) + (= (subs word 0 1) (string/upper-case (subs word 0 1)))) + (str (string/upper-case (subs token 0 1)) + (string/lower-case (subs token 1))) + + :else + (string/lower-case token))) + +(defn- interpolate + "Replace $1..$12 etc in `s` using JS replace args (match, g1, g2 ...)." + [s js-args] + (.replace s (js/RegExp. "\\$(\\d{1,2})" "g") + (fn [_ idx] + (let [i (js/parseInt idx 10) + v (aget js-args i)] + (or v ""))))) + +(defn- replace-with-rule + "Apply a [re repl] rule to word with casing restoration (matches JS behavior)." + [word [re repl]] + (.replace word re + (fn [& args] + ;; args: [match g1 g2 ... offset string] + (let [match (nth args 0) + ;; In JS replace callback, second-to-last is offset + offset (nth args (- (count args) 2)) + ;; interpolate expects JS-ish indexed args; + ;; easiest is to turn args into a JS array. + js-args (to-array args) + result (interpolate repl js-args)] + (if (= match "") + ;; match empty => restore based on char before match + (restore-case (subs word (dec offset) offset) result) + (restore-case match result)))))) + +(defn- sanitize-word + "Return sanitized `word` based on `token` and `rules`." + [token word rules] + (cond + (or (zero? (count token)) + (contains? @uncountables token)) + word + + :else + (let [rs rules + ;; JS iterates from end to start + n (count rs)] + (loop [i (dec n)] + (if (neg? i) + word + (let [[re _ :as rule] (nth rs i)] + (if (.test re word) + (replace-with-rule word rule) + (recur (dec i))))))))) + +(defn- replace-word-fn + "Build a word transformer (plural or singular)." + [replace-map-atom keep-map-atom rules-atom] + (fn [word] + (let [token (string/lower-case word) + keep-map @keep-map-atom + replace-map @replace-map-atom + rules @rules-atom] + (cond + (contains? keep-map token) + (restore-case word token) + + (contains? replace-map token) + (restore-case word (get replace-map token)) + + :else + (sanitize-word token word rules))))) + +(defn- check-word-fn + "Build a predicate for whether word is plural/singular (mirrors JS `checkWord`)." + [replace-map-atom keep-map-atom rules-atom] + (fn [word] + (let [token (string/lower-case word) + keep-map @keep-map-atom + replace-map @replace-map-atom + rules @rules-atom] + (cond + (contains? keep-map token) true + (contains? replace-map token) false + :else (= (sanitize-word token token rules) token))))) + +;; ----------------------------------------------------------------------------- +;; Public API (matches original surface) +;; ----------------------------------------------------------------------------- + +(def plural (replace-word-fn irregular-singles irregular-plurals plural-rules)) +(def singular (replace-word-fn irregular-plurals irregular-singles singular-rules)) + +(def is-plural? (check-word-fn irregular-singles irregular-plurals plural-rules)) +(def is-singular? (check-word-fn irregular-plurals irregular-singles singular-rules)) + +(defn pluralize + "Pluralize or singularize based on count. If inclusive, prefix with count." + ([word item-count] (pluralize word item-count false)) + ([word item-count inclusive] + (let [pluralized (if (= item-count 1) (singular word) (plural word))] + (str (when inclusive (str item-count " ")) + pluralized)))) + +(defn add-plural-rule! + [rule replacement] + (swap! plural-rules conj [(sanitize-rule rule) replacement])) + +(defn add-singular-rule! + [rule replacement] + (swap! singular-rules conj [(sanitize-rule rule) replacement])) + +(defn add-uncountable-rule! + "If word is string => mark as uncountable. + If regexp => add plural+singular passthrough rules ($0)." + [word] + (if (string? word) + (swap! uncountables assoc (string/lower-case word) true) + (do + (add-plural-rule! word "$0") + (add-singular-rule! word "$0")))) + +(defn add-irregular-rule! + [single plural-word] + (let [p (string/lower-case plural-word) + s (string/lower-case single)] + (swap! irregular-singles assoc s p) + (swap! irregular-plurals assoc p s))) + +;; ----------------------------------------------------------------------------- +;; Data initialization (same as original JS) +;; ----------------------------------------------------------------------------- + +(defn- ^:large-vars/cleanup-todo init-irregulars! [] + (doseq [[s p] + ;; Pronouns + irregulars + [["I" "we"] + ["me" "us"] + ["he" "they"] + ["she" "they"] + ["them" "them"] + ["myself" "ourselves"] + ["yourself" "yourselves"] + ["itself" "themselves"] + ["herself" "themselves"] + ["himself" "themselves"] + ["themself" "themselves"] + ["is" "are"] + ["was" "were"] + ["has" "have"] + ["this" "these"] + ["that" "those"] + ["my" "our"] + ["its" "their"] + ["his" "their"] + ["her" "their"] + ;; Words ending with consonant + o + ["echo" "echoes"] + ["dingo" "dingoes"] + ["volcano" "volcanoes"] + ["tornado" "tornadoes"] + ["torpedo" "torpedoes"] + ;; Ends with us + ["genus" "genera"] + ["viscus" "viscera"] + ;; Ends with ma + ["stigma" "stigmata"] + ["stoma" "stomata"] + ["dogma" "dogmata"] + ["lemma" "lemmata"] + ["schema" "schemata"] + ["anathema" "anathemata"] + ;; Other irregular + ["ox" "oxen"] + ["axe" "axes"] + ["die" "dice"] + ["yes" "yeses"] + ["foot" "feet"] + ["eave" "eaves"] + ["goose" "geese"] + ["tooth" "teeth"] + ["quiz" "quizzes"] + ["human" "humans"] + ["proof" "proofs"] + ["carve" "carves"] + ["valve" "valves"] + ["looey" "looies"] + ["thief" "thieves"] + ["groove" "grooves"] + ["pickaxe" "pickaxes"] + ["passerby" "passersby"] + ["canvas" "canvases"]]] + (add-irregular-rule! s p))) + +(defn- init-plural-rules! [] + (doseq [[rule repl] + [[(js/RegExp. "s?$" "i") "s"] + [(js/RegExp. "[^\\u0000-\\u007F]$" "i") "$0"] + [(js/RegExp. "([^aeiou]ese)$" "i") "$1"] + [(js/RegExp. "(ax|test)is$" "i") "$1es"] + [(js/RegExp. "(alias|[^aou]us|t[lm]as|gas|ris)$" "i") "$1es"] + [(js/RegExp. "(e[mn]u)s?$" "i") "$1s"] + [(js/RegExp. "([^l]ias|[aeiou]las|[ejzr]as|[iu]am)$" "i") "$1"] + [(js/RegExp. "(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$" "i") "$1i"] + [(js/RegExp. "(alumn|alg|vertebr)(?:a|ae)$" "i") "$1ae"] + [(js/RegExp. "(seraph|cherub)(?:im)?$" "i") "$1im"] + [(js/RegExp. "(her|at|gr)o$" "i") "$1oes"] + [(js/RegExp. "(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|automat|quor)(?:a|um)$" "i") "$1a"] + [(js/RegExp. "(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)(?:a|on)$" "i") "$1a"] + [(js/RegExp. "sis$" "i") "ses"] + [(js/RegExp. "(?:(kni|wi|li)fe|(ar|l|ea|eo|oa|hoo)f)$" "i") "$1$2ves"] + [(js/RegExp. "([^aeiouy]|qu)y$" "i") "$1ies"] + [(js/RegExp. "([^ch][ieo][ln])ey$" "i") "$1ies"] + [(js/RegExp. "(x|ch|ss|sh|zz)$" "i") "$1es"] + [(js/RegExp. "(matr|cod|mur|sil|vert|ind|append)(?:ix|ex)$" "i") "$1ices"] + [(js/RegExp. "\\b((?:tit)?m|l)(?:ice|ouse)$" "i") "$1ice"] + [(js/RegExp. "(pe)(?:rson|ople)$" "i") "$1ople"] + [(js/RegExp. "(child)(?:ren)?$" "i") "$1ren"] + [(js/RegExp. "eaux$" "i") "$0"] + [(js/RegExp. "m[ae]n$" "i") "men"] + ["thou" "you"]]] + (add-plural-rule! rule repl))) + +(defn- init-singular-rules! [] + (doseq [[rule repl] + [[(js/RegExp. "s$" "i") ""] + [(js/RegExp. "(ss)$" "i") "$1"] + [(js/RegExp. "(wi|kni|(?:after|half|high|low|mid|non|night|[^\\w]|^)li)ves$" "i") "$1fe"] + [(js/RegExp. "(ar|(?:wo|[ae])l|[eo][ao])ves$" "i") "$1f"] + [(js/RegExp. "ies$" "i") "y"] + [(js/RegExp. "(dg|ss|ois|lk|ok|wn|mb|th|ch|ec|oal|is|ck|ix|sser|ts|wb)ies$" "i") "$1ie"] + [(js/RegExp. "\\b(l|(?:neck|cross|hog|aun)?t|coll|faer|food|gen|goon|group|hipp|junk|vegg|(?:pork)?p|charl|calor|cut)ies$" "i") "$1ie"] + [(js/RegExp. "\\b(mon|smil)ies$" "i") "$1ey"] + [(js/RegExp. "\\b((?:tit)?m|l)ice$" "i") "$1ouse"] + [(js/RegExp. "(seraph|cherub)im$" "i") "$1"] + [(js/RegExp. "(x|ch|ss|sh|zz|tto|go|cho|alias|[^aou]us|t[lm]as|gas|(?:her|at|gr)o|[aeiou]ris)(?:es)?$" "i") "$1"] + [(js/RegExp. "(analy|diagno|parenthe|progno|synop|the|empha|cri|ne)(?:sis|ses)$" "i") "$1sis"] + [(js/RegExp. "(movie|twelve|abuse|e[mn]u)s$" "i") "$1"] + [(js/RegExp. "(test)(?:is|es)$" "i") "$1is"] + [(js/RegExp. "(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$" "i") "$1us"] + [(js/RegExp. "(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|quor)a$" "i") "$1um"] + [(js/RegExp. "(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)a$" "i") "$1on"] + [(js/RegExp. "(alumn|alg|vertebr)ae$" "i") "$1a"] + [(js/RegExp. "(cod|mur|sil|vert|ind)ices$" "i") "$1ex"] + [(js/RegExp. "(matr|append)ices$" "i") "$1ix"] + [(js/RegExp. "(pe)(rson|ople)$" "i") "$1rson"] + [(js/RegExp. "(child)ren$" "i") "$1"] + [(js/RegExp. "(eau)x?$" "i") "$1"] + [(js/RegExp. "men$" "i") "man"]]] + (add-singular-rule! rule repl))) + +(defn- init-uncountables! [] + (doseq [w + ["adulthood" "advice" "agenda" "aid" "aircraft" "alcohol" "ammo" + "analytics" "anime" "athletics" "audio" "bison" "blood" "bream" + "buffalo" "butter" "carp" "cash" "chassis" "chess" "clothing" "cod" + "commerce" "cooperation" "corps" "debris" "diabetes" "digestion" "elk" + "energy" "equipment" "excretion" "expertise" "firmware" "flounder" + "fun" "gallows" "garbage" "graffiti" "hardware" "headquarters" "health" + "herpes" "highjinks" "homework" "housework" "information" "jeans" + "justice" "kudos" "labour" "literature" "machinery" "mackerel" "mail" + "media" "mews" "moose" "music" "mud" "manga" "news" "only" "personnel" + "pike" "plankton" "pliers" "police" "pollution" "premises" "rain" + "research" "rice" "salmon" "scissors" "series" "sewage" "shambles" + "shrimp" "software" "staff" "swine" "tennis" "traffic" + "transportation" "trout" "tuna" "wealth" "welfare" "whiting" + "wildebeest" "wildlife" "you"]] + (add-uncountable-rule! w)) + (doseq [re [(js/RegExp. "pok[eé]mon$" "i") + (js/RegExp. "[^aeiou]ese$" "i") + (js/RegExp. "deer$" "i") + (js/RegExp. "fish$" "i") + (js/RegExp. "measles$" "i") + (js/RegExp. "o[iu]s$" "i") + (js/RegExp. "pox$" "i") + (js/RegExp. "sheep$" "i")]] + (add-uncountable-rule! re))) + +(init-irregulars!) +(init-plural-rules!) +(init-singular-rules!) +(init-uncountables!) diff --git a/deps/common/src/logseq/common/profile.clj b/deps/common/src/logseq/common/profile.clj new file mode 100644 index 0000000..072b3f4 --- /dev/null +++ b/deps/common/src/logseq/common/profile.clj @@ -0,0 +1,18 @@ +(ns logseq.common.profile + "Utils for profiling") + +(defmacro profile-fn! + [f & {:keys [print-on-call? gen-k-fn] + :or {print-on-call? true}}] + `(let [origin-f# ~f + gen-k-fn# (or ~gen-k-fn (constantly (keyword ~f)))] + (set! ~f (fn [& args#] + (let [start# (cljs.core/system-time) + r# (apply origin-f# args#) + end# (cljs.core/system-time) + k# (gen-k-fn# r#)] + (vswap! *key->call-count update k# inc) + (vswap! *key->time-sum update k# #(+ % (- end# start#))) + (when ~print-on-call? + (println "call-count:" (get @*key->call-count k#) "time-sum(ms):" (get @*key->time-sum k#))) + r#))))) diff --git a/deps/common/src/logseq/common/profile.cljs b/deps/common/src/logseq/common/profile.cljs new file mode 100644 index 0000000..484eac8 --- /dev/null +++ b/deps/common/src/logseq/common/profile.cljs @@ -0,0 +1,11 @@ +(ns logseq.common.profile + "Utils for profiling" + (:require-macros [logseq.common.profile])) + +(def *key->call-count + "key -> count" + (volatile! {})) + +(def *key->time-sum + "docstring" + (volatile! {})) diff --git a/deps/common/src/logseq/common/util.cljs b/deps/common/src/logseq/common/util.cljs new file mode 100644 index 0000000..880aa45 --- /dev/null +++ b/deps/common/src/logseq/common/util.cljs @@ -0,0 +1,376 @@ +(ns logseq.common.util + "Util fns shared between the app. Util fns only rely on + clojure standard libraries." + (:require [cljs-time.coerce :as tc] + [cljs-time.core :as t] + [cljs.reader :as reader] + [clojure.edn :as edn] + [clojure.string :as string] + [goog.string :as gstring] + [logseq.common.log :as log])) + +(defn safe-decode-uri-component + [uri] + (try + (js/decodeURIComponent uri) + (catch :default _ + (log/error :decode-uri-component-failed uri) + uri))) + +(defn path-normalize + "Normalize file path (for reading paths from FS, not required by writing) + Keep capitalization sensitivity" + [s] + (.normalize s "NFC")) + +(defn remove-nils-non-nested + "remove pairs of key-value that has nil value from a map (nested not supported)." + [nm] + (into {} (remove (comp nil? second)) nm)) + +(defn fast-remove-nils + "remove pairs of key-value that has nil value from a coll of maps." + [nm] + (keep (fn [m] (if (map? m) (remove-nils-non-nested m) m)) nm)) + +(defn split-first [pattern s] + (when-let [first-index (string/index-of s pattern)] + [(subs s 0 first-index) + (subs s (+ first-index (count pattern)) (count s))])) + +(defn split-last [pattern s] + (when-let [last-index (string/last-index-of s pattern)] + [(subs s 0 last-index) + (subs s (+ last-index (count pattern)) (count s))])) + +(defn tag-valid? + [tag-name] + (when (string? tag-name) + (not (re-find #"[#\t\r\n]+" tag-name)))) + +(defn safe-subs + "Like `subs`, but clamps out-of-range indices and returns \"\" for a non-string + `s` instead of throwing. `subs` is a bare `.substring` passthrough, so a nil + `s` otherwise raises \"Cannot read properties of null\"." + ([s start] + (let [c (count s)] + (safe-subs s start c))) + ([s start end] + (if (string? s) + (let [c (count s)] + (subs s (min c start) (min c end))) + ""))) + +(defn wrapped-by + [v start end] + (and (string? v) (>= (count v) 2) + (= start (first v)) (= end (last v)))) + +(defn wrapped-by-quotes? + [v] + (wrapped-by v "\"" "\"")) + +(defn wrapped-by-parens? + [v] + (wrapped-by v "(" ")")) + +(defn url? + "Test if it is a `protocol://`-style URL. + + NOTE: Can not handle mailto: links, use this with caution." + [s] + (and (string? s) + (try + (not (contains? #{nil "null"} (.-origin (js/URL. s)))) + (catch :default _e + false)))) + +(defn json->clj + [json-string] + (-> json-string + (js/JSON.parse) + (js->clj :keywordize-keys true))) + +(defn zero-pad + [n] + (if (< n 10) + (str "0" n) + (str n))) + +(defn remove-boundary-slashes + [s] + (when (string? s) + (let [s (if (= \/ (first s)) + (subs s 1) + s)] + (if (= \/ (last s)) + (subs s 0 (dec (count s))) + s)))) + +(defn split-namespace-pages + [title] + (let [parts (string/split title "/")] + (->> + (loop [others (rest parts) + result [(first parts)]] + (if (seq others) + (let [prev (last result)] + (recur (rest others) + (conj result (str prev "/" (first others))))) + result)) + (map string/trim)))) + +(def url-encoded-pattern #"(?i)%[0-9a-f]{2}") ;; (?i) for case-insensitive mode + +(defn page-name-sanity + "Sanitize the page-name. Unify different diacritics and other visual differences. + Two objectives: + 1. To be the same as in the filesystem; + 2. To be easier to search" + [page-name] + (some-> page-name + (remove-boundary-slashes) + (path-normalize))) + +(defn page-name-sanity-lc + "Sanitize the query string for a page name (mandate for :block/name)" + [s] + (page-name-sanity (string/lower-case s))) + +(defn safe-page-name-sanity-lc + [s] + (if (string? s) + (page-name-sanity-lc s) s)) + +(defn capitalize-all + [s] + (some->> (string/split s #" ") + (map string/capitalize) + (string/join " "))) + +(defn distinct-by + "Copy from medley" + [f coll] + (let [step (fn step [xs seen] + (lazy-seq + ((fn [[x :as xs] seen] + (when-let [s (seq xs)] + (let [fx (f x)] + (if (contains? seen fx) + (recur (rest s) seen) + (cons x (step (rest s) (conj seen fx))))))) + xs seen)))] + (step (seq coll) #{}))) + +(defn distinct-by-last-wins + [f col] + {:pre [(sequential? col)]} + (reverse (distinct-by f (reverse col)))) + +(defn normalize-format + [format] + (case (keyword format) + :md :markdown + ;; default + (keyword format))) + +(defn path->file-ext + [path-or-file-name] + (let [last-part (last (string/split path-or-file-name #"/"))] + (second (re-find #"(?:\.)(\w+)[^.]*$" last-part)))) + +(defn get-format + "File path to format keyword, :org, :markdown, etc." + [file] + (when file + (normalize-format (keyword (some-> (path->file-ext file) string/lower-case))))) + +(defn get-file-ext + "Copy of frontend.util/get-file-ext. Too basic to couple to main app" + [file] + (and + (string? file) + (string/includes? file ".") + (some-> (path->file-ext file) string/lower-case))) + +(defn valid-edn-keyword? + "Determine if string is a valid edn keyword" + [s] + (try + (boolean (and (= \: (first s)) + (edn/read-string (str "{" s " nil}")))) + (catch :default _ + false))) + +(defn safe-read-string + "Reads an edn string and returns nil if it fails to parse" + ([content] + (safe-read-string {} content)) + ([{:keys [log-error?] :or {log-error? true} :as opts} content] + (try + (reader/read-string (dissoc opts :log-error?) content) + (catch :default e + (when log-error? (log/error :parse/read-string-failed e)) + nil)))) + +(defn safe-read-map-string + "Reads an edn map string and returns {} if it fails to parse" + ([content] + (safe-read-map-string {} content)) + ([opts content] + (try + (reader/read-string opts content) + (catch :default e + (log/error :parse/read-string-failed e) + {})))) + +(defn safe-re-find + {:malli/schema [:=> [:cat :any :string] [:or :nil :string [:vector [:maybe :string]]]]} + [pattern s] + (when-not (string? s) + ;; TODO: sentry + (js/console.trace)) + (when (string? s) + (re-find pattern s))) + +(def uuid-pattern "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +(defonce exactly-uuid-pattern (re-pattern (str "(?i)^" uuid-pattern "$"))) + +(defn uuid-string? + {:malli/schema [:=> [:cat :string] :boolean]} + [s] + (boolean (safe-re-find exactly-uuid-pattern s))) + +(defn format + [fmt & args] + (apply gstring/format fmt args)) + +(defn concat-without-nil + [& cols] + (->> (apply concat cols) + (remove nil?))) + +(defn time-ms + "Current time in milliseconds" + [] + (tc/to-long (t/now))) + +(defn get-page-title + [page] + (or (:block/title page) + (:block/name page))) + +(defn string-join-path + "Replace all `strings/join` used to construct paths with this function to reduce lint output. + https://github.com/logseq/logseq/pull/8679" + [parts] + #_:clj-kondo/ignore + (string/join "/" parts)) + +(def ^:private escape-chars "\\[]{}().+*?|$^") + +(defn escape-regex-chars + "Escapes characters in string `old-value" + [old-value] + (reduce (fn [acc escape-char] + (string/replace acc escape-char (str "\\" escape-char))) + old-value escape-chars)) + +(defn replace-ignore-case + [s old-value new-value] + (string/replace s (re-pattern (str "(?i)" (escape-regex-chars old-value))) new-value)) + +(defn sort-coll-by-dependency + "Sort the elements in the collection based on dependencies. +coll: [{:id 1 :depend-on 2} {:id 2 :depend-on 3} {:id 3}] +get-elem-id-fn: :id +get-elem-dep-id-fn :depend-on +return: [{:id 3} {:id 2 :depend-on 3} {:id 1 :depend-on 2}]" + [get-elem-id-fn get-elem-dep-id-fn coll] + (let [id->elem (into {} (keep (juxt get-elem-id-fn identity)) coll) + id->dep-id (into {} (keep (juxt get-elem-id-fn get-elem-dep-id-fn)) coll) + all-ids (set (keys id->dep-id)) + seen-ids (volatile! #{}) ; to check dep-cycle + sorted-ids + (loop [r [] + rest-ids all-ids + id (first rest-ids)] + (if-not id + r + (if-let [dep-id (id->dep-id id)] + (let [next-id (get rest-ids dep-id)] + (if (and next-id + ;; if found dep-cycle, break it + (not (contains? @seen-ids next-id))) + (do (vswap! seen-ids conj next-id) + (recur r rest-ids next-id)) + (let [rest-ids* (disj rest-ids id)] + (vreset! seen-ids #{}) + (recur (conj r id) rest-ids* (first rest-ids*))))) + ;; not found dep-id, so this id can be put into result now + (let [rest-ids* (disj rest-ids id)] + (vreset! seen-ids #{}) + (recur (conj r id) rest-ids* (first rest-ids*))))))] + (mapv id->elem sorted-ids))) + +(defonce markdown-heading-pattern #"^#+\s+") + +(defn clear-markdown-heading + [content] + {:pre [(string? content)]} + (string/replace-first content markdown-heading-pattern "")) + +(defn block-with-timestamps + "Adds updated-at timestamp and created-at if it doesn't exist" + [block] + (let [updated-at (time-ms) + block (cond-> + (assoc block :block/updated-at updated-at) + (nil? (:block/created-at block)) + (assoc :block/created-at updated-at))] + block)) + +(defn get-timestamp + [value] + (let [now (t/now) + f t/minus] + (if (string? value) + (case value + "1 day ago" + (tc/to-long (f now (t/days 1))) + "3 days ago" + (tc/to-long (f now (t/days 3))) + "1 week ago" + (tc/to-long (f now (t/weeks 1))) + "1 month ago" + (tc/to-long (f now (t/months 1))) + "3 months ago" + (tc/to-long (f now (t/months 3))) + "1 year ago" + (tc/to-long (f now (t/years 1))) + nil) + (tc/to-long (tc/to-date value))))) + +(defn keyword->string + [x] + (if (keyword? x) + (if-let [nn (namespace x)] + (str nn "/" (name x)) + (name x)) + x)) + +(defn by-sorting + [sorting] + (let [get-value+cmp + (map + (fn [{:keys [get-value asc?]}] + [get-value + (if asc? compare #(compare %2 %1))]) + sorting)] + (fn [a b] + (reduce + (fn [order [get-value cmp]] + (if (zero? order) + (cmp (get-value a) (get-value b)) + (reduced order))) + 0 get-value+cmp)))) diff --git a/deps/common/src/logseq/common/util/block_ref.cljs b/deps/common/src/logseq/common/util/block_ref.cljs new file mode 100644 index 0000000..445206e --- /dev/null +++ b/deps/common/src/logseq/common/util/block_ref.cljs @@ -0,0 +1,37 @@ +(ns logseq.common.util.block-ref + "Core vars and util fns for block-refs" + (:require [clojure.string :as string])) + +(def left-parens "Opening characters for block-ref" "((") +(def right-parens "Closing characters for block-ref" "))") +(def left-and-right-parens "Opening and closing characters for block-ref" + (str left-parens right-parens)) +(def block-ref-re #"\(\(([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\)\)") + +(defn get-block-ref-id + "Extracts block id from block-ref using regex" + [s] + (second (re-matches block-ref-re s))) + +(defn get-string-block-ref-id + "Extracts block id from block-ref by stripping parens e.g. ((123)) -> 123. + This is a less strict version of get-block-ref-id" + [s] + (subs s 2 (- (count s) 2))) + +(defn block-ref? + "Determines if string is block ref using regex" + [s] + (boolean (get-block-ref-id s))) + +(defn string-block-ref? + "Determines if string is block ref by checking parens. This is less strict version +of block-ref?" + [s] + (and (string/starts-with? s left-parens) + (string/ends-with? s right-parens))) + +(defn ->block-ref + "Creates block ref string given id" + [block-id] + (str left-parens block-id right-parens)) diff --git a/deps/common/src/logseq/common/util/date_time.cljs b/deps/common/src/logseq/common/util/date_time.cljs new file mode 100644 index 0000000..6095b14 --- /dev/null +++ b/deps/common/src/logseq/common/util/date_time.cljs @@ -0,0 +1,110 @@ +(ns logseq.common.util.date-time + "cljs-time util fns for deps" + (:require [cljs-time.coerce :as tc] + [cljs-time.core :as t] + [cljs-time.format :as tf] + [clojure.string :as string] + [logseq.common.util :as common-util])) + +(def ^:private yyyyMMdd-formatter (tf/formatter "yyyyMMdd")) +(def ^:api default-journal-title-formatter "MMM do, yyyy") + +;; (tf/parse (tf/formatter "dd.MM.yyyy") "2021Q4") => 20040120T000000 +(defn safe-journal-title-formatters + [date-formatter] + (->> [date-formatter default-journal-title-formatter "yyyy-MM-dd" "yyyy_MM_dd"] + (remove string/blank?) + distinct)) + +(defn journal-title-> + [journal-title then-fn formatters] + (when-not (string/blank? journal-title) + (when-let [time' (->> (map + (fn [formatter] + (try + (tf/parse (tf/formatter formatter) (common-util/capitalize-all journal-title)) + (catch :default _e + nil))) + formatters) + (filter some?) + first)] + (then-fn time')))) + +(defn journal-title->int + [journal-title formatters] + (when journal-title + (let [journal-title (common-util/capitalize-all journal-title)] + (journal-title-> journal-title + #(parse-long (tf/unparse yyyyMMdd-formatter %)) + formatters)))) + +(defn format + [date date-formatter] + (when date-formatter + (tf/unparse (tf/formatter date-formatter) date))) + +(defn int->local-date + [day] + (let [s (str day) + year (js/parseInt (subs s 0 4)) + month (dec (js/parseInt (subs s 4 6))) + day (js/parseInt (subs s 6))] + (js/Date. year month day))) + +(defn int->journal-title + [day date-formatter] + (when day + (format (tf/parse yyyyMMdd-formatter (str day)) date-formatter))) + +(defn- get-weekday + [date] + (.toLocaleString date "en-us" (clj->js {:weekday "long"}))) + +(defn- get-date + ([] + (get-date (js/Date.))) + ([date] + {:year (.getFullYear date) + :month (inc (.getMonth date)) + :day (.getDate date) + :weekday (get-weekday date)})) + +(defn year-month-day-padded + ([] + (year-month-day-padded (get-date))) + ([date] + (let [{:keys [year month day]} date] + {:year year + :month (common-util/zero-pad month) + :day (common-util/zero-pad day)}))) + +(defn ymd + ([] + (ymd (js/Date.))) + ([date] + (ymd date "/")) + ([date sep] + (let [{:keys [year month day]} (year-month-day-padded (get-date date))] + (str year sep month sep day)))) + +(defn date->int + "Given a date object, returns its journal page integer" + [date] + (parse-long + (string/replace (ymd date) "/" ""))) + +(defn journal-day->ms + "Converts a journal's :block/journal-day integer into milliseconds" + [day] + (when day + (-> (tf/parse yyyyMMdd-formatter (str day)) + (tc/to-long)))) + +(defn ms->journal-day + "Converts a milliseconds timestamp to the nearest :block/journal-day" + [ms] + (some->> ms + tc/from-long + t/to-default-time-zone + (tf/unparse yyyyMMdd-formatter) + parse-long)) diff --git a/deps/common/src/logseq/common/util/macro.cljs b/deps/common/src/logseq/common/util/macro.cljs new file mode 100644 index 0000000..703cc36 --- /dev/null +++ b/deps/common/src/logseq/common/util/macro.cljs @@ -0,0 +1,38 @@ +(ns logseq.common.util.macro + "Core vars and util fns for built-in macros e.g. {{query }} and user macros e.g. {{foo}}" + (:require [clojure.string :as string])) + +(def left-braces "Opening characters for macro" "{{") +(def right-braces "Closing characters for macro" "}}") + +(defn macro? + [*s] + (when-let [s (and (string? *s) (string/trim *s))] + (and (string/starts-with? s left-braces) (string/ends-with? s right-braces)))) + +(defn macro-subs + [macro-content arguments] + (loop [s macro-content + args arguments + n 1] + (if (seq args) + (recur + (string/replace s (str "$" n) (first args)) + (rest args) + (inc n)) + s))) + +(defn- macro-expand-value + "Checks a string for a macro and expands it if there's a macro entry for it. + This is a slimmer version of macro-else-cp" + [value macros] + (if-let [[_ macro args] (and (string? value) + (seq (re-matches #"\{\{(\S+)\s+(.*)\}\}" value)))] + (if-let [content (get macros macro)] + (macro-subs content (string/split args #"\s+")) + value) + value)) + +(defn expand-value-if-macro + [s macros] + (if (macro? s) (macro-expand-value s macros) s)) \ No newline at end of file diff --git a/deps/common/src/logseq/common/util/namespace.cljs b/deps/common/src/logseq/common/util/namespace.cljs new file mode 100644 index 0000000..e398df3 --- /dev/null +++ b/deps/common/src/logseq/common/util/namespace.cljs @@ -0,0 +1,27 @@ +(ns logseq.common.util.namespace + "Util fns for namespace and parent features" + (:require [clojure.string :as string] + [logseq.common.util :as common-util])) + +;; Only used by DB graphs +(defonce parent-char "/") +(defonce parent-re #"/") +;; Used by DB and file graphs +(defonce namespace-char "/") + +(defn namespace-page? + "Used by DB and file graphs" + [page-name] + (and (string? page-name) + (string/includes? page-name namespace-char) + (not= (string/trim page-name) namespace-char) + (not (string/starts-with? page-name "../")) + (not (string/starts-with? page-name "./")) + (not (common-util/url? page-name)))) + +(defn get-last-part + "Get last part of a namespace page" + [page-name] + (if (namespace-page? page-name) + (last (string/split page-name parent-char)) + page-name)) diff --git a/deps/common/src/logseq/common/util/page_ref.cljs b/deps/common/src/logseq/common/util/page_ref.cljs new file mode 100644 index 0000000..0e38aca --- /dev/null +++ b/deps/common/src/logseq/common/util/page_ref.cljs @@ -0,0 +1,53 @@ +(ns logseq.common.util.page-ref + "Core vars and util fns for page-ref. Currently this only handles a logseq + page-ref e.g. [[page name]]" + (:require ["path" :as node-path] + [clojure.string :as string])) + +(def left-brackets "Opening characters for page-ref" "[[") +(def right-brackets "Closing characters for page-ref" "]]") +(def left-and-right-brackets "Opening and closing characters for page-ref" + (str left-brackets right-brackets)) + +;; common regular expressions +(def page-ref-re "Inner capture and doesn't match nested brackets" #"\[\[(.*?)\]\]") +(def page-ref-without-nested-re "Matches most inner nested brackets" #"\[\[([^\[\]]+)\]\]") +(def page-ref-any-re "Inner capture that matches anything between brackets" #"\[\[(.*)\]\]") +(def markdown-page-ref-re #"\[(.*)\]\(file:.*\)") + +(defn get-file-basename + "Returns the basename of a file path. e.g. /a/b/c.md -> c.md" + [path] + (when-not (string/blank? path) + (.-base (node-path/parse (string/replace path "+" "/"))))) + +(defn page-ref? + "Determines if string is page-ref. Avoid using with format-specific page-refs e.g. org" + [s] + (and (string/starts-with? s left-brackets) + (string/ends-with? s right-brackets))) + +(defn ->page-ref + "Create a page ref given a page name" + [page-name] + (str left-brackets page-name right-brackets)) + +(defn get-page-name + "Extracts page names from md page-refs or logseq page-refs. Only call in + contexts where md page-refs are used" + [s] + (and (string? s) + (or (when-let [[_ label _path] (re-matches markdown-page-ref-re s)] + (string/trim label)) + (-> (re-matches page-ref-any-re s) + second)))) + +(defn get-page-name! + "Extracts page-name from page-ref and fall back to arg. Useful for when user + input may (not) be a page-ref" + [s] + (or (get-page-name s) s)) + +(defn page-ref-un-brackets! + [s] + (or (get-page-name s) s)) diff --git a/deps/common/src/logseq/common/uuid.cljs b/deps/common/src/logseq/common/uuid.cljs new file mode 100644 index 0000000..f818676 --- /dev/null +++ b/deps/common/src/logseq/common/uuid.cljs @@ -0,0 +1,66 @@ +(ns logseq.common.uuid + "uuid generators" + (:require [datascript.core :as d])) + +(defn- gen-journal-page-uuid + "00000001-2024-0620-0000-000000000000 +first 8 chars as type, currently only '00000001' for journal-day-page. +the remaining chars for data of this type" + [journal-day] + {:pre [(pos-int? journal-day) + (> 1 (/ journal-day 100000000))]} + (let [journal-day-str (str journal-day) + part1 (subs journal-day-str 0 4) + part2 (subs journal-day-str 4 8)] + (uuid (str "00000001" "-" part1 "-" part2 "-0000-000000000000")))) + +(defn- fill-with-0 + [s length] + (let [s-length (count s)] + (apply str s (repeat (- length s-length) "0")))) + +(defn- gen-block-uuid + "prefix--" + [k prefix] + (let [hash-num (str (Math/abs (hash k))) + part1 (fill-with-0 (subs hash-num 0 4) 4) + part2 (fill-with-0 (subs hash-num 4 8) 4) + part3 (fill-with-0 (subs hash-num 8 12) 4) + part4 (fill-with-0 (subs hash-num 12) 12)] + (uuid (str prefix "-" part1 "-" part2 "-" part3 "-" part4)))) + +(defn- gen-db-ident-block-uuid + "00000002--" + [db-ident] + {:pre [(keyword? db-ident)]} + (gen-block-uuid db-ident "00000002")) + +(defn gen-uuid + "supported type: + - :journal-page-uuid, 00000001 + - :db-ident-block-uuid, 00000002 + - :migrate-new-block-uuid, 00000003 + - :builtin-block-uuid, 00000004 + - :view-block-uuid, 00000006" + ([] (d/squuid)) + ([type' v] + (assert (some? v)) + (case type' + :journal-page-uuid (gen-journal-page-uuid v) + :db-ident-block-uuid (gen-db-ident-block-uuid v) + :migrate-new-block-uuid (gen-block-uuid v "00000003") + :builtin-block-uuid (gen-block-uuid v "00000004") + :view-block-uuid (gen-block-uuid v "00000006")))) + +(defn gen-journal-template-block + "Persistent uuid for journal template block" + [journal-uuid template-block-uuid] + (assert (uuid? journal-uuid) (str journal-uuid)) + (assert (uuid? template-block-uuid) (str template-block-uuid)) + (uuid + (str "00000005" + "-" + ;; journal day + (subs (str journal-uuid) 9 23) + ;; template block uuid + (subs (str template-block-uuid) 23)))) diff --git a/deps/common/src/logseq/common/version.cljs b/deps/common/src/logseq/common/version.cljs new file mode 100644 index 0000000..0509f6d --- /dev/null +++ b/deps/common/src/logseq/common/version.cljs @@ -0,0 +1,18 @@ +(ns logseq.common.version + "Version metadata helpers for build time and revision strings.") + +(goog-define BUILD_TIME "unknown") +(goog-define REVISION "dev") + +(defn build-time + [] + BUILD_TIME) + +(defn revision + [] + REVISION) + +(defn format-version + [] + (str "Build time: " (build-time) "\n" + "Revision: " (revision))) diff --git a/deps/common/test/logseq/common/config_test.cljs b/deps/common/test/logseq/common/config_test.cljs new file mode 100644 index 0000000..caa443e --- /dev/null +++ b/deps/common/test/logseq/common/config_test.cljs @@ -0,0 +1,34 @@ +(ns logseq.common.config-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.common.config :as common-config])) + +(deftest remove-hidden-files + (let [files ["pages/foo.md" "pages/bar.md" + "script/README.md" "script/config.edn" + "dev/README.md" "dev/config.edn"]] + (is (= ["pages/foo.md" "pages/bar.md"] + (common-config/remove-hidden-files + files + {:hidden ["script" "/dev"]} + identity)) + "Removes hidden relative files") + + (is (= ["/pages/foo.md" "/pages/bar.md"] + (common-config/remove-hidden-files + (map #(str "/" %) files) + {:hidden ["script" "/dev"]} + identity)) + "Removes hidden files if they start with '/'"))) + +(deftest local-relative-asset? + (testing "local-relative-asset?" + (is (common-config/local-relative-asset? "assets/test.png")) + (is (common-config/local-relative-asset? "../assets/test.png")) + (is (common-config/local-relative-asset? "./assets/test.png")) + (is (not (common-config/local-relative-asset? "assets://"))) + (is (not (common-config/local-relative-asset? "assets://test.png"))) + (is (not (common-config/local-relative-asset? "http://assets/test.png"))) + (is (not (common-config/local-relative-asset? "file://assets/test.png"))) + (is (not (common-config/local-relative-asset? nil)))) + (testing "Windows backslash paths" + (is (common-config/local-relative-asset? "assets\\test.png")))) diff --git a/deps/common/test/logseq/common/graph_test.cljs b/deps/common/test/logseq/common/graph_test.cljs new file mode 100644 index 0000000..b4380fb --- /dev/null +++ b/deps/common/test/logseq/common/graph_test.cljs @@ -0,0 +1,50 @@ +(ns logseq.common.graph-test + (:require [logseq.common.graph :as common-graph] + [cljs.test :refer [deftest is use-fixtures async]] + ["fs" :as fs] + ["path" :as node-path])) + +(use-fixtures + :each + ;; Cleaning tmp/ before leaves last tmp/ after a test run for dev and debugging + {:before + #(async done + (if (fs/existsSync "tmp") + (fs/rm "tmp" #js {:recursive true} (fn [err] + (when err (js/console.log err)) + (done))) + (done)))}) + +(defn- create-logseq-graph + "Creates a minimal mock graph" + [dir] + (fs/mkdirSync (node-path/join dir "logseq") #js {:recursive true}) + (fs/mkdirSync (node-path/join dir "journals")) + (fs/mkdirSync (node-path/join dir "pages"))) + +(deftest get-files + (create-logseq-graph "tmp/test-graph") + ;; Create files that are recognized + (fs/writeFileSync "tmp/test-graph/pages/foo.md" "") + (fs/writeFileSync "tmp/test-graph/journals/2023_05_09.md" "") + (fs/mkdirSync (node-path/join "tmp/test-graph" "mirror" "markdown-notes") #js {:recursive true}) + (fs/mkdirSync (node-path/join "tmp/test-graph" "mirror" "markdown2") #js {:recursive true}) + (fs/writeFileSync "tmp/test-graph/mirror/markdown-notes/foo.md" "") + (fs/writeFileSync "tmp/test-graph/mirror/markdown2/foo.md" "") + ;; Create files that are ignored + (fs/mkdirSync (node-path/join "tmp/test-graph" "logseq" "bak")) + (fs/mkdirSync (node-path/join "tmp/test-graph" "mirror" "markdown" "pages") #js {:recursive true}) + (fs/writeFileSync "tmp/test-graph/logseq/bak/baz.md" "") + (fs/writeFileSync "tmp/test-graph/logseq/.gitignore" "") + (fs/writeFileSync "tmp/test-graph/mirror/markdown/pages/foo.md" "") + (is (= ["tmp/test-graph/journals/2023_05_09.md" + "tmp/test-graph/mirror/markdown-notes/foo.md" + "tmp/test-graph/mirror/markdown2/foo.md" + "tmp/test-graph/pages/foo.md"] + (common-graph/get-files "tmp/test-graph")))) + +(deftest ignored-markdown-mirror-path-honors-directory-boundary-test + (is (common-graph/ignored-path? "tmp/test-graph" "tmp/test-graph/mirror/markdown")) + (is (common-graph/ignored-path? "tmp/test-graph" "tmp/test-graph/mirror/markdown/pages/foo.md")) + (is (not (common-graph/ignored-path? "tmp/test-graph" "tmp/test-graph/mirror/markdown-notes/foo.md"))) + (is (not (common-graph/ignored-path? "tmp/test-graph" "tmp/test-graph/mirror/markdown2/foo.md")))) diff --git a/deps/common/test/logseq/common/path_test.cljs b/deps/common/test/logseq/common/path_test.cljs new file mode 100644 index 0000000..0519c0a --- /dev/null +++ b/deps/common/test/logseq/common/path_test.cljs @@ -0,0 +1,73 @@ +(ns logseq.common.path-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.common.path :as path])) + +(deftest filename + (is (= nil (path/filename "/path/to/dir/"))) + (is (= "file-name" (path/filename "/path/to/dir/file-name"))) + (is (= "file-name" (path/filename "dir/file-name")))) + +(deftest split-ext + (is (= ["some-song" "mp3"] (path/split-ext "some-song.MP3"))) + (is (= ["some-song" ""] (path/split-ext "some-song"))) + (is (= ["some-file.edn" "txt"] (path/split-ext "some-file.edn.txt")))) + +(deftest path-join + (testing "path-join" + (is (= "foo/bar" (path/path-join "foo" "bar"))) + (is (= "foo/bar" (path/path-join "foo/" "bar"))) + (is (= "foo/bar" (path/path-join nil "foo" "bar")) + "global dir") + (is (= "/foo/bar/baz/asdf" (path/path-join "/foo/bar//baz/asdf/quux/.."))) + (is (= "assets:///foo.bar/baz" (path/path-join "assets:///foo.bar" "baz"))) + (is (= "assets:///foo.bar/baz" (path/path-join "assets:///foo.bar/" "baz"))) + (is (= "file:///D:/a.txt" (path/path-join "file://" "D:/a.txt"))) + (is (= "file:///c:/x" (path/path-join "file://" "c:/x"))) + (is (= "//NAS/MyGraph/logseq/config.edn" (path/path-join "//NAS/MyGraph" "logseq/config.edn"))))) + +(deftest prepend-protocol + (testing "prepend-protocol" + (is (= "file:///home/logseq/graph" (path/prepend-protocol "file:" "/home/logseq/graph"))) + (is (= "file:///C:/Graph/pages" (path/prepend-protocol "file:" "C:/Graph/pages"))) + (is (= "file://NAS/MyGraph" (path/prepend-protocol "file:" "//NAS/MyGraph")) + "Windows UNC URL"))) + +(deftest file-url-or-path->path + (testing "file URL paths" + (is (= "D:/a.txt" (path/file-url-or-path->path "file:///D:/a.txt"))) + (is (= "c:/x" (path/file-url-or-path->path "file:///c:/x"))) + (is (= "//nas/share/x" (path/file-url-or-path->path "file://NAS/share/x"))) + (is (= "D:/a.txt" (path/file-url-or-path->path "file:///D%3A/a.txt"))) + (is (= "/home/admin/a.txt" (path/file-url-or-path->path "file:///home/admin/a.txt"))) + (is (= "/D:/a.txt" (path/file-url-or-path->path "/D:/a.txt"))) + (is (= "/D:\\a.txt" (path/file-url-or-path->path "/D:\\a.txt"))) + (is (= "/home/admin/a.txt" (path/file-url-or-path->path "/home/admin/a.txt"))))) + +(deftest path-absolute + (testing "absolute" + (is (true? (path/absolute? "D:\\sources\\sources.md"))) + (is (true? (path/absolute? "/home/xxx/logseq/test.md"))) + (is (false? (path/absolute? "logseq/test.md"))) + (is (false? (path/absolute? "test.md"))) + (is (false? (path/absolute? "test"))) + (is (false? (path/absolute? "D:test.md"))))) + +(deftest protocol-url + (testing "protocol url" + (is (true? (path/protocol-url? "mailto:help@logseq.com"))) + (is (true? (path/protocol-url? "https://logseq.com"))) + (is (true? (path/protocol-url? "ftp://logseq.com"))) + (is (true? (path/protocol-url? "file:///home/xxx/logseq/test.md"))) + (is (true? (path/protocol-url? "assets:///home/xxx/logseq/test.md"))) + (is (false? (path/protocol-url? "logseq/test.md"))) + (is (false? (path/protocol-url? "test.md"))) + (is (false? (path/protocol-url? "test"))) + (is (false? (path/protocol-url? "D:test.md"))))) + +(deftest invalid-or-bare-protocol-urls + (testing "invalid or bare protocol URLs do not crash" + (is (string? (path/url-to-path "assets://"))) + (is (string? (path/url-to-path "file://"))) + (is (= "assets:///foo" (path/path-join "assets://" "foo"))) + (is (string? (path/path-join "file://" "assets://"))) + (is (string? (path/url-normalize "assets://"))))) diff --git a/deps/common/test/logseq/common/util/page_ref_test.cljs b/deps/common/test/logseq/common/util/page_ref_test.cljs new file mode 100644 index 0000000..827e446 --- /dev/null +++ b/deps/common/test/logseq/common/util/page_ref_test.cljs @@ -0,0 +1,11 @@ +(ns logseq.common.util.page-ref-test + (:require [logseq.common.util.page-ref :as page-ref] + [cljs.test :refer [are deftest]])) + +(deftest page-ref? + (are [x y] (= (page-ref/page-ref? x) y) + "[[page]]" true + "[[another page]]" true + "[[some [[nested]] page]]" true + "[single bracket]" false + "no brackets" false)) diff --git a/deps/common/test/logseq/common/util_test.cljs b/deps/common/test/logseq/common/util_test.cljs new file mode 100644 index 0000000..4321655 --- /dev/null +++ b/deps/common/test/logseq/common/util_test.cljs @@ -0,0 +1,68 @@ +(ns logseq.common.util-test + (:require [clojure.test :refer [deftest are testing]] + [logseq.common.util :as common-util])) + +(deftest valid-edn-keyword? + (are [x y] + (= (common-util/valid-edn-keyword? x) y) + + ":foo-bar" true + ":foo!" true + ":foo,bar" false + "4" false + "foo bar" false + "`property" false)) + +(deftest extract-file-extension? + (are [x y] + (= (common-util/path->file-ext x) y) + "foo.bar" "bar" + "foo" nil + "foo.bar.baz" "baz" + "../assets/audio.mp3" "mp3" + ;; From https://www.w3.org/TR/media-frags/ + "../assets/audio.mp3?t=10,20" "mp3" + "../assets/audio.mp3?t=10,20#t=10" "mp3" + "/root/Documents/audio.mp3" "mp3" + "C:\\Users\\foo\\Documents\\audio.mp3" "mp3" + "/root/Documents/audio" nil + "/root/Documents/audio." nil + "special/characters/aäääöüß.7z" "7z" + "asldk lakls .lsad" "lsad" + "中文asldk lakls .lsad" "lsad")) + +(deftest url? + (are [x y] + (= (common-util/url? x) y) + "http://logseq.com" true + "prop:: value" false + "a:" false)) + +(deftest escape-regex-chars + (testing "ensure the result is a valid regex string" + (are [x] + (some? (re-find (re-pattern (common-util/escape-regex-chars x)) x)) + "[[page-name]]" + "end-with-backslash\\" + "\\[]{}().+*?|$^"))) + +(deftest safe-subs + (testing "behaves like subs for in-range indices" + (are [args y] + (= (apply common-util/safe-subs args) y) + ["hello" 1 3] "el" + ["hello" 2] "llo" + ["hello" 0 5] "hello")) + (testing "clamps out-of-range indices instead of throwing" + (are [args y] + (= (apply common-util/safe-subs args) y) + ["hello" 0 99] "hello" + ["hello" 99 99] "" + ["hello" 99] "")) + (testing "returns \"\" for a non-string instead of throwing" + (are [args y] + (= (apply common-util/safe-subs args) y) + [nil 0 0] "" + [nil 0 5] "" + [nil 0] "" + [42 0 1] ""))) diff --git a/deps/db-sync/.carve/config.edn b/deps/db-sync/.carve/config.edn new file mode 100644 index 0000000..fb2b765 --- /dev/null +++ b/deps/db-sync/.carve/config.edn @@ -0,0 +1,5 @@ +{:paths ["src"] + :api-namespaces [;; CLI entry points + logseq.db-sync.node.find-checksum-drift + logseq.db-sync.node.recompute-log-checksum] + :report {:format :ignore}} diff --git a/deps/db-sync/.carve/ignore b/deps/db-sync/.carve/ignore new file mode 100644 index 0000000..f234f1b --- /dev/null +++ b/deps/db-sync/.carve/ignore @@ -0,0 +1,38 @@ +;; API +logseq.db-sync.cycle/fix-cycle! +;; API +logseq.db-sync.node.entry/main +;; API (CLI entrypoint) +logseq.db-sync.node.show-checksum/main +;; API +logseq.db-sync.order/fix-duplicate-orders! +;; TODO: is this ns needed? +logseq.db-sync.platform.cloudflare/response +logseq.db-sync.platform.cloudflare/request +logseq.db-sync.platform.cloudflare/request-url +;; testing +logseq.db-sync.protocol/tx->transit +;; debugging +logseq.db-sync.protocol/datoms->wire +;; debugging +logseq.db-sync.protocol/datoms->wire +;; debugging +logseq.db-sync.sentry.node/capture-exception! +;; API +logseq.db-sync.snapshot/framed-length +logseq.db-sync.snapshot/finalize-datoms-jsonl-buffer +;; API +logseq.db-sync.worker/worker +;; debugging +logseq.db-sync.worker.timing/summary +;; API +logseq.db-sync.snapshot/finalize-datoms-jsonl-buffer +;; API +logseq.db-sync.checksum/recompute-checksum-diagnostics +;; API (test runner entrypoint) +logseq.db-sync.test-runner/main +;; Disabled FIXME tests in node_adapter_test.cljs +logseq.db-sync.node-adapter-test/post-json +logseq.db-sync.node-adapter-test/get-json +logseq.db-sync.node-adapter-test/parse-json +logseq.db-sync.node-adapter-test/start-test-server diff --git a/deps/db-sync/.clj-condo/config.edn b/deps/db-sync/.clj-condo/config.edn new file mode 100644 index 0000000..6ad5508 --- /dev/null +++ b/deps/db-sync/.clj-condo/config.edn @@ -0,0 +1,28 @@ +{:linters + {:aliased-namespace-symbol {:level :warning} + :namespace-name-mismatch {:level :warning} + :used-underscored-binding {:level :warning} + :shadowed-var {:level :warning + :exclude [meta name key keys uuid type]} + + :consistent-alias + {:aliases {clojure.pprint pprint + clojure.string string + datascript.core d + datascript.transit dt + logseq.db-sync.common common + logseq.db-sync.config config + logseq.db-sync.node.routes node-routes + logseq.db-sync.platform.core platform + logseq.db-sync.protocol protocol + logseq.db-sync.storage storage + logseq.db-sync.worker.auth auth + logseq.db-sync.worker.handler.sync sync-handler + logseq.db-sync.worker.handler.ws ws-handler + logseq.db-sync.worker.http http + logseq.db-sync.worker.routes.index routes + logseq.db-sync.worker.ws ws}}} + :lint-as {promesa.core/let clojure.core/let + shadow.cljs.modern/defclass clj-kondo.lint-as/def-catch-all} + :skip-comments true + :output {:progress true}} \ No newline at end of file diff --git a/deps/db-sync/README.md b/deps/db-sync/README.md new file mode 100644 index 0000000..4ce31e8 --- /dev/null +++ b/deps/db-sync/README.md @@ -0,0 +1,151 @@ +# Logseq DB Sync (deps/db-sync) + +This package contains the DB sync server code and tests used by Logseq. +It includes the Cloudflare Worker implementation and a Node.js adapter for self-hosting. + +## Requirements +- Node.js (see repo root for required version) +- Clojure (for shadow-cljs builds) + +## Build and Test + +### Cloudflare Worker + +```bash +cd deps/db-sync +pnpm watch + +# open another terminal +cd deps/db-sync/worker +wrangler dev +``` + +### D1 Schema (Worker) + +The worker no longer initializes schema at request time. Apply the D1 schema +via migrations during deployment/CI. + +```bash +cd deps/db-sync/worker +wrangler d1 migrations apply logseq-sync-graph-meta-staging --env staging +wrangler d1 migrations apply logseq-sync-graphs-prod --env prod +``` + +For local development, run `wrangler d1 migrations apply DB --local`. + +### Production Graph Lookup + +Show the graphs available to a production user by `username` or `user id`: + +```bash +cd deps/db-sync +pnpm show-graphs-for-user --username alice +pnpm show-graphs-for-user --user-id us-east-1:example-user-id +``` + +The script uses `worker/wrangler.toml`, runs against the remote D1 binding `DB`, +defaults to `--env prod`, and prints JSON when `--json` is added. + +Show usage totals (total users, total graphs, users created today, graphs created today): + +```bash +cd deps/db-sync +pnpm show-usage-stats +pnpm show-usage-stats --days 7 +pnpm show-usage-stats --json +``` + +`created today` uses UTC day boundaries from D1 (`date('now')`). +`active_*_last_n_days` uses deduplicated UTC-day activity rows from +`daily_active_entities` with the provided `--days` window. + +Download a graph snapshot into a local sqlite debug file matching local graph DB schema (`kvs` table only): + +```bash +cd deps/db-sync +pnpm run download-graph-db -- --graph-id 6f2d7f6f-xxxx-xxxx-xxxx-xxxxxxxxxxxx --admin-token +``` + +You can also pass `--admin-token ` or set `DB_SYNC_ADMIN_TOKEN`. The output defaults to +`tmp/graph-.snapshot.sqlite` and can be changed with `--output`. + +Show stored and recomputed checksum for a local sqlite graph db: + +```bash +cd deps/db-sync +pnpm run show-sqlite-checksum -- --db ~/Downloads/test.sqlite +``` + +Delete the graphs owned by a production user after an explicit confirmation: + +```bash +cd deps/db-sync +pnpm delete-graphs-for-user --username alice +pnpm delete-graphs-for-user --user-id us-east-1:example-user-id +``` + +The delete script shows the owned graphs first and requires typing `DELETE` +before it calls the worker delete endpoint for each graph. Set +`DB_SYNC_BASE_URL` and `DB_SYNC_ADMIN_TOKEN` or pass `--base-url` and +`--admin-token` when running it. + +Delete a user completely (owned graphs, memberships, keys, and user row): + +```bash +cd deps/db-sync +pnpm run delete-user-totally -- --username alice +pnpm run delete-user-totally -- --user-id us-east-1:example-user-id +``` + +The script prints all linked graphs first, deletes owned graphs through the +admin graph delete endpoint, then removes the user's remaining D1 references. +It requires typing `DELETE` as confirmation. + +### Node.js Adapter (self-hosted) + +Build the adapter: + +```bash +cd deps/db-sync +pnpm build:node-adapter +``` + +Run the adapter with Cognito auth: + +```bash +DB_SYNC_PORT=8787 \ +COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8 \ +COGNITO_CLIENT_ID=69cs1lgme7p8kbgld8n5kseii6 \ +COGNITO_JWKS_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json \ +node worker/dist/node-adapter.js +``` + +### Tests + +Run db-sync tests (includes Node adapter tests): + +```bash +cd deps/db-sync +pnpm test:node-adapter +``` + +## Environment Variables + +| Variable | Purpose | +| --- | --- | +| DB_SYNC_PORT | HTTP server port | +| DB_SYNC_BASE_URL | External base URL for asset links | +| DB_SYNC_ADMIN_TOKEN | Admin-only token for operator graph deletion endpoints | +| DB_SYNC_DATA_DIR | Data directory for sqlite + assets | +| DB_SYNC_STORAGE_DRIVER | Storage backend selection (sqlite) | +| DB_SYNC_ASSETS_DRIVER | Assets backend selection (filesystem) | +| SENTRY_DSN | Sentry DSN | +| SENTRY_RELEASE | Release identifier for Sentry events and sourcemaps | +| SENTRY_ENVIRONMENT | Sentry environment name (prod, staging, etc.) | +| SENTRY_TRACES_SAMPLE_RATE | Traces sample rate (0.0 - 1.0) | +| COGNITO_ISSUER | Cognito issuer URL | +| COGNITO_CLIENT_ID | Cognito client id | +| COGNITO_JWKS_URL | Cognito JWKS URL | + +## Notes +- Runtime and db-sync engineering guidance is consolidated in `../../docs/agent-guide/implemented/architecture/2026-08-24-logseq-runtime-and-engineering-guide.md`; current protocol and route definitions live under `src/logseq/db_sync/worker/`. diff --git a/deps/db-sync/bb.edn b/deps/db-sync/bb.edn new file mode 100644 index 0000000..13bef68 --- /dev/null +++ b/deps/db-sync/bb.edn @@ -0,0 +1,28 @@ +{: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 + {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 + {:metadata-exceptions #{:large-vars/cleanup-todo} + ;; AI generated code has its tradeoffs + :max-lines-count 110}}} \ No newline at end of file diff --git a/deps/db-sync/deps.edn b/deps/db-sync/deps.edn new file mode 100644 index 0000000..12c0dba --- /dev/null +++ b/deps/db-sync/deps.edn @@ -0,0 +1,26 @@ +{:paths ["src" "test" "../../resources"] + :deps + {org.clojure/clojure {:mvn/version "1.12.4"} + datascript/datascript {:git/url "https://github.com/logseq/datascript" + :sha "3f141af97b70e1f14c65eaa119acd822ebece37e"} + datascript-transit/datascript-transit {:mvn/version "0.3.0" + :exclusions [datascript/datascript]} + com.cognitect/transit-cljs {:mvn/version "0.8.280"} + cljs-bean/cljs-bean {:mvn/version "1.9.0"} + thheller/shadow-cljs {:mvn/version "3.4.4"} + com.lambdaisland/glogi {:git/url "https://github.com/lambdaisland/glogi" + :git/sha "30328a045141717aadbbb693465aed55f0904976"} + metosin/reitit {:mvn/version "0.10.1" + :exclusions [metosin/malli]} + logseq/common {:local/root "../common"} + logseq/db {:local/root "../db"} + logseq/outliner {:local/root "../outliner"}} + :aliases + {:cljs {:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.0"} + cider/cider-nrepl {:mvn/version "0.59.0"} + org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} + tortue/spy {:mvn/version "2.15.0"}} + :main-opts ["-m" "shadow.cljs.devtools.cli"]} + :clj-kondo + {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/db-sync/package.json b/deps/db-sync/package.json new file mode 100644 index 0000000..b55fcdd --- /dev/null +++ b/deps/db-sync/package.json @@ -0,0 +1,57 @@ +{ + "name": "@logseq/db-sync", + "version": "1.0.0", + "packageManager": "pnpm@10.33.0", + "private": true, + "scripts": { + "dev": "cd ./worker && pnpm exec wrangler dev", + "watch": "clojure -M:cljs watch db-sync db-sync-node", + "release": "clojure -M:cljs release db-sync", + "delete-graphs-for-user": "node worker/scripts/delete_graphs_for_user.js", + "delete-user-totally": "node worker/scripts/delete_user_totally.js", + "download-graph-db": "node worker/scripts/download_graph_db.js", + "show-graphs-for-user": "node worker/scripts/show_graphs_for_user.js", + "show-usage-stats": "node worker/scripts/show_usage_stats.js", + "build:node-adapter": "clojure -M:cljs release db-sync-node", + "dev:node-adapter": "clojure -M:cljs watch db-sync-node", + "start:node-adapter": "node worker/dist/node-adapter.js", + "show-sqlite-checksum": "clojure -M:cljs compile db-sync-show-checksum && node worker/dist/show-sqlite-checksum.js", + "recompute-log-checksum": "clojure -M:cljs compile db-sync-recompute-log-checksum && node worker/dist/recompute-log-checksum.js", + "migrate:local": "cd ./worker && wrangler d1 migrations apply DB --local", + "migrate:staging": "cd ./worker && wrangler d1 migrations apply logseq-sync-graph-meta-staging --env staging --remote", + "migrate:prod": "cd ./worker && wrangler d1 migrations apply logseq-sync-graphs-prod --env prod --remote", + "test:node-adapter": "clojure -M:cljs compile db-sync-test && node worker/dist/worker-test.js", + "test:chatgpt-app": "node --test worker/entry_test.mjs worker/asset_image_test.mjs", + "build:api-docs": "export COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8 && clojure -M:cljs release db-sync-api-docs && node worker/dist/api-docs-spec.js && redocly build-docs worker/dist/openapi.json --output=worker/dist/api-docs.html --title=\"Logseq Server API\" --disableGoogleFont && node worker/scripts/embed_api_docs.mjs", + "test:api-docs": "pnpm build:api-docs && node --test worker/api_docs_test.mjs worker/api_docs_build_test.mjs", + "test:large-op-128m": "clojure -M:cljs release db-sync-large-op-memory-test && node --max-old-space-size=128 worker/dist/large-op-memory-test.js", + "test": "clojure -M:cljs compile db-sync-test && node worker/dist/worker-test.js", + "test:mcp": "pnpm clean && pnpm release && pnpm build:api-docs && cd worker && pnpm exec wrangler deploy --dry-run --env=\"\"", + "clean": "rm -rf ./worker/dist/", + "sentry:sourcemaps": "sentry-cli sourcemaps upload --release $SENTRY_RELEASE --rewrite --strip-prefix worker/dist/worker --url-prefix \"~/\" worker/dist/worker", + "deploy-prod": "export SENTRY_RELEASE=$(git rev-parse HEAD) && pnpm clean && pnpm release && pnpm build:api-docs && pnpm sentry:sourcemaps && cd ./worker && pnpm exec wrangler deploy --env prod", + "deploy-staging": "export SENTRY_RELEASE=$(git rev-parse HEAD) && pnpm clean && pnpm release && pnpm build:api-docs && pnpm sentry:sourcemaps && cd ./worker && pnpm exec wrangler deploy --env staging" + }, + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@cloudflare/codemode": "^0.4.2", + "@modelcontextprotocol/sdk": "^1.29.0", + "@sentry/cloudflare": "^10.45.0", + "@sentry/node": "^10.45.0", + "better-sqlite3": "^12.8.0", + "mldoc": "^1.5.9", + "shadow-cljs": "^3.4.4", + "transit-js": "^0.8.874", + "ws": "^8.19.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@redocly/cli": "^2.38.0", + "@sentry/cli": "^3.3.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] + } +} diff --git a/deps/db-sync/pnpm-lock.yaml b/deps/db-sync/pnpm-lock.yaml new file mode 100644 index 0000000..4a596e8 --- /dev/null +++ b/deps/db-sync/pnpm-lock.yaml @@ -0,0 +1,2529 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 + '@cloudflare/codemode': + specifier: ^0.4.2 + version: 0.4.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(zod@4.4.3) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + '@sentry/cloudflare': + specifier: ^10.45.0 + version: 10.45.0(@cloudflare/workers-types@4.20260702.1) + '@sentry/node': + specifier: ^10.45.0 + version: 10.45.0 + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 + mldoc: + specifier: ^1.5.9 + version: 1.5.9 + shadow-cljs: + specifier: ^3.4.4 + version: 3.4.4 + transit-js: + specifier: ^0.8.874 + version: 0.8.874 + ws: + specifier: ^8.19.0 + version: 8.19.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@redocly/cli': + specifier: ^2.38.0 + version: 2.38.0 + '@sentry/cli': + specifier: ^3.3.3 + version: 3.3.3 + +packages: + + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@cloudflare/codemode@0.4.2': + resolution: {integrity: sha512-6sLMZDRY2USbXirrI4tjmGSMYAYM+E/PJIaDQLLbMdvQjk1z1TmJNSLomrZwErO1zFPXvGX2kaqkkxvixkfV2w==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.0 + '@tanstack/ai': '>=0.8.0 <1.0.0' + ai: ^6.0.0 + zod: ^4.0.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@tanstack/ai': + optional: true + ai: + optional: true + zod: + optional: true + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@fastify/otel@0.17.1': + resolution: {integrity: sha512-K4wyxfUZx2ux5o+b6BtTqouYFVILohLZmSbA2tKUueJstNcBnoGPVhllCaOvbQ3ZrXdUxUC/fyrSWSCqHhdOPg==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@opentelemetry/api-logs@0.207.0': + resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation-amqplib@0.60.0': + resolution: {integrity: sha512-q/B2IvoVXRm1M00MvhnzpMN6rKYOszPXVsALi6u0ss4AYHe+TidZEtLW9N1ZhrobI1dSriHnBqqtAOZVAv07sg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.56.0': + resolution: {integrity: sha512-PKp+sSZ7AfzMvGgO3VCyo1inwNu+q7A1k9X88WK4PQ+S6Hp7eFk8pie+sWHDTaARovmqq5V2osav3lQej2B0nw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dataloader@0.30.0': + resolution: {integrity: sha512-MXHP2Q38cd2OhzEBKAIXUi9uBlPEYzF6BNJbyjUXBQ6kLaf93kRC41vNMIz0Nl5mnuwK7fDvKT+/lpx7BXRwdg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.61.0': + resolution: {integrity: sha512-Xdmqo9RZuZlL29Flg8QdwrrX7eW1CZ7wFQPKHyXljNymgKhN1MCsYuqQ/7uxavhSKwAl7WxkTzKhnqpUApLMvQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.32.0': + resolution: {integrity: sha512-koR6apx0g0wX6RRiPpjA4AFQUQUbXrK16kq4/SZjVp7u5cffJhNkY4TnITxcGA4acGSPYAfx3NHRIv4Khn1axQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.56.0': + resolution: {integrity: sha512-fg+Jffs6fqrf0uQS0hom7qBFKsbtpBiBl8+Vkc63Gx8xh6pVh+FhagmiO6oM0m3vyb683t1lP7yGYq22SiDnqg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.61.0': + resolution: {integrity: sha512-pUiVASv6nh2XrerTvlbVHh7vKFzscpgwiQ/xvnZuAIzQ5lRjWVdRPUuXbvZJ/Yq79QsE81TZdJ7z9YsXiss1ew==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.59.0': + resolution: {integrity: sha512-33wa4mEr+9+ztwdgLor1SeBu4Opz4IsmpcLETXAd3VmBrOjez8uQtrsOhPCa5Vhbm5gzDlMYTgFRLQzf8/YHFA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.213.0': + resolution: {integrity: sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.61.0': + resolution: {integrity: sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.22.0': + resolution: {integrity: sha512-wJU4IBQMUikdJAcTChLFqK5lo+flo7pahqd8DSLv7uMxsdOdAHj6RzKYAm8pPfUS6ItKYutYyuicwKaFwQKsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.57.0': + resolution: {integrity: sha512-vMCSh8kolEm5rRsc+FZeTZymWmIJwc40hjIKnXH4O0Dv/gAkJJIRXCsPX5cPbe0c0j/34+PsENd0HqKruwhVYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.61.0': + resolution: {integrity: sha512-lvrfWe9ShK/D2X4brmx8ZqqeWPfRl8xekU0FCn7C1dHm5k6+rTOOi36+4fnaHAP8lig9Ux6XQ1D4RNIpPCt1WQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.57.0': + resolution: {integrity: sha512-cEqpUocSKJfwDtLYTTJehRLWzkZ2eoePCxfVIgGkGkb83fMB71O+y4MvRHJPbeV2bdoWdOVrl8uO0+EynWhTEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.66.0': + resolution: {integrity: sha512-d7m9QnAY+4TCWI4q1QRkfrc6fo/92VwssaB1DzQfXNRvu51b78P+HJlWP7Qg6N6nkwdb9faMZNBCZJfftmszkw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.59.0': + resolution: {integrity: sha512-6/jWU+c1NgznkVLDU/2y0bXV2nJo3o9FWZ9mZ9nN6T/JBNRoMnVXZl2FdBmgH+a5MwaWLs5kmRJTP5oUVGIkPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.59.0': + resolution: {integrity: sha512-n9/xrVCRBfG9egVbffnlU1uhr+HX0vF4GgtAB/Bvm48wpFgRidqD8msBMiym1kRYzmpWvJqTxNT47u1MkgBEdw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.59.0': + resolution: {integrity: sha512-r+V/Fh0sm7Ga8/zk/TI5H5FQRAjwr0RrpfPf8kNIehlsKf12XnvIaZi8ViZkpX0gyPEpLXqzqWD6QHlgObgzZw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.65.0': + resolution: {integrity: sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.61.0': + resolution: {integrity: sha512-JnPexA034/0UJRsvH96B0erQoNOqKJZjE2ZRSw9hiTSC23LzE0nJE/u6D+xqOhgUhRnhhcPHq4MdYtmUdYTF+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.32.0': + resolution: {integrity: sha512-BQS6gG8RJ1foEqfEZ+wxoqlwfCAzb1ZVG0ad8Gfe4x8T658HJCLGLd4E4NaoQd8EvPfLqOXgzGaE/2U4ytDSWA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.23.0': + resolution: {integrity: sha512-LL0VySzKVR2cJSFVZaTYpZl1XTpBGnfzoQPe2W7McS2267ldsaEIqtQY6VXs2KCXN0poFjze5110PIpxHDaDGg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.207.0': + resolution: {integrity: sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@prisma/instrumentation@7.4.2': + resolution: {integrity: sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg==} + peerDependencies: + '@opentelemetry/api': ^1.8 + + '@redocly/cli@2.38.0': + resolution: {integrity: sha512-zYwQiDJTqoiscB6pFwd80FwI065gA13lp8hg2tgjDDu4p8gotVdCD/EOCaPTalIHskiIa1UFxEPRErPOgkWqJw==} + engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} + hasBin: true + + '@sentry/cli-darwin@3.3.3': + resolution: {integrity: sha512-P8DoL79eX5fhKCfBHHl7xwwTShDPOb2drJC8lizZ3v1iS1JLPrNweM1KEzDefR30zH1wghbLSwsYv/svWdM3wA==} + engines: {node: '>=18'} + os: [darwin] + + '@sentry/cli-linux-arm64@3.3.3': + resolution: {integrity: sha512-9jaX9RGyTpjo9u2urNi5ciBDpRdTt107YJpFXev+BFHJ6Lwz/owgRuYzPRfAen8hKkOOFheZ3iy07kl576eZzw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@3.3.3': + resolution: {integrity: sha512-a7o/huozveLIImXHe0HDwEMVhvDopOP2tLcopvV7sQsVE8f/QOShR5FudKjmiaZz2opdLzPJO9pv5WuF9jAZPg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@3.3.3': + resolution: {integrity: sha512-VngQYzR2kDm2oojCuYF20ebLTK8HKvEwxe785J6gxob8Ef9JvZkERyUqENYppBa9aVgN0pandqPAqOECWykTMA==} + engines: {node: '>=18'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@3.3.3': + resolution: {integrity: sha512-rBxXQeIYGefUNI2cXHxEr0y3bhxDQjOD4G6j/gqLz/Dj+l8gJ/iKP64kTudnoViNIpn0pdYccG69th7zmzM/Fg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@3.3.3': + resolution: {integrity: sha512-c52g+YS6BO0rzH8AEHqQPmpqZrw0GJjMWqy0tQ5jcqaGdaLVnxk0mMEubv8R6Dv5MR2LShoKjiNsaeVfrWIMUg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@3.3.3': + resolution: {integrity: sha512-DygYzSY/+tS7oFj/mfeg/yzYxsQx3fO8cI+IWc2pns/at+JcJ9O5xyM/x/q55wOxpnwla7RL1D3rsqK2mqkYfg==} + engines: {node: '>=18'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@3.3.3': + resolution: {integrity: sha512-i0glPcHwkqbVA2Y+0Yz7CD/l8TSkfft1a+lTU9yk/+DDU8WGkyArEAxAji9bGo4p+k5HIFC8OC2MwpKdcdFM4Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@sentry/cli@3.3.3': + resolution: {integrity: sha512-4CZtfgiOraX+BntMjYQhfLDArXwpqt3sEo5Zdj2pqWSZSd4yI3ncfQ21CsxLcI/sUQrjmD5Vzidu4/1OShyxtA==} + engines: {node: '>= 18'} + hasBin: true + + '@sentry/cloudflare@10.45.0': + resolution: {integrity: sha512-4rXUCSnBu9MITm7Uj27tYxEhmHczamdMLfeipBjvgeFBJ3LUJBKqrfolCOL5d3q9in/CopgyhMxY7pgUwlCJ8w==} + engines: {node: '>=18'} + peerDependencies: + '@cloudflare/workers-types': ^4.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + '@sentry/core@10.45.0': + resolution: {integrity: sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q==} + engines: {node: '>=18'} + + '@sentry/node-core@10.45.0': + resolution: {integrity: sha512-KQZEvLKM344+EqXiA9HIzWbW5hzq6/9nnFUQ8niaBPoOgR9AiJhrccfIscfgb8vjkriiEtzE03OW/4h1CTgZ3Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/resources': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/context-async-hooks': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/resources': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@opentelemetry/semantic-conventions': + optional: true + + '@sentry/node@10.45.0': + resolution: {integrity: sha512-Kpiq9lRGnJc1ex8SwxOBl+FLQNl4Y137BydVooP7AFiAYZ6ftwHsIEF1bcYXaipHMT1YHS2bdhC2UQaaB2jkuQ==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.45.0': + resolution: {integrity: sha512-PmuGO+p/gC3ZQ8ddOeJ5P9ApnTTm35i12Bpuyb13AckCbNSJFvG2ggZda35JQOmiFU0kKYiwkoFAa8Mvj9od3Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@25.1.0': + resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + 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'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + 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==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + 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'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + 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'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + 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'} + + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@1.0.3: + resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + 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==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.29: + resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + + 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'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + 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-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + 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'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + mem@4.3.0: + resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==} + engines: {node: '>=6'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + 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'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + 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 + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + 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'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + 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'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + 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'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + 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'} + + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + require-main-filename@1.0.1: + resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadow-cljs-jar@1.3.4: + resolution: {integrity: sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==} + + shadow-cljs@3.4.4: + resolution: {integrity: sha512-6/Z4XVWBavdDSbAR+ECbZTnq22Gws1zy+Ilfwcdl5SMf3ZdOYiYczqykfg/rLjlU0Ap9BvX8cEZGBExK4PGDaw==} + engines: {node: '>=6.0.0'} + hasBin: true + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + 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==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + 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.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + transit-js@0.8.874: + resolution: {integrity: sha512-IDJJGKRzUbJHmN0P15HBBa05nbKor3r2MmG6aSt0UxXIlJZZKcddTk67/U7WyAeW9Hv/VYI02IqLzolsC4sbPA==} + engines: {node: '>= 0.10.0'} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + 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==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + 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==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@cfworker/json-schema@4.1.1': {} + + '@cloudflare/codemode@0.4.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@types/json-schema': 7.0.15 + acorn: 8.17.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + zod: 4.4.3 + + '@cloudflare/workers-types@4.20260702.1': + optional: true + + '@fastify/otel@0.17.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@hono/node-server@1.19.14(hono@4.12.29)': + dependencies: + hono: 4.12.29 + + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.29) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.29 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/api-logs@0.207.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation-amqplib@0.60.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.30.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.32.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.22.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.66.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.65.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.32.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.23.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.207.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/semantic-conventions@1.40.0': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + + '@prisma/instrumentation@7.4.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.207.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@redocly/cli@2.38.0': {} + + '@sentry/cli-darwin@3.3.3': + optional: true + + '@sentry/cli-linux-arm64@3.3.3': + optional: true + + '@sentry/cli-linux-arm@3.3.3': + optional: true + + '@sentry/cli-linux-i686@3.3.3': + optional: true + + '@sentry/cli-linux-x64@3.3.3': + optional: true + + '@sentry/cli-win32-arm64@3.3.3': + optional: true + + '@sentry/cli-win32-i686@3.3.3': + optional: true + + '@sentry/cli-win32-x64@3.3.3': + optional: true + + '@sentry/cli@3.3.3': + dependencies: + progress: 2.0.3 + proxy-from-env: 1.1.0 + undici: 6.23.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 3.3.3 + '@sentry/cli-linux-arm': 3.3.3 + '@sentry/cli-linux-arm64': 3.3.3 + '@sentry/cli-linux-i686': 3.3.3 + '@sentry/cli-linux-x64': 3.3.3 + '@sentry/cli-win32-arm64': 3.3.3 + '@sentry/cli-win32-i686': 3.3.3 + '@sentry/cli-win32-x64': 3.3.3 + + '@sentry/cloudflare@10.45.0(@cloudflare/workers-types@4.20260702.1)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@sentry/core': 10.45.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + + '@sentry/core@10.45.0': {} + + '@sentry/node-core@10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': + dependencies: + '@sentry/core': 10.45.0 + '@sentry/opentelemetry': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@sentry/node@10.45.0': + dependencies: + '@fastify/otel': 0.17.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.30.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.32.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.22.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.66.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.65.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.32.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.23.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@prisma/instrumentation': 7.4.2(@opentelemetry/api@1.9.0) + '@sentry/core': 10.45.0 + '@sentry/node-core': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/opentelemetry': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@sentry/opentelemetry@10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@sentry/core': 10.45.0 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.1.0 + + '@types/json-schema@7.0.15': {} + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 25.1.0 + + '@types/node@25.1.0': + dependencies: + undici-types: 7.16.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.16.0 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 25.1.0 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + + '@types/pg@8.16.0': + dependencies: + '@types/node': 25.1.0 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 25.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + acorn@8.17.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@2.1.1: {} + + ansi-regex@3.0.1: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + better-sqlite3@12.8.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + 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 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + + chownr@1.1.4: {} + + cjs-module-lexer@2.2.0: {} + + 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: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + 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 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.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: {} + + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.3: {} + + file-uri-to-path@1.0.0: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + function-bind@1.1.2: {} + + get-caller-file@1.0.3: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@4.1.0: + dependencies: + pump: 3.0.3 + + github-from-package@0.0.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.29: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + invert-kv@2.0.0: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@1.0.0: + dependencies: + number-is-nan: 1.0.1 + + is-fullwidth-code-point@2.0.0: {} + + is-promise@4.0.0: {} + + is-stream@1.1.0: {} + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + jose@6.2.3: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + 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 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + mem@4.3.0: + dependencies: + map-age-cleaner: 0.1.3 + mimic-fn: 2.1.0 + p-is-promise: 2.1.0 + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mldoc@1.5.9: + dependencies: + yargs: 12.0.5 + + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} + + napi-build-utils@2.0.0: {} + + negotiator@1.0.0: {} + + nice-try@1.0.5: {} + + node-abi@3.87.0: + dependencies: + semver: 7.7.4 + + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + + number-is-nan@1.0.1: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.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: {} + + parseurl@1.3.3: {} + + path-exists@3.0.0: {} + + path-key@2.0.1: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pkce-challenge@5.0.1: {} + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + process@0.11.10: {} + + progress@2.0.3: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.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 + + readline-sync@1.4.10: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + require-main-filename@1.0.1: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@5.7.2: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setprototypeof@1.2.0: {} + + shadow-cljs-jar@1.3.4: {} + + shadow-cljs@3.4.4: + dependencies: + buffer: 6.0.3 + process: 0.11.10 + readline-sync: 1.4.10 + shadow-cljs-jar: 1.3.4 + source-map-support: 0.5.21 + which: 5.0.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: {} + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + 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 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + 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.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + toidentifier@1.0.1: {} + + transit-js@0.8.874: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + undici-types@7.16.0: {} + + undici@6.23.0: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + which-module@2.0.1: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.1 + + wrap-ansi@2.1.0: + dependencies: + string-width: 1.0.2 + strip-ansi: 3.0.1 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + xtend@4.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 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/deps/db-sync/shadow-cljs.edn b/deps/db-sync/shadow-cljs.edn new file mode 100644 index 0000000..7f69937 --- /dev/null +++ b/deps/db-sync/shadow-cljs.edn @@ -0,0 +1,62 @@ +;; shadow-cljs configuration +{:deps true + :http {:port 9632} + :nrepl {:port 8703} + :builds + {:db-sync {:target :esm + :output-dir "worker/dist/worker" + :compiler-options {:source-map true + :warnings {:fn-deprecated false + :redef false}} + :modules {:main {:exports {default logseq.db-sync.worker/worker + SyncDO logseq.db-sync.worker/SyncDO}}} + :js-options {:js-provider :import} + :closure-defines {shadow.cljs.devtools.client.env/enabled false + goog.debug.LOGGING_ENABLED true} + :devtools {:enabled false}} + :db-sync-node {:target :node-script + :output-to "worker/dist/node-adapter.js" + :main logseq.db-sync.node.entry/main + :compiler-options {:source-map true + :warnings {:fn-deprecated false + :redef false}} + :devtools {:enabled false} + :closure-defines {goog.debug.LOGGING_ENABLED true}} + :db-sync-show-checksum {:target :node-script + :output-to "worker/dist/show-sqlite-checksum.js" + :main logseq.db-sync.node.show-checksum/main + :compiler-options {:source-map true + :warnings {:fn-deprecated false + :redef false}} + :devtools {:enabled false}} + :db-sync-find-checksum-drift {:target :node-script + :output-to "worker/dist/find-checksum-drift.js" + :main logseq.db-sync.node.find-checksum-drift/main + :compiler-options {:source-map true + :warnings {:fn-deprecated false + :redef false}} + :devtools {:enabled false}} + :db-sync-recompute-log-checksum {:target :node-script + :output-to "worker/dist/recompute-log-checksum.js" + :main logseq.db-sync.node.recompute-log-checksum/main + :compiler-options {:source-map true + :warnings {:fn-deprecated false + :redef false}} + :devtools {:enabled false}} + :db-sync-api-docs {:target :node-script + :output-to "worker/dist/api-docs-spec.js" + :main logseq.db-sync.node.api-docs/main + :compiler-options {:warnings {:fn-deprecated false + :redef false}} + :devtools {:enabled false}} + :db-sync-test {:target :node-test + :output-to "worker/dist/worker-test.js" + :devtools {:enabled false} + :main logseq.db-sync.test-runner/main} + :db-sync-large-op-memory-test {:target :node-script + :output-to "worker/dist/large-op-memory-test.js" + :main logseq.db-sync.worker-large-op-memory-test/main + :compiler-options {:source-map true + :warnings {:fn-deprecated false + :redef false}} + :devtools {:enabled false}}}} diff --git a/deps/db-sync/src/logseq/db_sync/batch.cljs b/deps/db-sync/src/logseq/db_sync/batch.cljs new file mode 100644 index 0000000..cd791ad --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/batch.cljs @@ -0,0 +1,18 @@ +(ns logseq.db-sync.batch + (:require [clojure.string :as string])) + +(def max-sql-params 99) +(def ^:private row-param-count 3) + +(defn rows->insert-batches + [table rows {:keys [max-params] :or {max-params max-sql-params}}] + (if (seq rows) + (let [max-rows (max 1 (js/Math.floor (/ max-params row-param-count))) + batches (partition-all max-rows rows)] + (mapv (fn [batch] + (let [placeholders (string/join "," (repeat (count batch) "(?, ?, ?)")) + sql (str "insert or replace into " table " (addr, content, addresses) values " placeholders) + args (vec (mapcat identity batch))] + {:sql sql :args args})) + batches)) + [])) diff --git a/deps/db-sync/src/logseq/db_sync/checksum.cljs b/deps/db-sync/src/logseq/db_sync/checksum.cljs new file mode 100644 index 0000000..d1f4d7c --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/checksum.cljs @@ -0,0 +1,429 @@ +(ns logseq.db-sync.checksum + (:require [clojure.set :as set] + [datascript.core :as d] + [logseq.db :as ldb])) + +(def ^:private fnv-offset 2166136261) +(def ^:private djb-offset 5381) +(def ^:private field-separator 31) + +(defn- fnv-step + [h code] + (bit-or (js/Math.imul (bit-xor h code) 16777619) 0)) + +(defn- djb-step + [h code] + (bit-or (+ (js/Math.imul h 33) code) 0)) + +(defn- add-step + [acc value] + (bit-or (+ acc value) 0)) + +(defn- sub-step + [acc value] + (bit-or (- acc value) 0)) + +(defn- hash-code + [[fnv djb] code] + [(fnv-step fnv code) + (djb-step djb code)]) + +(defn- digest-string + [state value] + (let [value (or value "")] + (loop [idx 0 + state state] + (if (< idx (count value)) + (recur (inc idx) + (hash-code state (.charCodeAt value idx))) + state)))) + +(defn- unsigned-hex + [n] + (-> (unsigned-bit-shift-right n 0) + (.toString 16) + (.padStart 8 "0"))) + +(defn- parse-hex32 + [s] + (when (= 8 (count s)) + (bit-or (js/parseInt s 16) 0))) + +(defn- checksum->state + [checksum] + (if (and (string? checksum) (= 16 (count checksum))) + [(or (parse-hex32 (subs checksum 0 8)) 0) + (or (parse-hex32 (subs checksum 8 16)) 0)] + [0 0])) + +(defn- valid-checksum? + [checksum] + (boolean + (and (string? checksum) + (re-matches #"[0-9a-fA-F]{16}" checksum)))) + +(defn- state->checksum + [[fnv djb]] + (str (unsigned-hex fnv) + (unsigned-hex djb))) + +(defn- relevant-attrs + [e2ee?] + (cond-> #{:block/uuid :block/parent :block/page :block/order} + (not e2ee?) (into #{:block/title :block/name}))) + +(defn- get-block-uuid + [db eid] + (:block/uuid (d/entity db eid))) + +(defn- parse-uuid-string + [value] + (when (string? value) + (try + (uuid value) + (catch :default _ + nil)))) + +(defn- entids + [db-before db-after value] + (let [lookup-ref (fn [db value] + (when value + (try + (d/entid db value) + (catch :default _ + nil)))) + uuid-value (cond + (uuid? value) value + (string? value) (parse-uuid-string value) + :else nil)] + (cond-> #{} + (integer? value) (conj value) + (lookup-ref db-before value) (conj (lookup-ref db-before value)) + (lookup-ref db-after value) (conj (lookup-ref db-after value)) + (and uuid-value (lookup-ref db-before [:block/uuid uuid-value])) + (conj (lookup-ref db-before [:block/uuid uuid-value])) + (and uuid-value (lookup-ref db-after [:block/uuid uuid-value])) + (conj (lookup-ref db-after [:block/uuid uuid-value]))))) + +(defn- normalize-checksum-value + [db attr value] + (case attr + :block/parent (get-block-uuid db value) + :block/page (get-block-uuid db value) + value)) + +(defn- entity-values + [db eid e2ee?] + (let [attrs (relevant-attrs e2ee?) + datoms (d/datoms db :eavt eid)] + (reduce (fn [acc datom] + (let [attr (:a datom)] + (if (contains? attrs attr) + (case attr + :block/uuid (assoc acc :block/uuid (:v datom)) + :block/order (assoc acc :block/order (:v datom)) + :block/title (assoc acc :block/title (:v datom)) + :block/name (assoc acc :block/name (:v datom)) + :block/parent (assoc acc :block/parent (get-block-uuid db (:v datom))) + :block/page (assoc acc :block/page (get-block-uuid db (:v datom))) + acc) + acc))) + {} + datoms))) + +(defn- checksum-eligible-entity? + [db eid] + (when-let [ent (d/entity db eid)] + (and (uuid? (:block/uuid ent)) + (not (ldb/built-in? ent)) + (or (ldb/page? ent) + (some? (:block/page ent)) + (some? (:block/name ent)))))) + +(defn- entity-checksum-tuples + [db eid e2ee?] + (when-let [entity-uuid (get-block-uuid db eid)] + (let [attrs (relevant-attrs e2ee?)] + (->> (d/datoms db :eavt eid) + (keep (fn [{:keys [a v]}] + (when (contains? attrs a) + [entity-uuid + a + (normalize-checksum-value db a v)]))) + set)))) + +(defn- tuple-digest + [[entity-uuid attr value]] + (-> [fnv-offset djb-offset] + (digest-string (str entity-uuid)) + (hash-code field-separator) + (digest-string (str attr)) + (hash-code field-separator) + (digest-string (some-> value str)))) + +(defn- subtract-digest + [[sum-fnv sum-djb] [fnv djb]] + [(sub-step sum-fnv fnv) + (sub-step sum-djb djb)]) + +(defn- add-digest + [[sum-fnv sum-djb] [fnv djb]] + [(add-step sum-fnv fnv) + (add-step sum-djb djb)]) + +(defn- db-checksum-tuples + [db e2ee?] + (->> (d/datoms db :avet :block/uuid) + (mapcat (fn [{:keys [e]}] + (when (checksum-eligible-entity? db e) + (entity-checksum-tuples db e e2ee?)))))) + +(defn- tx-item-eids + [db-before db-after tx-item] + (if-let [eid (:e tx-item)] + #{eid} + (if (vector? tx-item) + (let [[op entity attr value] tx-item + entity-ids (entids db-before db-after entity)] + (cond-> entity-ids + (and (= op :db/add) (= attr :block/uuid)) + (into (entids db-before db-after value)) + (and (= op :db/retract) (= attr :block/uuid)) + (into (entids db-before db-after value)))) + #{}))) + +(defn- block-uuid-tx-item? + [tx-item] + (= :block/uuid + (if (vector? tx-item) + (nth tx-item 2 nil) + (:a tx-item)))) + +(defn- touched-base-eids + [db-before db-after tx-data] + (let [before-eligible-cache (volatile! {}) + after-eligible-cache (volatile! {}) + cached-eligible? (fn [db cache eid] + (if-some [cached (find @cache eid)] + (val cached) + (let [eligible? (boolean (checksum-eligible-entity? db eid))] + (vswap! cache assoc eid eligible?) + eligible?)))] + (reduce (fn [result tx-item] + (let [block-uuid-change? (block-uuid-tx-item? tx-item)] + (reduce (fn [eids eid] + (cond-> eids + (or block-uuid-change? + (not= (get-block-uuid db-before eid) + (get-block-uuid db-after eid)) + (cached-eligible? db-before before-eligible-cache eid) + (cached-eligible? db-after after-eligible-cache eid)) + (conj eid))) + result + (tx-item-eids db-before db-after tx-item)))) + #{} + tx-data))) + +(defn- touched-checksum-uuids + [db-before db-after eids] + (->> eids + (mapcat (fn [eid] + [(get-block-uuid db-before eid) + (get-block-uuid db-after eid)])) + (remove nil?) + set)) + +(defn- eids-with-changed-block-uuid + [db-before db-after eids] + (->> eids + (filter (fn [eid] + (not= (get-block-uuid db-before eid) + (get-block-uuid db-after eid)))) + set)) + +(defn- referrer-eids-by-target + [db target-eid] + (->> (concat (d/datoms db :avet :block/parent target-eid) + (d/datoms db :avet :block/page target-eid)) + (map :e) + set)) + +(defn- impacted-referrer-eids + [db-before db-after target-eids] + (->> target-eids + (mapcat (fn [target-eid] + (concat (referrer-eids-by-target db-before target-eid) + (referrer-eids-by-target db-after target-eid)))) + set)) + +(defn- eids-by-block-uuid + [db block-uuid] + (->> (d/datoms db :avet :block/uuid block-uuid) + (map :e) + set)) + +(defn- block-uuid-datom-count + [db eid] + (count (d/datoms db :eavt eid :block/uuid))) + +(defn- duplicate-block-uuid? + [db-before db-after block-uuids] + (some (fn [uuid] + (or (> (count (d/datoms db-before :avet :block/uuid uuid)) 1) + (> (count (d/datoms db-after :avet :block/uuid uuid)) 1))) + block-uuids)) + +(defn- tuple-set-for-eids + [db eids e2ee?] + (reduce (fn [tuples eid] + (if (checksum-eligible-entity? db eid) + (into tuples (or (entity-checksum-tuples db eid e2ee?) #{})) + tuples)) + #{} + eids)) + +(defn- tuple-counts-for-eids + [db eids e2ee?] + (reduce + (fn [counts eid] + (let [datom-count (block-uuid-datom-count db eid)] + (if (and (pos? datom-count) + (checksum-eligible-entity? db eid)) + (reduce (fn [acc tuple] + (update acc tuple (fnil + 0) datom-count)) + counts + (or (entity-checksum-tuples db eid e2ee?) #{})) + counts))) + {} + eids)) + +(defn- net-tuple-delta + [db-before db-after e2ee? tx-data] + (let [base-eids (touched-base-eids db-before db-after tx-data)] + (if (empty? base-eids) + {:removed {} + :added {}} + (let [uuid-changed-eids (eids-with-changed-block-uuid db-before db-after base-eids) + dependent-eids (when (seq uuid-changed-eids) + (->> (impacted-referrer-eids db-before db-after uuid-changed-eids) + (filter (fn [eid] + (or (checksum-eligible-entity? db-before eid) + (checksum-eligible-entity? db-after eid)))) + set)) + effective-eids (if (seq dependent-eids) + (set/union base-eids dependent-eids) + base-eids) + touched-uuids (touched-checksum-uuids db-before db-after effective-eids)] + (if (duplicate-block-uuid? db-before db-after touched-uuids) + (let [peer-eids (->> touched-uuids + (mapcat (fn [uuid] + (concat (eids-by-block-uuid db-before uuid) + (eids-by-block-uuid db-after uuid)))) + (filter (fn [eid] + (or (checksum-eligible-entity? db-before eid) + (checksum-eligible-entity? db-after eid)))) + set) + touched-eids (set/union effective-eids peer-eids) + before-counts (tuple-counts-for-eids db-before touched-eids e2ee?) + after-counts (tuple-counts-for-eids db-after touched-eids e2ee?) + all-tuples (set/union (set (keys before-counts)) + (set (keys after-counts)))] + (reduce + (fn [{:keys [removed added]} tuple] + (let [before-count (get before-counts tuple 0) + after-count (get after-counts tuple 0)] + (cond + (> before-count after-count) + {:removed (assoc removed tuple (- before-count after-count)) + :added added} + + (> after-count before-count) + {:removed removed + :added (assoc added tuple (- after-count before-count))} + + :else + {:removed removed + :added added}))) + {:removed {} + :added {}} + all-tuples)) + (let [before-tuples (tuple-set-for-eids db-before effective-eids e2ee?) + after-tuples (tuple-set-for-eids db-after effective-eids e2ee?) + removed (set/difference before-tuples after-tuples) + added (set/difference after-tuples before-tuples)] + {:removed (into {} (map (fn [tuple] [tuple 1]) removed)) + :added (into {} (map (fn [tuple] [tuple 1]) added))})))))) + +(defn- apply-digest-n + [checksum-state tuple count op] + (let [digest (tuple-digest tuple)] + (loop [n count + state checksum-state] + (if (pos? n) + (recur (dec n) (op state digest)) + state)))) + +(defn recompute-checksum + [db] + (let [e2ee? (ldb/get-graph-rtc-e2ee? db) + tuples (db-checksum-tuples db e2ee?)] + (->> tuples + (reduce (fn [checksum-state tuple] + (add-digest checksum-state (tuple-digest tuple))) + [0 0]) + state->checksum))) + +(defn recompute-checksum-diagnostics + [db] + (let [e2ee? (boolean (ldb/get-graph-rtc-e2ee? db)) + attrs (relevant-attrs e2ee?) + eids (->> (d/datoms db :eavt) + (keep (fn [datom] + (when (contains? attrs (:a datom)) + (:e datom)))) + distinct) + blocks (->> eids + (keep (fn [eid] + (when (checksum-eligible-entity? db eid) + (let [{:block/keys [uuid title name parent page order]} + (entity-values db eid e2ee?)] + (cond-> {:block/uuid uuid + :block/parent parent + :block/page page + :block/order order} + (not e2ee?) (assoc :block/title title + :block/name name)))))) + (sort-by (comp str :block/uuid)) + vec)] + {:checksum (recompute-checksum db) + :e2ee? e2ee? + :attrs (->> attrs (sort-by str) vec) + :blocks blocks})) + +(defn update-checksum + [checksum {:keys [db-before db-after tx-data]}] + (let [before-e2ee? (ldb/get-graph-rtc-e2ee? db-before) + after-e2ee? (ldb/get-graph-rtc-e2ee? db-after) + tx-data (or tx-data [])] + (cond + (not= before-e2ee? after-e2ee?) + ;; E2EE mode changes the global digest semantics, so incremental deltas are invalid. + (recompute-checksum db-after) + + (empty? tx-data) + checksum + + :else + (let [initial-state (if (valid-checksum? checksum) + (checksum->state checksum) + (checksum->state (recompute-checksum db-before))) + {:keys [removed added]} (net-tuple-delta db-before db-after after-e2ee? tx-data) + state-after-removals (reduce-kv (fn [checksum-state tuple count] + (apply-digest-n checksum-state tuple count subtract-digest)) + initial-state + removed) + state-after-additions (reduce-kv (fn [checksum-state tuple count] + (apply-digest-n checksum-state tuple count add-digest)) + state-after-removals + added)] + (state->checksum state-after-additions))))) diff --git a/deps/db-sync/src/logseq/db_sync/common.cljs b/deps/db-sync/src/logseq/db_sync/common.cljs new file mode 100644 index 0000000..9e3c612 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/common.cljs @@ -0,0 +1,92 @@ +(ns logseq.db-sync.common + (:require [clojure.string :as string] + [logseq.db-sync.platform.core :as platform] + [logseq.db.sqlite.util :as sqlite-util] + [promesa.core :as p])) + +(defn cors-headers [] + #js {"Access-Control-Allow-Origin" "*" + "Access-Control-Allow-Headers" "content-type,content-encoding,authorization,x-amz-meta-checksum,x-amz-meta-type" + "Access-Control-Allow-Methods" "GET,POST,PUT,DELETE,OPTIONS,HEAD" + "Access-Control-Expose-Headers" "content-type,content-encoding,content-length,cache-control,x-asset-type,x-asset-size,x-snapshot-row-count"}) + +(defn json-response + ([data] (json-response data 200)) + ([data status] + (platform/response + (js/JSON.stringify (clj->js data)) + #js {:status status + :headers (js/Object.assign + #js {"content-type" "application/json"} + (cors-headers))}))) + +(defn options-response [] + (platform/response nil #js {:status 204 :headers (cors-headers)})) + +(defn get-sql-rows [^js result] + (let [iter-fn (when result (aget result js/Symbol.iterator))] + (cond + (nil? result) [] + (fn? (.-toArray result)) (.toArray result) + (fn? iter-fn) (vec (js/Array.from result)) + (array? (.-results result)) (.-results result) + (array? (.-rows result)) (.-rows result) + (array? result) (if (empty? result) + [] + (let [first-row (first result)] + (cond + (array? (.-results first-row)) (.-results first-row) + (array? (.-rows first-row)) (.-rows first-row) + :else result))) + :else []))) + +(defn sql-exec + [sql sql-str & args] + (.apply (.-exec sql) sql (to-array (cons sql-str args)))) + +(defn (d/entity db e) :block/page :db/id)) + +(defn- safe-target-for-class-extends + "Default safe target for :logseq.property.class/extends. + Must resolve to an existing entity. If you prefer 'retract only', return nil." + [db _e _attr _bad-v] + (d/entid db :logseq.class/Root)) + +(defn- ancestor? + "Return true if `ancestor-eid` appears in the parent chain of `start-eid`." + [db ancestor-eid start-eid] + (loop [current start-eid + seen #{}] + (cond + (nil? current) false + (= current ancestor-eid) true + (contains? seen current) false + :else + (let [parent (some-> (d/entity db current) :block/parent :db/id)] + (recur parent (conj seen current)))))) + +(def ^:private default-attr-opts + {;; Cardinality-one + :block/parent + {:cardinality :one + :safe-target-fn safe-target-for-block-parent} + + ;; Cardinality-many + :logseq.property.class/extends + {:cardinality :many + :safe-target-fn safe-target-for-class-extends}}) + +(def ^:private repair-attrs + (set (keys default-attr-opts))) + +;; ----------------------------------------------------------------------------- +;; Basics +;; ----------------------------------------------------------------------------- + +(defn- ref-eids + "Read a ref attribute as a set of eids. + + Supports: + - cardinality-one: returns #{v} or #{} + - cardinality-many: returns #{v1 v2 ...} or #{}" + [db e attr {:keys [cardinality] :or {cardinality :one}}] + (let [v (some-> (d/entity db e) (get attr))] + (case cardinality + :many (cond + (nil? v) #{} + (set? v) (into #{} (keep #(entid db %)) v) + (sequential? v) (into #{} (keep #(entid db %)) v) + :else (let [id (entid db v)] (if id #{id} #{}))) + ;; :one + (let [id (entid db v)] (if id #{id} #{}))))) + +(defn- touched-eids + "Collect entity ids whose `attr` was added/changed (added=true) in tx-data." + [tx-data attr] + (->> tx-data + (keep (fn [[e a _v _t added]] + (when (and added (= a attr)) e))) + distinct)) + +(defn- touched-eids-many + "Collect touched entity ids for repair attrs. + Returns {attr #{eid ...}}" + [tx-data] + (reduce (fn [m attr] + (let [xs (touched-eids tx-data attr)] + (if (seq xs) (assoc m attr (set xs)) m))) + {} + repair-attrs)) + +;; ----------------------------------------------------------------------------- +;; Cycle detection (DFS; cardinality-one and cardinality-many) +;; ----------------------------------------------------------------------------- + +(defn- reachable-cycle + "Detect a ref-cycle reachable from `start-eid` following edges of `attr`. + + Returns a vector like [a b c a] or nil. + For cardinality-many attrs, we DFS across ALL outgoing edges. + + attr-opts: + - :cardinality :one|:many + - :skip? (fn [db from attr to] -> boolean) ; ignore a specific edge" + [db start-eid attr {:keys [skip?] :as attr-opts}] + (let [visited (volatile! #{}) + stack (volatile! []) + in-stack (volatile! #{}) + cycle (volatile! nil)] + (letfn [(neighbors [e] + (->> (ref-eids db e attr attr-opts) + (remove nil?) + (remove (fn [to] (and skip? (skip? db e attr to)))) + vec)) + (record-cycle! [e] + (let [stk @stack + idx (.indexOf stk e)] + (when (>= idx 0) + (vreset! cycle (conj (subvec stk idx) e))))) + (dfs! [e] + (when-not @cycle + (cond + (contains? @in-stack e) (record-cycle! e) + (contains? @visited e) nil + :else + (do + (vswap! visited conj e) + (vswap! in-stack conj e) + (vswap! stack conj e) + (doseq [n (neighbors e)] + (when-not @cycle + (dfs! n))) + (vswap! stack pop) + (vswap! in-stack disj e)))))] + (dfs! start-eid) + @cycle))) + +;; ----------------------------------------------------------------------------- +;; Breaking cycles +;; ----------------------------------------------------------------------------- + +(defn- cycle-edges + "Convert cycle path [a b c a] into directed edges [[a b] [b c] [c a]]." + [cycle] + (when (and (vector? cycle) (>= (count cycle) 2)) + (mapv (fn [i] [(nth cycle i) (nth cycle (inc i))]) + (range (dec (count cycle)))))) + +(defn- pick-victim + "Pick which node in the cycle to detach. + + Preference: + 1) touched by local rebase (prefer remote value) + 2) touched by remote (detach the remote-changed edge) + 3) untouched by remote/local + 4) first node" + [cycle {:keys [local-touched remote-touched]}] + (let [nodes (vec (distinct (butlast cycle))) + local? (fn [e] (contains? (or local-touched #{}) e)) + remote? (fn [e] (contains? (or remote-touched #{}) e)) + untouched? (fn [e] (and (not (local? e)) (not (remote? e))))] + (or (some (fn [e] (when (local? e) e)) nodes) + (some (fn [e] (when (remote? e) e)) nodes) + (some (fn [e] (when (untouched? e) e)) nodes) + (first nodes)))) + +(defn- break-cycle-edge! + "Given a detected cycle, retract ONE edge on the cycle path. + + Returns a tx vector (possibly with an add), or nil." + [db cycle attr {:keys [safe-target-fn skip?] :as attr-opts} touched] + (let [edges (cycle-edges cycle) + cycle-nodes (set (distinct (butlast cycle))) + remote-parent-fn (:remote-parent-fn attr-opts) + remote-ref-set-fn (:remote-ref-set-fn attr-opts) + remote-parent-candidates (when (and (= :block/parent attr) remote-parent-fn) + (keep (fn [[from to]] + (let [remote-parent (remote-parent-fn db from to) + remote-parent (entid db remote-parent) + to-id (entid db to)] + (when (and remote-parent + (not= remote-parent to-id)) + {:victim from + :bad-v to-id + :safe remote-parent + :outside? (not (contains? cycle-nodes remote-parent))}))) + edges)) + remote-ref-candidates (when (and (= :logseq.property.class/extends attr) remote-ref-set-fn) + (keep (fn [[from to]] + (let [to-id (entid db to) + remote-set (remote-ref-set-fn db from) + remote-set (set (keep #(entid db %) remote-set))] + (when (and (seq remote-set) + (not (contains? remote-set to-id))) + {:victim from + :bad-v to-id}))) + edges)) + remote-choice (cond + (seq remote-parent-candidates) + (or (some #(when (:outside? %) %) remote-parent-candidates) + (first remote-parent-candidates)) + (seq remote-ref-candidates) + (first remote-ref-candidates) + :else + nil) + victim (or (:victim remote-choice) (pick-victim cycle touched)) + [_from bad-v] (or (when (and remote-choice (:bad-v remote-choice)) + [victim (:bad-v remote-choice)]) + (some (fn [[from _to :as e]] + (when (= from victim) e)) + edges)) + bad-v (entid db bad-v)] + (when (and victim bad-v) + (when-not (and skip? (skip? db victim attr bad-v)) + ;; Ensure the edge still exists in current db. + (when (contains? (ref-eids db victim attr attr-opts) bad-v) + (let [safe (or (:safe remote-choice) + (when safe-target-fn (safe-target-fn db victim attr bad-v))) + safe (if (and (= :block/parent attr) + (number? safe) + (nil? remote-choice) + (ancestor? db victim safe)) + (safe-target-for-block-parent db victim attr bad-v) + safe)] + (cond + (and safe (not= safe bad-v)) + [[:db/retract victim attr bad-v] + [:db/add victim attr safe]] + + :else + [[:db/retract victim attr bad-v]]))))))) + +;; ----------------------------------------------------------------------------- +;; Iterative repair (THIS is the key fix) +;; ----------------------------------------------------------------------------- + +(def ^:private fix-cycle-tx-meta + {:outliner-op :fix-cycle + :gen-undo-ops? false + :persist-op? false}) + +(defn- apply-cycle-repairs! + "Detect & break cycles AFTER rebase, iterating until stable. + + Why iteration: + - With cardinality-many (extends), you can end up with multiple disjoint or + overlapping cycles after merging remote + local edges. + - Breaking one cycle edge may still leave other cycles (e.g. 1<->2 and 2<->3). + + Inputs: + - candidates-by-attr: {attr #{eid ...}} + - touched-by-attr: {attr {:local-touched #{...} :remote-touched #{...}}} + - attr-opts: {attr {:cardinality :one|:many :safe-target-fn ... :skip? ...}} + + We cap iterations to avoid infinite loops if something keeps reintroducing cycles." + [transact! temp-conn candidates-by-attr touched-by-attr attr-opts tx-meta] + (let [max-iterations 16] + (loop [it 0 + ;; Track edges we already retracted to avoid repeating identical work. + ;; Edge key is [e attr v]. + seen-edges #{}] + (when (< it max-iterations) + (let [db @temp-conn + ;; Collect a set of edge-breaking txs for this iteration. + ;; We dedupe by edge key, not by tx vector. + {tx :tx + seen-edges' :seen} + (reduce + (fn [acc [attr es]] + (let [opts (merge {:cardinality :one} (get attr-opts attr {})) + touched (get touched-by-attr attr {})] + (reduce + (fn [{:keys [tx seen] :as acc2} e] + (if-let [cycle (reachable-cycle db e attr opts)] + (if-let [t (break-cycle-edge! db cycle attr opts touched)] + (let [[_op e2 a2 v2] (first t) + edge [e2 a2 v2]] + ;; Skip if we've already retracted this exact edge. + (if (contains? seen edge) + acc2 + {:tx (into tx t) + :seen (conj seen edge)})) + acc2) + acc2)) + acc + es))) + {:tx [] :seen seen-edges} + candidates-by-attr)] + + (if (seq tx) + (do + (transact! temp-conn tx (merge tx-meta fix-cycle-tx-meta)) + (recur (inc it) seen-edges')) + ;; No more cycles detected from these candidates => done + nil)))))) + +(defn- union-candidates + "Union remote + local candidates: {attr #{...}}" + [remote-by-attr local-by-attr] + (reduce + (fn [m attr] + (let [r (get remote-by-attr attr #{}) + l (get local-by-attr attr #{}) + u (into (set r) l)] + (if (seq u) (assoc m attr u) m))) + {} + (distinct (concat (keys remote-by-attr) (keys local-by-attr))))) + +(defn- touched-info-by-attr + "Build {attr {:remote-touched #{...} :local-touched #{...}}}." + [remote-by-attr local-by-attr] + (reduce + (fn [m attr] + (let [r (get remote-by-attr attr #{}) + l (get local-by-attr attr #{})] + (assoc m attr {:remote-touched r :local-touched l}))) + {} + (distinct (concat (keys remote-by-attr) (keys local-by-attr))))) + +(defn fix-cycle! + "Entry point: call AFTER rebase. + + - Computes candidates from remote + rebase tx reports for configured attrs. + - Iteratively breaks cycles until stable." + [temp-conn remote-tx-report rebase-tx-report & {:keys [transact! tx-meta] + :or {transact! ldb/transact!}}] + (let [remote-db (:db-after remote-tx-report) + attr-opts (cond-> default-attr-opts + remote-db + (assoc-in [:block/parent :remote-parent-fn] + (fn [_db e _bad-v] + (some-> (d/entity remote-db e) :block/parent :db/id))) + remote-db + (assoc-in [:block/parent :safe-target-fn] + (fn [db e attr bad-v] + (let [remote-parent (some-> (d/entity remote-db e) :block/parent :db/id) + remote-parent (when (and remote-parent (not= remote-parent bad-v)) remote-parent)] + (or remote-parent (safe-target-for-block-parent db e attr bad-v))))) + remote-db + (assoc-in [:logseq.property.class/extends :remote-ref-set-fn] + (fn [_db e] + (some->> (d/entity remote-db e) + :logseq.property.class/extends)))) + remote-touched-by-attr (touched-eids-many (:tx-data remote-tx-report)) + local-touched-by-attr (touched-eids-many (:tx-data rebase-tx-report)) + candidates-by-attr (union-candidates remote-touched-by-attr local-touched-by-attr) + touched-info (touched-info-by-attr remote-touched-by-attr local-touched-by-attr)] + (when (seq candidates-by-attr) + (apply-cycle-repairs! transact! temp-conn candidates-by-attr touched-info attr-opts tx-meta)))) diff --git a/deps/db-sync/src/logseq/db_sync/index.cljs b/deps/db-sync/src/logseq/db_sync/index.cljs new file mode 100644 index 0000000..624bb82 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/index.cljs @@ -0,0 +1,593 @@ +(ns logseq.db-sync.index + (:require [clojure.string :as string] + [logseq.db-sync.common :as common] + [promesa.core :as p])) + +(def ^:private user-upsert-cache-ttl-ms (* 60 60 1000)) +(def ^:private user-upsert-cache-max 1024) +(defonce ^:private *user-upsert-cache (atom {})) +(def ^:private activity-touch-cache-max 8192) +(defonce ^:private *activity-touch-cache (atom {})) + +(defn- prune-user-upsert-cache! [now-ms] + (swap! *user-upsert-cache + (fn [cache] + (let [cache (into {} + (remove (fn [[_ {:keys [cached-at]}]] + (>= (- now-ms cached-at) user-upsert-cache-ttl-ms))) + cache) + count-cache (count cache)] + (if (> count-cache user-upsert-cache-max) + (into {} + (drop (- count-cache user-upsert-cache-max) + (sort-by (comp :cached-at val) cache))) + cache))))) + +(defn- cache-user-upsert! [user-id email email-verified username now-ms] + (swap! *user-upsert-cache assoc user-id {:email email + :email-verified email-verified + :username username + :cached-at now-ms}) + (prune-user-upsert-cache! now-ms)) + +(defn- graph-e2ee-sql->bool + [v] + (cond + (nil? v) true + (or (= 1 v) (= "1" v)) true + (or (= 0 v) (= "0" v)) false + :else (true? v))) + +(defn- graph-e2ee-bool->sql + [v] + (if (false? v) 0 1)) + +(defn- graph-ready-for-use-sql->bool + [v] + (cond + (nil? v) true + (or (= 1 v) (= "1" v)) true + (or (= 0 v) (= "0" v)) false + :else (true? v))) + +(defn- graph-ready-for-use-bool->sql + [v] + (if (false? v) 0 1)) + +(def ^:private graph-e2ee-migration-sql + "alter table graphs add column graph_e2ee INTEGER DEFAULT 1") +(def ^:private graph-ready-for-use-migration-sql + "alter table graphs add column graph_ready_for_use integer default 1") +(def ^:private user-created-at-migration-sql + "alter table users add column created_at integer") +(def ^:private daily-active-entities-create-table-sql + (str "create table if not exists daily_active_entities (" + "day_utc TEXT," + "entity_type TEXT," + "entity_id TEXT," + "first_seen_at INTEGER," + "primary key (day_utc, entity_type, entity_id)," + "check (entity_type in ('user', 'graph'))" + ");")) +(def ^:private daily-active-entities-create-index-sql + "create index if not exists idx_daily_active_entities_type_day on daily_active_entities (entity_type, day_utc)") + +(defn- duplicate-column-error? + [error column-name] + (let [message (-> (or (ex-message error) (some-> error .-message) (str error)) + string/lower-case)] + (and (string/includes? message "duplicate column") + (string/includes? message (string/lower-case column-name))))) + +(defn- (common/ (p/let [result (common/ (common/ (p/let [result (common/ (common/ (p/let [result (common/ (.toISOString (js/Date. timestamp-ms)) + (subs 0 10))) + +(defn- prune-activity-touch-cache! + [cache] + (if (<= (count cache) activity-touch-cache-max) + cache + (let [drop-count (- (count cache) activity-touch-cache-max)] + (->> cache + (sort-by val) + (drop drop-count) + (into {}))))) + +(defn- cache-activity-touch! + [cache-key now-ms] + (swap! *activity-touch-cache + (fn [cache] + (-> cache + (assoc cache-key now-ms) + (prune-activity-touch-cache!))))) + +(defn- activity-touch-cached? + [cache-key] + (contains? @*activity-touch-cache cache-key)) + +(defn- bool (aget row "graph_e2ee")) + :graph-ready-for-use? (graph-ready-for-use-sql->bool (aget row "graph_ready_for_use")) + :role (aget row "role") + :invited-by (aget row "invited_by") + :created-at (aget row "created_at") + :updated-at (aget row "updated_at")}) + rows)) + [])) + +(defn decode-semantic-graph-cursor [cursor] + (when (string? cursor) + (try + (let [value (js->clj (js/JSON.parse (js/atob cursor)))] + (when (and (vector? value) + (= 2 (count value)) + (number? (first value)) + (string? (second value))) + value)) + (catch :default _ + nil)))) + +(defn- encode-semantic-graph-cursor [updated-at graph-id] + (js/btoa (js/JSON.stringify (clj->js [updated-at graph-id])))) + +(defn [(str "(g.user_id = ? or m.user_id = ?) ") + "g.graph_e2ee = 0" + "g.graph_ready_for_use = 1"] + (string? name) (conj "lower(g.graph_name) = lower(?)") + cursor-key (conj "(g.updated_at < ? or (g.updated_at = ? and g.graph_id > ?))")) + args (cond-> [user-id user-id user-id] + (string? name) (conj name) + cursor-key (conj cursor-updated-at cursor-updated-at cursor-graph-id) + true (conj (inc limit))) + sql (str "select g.graph_id, g.graph_name, g.schema_version, g.graph_ready_for_use, " + "g.created_at, g.updated_at, m.role, m.invited_by " + "from graphs g " + "left join graph_members m on g.graph_id = m.graph_id and m.user_id = ? " + "where " (string/join " and " conditions) " " + "order by g.updated_at desc, g.graph_id asc limit ?")] + (p/let [result (apply common/ (count rows) limit) + selected (take limit rows) + graphs (mapv (fn [row] + {:graph-id (aget row "graph_id") + :graph-name (aget row "graph_name") + :schema-version (aget row "schema_version") + :graph-e2ee? false + :graph-ready-for-use? (graph-ready-for-use-sql->bool (aget row "graph_ready_for_use")) + :role (aget row "role") + :invited-by (aget row "invited_by") + :created-at (aget row "created_at") + :updated-at (aget row "updated_at")}) + selected)] + (cond-> {:graphs graphs} + more? (assoc :next-cursor + (let [row (nth rows (dec limit))] + (encode-semantic-graph-cursor (aget row "updated_at") + (aget row "graph_id")))))))) + +(defn sql graph-e2ee?) + graph-ready-for-use? (graph-ready-for-use-bool->sql graph-ready-for-use?) + result (common/bool (some-> row (aget "graph_ready_for_use")))))) + +(defn bool (some-> row (aget "graph_e2ee")))))) + +(defn sql graph-ready-for-use?) + (common/now-ms) + graph-id))) + +(defn coercer [schema] + (ma/coercer schema json-transformer nil + #(ma/-fail! ::db-sync-malli-coerce + (select-keys % [:value])))) + +(def ws-client-message-coercer (->coercer ws-client-message-schema)) +(def ws-server-message-coercer (->coercer ws-server-message-schema)) + +(def http-request-coercers + (into {} + (map (fn [[k schema]] [k (->coercer schema)])) + http-request-schemas)) + +(def http-response-coercers + (into {} + (map (fn [[k schema]] [k (->coercer schema)])) + http-response-schemas)) diff --git a/deps/db-sync/src/logseq/db_sync/node/api_docs.cljs b/deps/db-sync/src/logseq/db_sync/node/api_docs.cljs new file mode 100644 index 0000000..2b6ca8a --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/api_docs.cljs @@ -0,0 +1,10 @@ +(ns logseq.db-sync.node.api-docs + (:require ["node:fs" :as fs] + [logseq.db-sync.worker.routes.semantic :as semantic-routes])) + +(defn ^:export main [] + (let [issuer (or (aget js/process.env "COGNITO_ISSUER") "") + document (semantic-routes/openapi-document issuer)] + (.mkdirSync fs "worker/dist" #js {:recursive true}) + (.writeFileSync fs "worker/dist/openapi.json" + (js/JSON.stringify (clj->js document) nil 2)))) diff --git a/deps/db-sync/src/logseq/db_sync/node/assets.cljs b/deps/db-sync/src/logseq/db_sync/node/assets.cljs new file mode 100644 index 0000000..8168c5e --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/assets.cljs @@ -0,0 +1,58 @@ +(ns logseq.db-sync.node.assets + (:require ["fs" :as fs] + ["path" :as node-path] + [promesa.core :as p])) + +(defn- ensure-dir! [dir] + (.mkdirSync fs dir #js {:recursive true})) + +(defn- meta-path [path] + (str path ".meta.json")) + +(defn- read-meta [path] + (p/let [content (.readFile (.-promises fs) path "utf8")] + (js/JSON.parse content))) + +(defn- write-meta! [path meta] + (.writeFile (.-promises fs) path (js/JSON.stringify meta) "utf8")) + +(defn- normalize-bytes [data] + (cond + (instance? js/Uint8Array data) data + (instance? js/ArrayBuffer data) (js/Uint8Array. data) + :else (js/Uint8Array. data))) + +(defn make-bucket [base-dir] + (ensure-dir! base-dir) + #js {:get (fn [key] + (let [file-path (node-path/join base-dir key) + meta-file (meta-path file-path)] + (p/catch + (p/let [buf (.readFile (.-promises fs) file-path) + meta (p/catch (read-meta meta-file) (fn [_] #js {}))] + #js {:body buf + :httpMetadata #js {:contentType (aget meta "contentType") + :contentEncoding (aget meta "contentEncoding") + :cacheControl (aget meta "cacheControl")} + :customMetadata (aget meta "customMetadata")}) + (fn [_] nil)))) + :put (fn [key body opts] + (let [file-path (node-path/join base-dir key) + meta-file (meta-path file-path) + dir (node-path/dirname file-path) + data (normalize-bytes body) + metadata (or (aget opts "httpMetadata") #js {}) + custom (or (aget opts "customMetadata") #js {})] + (ensure-dir! dir) + (p/let [_ (.writeFile (.-promises fs) file-path data) + _ (write-meta! meta-file #js {:contentType (aget metadata "contentType") + :contentEncoding (aget metadata "contentEncoding") + :cacheControl (aget metadata "cacheControl") + :customMetadata custom})] + #js {:ok true}))) + :delete (fn [key] + (let [file-path (node-path/join base-dir key) + meta-file (meta-path file-path)] + (p/let [_ (p/catch (.unlink (.-promises fs) file-path) (fn [_] nil)) + _ (p/catch (.unlink (.-promises fs) meta-file) (fn [_] nil))] + #js {:ok true})))}) diff --git a/deps/db-sync/src/logseq/db_sync/node/config.cljs b/deps/db-sync/src/logseq/db_sync/node/config.cljs new file mode 100644 index 0000000..580c944 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/config.cljs @@ -0,0 +1,44 @@ +(ns logseq.db-sync.node.config + (:require [clojure.string :as string])) + +(defn- env-value [^js env k] + (let [v (aget env k)] + (when (seq v) v))) + +(defn- parse-int [v default] + (let [n (js/parseInt v 10)] + (if (js/isNaN n) default n))) + +(defn config-from-env [] + (let [env (.-env js/process)] + {:port (when-let [v (env-value env "DB_SYNC_PORT")] (parse-int v 8080)) + :base-url (env-value env "DB_SYNC_BASE_URL") + :data-dir (or (env-value env "DB_SYNC_DATA_DIR") "data/db-sync") + :storage-driver (or (env-value env "DB_SYNC_STORAGE_DRIVER") "sqlite") + :assets-driver (or (env-value env "DB_SYNC_ASSETS_DRIVER") "filesystem") + :log-level (or (env-value env "DB_SYNC_LOG_LEVEL") "info") + :cognito-issuer (env-value env "COGNITO_ISSUER") + :cognito-client-id (env-value env "COGNITO_CLIENT_ID") + :cognito-jwks-url (env-value env "COGNITO_JWKS_URL")})) + +(def ^:private allowed-config-keys + [:port :base-url :data-dir :storage-driver :assets-driver :log-level + :cognito-issuer :cognito-client-id :cognito-jwks-url]) + +(defn normalize-config [overrides] + (let [defaults {:port 8080 + :data-dir "data/db-sync" + :storage-driver "sqlite" + :assets-driver "filesystem" + :log-level "info"} + merged (merge defaults (config-from-env) overrides) + storage-driver (string/lower-case (:storage-driver merged)) + assets-driver (string/lower-case (:assets-driver merged))] + (when-not (#{"sqlite"} storage-driver) + (throw (js/Error. (str "unsupported storage driver: " storage-driver)))) + (when-not (#{"filesystem"} assets-driver) + (throw (js/Error. (str "unsupported assets driver: " assets-driver)))) + (-> merged + (select-keys allowed-config-keys) + (assoc :storage-driver storage-driver + :assets-driver assets-driver)))) diff --git a/deps/db-sync/src/logseq/db_sync/node/dispatch.cljs b/deps/db-sync/src/logseq/db_sync/node/dispatch.cljs new file mode 100644 index 0000000..bd2d412 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/dispatch.cljs @@ -0,0 +1,76 @@ +(ns logseq.db-sync.node.dispatch + (:require [clojure.string :as string] + [logseq.db-sync.common :as common] + [logseq.db-sync.node.graph :as graph] + [logseq.db-sync.node.routes :as node-routes] + [logseq.db-sync.platform.core :as platform] + [logseq.db-sync.worker.handler.assets :as assets-handler] + [logseq.db-sync.worker.handler.index :as index-handler] + [logseq.db-sync.worker.handler.sync :as sync-handler] + [logseq.db-sync.worker.http :as http] + [promesa.core :as p])) + +(defn- admin-token-valid? + [request env] + (let [expected (aget env "DB_SYNC_ADMIN_TOKEN") + actual (.get (.-headers request) "x-db-sync-admin-token")] + (and (string? expected) + (seq expected) + (= expected actual)))) + +(defn handle-node-fetch + [{:keys [request env registry deps]}] + (let [url (platform/request-url request) + path (.-pathname url) + method (.-method request) + index-self #js {:env env :d1 (aget env "DB")}] + (cond + (= path "/health") + (http/json-response :worker/health {:ok true}) + + (or (= path "/graphs") + (string/starts-with? path "/graphs/")) + (index-handler/handle-fetch index-self request) + + (string/starts-with? path "/e2ee") + (index-handler/handle-fetch index-self request) + + (string/starts-with? path "/assets/") + (if (= method "OPTIONS") + (assets-handler/handle request env) + (if-let [{:keys [graph-id]} (assets-handler/parse-asset-path path)] + (if (admin-token-valid? request env) + (assets-handler/handle request env) + (p/let [access-resp (index-handler/graph-access-response request env graph-id)] + (if (.-ok access-resp) + (assets-handler/handle request env) + access-resp))) + (http/bad-request "invalid asset path"))) + + (= method "OPTIONS") + (common/options-response) + + (string/starts-with? path "/sync/") + (if-let [{:keys [graph-id tail]} (node-routes/parse-sync-path path)] + (if (seq graph-id) + (if (= method "OPTIONS") + (common/options-response) + (if (admin-token-valid? request env) + (let [ctx (graph/get-or-create-graph registry deps graph-id) + new-url (js/URL. (str (.-origin url) tail (.-search url)))] + (.set (.-searchParams new-url) "graph-id" graph-id) + (let [rewritten (platform/request (.toString new-url) request)] + (sync-handler/handle-http ctx rewritten))) + (p/let [access-resp (index-handler/graph-access-response request env graph-id)] + (if (.-ok access-resp) + (let [ctx (graph/get-or-create-graph registry deps graph-id) + new-url (js/URL. (str (.-origin url) tail (.-search url)))] + (.set (.-searchParams new-url) "graph-id" graph-id) + (let [rewritten (platform/request (.toString new-url) request)] + (sync-handler/handle-http ctx rewritten))) + access-resp)))) + (http/bad-request "missing graph id")) + (http/bad-request "missing graph id")) + + :else + (http/not-found)))) diff --git a/deps/db-sync/src/logseq/db_sync/node/entry.cljs b/deps/db-sync/src/logseq/db_sync/node/entry.cljs new file mode 100644 index 0000000..e5c6b2a --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/entry.cljs @@ -0,0 +1,18 @@ +(ns logseq.db-sync.node.entry + (:require [logseq.db-sync.node.config :as config] + [logseq.db-sync.node.server :as server] + [logseq.db-sync.sentry.node :as sentry] + [promesa.core :as p])) + +(defonce ^:private *server (atom nil)) + +(defn main [& _args] + (sentry/init!) + (let [cfg (config/normalize-config {})] + (js/console.log "Starting Logseq sync...") + (-> (server/start! cfg) + (p/then (fn [result] + (reset! *server result) + (js/console.log (str "Logseq sync listening on port " (:port result))))) + (p/catch (fn [error] + (js/console.error "Logseq sync failed to start" error)))))) diff --git a/deps/db-sync/src/logseq/db_sync/node/find_checksum_drift.cljs b/deps/db-sync/src/logseq/db_sync/node/find_checksum_drift.cljs new file mode 100644 index 0000000..76dedc0 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/find_checksum_drift.cljs @@ -0,0 +1,199 @@ +(ns logseq.db-sync.node.find-checksum-drift + (:require ["better-sqlite3" :as sqlite3] + ["fs" :as fs] + ["path" :as node-path] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db-sync.checksum :as sync-checksum] + [logseq.db-sync.common :as common] + [logseq.db-sync.storage :as storage] + [logseq.db.frontend.schema :as db-schema])) + +(def sqlite (if (find-ns 'nbb.core) (aget sqlite3 "default") sqlite3)) + +(defn- fail! + [msg] + (binding [*print-fn* *print-err-fn*] + (println (str "Error: " msg))) + (js/process.exit 1)) + +(defn- parse-args + [argv] + (loop [m {} + xs (vec argv)] + (if-let [x (first xs)] + (cond + (some #{x} ["-h" "--help"]) + (recur (assoc m :help? true) (next xs)) + + (= "--db" x) + (if-let [db (second xs)] + (recur (assoc m :db db) (nnext xs)) + (fail! "Missing value for --db")) + + (= "--diff-out" x) + (if-let [diff-out (second xs)] + (recur (assoc m :diff-out diff-out) (nnext xs)) + (fail! "Missing value for --diff-out")) + + :else + (fail! (str "Unknown argument: " x))) + m))) + +(defn- print-help! + [] + (println "Find first tx_log checksum drift in a sqlite graph db.") + (println "") + (println "Usage:") + (println " node worker/dist/find-checksum-drift.js --db ") + (println " node worker/dist/find-checksum-drift.js --db --diff-out ") + (println "") + (println "Output:") + (println " - stored/recomputed checksum for current db") + (println " - first tx_log t where incremental != recomputed during replay") + (println " - optional full current-vs-replayed block diff EDN file")) + +(defn- normalize-sql + [sql] + (-> sql string/trim string/lower-case)) + +(defn- select-sql? + [sql] + (string/starts-with? (normalize-sql sql) "select")) + +(defn- exec-with-args [^js stmt args] + (.apply (.-run stmt) stmt (to-array args))) + +(defn- all-with-args [^js stmt args] + (.apply (.-all stmt) stmt (to-array args))) + +(defn- wrap-db [^js db] + #js {:exec (fn [sql & args] + (if (seq args) + (let [stmt (.prepare db sql)] + (if (select-sql? sql) + (all-with-args stmt args) + (do + (exec-with-args stmt args) + nil))) + (if (select-sql? sql) + (.all (.prepare db sql)) + (.exec db sql)))) + :close (fn [] (.close db)) + :_db db}) + +(defn- short-preview + [tx-data] + (->> tx-data + (take 8) + vec)) + +(defn- tx-rows + [^js db] + (.all (.prepare db "select t, tx, outliner_op from tx_log order by t asc"))) + +(defn- replay-find-first-mismatch + [rows] + (let [conn (d/create-conn db-schema/schema)] + (loop [idx 0 + checksum nil] + (if (>= idx (count rows)) + {:ok? true + :idx idx + :checksum checksum + :recomputed (sync-checksum/recompute-checksum @conn) + :db @conn} + (let [row (nth rows idx) + t (aget row "t") + outliner-op (aget row "outliner_op") + tx-str (aget row "tx") + tx-data (common/read-transit tx-str) + tx-report (d/transact! conn tx-data) + incremental (sync-checksum/update-checksum checksum tx-report) + recomputed (sync-checksum/recompute-checksum (:db-after tx-report))] + (if (= incremental recomputed) + (recur (inc idx) incremental) + {:ok? false + :idx idx + :t t + :outliner-op outliner-op + :prev-checksum checksum + :incremental incremental + :recomputed recomputed + :tx-size (count tx-data) + :tx-preview (short-preview tx-data) + :db (:db-after tx-report)})))))) + +(defn- block-map-by-uuid + [blocks] + (into {} (map (juxt :block/uuid identity) blocks))) + +(defn- diff-blocks + [blocks-a blocks-b] + (let [a-by-id (block-map-by-uuid blocks-a) + b-by-id (block-map-by-uuid blocks-b) + all-ids (set (concat (keys a-by-id) (keys b-by-id)))] + (->> all-ids + (keep (fn [id] + (let [a (get a-by-id id) + b (get b-by-id id)] + (when (not= a b) + {:block/uuid id + :current a + :replayed b})))) + vec))) + +(defn- sorted-block-diffs + [block-diffs] + (sort-by (fn [m] + (some-> (:block/uuid m) str)) + block-diffs)) + +(defn main + [& argv] + (let [{:keys [help? db diff-out]} (parse-args argv)] + (when help? + (print-help!) + (js/process.exit 0)) + (when-not db + (fail! "Missing required --db argument")) + (let [db-path (node-path/resolve db)] + (when-not (.existsSync fs db-path) + (fail! (str "SQLite file not found: " db-path))) + (let [sqlite-db (new sqlite db-path nil) + sql (wrap-db sqlite-db)] + (try + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + db' @conn + stored (storage/get-checksum sql) + recomputed (sync-checksum/recompute-checksum db') + rows (tx-rows sqlite-db) + replay-result (replay-find-first-mismatch rows) + current-diag (sync-checksum/recompute-checksum-diagnostics db') + replay-diag (sync-checksum/recompute-checksum-diagnostics (:db replay-result)) + block-diffs (-> (diff-blocks (:blocks current-diag) (:blocks replay-diag)) + sorted-block-diffs)] + (println (str "db: " db-path)) + (println (str "stored-checksum: " stored)) + (println (str "recomputed-checksum: " recomputed)) + (println (str "tx-log-count: " (count rows))) + (println (str "current-blocks: " (count (:blocks current-diag)) + ", replayed-blocks: " (count (:blocks replay-diag)) + ", different-blocks: " (count block-diffs))) + (when (seq block-diffs) + (println "different-blocks-sample:") + (println (pr-str (take 5 block-diffs)))) + (when (seq diff-out) + (let [diff-out-path (node-path/resolve diff-out)] + (.writeFileSync fs diff-out-path + (str (pr-str block-diffs) "\n") + "utf8") + (println (str "diff-out: " diff-out-path)))) + (if (:ok? replay-result) + (println (pr-str (assoc replay-result + :stored-checksum stored + :final-recomputed recomputed))) + (println (pr-str replay-result)))) + (finally + (.close sql))))))) diff --git a/deps/db-sync/src/logseq/db_sync/node/graph.cljs b/deps/db-sync/src/logseq/db_sync/node/graph.cljs new file mode 100644 index 0000000..8cb7707 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/graph.cljs @@ -0,0 +1,57 @@ +(ns logseq.db-sync.node.graph + (:require [logseq.db-sync.node.storage :as storage])) + +(defn- make-state [] + (let [sockets (atom #{})] + #js {:getWebSockets (fn [] (to-array @sockets)) + :addWebSocket (fn [ws] (swap! sockets conj ws)) + :removeWebSocket (fn [ws] (swap! sockets disj ws))})) + +(defn- env-object [cfg index-db assets-bucket] + (let [allow-unverified-jwt-claims (some-> js/process .-env (aget "DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS")) + env (doto (js-obj) + (aset "DB" index-db) + (aset "LOGSEQ_SYNC_ASSETS" assets-bucket) + ;; Keep node-adapter snapshot stream uncompressed. + (aset "DB_SYNC_SNAPSHOT_STREAM_GZIP" "false") + (aset "COGNITO_ISSUER" (:cognito-issuer cfg)) + (aset "COGNITO_CLIENT_ID" (:cognito-client-id cfg)) + (aset "COGNITO_JWKS_URL" (:cognito-jwks-url cfg)))] + (when (some? allow-unverified-jwt-claims) + (aset env "DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS" allow-unverified-jwt-claims)) + env)) + +(defn graph-context + [{:keys [config index-db assets-bucket]} graph-id] + (let [{:keys [sql]} (storage/open-graph-db (:data-dir config) graph-id) + state (make-state) + env (env-object config index-db assets-bucket)] + #js {:state state + :env env + :sql sql + :conn nil + :schema-ready false})) + +(defn get-or-create-graph + [registry deps graph-id] + (or (get @registry graph-id) + (let [ctx (graph-context deps graph-id)] + (swap! registry assoc graph-id ctx) + ctx))) + +(defn- close-graph-context! + [^js ctx] + (when-let [^js sql (.-sql ctx)] + (when-let [close (.-close sql)] + (close)))) + +(defn delete-graph! + [registry deps graph-id] + (when-let [^js ctx (get @registry graph-id)] + (close-graph-context! ctx) + (swap! registry dissoc graph-id)) + (storage/delete-graph-db! (get-in deps [:config :data-dir]) graph-id)) + +(defn close-graphs! [registry] + (doseq [[_ ^js ctx] @registry] + (close-graph-context! ctx))) diff --git a/deps/db-sync/src/logseq/db_sync/node/recompute_log_checksum.cljs b/deps/db-sync/src/logseq/db_sync/node/recompute_log_checksum.cljs new file mode 100644 index 0000000..1f455a0 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/recompute_log_checksum.cljs @@ -0,0 +1,119 @@ +(ns logseq.db-sync.node.recompute-log-checksum + (:require ["fs" :as fs] + ["path" :as node-path] + [cljs.reader :as reader] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db-sync.checksum :as sync-checksum])) + +(defn- fail! + [msg] + (binding [*print-fn* *print-err-fn*] + (println (str "Error: " msg))) + (js/process.exit 1)) + +(defn- parse-args + [argv] + (loop [m {} + xs (vec argv)] + (if-let [x (first xs)] + (cond + (some #{x} ["-h" "--help"]) + (recur (assoc m :help? true) (next xs)) + + (= "--log" x) + (if-let [log-file (second xs)] + (recur (assoc m :log-file log-file) (nnext xs)) + (fail! "Missing value for --log")) + + :else + (fail! (str "Unknown argument: " x))) + m))) + +(defn- print-help! + [] + (println "Recompute checksum from a :before-checksum-error server log line.") + (println "") + (println "Usage:") + (println " node worker/dist/recompute-log-checksum.js --log /tmp/server1.log") + (println "") + (println "Output:") + (println " - logged incremental and recomputed checksums") + (println " - replayed incremental and recomputed checksums") + (println " - equality checks")) + +(defn- parse-log-payload + [line] + (let [prefix ":debug :before-checksum-error "] + (when-not (string/starts-with? line prefix) + (fail! (str "Log does not start with expected prefix: " prefix))) + (reader/read-string (subs line (count prefix))))) + +(defn- ensure-tx-data + [tx-data] + (cond + (string? tx-data) + (let [decoded (ldb/read-transit-str tx-data)] + (if (sequential? decoded) + decoded + (fail! "Decoded transit tx-data is not sequential"))) + + (sequential? tx-data) + tx-data + + :else + (fail! (str "Unsupported tx-data shape: " (type tx-data))))) + +(defn main + [& argv] + (let [{:keys [help? log-file]} (parse-args argv)] + (when help? + (print-help!) + (js/process.exit 0)) + (when-not log-file + (fail! "Missing required --log argument")) + (let [log-path (node-path/resolve log-file)] + (when-not (.existsSync fs log-path) + (fail! (str "Log file not found: " log-path))) + (let [line (string/trim (.readFileSync fs log-path "utf8")) + payload (parse-log-payload line) + db-before (ldb/read-transit-str (:db-before payload)) + tx-data (vec (ensure-tx-data (:tx-data payload))) + tx-report (d/with db-before tx-data) + input-checksum (or (:prev-checksum payload) + (:current-checksum payload)) + logged-prev-full (:prev-full-checksum payload) + logged-full-after (or (:recomputed-after-checksum payload) + (:full-checksum payload)) + replayed-prev-full (sync-checksum/recompute-checksum db-before) + replayed-incremental-from-full-before + (sync-checksum/update-checksum replayed-prev-full tx-report) + replayed-incremental-from-input + (sync-checksum/update-checksum input-checksum tx-report) + replayed-recomputed (sync-checksum/recompute-checksum (:db-after tx-report)) + result {:log-file log-path + :prev-tx (:prev-tx payload) + :tx-meta (:tx-meta payload) + :tx-count (count tx-data) + :input-checksum input-checksum + :logged-prev-full-checksum logged-prev-full + :replayed-prev-full-checksum replayed-prev-full + :input-checksum-eq-replayed-prev-full? + (= input-checksum replayed-prev-full) + :match-logged-prev-full? + (= logged-prev-full replayed-prev-full) + :logged-new-checksum (:new-checksum payload) + :replayed-incremental replayed-incremental-from-input + :replayed-incremental-from-full-before replayed-incremental-from-full-before + :logged-full-after-checksum logged-full-after + :replayed-recomputed-after replayed-recomputed + :match-logged-new? + (= replayed-incremental-from-input (:new-checksum payload)) + :match-logged-full-after? + (= replayed-recomputed logged-full-after) + :incremental-eq-full? + (= replayed-incremental-from-input replayed-recomputed) + :incremental-from-full-before-eq-full? + (= replayed-incremental-from-full-before replayed-recomputed)}] + (println (pr-str result)))))) diff --git a/deps/db-sync/src/logseq/db_sync/node/routes.cljs b/deps/db-sync/src/logseq/db_sync/node/routes.cljs new file mode 100644 index 0000000..762563e --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/routes.cljs @@ -0,0 +1,16 @@ +(ns logseq.db-sync.node.routes + (:require [clojure.string :as string])) + +(defn parse-sync-path [path] + (when (string/starts-with? path "/sync/") + (let [rest-path (subs path (count "/sync/")) + rest-path (if (string/starts-with? rest-path "/") + (subs rest-path 1) + rest-path) + slash-idx (or (string/index-of rest-path "/") -1) + graph-id (if (neg? slash-idx) rest-path (subs rest-path 0 slash-idx)) + tail (if (neg? slash-idx) + "/" + (subs rest-path slash-idx))] + {:graph-id graph-id + :tail tail}))) diff --git a/deps/db-sync/src/logseq/db_sync/node/server.cljs b/deps/db-sync/src/logseq/db_sync/node/server.cljs new file mode 100644 index 0000000..bc1de14 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/server.cljs @@ -0,0 +1,181 @@ +(ns logseq.db-sync.node.server + (:require ["http" :as http] + ["path" :as node-path] + ["ws" :as ws] + [clojure.string :as string] + [lambdaisland.glogi :as log] + [logseq.db-sync.index :as index] + [logseq.db-sync.logging :as logging] + [logseq.db-sync.node.assets :as assets] + [logseq.db-sync.node.config :as config] + [logseq.db-sync.node.dispatch :as dispatch] + [logseq.db-sync.node.graph :as graph] + [logseq.db-sync.node.routes :as node-routes] + [logseq.db-sync.node.storage :as storage] + [logseq.db-sync.platform.core :as platform] + [logseq.db-sync.platform.node :as platform-node] + [logseq.db-sync.worker.auth :as auth] + [logseq.db-sync.worker.handler.sync :as sync-handler] + [logseq.db-sync.worker.handler.ws :as ws-handler] + [logseq.db-sync.worker.http :as worker-http] + [logseq.db-sync.worker.presence :as presence] + [promesa.core :as p])) + +(logging/install!) + +(defn- make-env [cfg index-db assets-bucket] + (let [allow-unverified-jwt-claims (some-> js/process .-env (aget "DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS")) + env (doto (js-obj) + (aset "DB" index-db) + (aset "LOGSEQ_SYNC_ASSETS" assets-bucket) + ;; Node adapter serves snapshot transit stream without gzip to avoid + ;; browser/adapter content-encoding mismatches during graph download. + (aset "DB_SYNC_SNAPSHOT_STREAM_GZIP" "false") + (aset "COGNITO_ISSUER" (:cognito-issuer cfg)) + (aset "COGNITO_CLIENT_ID" (:cognito-client-id cfg)) + (aset "COGNITO_JWKS_URL" (:cognito-jwks-url cfg)))] + (when (some? allow-unverified-jwt-claims) + (aset env "DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS" allow-unverified-jwt-claims)) + env)) + +(defn- request-origin-opts + [cfg] + (if-let [base-url (:base-url cfg)] + (let [url (js/URL. base-url) + protocol (.-protocol url) + scheme (if (string/ends-with? protocol ":") + (subs protocol 0 (dec (count protocol))) + protocol)] + {:scheme scheme + :host (.-host url)}) + {:scheme "http"})) + +(defn- access-allowed? + [env graph-id request] + (p/let [claims (auth/auth-claims request env) + user-id (when claims (aget claims "sub")) + db (aget env "DB")] + (if (string? user-id) + (index/user claims)] + (when user + (presence/add-presence! ctx socket user)) + (presence/broadcast-online-users! ctx)) + (.on socket "message" + (fn [data] + (let [text (if (string? data) data (.toString data))] + (try + (ws-handler/handle-ws-message! ctx socket text) + (catch :default e + (log/error :db-sync/ws-error e) + (.send socket (js/JSON.stringify #js {:type "error" :message "server error"}))))))) + (.on socket "close" + (fn [] + (presence/remove-presence! ctx socket) + (presence/broadcast-online-users! ctx) + (detach-ws! ctx socket))) + (.on socket "error" + (fn [error] + (presence/remove-presence! ctx socket) + (presence/broadcast-online-users! ctx) + (detach-ws! ctx socket) + (log/error :db-sync/ws-error error)))) + +(defn start! + [overrides] + (let [cfg (config/normalize-config overrides) + request-origin (request-origin-opts cfg) + index-db (storage/open-index-db (:data-dir cfg)) + assets-bucket (assets/make-bucket (node-path/join (:data-dir cfg) "assets")) + registry (atom {}) + deps {:config cfg + :index-db index-db + :assets-bucket assets-bucket} + env (doto (make-env cfg index-db assets-bucket) + (aset "DB_SYNC_DELETE_GRAPH" + (fn [graph-id] + (graph/delete-graph! registry deps graph-id)))) + server (.createServer http + (fn [req res] + (-> (p/let [request (platform-node/request-from-node req request-origin) + response (dispatch/handle-node-fetch {:request request + :env env + :registry registry + :deps deps})] + (platform-node/send-response! res response)) + (p/catch + (fn [e] + (log/error :db-sync/node-request-failed {:error e}) + (js/console.error ":db-sync/node-request-failed" e) + (platform-node/send-response! res (worker-http/error-response "server error" 500))))))) + WSS (or (.-WebSocketServer ws) (.-Server ws)) + ^js wss (new WSS #js {:noServer true})] + (.on server "error" (fn [error] (log/error :db-sync/node-server-error {:error error}))) + (.on wss "error" (fn [error] (log/error :db-sync/node-ws-error {:error error}))) + (p/let [_ (index/") + (println "") + (println "Output:") + (println " - stored checksum (sync_meta.checksum, if present)") + (println " - recomputed checksum (from datascript graph)")) + +(defn- normalize-sql + [sql] + (-> sql string/trim string/lower-case)) + +(defn- select-sql? + [sql] + (string/starts-with? (normalize-sql sql) "select")) + +(defn- exec-with-args [^js stmt args] + (.apply (.-run stmt) stmt (to-array args))) + +(defn- all-with-args [^js stmt args] + (.apply (.-all stmt) stmt (to-array args))) + +(defn- wrap-db [^js db] + #js {:exec (fn [sql & args] + (if (seq args) + (let [stmt (.prepare db sql)] + (if (select-sql? sql) + (all-with-args stmt args) + (do + (exec-with-args stmt args) + nil))) + (if (select-sql? sql) + (.all (.prepare db sql)) + (.exec db sql)))) + :close (fn [] (.close db)) + :_db db}) + +(defn- datom-count + [db] + (count (d/datoms db :eavt))) + +(defn main + [& argv] + (let [{:keys [help? db]} (parse-args argv)] + (when help? + (print-help!) + (js/process.exit 0)) + (when-not db + (fail! "Missing required --db argument")) + (let [db-path (node-path/resolve db)] + (when-not (.existsSync fs db-path) + (fail! (str "SQLite file not found: " db-path))) + (let [sqlite-db (new sqlite db-path nil) + sql (wrap-db sqlite-db)] + (try + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + db' @conn + recomputed-checksum (sync-checksum/recompute-checksum db')] + (println (str "db: " db-path)) + (println (str "datoms: " (datom-count db'))) + (println (str "recomputed-checksum: " recomputed-checksum))) + (finally + (.close sql))))))) diff --git a/deps/db-sync/src/logseq/db_sync/node/storage.cljs b/deps/db-sync/src/logseq/db_sync/node/storage.cljs new file mode 100644 index 0000000..0b71437 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/node/storage.cljs @@ -0,0 +1,58 @@ +(ns logseq.db-sync.node.storage + (:require ["better-sqlite3" :as sqlite3] + ["fs" :as fs] + ["path" :as node-path] + [clojure.string :as string] + [logseq.db.common.sqlite :as common-sqlite])) + +(def sqlite (if (find-ns 'nbb.core) (aget sqlite3 "default") sqlite3)) + +(defn- ensure-dir! [dir] + (.mkdirSync fs dir #js {:recursive true})) + +(defn- normalize-sql [sql] + (-> sql string/trim string/lower-case)) + +(defn- select-sql? [sql] + (string/starts-with? (normalize-sql sql) "select")) + +(defn- exec-with-args [^js stmt args] + (.apply (.-run stmt) stmt (to-array args))) + +(defn- all-with-args [^js stmt args] + (.apply (.-all stmt) stmt (to-array args))) + +(defn- wrap-db [^js db] + #js {:exec (fn [sql & args] + (if (seq args) + (let [stmt (.prepare db sql)] + (if (select-sql? sql) + (all-with-args stmt args) + (do + (exec-with-args stmt args) + nil))) + (if (select-sql? sql) + (.all (.prepare db sql)) + (.exec db sql)))) + :prepare (fn [sql] (.prepare db sql)) + :close (fn [] (.close db)) + :_db db}) + +(defn open-index-db [data-dir] + (let [db-path (node-path/join data-dir "index.sqlite")] + (ensure-dir! (node-path/dirname db-path)) + (wrap-db (new sqlite db-path nil)))) + +(defn open-graph-db [data-dir graph-id] + (let [[graph-name db-path] (common-sqlite/get-db-full-path (node-path/join data-dir "graphs") graph-id)] + (ensure-dir! (node-path/dirname db-path)) + {:graph-name graph-name + :sql (wrap-db (new sqlite db-path nil))})) + +(defn delete-graph-db! + [data-dir graph-id] + (let [[_graph-name db-path] (common-sqlite/get-db-full-path (node-path/join data-dir "graphs") graph-id) + graph-dir (node-path/dirname db-path)] + (when (.existsSync fs graph-dir) + (.rmSync fs graph-dir #js {:recursive true + :force true})))) diff --git a/deps/db-sync/src/logseq/db_sync/order.cljs b/deps/db-sync/src/logseq/db_sync/order.cljs new file mode 100644 index 0000000..c66f416 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/order.cljs @@ -0,0 +1,46 @@ +(ns logseq.db-sync.order + (:require [datascript.core :as d] + [logseq.db.common.order :as db-order])) + +(defn fix-duplicate-orders! [conn tx-data tx-meta] + (let [db @conn + updates (->> tx-data + (filter (fn [[e a v _tx added]] + (and (= a :block/order) added e v + (:block/uuid (d/entity db e)))))) + groups (group-by (fn [{:keys [e v]}] + (let [parent (:block/parent (d/entity db e))] + [(:db/id parent) v])) + updates) + fixes (reduce-kv + (fn [acc [parent-eid value] _group] + (if (and parent-eid value) + (let [siblings (d/datoms db :avet :block/parent parent-eid) + existing-same (->> siblings + (keep (fn [datom] + (let [sib-eid (:e datom)] + (when (= value (:block/order (d/entity db sib-eid))) + sib-eid))))) + same-order-siblings (->> (map (fn [id] (d/entity db id)) existing-same) + (sort-by :block/uuid)) + need-fix? (> (count same-order-siblings) 1)] + (if need-fix? + (let [orders (->> siblings + (keep (fn [d] + (let [sib-eid (:e d)] + (:block/order (d/entity db sib-eid)))))) + end (some (fn [order] + (when (> (compare order value) 0) + order)) + orders) + new-orders (db-order/gen-n-keys (count same-order-siblings) value end)] + (into acc + (map-indexed (fn [idx id] + [:db/add id :block/order (nth new-orders idx)]) + (map :db/id same-order-siblings)))) + acc)) + acc)) + [] + groups)] + (when (seq fixes) + (d/transact! conn fixes (merge tx-meta {:op :fix-duplicate-order}))))) diff --git a/deps/db-sync/src/logseq/db_sync/platform/cloudflare.cljs b/deps/db-sync/src/logseq/db_sync/platform/cloudflare.cljs new file mode 100644 index 0000000..fe9616b --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/platform/cloudflare.cljs @@ -0,0 +1,6 @@ +(ns logseq.db-sync.platform.cloudflare + (:require [logseq.db-sync.platform.core :as core])) + +(def response core/response) +(def request core/request) +(def request-url core/request-url) diff --git a/deps/db-sync/src/logseq/db_sync/platform/core.cljs b/deps/db-sync/src/logseq/db_sync/platform/core.cljs new file mode 100644 index 0000000..d708d88 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/platform/core.cljs @@ -0,0 +1,13 @@ +(ns logseq.db-sync.platform.core) + +(defn response + [body init] + (js/Response. body init)) + +(defn request + [url init] + (js/Request. url init)) + +(defn request-url + [request'] + (js/URL. (.-url request'))) diff --git a/deps/db-sync/src/logseq/db_sync/platform/node.cljs b/deps/db-sync/src/logseq/db_sync/platform/node.cljs new file mode 100644 index 0000000..c73f4f9 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/platform/node.cljs @@ -0,0 +1,50 @@ +(ns logseq.db-sync.platform.node + (:require [clojure.string :as string] + [logseq.db-sync.platform.core :as core] + [promesa.core :as p])) + +(defn- headers->object [headers] + (let [out (js-obj)] + (.forEach headers (fn [value key] (aset out key value))) + out)) + +(defn request-from-node + [^js req {:keys [scheme host]}] + (let [headers (js/Headers.) + node-headers (.-headers req) + header-keys (js/Object.keys node-headers) + _ (doseq [k header-keys] + (let [value (aget node-headers k)] + (when (some? value) + (.set headers (string/lower-case k) value)))) + method (or (.-method req) "GET") + host (or host (aget node-headers "host") "localhost") + scheme (or scheme "http") + url (str scheme "://" host (.-url req)) + init #js {:method method + :headers headers}] + (when-not (or (= method "GET") (= method "HEAD")) + (aset init "body" req) + (aset init "duplex" "half")) + (core/request url init))) + +(defn send-response! + [^js res ^js response] + (let [headers (headers->object (.-headers response)) + status (.-status response)] + (.writeHead res status headers) + (if-let [body (.-body response)] + (let [^js stream (try + (let [Readable (.-Readable (js/require "stream"))] + (when (and Readable (.-fromWeb Readable)) + (.fromWeb Readable body))) + (catch :default _ nil))] + (if stream + (do + (.pipe stream res) + (js/Promise.resolve nil)) + (p/let [buf (.arrayBuffer response)] + (.end res (js/Buffer.from buf))))) + (do + (.end res) + (js/Promise.resolve nil))))) diff --git a/deps/db-sync/src/logseq/db_sync/protocol.cljs b/deps/db-sync/src/logseq/db_sync/protocol.cljs new file mode 100644 index 0000000..75bc30d --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/protocol.cljs @@ -0,0 +1,27 @@ +(ns logseq.db-sync.protocol + (:require [clojure.walk :as walk] + [logseq.db-sync.common :as common])) + +(defn- stringify-uuid + [value] + (if (uuid? value) + (str value) + value)) + +(defn parse-message [raw] + (try + (let [data (js->clj (js/JSON.parse raw) :keywordize-keys true)] + (when (map? data) + data)) + (catch :default _ + nil))) + +(defn encode-message [m] + (js/JSON.stringify + (clj->js (walk/postwalk stringify-uuid m)))) + +(defn transit->tx [value] + (common/read-transit value)) + +(defn tx->transit [tx-data] + (common/write-transit tx-data)) diff --git a/deps/db-sync/src/logseq/db_sync/sentry.cljs b/deps/db-sync/src/logseq/db_sync/sentry.cljs new file mode 100644 index 0000000..21dde3a --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/sentry.cljs @@ -0,0 +1,19 @@ +(ns logseq.db-sync.sentry) + +(defn- not-blank [v] + (when (and (string? v) (seq v)) v)) + +(defn- parse-sample-rate [v] + (let [n (js/parseFloat v)] + (when (and (not (js/isNaN n)) (<= 0 n 1)) n))) + +(defn options-from-env [^js env] + (let [dsn (not-blank (aget env "SENTRY_DSN")) + release (not-blank (aget env "SENTRY_RELEASE")) + environment (not-blank (aget env "SENTRY_ENVIRONMENT")) + traces-sample-rate (parse-sample-rate (aget env "SENTRY_TRACES_SAMPLE_RATE"))] + (when dsn + (cond-> {:dsn dsn} + release (assoc :release release) + environment (assoc :environment environment) + traces-sample-rate (assoc :tracesSampleRate traces-sample-rate))))) diff --git a/deps/db-sync/src/logseq/db_sync/sentry/node.cljs b/deps/db-sync/src/logseq/db_sync/sentry/node.cljs new file mode 100644 index 0000000..b2ba0c3 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/sentry/node.cljs @@ -0,0 +1,16 @@ +(ns logseq.db-sync.sentry.node + (:require [logseq.db-sync.sentry :as sentry-config])) + +(defonce ^:private *sentry (atom nil)) + +(defn- sentry! [] + (or @*sentry + (reset! *sentry (js/require "@sentry/node")))) + +(defn init! [] + (when-let [opts (sentry-config/options-from-env (.-env js/process))] + (.init (sentry!) (clj->js opts)))) + +(defn capture-exception! [error] + (when-let [^js sentry @*sentry] + (.captureException sentry error))) diff --git a/deps/db-sync/src/logseq/db_sync/sentry/worker.cljs b/deps/db-sync/src/logseq/db_sync/sentry/worker.cljs new file mode 100644 index 0000000..b27605e --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/sentry/worker.cljs @@ -0,0 +1,11 @@ +(ns logseq.db-sync.sentry.worker + (:require ["@sentry/cloudflare" :as sentry] + [logseq.db-sync.sentry :as sentry-config])) + +(defn wrap-handler [handler] + (sentry/withSentry (fn [^js env] + (clj->js (or (sentry-config/options-from-env env) {}))) + handler)) + +(defn capture-exception! [error] + (sentry/captureException error)) diff --git a/deps/db-sync/src/logseq/db_sync/snapshot.cljs b/deps/db-sync/src/logseq/db_sync/snapshot.cljs new file mode 100644 index 0000000..34c6047 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/snapshot.cljs @@ -0,0 +1,62 @@ +(ns logseq.db-sync.snapshot + (:require [cognitect.transit :as transit])) + +(def ^:private transit-w (transit/writer :json)) +(def ^:private transit-r (transit/reader :json)) +(def ^:private text-decoder (js/TextDecoder.)) + +(defn- ->uint8 + [data] + (cond + (instance? js/Uint8Array data) data + (instance? js/ArrayBuffer data) (js/Uint8Array. data) + (string? data) (.encode (js/TextEncoder.) data) + :else (js/Uint8Array. data))) + +(defn encode-rows + [rows] + (->uint8 (transit/write transit-w rows))) + +(defn decode-rows + [payload] + (transit/read transit-r (.decode text-decoder (->uint8 payload)))) + +(defn concat-bytes + [^js a ^js b] + (cond + (nil? a) b + (nil? b) a + :else + (let [out (js/Uint8Array. (+ (.-byteLength a) (.-byteLength b)))] + (.set out a 0) + (.set out b (.-byteLength a)) + out))) + +(defn parse-framed-chunk + [buffer chunk] + (let [data (concat-bytes buffer chunk) + total (.-byteLength data)] + (loop [offset 0 + rows []] + (if (< (- total offset) 4) + {:rows rows + :buffer (when (< offset total) + (.slice data offset total))} + (let [view (js/DataView. (.-buffer data) offset 4) + len (.getUint32 view 0 false) + next-offset (+ offset 4 len)] + (if (<= next-offset total) + (let [payload (.slice data (+ offset 4) next-offset) + decoded (decode-rows payload)] + (recur next-offset (into rows decoded))) + {:rows rows + :buffer (.slice data offset total)})))))) + +(defn finalize-framed-buffer + [buffer] + (if (or (nil? buffer) (zero? (.-byteLength buffer))) + [] + (let [{:keys [rows buffer]} (parse-framed-chunk nil buffer)] + (if (and (seq rows) (or (nil? buffer) (zero? (.-byteLength buffer)))) + rows + (throw (ex-info "incomplete framed buffer" {:buffer buffer :rows rows})))))) diff --git a/deps/db-sync/src/logseq/db_sync/storage.cljs b/deps/db-sync/src/logseq/db_sync/storage.cljs new file mode 100644 index 0000000..513b69f --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/storage.cljs @@ -0,0 +1,218 @@ +(ns logseq.db-sync.storage + (:require + [cljs-bean.core :as bean] + [clojure.string :as string] + [datascript.core :as d] + [datascript.storage :refer [IStorage]] + [logseq.db-sync.checksum :as sync-checksum] + [logseq.db-sync.common :as common] + [logseq.db.common.normalize :as db-normalize] + [logseq.db.common.sqlite :as common-sqlite] + [logseq.db.frontend.schema :as db-schema])) + +(def ^:private tx-log-outliner-op-migration-sql + "alter table tx_log add column outliner_op TEXT") + +(defn- duplicate-column-error? + [error column-name] + (let [message (-> (or (ex-message error) (some-> error .-message) (str error)) + string/lower-case)] + (and (string/includes? message "duplicate column") + (string/includes? message (string/lower-case column-name))))) + +(defn- ensure-tx-log-outliner-op-column! + [sql] + (try + (common/sql-exec sql tx-log-outliner-op-migration-sql) + (catch :default error + (when-not (duplicate-column-error? error "outliner_op") + (throw error))))) + +;; TODO: GC kvs table + +(defn init-schema! [sql] + (common/sql-exec sql "create table if not exists kvs (addr INTEGER primary key, content TEXT, addresses JSON)") + (common/sql-exec sql + (str "create table if not exists tx_log (" + "t INTEGER primary key," + "tx TEXT not null," + "created_at INTEGER" + ");")) + (ensure-tx-log-outliner-op-column! sql) + (common/sql-exec sql + (str "create table if not exists sync_meta (" + "key TEXT primary key," + "value TEXT" + ");"))) + +(defn- select-one [sql sql-str & args] + (first (common/get-sql-rows (apply common/sql-exec sql sql-str args)))) + +(defn get-meta [sql k] + (when-let [row (select-one sql "select value from sync_meta where key = ?" (name k))] + (aget row "value"))) + +(defn set-meta! [sql k v] + (common/sql-exec sql + (str "insert into sync_meta (key, value) values (?, ?)" + " on conflict(key) do update set value = excluded.value") + (name k) + (str v))) + +(defn get-checksum [sql] + (get-meta sql :checksum)) + +(defn set-checksum! [sql checksum] + (set-meta! sql :checksum checksum)) + +(defn get-t [sql] + (let [value (get-meta sql :t)] + (if (string? value) + (js/parseInt value 10) + 0))) + +(defn set-t! [sql t] + (set-meta! sql :t t)) + +(def ^:dynamic *in-sql-transaction?* false) + +(defn with-sql-transaction! + [sql f] + (if *in-sql-transaction?* + (f) + (let [f' (fn [] + (binding [*in-sql-transaction?* true] + (f)))] + (if-let [db (aget sql "_db")] + (let [transaction (.-transaction db)] + (if (fn? transaction) + (let [tx-fn (.call transaction db f')] + (tx-fn)) + (f'))) + (f'))))) + +(defn set-initial-checksum! [sql checksum] + (with-sql-transaction! + sql + (fn [] + (let [existing-checksum (get-checksum sql) + current-t (get-t sql)] + (cond + (and (some? existing-checksum) + (not= existing-checksum checksum)) + (throw (ex-info "Cannot overwrite existing checksum with snapshot checksum" + {:type :db-sync/stale-snapshot-checksum + :existing-checksum existing-checksum + :snapshot-checksum checksum})) + + (and (nil? existing-checksum) + (pos? current-t)) + (throw (ex-info "Cannot initialize checksum after tx history advanced" + {:type :db-sync/snapshot-checksum-after-tx-log + :t current-t + :snapshot-checksum checksum})) + + (nil? existing-checksum) + (set-checksum! sql checksum)))))) + +(defn- outliner-op->sql [outliner-op] + (cond + (keyword? outliner-op) (name outliner-op) + (string? outliner-op) outliner-op + :else nil)) + +(defn- sql->outliner-op [value] + (when (string? value) + (keyword value))) + +(defn append-tx! [sql t tx-str created-at outliner-op] + (common/sql-exec sql + (str "insert into tx_log (t, tx, created_at, outliner_op) values (?, ?, ?, ?)" + " on conflict(t) do update set tx = excluded.tx, created_at = excluded.created_at, outliner_op = excluded.outliner_op") + t + tx-str + created-at + (outliner-op->sql outliner-op))) + +(defn fetch-tx-since [sql since-t] + (let [rows (common/get-sql-rows + (common/sql-exec sql + "select t, tx, outliner_op from tx_log where t > ? order by t asc" + since-t))] + (mapv (fn [row] + {:t (aget row "t") + :tx (aget row "tx") + :outliner-op (sql->outliner-op (aget row "outliner_op"))}) + rows))) + +(defn- upsert-addr-content! [sql data] + (doseq [item data] + (common/sql-exec sql + (str "insert into kvs (addr, content, addresses) values (?, ?, ?)" + " on conflict(addr) do update set content = excluded.content, addresses = excluded.addresses") + (aget item "addr") + (aget item "content") + (aget item "addresses")))) + +(defn- restore-data-from-addr [sql addr] + (when-let [row (select-one sql "select content, addresses from kvs where addr = ?" addr)] + (let [{:keys [content addresses]} (bean/->clj row) + addresses (when addresses (js/JSON.parse addresses)) + data (common/read-transit content)] + (if (and addresses (map? data)) + (assoc data :addresses addresses) + data)))) + +(defn new-sqlite-storage [sql] + (reify IStorage + (-store [_ addr+data-seq _delete-addrs] + (let [data (map + (fn [[addr data]] + (let [data' (if (map? data) (dissoc data :addresses) data) + addresses (when (map? data) + (when-let [addresses (:addresses data)] + (js/JSON.stringify (bean/->js addresses))))] + #js {"addr" addr + "content" (common/write-transit data') + "addresses" addresses})) + addr+data-seq)] + (upsert-addr-content! sql data))) + (-restore [_ addr] + (restore-data-from-addr sql addr)))) + +(defn- append-tx-for-tx-report + [sql {:keys [db-after db-before tx-data tx-meta] :as tx-report}] + (when-not (or (:db-sync/skip-tx-log? tx-meta) + (empty? tx-data)) + (let [created-at (common/now-ms) + normalized-data (->> tx-data + (db-normalize/normalize-tx-data db-after db-before) + vec) + tx-str (common/write-transit normalized-data)] + (with-sql-transaction! + sql + (fn [] + (let [new-t (inc (get-t sql))] + (append-tx! sql new-t tx-str created-at (:outliner-op tx-meta)) + (set-t! sql new-t) + (when-not (:db-sync/skip-checksum-update? tx-meta) + (let [prev-checksum (get-checksum sql) + checksum (sync-checksum/update-checksum + prev-checksum + (assoc tx-report :tx-data normalized-data))] + (set-checksum! sql checksum))))))))) + +(defn- listen-db-updates! + [sql conn] + (d/listen! conn ::listen-db-updates + (fn [tx-report] + (append-tx-for-tx-report sql tx-report)))) + +(defn open-conn + [sql] + (init-schema! sql) + (let [storage (new-sqlite-storage sql) + schema db-schema/schema + conn (common-sqlite/get-storage-conn storage schema)] + (listen-db-updates! sql conn) + conn)) diff --git a/deps/db-sync/src/logseq/db_sync/tx_sanitize.cljs b/deps/db-sync/src/logseq/db_sync/tx_sanitize.cljs new file mode 100644 index 0000000..bcac825 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/tx_sanitize.cljs @@ -0,0 +1,208 @@ +(ns logseq.db-sync.tx-sanitize + (:require [clojure.set :as set] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.frontend.kv-entity :as kv-entity])) + +(def ^:private retract-entity-ops + #{:db/retractEntity :db.fn/retractEntity}) + +(defn- retract-entity-op? + [item] + (and (vector? item) + (= 2 (count item)) + (contains? retract-entity-ops (first item)))) + +(defn- entity-ref->eid + [db entity-ref] + (cond + (and (number? entity-ref) (neg? entity-ref)) + nil + + :else + (try + (some-> (d/entity db entity-ref) :db/id) + (catch :default _ + nil)))) + +(def ^:private entity-op-kinds + #{:db/add :db/retract :db/cas :db.fn/cas}) + +(defn- entity-op? + [item] + (and (vector? item) + (<= 4 (count item)) + (contains? entity-op-kinds (first item)))) + +(def ^:private ignored-kv-entities + (into #{} + (keep (fn [[kw config]] + (when (get-in config [:rtc :rtc/ignore-entity-when-init-upload]) + kw))) + kv-entity/kv-entities)) + +(defn- tx-ignored-kv-entity-refs + [tx-data] + (into ignored-kv-entities + (keep (fn [item] + (cond + (and (map? item) + (contains? ignored-kv-entities (:db/ident item))) + (:db/id item) + + (and (entity-op? item) + (= :db/ident (nth item 2)) + (contains? ignored-kv-entities (nth item 3))) + (second item)))) + tx-data)) + +(defn- ignored-kv-entity-ref? + [db ignored-entity-refs entity-ref] + (or (contains? ignored-entity-refs entity-ref) + (when-let [eid (entity-ref->eid db entity-ref)] + (contains? ignored-kv-entities (:db/ident (d/entity db eid)))))) + +(defn- ignored-kv-entity-op? + [db ignored-entity-refs item] + (cond + (and (map? item) (contains? item :db/id)) + (ignored-kv-entity-ref? db ignored-entity-refs (:db/id item)) + + (retract-entity-op? item) + (ignored-kv-entity-ref? db ignored-entity-refs (second item)) + + (entity-op? item) + (ignored-kv-entity-ref? db ignored-entity-refs (second item)) + + :else + false)) + +(defn- strip-ignored-kv-entity-ops + [db tx-data] + (let [ignored-entity-refs (tx-ignored-kv-entity-refs tx-data)] + (remove (partial ignored-kv-entity-op? db ignored-entity-refs) tx-data))) + +(defn- drop-ops-targeting-retracted-entities + [db tx-data] + (let [retract-eids (->> tx-data + (keep (fn [item] + (when (retract-entity-op? item) + (entity-ref->eid db (second item))))) + set)] + (if (empty? retract-eids) + tx-data + (remove (fn [item] + (and (entity-op? item) + (contains? retract-eids + (entity-ref->eid db (second item))))) + tx-data)))) + +(def ^:private migration-deleted-attrs + #{:block/path-refs + :block/pre-block? + :logseq.property.embedding/hnsw-label + :logseq.property.embedding/hnsw-label-updated-at}) + +(defn- strip-migration-deleted-attrs + [tx-data] + (keep (fn [item] + (when-not (and (vector? item) + (<= 4 (count item)) + (entity-op? item) + (contains? migration-deleted-attrs (nth item 2))) + item)) + tx-data)) + +(def ^:private encrypted-attrs + #{:block/title :block/name}) + +(defn- drop-conflicted-encrypted-retracts + "When encrypted tx data is decrypted, old/new ciphertexts can collapse to the + same plaintext value. A valid pair like + [:db/retract e :block/title old-cipher] + [:db/add e :block/title new-cipher] + may become a same-key add/retract pair. Keep the add, drop the retract." + [tx-data] + (let [conflicted-keys (->> tx-data + (keep (fn [item] + (when (and (vector? item) + (<= 4 (count item)) + (contains? entity-op-kinds (first item)) + (contains? encrypted-attrs (nth item 2))) + {:op (first item) + :k [(second item) (nth item 2) (nth item 3)]}))) + (group-by :k) + (keep (fn [[k entries]] + (let [ops (set (map :op entries))] + (when (and (contains? ops :db/add) + (contains? ops :db/retract)) + k)))) + set)] + (if (empty? conflicted-keys) + tx-data + (remove (fn [item] + (and (vector? item) + (<= 4 (count item)) + (= :db/retract (first item)) + (contains? conflicted-keys + [(second item) (nth item 2) (nth item 3)]))) + tx-data)))) + +(defn- touched-entity-eid + [db item] + (cond + (and (map? item) (contains? item :db/id)) + (entity-ref->eid db (:db/id item)) + + (and (map? item) (contains? item :block/uuid)) + (entity-ref->eid db [:block/uuid (:block/uuid item)]) + + (and (vector? item) + (contains? entity-op-kinds (first item)) + (<= 4 (count item))) + (entity-ref->eid db (second item)) + + :else + nil)) + +(defn sanitize-tx + ([db tx-data] + (sanitize-tx db tx-data nil)) + ([db tx-data {:keys [drop-missing-retract-ops? + drop-ops-targeting-retracted-entities? + retract-touched-descendants?] + :or {drop-missing-retract-ops? false + drop-ops-targeting-retracted-entities? false + retract-touched-descendants? false}}] + (let [tx-data* (cond->> (strip-migration-deleted-attrs tx-data) + (seq ignored-kv-entities) + (strip-ignored-kv-entity-ops db) + drop-missing-retract-ops? + (remove (fn [item] + (and (retract-entity-op? item) + (nil? (entity-ref->eid db (second item)))))) + drop-ops-targeting-retracted-entities? + (drop-ops-targeting-retracted-entities db)) + tx-data* (drop-conflicted-encrypted-retracts tx-data*) + tx-data* (vec tx-data*) + retract-eids (->> tx-data* + (keep (fn [item] + (when (retract-entity-op? item) + (entity-ref->eid db (second item))))) + set) + touched-eids (->> tx-data* + (remove retract-entity-op?) + (keep (partial touched-entity-eid db)) + set) + descendant-retract-eids (->> retract-eids + (mapcat (fn [eid] + (let [entity (d/entity db eid)] + (when (:block/uuid entity) + (ldb/get-block-full-children-ids db eid))))) + set) + preserved-eids (cond-> retract-eids + (not retract-touched-descendants?) + (set/union touched-eids)) + missing-retract-eids (sort (set/difference descendant-retract-eids preserved-eids))] + (cond-> tx-data* + (seq missing-retract-eids) + (into (map (fn [eid] [:db/retractEntity eid]) missing-retract-eids)))))) diff --git a/deps/db-sync/src/logseq/db_sync/worker.cljs b/deps/db-sync/src/logseq/db_sync/worker.cljs new file mode 100644 index 0000000..475bf7c --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker.cljs @@ -0,0 +1,77 @@ +(ns logseq.db-sync.worker + ;; Turn off false defclass errors + {:clj-kondo/config {:linters {:unresolved-symbol {:level :off}}}} + (:require ["cloudflare:workers" :refer [DurableObject]] + [lambdaisland.glogi :as log] + [logseq.db-sync.common :as common] + [logseq.db-sync.logging :as logging] + [logseq.db-sync.sentry.worker :as sentry] + [logseq.db-sync.worker.dispatch :as dispatch] + [logseq.db-sync.worker.handler.sync :as sync-handler] + [logseq.db-sync.worker.handler.ws :as ws-handler] + [logseq.db-sync.worker.presence :as presence] + [logseq.db-sync.worker.ws :as ws] + [promesa.core :as p] + [shadow.cljs.modern :refer (defclass)])) + +(logging/install!) + +(def worker + (sentry/wrap-handler + #js {:fetch (fn [request env _ctx] + (dispatch/handle-worker-fetch request env))})) + +(defclass SyncDO + (extends DurableObject) + + (constructor [this ^js state env] + (super state env) + (set! (.-state this) state) + (set! (.-env this) env) + (set! (.-sql this) (.-sql ^js (.-storage state))) + (set! (.-conn this) nil) + (set! (.-schema-ready this) false) + (let [presence (presence/presence* this) + sockets (.getWebSockets state)] + (doseq [^js ws sockets] + (when-let [attachment (.deserializeAttachment ws)] + (when-let [user (presence/attachment->user attachment)] + (swap! presence assoc ws user)))) + (.setWebSocketAutoResponse + state + (js/WebSocketRequestResponsePair. "ping" "pong")))) + + Object + (fetch [this request] + (-> + (p/do + (if (common/upgrade-request? request) + (ws-handler/handle-ws this request) + (sync-handler/handle-http this request))) + (p/catch (fn [error] + (let [message (cond + (instance? ExceptionInfo error) (str (.-message error) " | " (pr-str (ex-data error))) + (instance? js/Error error) (.-message error) + :else (pr-str error)) + stack (when (instance? js/Error error) (.-stack error))] + (common/json-response + {:error "DO internal error" + :debug-message message + :debug-stack stack} + 500)))))) + (webSocketMessage [this ws message] + (try + (ws-handler/handle-ws-message! this ws message) + (catch :default e + (sentry/capture-exception! e) + (log/error :db-sync/ws-error e) + (js/console.error e) + (ws/send! ws {:type "error" :message "server error"})))) + (webSocketClose [this ws _code _reason] + (presence/remove-presence! this ws) + (presence/broadcast-online-users! this)) + (webSocketError [this ws error] + (presence/remove-presence! this ws) + (presence/broadcast-online-users! this) + (sentry/capture-exception! error) + (log/error :db-sync/ws-error error))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/asset_link.cljs b/deps/db-sync/src/logseq/db_sync/worker/asset_link.cljs new file mode 100644 index 0000000..e116875 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/asset_link.cljs @@ -0,0 +1,58 @@ +(ns logseq.db-sync.worker.asset-link + (:require [logseq.db-sync.worker.handler.assets :as assets-handler] + [promesa.core :as p])) + +(def ^:private ttl-seconds 300) + +(defn- secret-key [^js env usages] + (let [secret (aget env "ASSET_LINK_SECRET")] + (if-not (and (string? secret) (seq secret)) + (p/rejected (ex-info "missing ASSET_LINK_SECRET" {:binding "ASSET_LINK_SECRET"})) + (.importKey (.-subtle js/crypto) + "raw" + (.encode (js/TextEncoder.) secret) + #js {:name "HMAC" :hash "SHA-256"} + false + (clj->js usages))))) + +(defn- signature-hex [buffer] + (->> (js/Uint8Array. buffer) + (map #(-> (.toString % 16) (.padStart 2 "0"))) + (apply str))) + +(defn- hex-bytes [value] + (when (and (string? value) (zero? (mod (count value) 2))) + (let [payload-bytes (js/Uint8Array. (/ (count value) 2))] + (doseq [index (range (.-length payload-bytes))] + (aset payload-bytes index (js/parseInt (subs value (* index 2) (+ (* index 2) 2)) 16))) + payload-bytes))) + +(defn- payload [graph-id file expires] + (str graph-id "/" file ":" expires)) + +(defn expires now) (<= expires (+ now ttl-seconds))) + (p/resolved false) + (let [file (str (:asset-uuid parsed) "." (:asset-type parsed)) + data (.encode (js/TextEncoder.) (payload (:graph-id parsed) file expires))] + (p/let [key (secret-key env ["verify"])] + (.verify (.-subtle js/crypto) "HMAC" key signature data)))))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/auth.cljs b/deps/db-sync/src/logseq/db_sync/worker/auth.cljs new file mode 100644 index 0000000..f9f62aa --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/auth.cljs @@ -0,0 +1,83 @@ +(ns logseq.db-sync.worker.auth + (:require [clojure.string :as string] + [logseq.common.authorization :as authorization] + [promesa.core :as p])) + +(defn- bearer-token [auth-header] + (when (and (string? auth-header) (string/starts-with? auth-header "Bearer ")) + (subs auth-header 7))) + +(defn token-from-request [request] + (or (bearer-token (.get (.-headers request) "authorization")) + (let [url (js/URL. (.-url request))] + (.get (.-searchParams url) "token")))) + +(defn- decode-jwt-part [part] + (let [pad (if (pos? (mod (count part) 4)) + (apply str (repeat (- 4 (mod (count part) 4)) "=")) + "") + base64 (-> (str part pad) + (string/replace "-" "+") + (string/replace "_" "/")) + raw (js/atob base64)] + (js/JSON.parse raw))) + +(defn unsafe-jwt-claims [token] + (try + (when (string? token) + (let [parts (string/split token #"\.")] + (when (= 3 (count parts)) + (decode-jwt-part (nth parts 1))))) + (catch :default _ + nil))) + +(def ^:private recoverable-auth-errors + #{"invalid" "iss not found" "aud not found" "exp" "kid"}) + +(def ^:private truthy-env-values + #{"1" "true" "yes" "on"}) + +(defn- recoverable-auth-error? + [error] + (when error + (let [message (or (ex-message error) (some-> error .-message))] + (contains? recoverable-auth-errors message)))) + +(defn- env-flag-enabled? + [env k] + (let [v (some-> env (aget k))] + (cond + (true? v) true + (false? v) false + (string? v) (contains? truthy-env-values (string/lower-case v)) + :else false))) + +(defn- allow-unverified-jwt-claims? + [env] + (env-flag-enabled? env "DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS")) + +(defn- expired-token? + [token] + (when-let [claims (unsafe-jwt-claims token)] + (let [exp (aget claims "exp") + now-s (js/Math.floor (/ (.now js/Date) 1000))] + (and (number? exp) + (<= exp now-s))))) + +(defn auth-claims [request env] + (let [token (token-from-request request)] + (if (string? token) + (if (expired-token? token) + (p/resolved nil) + (-> (authorization/verify-jwt token env) + (p/catch (fn [error] + (cond + (recoverable-auth-error? error) + nil + + (allow-unverified-jwt-claims? env) + (unsafe-jwt-claims token) + + :else + (p/rejected error)))))) + (p/resolved nil)))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/coerce.cljs b/deps/db-sync/src/logseq/db_sync/worker/coerce.cljs new file mode 100644 index 0000000..27ee800 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/coerce.cljs @@ -0,0 +1,13 @@ +(ns logseq.db-sync.worker.coerce + (:require [lambdaisland.glogi :as log])) + +(def invalid-coerce ::invalid-coerce) + +(defn coerce + [coercer value context] + (try + (coercer value) + (catch :default e + (js/console.error "DEBUG coerce FAILED:" (pr-str context) "value:" (pr-str value) "error:" e) + (log/error :db-sync/malli-coerce-failed (merge context {:error e :value value})) + invalid-coerce))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/dispatch.cljs b/deps/db-sync/src/logseq/db_sync/worker/dispatch.cljs new file mode 100644 index 0000000..6082807 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/dispatch.cljs @@ -0,0 +1,239 @@ +(ns logseq.db-sync.worker.dispatch + (:require [clojure.string :as string] + [lambdaisland.glogi :as log] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [logseq.db-sync.platform.core :as platform] + [logseq.db-sync.worker.auth :as auth] + [logseq.db-sync.worker.asset-link :as asset-link] + [logseq.db-sync.worker.handler.assets :as assets-handler] + [logseq.db-sync.worker.handler.index :as index-handler] + [logseq.db-sync.worker.http :as http] + [logseq.db-sync.worker.routes.semantic :as semantic-routes] + [promesa.core :as p])) + +(defn- admin-token-valid? + [request ^js env] + (let [expected (aget env "DB_SYNC_ADMIN_TOKEN") + actual (.get (.-headers request) "x-db-sync-admin-token")] + (and (string? expected) + (seq expected) + (= expected actual)))) + +(defn- forward-sync-request + [request ^js env graph-id ^js new-url] + (let [^js namespace (.-LOGSEQ_SYNC_DO env) + do-id (.idFromName namespace graph-id) + stub (.get namespace do-id)] + (if (common/upgrade-request? request) + (.fetch stub request) + (do + (.set (.-searchParams new-url) "graph-id" graph-id) + (let [rewritten (platform/request (.toString new-url) request)] + (.fetch stub rewritten)))))) + +(defn- scopes [claims] + (-> (or (some-> claims (aget "scope")) "") + (string/split #"\s+") + set)) + +(defn- forward-semantic-request [request ^js env {:keys [internal-path path-params]} ^js url] + (let [graph-id (:graph-id path-params) + path (reduce-kv (fn [result k value] + (string/replace result (str ":" (name k)) value)) + internal-path + path-params) + target (js/URL. (str (.-origin url) path (.-search url)))] + (.set (.-searchParams target) "graph-id" graph-id) + (forward-sync-request request env graph-id target))) + +(defn- rate-limit-response [] + (js/Response. (js/JSON.stringify #js {:error "rate limit exceeded"}) + #js {:status 429 + :headers #js {"content-type" "application/json" + "retry-after" "60"}})) + +(defn- semantic-limit [^js url] + (let [raw (.get (.-searchParams url) "limit") + parsed (when raw (js/parseInt raw 10))] + (cond + (nil? raw) 50 + (or (js/isNaN parsed) (not (pos? parsed))) nil + :else (min parsed 200)))) + +(defn- handle-semantic-graphs-list [^js env ^js url claims operation] + (let [limit (semantic-limit url) + cursor (.get (.-searchParams url) "cursor") + name (.get (.-searchParams url) "name")] + (cond + (nil? limit) (http/bad-request "invalid limit") + (and cursor (nil? (index/decode-semantic-graph-cursor cursor))) (http/bad-request "invalid cursor") + :else + (let [^js limiter (aget env "SEMANTIC_READ_RATE_LIMITER")] + (if-not limiter + (http/error-response "rate limiter unavailable" 503) + (p/let [result (.limit limiter #js {:key (str (aget claims "sub") ":" + (:operation-id operation))})] + (if (false? (aget result "success")) + (rate-limit-response) + (p/let [result (index/ claims (aget "sub"))] + (when (string? user-id) + user-id))) + +(defn- (p/let [_ (index/ + (p/do + (let [url (platform/request-url request) + path (.-pathname url) + method (.-method request)] + (cond + (= path "/health") + (http/json-response :worker/health {:ok true}) + + (= path "/openapi.json") + (http/json-response nil (semantic-routes/openapi-document (or (aget env "COGNITO_ISSUER") ""))) + + (semantic-routes/match-public method path) + (handle-semantic-request request env url (semantic-routes/match-public method path)) + + (or (= path "/graphs") + (string/starts-with? path "/graphs/")) + (index-handler/handle-fetch #js {:env env :d1 (aget env "DB")} request) + + (string/starts-with? path "/admin/graphs/") + (index-handler/handle-fetch #js {:env env :d1 (aget env "DB")} request) + + (string/starts-with? path "/e2ee") + (index-handler/handle-fetch #js {:env env :d1 (aget env "DB")} request) + + (string/starts-with? path "/assets/") + (if (= method "OPTIONS") + (assets-handler/handle request env) + (if-let [{:keys [graph-id]} (assets-handler/parse-asset-path path)] + (if (admin-token-valid? request env) + (assets-handler/handle request env) + (p/let [signed? (if (= method "GET") (asset-link/content-type + {"png" "image/png" + "jpg" "image/jpeg" + "jpeg" "image/jpeg" + "gif" "image/gif" + "webp" "image/webp" + "bmp" "image/bmp" + "svg" "image/svg+xml" + "ico" "image/x-icon" + "pdf" "application/pdf"}) + +(defn- response-content-type [stored-content-type asset-type] + (or (get asset-type->content-type (string/lower-case asset-type)) + stored-content-type + "application/octet-stream")) + +(defn- parse-size + [size] + (cond + (number? size) size + (string? size) (let [n (js/parseInt size 10)] + (when-not (js/isNaN n) + n)) + :else nil)) + +(defn- fixed-length-body + [body size] + (when (and (number? size) + (exists? js/FixedLengthStream) + (some? body) + (fn? (.-pipeTo body))) + (let [^js fixed (js/FixedLengthStream. size)] + {:body (.-readable fixed) + :pipe-promise (.pipeTo body (.-writable fixed))}))) + +(defn- decoded-base64-chunk + [value] + (when-not (re-matches #"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?" value) + (throw (js/Error. "invalid base64 asset body"))) + (let [decoded (js/atob value) + result (js/Uint8Array. (.-length decoded))] + (dotimes [index (.-length decoded)] + (aset result index (.charCodeAt decoded index))) + result)) + +(defn- base64-decoded-body + [body] + (let [remainder (atom "") + decoder (js/TextDecoder.) + transform (js/TransformStream. + #js {:transform + (fn [chunk controller] + (let [text (string/replace + (str @remainder (.decode decoder chunk #js {:stream true})) + #"\s" "") + complete-length (* 4 (max 0 (dec (quot (count text) 4)))) + complete (subs text 0 complete-length)] + (reset! remainder (subs text complete-length)) + (when (seq complete) + (.enqueue controller (decoded-base64-chunk complete))))) + :flush + (fn [controller] + (let [tail (string/replace (str @remainder (.decode decoder)) #"\s" "")] + (when (seq tail) + (.enqueue controller (decoded-base64-chunk tail)))))})] + {:body (.-readable transform) + :pipe-promise (.pipeTo body (.-writable transform))})) + +(defn- response-fixed-length-body + [body size] + (let [{stream-body :body pipe-promise :pipe-promise} (fixed-length-body body size)] + (when pipe-promise + ;; The response consumes the paired readable stream after this handler returns. + (p/catch pipe-promise (fn [_] nil))) + stream-body)) + +(defn size max-asset-size) + (p/resolved (http/error-response "asset too large" 413)) + + (nil? body) + (p/resolved (http/error-response "missing asset body" 400)) + + :else + (let [{decoded-body :body decode-promise :pipe-promise} + (when (= "base64" encoding) (base64-decoded-body body)) + source-body (or decoded-body body) + {stream-body :body fixed-length-promise :pipe-promise} (fixed-length-body source-body size) + put-promise (.put bucket key (or stream-body source-body) + #js {:httpMetadata #js {:contentType (or content-type "application/octet-stream")} + :customMetadata #js {:checksum checksum :type asset-type}})] + (-> (p/all (cond-> [put-promise] + decode-promise (conj decode-promise) + fixed-length-promise (conj fixed-length-promise))) + (p/then (fn [_] + (http/json-response :assets/put {:ok true} 200))) + (p/catch (fn [error] + (if (= "invalid base64 asset body" (.-message error)) + (http/error-response (.-message error) 400) + (throw error)))))))) + +(defn- body .-pipeTo))) + (p/resolved (response-fixed-length-body body size)) + + ;; Some runtimes drop content-length for streamed bodies without a fixed-length wrapper. + ;; Buffer as a fallback so clients still receive the header. + (and (number? size) + (fn? (some-> body .-getReader))) + (p/let [resp (js/Response. body) + buf (.arrayBuffer resp)] + buf) + + :else + (p/resolved body))) + +(defn- handle-get-asset + [^js bucket key asset-type] + (.then (.get bucket key) + (fn [^js obj] + (if (nil? obj) + (http/error-response "not found" 404) + (let [metadata (.-httpMetadata obj) + content-type (response-content-type (.-contentType metadata) asset-type) + content-encoding (.-contentEncoding metadata) + cache-control (.-cacheControl metadata) + size (parse-size (or (.-size obj) + (some-> (.-body obj) .-byteLength))) + content-length (cond + (number? size) (str size) + (string? size) size + :else nil)] + (p/let [body ( {"content-type" content-type + "content-disposition" (str "inline; filename=\"" + (last (string/split key #"/")) "\"") + "x-asset-type" asset-type} + (and (string? content-length) + (pos? (.-length content-length))) + (assoc "content-length" content-length) + (and (string? content-length) + (pos? (.-length content-length))) + (assoc "x-asset-size" content-length) + (and (string? content-encoding) + (not= content-encoding "null") + (pos? (.-length content-encoding))) + (assoc "content-encoding" content-encoding) + (and (string? cache-control) + (pos? (.-length cache-control))) + (assoc "cache-control" cache-control) + true + (bean/->js))] + (js/Response. body + #js {:status 200 + :headers (js/Object.assign + headers + (cors-headers))}))))))) + +(defn parse-asset-path [path] + (let [prefix "/assets/"] + (when (string/starts-with? path prefix) + (let [rest-path (subs path (count prefix)) + slash-idx (string/index-of rest-path "/") + graph-id (when (and slash-idx (pos? slash-idx)) (subs rest-path 0 slash-idx)) + file (when (and slash-idx (pos? slash-idx)) (subs rest-path (inc slash-idx))) + dot-idx (when file (string/last-index-of file ".")) + asset-uuid (when (and dot-idx (pos? dot-idx)) (subs file 0 dot-idx)) + asset-type (when (and dot-idx (pos? dot-idx)) (subs file (inc dot-idx)))] + (when (and (seq graph-id) (seq asset-uuid) (seq asset-type)) + {:graph-id graph-id + :asset-uuid asset-uuid + :asset-type asset-type + :key (str graph-id "/" asset-uuid "." asset-type)}))))) + +(defn handle [request ^js env] + (let [url (js/URL. (.-url request)) + path (.-pathname url) + method (.-method request)] + (cond + (= method "OPTIONS") + (js/Response. nil #js {:status 204 :headers (cors-headers)}) + + :else + (if-let [{:keys [graph-id key asset-type]} (parse-asset-path path)] + (let [^js bucket (.-LOGSEQ_SYNC_ASSETS env)] + (if-not bucket + (http/error-response "missing assets bucket" 500) + (case method + "GET" + (handle-get-asset bucket key asset-type) + + "PUT" + (p/let [e2ee? (index/ (.-byteLength buf) size-limit) + (http/error-response "asset too large" 413) + (p/let [_ (.put bucket + key + buf + #js {:httpMetadata #js {:contentType (or (.get (.-headers request) "content-type") + "application/octet-stream")} + :customMetadata #js {:checksum (.get (.-headers request) "x-amz-meta-checksum") + :type asset-type}})] + (http/json-response :assets/put {:ok true} 200)))) + + "DELETE" + (.then (.delete bucket key) + (fn [_] + (http/json-response :assets/delete {:ok true} 200))) + + (http/error-response "method not allowed" 405)))) + (http/error-response "invalid asset path" 400))))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/handler/index.cljs b/deps/db-sync/src/logseq/db_sync/worker/handler/index.cljs new file mode 100644 index 0000000..d336224 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/handler/index.cljs @@ -0,0 +1,539 @@ +(ns logseq.db-sync.worker.handler.index + (:require [lambdaisland.glogi :as log] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [logseq.db-sync.worker.auth :as auth] + [logseq.db-sync.worker.http :as http] + [logseq.db-sync.worker.routes.index :as routes] + [promesa.core :as p])) + +(defn- index-db [^js self] + (let [db (.-d1 self)] + (when-not db + (log/error :db-sync/index-db-missing {:binding "DB"})) + db)) + +(defn- admin-token-valid? + [request ^js env] + (let [expected (aget env "DB_SYNC_ADMIN_TOKEN") + actual (.get (.-headers request) "x-db-sync-admin-token")] + (and (string? expected) + (seq expected) + (= expected actual)))) + +(defn- (index/ (index/ claims (aget "sub"))] + (case (:handler route) + :graphs/list + (if (string? user-id) + (p/let [graphs (index/clj result :keywordize-keys true) + body (http/coerce-http-request :graphs/create body) + graph-id (str (random-uuid))] + (cond + (not (string? user-id)) + (http/unauthorized) + + (nil? body) + (http/bad-request "invalid body") + + :else + (p/let [{:keys [graph-name schema-version graph-e2ee? graph-ready-for-use?]} body + graph-e2ee? (if (nil? graph-e2ee?) true (true? graph-e2ee?)) + graph-ready-for-use? (if (nil? graph-ready-for-use?) true (true? graph-ready-for-use?)) + name-exists? (index/clj result :keywordize-keys true) + body (http/coerce-http-request :graph-members/create body) + member-id (:user-id body) + email (:email body) + role (or (:role body) "member")] + (cond + (nil? body) + (http/bad-request "invalid body") + + (and (not (string? member-id)) + (not (string? email))) + (http/bad-request "invalid user") + + :else + (p/let [manager? (index/clj result :keywordize-keys true) + body (http/coerce-http-request :graph-members/update body) + role (:role body)] + (cond + (nil? body) + (http/bad-request "invalid body") + + :else + (p/let [manager? (index/clj result :keywordize-keys true) + body (http/coerce-http-request :e2ee/user-keys body)] + (cond + (not (string? user-id)) + (http/unauthorized) + + (nil? body) + (http/bad-request "invalid body") + + :else + (let [{:keys [public-key encrypted-private-key]} body] + (p/let [_ (index/ {} + (some? public-key) + (assoc :public-key public-key))))) + + :e2ee/graph-aes-key-get + (cond + (not (string? user-id)) + (http/unauthorized) + + :else + (p/let [access? (index/clj result :keywordize-keys true) + body (http/coerce-http-request :e2ee/graph-aes-key body)] + (if (nil? body) + (http/bad-request "invalid body") + (p/let [access? (index/clj result :keywordize-keys true) + body (http/coerce-http-request :e2ee/grant-access body)] + (if (nil? body) + (http/bad-request "invalid body") + (p/let [manager? (index/ {:ok true} + (seq @missing) + (assoc :missing-users @missing))))))))))))) + + (http/not-found)))) + +(defn handle-fetch [^js self request] + (let [db (index-db self) + env (.-env self) + url (js/URL. (.-url request)) + path (.-pathname url) + method (.-method request)] + (try + (cond + (contains? #{"OPTIONS" "HEAD"} method) + (common/options-response) + + (nil? db) + (http/error-response "server error" 500) + + :else + (let [route (routes/match-route method path)] + (cond + (nil? route) + (http/not-found) + + (= :admin-graphs/delete (:handler route)) + (if (admin-token-valid? request env) + (handle {:db db + :env env + :request request + :url url + :claims nil + :route route}) + (http/unauthorized)) + + :else + (p/let [claims (auth/auth-claims request env) + _ (when claims + (index/ route :path-params :graph-id) + _ (when (and (string? user-id) + (string? graph-id) + (< (.-status response) 400)) + (> fresh + (sort-by (comp :cached-at val)) + (drop drop-count) + (into {})))))) + +(defn- cache-graph-access! + [graph-id token allowed? current-ms] + (let [cache-key [graph-id token]] + (swap! *graph-access-cache + (fn [cache] + (-> cache + (assoc cache-key {:allowed? allowed? :cached-at current-ms}) + (prune-graph-access-cache current-ms)))))) + +(defn graph-access-response-with-timing + [request env graph-id] + (let [token (auth/token-from-request request) + db (aget env "DB")] + (cond + (or (not (string? token)) + (not (seq token))) + (p/resolved {:response (http/unauthorized) + :timing (unauthorized-timing 0)}) + + (nil? db) + (p/resolved {:response (http/error-response "server error" 500) + :timing {:access-ok? false + :cache-hit? false}}) + + :else + (let [current-ms (now-ms)] + (if-let [{:keys [allowed?]} (lookup-graph-access-cache graph-id token current-ms)] + (p/resolved {:response (if allowed? + (http/json-response :graphs/access {:ok true}) + (http/forbidden)) + :timing {:access-ok? allowed? + :cache-hit? true + :jwt-verify-ms 0 + :access-query-ms 0 + :access-check-ms 0}}) + (let [jwt-start-ms (now-ms)] + (-> + (p/let [claims (auth/auth-claims request env) + jwt-end-ms (now-ms) + jwt-verify-ms (- jwt-end-ms jwt-start-ms)] + (if (nil? claims) + {:response (http/unauthorized) + :timing (unauthorized-timing jwt-verify-ms)} + (let [user-id (aget claims "sub")] + (if-not (string? user-id) + {:response (http/unauthorized) + :timing (unauthorized-timing jwt-verify-ms)} + (p/let [query-start-ms (now-ms) + access? (index/ {:uuid (uuid-string (:block/uuid block)) + :kind (block-kind block) + :title (:block/title block) + :order (:block/order block)} + (:block/parent block) (assoc :parent-id (uuid-string (:block/uuid (:block/parent block)))) + (:block/page block) (assoc :page-id (uuid-string (:block/uuid (:block/page block)))) + (seq (:block/children block)) (assoc :children (mapv block-response (:block/children block))))) + +(defn- tag-response [entity] + {:uuid (uuid-string (:block/uuid entity)) + :ident (entity-ident entity) + :title (:block/title entity)}) + +(defn- property-choice-response [entity] + {:uuid (uuid-string (:block/uuid entity)) + :ident (entity-ident entity) + :title (:block/title entity) + :value (:logseq.property/value entity)}) + +(defn- property-response [entity] + (cond-> {:uuid (uuid-string (:block/uuid entity)) + :ident (entity-ident entity) + :title (:block/title entity) + :type (some-> (:logseq.property/type entity) name) + :cardinality (some-> (:db/cardinality entity) name)} + (seq (:block/_closed-value-property entity)) + (assoc :choices (mapv property-choice-response (:block/_closed-value-property entity))))) + +(defn- asset-response [entity] + {:uuid (uuid-string (:block/uuid entity)) + :title (:block/title entity) + :type (:logseq.property.asset/type entity) + :size (:logseq.property.asset/size entity) + :checksum (:logseq.property.asset/checksum entity)}) + +(defn- entities-by-avet [db attribute & [value]] + (->> (if (some? value) + (d/datoms db :avet attribute value) + (d/datoms db :avet attribute)) + (map #(d/entity db (:e %))) + (remove entity-util/hidden?))) + +(defn- parse-limit [url] + (let [raw (.get (.-searchParams url) "limit") + parsed (when raw (js/parseInt raw 10))] + (cond + (nil? raw) default-limit + (or (js/isNaN parsed) (not (pos? parsed))) nil + :else (min parsed max-limit)))) + +(def ^:private text-encoder (js/TextEncoder.)) +(def ^:private text-decoder (js/TextDecoder.)) + +(defn- encode-cursor [key] + (let [payload (.encode text-encoder (js/JSON.stringify (clj->js key))) + binary (.join (.map (js/Array.from payload) #(js/String.fromCharCode %)) "")] + (js/btoa binary))) + +(defn- decode-cursor [value] + (when value + (try + (let [binary (js/atob value) + payload (js/Uint8Array.from (js/Array.from binary #(.charCodeAt % 0))) + decoded (js->clj (js/JSON.parse (.decode text-decoder payload)))] + (when (and (vector? decoded) (= 2 (count decoded)) (every? string? decoded)) decoded)) + (catch :default _ nil)))) + +(defn- sort-key [item] + [(or (:order item) (:block/order item) + (string/lower-case (or (:title item) (:block/title item) ""))) + (str (or (:uuid item) (:block/uuid item) ""))]) + +(defn- paginate [items limit cursor] + (let [sorted (sort-by sort-key items) + remaining (if cursor (drop-while #(not (pos? (compare (sort-key %) cursor))) sorted) sorted) + selected (vec (take (inc limit) remaining)) + more? (> (count selected) limit) + page (vec (take limit selected))] + (cond-> {:items page} + more? (assoc :next-cursor (encode-cursor (sort-key (last page))))))) + +(defn- paginate-bounded [items limit cursor] + (let [selected (reduce (fn [selected item] + (if (or (nil? cursor) + (pos? (compare (sort-key item) cursor))) + (->> (conj selected item) + (sort-by sort-key) + (take (inc limit)) + vec) + selected)) + [] + items) + more? (> (count selected) limit) + page (vec (take limit selected))] + (cond-> {:items page} + more? (assoc :next-cursor (encode-cursor (sort-key (last page))))))) + +(defn- pagination-options [url] + (let [raw-cursor (.get (.-searchParams url) "cursor")] + {:limit (parse-limit url) + :raw-cursor raw-cursor + :cursor (decode-cursor raw-cursor)})) + +(defn- parse-timestamp [raw] + (cond + (nil? raw) nil + (not (re-matches #"\d+" raw)) ::invalid + :else (js/parseInt raw 10))) + +(defn- time-filter-options [url] + (let [created-after (parse-timestamp (.get (.-searchParams url) "created-after")) + updated-after (parse-timestamp (.get (.-searchParams url) "updated-after"))] + {:created-after created-after + :updated-after updated-after + :invalid? (or (= ::invalid created-after) (= ::invalid updated-after))})) + +(defn- after-time-filters? [entity {:keys [created-after updated-after]}] + (and (or (nil? created-after) + (and (number? (:block/created-at entity)) + (> (:block/created-at entity) created-after))) + (or (nil? updated-after) + (and (number? (:block/updated-at entity)) + (> (:block/updated-at entity) updated-after))))) + +(defn- paginated-response + ([url response-key items] + (paginated-response url response-key items identity)) + ([url response-key items response-fn] + (paginated-response url response-key items response-fn paginate)) + ([url response-key items response-fn paginate-fn] + (let [{:keys [limit raw-cursor cursor]} (pagination-options url) + time-filters (time-filter-options url)] + (cond + (nil? limit) (http/bad-request "invalid limit") + (and raw-cursor (nil? cursor)) (http/bad-request "invalid cursor") + (:invalid? time-filters) (http/bad-request "invalid time filter") + :else (let [items (filter #(after-time-filters? % time-filters) items) + {:keys [items next-cursor]} (paginate-fn items limit cursor)] + (http/json-response nil (cond-> {response-key (mapv response-fn items)} + next-cursor (assoc :next-cursor next-cursor)))))))) + +(defn- indexed-reference-response + ([db url response-key attribute target-id] + (indexed-reference-response db url response-key attribute target-id {})) + ([db url response-key attribute target-id {:keys [entity-filter response-fn] + :or {entity-filter (constantly true) + response-fn block-response}}] + (let [{:keys [limit raw-cursor cursor]} (pagination-options url) + time-filters (time-filter-options url) + cursor-e (when (= "entity-id" (first cursor)) + (js/parseInt (second cursor) 10))] + (cond + (nil? limit) (http/bad-request "invalid limit") + (and raw-cursor (or (nil? cursor) (js/isNaN cursor-e))) (http/bad-request "invalid cursor") + (:invalid? time-filters) (http/bad-request "invalid time filter") + :else + (let [datoms (cond->> (d/datoms db :avet attribute target-id) + cursor-e (drop-while #(<= (:e %) cursor-e)) + true (remove #(entity-util/hidden? (d/entity db (:e %)))) + true (filter #(after-time-filters? (d/entity db (:e %)) time-filters)) + true (filter #(entity-filter (d/entity db (:e %))))) + selected (vec (take (inc limit) datoms)) + more? (> (count selected) limit) + page (take limit selected)] + (http/json-response + nil + (cond-> {response-key (mapv #(response-fn (d/entity db (:e %))) page)} + more? (assoc :next-cursor + (encode-cursor ["entity-id" (str (:e (last page)))]))))))))) + +(defn- find-entity [db id] + (when-let [parsed (try (uuid id) (catch :default _ nil))] + (d/entity db [:block/uuid parsed]))) + +(defn- find-visible-entity [db id] + (when-let [entity (find-entity db id)] + (when-not (entity-util/hidden? entity) entity))) + +(defn- page-root-blocks [db page] + (let [page-id (:db/id page)] + (->> (d/datoms db :avet :block/page page-id) + (filter #(seq (d/datoms db :eavt (:e %) :block/parent page-id))) + (map #(:e %)) + (map #(d/pull db [:db/id :block/uuid :block/title :block/order + :block/created-at :block/updated-at + :logseq.property/hide? :logseq.property/deleted-at + {:block/parent [:db/id :block/uuid]} + {:block/page [:db/id :block/uuid]}] + %)) + (remove entity-util/hidden?)))) + +(def ^:private page-block-pull-keys + [:db/id :block/uuid :block/title :block/order + :logseq.property/hide? :logseq.property/deleted-at + {:block/parent [:db/id :block/uuid]} + {:block/page [:db/id :block/uuid]}]) + +(defn- page-block-tree [db root] + (let [blocks (->> (ldb/get-block-and-children db (:block/uuid root)) + (remove #(entity-util/hidden? (d/entity db (:db/id %)))))] + (some->> blocks + (map #(d/pull db page-block-pull-keys (:db/id %))) + (otree/non-consecutive-blocks->vec-tree) + first + block-response))) + +(defn- paginated-page-blocks-response [db page url] + (let [{:keys [limit raw-cursor cursor]} (pagination-options url) + time-filters (time-filter-options url)] + (cond + (nil? limit) (http/bad-request "invalid limit") + (and raw-cursor (nil? cursor)) (http/bad-request "invalid cursor") + (:invalid? time-filters) (http/bad-request "invalid time filter") + :else + (let [{roots :items next-cursor :next-cursor} + (paginate (filter #(after-time-filters? % time-filters) + (page-root-blocks db page)) + limit + cursor)] + (http/json-response + nil + (cond-> {:blocks (mapv #(page-block-tree db %) roots)} + next-cursor (assoc :next-cursor next-cursor))))))) + +(defn- body-clj [request] + (p/let [body (common/read-json request)] + (when body (js->clj body :keywordize-keys true)))) + +(defn- broadcast-change! [^js self] + (ws/broadcast! self nil {:type "changed" :t (storage/get-t (.-sql self))})) + +(defn- list-pages [db] + (->> [:logseq.class/Page :logseq.class/Journal] + (keep #(d/entid db %)) + (mapcat #(entities-by-avet db :block/tags %)))) + +(defn- page-ref-values [title] + (when (string? title) + (->> (re-seq page-ref/page-ref-re title) + (map (comp string/trim second)) + (remove string/blank?) + distinct))) + +(defn- integer-string? [value] + (boolean (re-matches #"-?\d+" value))) + +(defn- resolve-page-ref! [conn value] + (cond + (common-util/uuid-string? value) + (when-let [entity (d/entity @conn [:block/uuid (uuid value)])] + {:block/uuid (:block/uuid entity) + :block/title value}) + + (integer-string? value) + (when-let [entity (d/entity @conn (js/parseInt value 10))] + {:block/uuid (:block/uuid entity) + :block/title value}) + + :else + (let [page (or (ldb/get-page @conn value) + (let [[_ page-id] (outliner-page/create! conn value {})] + (d/entity @conn [:block/uuid page-id])))] + {:block/uuid (:block/uuid page) + :block/title (:block/title page)}))) + +(defn- prepare-block-title! [conn title] + (let [refs (->> (page-ref-values title) + (keep #(resolve-page-ref! conn %)) + (reduce (fn [result ref] + (assoc result (:block/uuid ref) ref)) {}) + vals + vec)] + {:block/title (db-content/title-ref->id-ref title refs :replace-tag? false) + :block/refs (mapv #(vector :block/uuid (:block/uuid %)) refs)})) + +(defn- tree-block [conn node] + (assoc (prepare-block-title! conn (:title node)) :block/uuid (random-uuid))) + +(defn- insert-tree! [conn target nodes position] + (let [nodes (vec nodes) + blocks (mapv #(tree-block conn %) nodes)] + (when (seq blocks) + (outliner-core/insert-blocks! conn blocks target + {:sibling? false :top? (= position "prepend") + :bottom? (not= position "prepend") :keep-uuid? true}) + (mapv (fn [node block] + (let [children (when (seq (:children node)) + (insert-tree! conn (d/entity @conn [:block/uuid (:block/uuid block)]) + (:children node) "append"))] + (cond-> {:uuid (str (:block/uuid block)) :title (:title node)} + (seq children) (assoc :children children)))) + nodes blocks)))) + +(defn- today-journal-day [] + (let [now (js/Date.)] + (+ (* (.getFullYear now) 10000) (* (inc (.getMonth now)) 100) (.getDate now)))) + +(defn- ensure-today-page! [conn] + (let [day (today-journal-day)] + (or (ldb/get-journal-page-by-day @conn day) + (let [formatter (:logseq.property.journal/title-format (d/entity @conn :logseq.class/Journal)) + title (date-time-util/int->journal-title day formatter) + [_ page-id] (outliner-page/create! conn title {:journal? true :today-journal? true})] + (d/entity @conn [:block/uuid page-id]))))) + +(defn- resolve-property-ident [db value] + (or (when-let [entity (find-entity db value)] (:db/ident entity)) + (when (string? value) + (let [ident (keyword value)] + (or (when (d/entity db ident) ident) + (some->> (d/datoms db :avet :block/title value) + (map #(d/entity db (:e %))) + (filter property?) + first + :db/ident)))))) + +(defn- resolve-property [db value] + (when-let [ident (resolve-property-ident db value)] + (let [property (d/entity db ident)] + (when-not (entity-util/hidden? property) + property)))) + +(def ^:private allowed-property-types + (into (set db-property-type/user-built-in-property-types) + db-property-type/user-allowed-internal-property-types)) + +(def ^:private allowed-cardinalities + #{:db.cardinality/one :db.cardinality/many}) + +(defn- valid-property-schema? [{:keys [type cardinality]}] + (and (or (nil? type) + (and (string? type) (contains? allowed-property-types (keyword type)))) + (or (nil? cardinality) + (and (string? cardinality) (contains? allowed-cardinalities (keyword cardinality)))))) + +(def ^:private task-status-idents + {"todo" :logseq.property/status.todo + "doing" :logseq.property/status.doing + "in-review" :logseq.property/status.in-review + "done" :logseq.property/status.done + "canceled" :logseq.property/status.canceled + "backlog" :logseq.property/status.backlog}) + +(def ^:private task-priority-idents + {"low" :logseq.property/priority.low + "medium" :logseq.property/priority.medium + "high" :logseq.property/priority.high + "urgent" :logseq.property/priority.urgent}) + +(def ^:private property-choice-aliases + {:logseq.property/status task-status-idents + :logseq.property/priority task-priority-idents}) + +(defn- resolve-ref-entity [db property-ident value] + (let [value (if (string? value) + (get-in property-choice-aliases + [property-ident (string/lower-case value)] value) + value) + entity (cond + (number? value) (d/entity db value) + (qualified-keyword? value) (d/entity db value) + (and (string? value) (common-util/uuid-string? value)) + (d/entity db [:block/uuid (uuid value)]) + (string? value) + (or (d/entity db (keyword value)) + (some->> (d/datoms db :avet :block/title value) + (map #(d/entity db (:e %))) + (filter #(if (= property-ident :block/tags) + (entity-util/class? %) + true)) + first)) + :else nil)] + (when (and entity + (or (not= property-ident :block/tags) (entity-util/class? entity))) + (:db/id entity)))) + +(defn- prepare-property-value [db property-ident value] + (let [property (d/entity db property-ident)] + (if-not (= :db.type/ref (:db/valueType property)) + {:value value} + (let [many-input? (or (sequential? value) (set? value)) + values (if many-input? value [value]) + resolved (mapv #(resolve-ref-entity db property-ident %) values)] + (if (every? some? resolved) + {:value (if many-input? resolved (first resolved))} + {:error (if (= property-ident :block/tags) + "property value must resolve to an existing class by UUID, ident, or title" + "property value must resolve to an existing entity by UUID, ident, or title")}))))) + +(defn- set-prepared-property! [conn block-id property-ident value reset-existing-values?] + (if (coll? value) + (do + (when reset-existing-values? + (outliner-property/remove-block-property! conn block-id property-ident)) + (doseq [item value] + (outliner-property/batch-set-property! conn [block-id] property-ident item {:entity-id? true}))) + (outliner-property/set-block-property! conn block-id property-ident value))) + +(defn- task-response [task] + (cond-> (block-response task) + (:logseq.property/status task) + (assoc :status (property-choice-response (:logseq.property/status task))) + (:logseq.property/priority task) + (assoc :priority (property-choice-response (:logseq.property/priority task))) + (:logseq.property/scheduled task) (assoc :scheduled (:logseq.property/scheduled task)) + (:logseq.property/deadline task) (assoc :deadline (:logseq.property/deadline task)))) + +(defn- resolve-property-choice [db property-ident aliases selector] + (let [selector (get aliases selector selector) + choice-id (resolve-ref-entity db property-ident selector) + choice (when choice-id (d/entity db choice-id)) + property-id (d/entid db property-ident)] + (when (and choice-id + (some #(= property-id (:v %)) + (d/datoms db :eavt choice-id :block/closed-value-property))) + choice))) + +(defn- search-result-response [entity] + (let [kind (block-kind entity) + type (case kind "tag" "tags" "property" "properties" "asset" "assets" "blocks") + response (case kind + "tag" (tag-response entity) + "property" (property-response entity) + "asset" (asset-response entity) + (block-response entity))] + (assoc response :resource type))) + +(defn- search-results [db query types] + (let [needle (string/lower-case query) + enabled (if (seq types) (set (string/split types #",")) #{"blocks" "tags" "properties" "assets"}) + title-matches (keep (fn [datom] + (try + (when (string/includes? (string/lower-case (:v datom)) needle) + (:e datom)) + (catch :default _ + nil))) + (d/datoms db :avet :block/title))] + (->> title-matches + (map #(d/entity db %)) + (remove entity-util/hidden?) + (filter (fn [entity] + (let [kind (block-kind entity) + type (case kind "tag" "tags" "property" "properties" "asset" "assets" "blocks")] + (and (contains? enabled type) + (string/includes? (string/lower-case (or (:block/title entity) "")) needle)))))))) + +(defn- handle-pages [{:keys [^js self request ^js url conn db handler path-params]}] + (case handler + :semantic/pages-list + (paginated-response url :blocks (list-pages db) block-response) + + :semantic/pages-create + (p/let [body (body-clj request)] + (if-not (seq (:title body)) + (http/bad-request "missing title") + (let [[title page-id] (outliner-page/create! conn (:title body) {})] + (broadcast-change! self) + (http/json-response nil {:uuid (str page-id) :kind "page" :title title} 201)))) + + :semantic/pages-blocks + (if-let [page (find-visible-entity db (:page-id path-params))] + (if-not (page? page) + (http/bad-request "block is not a page") + (paginated-page-blocks-response db page url)) + (http/not-found)) + + :semantic/pages-references + (if-let [page (find-visible-entity db (:page-id path-params))] + (if (page? page) + (indexed-reference-response db url :references :block/refs (:db/id page)) + (http/not-found)) + (http/not-found)) + + :semantic/pages-get + (if-let [page (find-visible-entity db (:page-id path-params))] + (if (page? page) (http/json-response nil (block-response page)) (http/not-found)) + (http/not-found)) + + :semantic/pages-update + (p/let [body (body-clj request) + page (find-entity db (:page-id path-params))] + (if (or (not (page? page)) (not (seq (:title body)))) + (http/bad-request "invalid page-id or title") + (do + (outliner-core/save-block! conn {:block/uuid (:block/uuid page) :block/title (:title body)}) + (broadcast-change! self) + (http/json-response nil (assoc (block-response page) :title (:title body)))))) + + :semantic/pages-delete + (if-let [page (find-entity db (:page-id path-params))] + (if-not (page? page) + (http/bad-request "block is not a page") + (do + (outliner-page/delete! conn (:block/uuid page)) + (broadcast-change! self) + (js/Response. nil #js {:status 204}))) + (http/not-found)))) + +(defn- handle-blocks [{:keys [^js self request conn db handler path-params]}] + (case handler + + :semantic/blocks-get + (if-let [block (find-visible-entity db (:block-id path-params))] + (http/json-response nil (block-response block)) + (http/not-found)) + + :semantic/blocks-update + (p/let [body (body-clj request) + block (find-entity db (:block-id path-params))] + (if (or (nil? block) (not (seq (:title body)))) + (http/bad-request "invalid block-id or title") + (do + (outliner-core/save-block! conn + (assoc (prepare-block-title! conn (:title body)) + :block/uuid (:block/uuid block))) + (broadcast-change! self) + (http/json-response nil {:uuid (str (:block/uuid block)) :kind (block-kind block) :title (:title body)})))) + + :semantic/blocks-delete + (if-let [block (find-entity db (:block-id path-params))] + (do + (if (page? block) + (outliner-page/delete! conn (:block/uuid block)) + (outliner-core/delete-blocks! conn [block] {})) + (broadcast-change! self) + (js/Response. nil #js {:status 204})) + (http/not-found)) + + :semantic/blocks-move + (p/let [body (body-clj request) + block-ids (:block-ids body) + blocks (mapv #(find-entity db %) block-ids) + target (find-entity db (:target-id body))] + (if (or (not (seq block-ids)) + (some nil? blocks) (nil? target) + (not= (count blocks) (count (distinct (map :db/id blocks)))) + (some #(= (:db/id target) (:db/id %)) blocks) + (not (contains? #{"before" "after" "first-child" "last-child"} (:position body)))) + (http/bad-request "invalid blocks, target, or position") + (let [position (:position body) + opts (case position + "before" {:sibling? true :top? true} + "after" {:sibling? true :bottom? true} + "first-child" {:sibling? false :top? true} + "last-child" {:sibling? false :bottom? true})] + (outliner-core/move-blocks! conn blocks target opts) + (broadcast-change! self) + (http/json-response nil {:uuids (mapv #(str (:block/uuid %)) blocks) :moved true})))) + + :semantic/blocks-insert-children + (p/let [body (body-clj request) + target (find-entity db (:block-id path-params))] + (if (or (nil? target) (not (contains? #{"append" "prepend"} (:position body))) + (not (seq (:blocks body)))) + (http/bad-request "invalid target, position, or blocks") + (let [inserted (insert-tree! conn target (:blocks body) (:position body))] + (broadcast-change! self) + (http/json-response nil {:blocks inserted} 201)))) + + :semantic/blocks-insert-tree + (p/let [body (body-clj request) + target (find-entity db (:target-id body))] + (if (or (nil? target) (not (seq (:blocks body)))) + (http/bad-request "invalid target or blocks") + (let [inserted (insert-tree! conn target (:blocks body) (or (:position body) "append"))] + (broadcast-change! self) + (http/json-response nil {:blocks inserted} 201)))))) + +(defn- handle-block-properties [{:keys [^js self request conn db handler path-params]}] + (case handler + + :semantic/blocks-set-property + (p/let [body (body-clj request) + block (find-entity db (:block-id path-params)) + property-ident (resolve-property-ident db (:property-id path-params)) + prepared (when property-ident (prepare-property-value db property-ident (:value body)))] + (cond + (nil? block) (http/bad-request "block not found") + (nil? property-ident) (http/bad-request "property not found") + (not (contains? body :value)) (http/bad-request "missing property value") + (:error prepared) (http/bad-request (:error prepared)) + :else + (do (set-prepared-property! conn (:db/id block) property-ident (:value prepared) true) + (broadcast-change! self) + (http/json-response nil {:updated true})))) + + :semantic/blocks-delete-property + (let [block (find-entity db (:block-id path-params)) + property-ident (resolve-property-ident db (:property-id path-params))] + (if (or (nil? block) (nil? property-ident)) + (http/bad-request "invalid block or property") + (do + (outliner-property/remove-block-property! conn (:db/id block) property-ident) + (broadcast-change! self) + (js/Response. nil #js {:status 204})))) + + :semantic/blocks-batch-set-property + (p/let [body (body-clj request) + entries (:entries body) + resolved (mapv (fn [{:keys [block-id property-id value]}] + (let [property-ident (resolve-property-ident db property-id)] + {:block (find-entity db block-id) + :property-ident property-ident + :prepared (when property-ident + (prepare-property-value db property-ident value))})) entries)] + (if (or (not (seq entries)) + (some #(or (nil? (:block %)) (nil? (:property-ident %)) + (:error (:prepared %))) resolved)) + (http/bad-request "invalid batch property entry") + (do (ldb/batch-transact-with-temp-conn! + conn {:outliner-op :batch-set-property} + (fn [temp-conn] + (doseq [{:keys [block property-ident prepared]} resolved] + (set-prepared-property! temp-conn (:db/id block) property-ident (:value prepared) + (true? (:isResetExistingValues body)))))) + (broadcast-change! self) + (http/json-response nil {:updated (count resolved)})))) + + :semantic/blocks-batch-delete-property + (p/let [body (body-clj request) + entries (:entries body) + resolved (mapv (fn [{:keys [block-id property-id]}] + {:block (find-entity db block-id) + :property-ident (resolve-property-ident db property-id)}) entries)] + (if (or (not (seq entries)) (some #(or (nil? (:block %)) (nil? (:property-ident %))) resolved)) + (http/bad-request "invalid batch property entry") + (do + (ldb/batch-transact-with-temp-conn! + conn {:outliner-op :batch-remove-property} + (fn [temp-conn] + (doseq [{:keys [block property-ident]} resolved] + (outliner-property/remove-block-property! temp-conn (:db/id block) property-ident)))) + (broadcast-change! self) + (http/json-response nil {:deleted (count resolved)})))))) + +(defn- handle-capture-and-tags [{:keys [^js self request ^js url conn db handler path-params]}] + (case handler + + :semantic/capture + (p/let [body (body-clj request)] + (if-not (seq (:blocks body)) + (http/bad-request "missing blocks") + (let [today (ensure-today-page! conn) + inserted (insert-tree! conn today (:blocks body) "append")] + (broadcast-change! self) + (http/json-response nil {:page-id (str (:block/uuid today)) :blocks inserted} 201)))) + + :semantic/tags-list + (paginated-response url :tags (entities-by-avet db :block/tags :logseq.class/Tag) tag-response) + + :semantic/tags-create + (p/let [body (body-clj request)] + (if-not (seq (:title body)) + (http/bad-request "missing title") + (let [[title tag-id] (outliner-page/create! conn (:title body) {:class? true})] + (broadcast-change! self) + (http/json-response nil {:uuid (str tag-id) :title title} 201)))) + + :semantic/tags-get + (if-let [tag (find-visible-entity db (:tag-id path-params))] + (if (tag? tag) (http/json-response nil (tag-response tag)) (http/not-found)) + (http/not-found)) + + :semantic/tags-objects + (if-let [tag (find-visible-entity db (:tag-id path-params))] + (if (tag? tag) + (indexed-reference-response db url :objects :block/tags (:db/id tag)) + (http/not-found)) + (http/not-found)) + + :semantic/tags-update + (p/let [body (body-clj request) + tag (find-entity db (:tag-id path-params))] + (if (or (not (tag? tag)) (not (seq (:title body)))) + (http/bad-request "invalid tag-id or title") + (do + (outliner-core/save-block! conn {:block/uuid (:block/uuid tag) :block/title (:title body)}) + (broadcast-change! self) + (http/json-response nil (assoc (tag-response tag) :title (:title body)))))) + + :semantic/tags-delete + (if-let [tag (find-entity db (:tag-id path-params))] + (if-not (tag? tag) + (http/not-found) + (do + (outliner-page/delete! conn (:block/uuid tag)) + (broadcast-change! self) + (js/Response. nil #js {:status 204}))) + (http/not-found)))) + +(defn- handle-tasks [{:keys [^js self request ^js url conn db handler]}] + (case handler + :semantic/tasks-list + (let [status (.get (.-searchParams url) "status") + priority (.get (.-searchParams url) "priority") + content (some-> (.get (.-searchParams url) "content") string/lower-case) + status-choice (when status (resolve-property-choice db :logseq.property/status + task-status-idents status)) + priority-choice (when priority (resolve-property-choice db :logseq.property/priority + task-priority-idents priority))] + (cond + (and status (nil? status-choice)) (http/bad-request "invalid task status") + (and priority (nil? priority-choice)) (http/bad-request "invalid task priority") + :else + (indexed-reference-response + db url :tasks :block/tags :logseq.class/Task + {:entity-filter + (fn [entity] + (and (or (nil? status-choice) + (= (:db/id status-choice) (:db/id (:logseq.property/status entity)))) + (or (nil? priority-choice) + (= (:db/id priority-choice) (:db/id (:logseq.property/priority entity)))) + (or (nil? content) + (string/includes? (string/lower-case (or (:block/title entity) "")) content)))) + :response-fn task-response}))) + + :semantic/tasks-create + (p/let [body (body-clj request)] + (let [status (or (:status body) "todo") + priority (:priority body) + status-choice (resolve-property-choice db :logseq.property/status task-status-idents status) + priority-choice (when priority + (resolve-property-choice db :logseq.property/priority + task-priority-idents priority)) + target (when-let [page-id (:page-id body)] + (find-entity db page-id))] + (cond + (not (seq (:title body))) (http/bad-request "missing task title") + (nil? status-choice) (http/bad-request "invalid task status") + (and priority (nil? priority-choice)) (http/bad-request "invalid task priority") + (and (:page-id body) (not (page? target))) (http/bad-request "invalid task page-id") + :else + (let [task-id (atom nil)] + (ldb/batch-transact-with-temp-conn! + conn {:outliner-op :create-task} + (fn [temp-conn] + (let [target (if target + (d/entity @temp-conn [:block/uuid (:block/uuid target)]) + (ensure-today-page! temp-conn)) + inserted (first (insert-tree! temp-conn target [{:title (:title body)}] "append")) + task-uuid (uuid (:uuid inserted)) + task (d/entity @temp-conn [:block/uuid task-uuid])] + (reset! task-id task-uuid) + (outliner-property/set-block-property! temp-conn (:db/id task) :block/tags + (:db/id (d/entity @temp-conn :logseq.class/Task))) + (outliner-property/set-block-property! temp-conn (:db/id task) :logseq.property/status + (:db/id status-choice)) + (when priority-choice + (outliner-property/set-block-property! temp-conn (:db/id task) :logseq.property/priority + (:db/id priority-choice))) + (doseq [property-ident [:logseq.property/scheduled :logseq.property/deadline] + :let [value (get body (keyword (name property-ident)))] + :when (some? value)] + (outliner-property/set-block-property! temp-conn (:db/id task) property-ident value))))) + (broadcast-change! self) + (http/json-response nil (task-response (d/entity @conn [:block/uuid @task-id])) 201))))))) + +(defn- handle-assets + [{:keys [^js self request ^js url conn db handler path-params]}] + (case handler + :semantic/assets-list + (paginated-response url :assets + (entities-by-avet db :logseq.property.asset/type) + asset-response) + + :semantic/assets-create + (let [file-name (.get (.-searchParams url) "file-name") + raw-size (.get (.-searchParams url) "size") + size (when (and raw-size (re-matches #"\d+" raw-size)) + (js/parseInt raw-size 10)) + checksum (some-> (.get (.-searchParams url) "checksum") string/lower-case) + encoding (.get (.-searchParams url) "encoding") + asset-type (when (string? file-name) (db-asset/asset-path->type file-name)) + page-id (.get (.-searchParams url) "page-id") + target (when page-id (find-entity db page-id)) + ^js bucket (some-> (.-env self) (aget "LOGSEQ_SYNC_ASSETS"))] + (cond + (or (not (seq file-name)) (not (seq asset-type))) (http/bad-request "invalid asset file-name") + (nil? size) (http/bad-request "invalid asset size") + (> size assets-handler/max-asset-size) (http/error-response "asset too large" 413) + (not (and (string? checksum) (re-matches #"[0-9a-f]{64}" checksum))) + (http/bad-request "invalid asset checksum") + (and encoding (not= "base64" encoding)) (http/bad-request "invalid asset encoding") + (and page-id (not (page? target))) (http/bad-request "invalid asset page-id") + (nil? bucket) (http/error-response "missing assets bucket" 500) + (seq (d/datoms db :avet :logseq.property.asset/checksum checksum)) + (http/error-response "asset checksum already exists" 409) + :else + (let [asset-id (random-uuid) + key (str (.get (.-searchParams url) "graph-id") "/" asset-id "." asset-type) + title (or (.get (.-searchParams url) "title") + (db-asset/asset-name->title file-name))] + (-> (p/let [upload-response (assets-handler/ (p/let [target (or target (ensure-today-page! conn)) + block {:block/uuid asset-id + :block/title title + :block/tags #{:logseq.class/Asset} + :logseq.property.asset/type asset-type + :logseq.property.asset/size size + :logseq.property.asset/checksum checksum + :logseq.property.asset/remote-metadata {:checksum checksum + :type asset-type}} + _ (outliner-core/insert-blocks! conn [block] target + {:sibling? false :bottom? true + :keep-uuid? true}) + asset (d/entity @conn [:block/uuid asset-id])] + (broadcast-change! self) + (http/json-response nil (asset-response asset) 201)) + (p/catch (fn [error] + (p/let [_ (.delete bucket key)] + (throw error))))))) + (p/catch (fn [error] + (http/error-response (or (.-message error) "asset upload failed") 500))))))) + + :semantic/assets-get + (if-let [asset (find-visible-entity db (:asset-block-id path-params))] + (if-not (asset? asset) + (http/bad-request "block is not an asset") + (p/let [link (asset-link/> (db-db/get-all-properties db) + (remove entity-util/hidden?)) + property-response) + + :semantic/properties-create + (p/let [body (body-clj request)] + (if (or (not (seq (:title body))) (not (valid-property-schema? body))) + (http/bad-request "invalid property schema") + (let [property (outliner-property/upsert-property! + conn nil + {:logseq.property/type (keyword (or (:type body) "default")) + :db/cardinality (keyword (or (:cardinality body) "db.cardinality/one"))} + {:property-name (:title body)})] + (broadcast-change! self) + (http/json-response nil (property-response property) 201)))) + + :semantic/properties-get + (if-let [property (resolve-property db (:property-id path-params))] + (if (property? property) (http/json-response nil (property-response property)) (http/not-found)) + (http/not-found)) + + :semantic/properties-update + (p/let [body (body-clj request) + property (resolve-property db (:property-id path-params))] + (if (or (not (property? property)) (empty? body) (not (valid-property-schema? body))) + (http/bad-request "invalid property-id or update") + (let [schema (cond-> {} + (:type body) (assoc :logseq.property/type (keyword (:type body))) + (:cardinality body) (assoc :db/cardinality (keyword (:cardinality body)))) + updated (outliner-property/upsert-property! + conn (:db/ident property) schema + (cond-> {} (:title body) (assoc :property-name (:title body))))] + (broadcast-change! self) + (http/json-response nil (property-response updated))))) + + :semantic/properties-delete + (if-let [property (resolve-property db (:property-id path-params))] + (if-not (property? property) + (http/not-found) + (if (outliner-page/delete! conn (:block/uuid property)) + (do + (broadcast-change! self) + (js/Response. nil #js {:status 204})) + (http/bad-request "property cannot be deleted"))) + (http/not-found)) + + :semantic/search + (let [query (.get (.-searchParams url) "q")] + (if-not (seq query) + (http/bad-request "missing q") + (paginated-response url :results + (search-results db query (.get (.-searchParams url) "types")) + search-result-response + paginate-bounded))))) + +(def ^:private page-handlers + #{:semantic/pages-list :semantic/pages-create :semantic/pages-blocks :semantic/pages-references :semantic/pages-get + :semantic/pages-update :semantic/pages-delete}) + +(def ^:private block-handlers + #{:semantic/blocks-get :semantic/blocks-update :semantic/blocks-delete :semantic/blocks-move + :semantic/blocks-insert-children :semantic/blocks-insert-tree}) + +(def ^:private block-property-handlers + #{:semantic/blocks-set-property :semantic/blocks-delete-property + :semantic/blocks-batch-set-property :semantic/blocks-batch-delete-property}) + +(def ^:private capture-and-tag-handlers + #{:semantic/capture :semantic/tags-list :semantic/tags-create :semantic/tags-get :semantic/tags-objects + :semantic/tags-update :semantic/tags-delete}) + +(def ^:private task-handlers + #{:semantic/tasks-list :semantic/tasks-create}) + +(def ^:private asset-handlers + #{:semantic/assets-list :semantic/assets-create :semantic/assets-get}) + +(def ^:private property-and-search-handlers + #{:semantic/properties-list :semantic/properties-create :semantic/properties-get + :semantic/properties-update :semantic/properties-delete + :semantic/search}) + +(defn handle [{:keys [^js self route] :as context}] + (let [conn (ensure-conn! self) + handler (:handler route) + context (assoc context :conn conn :db @conn :handler handler :path-params (:path-params route))] + (cond + (contains? page-handlers handler) (handle-pages context) + (contains? block-handlers handler) (handle-blocks context) + (contains? block-property-handlers handler) (handle-block-properties context) + (contains? capture-and-tag-handlers handler) (handle-capture-and-tags context) + (contains? task-handlers handler) (handle-tasks context) + (contains? asset-handlers handler) (handle-assets context) + (contains? property-and-search-handlers handler) (handle-properties-and-search context) + :else (http/not-found)))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs b/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs new file mode 100644 index 0000000..6da421f --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs @@ -0,0 +1,928 @@ +(ns logseq.db-sync.worker.handler.sync + (:require [clojure.string :as string] + [datascript.core :as d] + [lambdaisland.glogi :as log] + [logseq.db :as ldb] + [logseq.db-sync.batch :as batch] + [logseq.db-sync.checksum :as sync-checksum] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [logseq.db-sync.protocol :as protocol] + [logseq.db-sync.snapshot :as snapshot] + [logseq.db-sync.storage :as storage] + [logseq.db-sync.tx-sanitize :as tx-sanitize] + [logseq.db-sync.worker.http :as http] + [logseq.db-sync.worker.handler.semantic :as semantic-handler] + [logseq.db-sync.worker.routes.semantic :as semantic-routes] + [logseq.db-sync.worker.routes.sync :as sync-routes] + [logseq.db-sync.worker.ws :as ws] + [promesa.core :as p])) + +(def ^:private snapshot-download-batch-size 10000) +;; (def ^:private snapshot-cache-control "private, max-age=300") +(def ^:private snapshot-content-type "application/transit+json") +(def ^:private snapshot-content-encoding "gzip") +(def ^:private snapshot-uploading-meta-key :snapshot-uploading?) +(def ^:private large-tx-min-items 500) +(def ^:private large-tx-max-chunk-items 500) +;; 10m +;; (def ^:private snapshot-multipart-part-size (* 10 1024 1024)) + +(defn parse-int [value] + (when (some? value) + (let [n (js/parseInt value 10)] + (when-not (js/isNaN n) + n)))) + +(defn- ensure-schema! [^js self] + (when-not (true? (.-schema-ready self)) + (try + (storage/init-schema! (.-sql self)) + (catch :default e + ;; Schema may already exist. If DDL writes are rejected, probe + ;; existing tables before deciding this is a fatal error. + (try + (common/sql-exec (.-sql self) "select 1 from kvs limit 1") + (common/sql-exec (.-sql self) "select 1 from tx_log limit 1") + (common/sql-exec (.-sql self) "select 1 from sync_meta limit 1") + (catch :default _ + (throw e))))) + (set! (.-schema-ready self) true))) + +(defn- ensure-conn! [^js self] + (ensure-schema! self) + (when-not (.-conn self) + (set! (.-conn self) + (storage/open-conn (.-sql self))))) + +(defn t-now [^js self] + (ensure-schema! self) + (storage/get-t (.-sql self))) + +(defn current-checksum [^js self] + (ensure-conn! self) + (storage/get-checksum (.-sql self))) + +(defn snapshot-upload-finished? [^js self] + (ensure-schema! self) + (not= "true" (storage/get-meta (.-sql self) snapshot-uploading-meta-key))) + +(defn self .-env (aget "DB"))] + (p/let [graph-ready-for-use? (index/ self .-env (aget "DB"))] + (p/let [result (index/ result (aget "meta")) + rows-affected (or (some-> meta (aget "changes")) + (some-> meta (aget "rows_written")) + (some-> result (aget "changes")) + (some-> result (aget "rows_written")))] + (when (or (nil? result) + (false? (some-> result (aget "success")))) + (throw (ex-info "failed to persist graph_ready_for_use" + {:type :db-sync/graph-ready-for-use-set-failed + :graph-id graph-id + :graph-ready-for-use? graph-ready-for-use? + :result result}))) + (when (and (number? rows-affected) + (<= rows-affected 0)) + (throw (ex-info "graph_ready_for_use update affected no rows" + {:type :db-sync/graph-ready-for-use-set-no-rows + :graph-id graph-id + :graph-ready-for-use? graph-ready-for-use? + :rows-affected rows-affected + :result result}))) + result) + (p/rejected (ex-info "missing DB binding for graph_ready_for_use update" + {:type :db-sync/missing-db-binding + :graph-id graph-id + :graph-ready-for-use? graph-ready-for-use?})))) + +(defn- import-snapshot-rows! + [sql table rows] + (when (seq rows) + (doseq [batch (batch/rows->insert-batches table rows nil)] + (let [sql-str (:sql batch) + args (:args batch)] + (apply common/sql-exec sql sql-str args))))) + +(defn- reset-import! + [sql] + (common/sql-exec sql "delete from kvs") + (common/sql-exec sql "delete from tx_log") + (common/sql-exec sql "delete from sync_meta") + (storage/set-t! sql 0)) + +(defn- graph-id-from-sync-path + [^js url] + (let [path (.-pathname url) + prefix "/sync/"] + (when (string/starts-with? path prefix) + (let [rest-path (subs path (count prefix)) + slash-idx (or (string/index-of rest-path "/") -1) + graph-id (if (neg? slash-idx) + rest-path + (subs rest-path 0 slash-idx))] + (when (seq graph-id) + graph-id))))) + +(defn graph-id-from-request [request] + (let [header-id (.get (.-headers request) "x-graph-id") + url (js/URL. (.-url request)) + param-id (.get (.-searchParams url) "graph-id") + graph-id (or header-id param-id (graph-id-from-sync-path url))] + (when (seq graph-id) + graph-id))) + +;; (defn- snapshot-key [graph-id snapshot-id] +;; (str graph-id "/" snapshot-id ".snapshot")) + +;; (defn- snapshot-url [request graph-id snapshot-id] +;; (let [url (js/URL. (.-url request))] +;; (str (.-origin url) "/assets/" graph-id "/" snapshot-id ".snapshot"))) + +(defn- snapshot-stream-url [request graph-id] + (let [url (js/URL. (.-url request))] + (str (.-origin url) "/sync/" graph-id "/snapshot/stream"))) + +(defn- maybe-decompress-stream [stream encoding] + (if (and (= encoding snapshot-content-encoding) (exists? js/DecompressionStream)) + (.pipeThrough stream (js/DecompressionStream. "gzip")) + stream)) + +(defn- maybe-compress-stream [stream] + (.pipeThrough stream (js/CompressionStream. snapshot-content-encoding))) + +(defn- snapshot-stream-gzip-enabled? + [^js self] + (let [v (some-> self .-env (aget "DB_SYNC_SNAPSHOT_STREAM_GZIP"))] + (cond + (nil? v) true + (false? v) false + (string? v) (not (contains? #{"false" "0" "off" "no"} + (string/lower-case v))) + :else (boolean v)))) + +;; (defn- uint8 [data] +;; (cond +;; (instance? js/Uint8Array data) data +;; (instance? js/ArrayBuffer data) (js/Uint8Array. data) +;; :else (js/Uint8Array. data))) + +;; (defn- concat-uint8 [^js a ^js b] +;; (cond +;; (nil? a) b +;; (nil? b) a +;; :else +;; (let [out (js/Uint8Array. (+ (.-byteLength a) (.-byteLength b)))] +;; (.set out a 0) +;; (.set out b (.-byteLength a)) +;; out))) + +(defn- frame-bytes + [^js data] + (let [len (.-byteLength data) + out (js/Uint8Array. (+ 4 len)) + view (js/DataView. (.-buffer out))] + (.setUint32 view 0 len false) + (.set out data 4) + out)) + +(defn- fetch-snapshot-kvs-rows + [sql last-addr limit] + (let [rows (common/get-sql-rows + (common/sql-exec sql + "select addr, content, addresses from kvs where addr > ? order by addr asc limit ?" + last-addr + limit))] + (mapv (fn [row] + [(aget row "addr") + (aget row "content") + (aget row "addresses")]) + rows))) + +(defn- snapshot-row-count + [sql] + (if-let [row (first (common/get-sql-rows + (common/sql-exec sql "select count(*) as row_count from kvs")))] + (or (aget row "row_count") 0) + 0)) + +(defn- snapshot-export-stream [^js self] + (ensure-schema! self) + (let [sql (.-sql self) + last-addr (volatile! -1)] + (js/ReadableStream. + (clj->js + {:pull (fn [controller] + (let [batch (fetch-snapshot-kvs-rows sql @last-addr snapshot-download-batch-size)] + (if (empty? batch) + (.close controller) + (let [payload (snapshot/encode-rows batch)] + (vreset! last-addr (first (peek batch))) + (.enqueue controller (frame-bytes payload))))))})))) + +;; (defn- upload-multipart! +;; [^js bucket key stream opts] +;; (p/let [^js upload (.createMultipartUpload bucket key opts)] +;; (let [reader (.getReader stream)] +;; (-> (p/loop [buffer nil +;; part-number 1 +;; parts []] +;; (p/let [chunk (.read reader)] +;; (if (.-done chunk) +;; (cond +;; (and buffer (pos? (.-byteLength buffer))) +;; (p/let [^js resp (.uploadPart upload part-number buffer) +;; parts (conj parts {:partNumber part-number :etag (.-etag resp)})] +;; (p/let [_ (.complete upload (clj->js parts))] +;; {:ok true})) + +;; (seq parts) +;; (p/let [_ (.complete upload (clj->js parts))] +;; {:ok true}) + +;; :else +;; (p/let [_ (.abort upload)] +;; (.put bucket key (js/Uint8Array. 0) opts))) +;; (let [value (.-value chunk) +;; buffer (concat-uint8 buffer (->uint8 value))] +;; (if (>= (.-byteLength buffer) snapshot-multipart-part-size) +;; (let [part (.slice buffer 0 snapshot-multipart-part-size) +;; rest-parts (.slice buffer snapshot-multipart-part-size (.-byteLength buffer))] +;; (p/let [^js resp (.uploadPart upload part-number part) +;; parts (conj parts {:partNumber part-number :etag (.-etag resp)})] +;; (p/recur rest-parts (inc part-number) parts))) +;; (p/recur buffer part-number parts)))))) +;; (p/catch (fn [error] +;; (.abort upload) +;; (throw error))))))) + +(declare import-snapshot!) +(defn- import-snapshot-stream! [^js self stream reset?] + (let [reader (.getReader stream) + reset-pending? (volatile! reset?) + total-count (volatile! 0)] + (p/let [buffer nil] + (p/catch + (p/loop [buffer buffer] + (p/let [chunk (.read reader)] + (if (.-done chunk) + (let [rows (snapshot/finalize-framed-buffer buffer) + rows-count (count rows) + reset? (and @reset-pending? true)] + (when (or reset? (seq rows)) + (import-snapshot! self rows reset?) + (vreset! reset-pending? false)) + (vswap! total-count + rows-count) + @total-count) + (let [value (.-value chunk) + {:keys [rows buffer]} (snapshot/parse-framed-chunk buffer value) + rows-count (count rows) + reset? (boolean (and @reset-pending? (seq rows)))] + (when (seq rows) + (import-snapshot! self rows reset?) + (vreset! reset-pending? false)) + (vswap! total-count + rows-count) + (p/recur buffer))))) + (fn [error] + (throw error)))))) + +(defn pull-response [^js self since] + (let [sql (.-sql self) + txs (storage/fetch-tx-since sql since) + checksum (current-checksum self)] + (cond-> {:type "pull/ok" + :t (t-now self) + :txs txs} + (string? checksum) (assoc :checksum checksum)))) + +(defn- block-uuid-lookup-ref + [entity-id] + (when (and (sequential? entity-id) + (= :block/uuid (first entity-id)) + (uuid? (second entity-id))) + (second entity-id))) + +(defn- missing-block-uuids-from-error + [error] + (loop [e error + result []] + (if e + (let [missing-uuid (block-uuid-lookup-ref (:entity-id (ex-data e)))] + (recur (ex-cause e) + (cond-> result + missing-uuid (conj missing-uuid)))) + (-> result distinct vec)))) + +(def ^:private delete-outliner-ops + #{:delete-blocks + :delete-page}) + +(defn- request-context->tx-meta + [{:keys [graph-id client-revision username]}] + (cond-> {} + graph-id (assoc :graph-id graph-id) + client-revision (assoc :client-revision client-revision) + username (assoc :username username))) + +(defn- tempid? + [value] + (or (and (integer? value) (neg? value)) + (string? value))) + +(defn- ref-attr? + [db attr] + (= :db.type/ref (:db/valueType (d/entity db attr)))) + +(defn- tx-item-tempids + [db item] + (cond + (and (map? item) (tempid? (:db/id item))) + #{(:db/id item)} + + (and (vector? item) + (contains? #{:db/add :db/retract :db/cas :db.fn/cas} (first item)) + (<= 4 (count item))) + (let [[_op entity attr value] item] + (cond-> #{} + (tempid? entity) + (conj entity) + (and (ref-attr? db attr) + (tempid? value)) + (conj value))) + + (and (vector? item) + (contains? #{:db/retractEntity :db.fn/retractEntity} (first item)) + (= 2 (count item)) + (tempid? (second item))) + #{(second item)} + + :else + #{})) + +(defn- merge-ranges + [ranges] + (loop [remaining (sort-by first ranges) + merged []] + (if-let [[start end] (first remaining)] + (if-let [[prev-start prev-end] (peek merged)] + (if (<= start prev-end) + (recur (next remaining) + (conj (pop merged) [prev-start (max prev-end end)])) + (recur (next remaining) + (conj merged [start end]))) + (recur (next remaining) [[start end]])) + merged))) + +(defn- tempid-ranges + [db tx-data] + (let [ranges-by-tempid + (reduce-kv + (fn [acc idx item] + (reduce (fn [acc* tempid] + (update acc* tempid + (fn [[start end]] + [(if (some? start) (min start idx) idx) + (if (some? end) (max end idx) idx)]))) + acc + (tx-item-tempids db item))) + {} + tx-data)] + (merge-ranges (vals ranges-by-tempid)))) + +(defn- tempid-range-by-start + [db tx-data] + (let [ranges (tempid-ranges db tx-data) + range-by-start (into {} (map (fn [[start end]] [start end]) ranges))] + range-by-start)) + +(defn- next-ordered-tx-group + [tx-data range-by-start idx] + (if-let [end (get range-by-start idx)] + [(inc end) (subvec tx-data idx (inc end))] + [(inc idx) [(nth tx-data idx)]])) + +(defn- add-group-to-chunk + [items group] + (into items group)) + +(defn- reduce-ordered-tx-chunks + [db f init tx-data] + (let [tx-data (vec tx-data) + item-count (count tx-data) + range-by-start (tempid-range-by-start db tx-data)] + (loop [idx 0 + chunk [] + acc init] + (if (< idx item-count) + (let [[next-idx group] (next-ordered-tx-group tx-data range-by-start idx) + next-count (+ (count chunk) (count group))] + (if (and (seq chunk) + (> next-count large-tx-max-chunk-items)) + (recur idx [] (f acc chunk)) + (recur next-idx (add-group-to-chunk chunk group) acc))) + (cond-> acc + (seq chunk) (f chunk)))))) + +(defn- large-tx? + [tx-data] + (>= (count tx-data) large-tx-min-items)) + +(defn- import-snapshot! [^js self rows reset?] + (let [sql (.-sql self)] + (ensure-schema! self) + (when reset? + (set! (.-conn self) nil) + (reset-import! sql)) + (import-snapshot-rows! sql "kvs" rows))) + +(defn- apply-client-tx-meta + [request-context outliner-op] + (cond-> (merge {:op :apply-client-tx} + (request-context->tx-meta request-context)) + outliner-op + (assoc :outliner-op outliner-op) + (= outliner-op :db-migrate) + (assoc :db-migrate? true + :skip-validate-db? true))) + +(defn- apply-large-tx-entry! + [self conn tx-data {:keys [tx-id outliner-op]} request-context] + (let [db-before @conn + tx-meta (apply-client-tx-meta request-context outliner-op) + sql (when self (.-sql ^js self)) + prev-checksum (when sql (storage/get-checksum sql)) + logical-tx-data (volatile! []) + chunk-count (volatile! 0)] + (log/info :db-sync/apply-large-tx-entry-start + {:graph-id (:graph-id request-context) + :tx-id tx-id + :outliner-op outliner-op + :tx-count (count tx-data) + :max-chunk-items large-tx-max-chunk-items}) + (try + (when sql + (d/listen! conn ::large-logical-tx-checksum + (fn [{:keys [tx-data]}] + (vswap! logical-tx-data into tx-data)))) + ((if sql + #(storage/with-sql-transaction! sql %) + (fn [f] (f))) + (fn [] + (try + (reduce-ordered-tx-chunks + db-before + (fn [_ chunk] + (vswap! chunk-count inc) + (ldb/transact! conn + chunk + (cond-> tx-meta + sql + (assoc :db-sync/skip-checksum-update? true))) + nil) + nil + tx-data) + (when sql + (storage/set-checksum! + sql + (sync-checksum/update-checksum + prev-checksum + {:db-before db-before + :db-after @conn + :tx-data @logical-tx-data}))) + (finally + (when sql + (d/unlisten! conn ::large-logical-tx-checksum)))))) + (log/info :db-sync/apply-large-tx-entry-done + {:graph-id (:graph-id request-context) + :tx-id tx-id + :outliner-op outliner-op + :tx-count (count tx-data) + :chunk-count @chunk-count}) + true + (catch :default error + (log/info :db-sync/apply-large-tx-entry-failed + {:graph-id (:graph-id request-context) + :tx-id tx-id + :outliner-op outliner-op + :tx-count (count tx-data) + :chunk-count @chunk-count}) + (reset! conn db-before) + (throw error))))) + +(defn- sanitize-tx-entry + [db {:keys [tx outliner-op] :as tx-entry}] + (let [input-tx-data (protocol/transit->tx tx) + tx-data (tx-sanitize/sanitize-tx db + input-tx-data + {:drop-missing-retract-ops? (or (= outliner-op :fix) + (contains? delete-outliner-ops outliner-op)) + :drop-ops-targeting-retracted-entities? (contains? delete-outliner-ops + outliner-op) + :retract-touched-descendants? (contains? delete-outliner-ops outliner-op)})] + {:input-tx-data input-tx-data + :tx-data tx-data + :tx-entry tx-entry})) + +(defn- apply-tx-entry! + ([conn tx-entry] + (apply-tx-entry! nil conn tx-entry nil)) + ([self conn {:keys [outliner-op] :as tx-entry} request-context] + (let [sanitized (sanitize-tx-entry @conn tx-entry) + input-tx-data (:input-tx-data sanitized) + tx-data (:tx-data sanitized) + sanitized-entry (:tx-entry sanitized)] + (if (seq tx-data) + (try + (if (and (not= outliner-op :db-migrate) + (large-tx? tx-data)) + (apply-large-tx-entry! self conn tx-data sanitized-entry request-context) + (do + (ldb/transact! conn tx-data (apply-client-tx-meta request-context outliner-op)) + true)) + (catch :default e + ;; Rebase/fix txs are inferred from local history and can become stale + ;; when concurrent remote edits remove referenced entities before upload. + ;; Treat stale :entity-id/missing rebases/fixes as no-op so sync can continue. + (if (and (contains? #{:rebase :fix} outliner-op) + (= :entity-id/missing (:error (ex-data e)))) + (do + (log/warn :db-sync/drop-stale-rebase-tx + {:outliner-op outliner-op + :tx-data tx-data + :error (str e)}) + false) + (throw e)))) + (if (and (contains? delete-outliner-ops outliner-op) + (empty? input-tx-data)) + (throw (ex-info "delete tx input is empty" + {:type :db-sync/empty-delete-tx + :outliner-op outliner-op})) + false))))) + +(defn- apply-tx! [^js self tx-entries request-context] + (let [sql (.-sql self)] + (ensure-conn! self) + (let [conn (.-conn self)] + (loop [remaining tx-entries + applied? false + successful-tx-ids []] + (if-let [tx-entry (first remaining)] + (let [tx-id (:tx-id tx-entry) + applied-entry? (try + (boolean (apply-tx-entry! self conn tx-entry request-context)) + (catch :default e + (log/error :db-sync/transact-failed e) + (let [missing-block-uuids (missing-block-uuids-from-error e)] + (throw (ex-info "tx entry apply failed" + (cond-> {:type :db-sync/tx-entry-failed + :successful-tx-ids successful-tx-ids} + tx-id (assoc :failed-tx-id tx-id) + (seq missing-block-uuids) + (assoc :missing-block-uuids missing-block-uuids)) + e))))) + next-successful-tx-ids (cond-> successful-tx-ids + tx-id (conj tx-id))] + (recur (next remaining) + (or applied? applied-entry?) + next-successful-tx-ids)) + (let [new-t (storage/get-t sql)] + {:t new-t + :applied? applied? + :successful-tx-ids successful-tx-ids})))))) + +(defn handle-tx-batch! + ([^js self sender txs t-before] + (handle-tx-batch! self sender txs t-before nil)) + ([^js self sender txs t-before request-context] + (let [current-t (t-now self)] + (cond + (not (snapshot-upload-finished? self)) + {:type "tx/reject" + :reason "snapshot upload in progress" + :t current-t} + + (or (not (number? t-before)) (neg? t-before)) + {:type "tx/reject" + :reason "invalid t-before"} + + (not= t-before current-t) + {:type "tx/reject" + :reason "stale" + :t current-t} + + :else + (if (seq txs) + (try + (let [{:keys [t applied?]} (apply-tx! self txs request-context) + checksum (current-checksum self)] + (when applied? + ;; Broadcast once per processed batch after tx-log/checksum settle. + (ws/broadcast! self sender {:type "changed" :t t})) + (cond-> {:type "tx/batch/ok" + :t t} + (string? checksum) (assoc :checksum checksum))) + (catch :default e + (let [new-t (t-now self) + checksum (current-checksum self) + {:keys [successful-tx-ids failed-tx-id missing-block-uuids]} + (ex-data e)] + (log/error :db-sync/transact-failed e) + (when (> new-t current-t) + ;; Broadcast once when partial batch writes advanced the graph. + (ws/broadcast! self sender {:type "changed" :t new-t})) + (cond-> {:type "tx/reject" + :reason "db transact failed" + :error-detail (str e) + :t new-t} + (seq successful-tx-ids) (assoc :success-tx-ids successful-tx-ids) + failed-tx-id (assoc :failed-tx-id failed-tx-id) + (seq missing-block-uuids) (assoc :missing-block-uuids missing-block-uuids) + (string? checksum) (assoc :checksum checksum))))) + {:type "tx/reject" + :reason "empty tx data"}))))) + +(defn- handle-sync-pull + [^js self ^js url] + (let [raw-since (.get (.-searchParams url) "since") + since (if (some? raw-since) (parse-int raw-since) 0) + graph-id (.get (.-searchParams url) "graph-id")] + (if (or (and (some? raw-since) (not (number? since))) (neg? since)) + (http/bad-request "invalid since") + (p/let [ready-for-sync? ( block + uuid (assoc :block/uuid (str uuid)) + parent (assoc :block/parent (str parent)) + page (assoc :block/page (str page)) + order (assoc :block/order order))) + +(defn- checksum-diagnostics-response + [^js self] + (ensure-conn! self) + (-> (sync-checksum/recompute-checksum-diagnostics @(.-conn self)) + (update :blocks (fn [blocks] + (mapv normalize-diagnostic-block blocks))))) + +(defn- handle-sync-checksum-diagnostics + [^js self request] + (let [graph-id (graph-id-from-request request)] + (if (not (seq graph-id)) + (http/bad-request "missing graph id") + (p/let [ready-for-sync? ( (snapshot-export-stream self) + gzip? + (maybe-compress-stream)) + row-count (snapshot-row-count (.-sql self)) + headers (cond-> {"content-type" snapshot-content-type} + gzip? + (assoc "content-encoding" snapshot-content-encoding))] + (js/Response. stream + #js {:status 200 + :headers (js/Object.assign + (clj->js headers) + #js {"x-snapshot-row-count" (str row-count)} + (common/cors-headers))}))))) + +(defn- handle-sync-snapshot-download + [^js self request] + (let [graph-id (graph-id-from-request request)] + (cond + (not (seq graph-id)) + (http/bad-request "missing graph id") + + :else + (p/let [ready-for-sync? ( {:ok true + :key key + :url url} + content-encoding + (assoc :content-encoding content-encoding))))))))) + +(defn- handle-sync-admin-reset + [^js self] + (let [^js state (.-state self) + ^js storage (.-storage state) + delete-all (.-deleteAll storage) + delete-alarm (.-deleteAlarm storage)] + (doseq [^js ws (.getWebSockets state)] + (.close ws 1000 "graph deleted")) + (p/let [_ (when (fn? delete-alarm) + (.deleteAlarm storage))] + (if (fn? delete-all) + (p/let [_ (.deleteAll storage)] + (set! (.-schema-ready self) false) + (set! (.-conn self) nil) + (http/json-response :sync/admin-reset {:ok true})) + (do + (common/sql-exec (.-sql self) "drop table if exists kvs") + (common/sql-exec (.-sql self) "drop table if exists tx_log") + (common/sql-exec (.-sql self) "drop table if exists sync_meta") + (storage/init-schema! (.-sql self)) + (set! (.-schema-ready self) true) + (set! (.-conn self) nil) + (http/json-response :sync/admin-reset {:ok true})))))) + +(defn- handle-sync-tx-batch + [^js self request] + (.then (common/read-json request) + (fn [result] + (if (nil? result) + (http/bad-request "missing body") + (let [body (js->clj result :keywordize-keys true) + body (http/coerce-http-request :sync/tx-batch body) + graph-id (graph-id-from-request request)] + (if (nil? body) + (http/bad-request "invalid tx") + (let [{:keys [client-revision txs t-before]} body + t-before (parse-int t-before)] + (if (sequential? txs) + (p/let [ready-for-sync? ( (or (ex-message error) + (some-> error .-message) + (str error)) + string/lower-case)] + (or (string/includes? message "sqlite_toobig") + (string/includes? message "string or blob too big") + (string/includes? message "statement too long")))) + +(defn- handle-sync-snapshot-upload + [^js self request url] + (let [graph-id (graph-id-from-request request) + reset-param (.get (.-searchParams url) "reset") + reset? (parse-reset-param reset-param) + finished-param (.get (.-searchParams url) "finished") + finished? (parse-finished-param finished-param) + checksum-param (.get (.-searchParams url) "checksum") + req-encoding (.get (.-headers request) "content-encoding")] + (cond + (not (seq graph-id)) + (http/bad-request "missing graph id") + + (nil? (.-body request)) + (http/bad-request "missing body") + + :else + (let [stream (.-body request) + encoding (or req-encoding "")] + (if (and (= encoding snapshot-content-encoding) + (not (exists? js/DecompressionStream))) + (http/error-response "gzip not supported" 500) + (p/catch + (p/let [_ (ensure-schema! self) + _ (when reset? + (storage/set-meta! (.-sql self) snapshot-uploading-meta-key true)) + _ (when reset? + ( raw protocol/parse-message ws/coerce-ws-client-message)] + (if-not (map? message) + (ws/send! ws {:type "error" :message "invalid request"}) + (case (:type message) + "hello" + (let [checksum (sync-handler/current-checksum self)] + (ws/send! ws (cond-> {:type "hello" + :t (sync-handler/t-now self)} + (string? checksum) (assoc :checksum checksum)))) + + "ping" + (ws/send! ws {:type "pong"}) + + "presence" + (let [editing-block-uuid (:editing-block-uuid message) + user (presence/get-user self ws)] + (presence/update-presence! self ws {:editing-block-uuid editing-block-uuid}) + (ws/broadcast! self ws {:type "presence" + :editing-block-uuid editing-block-uuid + :user-id (:user-id user)})) + + "pull" + (let [raw-since (:since message) + since (if (some? raw-since) (sync-handler/parse-int raw-since) 0)] + (if (or (and (some? raw-since) (not (number? since))) (neg? since)) + (ws/send! ws {:type "error" :message "invalid since"}) + (ws/send! ws (sync-handler/pull-response self since)))) + + ;; "snapshot" + ;; (send! ws (snapshot-response self)) + + "tx/batch" + (let [txs (:txs message) + user (presence/get-user self ws) + t-before (sync-handler/parse-int (:t-before message))] + (if (sequential? txs) + (ws/send! ws (sync-handler/handle-tx-batch! + self + ws + txs + t-before + (cond-> {:graph-id (aget self "graph-id")} + (:client-revision message) + (assoc :client-revision (:client-revision message)) + (:username user) + (assoc :username (:username user))))) + (ws/send! ws {:type "tx/reject" :reason "invalid tx"}))) + + (ws/send! ws {:type "error" :message "unknown type"}))))) + +(defn handle-ws [^js self request] + (let [graph-id (sync-handler/graph-id-from-request request)] + (p/let [ready-for-sync? (sync-handler/user claims)] + (when user + (presence/add-presence! self server user)) + (presence/broadcast-online-users! self) + (js/Response. nil #js {:status 101 :webSocket client}))))))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/http.cljs b/deps/db-sync/src/logseq/db_sync/worker/http.cljs new file mode 100644 index 0000000..910222b --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/http.cljs @@ -0,0 +1,42 @@ +(ns logseq.db-sync.worker.http + (:require [logseq.db-sync.common :as common] + [logseq.db-sync.malli-schema :as db-sync-schema] + [logseq.db-sync.worker.coerce :as coerce])) + +(defn coerce-http-request [schema-key body] + (if-let [coercer (get db-sync-schema/http-request-coercers schema-key)] + (let [coerced (coerce/coerce coercer body {:schema schema-key :dir :request})] + (when-not (= coerced coerce/invalid-coerce) + coerced)) + body)) + +(defn json-response + ([schema-key data] (json-response schema-key data 200)) + ([schema-key data status] + (if-let [coercer (get db-sync-schema/http-response-coercers schema-key)] + (let [coerced (coerce/coerce coercer data {:schema schema-key :dir :response})] + (if (= coerced coerce/invalid-coerce) + (do + (js/console.error "DEBUG json-response coercion FAILED for" (pr-str schema-key) "data:" (pr-str data)) + (common/json-response + {:error "server error" + :debug-coercion-failed (pr-str schema-key) + :debug-data (pr-str data)} + 500)) + (common/json-response coerced status))) + (common/json-response data status)))) + +(defn error-response [message status] + (json-response :error {:error message} status)) + +(defn bad-request [message] + (error-response message 400)) + +(defn unauthorized [] + (error-response "unauthorized" 401)) + +(defn forbidden [] + (error-response "forbidden" 403)) + +(defn not-found [] + (error-response "not found" 404)) diff --git a/deps/db-sync/src/logseq/db_sync/worker/presence.cljs b/deps/db-sync/src/logseq/db_sync/worker/presence.cljs new file mode 100644 index 0000000..cca6a94 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/presence.cljs @@ -0,0 +1,68 @@ +(ns logseq.db-sync.worker.presence + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [logseq.db-sync.worker.ws :as ws])) + +(defn claims->user + [claims] + (when claims + (let [user-id (aget claims "sub") + email (aget claims "email") + username (or (aget claims "preferred_username") + (aget claims "cognito:username") + (aget claims "username")) + name (aget claims "name")] + (when (string? user-id) + (cond-> {:user-id user-id} + (string? email) (assoc :email email) + (string? username) (assoc :username username) + (string? name) (assoc :name name)))))) + +(defn attachment->user + [attachment] + (:presence/user (bean/->clj attachment))) + +(defn- serialize-attachment! + [^js ws user] + (.serializeAttachment ws (bean/->js {:presence/user user}))) + +(defn presence* + [^js self] + (or (.-presence self) + (set! (.-presence self) (atom {})))) + +(defn online-users + [^js self] + (vec (distinct (vals @(presence* self))))) + +(defn broadcast-online-users! + [^js self] + (ws/broadcast! self nil {:type "online-users" :online-users (online-users self)})) + +(defn add-presence! + [^js self ^js ws user] + (swap! (presence* self) assoc ws user) + (serialize-attachment! ws user)) + +(defn update-presence! + [^js self ^js ws {:keys [editing-block-uuid] :as updates}] + (swap! (presence* self) + (fn [presence] + (if-let [user (get presence ws)] + (let [user' (if (contains? updates :editing-block-uuid) + (if (and (string? editing-block-uuid) + (not (string/blank? editing-block-uuid))) + (assoc user :editing-block-uuid editing-block-uuid) + (dissoc user :editing-block-uuid)) + user)] + (serialize-attachment! ws user') + (assoc presence ws user')) + presence)))) + +(defn get-user + [^js self ^js ws] + (get @(presence* self) ws)) + +(defn remove-presence! + [^js self ^js ws] + (swap! (presence* self) dissoc ws)) diff --git a/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs b/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs new file mode 100644 index 0000000..807a730 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/routes/index.cljs @@ -0,0 +1,34 @@ +(ns logseq.db-sync.worker.routes.index + (:require [reitit.core :as r])) + +(def ^:private route-data + [["/admin" + ["/graphs/:graph-id" {:methods {"DELETE" :admin-graphs/delete}}]] + + ["/graphs" + ["" {:methods {"GET" :graphs/list + "POST" :graphs/create}}] + ["/:graph-id" + ["/access" {:methods {"GET" :graphs/access}}] + ["/members" {:methods {"GET" :graph-members/list + "POST" :graph-members/create}}] + ["/members/:member-id" {:methods {"PUT" :graph-members/update + "DELETE" :graph-members/delete}}] + ["" {:methods {"DELETE" :graphs/delete}}]]] + + ["/e2ee" + ["/user-keys" {:methods {"GET" :e2ee/user-keys-get + "POST" :e2ee/user-keys-post}}] + ["/user-public-key" {:methods {"GET" :e2ee/user-public-key-get}}] + ["/graphs/:graph-id" + ["/aes-key" {:methods {"GET" :e2ee/graph-aes-key-get + "POST" :e2ee/graph-aes-key-post}}] + ["/grant-access" {:methods {"POST" :e2ee/grant-access}}]]]]) + +(def ^:private router + (r/router route-data)) + +(defn match-route [method path] + (when-let [match (r/match-by-path router path)] + (when-let [handler (get-in match [:data :methods method])] + (assoc match :handler handler)))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/routes/semantic.cljs b/deps/db-sync/src/logseq/db_sync/worker/routes/semantic.cljs new file mode 100644 index 0000000..3001582 --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/routes/semantic.cljs @@ -0,0 +1,410 @@ +(ns logseq.db-sync.worker.routes.semantic + (:require [clojure.string :as string] + [flatland.ordered.map :refer [ordered-map]] + [reitit.core :as r])) + +(def operations + [{:method "GET" :path "/api/v1/graphs" + :handler :semantic/graphs-list :operation-id "listGraphs" :scope "logseq/read" :rate-class :read} + {:method "GET" :path "/api/v1/graphs/:graph-id/pages" :internal-path "/semantic/pages" + :handler :semantic/pages-list :operation-id "listPages" :scope "logseq/read" :rate-class :read} + {:method "POST" :path "/api/v1/graphs/:graph-id/pages" :internal-path "/semantic/pages" + :handler :semantic/pages-create :operation-id "createPage" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/pages/:page-id/blocks" :internal-path "/semantic/pages/:page-id/blocks" + :handler :semantic/pages-blocks :operation-id "listPageBlocks" :scope "logseq/read" :rate-class :read} + {:method "GET" :path "/api/v1/graphs/:graph-id/pages/:page-id/references" :internal-path "/semantic/pages/:page-id/references" + :handler :semantic/pages-references :operation-id "listPageReferences" :scope "logseq/read" :rate-class :read} + {:method "DELETE" :path "/api/v1/graphs/:graph-id/pages/:page-id" :internal-path "/semantic/pages/:page-id" + :handler :semantic/pages-delete :operation-id "deletePage" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/pages/:page-id" :internal-path "/semantic/pages/:page-id" + :handler :semantic/pages-get :operation-id "getPage" :scope "logseq/read" :rate-class :read} + {:method "PATCH" :path "/api/v1/graphs/:graph-id/pages/:page-id" :internal-path "/semantic/pages/:page-id" + :handler :semantic/pages-update :operation-id "updatePage" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/blocks/:block-id" :internal-path "/semantic/blocks/:block-id" + :handler :semantic/blocks-get :operation-id "getBlock" :scope "logseq/read" :rate-class :read} + {:method "PATCH" :path "/api/v1/graphs/:graph-id/blocks/:block-id" :internal-path "/semantic/blocks/:block-id" + :handler :semantic/blocks-update :operation-id "updateBlock" :scope "logseq/write" :rate-class :write} + {:method "DELETE" :path "/api/v1/graphs/:graph-id/blocks/:block-id" :internal-path "/semantic/blocks/:block-id" + :handler :semantic/blocks-delete :operation-id "deleteBlock" :scope "logseq/write" :rate-class :write} + {:method "POST" :path "/api/v1/graphs/:graph-id/block-moves" :internal-path "/semantic/block-moves" + :handler :semantic/blocks-move :operation-id "moveBlocks" :scope "logseq/write" :rate-class :write} + {:method "POST" :path "/api/v1/graphs/:graph-id/blocks/:block-id/children" :internal-path "/semantic/blocks/:block-id/children" + :handler :semantic/blocks-insert-children :operation-id "insertBlockChildren" :scope "logseq/write" :rate-class :write} + {:method "POST" :path "/api/v1/graphs/:graph-id/block-trees" :internal-path "/semantic/block-trees" + :handler :semantic/blocks-insert-tree :operation-id "insertBlockTree" :scope "logseq/write" :rate-class :write} + {:method "PUT" :path "/api/v1/graphs/:graph-id/blocks/:block-id/properties/:property-id" :internal-path "/semantic/blocks/:block-id/properties/:property-id" + :handler :semantic/blocks-set-property :operation-id "setBlockProperty" :scope "logseq/write" :rate-class :write} + {:method "DELETE" :path "/api/v1/graphs/:graph-id/blocks/:block-id/properties/:property-id" :internal-path "/semantic/blocks/:block-id/properties/:property-id" + :handler :semantic/blocks-delete-property :operation-id "deleteBlockProperty" :scope "logseq/write" :rate-class :write} + {:method "POST" :path "/api/v1/graphs/:graph-id/block-properties/batch-set" :internal-path "/semantic/block-properties/batch-set" + :handler :semantic/blocks-batch-set-property :operation-id "batchSetBlockProperty" :scope "logseq/write" :rate-class :write} + {:method "POST" :path "/api/v1/graphs/:graph-id/block-properties/batch-delete" :internal-path "/semantic/block-properties/batch-delete" + :handler :semantic/blocks-batch-delete-property :operation-id "batchDeleteBlockProperty" :scope "logseq/write" :rate-class :write} + {:method "POST" :path "/api/v1/graphs/:graph-id/capture" :internal-path "/semantic/capture" + :handler :semantic/capture :operation-id "captureToToday" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/tasks" :internal-path "/semantic/tasks" + :handler :semantic/tasks-list :operation-id "listTasks" :scope "logseq/read" :rate-class :read} + {:method "POST" :path "/api/v1/graphs/:graph-id/tasks" :internal-path "/semantic/tasks" + :handler :semantic/tasks-create :operation-id "createTask" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/tags" :internal-path "/semantic/tags" + :handler :semantic/tags-list :operation-id "listTags" :scope "logseq/read" :rate-class :read} + {:method "POST" :path "/api/v1/graphs/:graph-id/tags" :internal-path "/semantic/tags" + :handler :semantic/tags-create :operation-id "createTag" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/tags/:tag-id" :internal-path "/semantic/tags/:tag-id" + :handler :semantic/tags-get :operation-id "getTag" :scope "logseq/read" :rate-class :read} + {:method "GET" :path "/api/v1/graphs/:graph-id/tags/:tag-id/objects" :internal-path "/semantic/tags/:tag-id/objects" + :handler :semantic/tags-objects :operation-id "listTagObjects" :scope "logseq/read" :rate-class :read} + {:method "PATCH" :path "/api/v1/graphs/:graph-id/tags/:tag-id" :internal-path "/semantic/tags/:tag-id" + :handler :semantic/tags-update :operation-id "updateTag" :scope "logseq/write" :rate-class :write} + {:method "DELETE" :path "/api/v1/graphs/:graph-id/tags/:tag-id" :internal-path "/semantic/tags/:tag-id" + :handler :semantic/tags-delete :operation-id "deleteTag" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/properties" :internal-path "/semantic/properties" + :handler :semantic/properties-list :operation-id "listProperties" :scope "logseq/read" :rate-class :read} + {:method "POST" :path "/api/v1/graphs/:graph-id/properties" :internal-path "/semantic/properties" + :handler :semantic/properties-create :operation-id "createProperty" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/properties/:property-id" :internal-path "/semantic/properties/:property-id" + :handler :semantic/properties-get :operation-id "getProperty" :scope "logseq/read" :rate-class :read} + {:method "PATCH" :path "/api/v1/graphs/:graph-id/properties/:property-id" :internal-path "/semantic/properties/:property-id" + :handler :semantic/properties-update :operation-id "updateProperty" :scope "logseq/write" :rate-class :write} + {:method "DELETE" :path "/api/v1/graphs/:graph-id/properties/:property-id" :internal-path "/semantic/properties/:property-id" + :handler :semantic/properties-delete :operation-id "deleteProperty" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/assets" :internal-path "/semantic/assets" + :handler :semantic/assets-list :operation-id "listAssets" :scope "logseq/read" :rate-class :read} + {:method "POST" :path "/api/v1/graphs/:graph-id/assets" :internal-path "/semantic/assets" + :handler :semantic/assets-create :operation-id "createAsset" :scope "logseq/write" :rate-class :write} + {:method "GET" :path "/api/v1/graphs/:graph-id/assets/:asset-block-id" :internal-path "/semantic/assets/:asset-block-id" + :handler :semantic/assets-get :operation-id "getAsset" :scope "logseq/read" :rate-class :read} + {:method "GET" :path "/api/v1/graphs/:graph-id/search" :internal-path "/semantic/search" + :handler :semantic/search :operation-id "searchGraph" :scope "logseq/read" :rate-class :read}]) + +(def ^:private operation-docs + {"listGraphs" ["List available graphs" "Returns cursor-paginated non-E2EE graphs available to the authenticated user. Use the optional exact name filter to resolve a graph name to its UUID."] + "listPages" ["List pages" "Returns a cursor-paginated list of page blocks in the graph."] + "createPage" ["Create a page" "Creates a page block with the supplied title."] + "listPageBlocks" ["List a page's blocks" "Returns a cursor-paginated list of the page's top-level blocks, including each selected block's descendant tree."] + "listPageReferences" ["List references to a page" "Returns a cursor-paginated flat list of blocks and pages that reference the addressed page. Results are not recursively expanded."] + "getPage" ["Get a page" "Returns one page block by UUID without loading its block tree."] + "updatePage" ["Update a page" "Renames one page identified by its block UUID."] + "deletePage" ["Delete a page" "Deletes one page through Logseq's page deletion rules, including its block tree."] + "getBlock" ["Get a block" "Returns one block or page by UUID without loading an unbounded tree."] + "updateBlock" ["Update a block" "Replaces the title of one block identified by UUID."] + "deleteBlock" ["Delete a block" "Deletes one block and its descendants. Asset blocks are deleted through this same operation."] + "moveBlocks" ["Move blocks" "Moves one or more blocks together before, after, into the first-child position, or into the last-child position of a target block. Logseq preserves the blocks' outliner order."] + "insertBlockChildren" ["Insert child block trees" "Appends or prepends one or more recursive block trees as children of the addressed block."] + "insertBlockTree" ["Insert block trees" "Inserts one or more recursive block trees relative to an explicit target block."] + "setBlockProperty" ["Set a block property" "Idempotently adds or replaces one typed property value on one block in a DB graph. The property path value may be its UUID or ident. Never write legacy file-graph key:: value syntax into the block title."] + "deleteBlockProperty" ["Delete a block property" "Idempotently removes one property from one block. The property definition itself is not deleted."] + "batchSetBlockProperty" ["Set block properties in a batch" "Atomically adds or replaces multiple typed block-property values in a DB graph after validating every entry. Property IDs are UUIDs or idents; never use legacy file-graph key:: value syntax."] + "batchDeleteBlockProperty" ["Delete block properties in a batch" "Atomically removes multiple block-property values after validating every entry."] + "captureToToday" ["Capture to today's journal" "Creates today's journal page when necessary and appends the supplied recursive block trees to its end."] + "listTasks" ["List DB tasks" "Lists cursor-paginated DB Task objects using the Task class index, with optional status, priority, and title-content filters. Use this operation instead of graph search to retrieve tasks."] + "createTask" ["Create a DB task" "Creates a DB Task object with the Task class and typed status, priority, scheduled, and deadline properties. Never encode a task as Markdown TODO text."] + "listTags" ["List tags" "Returns a cursor-paginated list of tag entities in the graph."] + "createTag" ["Create a tag" "Creates a Logseq tag with the supplied title."] + "getTag" ["Get a tag" "Returns one tag by UUID."] + "listTagObjects" ["List objects with a tag" "Returns a cursor-paginated flat list of blocks and pages tagged with the addressed tag. Results are not recursively expanded."] + "updateTag" ["Update a tag" "Renames one tag identified by UUID."] + "deleteTag" ["Delete a tag" "Deletes one tag through Logseq's page deletion rules."] + "listProperties" ["List property definitions" "Returns a cursor-paginated list of property definitions in the graph."] + "createProperty" ["Create a property definition" "Creates a typed property definition in a DB graph with its name, value type, and cardinality. Use the returned UUID or ident to set values; never use legacy file-graph key:: value syntax."] + "getProperty" ["Get a property definition" "Returns one property definition by UUID."] + "updateProperty" ["Update a property definition" "Updates the name, value type, or cardinality of one typed property definition in a DB graph. Never use legacy file-graph key:: value syntax."] + "deleteProperty" ["Delete a property definition" "Deletes one property definition through Logseq's property deletion rules."] + "listAssets" ["List assets" "Returns a cursor-paginated list of asset blocks with optional creation and update time filters."] + "createAsset" ["Upload an asset" "Streams a file of at most 100MB to R2 and creates its Asset-class block. Requires a client-computed SHA-256 checksum."] + "getAsset" ["Get an asset download link" "Returns asset metadata and a five-minute signed URL for the asset block's R2 object. For an image asset, fetch or use the URL directly to display image content; it does not require an Authorization header."] + "searchGraph" ["Search graph resources" "Searches blocks, tags, properties, and assets by title and returns cursor-paginated results."]}) + +(defn- operation-routes [path-key] + (->> operations + (filter path-key) + (group-by path-key) + (mapv (fn [[path path-operations]] + [path {:methods (into {} (map (juxt :method identity)) path-operations)}])))) + +(def ^:private public-router + (r/router (operation-routes :path))) + +(def ^:private internal-router + (r/router (operation-routes :internal-path))) + +(defn- match [router method path] + (when-let [route (r/match-by-path router path)] + (when-let [operation (get-in route [:data :methods method])] + (merge operation (select-keys route [:path-params]))))) + +(defn match-public [method path] + (match public-router method path)) + +(defn match-internal [method path] + (match internal-router method path)) + +(defn- openapi-path [path] + (string/replace path #":([^/]+)" "{$1}")) + +(defn- path-parameters [path] + (mapv (fn [[_ parameter]] + (cond-> {:name parameter :in "path" :required true :schema {:type "string"}} + (= "property-id" parameter) + (assoc :description "Property UUID, qualified ident, or exact title."))) + (re-seq #":([^/]+)" path))) + +(def ^:private pagination-parameters + [{:name "limit" :in "query" :schema {:type "integer" :minimum 1 :maximum 200 :default 50}} + {:name "cursor" :in "query" :schema {:type "string"}}]) + +(def ^:private time-filter-parameters + [{:name "created-after" :in "query" + :description "Return entities created strictly after this Unix epoch timestamp in milliseconds." + :schema {:type "integer" :minimum 0}} + {:name "updated-after" :in "query" + :description "Return entities updated strictly after this Unix epoch timestamp in milliseconds." + :schema {:type "integer" :minimum 0}}]) + +(def ^:private asset-upload-parameters + [{:name "file-name" :in "query" :required true :schema {:type "string"}} + {:name "size" :in "query" :required true + :description "Exact decoded file size in bytes. The trusted MCP host recalculates it for encoding=base64; direct API uploads must supply it. Uploads larger than 100MB are rejected before R2." + :schema {:type "integer" :minimum 0 :maximum 104857600}} + {:name "title" :in "query" :schema {:type "string"}} + {:name "page-id" :in "query" + :description "Destination page UUID. Omit to append to today's journal." + :schema {:type "string"}} + {:name "checksum" :in "query" :required true + :description "Lowercase or uppercase SHA-256 hex digest of the file." + :schema {:type "string" :pattern "^[0-9a-fA-F]{64}$"}} + {:name "encoding" :in "query" + :description "Use base64 when the caller cannot transfer raw binary, including ChatGPT Code Mode. The server decodes it as a stream before writing to R2; size and checksum describe the decoded file." + :schema {:type "string" :enum ["base64"]}}]) + +(defn- request-schema [operation-id] + (case operation-id + "createPage" {:required ["title"] :properties {:title {:type "string"}}} + "updatePage" {:required ["title"] :properties {:title {:type "string"}}} + "updateBlock" {:required ["title"] :properties {:title {:type "string"}}} + "moveBlocks" {:required ["block-ids" "target-id" "position"] + :properties {:block-ids {:type "array" :minItems 1 :uniqueItems true + :items {:type "string"}} + :target-id {:type "string"} + :position {:enum ["before" "after" "first-child" "last-child"]}}} + "insertBlockChildren" {:required ["position" "blocks"] + :properties {:position {:enum ["append" "prepend"]} + :blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}} + "insertBlockTree" {:required ["target-id" "blocks"] + :properties {:target-id {:type "string"} + :position {:enum ["append" "prepend"] :default "append"} + :blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}} + "setBlockProperty" {:required ["value"] + :properties {:value {:$ref "#/components/schemas/PropertyValue"}}} + "batchSetBlockProperty" {:required ["entries"] + :properties {:isResetExistingValues + {:type "boolean" :default false + :description "For cardinality-many values, replace existing values when true; append when false or omitted."} + :entries {:type "array" + :items {:type "object" :required ["block-id" "property-id" "value"] + :properties {:block-id {:type "string"} + :property-id {:type "string"} + :value {:$ref "#/components/schemas/PropertyValue"}}}}}} + "batchDeleteBlockProperty" {:required ["entries"] + :properties {:entries {:type "array" + :items {:type "object" :required ["block-id" "property-id"] + :properties {:block-id {:type "string"} + :property-id {:type "string"}}}}}} + "captureToToday" {:required ["blocks"] + :properties {:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}} + "createTask" {:required ["title"] + :properties {:title {:type "string"} + :page-id {:type "string" + :description "Destination page UUID. Omit to append to today's journal."} + :status {:$ref "#/components/schemas/TaskStatusSelector"} + :priority {:$ref "#/components/schemas/TaskPrioritySelector"} + :scheduled {:type "integer" :description "Scheduled date as YYYYMMDD."} + :deadline {:type "integer" :description "Deadline date as YYYYMMDD."}}} + "createTag" {:required ["title"] :properties {:title {:type "string"}}} + "updateTag" {:required ["title"] :properties {:title {:type "string"}}} + "createProperty" {:required ["title"] + :properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"} + :cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}} + "updateProperty" {:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"} + :cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}} + nil)) + +(defn- operation-parameters [{:keys [operation-id path]}] + (cond-> (path-parameters path) + (contains? #{"listGraphs" "listPages" "listPageBlocks" "listPageReferences" "listTasks" "listTags" + "listTagObjects" "listProperties" "listAssets" "searchGraph"} operation-id) + (into pagination-parameters) + (contains? #{"listPages" "listTasks" "listTags" "listTagObjects" "listProperties" "listAssets" "searchGraph"} + operation-id) + (into time-filter-parameters) + (= "createAsset" operation-id) + (into asset-upload-parameters) + (= "listGraphs" operation-id) + (into [{:name "name" :in "query" :schema {:type "string"} + :description "Exact graph name, matched case-insensitively."}]) + (= "searchGraph" operation-id) + (into [{:name "q" :in "query" :required true :schema {:type "string"}} + {:name "types" :in "query" :schema {:type "string"}}]) + (= "listTasks" operation-id) + (into [{:name "status" :in "query" :schema {:$ref "#/components/schemas/TaskStatusSelector"}} + {:name "priority" :in "query" :schema {:$ref "#/components/schemas/TaskPrioritySelector"}} + {:name "content" :in "query" :schema {:type "string"} + :description "Case-insensitive task title filter."}]))) + +(defn- request-body [operation-id] + (if (= "createAsset" operation-id) + {:required true + :content {"application/octet-stream" + {:schema {:type "string" :format "binary"}}}} + (when-let [schema (request-schema operation-id)] + {:required true + :content {"application/json" {:schema (assoc schema :type "object")}}}))) + +(defn- list-response [response-key schema description] + {:description description + :content {"application/json" + {:schema {:type "object" + :required [(name response-key)] + :properties {response-key {:type "array" + :items {:$ref (str "#/components/schemas/" schema)}} + :next-cursor {:type "string"}}}}}}) + +(defn- entity-response [schema description] + {:description description + :content {"application/json" {:schema {:$ref (str "#/components/schemas/" schema)}}}}) + +(defn- operation-responses [operation-id] + (merge {"200" (case operation-id + "listPages" (list-response :pages "PageResponse" "A cursor page of pages") + "listPageBlocks" + (list-response :blocks "BlockResponse" "A cursor page of top-level block trees") + "listPageReferences" (list-response :references "BlockResponse" "A cursor page of page references") + "listTasks" + (list-response :tasks "TaskResponse" "A cursor page of DB Task objects") + "listTags" (list-response :tags "TagResponse" "A cursor page of tags") + "listTagObjects" (list-response :objects "BlockResponse" "A cursor page of tagged objects") + "listProperties" (list-response :properties "PropertyResponse" "A cursor page of property definitions") + "listAssets" + (list-response :assets "AssetResponse" "A cursor page of asset blocks") + "searchGraph" (list-response :results "SearchResultResponse" "A cursor page of search results") + "getPage" (entity-response "PageResponse" "A page") + "updatePage" (entity-response "PageResponse" "The updated page") + "getBlock" (entity-response "BlockResponse" "A block") + "updateBlock" (entity-response "BlockResponse" "The updated block") + "getTag" (entity-response "TagResponse" "A tag") + "updateTag" (entity-response "TagResponse" "The updated tag") + "getProperty" + (entity-response "PropertyResponse" "A property definition and its user-extensible choices") + "updateProperty" (entity-response "PropertyResponse" "The updated property definition") + "getAsset" (entity-response "AssetResponse" "Asset metadata and a temporary URL") + {:description "Success"}) + "400" {:description "Invalid request"} + "403" {:description "Forbidden"} + "409" {:description "Unavailable for E2EE graphs"} + "429" {:description "Rate limit exceeded"}} + (case operation-id + "createPage" {"201" (entity-response "PageResponse" "The created page")} + "insertBlockChildren" {"201" {:description "The inserted block trees"}} + "insertBlockTree" {"201" {:description "The inserted block trees"}} + "createTag" {"201" (entity-response "TagResponse" "The created tag")} + "createProperty" {"201" (entity-response "PropertyResponse" "The created property definition")} + "createTask" + {"201" (entity-response "TaskResponse" "The created DB Task object")} + "createAsset" + {"201" (entity-response "AssetResponse" "The created asset block")} + {}))) + +(defn openapi-document [issuer] + {:openapi "3.1.0" + :info {:title "Logseq Server API" :version "1.0.0"} + :servers [{:url "/"}] + :components + {:schemas (ordered-map + :BlockTree {:type "object" :required ["title"] + :properties {:title {:type "string"} + :children {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}} + :BlockResponse {:type "object" :required ["uuid" "kind" "title"] + :properties {:uuid {:type "string"} + :kind {:type "string"} + :title {:type "string"} + :order {:type "string"} + :parent-id {:type "string"} + :page-id {:type "string"} + :children {:type "array" + :items {:$ref "#/components/schemas/BlockResponse"}}}} + :PageResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}]} + :TagResponse {:type "object" :required ["uuid" "title"] + :properties {:uuid {:type "string"} + :title {:type "string"} + :ident {:type "string"}}} + :SearchResultResponse {:type "object" :required ["uuid" "title" "resource"] + :properties {:uuid {:type "string"} + :title {:type "string"} + :resource {:enum ["blocks" "tags" "properties" "assets"]}}} + :EntitySelector {:type "string" + :description "An existing entity UUID, qualified ident, or exact title."} + :TaskStatusSelector + {:description "A built-in status alias or any user-extensible Status property choice selected by UUID, ident, or exact title." + :anyOf [{:type "string" :enum ["todo" "doing" "in-review" "done" "canceled" "backlog"]} + {:$ref "#/components/schemas/EntitySelector"}]} + :TaskPrioritySelector + {:description "A built-in priority alias or any user-extensible Priority property choice selected by UUID, ident, or exact title." + :anyOf [{:type "string" :enum ["low" "medium" "high" "urgent"]} + {:$ref "#/components/schemas/EntitySelector"}]} + :PropertyChoice {:type "object" :required ["uuid" "title"] + :properties {:uuid {:type "string"} + :ident {:type "string"} + :title {:type "string"} + :value {}}} + :PropertyResponse {:type "object" :required ["uuid" "title" "type" "cardinality"] + :properties {:uuid {:type "string"} + :ident {:type "string"} + :title {:type "string"} + :type {:type "string"} + :cardinality {:type "string"} + :choices {:type "array" + :description "The current user-extensible choices for this property." + :items {:$ref "#/components/schemas/PropertyChoice"}}}} + :PropertyType {:type "string" + :enum ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset" + "map" "json" "string"]} + :AssetResponse {:type "object" :required ["uuid" "title" "type" "size" "checksum"] + :properties {:uuid {:type "string"} + :title {:type "string"} + :type {:type "string"} + :size {:type "integer" :minimum 0 :maximum 104857600} + :checksum {:type "string" :pattern "^[0-9a-f]{64}$"} + :url {:type "string" :format "uri"} + :expires-at {:type "integer"}}} + :TaskResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"} + {:type "object" + :required ["status"] + :properties {:status {:$ref "#/components/schemas/PropertyChoice"} + :priority {:$ref "#/components/schemas/PropertyChoice"} + :scheduled {:type "integer"} + :deadline {:type "integer"}}}]} + :PropertyValue + {:description "For ref properties, pass an existing entity by UUID, ident, or title. Status and Priority also accept case-insensitive built-in aliases such as TODO, DONE, and HIGH; user-defined choices use UUID, ident, or exact title. Class properties such as Tags accept a class UUID, ident, or title, or an array of those selectors. Scalar properties accept their JSON scalar value." + :oneOf [{:type "string"} {:type "number"} {:type "boolean"} + {:type "array" :items {:oneOf [{:type "string"} {:type "number"}]}}]}) + :securitySchemes + {:oauth {:type "oauth2" + :flows {:authorizationCode + {:authorizationUrl (str issuer "/oauth2/authorize") + :tokenUrl (str issuer "/oauth2/token") + :scopes {:logseq/read "Read pages and blocks" + :logseq/write "Create and edit pages and blocks"}}}}}} + :paths + (reduce (fn [paths {:keys [method path operation-id scope] :as operation}] + (let [[summary description] (get operation-docs operation-id)] + (assoc-in paths [(openapi-path path) (keyword (.toLowerCase method))] + (cond-> {:operationId operation-id + :summary summary + :description description + :parameters (operation-parameters operation) + :security [{:oauth [scope]}] + :responses (operation-responses operation-id)} + (request-body operation-id) + (assoc :requestBody (request-body operation-id)))))) + (ordered-map) + operations)}) diff --git a/deps/db-sync/src/logseq/db_sync/worker/routes/sync.cljs b/deps/db-sync/src/logseq/db_sync/worker/routes/sync.cljs new file mode 100644 index 0000000..0e678ec --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/routes/sync.cljs @@ -0,0 +1,20 @@ +(ns logseq.db-sync.worker.routes.sync + (:require [reitit.core :as r])) + +(def ^:private route-data + [["/health" {:methods {"GET" :sync/health}}] + ["/pull" {:methods {"GET" :sync/pull}}] + ["/checksum/diagnostics" {:methods {"GET" :sync/checksum-diagnostics}}] + ["/snapshot/download" {:methods {"GET" :sync/snapshot-download}}] + ["/snapshot/stream" {:methods {"GET" :sync/snapshot-stream}}] + ["/admin/reset" {:methods {"DELETE" :sync/admin-reset}}] + ["/tx/batch" {:methods {"POST" :sync/tx-batch}}] + ["/snapshot/upload" {:methods {"POST" :sync/snapshot-upload}}]]) + +(def ^:private router + (r/router route-data)) + +(defn match-route [method path] + (when-let [match (r/match-by-path router path)] + (when-let [handler (get-in match [:data :methods method])] + (assoc match :handler handler)))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/timing.cljs b/deps/db-sync/src/logseq/db_sync/worker/timing.cljs new file mode 100644 index 0000000..5bf0d9b --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/timing.cljs @@ -0,0 +1,20 @@ +(ns logseq.db-sync.worker.timing) + +(defn summary + "Returns duration summary from `start-ms` to `end-ms` with ordered `steps`. + + `steps` is a vector of `[label ms]` pairs where each `ms` is an absolute + timestamp in milliseconds." + [start-ms steps end-ms] + (let [steps (or steps []) + result (reduce (fn [{:keys [prev entries]} [label ms]] + {:prev ms + :entries (conj entries {:label label + :ms (- ms prev)})}) + {:prev start-ms :entries []} + steps) + last-ms (:prev result) + tail-ms (- end-ms last-ms)] + {:total-ms (- end-ms start-ms) + :steps (:entries result) + :tail-ms tail-ms})) diff --git a/deps/db-sync/src/logseq/db_sync/worker/ws.cljs b/deps/db-sync/src/logseq/db_sync/worker/ws.cljs new file mode 100644 index 0000000..5eed65c --- /dev/null +++ b/deps/db-sync/src/logseq/db_sync/worker/ws.cljs @@ -0,0 +1,36 @@ +(ns logseq.db-sync.worker.ws + (:require [lambdaisland.glogi :as log] + [logseq.db-sync.malli-schema :as db-sync-schema] + [logseq.db-sync.protocol :as protocol] + [logseq.db-sync.worker.coerce :as coerce])) + +(defn ws-open? [ws] + (= 1 (.-readyState ws))) + +(defn coerce-ws-client-message [message] + (when message + (let [coerced (coerce/coerce db-sync-schema/ws-client-message-coercer message {:schema :ws/client})] + (when-not (= coerced coerce/invalid-coerce) + coerced)))) + +(defn coerce-ws-server-message [message] + (when message + (let [coerced (coerce/coerce db-sync-schema/ws-server-message-coercer message {:schema :ws/server})] + (when-not (= coerced coerce/invalid-coerce) + coerced)))) + +(defn send! [ws msg] + (when (ws-open? ws) + (if-let [coerced (coerce-ws-server-message msg)] + (.send ws (protocol/encode-message coerced)) + (do + (log/error :db-sync/ws-response-invalid {:message msg}) + (.send ws (protocol/encode-message {:type "error" :message "server error"})))))) + +(defn broadcast! [^js self sender msg] + (when-let [state (some-> self .-state)] + (when (fn? (.-getWebSockets state)) + (let [clients (.getWebSockets state)] + (doseq [ws clients] + (when (and (not= ws sender) (ws-open? ws)) + (send! ws msg))))))) diff --git a/deps/db-sync/start.sh b/deps/db-sync/start.sh new file mode 100755 index 0000000..fc4a1ba --- /dev/null +++ b/deps/db-sync/start.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DB_SYNC_PORT:=8787}" + +# Defaults match the local `pnpm watch` app auth config. +# Override these env vars for production pool values if needed. +: "${COGNITO_ISSUER:=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8}" +: "${COGNITO_CLIENT_ID:=69cs1lgme7p8kbgld8n5kseii6}" +: "${COGNITO_JWKS_URL:=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json}" + +export DB_SYNC_PORT +export COGNITO_ISSUER +export COGNITO_CLIENT_ID +export COGNITO_JWKS_URL + +node worker/dist/node-adapter.js diff --git a/deps/db-sync/test/logseq/db_sync/batch_test.cljs b/deps/db-sync/test/logseq/db_sync/batch_test.cljs new file mode 100644 index 0000000..af88a6b --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/batch_test.cljs @@ -0,0 +1,17 @@ +(ns logseq.db-sync.batch-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.db-sync.batch :as batch])) + +(deftest rows->insert-batches-test + (testing "splits rows into batches based on max params" + (let [rows [[1 "a" nil] + [2 "b" nil] + [3 "c" nil]] + batches (batch/rows->insert-batches "kvs_import" rows {:max-params 6})] + (is (= 2 (count batches))) + (is (= [1 "a" nil 2 "b" nil] (:args (first batches)))) + (is (= [3 "c" nil] (:args (second batches)))) + (is (string? (:sql (first batches)))) + (is (string? (:sql (second batches)))))) + (testing "empty rows returns no batches" + (is (= [] (batch/rows->insert-batches "kvs_import" [] {:max-params 6}))))) diff --git a/deps/db-sync/test/logseq/db_sync/checksum_test.cljs b/deps/db-sync/test/logseq/db_sync/checksum_test.cljs new file mode 100644 index 0000000..22b2a66 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/checksum_test.cljs @@ -0,0 +1,547 @@ +(ns logseq.db-sync.checksum-test + (:require ["fs" :as fs] + [cljs.reader :as reader] + [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db-sync.checksum :as checksum] + [logseq.db.frontend.schema :as db-schema])) + +(defn- sample-db + [] + (let [page-a-uuid (random-uuid) + page-b-uuid (random-uuid) + parent-uuid (random-uuid) + child-uuid (random-uuid)] + (-> (d/empty-db db-schema/schema) + (d/db-with [{:db/id 1 + :block/uuid page-a-uuid + :block/name "page-a" + :block/title "Page A"} + {:db/id 2 + :block/uuid page-b-uuid + :block/name "page-b" + :block/title "Page B"} + {:db/id 3 + :block/uuid parent-uuid + :block/title "Parent" + :block/parent 1 + :block/page 1} + {:db/id 4 + :block/uuid child-uuid + :block/title "Child" + :block/parent 3 + :block/page 1}])))) + +(defn- assert-incremental=full! + [db-before checksum-before tx-data] + (let [tx-report (d/with db-before tx-data) + full (checksum/recompute-checksum (:db-after tx-report)) + incremental (checksum/update-checksum checksum-before tx-report)] + (is (= full incremental) + (str "Expected checksum parity for tx-data: " (pr-str tx-data))) + {:db (:db-after tx-report) + :checksum incremental})) + +(defn- load-rebased-retract-checksum-fixture + [] + (let [payload (-> (.readFileSync fs "test/logseq/db_sync/fixtures/rebased_retract_checksum_payload.edn" "utf8") + reader/read-string)] + {:db-before (ldb/read-transit-str (:db-before payload)) + :db-after (ldb/read-transit-str (:db-after payload)) + :tx-data (ldb/read-transit-str (:tx-data payload))})) + +(defn- load-parent-order-rebase-checksum-fixture + [] + (let [payload (-> (.readFileSync fs "test/logseq/db_sync/fixtures/parent_order_rebase_checksum_payload.edn" "utf8") + reader/read-string)] + {:db-before (ldb/read-transit-str (:db-before payload)) + :db-after (ldb/read-transit-str (:db-after payload)) + :tx-data (ldb/read-transit-str (:tx-data payload))})) + +(deftest checksum-ignores-unrelated-datoms-test + (testing "recompute and incremental checksums ignore unrelated datoms" + (let [db-before (sample-db) + checksum-before (checksum/recompute-checksum db-before) + tx-data [[:db/add 4 :block/updated-at 1773661308002] + [:db/add 4 :logseq.property/created-by-ref 99]] + tx-report (d/with db-before tx-data)] + (is (= checksum-before + (checksum/recompute-checksum (:db-after tx-report)))) + (is (= checksum-before + (checksum/update-checksum checksum-before tx-report)))))) + +(deftest incremental-checksum-matches-recompute-on-rebased-retract-entity-log-repro-test + (testing "incremental checksum should equal full recompute on rebased retract-entity replay payload" + (let [{:keys [db-before db-after tx-data]} (load-rebased-retract-checksum-fixture) + checksum-before (checksum/recompute-checksum db-before) + tx-report {:db-before db-before + :db-after db-after + :tx-data tx-data} + full (checksum/recompute-checksum db-after) + incremental (checksum/update-checksum checksum-before tx-report)] + (is (not= checksum-before full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-on-parent-order-rebase-log-repro-test + (testing "incremental checksum should equal full recompute on parent/order rebase payload" + (let [{:keys [db-before db-after tx-data]} (load-parent-order-rebase-checksum-fixture) + checksum-before (checksum/recompute-checksum db-before) + tx-report {:db-before db-before + :db-after db-after + :tx-data tx-data} + full (checksum/recompute-checksum db-after) + incremental (checksum/update-checksum checksum-before tx-report)] + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-on-replace-datom-test + (testing "incremental checksum matches full recompute when replacing existing values" + (let [db-before (sample-db) + tx-report (d/with db-before [[:db/add 4 :block/title "Child updated"] + [:db/add 1 :block/name "page-a-updated"]])] + (is (= (checksum/recompute-checksum (:db-after tx-report)) + (checksum/update-checksum (checksum/recompute-checksum db-before) tx-report)))))) + +(deftest incremental-checksum-matches-recompute-across-mixed-mutations-test + (testing "incremental checksum stays equal to full recompute across typical tx sequences" + (let [db0 (sample-db) + new-block-uuid (random-uuid) + {:keys [db checksum]} (reduce + (fn [{:keys [db checksum]} {:keys [tx-data]}] + (assert-incremental=full! db checksum tx-data)) + {:db db0 + :checksum (checksum/recompute-checksum db0)} + [{:tx-data [[:db/add 4 :block/title "Child edited"]]} + {:tx-data [[:db/add 1 :block/name "page-a-renamed"] + [:db/add 1 :block/title "Page A Renamed"]]} + {:tx-data [[:db/add 4 :block/parent 2] + [:db/add 4 :block/page 2]]} + {:tx-data [[:db/add -1 :block/uuid new-block-uuid] + [:db/add -1 :block/title "New block"] + [:db/add -1 :block/parent 2] + [:db/add -1 :block/page 2]]} + {:tx-data [[:db/retract 3 :block/title "Parent"]]} + {:tx-data [[:db/retractEntity [:block/uuid new-block-uuid]]]} + {:tx-data [[:db/add 4 :block/updated-at 1773661308002]]}])] + (is (= checksum (checksum/recompute-checksum db)))))) + +(deftest incremental-checksum-uses-recompute-when-initial-checksum-missing-test + (testing "nil initial checksum uses db-before recompute as baseline" + (let [db-before (sample-db) + tx-report (d/with db-before [[:db/add 4 :block/title "Child updated"]])] + (is (= (checksum/recompute-checksum (:db-after tx-report)) + (checksum/update-checksum nil tx-report)))))) + +(deftest checksum-e2ee-ignores-title-and-name-test + (testing "with E2EE enabled, checksum ignores title/name changes for both modes" + (let [db-before (-> (sample-db) + (d/db-with [{:db/ident :logseq.kv/graph-rtc-e2ee? + :kv/value true}])) + checksum-before (checksum/recompute-checksum db-before) + tx-report (d/with db-before [[:db/add 4 :block/title "Encrypted title update"] + [:db/add 1 :block/name "encrypted-name-update"]])] + (is (= checksum-before + (checksum/recompute-checksum (:db-after tx-report)))) + (is (= checksum-before + (checksum/update-checksum checksum-before tx-report)))))) + +(deftest incremental-checksum-recomputes-when-e2ee-mode-toggles-test + (testing "incremental checksum falls back to full recompute when E2EE mode changes" + (let [db-before (sample-db) + tx-report (d/with db-before [{:db/ident :logseq.kv/graph-rtc-e2ee? + :kv/value true}])] + (is (= (checksum/recompute-checksum (:db-after tx-report)) + (checksum/update-checksum (checksum/recompute-checksum db-before) tx-report)))))) + +(deftest incremental-checksum-matches-recompute-when-referenced-entity-disappears-test + (testing "incremental checksum tracks blocks whose parent/page UUID becomes unresolved after retracting referenced entities" + (let [db-before (sample-db) + before-checksum (checksum/recompute-checksum db-before) + tx-report (d/with db-before [[:db/retractEntity 3] + [:db/retractEntity 1]]) + db-after (:db-after tx-report) + full (checksum/recompute-checksum db-after) + incremental (checksum/update-checksum before-checksum tx-report)] + (is (not= before-checksum full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-when-parent-uuid-changes-test + (testing "incremental checksum tracks children whose normalized parent UUID changes via parent :block/uuid update" + (let [db-before (sample-db) + before-checksum (checksum/recompute-checksum db-before) + tx-report (d/with db-before [[:db/add 3 :block/uuid (random-uuid)]]) + db-after (:db-after tx-report) + full (checksum/recompute-checksum db-after) + incremental (checksum/update-checksum before-checksum tx-report)] + (is (not= before-checksum full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-when-page-uuid-changes-test + (testing "incremental checksum tracks blocks whose normalized page UUID changes via page :block/uuid update" + (let [db-before (sample-db) + before-checksum (checksum/recompute-checksum db-before) + tx-report (d/with db-before [[:db/add 1 :block/uuid (random-uuid)]]) + db-after (:db-after tx-report) + full (checksum/recompute-checksum db-after) + incremental (checksum/update-checksum before-checksum tx-report)] + (is (not= before-checksum full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-when-parent-uuid-changes-on-ineligible-target-test + (testing "incremental checksum tracks children when parent UUID changes on a non-checksum-eligible target entity" + (let [db-before (-> (d/empty-db db-schema/schema) + (d/db-with [{:db/id 1 + :block/uuid (random-uuid) + :block/name "page-a" + :block/title "Page A"} + {:db/id 2 + :block/uuid (random-uuid) + :block/title "Orphan parent"} + {:db/id 3 + :block/uuid (random-uuid) + :block/title "Child" + :block/parent 2 + :block/page 1}])) + before-checksum (checksum/recompute-checksum db-before) + tx-report (d/with db-before [[:db/add 2 :block/uuid (random-uuid)]]) + db-after (:db-after tx-report) + full (checksum/recompute-checksum db-after) + incremental (checksum/update-checksum before-checksum tx-report)] + (is (not= before-checksum full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-when-ineligible-referenced-entity-is-retracted-test + (testing "incremental checksum tracks children when normalized tx data retracts a referenced non-checksum-eligible entity" + (let [parent-uuid (random-uuid) + child-uuid (random-uuid) + page-uuid (random-uuid) + db-before (-> (d/empty-db db-schema/schema) + (d/db-with [{:db/id 1 + :block/uuid page-uuid + :block/name "page-a" + :block/title "Page A"} + {:db/id 2 + :block/uuid parent-uuid + :block/title "Orphan parent"} + {:db/id 3 + :block/uuid child-uuid + :block/title "Child" + :block/parent 2 + :block/page 1}])) + before-checksum (checksum/recompute-checksum db-before) + tx-report* (d/with db-before [[:db/retractEntity [:block/uuid parent-uuid]]]) + tx-report (assoc tx-report* + :tx-data [[:db/retractEntity [:block/uuid parent-uuid]]]) + full (checksum/recompute-checksum (:db-after tx-report)) + incremental (checksum/update-checksum before-checksum tx-report)] + (is (not= before-checksum full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-when-block-is-readded-test + (testing "incremental checksum remains equal to recompute when a block is deleted and re-added with the same UUID" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + child-uuid (:block/uuid (d/entity db0 4)) + parent-uuid (:block/uuid (d/entity db0 3)) + page-uuid (:block/uuid (d/entity db0 1)) + {:keys [db checksum]} (assert-incremental=full! db0 checksum0 [[:db/retractEntity [:block/uuid child-uuid]]])] + (assert-incremental=full! db checksum [{:db/id -1 + :block/uuid child-uuid + :block/title "Child" + :block/parent [:block/uuid parent-uuid] + :block/page [:block/uuid page-uuid]}])))) + +(deftest incremental-checksum-matches-recompute-when-block-is-readded-with-uuid-tempid-test + (testing "server rebase txs can delete and recreate the same block using a UUID string tempid" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + block-uuid (random-uuid) + block-tempid (str block-uuid) + page-uuid (:block/uuid (d/entity db0 1)) + parent-uuid (:block/uuid (d/entity db0 3)) + tx-data [[:db/add block-tempid :block/uuid block-uuid 536871536] + [:db/add block-tempid :block/title "undo redo block" 536871536] + [:db/add block-tempid :block/parent [:block/uuid parent-uuid] 536871536] + [:db/add block-tempid :block/order "a0" 536871536] + [:db/add block-tempid :block/page [:block/uuid page-uuid] 536871536]] + {:keys [db checksum]} (assert-incremental=full! db0 checksum0 tx-data) + {:keys [db checksum]} (assert-incremental=full! db checksum [[:db/retractEntity [:block/uuid block-uuid]]])] + (assert-incremental=full! + db + checksum + [[:db/add block-tempid :block/uuid block-uuid 536871538] + [:db/add block-tempid :block/title "undo redo block" 536871538] + [:db/add block-tempid :block/parent [:block/uuid parent-uuid] 536871538] + [:db/add block-tempid :block/order "a0" 536871538] + [:db/add block-tempid :block/page [:block/uuid page-uuid] 536871538]])))) + +(deftest incremental-checksum-matches-recompute-with-normalized-vector-tx-data-test + (testing "incremental checksum tracks normalized tx vectors persisted in server tx_log" + (let [db-before (sample-db) + checksum-before (checksum/recompute-checksum db-before) + block-uuid (random-uuid) + block-tempid (str block-uuid) + parent-uuid (:block/uuid (d/entity db-before 3)) + page-uuid (:block/uuid (d/entity db-before 1)) + normalized-tx-data [[:db/add block-tempid :block/uuid block-uuid 536871538] + [:db/add block-tempid :block/title "normalized block" 536871538] + [:db/add block-tempid :block/parent [:block/uuid parent-uuid] 536871538] + [:db/add block-tempid :block/order "a0" 536871538] + [:db/add block-tempid :block/page [:block/uuid page-uuid] 536871538]] + tx-report (d/with db-before normalized-tx-data) + full (checksum/recompute-checksum (:db-after tx-report)) + incremental (checksum/update-checksum + checksum-before + (assoc tx-report :tx-data normalized-tx-data))] + (is (not= checksum-before full)) + (is (= full incremental))))) + +(deftest incremental-checksum-matches-recompute-when-delete-tree-undo-and-delete-again-test + (testing "incremental checksum matches recompute across delete-tree, undo-all, then delete-tree-again" + (let [db0 (sample-db) + parent-uuid (:block/uuid (d/entity db0 3)) + child-uuid (:block/uuid (d/entity db0 4)) + page-uuid (:block/uuid (d/entity db0 1)) + tx-seq [{:tx-data [[:db/retractEntity [:block/uuid child-uuid]] + [:db/retractEntity [:block/uuid parent-uuid]]]} + {:tx-data [{:db/id -1 + :block/uuid parent-uuid + :block/title "Parent" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]} + {:db/id -2 + :block/uuid child-uuid + :block/title "Child" + :block/parent [:block/uuid parent-uuid] + :block/page [:block/uuid page-uuid]}]} + {:tx-data [[:db/retractEntity [:block/uuid child-uuid]] + [:db/retractEntity [:block/uuid parent-uuid]]]}] + {:keys [db checksum]} (reduce + (fn [{:keys [db checksum]} {:keys [tx-data]}] + (assert-incremental=full! db checksum tx-data)) + {:db db0 + :checksum (checksum/recompute-checksum db0)} + tx-seq)] + (is (= checksum (checksum/recompute-checksum db)))))) + +(deftest recompute-checksum-diagnostics-includes-relevant-attrs-test + (testing "diagnostics includes checksum attrs and block values used for checksum export" + (let [db (sample-db) + {:keys [checksum attrs blocks e2ee?]} (checksum/recompute-checksum-diagnostics db) + child-uuid (:block/uuid (d/entity db 4)) + child-parent-uuid (:block/uuid (:block/parent (d/entity db 4))) + child-page-uuid (:block/uuid (:block/page (d/entity db 4))) + child (some #(when (= child-uuid (:block/uuid %)) %) blocks)] + (is (false? e2ee?)) + (is (= (checksum/recompute-checksum db) checksum)) + (is (= #{:block/uuid :block/title :block/name :block/parent :block/page :block/order} + (set attrs))) + (is (= 4 (count blocks))) + (is (= child-parent-uuid (:block/parent child))) + (is (= child-page-uuid (:block/page child))) + (is (string? (:block/title child)))))) + +(deftest recompute-checksum-diagnostics-omits-title-and-name-in-e2ee-test + (testing "diagnostics for E2EE graphs omits title/name from checksum attrs and export blocks" + (let [db (-> (sample-db) + (d/db-with [{:db/ident :logseq.kv/graph-rtc-e2ee? + :kv/value true}])) + {:keys [checksum attrs blocks e2ee?]} (checksum/recompute-checksum-diagnostics db)] + (is e2ee?) + (is (= (checksum/recompute-checksum db) checksum)) + (is (= #{:block/uuid :block/parent :block/page :block/order} + (set attrs))) + (is (every? #(not (contains? % :block/title)) blocks)) + (is (every? #(not (contains? % :block/name)) blocks))))) + +(deftest incremental-checksum-is-invariant-across-tx-partitioning-test + (testing "incremental checksum converges to the same value regardless of tx partitioning" + (let [db0 (sample-db) + tx-a [[:db/add 4 :block/order "aBL"] + [:db/add 4 :block/title "Child v2"]] + tx-b [[:db/add 3 :block/order "aBK"] + [:db/add 4 :block/parent 2] + [:db/add 4 :block/page 2]] + one-shot-report (d/with db0 (into tx-a tx-b)) + one-shot-checksum (checksum/update-checksum (checksum/recompute-checksum db0) + one-shot-report) + checksum0 (checksum/recompute-checksum db0) + report-a (d/with db0 tx-a) + checksum-a (checksum/update-checksum checksum0 report-a) + db-a (:db-after report-a) + report-b (d/with db-a tx-b) + checksum-b (checksum/update-checksum checksum-a report-b) + db-final (:db-after report-b) + full-final (checksum/recompute-checksum db-final)] + (is (= full-final one-shot-checksum)) + (is (= full-final checksum-b)) + (is (= one-shot-checksum checksum-b))))) + +(deftest incremental-checksum-is-invariant-across-commuting-batch-order-test + (testing "incremental checksum converges when commuting tx batches are applied in different order" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + tx-a [[:db/add 3 :block/title "Parent v2"] + [:db/add 4 :block/order "a9"]] + tx-b [[:db/add 1 :block/name "page-a-v2"] + [:db/add 2 :block/title "Page B v2"]] + report-a (d/with db0 tx-a) + checksum-a (checksum/update-checksum checksum0 report-a) + db-a (:db-after report-a) + report-b-after-a (d/with db-a tx-b) + checksum-ab (checksum/update-checksum checksum-a report-b-after-a) + db-ab (:db-after report-b-after-a) + full-ab (checksum/recompute-checksum db-ab) + + report-b (d/with db0 tx-b) + checksum-b (checksum/update-checksum checksum0 report-b) + db-b (:db-after report-b) + report-a-after-b (d/with db-b tx-a) + checksum-ba (checksum/update-checksum checksum-b report-a-after-b) + db-ba (:db-after report-a-after-b) + full-ba (checksum/recompute-checksum db-ba)] + (is (= full-ab full-ba)) + (is (= full-ab checksum-ab)) + (is (= full-ba checksum-ba)) + (is (= checksum-ab checksum-ba))))) + +(deftest incremental-checksum-is-invariant-across-intra-batch-datom-order-test + (testing "incremental checksum converges when same tx-data uses different datom order" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + uuid-a (random-uuid) + uuid-b (random-uuid) + tx-order-a [{:db/id -1 + :block/uuid uuid-a + :block/title "Inserted A" + :block/order "a5" + :block/parent 3 + :block/page 1} + {:db/id -2 + :block/uuid uuid-b + :block/title "Inserted B" + :block/order "a6" + :block/parent 3 + :block/page 1}] + tx-order-b (vec (reverse tx-order-a)) + report-a (d/with db0 tx-order-a) + db-a (:db-after report-a) + full-a (checksum/recompute-checksum db-a) + incremental-a (checksum/update-checksum checksum0 report-a) + report-b (d/with db0 tx-order-b) + db-b (:db-after report-b) + full-b (checksum/recompute-checksum db-b) + incremental-b (checksum/update-checksum checksum0 report-b)] + (is (= full-a full-b)) + (is (= full-a incremental-a)) + (is (= full-b incremental-b)) + (is (= incremental-a incremental-b))))) + +(deftest incremental-checksum-handles-rebase-like-toggle-churn-test + (testing "incremental checksum uses net tuple delta when batch contains add/retract/add churn" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + ;; Simulate rebase churn on a tuple absent in db0: add -> retract -> add + report-1 (d/with db0 [[:db/add 4 :block/order "aBL"]]) + db1 (:db-after report-1) + report-2 (d/with db1 [[:db/retract 4 :block/order "aBL"]]) + db2 (:db-after report-2) + report-3 (d/with db2 [[:db/add 4 :block/order "aBL"]]) + db3 (:db-after report-3) + batch-report {:db-before db0 + :db-after db3 + :tx-data (vec (concat (:tx-data report-1) + (:tx-data report-2) + (:tx-data report-3)))} + full-final (checksum/recompute-checksum db3) + incremental (checksum/update-checksum checksum0 batch-report)] + (is (not= checksum0 full-final)) + (is (= full-final incremental))))) + +(deftest incremental-checksum-handles-transient-entity-churn-in-one-batch-test + (testing "incremental checksum remains stable when a newly created block is retracted in the same batch" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + page-id 1 + parent-id 3 + transient-uuid (random-uuid) + report-1 (d/with db0 [{:db/id -1 + :block/uuid transient-uuid + :block/title "Transient" + :block/parent parent-id + :block/page page-id}]) + db1 (:db-after report-1) + report-2 (d/with db1 [[:db/retractEntity [:block/uuid transient-uuid]]]) + db2 (:db-after report-2) + batch-report {:db-before db0 + :db-after db2 + :tx-data (vec (concat (:tx-data report-1) + (:tx-data report-2)))} + full-final (checksum/recompute-checksum db2) + incremental (checksum/update-checksum checksum0 batch-report)] + (is (= checksum0 full-final)) + (is (= full-final incremental))))) + +(deftest incremental-checksum-handles-new-entity-attr-replacement-in-one-batch-test + (testing "incremental checksum cancels replaced attrs on entities created inside the same batch" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + page-id 1 + parent-id 3 + transient-uuid (random-uuid) + report-1 (d/with db0 [{:db/id -1 + :block/uuid transient-uuid + :block/title "Transient" + :block/order "a1" + :block/parent parent-id + :block/page page-id}]) + db1 (:db-after report-1) + report-2 (d/with db1 [[:db/add [:block/uuid transient-uuid] :block/order "a2"]]) + db2 (:db-after report-2) + batch-report {:db-before db0 + :db-after db2 + :tx-data (vec (concat (:tx-data report-1) + (:tx-data report-2)))} + full-final (checksum/recompute-checksum db2) + incremental (checksum/update-checksum checksum0 batch-report)] + (is (= full-final incremental))))) + +(deftest incremental-checksum-matches-recompute-with-duplicate-block-uuid-tx-churn-test + (testing "incremental checksum stays equal to recompute when tx-data retracts+adds the same :block/uuid in one tx" + (let [db0 (sample-db) + shared-uuid (random-uuid) + db1 (:db-after (d/with db0 [{:db/id 5 + :block/uuid shared-uuid + :block/title "Peer" + :block/parent 1 + :block/page 1}])) + original-parent-uuid (:block/uuid (d/entity db1 3)) + tx-data [(d/datom 3 :block/uuid original-parent-uuid 200 false) + (d/datom 5 :block/uuid shared-uuid 200 false) + (d/datom 3 :block/uuid shared-uuid 200 true) + (d/datom 3 :block/order "a9" 200 true)] + tx-report (d/with db1 tx-data) + db-before (:db-before tx-report) + checksum-before (checksum/recompute-checksum db-before) + full (checksum/recompute-checksum (:db-after tx-report)) + incremental (checksum/update-checksum checksum-before tx-report)] + (is (= full incremental))))) + +(deftest checksum-ignores-non-page-non-block-entities-test + (testing "entities with uuid but without page semantics do not affect checksum" + (let [db0 (sample-db) + checksum0 (checksum/recompute-checksum db0) + internal-uuid (random-uuid) + tx-report (d/with db0 [{:db/id -1 + :block/uuid internal-uuid + :block/order "zz"}]) + db1 (:db-after tx-report) + full1 (checksum/recompute-checksum db1) + incremental1 (checksum/update-checksum checksum0 tx-report) + tx-report-2 (d/with db1 [[:db/add [:block/uuid internal-uuid] :block/order "aa"]]) + full2 (checksum/recompute-checksum (:db-after tx-report-2)) + incremental2 (checksum/update-checksum full1 tx-report-2)] + (is (= checksum0 full1)) + (is (= checksum0 incremental1)) + (is (= checksum0 full2)) + (is (= full2 incremental2))))) diff --git a/deps/db-sync/test/logseq/db_sync/common_test.cljs b/deps/db-sync/test/logseq/db_sync/common_test.cljs new file mode 100644 index 0000000..1b1a919 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/common_test.cljs @@ -0,0 +1,91 @@ +(ns logseq.db-sync.common-test + (:require [cljs.test :refer [async deftest is]] + [logseq.db-sync.common :as common] + [promesa.core :as p])) + +(defn- make-stmt [calls result] + (let [stmt (js-obj)] + (aset stmt + "bind" + (fn [& args] + (swap! calls assoc :bind-args (vec args)) + stmt)) + (aset stmt + "all" + (fn [] + (swap! calls update :all-count (fnil inc 0)) + result)) + stmt)) + +(deftest d1-all-falls-back-when-with-session-missing-test + (async done + (let [calls (atom {}) + result #js {:results #js [#js {"id" "u1"}]} + stmt (make-stmt calls result) + db #js {:prepare (fn [sql] + (swap! calls assoc :prepare-sql sql) + stmt)}] + (-> (p/let [query-result (common/ (p/let [query-result (common/ (p/let [query-result (common/\",true],\"~:file/created-at\",[\"^ \"],\"~:logseq.property.repeat/temporal-property\",[\"^ \",\"^;\",\"^B\",\"^<\",\"^=\",\"^>\",true,\"~:db/valueType\",\"~:db.type/ref\"],\"~:logseq.property.user/name\",[\"^ \",\"^;\",\"^E\",\"^<\",\"^=\",\"^>\",true],\"~i1\",\"~:logseq.property.reaction/target\",\"~i33\",\"~:logseq.property/priority.urgent\",\"~i65\",\"~:logseq.property/color.yellow\",\"~i97\",\"~:block/closed-value-property\",\"~:logseq.property.pdf/hl-image\",[\"^ \",\"^;\",\"^M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i129\",\"~:logseq.property.user/avatar\",\"~:logseq.property.fsrs/state\",[\"^ \",\"^;\",\"^P\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.pdf/hl-color\",[\"^ \",\"^;\",\"^Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-initial-schema-version\",[\"^ \",\"^;\",\"^R\"],\"~:block/tx-id\",[\"^ \"],\"~:logseq.property/value\",[\"^ \",\"^;\",\"^T\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.week\",[\"^ \",\"^;\",\"^U\"],\"~i2\",\"~:logseq.kv/db-type\",\"~i34\",\"~:logseq.property/public?\",\"~i66\",\"~:logseq.property.asset/width\",\"~i98\",\"~:logseq.property.reaction/emoji-id\",\"~i130\",\"^E\",\"~:logseq.property.table/sorting\",[\"^ \",\"^;\",\"^12\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/choice-exclusions\",[\"^ \",\"^;\",\"^13\",\"^<\",\"~:db.cardinality/many\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.table/pinned-columns\",[\"^ \",\"^;\",\"^15\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit.day\",[\"^ \",\"^;\",\"^16\"],\"~i3\",\"~:logseq.property.repeat/checked-property\",\"~i35\",\"~:logseq.property.recycle/original-order\",\"~i67\",\"~:logseq.property.asset/remote-metadata\",\"~i99\",\"~:logseq.property/priority.high\",\"~i131\",\"~:logseq.property.asset/size\",\"~:logseq.property.sync/large-title-object\",[\"^ \",\"^;\",\"^1@\",\"^<\",\"^=\",\"^>\",true],\"~i4\",\"~:logseq.class/Pdf-annotation\",\"~i36\",\"~:block/journal-day\",\"~i68\",\"~:logseq.property.view/type.list\",\"~i100\",\"~:logseq.property/color.blue\",\"~i132\",\"~:logseq.property.class/bidirectional-property-title\",\"~:logseq.property/deadline\",[\"^ \",\"^;\",\"^1J\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/type\",[\"^ \",\"^;\",\"^1K\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.year\",[\"^ \",\"^;\",\"^1L\"],\"~:file/content\",[\"^ \"],\"~:user.property/p3-Bhx1XTp3\",[\"^ \",\"^;\",\"^1N\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i5\",\"~:logseq.property.asset/checksum\",\"~i37\",\"~:logseq.property.node/display-type\",\"~i69\",\"~:block/refs\",\"~i101\",\"~:logseq.property.table/ordered-columns\",\"~:logseq.property.view/sort-groups-desc?\",[\"^ \",\"^;\",\"^1V\",\"^<\",\"^=\",\"^>\",true],\"~i133\",\"~:logseq.property.class/hide-from-node\",\"~:logseq.property.table/sized-columns\",[\"^ \",\"^;\",\"^1Y\",\"^<\",\"^=\",\"^>\",true],\"~:block/alias\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^>\",true,\"^;\",\"^1Z\"],\"~:logseq.property.publish/published-url\",[\"^ \",\"^;\",\"^1[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/external-url\",[\"^ \",\"^;\",\"^20\",\"^<\",\"^=\",\"^>\",true],\"~i6\",\"^1Y\",\"^O\",[\"^ \",\"^;\",\"^O\",\"^<\",\"^=\",\"^>\",true],\"~i38\",\"~:logseq.class/Template\",\"~:user.property/p5-q5guozE4\",[\"^ \",\"^;\",\"^23\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i70\",\"^?\",\"~i102\",\"~:logseq.property/background-color\",\"~i134\",\"^B\",\"~:kv/value\",[\"^ \"],\"~:logseq.property/scalar-default-value\",[\"^ \",\"^;\",\"^29\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/type\",[\"^ \",\"^;\",\"^2:\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Asset\",[\"^ \",\"^;\",\"^2;\"],\"^19\",[\"^ \",\"^;\",\"^19\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.class/enable-bidirectional?\",[\"^ \",\"^;\",\"^2<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/color.green\",[\"^ \",\"^;\",\"^2=\"],\"~i7\",\"^1Z\",\"~i39\",\"~:logseq.kv/local-graph-uuid\",\"~i71\",\"~:logseq.property/description\",\"~i103\",\"~:logseq.class/Cards\",\"~i135\",\"~:logseq.property.repeat/recur-unit.minute\",\"~:logseq.property.linked-references/includes\",[\"^ \",\"^;\",\"^2F\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit\",[\"^ \",\"^;\",\"^2G\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/checkbox-display-properties\",[\"^ \",\"^;\",\"^2H\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:block/link\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^2I\",\"^<\",\"^=\"],\"~:logseq.property.view/type\",[\"^ \",\"^;\",\"^2J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.class/Quote-block\",[\"^ \",\"^;\",\"^2K\"],\"~:logseq.property/heading\",[\"^ \",\"^;\",\"^2L\",\"^<\",\"^=\",\"^>\",true],\"~i8\",\"~:logseq.property.view/sort-groups-by-property\",\"~i40\",\"~:logseq.property.pdf/hl-page\",\"~i72\",\"~:logseq.property.recycle/original-page\",\"~i104\",\"~:logseq.class/Page\",\"~:block/uuid\",[\"^ \",\"~:db/unique\",\"~:db.unique/identity\"],\"~i136\",\"~:logseq.property/query\",\"~:logseq.property/color.purple\",[\"^ \",\"^;\",\"^2Y\"],\"~:logseq.property.asset/external-file-name\",[\"^ \",\"^;\",\"^2Z\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.table/hidden-columns\",[\"^ \",\"^;\",\"^2[\",\"^<\",\"^14\",\"^>\",true],\"~:logseq.class/Property\",[\"^ \",\"^;\",\"^30\"],\"~:logseq.kv/graph-git-sha\",[\"^ \",\"^;\",\"^31\"],\"~i9\",\"~:logseq.property/status.doing\",\"~i41\",\"^2H\",\"~i73\",\"~:logseq.property.linked-references/excludes\",\"~i105\",\"^U\",\"^2S\",[\"^ \",\"^;\",\"^2S\"],\"~i137\",\"~:logseq.property.user/email\",\"~:logseq.kv/graph-remote?\",[\"^ \",\"^;\",\"^39\"],\"^2?\",[\"^ \",\"^;\",\"^2?\"],\"~:logseq.kv/graph-rtc-e2ee?\",[\"^ \",\"^;\",\"^3:\"],\"~:block/updated-at\",[\"^ \",\"^>\",true,\"^;\",\"^3;\",\"^<\",\"^=\"],\"^1G\",[\"^ \",\"^;\",\"^1G\"],\"~i10\",\"~:logseq.class/Math-block\",\"~i42\",\"^2Y\",\"~i74\",\"~:logseq.property.history/scalar-value\",\"~i106\",\"~:logseq.property.tldraw/shape\",\"~i138\",\"~:block/collapsed?\",\"^1E\",[\"^ \",\"^;\",\"^1E\"],\"^F\",[\"^ \",\"^;\",\"^F\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i11\",\"~:logseq.property/status.canceled\",\"~:file/size\",[\"^ \"],\"~i43\",\"^P\",\"~:logseq.property/status\",[\"^ \",\"^;\",\"^3I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i75\",\"^1J\",\"~:logseq.property.class/extends\",[\"^ \",\"^;\",\"^3K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i107\",\"~:logseq.property.asset/height\",\"~i139\",\"~:logseq.property.repeat/recur-unit.hour\",\"~:logseq.property/hide-empty-value\",[\"^ \",\"^;\",\"^3P\",\"^<\",\"^=\",\"^>\",true],\"^2M\",[\"^ \",\"^;\",\"^2M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1S\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^1S\",\"^>\",true],\"~i619\",\"~:user.class/tag1-bGi52uW6\",\"~:logseq.class/Card\",[\"^ \",\"^;\",\"^3S\"],\"~:user.property/p2-JBrZedg-\",[\"^ \",\"^;\",\"^3T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^10\",[\"^ \",\"^;\",\"^10\",\"^<\",\"^=\",\"^>\",true],\"~i12\",\"~:block/parent\",\"~i44\",\"^1V\",\"~i76\",\"^2=\",\"~i2124\",\"~:user.class/tag2-GCc7Ko89\",\"~i108\",\"~:logseq.property.code/lang\",\"~i140\",\"~:logseq.property.tldraw/page\",\"~:logseq.property.view/group-by-property\",[\"^ \",\"^;\",\"^43\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/view-context\",[\"^ \",\"^;\",\"^44\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/schema-version\",[\"^ \",\"^;\",\"^45\"],\"^2O\",[\"^ \",\"^;\",\"^2O\",\"^<\",\"^=\",\"^>\",true],\"~i13\",\"^2G\",\"~i45\",\"~:block/created-at\",\"^L\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^=\",\"^;\",\"^L\",\"^>\",true],\"~i77\",\"~:logseq.property/choice-classes\",\"~i109\",\"~:logseq.property/created-by-ref\",\"^1O\",[\"^ \",\"^;\",\"^1O\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/publishing-public?\",[\"^ \",\"^;\",\"^4=\",\"^<\",\"^=\",\"^>\",true],\"~i141\",\"^31\",\"~:user.property/p10-QpfPhdWj\",[\"^ \",\"^;\",\"^4?\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1Q\",[\"^ \",\"^;\",\"^1Q\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/status.backlog\",[\"^ \",\"^;\",\"^4@\"],\"~i14\",\"~:logseq.class/Tag\",\"~:file/last-modified-at\",[\"^ \"],\"~i46\",\"^2;\",\"~i78\",\"^2K\",\"~i2126\",\"^23\",\"~i110\",\"^13\",\"~i142\",\"~:logseq.class/Query\",\"~:logseq.property.class/properties\",[\"^ \",\"^;\",\"^4J\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/used-template\",[\"^ \",\"^;\",\"^4K\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/enable-history?\",[\"^ \",\"^;\",\"^4L\",\"^<\",\"^=\",\"^>\",true],\"~i15\",\"~:logseq.kv/graph-created-at\",\"~i47\",\"~:block/page\",\"~i79\",\"^29\",\"^3=\",[\"^ \",\"^;\",\"^3=\"],\"~i111\",\"^R\",\"~i143\",\"~:logseq.property/choice-checkbox-state\",\"^48\",[\"^ \",\"^>\",true,\"^;\",\"^48\",\"^<\",\"^=\"],\"^3@\",[\"^ \",\"^;\",\"^3@\",\"^<\",\"^=\",\"^>\",true],\"~i2351\",\"^4?\",\"~:logseq.property/page-tags\",[\"^ \",\"^;\",\"^4V\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/scheduled\",[\"^ \",\"^;\",\"^4W\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Task\",[\"^ \",\"^;\",\"^4X\"],\"^3D\",[\"^ \",\"^;\",\"^3D\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/last-visit-page\",[\"^ \",\"^;\",\"^4Y\",\"^<\",\"^=\",\"^>\",true],\"^42\",[\"^ \",\"^;\",\"^42\",\"^<\",\"^=\",\"^>\",true],\"~i16\",\"~:logseq.property/icon\",\"~i48\",\"^1L\",\"~i80\",\"^4Y\",\"~i112\",\"~:logseq.property.journal/title-format\",\"~i144\",\"^3P\",\"~:logseq.property/built-in?\",[\"^ \",\"^;\",\"^55\",\"^<\",\"^=\",\"^>\",true],\"~i17\",\"^2Z\",\"~i49\",\"~:logseq.property/exclude-from-graph-view\",\"~i81\",\"^4K\",\"^1X\",[\"^ \",\"^;\",\"^1X\",\"^<\",\"^=\",\"^>\",true],\"~i113\",\"^2F\",\"~i145\",\"^3;\",\"^35\",[\"^ \",\"^;\",\"^35\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.view/feature-type\",[\"^ \",\"^;\",\"^5<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Code-block\",[\"^ \",\"^;\",\"^5=\"],\"^4T\",[\"^ \",\"^;\",\"^4T\",\"^<\",\"^=\",\"^>\",true],\"^17\",[\"^ \",\"^;\",\"^17\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/resize-metadata\",[\"^ \",\"^;\",\"^5>\",\"^<\",\"^=\",\"^>\",true],\"^5\",[\"^ \",\"^;\",\"^5\",\"^<\",\"^=\",\"^>\",true],\"^2C\",[\"^ \",\"^;\",\"^2C\"],\"^38\",[\"^ \",\"^;\",\"^38\",\"^<\",\"^=\",\"^>\",true],\"^9\",[\"^ \",\"^;\",\"^9\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^Z\",[\"^ \",\"^;\",\"^Z\",\"^<\",\"^=\",\"^>\",true],\"~i18\",\"~:logseq.property.history/block\",\"^2X\",[\"^ \",\"^;\",\"^2X\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i50\",\"^15\",\"~i82\",\"~:logseq.property.repeat/recur-unit.month\",\"~i114\",\"~:logseq.property/status.todo\",\"~i146\",\"^20\",\"~:logseq.property/hide?\",[\"^ \",\"^;\",\"^5G\",\"^<\",\"^=\",\"^>\",true],\"^26\",[\"^ \",\"^;\",\"^26\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-frequency\",[\"^ \",\"^;\",\"^5H\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/default-value\",[\"^ \",\"^;\",\"^5I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1C\",[\"^ \",\"^>\",true,\"^;\",\"^1C\",\"^<\",\"^=\"],\"~:user.property/p1-Ka7c_Z9D\",[\"^ \",\"^;\",\"^5J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/color.red\",[\"^ \",\"^;\",\"^5K\"],\"~i19\",\"^Q\",\"~:logseq.class/Root\",[\"^ \",\"^;\",\"^5M\"],\"~i51\",\"^1@\",\"~i83\",\"~:logseq.property.history/ref-value\",\"~i115\",\"~:logseq.property.history/property\",\"~i147\",\"~:logseq.property/template-applied-to\",\"^2E\",[\"^ \",\"^;\",\"^2E\"],\"~:logseq.property/empty-placeholder\",[\"^ \",\"^;\",\"^5U\"],\"^40\",[\"^ \",\"^;\",\"^40\",\"^<\",\"^=\",\"^>\",true],\"~i20\",\"^2<\",\"~i52\",\"^45\",\"~i84\",\"~:logseq.property.table/filters\",\"~i116\",\"~:logseq.property.recycle/original-parent\",\"^4<\",[\"^ \",\"^;\",\"^4<\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-uuid\",[\"^ \",\"^;\",\"^60\"],\"~i148\",\"~:logseq.property/status.in-review\",\"^1=\",[\"^ \",\"^;\",\"^1=\"],\"~:logseq.property/priority.low\",[\"^ \",\"^;\",\"^63\"],\"~:block/tags\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^64\",\"^>\",true],\"~:block/title\",[\"^ \",\"^>\",true,\"^;\",\"^65\",\"^<\",\"^=\"],\"~:user.property/p4-Et0d5LxL\",[\"^ \",\"^;\",\"^66\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^3B\",[\"^ \",\"^;\",\"^3B\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Whiteboard\",[\"^ \",\"^;\",\"^67\"],\"~i21\",\"^65\",\"~i53\",\"~:logseq.property/created-from-property\",\"~i85\",\"~:logseq.class/Journal\",\"~i117\",\"~:logseq.property.pdf/hl-value\",\"~i149\",\"^1[\",\"^6:\",[\"^ \",\"^;\",\"^6:\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i565\",\"^5J\",\"^4[\",[\"^ \",\"^;\",\"^4[\",\"^<\",\"^=\",\"^>\",true],\"^1I\",[\"^ \",\"^;\",\"^1I\",\"^<\",\"^=\",\"^>\",true],\"^H\",[\"^ \",\"^;\",\"^H\"],\"^4:\",[\"^ \",\"^;\",\"^4:\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^1?\",[\"^ \",\"^;\",\"^1?\",\"^<\",\"^=\",\"^>\",true],\"~i22\",\"~:logseq.property/classes\",\"~i54\",\"^4=\",\"~i86\",\"^55\",\"~i118\",\"^43\",\"~i150\",\"~:logseq.property.view/type.gallery\",\"~i182\",\"^60\",\"^6B\",[\"^ \",\"^;\",\"^6B\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/asset\",[\"^ \",\"^;\",\"^6I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^4N\",[\"^ \",\"^;\",\"^4N\"],\"~i23\",\"^39\",\"~i55\",\"~:logseq.property/status.done\",\"~:logseq.property/deleted-at\",[\"^ \",\"^;\",\"^6M\",\"^<\",\"^=\",\"^>\",true],\"~i87\",\"^67\",\"~i119\",\"^5G\",\"~i151\",\"^5U\",\"~i183\",\"^3:\",\"~:logseq.property/order-list-type\",[\"^ \",\"^;\",\"^6R\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^58\",[\"^ \",\"^;\",\"^58\",\"^<\",\"^=\",\"^>\",true],\"~i24\",\"^5>\",\"~:logseq.property/deleted-by-ref\",[\"^ \",\"^;\",\"^6T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i56\",\"^T\",\"~:logseq.property.pdf/hl-type\",[\"^ \",\"^;\",\"^6V\",\"^<\",\"^=\",\"^>\",true],\"~i88\",\"^:\",\"~i120\",\"^2I\",\"~i152\",\"^4X\",\"^;\",[\"^ \",\"^2U\",\"^2V\"],\"^6G\",[\"^ \",\"^;\",\"^6G\"],\"^2A\",[\"^ \",\"^;\",\"^2A\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i568\",\"^3T\",\"^5@\",[\"^ \",\"^;\",\"^5@\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i600\",\"^66\",\"^3M\",[\"^ \",\"^;\",\"^3M\",\"^<\",\"^=\",\"^>\",true],\"^3V\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^3V\",\"^<\",\"^=\"],\"~i25\",\"^6M\",\"~i57\",\"^2:\",\"~i89\",\"^5M\",\"^2Q\",[\"^ \",\"^;\",\"^2Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i121\",\"^4J\",\"~i153\",\"~:logseq.property/priority.medium\",\"~i569\",\"^1N\",\"^6<\",[\"^ \",\"^;\",\"^6<\"],\"~i26\",\"^3I\",\"~i58\",\"^64\",\"~i90\",\"^2L\",\"^3O\",[\"^ \",\"^;\",\"^3O\"],\"^J\",[\"^ \",\"^;\",\"^J\"],\"~i122\",\"~:block/order\",\"~i154\",\"^1K\",\"^5[\",[\"^ \",\"^;\",\"^5[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^75\",[\"^ \",\"^;\",\"^75\"],\"^5R\",[\"^ \",\"^;\",\"^5R\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^6>\",[\"^ \",\"^;\",\"^6>\",\"^<\",\"^=\",\"^>\",true],\"^4I\",[\"^ \",\"^;\",\"^4I\"],\"~i27\",\"^3S\",\"~i59\",\"^6R\",\"~i91\",\"^M\",\"~i123\",\"^4L\",\"~i155\",\"^4W\",\"^1;\",[\"^ \",\"^;\",\"^1;\",\"^<\",\"^=\",\"^>\",true],\"^3Z\",[\"^ \",\"^;\",\"^3Z\"],\"^5C\",[\"^ \",\"^;\",\"^5C\"],\"^7;\",[\"^ \",\"^>\",true,\"^;\",\"^7;\",\"^<\",\"^=\"],\"^3\",[\"^ \",\"^;\",\"^3\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5P\",[\"^ \",\"^;\",\"^5P\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.fsrs/due\",[\"^ \",\"^;\",\"^7B\",\"^<\",\"^=\",\"^>\",true],\"~i28\",\"^5=\",\"^32\",[\"^ \",\"^;\",\"^32\"],\"~i60\",\"^@\",\"~i92\",\"^44\",\"~i124\",\"^30\",\"^4P\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^4P\",\"^<\",\"^=\"],\"~i156\",\"^5I\",\"~:block/name\",[\"^ \",\"^>\",true],\"^1A\",[\"^ \",\"^;\",\"^1A\"],\"^1U\",[\"^ \",\"^;\",\"^1U\",\"^<\",\"^=\",\"^>\",true],\"~:file/path\",[\"^ \",\"^2U\",\"^2V\"],\"~i29\",\"^7B\",\"~i61\",\"^16\",\"~i93\",\"^5<\",\"~i125\",\"^4V\",\"~i157\",\"^6T\",\"^7\",[\"^ \",\"^;\",\"^7\",\"^<\",\"^=\",\"^>\",true],\"^4B\",[\"^ \",\"^;\",\"^4B\"],\"^62\",[\"^ \",\"^;\",\"^62\"],\"^X\",[\"^ \",\"^;\",\"^X\",\"^<\",\"^=\",\"^>\",true],\"~i30\",\"^6I\",\"~i62\",\"^6V\",\"~i94\",\"^63\",\"~i126\",\"^4@\",\"~i158\",\"^3K\",\"^3F\",[\"^ \",\"^;\",\"^3F\"],\"^5T\",[\"^ \",\"^;\",\"^5T\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^22\",[\"^ \",\"^;\",\"^22\"],\"^53\",[\"^ \",\"^;\",\"^53\",\"^<\",\"^=\",\"^>\",true],\"^6L\",[\"^ \",\"^;\",\"^6L\"],\"~i31\",\"^5K\",\"~i63\",\"^2[\",\"^V\",[\"^ \",\"^;\",\"^V\"],\"~i95\",\"^5H\",\"~i127\",\"^2J\",\"~i159\",\"^12\",\"^5Y\",[\"^ \",\"^;\",\"^5Y\",\"^<\",\"^=\",\"^>\",true],\"^3R\",[\"^ \",\"^;\",\"^3R\"],\"^5E\",[\"^ \",\"^;\",\"^5E\"]],\"~:datoms\",[\"~#list\",[[\"~#datascript/Datom\",[1,\"^48\",1775980635495,536870913]],[\"^7[\",[1,\"^7H\",\"reaction target\",536870913]],[\"^7[\",[1,\"^7;\",\"d3BHM\",536870913]],[\"^7[\",[1,\"^64\",124,536870913]],[\"^7[\",[1,\"^65\",\"Reaction target\",536870913]],[\"^7[\",[1,\"^3;\",1775980635495,536870913]],[\"^7[\",[1,\"^2T\",\"~u00000002-1127-5718-5000-000000000000\",536870913]],[\"^7[\",[1,\"^<\",\"^=\",536870913]],[\"^7[\",[1,\"^;\",\"^F\",536870913]],[\"^7[\",[1,\"^>\",true,536870913]],[\"^7[\",[1,\"^C\",\"^D\",536870913]],[\"^7[\",[1,\"^55\",true,536870913]],[\"^7[\",[1,\"^5G\",true,536870913]],[\"^7[\",[1,\"^X\",false,536870913]],[\"^7[\",[1,\"^1K\",\"~:node\",536870913]],[\"^7[\",[2,\"^;\",\"^V\",536870913]],[\"^7[\",[2,\"^28\",\"db\",536870913]],[\"^7[\",[3,\"^48\",1775980635492,536870913]],[\"^7[\",[3,\"^7H\",\"repeating checked property\",536870913]],[\"^7[\",[3,\"^7;\",\"d3BGY\",536870913]],[\"^7[\",[3,\"^64\",124,536870913]],[\"^7[\",[3,\"^65\",\"Repeating Checked Property\",536870913]],[\"^7[\",[3,\"^3;\",1775980635492,536870913]],[\"^7[\",[3,\"^2T\",\"~u00000002-1866-3655-5300-000000000000\",536870913]],[\"^7[\",[3,\"^<\",\"^=\",536870913]],[\"^7[\",[3,\"^;\",\"^17\",536870913]],[\"^7[\",[3,\"^>\",true,536870913]],[\"^7[\",[3,\"^C\",\"^D\",536870913]],[\"^7[\",[3,\"^55\",true,536870913]],[\"^7[\",[3,\"^5G\",true,536870913]],[\"^7[\",[3,\"^1K\",\"~:property\",536870913]],[\"^7[\",[4,\"^48\",1775980635496,536870913]],[\"^7[\",[4,\"^7H\",\"pdf annotation\",536870913]],[\"^7[\",[4,\"^64\",14,536870913]],[\"^7[\",[4,\"^65\",\"PDF Annotation\",536870913]],[\"^7[\",[4,\"^3;\",1775980635496,536870913]],[\"^7[\",[4,\"^2T\",\"~u00000002-5049-5962-0000-000000000000\",536870913]],[\"^7[\",[4,\"^;\",\"^1A\",536870913]],[\"^7[\",[4,\"^55\",true,536870913]],[\"^7[\",[4,\"^3K\",89,536870913]],[\"^7[\",[4,\"^1X\",true,536870913]],[\"^7[\",[4,\"^4J\",19,536870913]],[\"^7[\",[4,\"^4J\",30,536870913]],[\"^7[\",[4,\"^4J\",40,536870913]],[\"^7[\",[4,\"^4J\",62,536870913]],[\"^7[\",[4,\"^4J\",64,536870913]],[\"^7[\",[4,\"^4J\",91,536870913]],[\"^7[\",[4,\"^4J\",117,536870913]],[\"^7[\",[5,\"^48\",1775980635493,536870913]],[\"^7[\",[5,\"^7H\",\"file checksum\",536870913]],[\"^7[\",[5,\"^7;\",\"d3BGy\",536870913]],[\"^7[\",[5,\"^64\",124,536870913]],[\"^7[\",[5,\"^65\",\"File checksum\",536870913]],[\"^7[\",[5,\"^3;\",1775980635493,536870913]],[\"^7[\",[5,\"^2T\",\"~u00000002-1011-4169-7900-000000000000\",536870913]],[\"^7[\",[5,\"^<\",\"^=\",536870913]],[\"^7[\",[5,\"^;\",\"^1O\",536870913]],[\"^7[\",[5,\"^>\",true,536870913]],[\"^7[\",[5,\"^55\",true,536870913]],[\"^7[\",[5,\"^5G\",true,536870913]],[\"^7[\",[5,\"^X\",false,536870913]],[\"^7[\",[5,\"^1K\",\"~:string\",536870913]],[\"^7[\",[6,\"^48\",1775980635493,536870913]],[\"^7[\",[6,\"^7H\",\"view columns settings\",536870913]],[\"^7[\",[6,\"^7;\",\"d3BGp\",536870913]],[\"^7[\",[6,\"^64\",124,536870913]],[\"^7[\",[6,\"^65\",\"View columns settings\",536870913]],[\"^7[\",[6,\"^3;\",1775980635493,536870913]],[\"^7[\",[6,\"^2T\",\"~u00000002-1675-5105-5500-000000000000\",536870913]],[\"^7[\",[6,\"^<\",\"^=\",536870913]],[\"^7[\",[6,\"^;\",\"^1Y\",536870913]],[\"^7[\",[6,\"^>\",true,536870913]],[\"^7[\",[6,\"^55\",true,536870913]],[\"^7[\",[6,\"^5G\",true,536870913]],[\"^7[\",[6,\"^X\",false,536870913]],[\"^7[\",[6,\"^1K\",\"~:map\",536870913]],[\"^7[\",[7,\"^48\",1775980635487,536870913]],[\"^7[\",[7,\"^7H\",\"alias\",536870913]],[\"^7[\",[7,\"^7;\",\"d3BFF\",536870913]],[\"^7[\",[7,\"^64\",124,536870913]],[\"^7[\",[7,\"^65\",\"Alias\",536870913]],[\"^7[\",[7,\"^3;\",1775980635487,536870913]],[\"^7[\",[7,\"^2T\",\"~u00000002-2112-6446-9900-000000000000\",536870913]],[\"^7[\",[7,\"^<\",\"^14\",536870913]],[\"^7[\",[7,\"^;\",\"^1Z\",536870913]],[\"^7[\",[7,\"^>\",true,536870913]],[\"^7[\",[7,\"^C\",\"^D\",536870913]],[\"^7[\",[7,\"^55\",true,536870913]],[\"^7[\",[7,\"^X\",true,536870913]],[\"^7[\",[7,\"^1K\",\"~:page\",536870913]],[\"^7[\",[7,\"^44\",\"^84\",536870913]],[\"^7[\",[8,\"^48\",1775980635492,536870913]],[\"^7[\",[8,\"^7H\",\"view sort groups by\",536870913]],[\"^7[\",[8,\"^7;\",\"d3BGj\",536870913]],[\"^7[\",[8,\"^64\",124,536870913]],[\"^7[\",[8,\"^65\",\"View sort groups by\",536870913]],[\"^7[\",[8,\"^3;\",1775980635492,536870913]],[\"^7[\",[8,\"^2T\",\"~u00000002-6253-4002-1000-000000000000\",536870913]],[\"^7[\",[8,\"^<\",\"^=\",536870913]],[\"^7[\",[8,\"^;\",\"^2M\",536870913]],[\"^7[\",[8,\"^>\",true,536870913]],[\"^7[\",[8,\"^C\",\"^D\",536870913]],[\"^7[\",[8,\"^55\",true,536870913]],[\"^7[\",[8,\"^5G\",true,536870913]],[\"^7[\",[8,\"^X\",false,536870913]],[\"^7[\",[8,\"^1K\",\"^81\",536870913]],[\"^7[\",[9,\"^L\",26,536870913]],[\"^7[\",[9,\"^48\",1775980635491,536870913]],[\"^7[\",[9,\"^7;\",\"d3BGA\",536870913]],[\"^7[\",[9,\"^4P\",26,536870913]],[\"^7[\",[9,\"^3V\",26,536870913]],[\"^7[\",[9,\"^65\",\"Doing\",536870913]],[\"^7[\",[9,\"^3;\",1775980635491,536870913]],[\"^7[\",[9,\"^2T\",\"~u00000002-1840-1229-0800-000000000000\",536870913]],[\"^7[\",[9,\"^;\",\"^32\",536870913]],[\"^7[\",[9,\"^55\",true,536870913]],[\"^7[\",[9,\"^6:\",26,536870913]],[\"^7[\",[9,\"^4[\",[\"^ \",\"~:type\",\"~:tabler-icon\",\"~:id\",\"InProgress50\"],536870913]],[\"^7[\",[10,\"^48\",1775980635496,536870913]],[\"^7[\",[10,\"^7H\",\"math\",536870913]],[\"^7[\",[10,\"^64\",14,536870913]],[\"^7[\",[10,\"^65\",\"Math\",536870913]],[\"^7[\",[10,\"^3;\",1775980635496,536870913]],[\"^7[\",[10,\"^2T\",\"~u00000002-2038-9631-2100-000000000000\",536870913]],[\"^7[\",[10,\"^;\",\"^3=\",536870913]],[\"^7[\",[10,\"^55\",true,536870913]],[\"^7[\",[10,\"^3K\",89,536870913]],[\"^7[\",[10,\"^1X\",true,536870913]],[\"^7[\",[10,\"^4J\",37,536870913]],[\"^7[\",[11,\"^L\",26,536870913]],[\"^7[\",[11,\"^48\",1775980635491,536870913]],[\"^7[\",[11,\"^7;\",\"d3BGD\",536870913]],[\"^7[\",[11,\"^4P\",26,536870913]],[\"^7[\",[11,\"^3V\",26,536870913]],[\"^7[\",[11,\"^65\",\"Canceled\",536870913]],[\"^7[\",[11,\"^3;\",1775980635491,536870913]],[\"^7[\",[11,\"^2T\",\"~u00000002-7526-4326-2000-000000000000\",536870913]],[\"^7[\",[11,\"^;\",\"^3F\",536870913]],[\"^7[\",[11,\"^55\",true,536870913]],[\"^7[\",[11,\"^6:\",26,536870913]],[\"^7[\",[11,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Cancelled\"],536870913]],[\"^7[\",[12,\"^48\",1775980635488,536870913]],[\"^7[\",[12,\"^7H\",\"node parent\",536870913]],[\"^7[\",[12,\"^7;\",\"d3BFH\",536870913]],[\"^7[\",[12,\"^64\",124,536870913]],[\"^7[\",[12,\"^65\",\"Node parent\",536870913]],[\"^7[\",[12,\"^3;\",1775980635488,536870913]],[\"^7[\",[12,\"^2T\",\"~u00000002-9183-0906-4000-000000000000\",536870913]],[\"^7[\",[12,\"^<\",\"^=\",536870913]],[\"^7[\",[12,\"^;\",\"^3V\",536870913]],[\"^7[\",[12,\"^>\",true,536870913]],[\"^7[\",[12,\"^C\",\"^D\",536870913]],[\"^7[\",[12,\"^55\",true,536870913]],[\"^7[\",[12,\"^5G\",true,536870913]],[\"^7[\",[12,\"^X\",false,536870913]],[\"^7[\",[12,\"^1K\",\"~:entity\",536870913]],[\"^7[\",[13,\"^48\",1775980635491,536870913]],[\"^7[\",[13,\"^7H\",\"repeating recur unit\",536870913]],[\"^7[\",[13,\"^7;\",\"d3BGP\",536870913]],[\"^7[\",[13,\"^64\",124,536870913]],[\"^7[\",[13,\"^65\",\"Repeating recur unit\",536870913]],[\"^7[\",[13,\"^3;\",1775980635491,536870913]],[\"^7[\",[13,\"^2T\",\"~u00000002-6903-0624-7000-000000000000\",536870913]],[\"^7[\",[13,\"^<\",\"^=\",536870913]],[\"^7[\",[13,\"^;\",\"^2G\",536870913]],[\"^7[\",[13,\"^>\",true,536870913]],[\"^7[\",[13,\"^C\",\"^D\",536870913]],[\"^7[\",[13,\"^55\",true,536870913]],[\"^7[\",[13,\"^5I\",61,536870913]],[\"^7[\",[13,\"^3P\",true,536870913]],[\"^7[\",[13,\"^X\",false,536870913]],[\"^7[\",[13,\"^1K\",\"~:default\",536870913]],[\"^7[\",[14,\"^48\",1775980635496,536870913]],[\"^7[\",[14,\"^7H\",\"tag\",536870913]],[\"^7[\",[14,\"^64\",14,536870913]],[\"^7[\",[14,\"^65\",\"Tag\",536870913]],[\"^7[\",[14,\"^3;\",1775980635496,536870913]],[\"^7[\",[14,\"^2T\",\"~u00000002-5389-0208-3000-000000000000\",536870913]],[\"^7[\",[14,\"^;\",\"^4B\",536870913]],[\"^7[\",[14,\"^55\",true,536870913]],[\"^7[\",[14,\"^3K\",89,536870913]],[\"^7[\",[15,\"^;\",\"^4N\",536870913]],[\"^7[\",[15,\"^28\",1775980635485,536870913]],[\"^7[\",[16,\"^48\",1775980635492,536870913]],[\"^7[\",[16,\"^7H\",\"icon\",536870913]],[\"^7[\",[16,\"^7;\",\"d3BGZ\",536870913]],[\"^7[\",[16,\"^64\",124,536870913]],[\"^7[\",[16,\"^65\",\"Icon\",536870913]],[\"^7[\",[16,\"^3;\",1775980635492,536870913]],[\"^7[\",[16,\"^2T\",\"~u00000002-5891-2328-5000-000000000000\",536870913]],[\"^7[\",[16,\"^<\",\"^=\",536870913]],[\"^7[\",[16,\"^;\",\"^4[\",536870913]],[\"^7[\",[16,\"^>\",true,536870913]],[\"^7[\",[16,\"^55\",true,536870913]],[\"^7[\",[16,\"^1K\",\"^83\",536870913]],[\"^7[\",[17,\"^48\",1775980635493,536870913]],[\"^7[\",[17,\"^7H\",\"external file name\",536870913]],[\"^7[\",[17,\"^7;\",\"d3BGu\",536870913]],[\"^7[\",[17,\"^64\",124,536870913]],[\"^7[\",[17,\"^65\",\"External file name\",536870913]],[\"^7[\",[17,\"^3;\",1775980635493,536870913]],[\"^7[\",[17,\"^2T\",\"~u00000002-3995-2972-0000-000000000000\",536870913]],[\"^7[\",[17,\"^<\",\"^=\",536870913]],[\"^7[\",[17,\"^;\",\"^2Z\",536870913]],[\"^7[\",[17,\"^>\",true,536870913]],[\"^7[\",[17,\"^55\",true,536870913]],[\"^7[\",[17,\"^5G\",true,536870913]],[\"^7[\",[17,\"^X\",false,536870913]],[\"^7[\",[17,\"^1K\",\"^82\",536870913]],[\"^7[\",[18,\"^48\",1775980635494,536870913]],[\"^7[\",[18,\"^7H\",\"history block\",536870913]],[\"^7[\",[18,\"^7;\",\"d3BHB\",536870913]],[\"^7[\",[18,\"^64\",124,536870913]],[\"^7[\",[18,\"^65\",\"History block\",536870913]],[\"^7[\",[18,\"^3;\",1775980635494,536870913]],[\"^7[\",[18,\"^2T\",\"~u00000002-1142-5541-6000-000000000000\",536870913]],[\"^7[\",[18,\"^<\",\"^=\",536870913]],[\"^7[\",[18,\"^;\",\"^5@\",536870913]],[\"^7[\",[18,\"^>\",true,536870913]],[\"^7[\",[18,\"^C\",\"^D\",536870913]],[\"^7[\",[18,\"^55\",true,536870913]],[\"^7[\",[18,\"^5G\",true,536870913]],[\"^7[\",[18,\"^1K\",\"^88\",536870913]],[\"^7[\",[19,\"^48\",1775980635490,536870913]],[\"^7[\",[19,\"^7H\",\"annotation color\",536870913]],[\"^7[\",[19,\"^7;\",\"d3BFo\",536870913]],[\"^7[\",[19,\"^64\",124,536870913]],[\"^7[\",[19,\"^65\",\"Annotation color\",536870913]],[\"^7[\",[19,\"^3;\",1775980635490,536870913]],[\"^7[\",[19,\"^2T\",\"~u00000002-6747-9388-7000-000000000000\",536870913]],[\"^7[\",[19,\"^<\",\"^=\",536870913]],[\"^7[\",[19,\"^;\",\"^Q\",536870913]],[\"^7[\",[19,\"^>\",true,536870913]],[\"^7[\",[19,\"^C\",\"^D\",536870913]],[\"^7[\",[19,\"^55\",true,536870913]],[\"^7[\",[19,\"^5G\",true,536870913]],[\"^7[\",[19,\"^1K\",\"^89\",536870913]],[\"^7[\",[20,\"^48\",1775980635489,536870913]],[\"^7[\",[20,\"^7H\",\"enable bidirectional properties\",536870913]],[\"^7[\",[20,\"^7;\",\"d3BFb\",536870913]],[\"^7[\",[20,\"^64\",124,536870913]],[\"^7[\",[20,\"^65\",\"Enable bidirectional properties\",536870913]],[\"^7[\",[20,\"^3;\",1775980635489,536870913]],[\"^7[\",[20,\"^2T\",\"~u00000002-2917-8613-8000-000000000000\",536870913]],[\"^7[\",[20,\"^<\",\"^=\",536870913]],[\"^7[\",[20,\"^;\",\"^2<\",536870913]],[\"^7[\",[20,\"^>\",true,536870913]],[\"^7[\",[20,\"^55\",true,536870913]],[\"^7[\",[20,\"^2A\",169,536870913]],[\"^7[\",[20,\"^X\",true,536870913]],[\"^7[\",[20,\"^1K\",\"~:checkbox\",536870913]],[\"^7[\",[20,\"^44\",\"~:class\",536870913]],[\"^7[\",[21,\"^48\",1775980635488,536870913]],[\"^7[\",[21,\"^7H\",\"node title\",536870913]],[\"^7[\",[21,\"^7;\",\"d3BFN\",536870913]],[\"^7[\",[21,\"^64\",124,536870913]],[\"^7[\",[21,\"^65\",\"Node title\",536870913]],[\"^7[\",[21,\"^3;\",1775980635488,536870913]],[\"^7[\",[21,\"^2T\",\"~u00000002-7104-4568-4000-000000000000\",536870913]],[\"^7[\",[21,\"^<\",\"^=\",536870913]],[\"^7[\",[21,\"^;\",\"^65\",536870913]],[\"^7[\",[21,\"^>\",true,536870913]],[\"^7[\",[21,\"^55\",true,536870913]],[\"^7[\",[21,\"^5G\",true,536870913]],[\"^7[\",[21,\"^X\",false,536870913]],[\"^7[\",[21,\"^1K\",\"^82\",536870913]],[\"^7[\",[22,\"^48\",1775980635487,536870913]],[\"^7[\",[22,\"^7H\",\"property classes\",536870913]],[\"^7[\",[22,\"^7;\",\"d3BFD\",536870913]],[\"^7[\",[22,\"^64\",124,536870913]],[\"^7[\",[22,\"^65\",\"Property classes\",536870913]],[\"^7[\",[22,\"^3;\",1775980635487,536870913]],[\"^7[\",[22,\"^2T\",\"~u00000002-9137-5048-6000-000000000000\",536870913]],[\"^7[\",[22,\"^<\",\"^14\",536870913]],[\"^7[\",[22,\"^;\",\"^6B\",536870913]],[\"^7[\",[22,\"^>\",true,536870913]],[\"^7[\",[22,\"^C\",\"^D\",536870913]],[\"^7[\",[22,\"^55\",true,536870913]],[\"^7[\",[22,\"^5G\",true,536870913]],[\"^7[\",[22,\"^X\",false,536870913]],[\"^7[\",[22,\"^1K\",\"^88\",536870913]],[\"^7[\",[23,\"^;\",\"^39\",536870913]],[\"^7[\",[23,\"^28\",true,536870913]],[\"^7[\",[24,\"^48\",1775980635494,536870913]],[\"^7[\",[24,\"^7H\",\"asset resize metadata\",536870913]],[\"^7[\",[24,\"^7;\",\"d3BH2\",536870913]],[\"^7[\",[24,\"^64\",124,536870913]],[\"^7[\",[24,\"^65\",\"Asset resize metadata\",536870913]],[\"^7[\",[24,\"^3;\",1775980635494,536870913]],[\"^7[\",[24,\"^2T\",\"~u00000002-1297-5230-5500-000000000000\",536870913]],[\"^7[\",[24,\"^<\",\"^=\",536870913]],[\"^7[\",[24,\"^;\",\"^5>\",536870913]],[\"^7[\",[24,\"^>\",true,536870913]],[\"^7[\",[24,\"^55\",true,536870913]],[\"^7[\",[24,\"^5G\",true,536870913]],[\"^7[\",[24,\"^X\",false,536870913]],[\"^7[\",[24,\"^1K\",\"^83\",536870913]],[\"^7[\",[25,\"^48\",1775980635495,536870913]],[\"^7[\",[25,\"^7H\",\"deleted at\",536870913]],[\"^7[\",[25,\"^7;\",\"d3BHG\",536870913]],[\"^7[\",[25,\"^64\",124,536870913]],[\"^7[\",[25,\"^65\",\"Deleted at\",536870913]],[\"^7[\",[25,\"^3;\",1775980635495,536870913]],[\"^7[\",[25,\"^2T\",\"~u00000002-6852-1375-1000-000000000000\",536870913]],[\"^7[\",[25,\"^<\",\"^=\",536870913]],[\"^7[\",[25,\"^;\",\"^6M\",536870913]],[\"^7[\",[25,\"^>\",true,536870913]],[\"^7[\",[25,\"^55\",true,536870913]],[\"^7[\",[25,\"^5G\",true,536870913]],[\"^7[\",[25,\"^X\",false,536870913]],[\"^7[\",[25,\"^1K\",\"~:datetime\",536870913]],[\"^7[\",[26,\"^48\",1775980635491,536870913]],[\"^7[\",[26,\"^7H\",\"status\",536870913]],[\"^7[\",[26,\"^7;\",\"d3BG7\",536870913]],[\"^7[\",[26,\"^64\",124,536870913]],[\"^7[\",[26,\"^65\",\"Status\",536870913]],[\"^7[\",[26,\"^3;\",1775980635491,536870913]],[\"^7[\",[26,\"^2T\",\"~u00000002-9072-1685-3000-000000000000\",536870913]],[\"^7[\",[26,\"^<\",\"^=\",536870913]],[\"^7[\",[26,\"^;\",\"^3I\",536870913]],[\"^7[\",[26,\"^>\",true,536870913]],[\"^7[\",[26,\"^C\",\"^D\",536870913]],[\"^7[\",[26,\"^55\",true,536870913]],[\"^7[\",[26,\"^5I\",114,536870913]],[\"^7[\",[26,\"^4L\",true,536870913]],[\"^7[\",[26,\"^3P\",true,536870913]],[\"^7[\",[26,\"^X\",true,536870913]],[\"^7[\",[26,\"^1K\",\"^89\",536870913]],[\"^7[\",[26,\"^@\",\"~:block-left\",536870913]],[\"^7[\",[27,\"^48\",1775980635496,536870913]],[\"^7[\",[27,\"^7H\",\"card\",536870913]],[\"^7[\",[27,\"^64\",14,536870913]],[\"^7[\",[27,\"^65\",\"Card\",536870913]],[\"^7[\",[27,\"^3;\",1775980635496,536870913]],[\"^7[\",[27,\"^2T\",\"~u00000002-1358-2811-0900-000000000000\",536870913]],[\"^7[\",[27,\"^;\",\"^3S\",536870913]],[\"^7[\",[27,\"^55\",true,536870913]],[\"^7[\",[27,\"^3K\",89,536870913]],[\"^7[\",[27,\"^4J\",29,536870913]],[\"^7[\",[27,\"^4J\",43,536870913]],[\"^7[\",[28,\"^48\",1775980635496,536870913]],[\"^7[\",[28,\"^7H\",\"code\",536870913]],[\"^7[\",[28,\"^64\",14,536870913]],[\"^7[\",[28,\"^65\",\"Code\",536870913]],[\"^7[\",[28,\"^3;\",1775980635496,536870913]],[\"^7[\",[28,\"^2T\",\"~u00000002-1454-9866-4100-000000000000\",536870913]],[\"^7[\",[28,\"^;\",\"^5=\",536870913]],[\"^7[\",[28,\"^55\",true,536870913]],[\"^7[\",[28,\"^3K\",89,536870913]],[\"^7[\",[28,\"^1X\",true,536870913]],[\"^7[\",[28,\"^4J\",37,536870913]],[\"^7[\",[28,\"^4J\",108,536870913]],[\"^7[\",[29,\"^48\",1775980635494,536870913]],[\"^7[\",[29,\"^7H\",\"due\",536870913]],[\"^7[\",[29,\"^7;\",\"d3BH4\",536870913]],[\"^7[\",[29,\"^64\",124,536870913]],[\"^7[\",[29,\"^65\",\"Due\",536870913]],[\"^7[\",[29,\"^3;\",1775980635494,536870913]],[\"^7[\",[29,\"^2T\",\"~u00000002-1089-0805-4900-000000000000\",536870913]],[\"^7[\",[29,\"^<\",\"^=\",536870913]],[\"^7[\",[29,\"^;\",\"^7B\",536870913]],[\"^7[\",[29,\"^>\",true,536870913]],[\"^7[\",[29,\"^55\",true,536870913]],[\"^7[\",[29,\"^5G\",false,536870913]],[\"^7[\",[29,\"^X\",false,536870913]],[\"^7[\",[29,\"^1K\",\"^8<\",536870913]],[\"^7[\",[30,\"^48\",1775980635490,536870913]],[\"^7[\",[30,\"^7H\",\"asset\",536870913]],[\"^7[\",[30,\"^7;\",\"d3BFl\",536870913]],[\"^7[\",[30,\"^64\",124,536870913]],[\"^7[\",[30,\"^65\",\"Asset\",536870913]],[\"^7[\",[30,\"^3;\",1775980635490,536870913]],[\"^7[\",[30,\"^2T\",\"~u00000002-8768-5679-0000-000000000000\",536870913]],[\"^7[\",[30,\"^<\",\"^=\",536870913]],[\"^7[\",[30,\"^;\",\"^6I\",536870913]],[\"^7[\",[30,\"^>\",true,536870913]],[\"^7[\",[30,\"^C\",\"^D\",536870913]],[\"^7[\",[30,\"^55\",true,536870913]],[\"^7[\",[30,\"^5G\",true,536870913]],[\"^7[\",[30,\"^1K\",\"^88\",536870913]],[\"^7[\",[31,\"^L\",19,536870913]],[\"^7[\",[31,\"^48\",1775980635490,536870913]],[\"^7[\",[31,\"^7;\",\"d3BFq\",536870913]],[\"^7[\",[31,\"^4P\",19,536870913]],[\"^7[\",[31,\"^3V\",19,536870913]],[\"^7[\",[31,\"^65\",\"red\",536870913]],[\"^7[\",[31,\"^3;\",1775980635490,536870913]],[\"^7[\",[31,\"^2T\",\"~u00000002-4136-2216-2000-000000000000\",536870913]],[\"^7[\",[31,\"^;\",\"^5K\",536870913]],[\"^7[\",[31,\"^55\",true,536870913]],[\"^7[\",[31,\"^6:\",19,536870913]],[\"^7[\",[32,\"^48\",1775980635491,536870913]],[\"^7[\",[32,\"^7H\",\"priority\",536870913]],[\"^7[\",[32,\"^7;\",\"d3BGE\",536870913]],[\"^7[\",[32,\"^64\",124,536870913]],[\"^7[\",[32,\"^65\",\"Priority\",536870913]],[\"^7[\",[32,\"^3;\",1775980635491,536870913]],[\"^7[\",[32,\"^2T\",\"~u00000002-2392-2841-1000-000000000000\",536870913]],[\"^7[\",[32,\"^<\",\"^=\",536870913]],[\"^7[\",[32,\"^;\",\"^3\",536870913]],[\"^7[\",[32,\"^>\",true,536870913]],[\"^7[\",[32,\"^C\",\"^D\",536870913]],[\"^7[\",[32,\"^55\",true,536870913]],[\"^7[\",[32,\"^4L\",true,536870913]],[\"^7[\",[32,\"^3P\",true,536870913]],[\"^7[\",[32,\"^X\",true,536870913]],[\"^7[\",[32,\"^1K\",\"^89\",536870913]],[\"^7[\",[32,\"^@\",\"^8=\",536870913]],[\"^7[\",[33,\"^L\",32,536870913]],[\"^7[\",[33,\"^48\",1775980635491,536870913]],[\"^7[\",[33,\"^7;\",\"d3BGI\",536870913]],[\"^7[\",[33,\"^4P\",32,536870913]],[\"^7[\",[33,\"^3V\",32,536870913]],[\"^7[\",[33,\"^65\",\"Urgent\",536870913]],[\"^7[\",[33,\"^3;\",1775980635491,536870913]],[\"^7[\",[33,\"^2T\",\"~u00000002-1996-4346-6700-000000000000\",536870913]],[\"^7[\",[33,\"^;\",\"^H\",536870913]],[\"^7[\",[33,\"^55\",true,536870913]],[\"^7[\",[33,\"^6:\",32,536870913]],[\"^7[\",[33,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlUrgent\"],536870913]],[\"^7[\",[34,\"^48\",1775980635487,536870913]],[\"^7[\",[34,\"^7H\",\"property public?\",536870913]],[\"^7[\",[34,\"^7;\",\"d3BFA\",536870913]],[\"^7[\",[34,\"^64\",124,536870913]],[\"^7[\",[34,\"^65\",\"Property public?\",536870913]],[\"^7[\",[34,\"^3;\",1775980635487,536870913]],[\"^7[\",[34,\"^2T\",\"~u00000002-1843-0851-4900-000000000000\",536870913]],[\"^7[\",[34,\"^<\",\"^=\",536870913]],[\"^7[\",[34,\"^;\",\"^X\",536870913]],[\"^7[\",[34,\"^>\",true,536870913]],[\"^7[\",[34,\"^55\",true,536870913]],[\"^7[\",[34,\"^5G\",true,536870913]],[\"^7[\",[34,\"^1K\",\"^8:\",536870913]],[\"^7[\",[35,\"^48\",1775980635495,536870913]],[\"^7[\",[35,\"^7H\",\"recycle original order\",536870913]],[\"^7[\",[35,\"^7;\",\"d3BHK\",536870913]],[\"^7[\",[35,\"^64\",124,536870913]],[\"^7[\",[35,\"^65\",\"Recycle original order\",536870913]],[\"^7[\",[35,\"^3;\",1775980635495,536870913]],[\"^7[\",[35,\"^2T\",\"~u00000002-8699-2537-0000-000000000000\",536870913]],[\"^7[\",[35,\"^<\",\"^=\",536870913]],[\"^7[\",[35,\"^;\",\"^19\",536870913]],[\"^7[\",[35,\"^>\",true,536870913]],[\"^7[\",[35,\"^55\",true,536870913]],[\"^7[\",[35,\"^5G\",true,536870913]],[\"^7[\",[35,\"^X\",false,536870913]],[\"^7[\",[35,\"^1K\",\"^82\",536870913]],[\"^7[\",[36,\"^48\",1775980635488,536870913]],[\"^7[\",[36,\"^7H\",\"journal date\",536870913]],[\"^7[\",[36,\"^7;\",\"d3BFP\",536870913]],[\"^7[\",[36,\"^64\",124,536870913]],[\"^7[\",[36,\"^65\",\"Journal date\",536870913]],[\"^7[\",[36,\"^3;\",1775980635488,536870913]],[\"^7[\",[36,\"^2T\",\"~u00000002-1457-4836-6000-000000000000\",536870913]],[\"^7[\",[36,\"^<\",\"^=\",536870913]],[\"^7[\",[36,\"^;\",\"^1C\",536870913]],[\"^7[\",[36,\"^>\",true,536870913]],[\"^7[\",[36,\"^55\",true,536870913]],[\"^7[\",[36,\"^5G\",true,536870913]],[\"^7[\",[36,\"^X\",false,536870913]],[\"^7[\",[36,\"^1K\",\"~:raw-number\",536870913]],[\"^7[\",[37,\"^48\",1775980635488,536870913]],[\"^7[\",[37,\"^7H\",\"node display type\",536870913]],[\"^7[\",[37,\"^7;\",\"d3BFS\",536870913]],[\"^7[\",[37,\"^64\",124,536870913]],[\"^7[\",[37,\"^65\",\"Node Display Type\",536870913]],[\"^7[\",[37,\"^3;\",1775980635488,536870913]],[\"^7[\",[37,\"^2T\",\"~u00000002-4424-4618-9000-000000000000\",536870913]],[\"^7[\",[37,\"^<\",\"^=\",536870913]],[\"^7[\",[37,\"^;\",\"^1Q\",536870913]],[\"^7[\",[37,\"^>\",true,536870913]],[\"^7[\",[37,\"^55\",true,536870913]],[\"^7[\",[37,\"^5G\",true,536870913]],[\"^7[\",[37,\"^X\",false,536870913]],[\"^7[\",[37,\"^1K\",\"~:keyword\",536870913]],[\"^7[\",[37,\"^44\",\"~:block\",536870913]],[\"^7[\",[38,\"^48\",1775980635496,536870913]],[\"^7[\",[38,\"^7H\",\"template\",536870913]],[\"^7[\",[38,\"^64\",14,536870913]],[\"^7[\",[38,\"^65\",\"Template\",536870913]],[\"^7[\",[38,\"^3;\",1775980635496,536870913]],[\"^7[\",[38,\"^2T\",\"~u00000002-1720-8548-4600-000000000000\",536870913]],[\"^7[\",[38,\"^;\",\"^22\",536870913]],[\"^7[\",[38,\"^55\",true,536870913]],[\"^7[\",[38,\"^3K\",89,536870913]],[\"^7[\",[38,\"^4J\",147,536870913]],[\"^7[\",[39,\"^;\",\"^2?\",536870913]],[\"^7[\",[39,\"^28\",\"~u00000000-4735-4d82-94bb-c9bfb8039105\",536870913]],[\"^7[\",[40,\"^48\",1775980635490,536870913]],[\"^7[\",[40,\"^7H\",\"annotation page\",536870913]],[\"^7[\",[40,\"^7;\",\"d3BFu\",536870913]],[\"^7[\",[40,\"^64\",124,536870913]],[\"^7[\",[40,\"^65\",\"Annotation page\",536870913]],[\"^7[\",[40,\"^3;\",1775980635490,536870913]],[\"^7[\",[40,\"^2T\",\"~u00000002-7532-8459-6000-000000000000\",536870913]],[\"^7[\",[40,\"^<\",\"^=\",536870913]],[\"^7[\",[40,\"^;\",\"^2O\",536870913]],[\"^7[\",[40,\"^>\",true,536870913]],[\"^7[\",[40,\"^55\",true,536870913]],[\"^7[\",[40,\"^5G\",true,536870913]],[\"^7[\",[40,\"^1K\",\"^8>\",536870913]],[\"^7[\",[41,\"^48\",1775980635491,536870913]],[\"^7[\",[41,\"^7H\",\"properties displayed as checkbox\",536870913]],[\"^7[\",[41,\"^7;\",\"d3BG6\",536870913]],[\"^7[\",[41,\"^64\",124,536870913]],[\"^7[\",[41,\"^65\",\"Properties displayed as checkbox\",536870913]],[\"^7[\",[41,\"^3;\",1775980635491,536870913]],[\"^7[\",[41,\"^2T\",\"~u00000002-3215-3256-9000-000000000000\",536870913]],[\"^7[\",[41,\"^<\",\"^14\",536870913]],[\"^7[\",[41,\"^;\",\"^2H\",536870913]],[\"^7[\",[41,\"^>\",true,536870913]],[\"^7[\",[41,\"^C\",\"^D\",536870913]],[\"^7[\",[41,\"^55\",true,536870913]],[\"^7[\",[41,\"^5G\",true,536870913]],[\"^7[\",[41,\"^1K\",\"^81\",536870913]],[\"^7[\",[42,\"^L\",19,536870913]],[\"^7[\",[42,\"^48\",1775980635490,536870913]],[\"^7[\",[42,\"^7;\",\"d3BFt\",536870913]],[\"^7[\",[42,\"^4P\",19,536870913]],[\"^7[\",[42,\"^3V\",19,536870913]],[\"^7[\",[42,\"^65\",\"purple\",536870913]],[\"^7[\",[42,\"^3;\",1775980635490,536870913]],[\"^7[\",[42,\"^2T\",\"~u00000002-1104-3848-5600-000000000000\",536870913]],[\"^7[\",[42,\"^;\",\"^2Y\",536870913]],[\"^7[\",[42,\"^55\",true,536870913]],[\"^7[\",[42,\"^6:\",19,536870913]],[\"^7[\",[43,\"^48\",1775980635494,536870913]],[\"^7[\",[43,\"^7H\",\"state\",536870913]],[\"^7[\",[43,\"^7;\",\"d3BH5\",536870913]],[\"^7[\",[43,\"^64\",124,536870913]],[\"^7[\",[43,\"^65\",\"State\",536870913]],[\"^7[\",[43,\"^3;\",1775980635494,536870913]],[\"^7[\",[43,\"^2T\",\"~u00000002-1165-1650-8700-000000000000\",536870913]],[\"^7[\",[43,\"^<\",\"^=\",536870913]],[\"^7[\",[43,\"^;\",\"^P\",536870913]],[\"^7[\",[43,\"^>\",true,536870913]],[\"^7[\",[43,\"^55\",true,536870913]],[\"^7[\",[43,\"^5G\",false,536870913]],[\"^7[\",[43,\"^X\",false,536870913]],[\"^7[\",[43,\"^1K\",\"^83\",536870913]],[\"^7[\",[44,\"^48\",1775980635492,536870913]],[\"^7[\",[44,\"^7H\",\"view sort groups desc\",536870913]],[\"^7[\",[44,\"^7;\",\"d3BGk\",536870913]],[\"^7[\",[44,\"^64\",124,536870913]],[\"^7[\",[44,\"^65\",\"View sort groups DESC\",536870913]],[\"^7[\",[44,\"^3;\",1775980635492,536870913]],[\"^7[\",[44,\"^2T\",\"~u00000002-1081-4073-8700-000000000000\",536870913]],[\"^7[\",[44,\"^<\",\"^=\",536870913]],[\"^7[\",[44,\"^;\",\"^1V\",536870913]],[\"^7[\",[44,\"^>\",true,536870913]],[\"^7[\",[44,\"^55\",true,536870913]],[\"^7[\",[44,\"^5G\",true,536870913]],[\"^7[\",[44,\"^X\",false,536870913]],[\"^7[\",[44,\"^29\",true,536870913]],[\"^7[\",[44,\"^1K\",\"^8:\",536870913]],[\"^7[\",[45,\"^48\",1775980635488,536870913]],[\"^7[\",[45,\"^7H\",\"node created at\",536870913]],[\"^7[\",[45,\"^7;\",\"d3BFQ\",536870913]],[\"^7[\",[45,\"^64\",124,536870913]],[\"^7[\",[45,\"^65\",\"Node created at\",536870913]],[\"^7[\",[45,\"^3;\",1775980635488,536870913]],[\"^7[\",[45,\"^2T\",\"~u00000002-1440-0150-0000-000000000000\",536870913]],[\"^7[\",[45,\"^<\",\"^=\",536870913]],[\"^7[\",[45,\"^;\",\"^48\",536870913]],[\"^7[\",[45,\"^>\",true,536870913]],[\"^7[\",[45,\"^55\",true,536870913]],[\"^7[\",[45,\"^5G\",true,536870913]],[\"^7[\",[45,\"^X\",false,536870913]],[\"^7[\",[45,\"^1K\",\"^8<\",536870913]],[\"^7[\",[46,\"^48\",1775980635496,536870913]],[\"^7[\",[46,\"^7H\",\"asset\",536870913]],[\"^7[\",[46,\"^64\",14,536870913]],[\"^7[\",[46,\"^65\",\"Asset\",536870913]],[\"^7[\",[46,\"^3;\",1775980635496,536870913]],[\"^7[\",[46,\"^2T\",\"~u00000002-7975-0297-0000-000000000000\",536870913]],[\"^7[\",[46,\"^;\",\"^2;\",536870913]],[\"^7[\",[46,\"^55\",true,536870913]],[\"^7[\",[46,\"^3K\",89,536870913]],[\"^7[\",[46,\"^1X\",true,536870913]],[\"^7[\",[46,\"^4J\",5,536870913]],[\"^7[\",[46,\"^4J\",57,536870913]],[\"^7[\",[46,\"^4J\",131,536870913]],[\"^7[\",[46,\"^2J\",150,536870913]],[\"^7[\",[47,\"^48\",1775980635488,536870913]],[\"^7[\",[47,\"^7H\",\"node page\",536870913]],[\"^7[\",[47,\"^7;\",\"d3BFK\",536870913]],[\"^7[\",[47,\"^64\",124,536870913]],[\"^7[\",[47,\"^65\",\"Node page\",536870913]],[\"^7[\",[47,\"^3;\",1775980635488,536870913]],[\"^7[\",[47,\"^2T\",\"~u00000002-8223-1410-8000-000000000000\",536870913]],[\"^7[\",[47,\"^<\",\"^=\",536870913]],[\"^7[\",[47,\"^;\",\"^4P\",536870913]],[\"^7[\",[47,\"^>\",true,536870913]],[\"^7[\",[47,\"^C\",\"^D\",536870913]],[\"^7[\",[47,\"^55\",true,536870913]],[\"^7[\",[47,\"^5G\",true,536870913]],[\"^7[\",[47,\"^X\",false,536870913]],[\"^7[\",[47,\"^1K\",\"^88\",536870913]],[\"^7[\",[48,\"^L\",13,536870913]],[\"^7[\",[48,\"^48\",1775980635492,536870913]],[\"^7[\",[48,\"^7;\",\"d3BGV\",536870913]],[\"^7[\",[48,\"^4P\",13,536870913]],[\"^7[\",[48,\"^3V\",13,536870913]],[\"^7[\",[48,\"^65\",\"Year\",536870913]],[\"^7[\",[48,\"^3;\",1775980635492,536870913]],[\"^7[\",[48,\"^2T\",\"~u00000002-1520-4385-2400-000000000000\",536870913]],[\"^7[\",[48,\"^;\",\"^1L\",536870913]],[\"^7[\",[48,\"^55\",true,536870913]],[\"^7[\",[48,\"^6:\",13,536870913]],[\"^7[\",[49,\"^48\",1775980635492,536870913]],[\"^7[\",[49,\"^7H\",\"excluded from graph view?\",536870913]],[\"^7[\",[49,\"^7;\",\"d3BGc\",536870913]],[\"^7[\",[49,\"^64\",124,536870913]],[\"^7[\",[49,\"^65\",\"Excluded from Graph view?\",536870913]],[\"^7[\",[49,\"^3;\",1775980635492,536870913]],[\"^7[\",[49,\"^2T\",\"~u00000002-4524-3306-5000-000000000000\",536870913]],[\"^7[\",[49,\"^<\",\"^=\",536870913]],[\"^7[\",[49,\"^;\",\"^58\",536870913]],[\"^7[\",[49,\"^>\",true,536870913]],[\"^7[\",[49,\"^55\",true,536870913]],[\"^7[\",[49,\"^5G\",true,536870913]],[\"^7[\",[49,\"^X\",true,536870913]],[\"^7[\",[49,\"^1K\",\"^8:\",536870913]],[\"^7[\",[49,\"^44\",\"^84\",536870913]],[\"^7[\",[50,\"^48\",1775980635493,536870913]],[\"^7[\",[50,\"^7H\",\"table view pinned columns\",536870913]],[\"^7[\",[50,\"^7;\",\"d3BGq\",536870913]],[\"^7[\",[50,\"^64\",124,536870913]],[\"^7[\",[50,\"^65\",\"Table view pinned columns\",536870913]],[\"^7[\",[50,\"^3;\",1775980635493,536870913]],[\"^7[\",[50,\"^2T\",\"~u00000002-2673-7513-8000-000000000000\",536870913]],[\"^7[\",[50,\"^<\",\"^14\",536870913]],[\"^7[\",[50,\"^;\",\"^15\",536870913]],[\"^7[\",[50,\"^>\",true,536870913]],[\"^7[\",[50,\"^C\",\"^D\",536870913]],[\"^7[\",[50,\"^55\",true,536870913]],[\"^7[\",[50,\"^5G\",true,536870913]],[\"^7[\",[50,\"^X\",false,536870913]],[\"^7[\",[50,\"^1K\",\"^81\",536870913]],[\"^7[\",[51,\"^48\",1775980635495,536870913]],[\"^7[\",[51,\"^7H\",\"reference to large block title stored in remote object storage\",536870913]],[\"^7[\",[51,\"^7;\",\"d3BHP\",536870913]],[\"^7[\",[51,\"^64\",124,536870913]],[\"^7[\",[51,\"^65\",\"Reference to large block title stored in remote object storage\",536870913]],[\"^7[\",[51,\"^3;\",1775980635495,536870913]],[\"^7[\",[51,\"^2T\",\"~u00000002-1044-8271-6500-000000000000\",536870913]],[\"^7[\",[51,\"^<\",\"^=\",536870913]],[\"^7[\",[51,\"^;\",\"^1@\",536870913]],[\"^7[\",[51,\"^>\",true,536870913]],[\"^7[\",[51,\"^55\",true,536870913]],[\"^7[\",[51,\"^5G\",true,536870913]],[\"^7[\",[51,\"^X\",false,536870913]],[\"^7[\",[51,\"^1K\",\"^83\",536870913]],[\"^7[\",[52,\"^;\",\"^45\",536870913]],[\"^7[\",[52,\"^28\",[\"^ \",\"~:major\",65,\"~:minor\",24],536870913]],[\"^7[\",[53,\"^48\",1775980635487,536870913]],[\"^7[\",[53,\"^7H\",\"created from property\",536870913]],[\"^7[\",[53,\"^7;\",\"d3BF9\",536870913]],[\"^7[\",[53,\"^64\",124,536870913]],[\"^7[\",[53,\"^65\",\"Created from property\",536870913]],[\"^7[\",[53,\"^3;\",1775980635487,536870913]],[\"^7[\",[53,\"^2T\",\"~u00000002-8618-9226-7000-000000000000\",536870913]],[\"^7[\",[53,\"^<\",\"^=\",536870913]],[\"^7[\",[53,\"^;\",\"^6:\",536870913]],[\"^7[\",[53,\"^>\",true,536870913]],[\"^7[\",[53,\"^C\",\"^D\",536870913]],[\"^7[\",[53,\"^55\",true,536870913]],[\"^7[\",[53,\"^5G\",true,536870913]],[\"^7[\",[53,\"^1K\",\"^88\",536870913]],[\"^7[\",[54,\"^48\",1775980635492,536870913]],[\"^7[\",[54,\"^7H\",\"publishing public?\",536870913]],[\"^7[\",[54,\"^7;\",\"d3BGa\",536870913]],[\"^7[\",[54,\"^64\",124,536870913]],[\"^7[\",[54,\"^65\",\"Publishing Public?\",536870913]],[\"^7[\",[54,\"^3;\",1775980635492,536870913]],[\"^7[\",[54,\"^2T\",\"~u00000002-1094-6579-3900-000000000000\",536870913]],[\"^7[\",[54,\"^<\",\"^=\",536870913]],[\"^7[\",[54,\"^;\",\"^4=\",536870913]],[\"^7[\",[54,\"^>\",true,536870913]],[\"^7[\",[54,\"^55\",true,536870913]],[\"^7[\",[54,\"^5G\",true,536870913]],[\"^7[\",[54,\"^X\",true,536870913]],[\"^7[\",[54,\"^1K\",\"^8:\",536870913]],[\"^7[\",[54,\"^44\",\"^84\",536870913]],[\"^7[\",[55,\"^L\",26,536870913]],[\"^7[\",[55,\"^48\",1775980635491,536870913]],[\"^7[\",[55,\"^7;\",\"d3BGC\",536870913]],[\"^7[\",[55,\"^4P\",26,536870913]],[\"^7[\",[55,\"^3V\",26,536870913]],[\"^7[\",[55,\"^65\",\"Done\",536870913]],[\"^7[\",[55,\"^3;\",1775980635491,536870913]],[\"^7[\",[55,\"^2T\",\"~u00000002-1827-5820-8200-000000000000\",536870913]],[\"^7[\",[55,\"^;\",\"^6L\",536870913]],[\"^7[\",[55,\"^55\",true,536870913]],[\"^7[\",[55,\"^4T\",true,536870913]],[\"^7[\",[55,\"^6:\",26,536870913]],[\"^7[\",[55,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Done\"],536870913]],[\"^7[\",[56,\"^48\",1775980635487,536870913]],[\"^7[\",[56,\"^7H\",\"property value\",536870913]],[\"^7[\",[56,\"^7;\",\"d3BFE\",536870913]],[\"^7[\",[56,\"^64\",124,536870913]],[\"^7[\",[56,\"^65\",\"Property value\",536870913]],[\"^7[\",[56,\"^3;\",1775980635487,536870913]],[\"^7[\",[56,\"^2T\",\"~u00000002-1396-5248-6500-000000000000\",536870913]],[\"^7[\",[56,\"^<\",\"^=\",536870913]],[\"^7[\",[56,\"^;\",\"^T\",536870913]],[\"^7[\",[56,\"^>\",true,536870913]],[\"^7[\",[56,\"^55\",true,536870913]],[\"^7[\",[56,\"^5G\",true,536870913]],[\"^7[\",[56,\"^X\",false,536870913]],[\"^7[\",[56,\"^1K\",\"~:any\",536870913]],[\"^7[\",[57,\"^48\",1775980635493,536870913]],[\"^7[\",[57,\"^7H\",\"file type\",536870913]],[\"^7[\",[57,\"^7;\",\"d3BGs\",536870913]],[\"^7[\",[57,\"^64\",124,536870913]],[\"^7[\",[57,\"^65\",\"File Type\",536870913]],[\"^7[\",[57,\"^3;\",1775980635493,536870913]],[\"^7[\",[57,\"^2T\",\"~u00000002-1142-0830-9800-000000000000\",536870913]],[\"^7[\",[57,\"^<\",\"^=\",536870913]],[\"^7[\",[57,\"^;\",\"^2:\",536870913]],[\"^7[\",[57,\"^>\",true,536870913]],[\"^7[\",[57,\"^55\",true,536870913]],[\"^7[\",[57,\"^5G\",true,536870913]],[\"^7[\",[57,\"^X\",false,536870913]],[\"^7[\",[57,\"^1K\",\"^82\",536870913]],[\"^7[\",[58,\"^48\",1775980635487,536870913]],[\"^7[\",[58,\"^7H\",\"tags\",536870913]],[\"^7[\",[58,\"^7;\",\"d3BFG\",536870913]],[\"^7[\",[58,\"^64\",124,536870913]],[\"^7[\",[58,\"^65\",\"Tags\",536870913]],[\"^7[\",[58,\"^3;\",1775980635487,536870913]],[\"^7[\",[58,\"^2T\",\"~u00000002-1814-9483-4000-000000000000\",536870913]],[\"^7[\",[58,\"^<\",\"^14\",536870913]],[\"^7[\",[58,\"^;\",\"^64\",536870913]],[\"^7[\",[58,\"^>\",true,536870913]],[\"^7[\",[58,\"^C\",\"^D\",536870913]],[\"^7[\",[58,\"^55\",true,536870913]],[\"^7[\",[58,\"^X\",true,536870913]],[\"^7[\",[58,\"^1K\",\"^8;\",536870913]],[\"^7[\",[59,\"^48\",1775980635490,536870913]],[\"^7[\",[59,\"^7H\",\"list type\",536870913]],[\"^7[\",[59,\"^7;\",\"d3BFx\",536870913]],[\"^7[\",[59,\"^64\",124,536870913]],[\"^7[\",[59,\"^65\",\"List type\",536870913]],[\"^7[\",[59,\"^3;\",1775980635490,536870913]],[\"^7[\",[59,\"^2T\",\"~u00000002-6078-1711-1000-000000000000\",536870913]],[\"^7[\",[59,\"^<\",\"^=\",536870913]],[\"^7[\",[59,\"^;\",\"^6R\",536870913]],[\"^7[\",[59,\"^>\",true,536870913]],[\"^7[\",[59,\"^C\",\"^D\",536870913]],[\"^7[\",[59,\"^55\",true,536870913]],[\"^7[\",[59,\"^5G\",true,536870913]],[\"^7[\",[59,\"^1K\",\"^89\",536870913]],[\"^7[\",[60,\"^48\",1775980635487,536870913]],[\"^7[\",[60,\"^7H\",\"property position\",536870913]],[\"^7[\",[60,\"^7;\",\"d3BFC\",536870913]],[\"^7[\",[60,\"^64\",124,536870913]],[\"^7[\",[60,\"^65\",\"Property position\",536870913]],[\"^7[\",[60,\"^3;\",1775980635487,536870913]],[\"^7[\",[60,\"^2T\",\"~u00000002-1869-2008-6400-000000000000\",536870913]],[\"^7[\",[60,\"^<\",\"^=\",536870913]],[\"^7[\",[60,\"^;\",\"^@\",536870913]],[\"^7[\",[60,\"^>\",true,536870913]],[\"^7[\",[60,\"^55\",true,536870913]],[\"^7[\",[60,\"^5G\",true,536870913]],[\"^7[\",[60,\"^1K\",\"^8?\",536870913]],[\"^7[\",[61,\"^L\",13,536870913]],[\"^7[\",[61,\"^48\",1775980635492,536870913]],[\"^7[\",[61,\"^7;\",\"d3BGS\",536870913]],[\"^7[\",[61,\"^4P\",13,536870913]],[\"^7[\",[61,\"^3V\",13,536870913]],[\"^7[\",[61,\"^65\",\"Day\",536870913]],[\"^7[\",[61,\"^3;\",1775980635492,536870913]],[\"^7[\",[61,\"^2T\",\"~u00000002-3924-1785-8000-000000000000\",536870913]],[\"^7[\",[61,\"^;\",\"^16\",536870913]],[\"^7[\",[61,\"^55\",true,536870913]],[\"^7[\",[61,\"^6:\",13,536870913]],[\"^7[\",[62,\"^48\",1775980635490,536870913]],[\"^7[\",[62,\"^7H\",\"annotation type\",536870913]],[\"^7[\",[62,\"^7;\",\"d3BFn\",536870913]],[\"^7[\",[62,\"^64\",124,536870913]],[\"^7[\",[62,\"^65\",\"Annotation type\",536870913]],[\"^7[\",[62,\"^3;\",1775980635490,536870913]],[\"^7[\",[62,\"^2T\",\"~u00000002-9984-3783-2000-000000000000\",536870913]],[\"^7[\",[62,\"^<\",\"^=\",536870913]],[\"^7[\",[62,\"^;\",\"^6V\",536870913]],[\"^7[\",[62,\"^>\",true,536870913]],[\"^7[\",[62,\"^55\",true,536870913]],[\"^7[\",[62,\"^5G\",true,536870913]],[\"^7[\",[62,\"^1K\",\"^8?\",536870913]],[\"^7[\",[63,\"^48\",1775980635493,536870913]],[\"^7[\",[63,\"^7H\",\"view hidden columns\",536870913]],[\"^7[\",[63,\"^7;\",\"d3BGn\",536870913]],[\"^7[\",[63,\"^64\",124,536870913]],[\"^7[\",[63,\"^65\",\"View hidden columns\",536870913]],[\"^7[\",[63,\"^3;\",1775980635493,536870913]],[\"^7[\",[63,\"^2T\",\"~u00000002-9750-5719-2000-000000000000\",536870913]],[\"^7[\",[63,\"^<\",\"^14\",536870913]],[\"^7[\",[63,\"^;\",\"^2[\",536870913]],[\"^7[\",[63,\"^>\",true,536870913]],[\"^7[\",[63,\"^55\",true,536870913]],[\"^7[\",[63,\"^5G\",true,536870913]],[\"^7[\",[63,\"^X\",false,536870913]],[\"^7[\",[63,\"^1K\",\"^8?\",536870913]],[\"^7[\",[64,\"^48\",1775980635490,536870913]],[\"^7[\",[64,\"^7H\",\"ls-type\",536870913]],[\"^7[\",[64,\"^7;\",\"d3BFm\",536870913]],[\"^7[\",[64,\"^64\",124,536870913]],[\"^7[\",[64,\"^65\",\"ls-type\",536870913]],[\"^7[\",[64,\"^3;\",1775980635490,536870913]],[\"^7[\",[64,\"^2T\",\"~u00000002-3269-7934-5000-000000000000\",536870913]],[\"^7[\",[64,\"^<\",\"^=\",536870913]],[\"^7[\",[64,\"^;\",\"^5\",536870913]],[\"^7[\",[64,\"^>\",true,536870913]],[\"^7[\",[64,\"^55\",true,536870913]],[\"^7[\",[64,\"^5G\",true,536870913]],[\"^7[\",[64,\"^1K\",\"^8?\",536870913]],[\"^7[\",[65,\"^L\",19,536870913]],[\"^7[\",[65,\"^48\",1775980635490,536870913]],[\"^7[\",[65,\"^7;\",\"d3BFp\",536870913]],[\"^7[\",[65,\"^4P\",19,536870913]],[\"^7[\",[65,\"^3V\",19,536870913]],[\"^7[\",[65,\"^65\",\"yellow\",536870913]],[\"^7[\",[65,\"^3;\",1775980635490,536870913]],[\"^7[\",[65,\"^2T\",\"~u00000002-1752-1030-0200-000000000000\",536870913]],[\"^7[\",[65,\"^;\",\"^J\",536870913]],[\"^7[\",[65,\"^55\",true,536870913]],[\"^7[\",[65,\"^6:\",19,536870913]],[\"^7[\",[66,\"^48\",1775980635493,536870913]],[\"^7[\",[66,\"^7H\",\"image width\",536870913]],[\"^7[\",[66,\"^7;\",\"d3BGw\",536870913]],[\"^7[\",[66,\"^64\",124,536870913]],[\"^7[\",[66,\"^65\",\"Image width\",536870913]],[\"^7[\",[66,\"^3;\",1775980635493,536870913]],[\"^7[\",[66,\"^2T\",\"~u00000002-1857-8658-3900-000000000000\",536870913]],[\"^7[\",[66,\"^<\",\"^=\",536870913]],[\"^7[\",[66,\"^;\",\"^Z\",536870913]],[\"^7[\",[66,\"^>\",true,536870913]],[\"^7[\",[66,\"^55\",true,536870913]],[\"^7[\",[66,\"^5G\",true,536870913]],[\"^7[\",[66,\"^X\",false,536870913]],[\"^7[\",[66,\"^1K\",\"^8>\",536870913]],[\"^7[\",[67,\"^48\",1775980635494,536870913]],[\"^7[\",[67,\"^7H\",\"file remote metadata\",536870913]],[\"^7[\",[67,\"^7;\",\"d3BH0\",536870913]],[\"^7[\",[67,\"^64\",124,536870913]],[\"^7[\",[67,\"^65\",\"File remote metadata\",536870913]],[\"^7[\",[67,\"^3;\",1775980635494,536870913]],[\"^7[\",[67,\"^2T\",\"~u00000002-9907-5046-9000-000000000000\",536870913]],[\"^7[\",[67,\"^<\",\"^=\",536870913]],[\"^7[\",[67,\"^;\",\"^1;\",536870913]],[\"^7[\",[67,\"^>\",true,536870913]],[\"^7[\",[67,\"^55\",true,536870913]],[\"^7[\",[67,\"^2A\",167,536870913]],[\"^7[\",[67,\"^5G\",true,536870913]],[\"^7[\",[67,\"^X\",false,536870913]],[\"^7[\",[67,\"^1K\",\"^83\",536870913]],[\"^7[\",[68,\"^L\",127,536870913]],[\"^7[\",[68,\"^48\",1775980635492,536870913]],[\"^7[\",[68,\"^7;\",\"d3BGf\",536870913]],[\"^7[\",[68,\"^4P\",127,536870913]],[\"^7[\",[68,\"^3V\",127,536870913]],[\"^7[\",[68,\"^65\",\"List View\",536870913]],[\"^7[\",[68,\"^3;\",1775980635492,536870913]],[\"^7[\",[68,\"^2T\",\"~u00000002-1164-8285-0200-000000000000\",536870913]],[\"^7[\",[68,\"^;\",\"^1E\",536870913]],[\"^7[\",[68,\"^55\",true,536870913]],[\"^7[\",[68,\"^6:\",127,536870913]],[\"^7[\",[68,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"list\"],536870913]],[\"^7[\",[69,\"^48\",1775980635488,536870913]],[\"^7[\",[69,\"^7H\",\"node references\",536870913]],[\"^7[\",[69,\"^7;\",\"d3BFL\",536870913]],[\"^7[\",[69,\"^64\",124,536870913]],[\"^7[\",[69,\"^65\",\"Node references\",536870913]],[\"^7[\",[69,\"^3;\",1775980635488,536870913]],[\"^7[\",[69,\"^2T\",\"~u00000002-1214-4953-4900-000000000000\",536870913]],[\"^7[\",[69,\"^<\",\"^14\",536870913]],[\"^7[\",[69,\"^;\",\"^1S\",536870913]],[\"^7[\",[69,\"^>\",true,536870913]],[\"^7[\",[69,\"^C\",\"^D\",536870913]],[\"^7[\",[69,\"^55\",true,536870913]],[\"^7[\",[69,\"^5G\",true,536870913]],[\"^7[\",[69,\"^X\",false,536870913]],[\"^7[\",[69,\"^1K\",\"^88\",536870913]],[\"^7[\",[70,\"^L\",127,536870913]],[\"^7[\",[70,\"^48\",1775980635492,536870913]],[\"^7[\",[70,\"^7;\",\"d3BGe\",536870913]],[\"^7[\",[70,\"^4P\",127,536870913]],[\"^7[\",[70,\"^3V\",127,536870913]],[\"^7[\",[70,\"^65\",\"Table View\",536870913]],[\"^7[\",[70,\"^3;\",1775980635492,536870913]],[\"^7[\",[70,\"^2T\",\"~u00000002-1942-5424-0000-000000000000\",536870913]],[\"^7[\",[70,\"^;\",\"^?\",536870913]],[\"^7[\",[70,\"^55\",true,536870913]],[\"^7[\",[70,\"^6:\",127,536870913]],[\"^7[\",[70,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"table\"],536870913]],[\"^7[\",[71,\"^48\",1775980635488,536870913]],[\"^7[\",[71,\"^7H\",\"description\",536870913]],[\"^7[\",[71,\"^7;\",\"d3BFT\",536870913]],[\"^7[\",[71,\"^64\",124,536870913]],[\"^7[\",[71,\"^65\",\"Description\",536870913]],[\"^7[\",[71,\"^3;\",1775980635488,536870913]],[\"^7[\",[71,\"^2T\",\"~u00000002-3362-3620-0000-000000000000\",536870913]],[\"^7[\",[71,\"^<\",\"^=\",536870913]],[\"^7[\",[71,\"^;\",\"^2A\",536870913]],[\"^7[\",[71,\"^>\",true,536870913]],[\"^7[\",[71,\"^C\",\"^D\",536870913]],[\"^7[\",[71,\"^55\",true,536870913]],[\"^7[\",[71,\"^X\",true,536870913]],[\"^7[\",[71,\"^1K\",\"^89\",536870913]],[\"^7[\",[72,\"^48\",1775980635495,536870913]],[\"^7[\",[72,\"^7H\",\"recycle original page\",536870913]],[\"^7[\",[72,\"^7;\",\"d3BHJ\",536870913]],[\"^7[\",[72,\"^64\",124,536870913]],[\"^7[\",[72,\"^65\",\"Recycle original page\",536870913]],[\"^7[\",[72,\"^3;\",1775980635495,536870913]],[\"^7[\",[72,\"^2T\",\"~u00000002-1658-7897-9900-000000000000\",536870913]],[\"^7[\",[72,\"^<\",\"^=\",536870913]],[\"^7[\",[72,\"^;\",\"^2Q\",536870913]],[\"^7[\",[72,\"^>\",true,536870913]],[\"^7[\",[72,\"^C\",\"^D\",536870913]],[\"^7[\",[72,\"^55\",true,536870913]],[\"^7[\",[72,\"^5G\",true,536870913]],[\"^7[\",[72,\"^X\",false,536870913]],[\"^7[\",[72,\"^1K\",\"^80\",536870913]],[\"^7[\",[73,\"^48\",1775980635490,536870913]],[\"^7[\",[73,\"^7H\",\"excluded references\",536870913]],[\"^7[\",[73,\"^7;\",\"d3BFz\",536870913]],[\"^7[\",[73,\"^64\",124,536870913]],[\"^7[\",[73,\"^65\",\"Excluded references\",536870913]],[\"^7[\",[73,\"^3;\",1775980635490,536870913]],[\"^7[\",[73,\"^2T\",\"~u00000002-2426-7588-9000-000000000000\",536870913]],[\"^7[\",[73,\"^<\",\"^14\",536870913]],[\"^7[\",[73,\"^;\",\"^35\",536870913]],[\"^7[\",[73,\"^>\",true,536870913]],[\"^7[\",[73,\"^C\",\"^D\",536870913]],[\"^7[\",[73,\"^55\",true,536870913]],[\"^7[\",[73,\"^5G\",true,536870913]],[\"^7[\",[73,\"^1K\",\"^80\",536870913]],[\"^7[\",[74,\"^48\",1775980635494,536870913]],[\"^7[\",[74,\"^7H\",\"history scalar value\",536870913]],[\"^7[\",[74,\"^7;\",\"d3BHE\",536870913]],[\"^7[\",[74,\"^64\",124,536870913]],[\"^7[\",[74,\"^65\",\"History scalar value\",536870913]],[\"^7[\",[74,\"^3;\",1775980635494,536870913]],[\"^7[\",[74,\"^2T\",\"~u00000002-2393-3777-5000-000000000000\",536870913]],[\"^7[\",[74,\"^<\",\"^=\",536870913]],[\"^7[\",[74,\"^;\",\"^3@\",536870913]],[\"^7[\",[74,\"^>\",true,536870913]],[\"^7[\",[74,\"^55\",true,536870913]],[\"^7[\",[74,\"^5G\",true,536870913]],[\"^7[\",[74,\"^1K\",\"^8C\",536870913]],[\"^7[\",[75,\"^48\",1775980635491,536870913]],[\"^7[\",[75,\"^7H\",\"deadline\",536870913]],[\"^7[\",[75,\"^7;\",\"d3BGJ\",536870913]],[\"^7[\",[75,\"^64\",124,536870913]],[\"^7[\",[75,\"^65\",\"Deadline\",536870913]],[\"^7[\",[75,\"^3;\",1775980635491,536870913]],[\"^7[\",[75,\"^2T\",\"~u00000002-1685-9016-0400-000000000000\",536870913]],[\"^7[\",[75,\"^<\",\"^=\",536870913]],[\"^7[\",[75,\"^;\",\"^1J\",536870913]],[\"^7[\",[75,\"^>\",true,536870913]],[\"^7[\",[75,\"^55\",true,536870913]],[\"^7[\",[75,\"^2A\",166,536870913]],[\"^7[\",[75,\"^3P\",true,536870913]],[\"^7[\",[75,\"^X\",true,536870913]],[\"^7[\",[75,\"^1K\",\"^8<\",536870913]],[\"^7[\",[75,\"^@\",\"~:block-below\",536870913]],[\"^7[\",[76,\"^L\",19,536870913]],[\"^7[\",[76,\"^48\",1775980635490,536870913]],[\"^7[\",[76,\"^7;\",\"d3BFr\",536870913]],[\"^7[\",[76,\"^4P\",19,536870913]],[\"^7[\",[76,\"^3V\",19,536870913]],[\"^7[\",[76,\"^65\",\"green\",536870913]],[\"^7[\",[76,\"^3;\",1775980635490,536870913]],[\"^7[\",[76,\"^2T\",\"~u00000002-1992-2016-2600-000000000000\",536870913]],[\"^7[\",[76,\"^;\",\"^2=\",536870913]],[\"^7[\",[76,\"^55\",true,536870913]],[\"^7[\",[76,\"^6:\",19,536870913]],[\"^7[\",[77,\"^48\",1775980635491,536870913]],[\"^7[\",[77,\"^7H\",\"choice classes\",536870913]],[\"^7[\",[77,\"^7;\",\"d3BG4\",536870913]],[\"^7[\",[77,\"^64\",124,536870913]],[\"^7[\",[77,\"^65\",\"Choice classes\",536870913]],[\"^7[\",[77,\"^3;\",1775980635491,536870913]],[\"^7[\",[77,\"^2T\",\"~u00000002-7629-6621-9000-000000000000\",536870913]],[\"^7[\",[77,\"^<\",\"^14\",536870913]],[\"^7[\",[77,\"^;\",\"^4:\",536870913]],[\"^7[\",[77,\"^>\",true,536870913]],[\"^7[\",[77,\"^C\",\"^D\",536870913]],[\"^7[\",[77,\"^55\",true,536870913]],[\"^7[\",[77,\"^5G\",true,536870913]],[\"^7[\",[77,\"^X\",false,536870913]],[\"^7[\",[77,\"^1K\",\"^8;\",536870913]],[\"^7[\",[77,\"^44\",\"~:never\",536870913]],[\"^7[\",[78,\"^48\",1775980635496,536870913]],[\"^7[\",[78,\"^7H\",\"quote\",536870913]],[\"^7[\",[78,\"^64\",14,536870913]],[\"^7[\",[78,\"^65\",\"Quote\",536870913]],[\"^7[\",[78,\"^3;\",1775980635496,536870913]],[\"^7[\",[78,\"^2T\",\"~u00000002-1176-1666-1700-000000000000\",536870913]],[\"^7[\",[78,\"^;\",\"^2K\",536870913]],[\"^7[\",[78,\"^55\",true,536870913]],[\"^7[\",[78,\"^3K\",89,536870913]],[\"^7[\",[78,\"^1X\",true,536870913]],[\"^7[\",[78,\"^4J\",37,536870913]],[\"^7[\",[79,\"^48\",1775980635489,536870913]],[\"^7[\",[79,\"^7H\",\"non ref type default value\",536870913]],[\"^7[\",[79,\"^7;\",\"d3BFW\",536870913]],[\"^7[\",[79,\"^64\",124,536870913]],[\"^7[\",[79,\"^65\",\"Non ref type default value\",536870913]],[\"^7[\",[79,\"^3;\",1775980635489,536870913]],[\"^7[\",[79,\"^2T\",\"~u00000002-1595-7230-1400-000000000000\",536870913]],[\"^7[\",[79,\"^<\",\"^=\",536870913]],[\"^7[\",[79,\"^;\",\"^29\",536870913]],[\"^7[\",[79,\"^>\",true,536870913]],[\"^7[\",[79,\"^55\",true,536870913]],[\"^7[\",[79,\"^5G\",true,536870913]],[\"^7[\",[79,\"^X\",false,536870913]],[\"^7[\",[79,\"^1K\",\"^8C\",536870913]],[\"^7[\",[79,\"^44\",\"^81\",536870913]],[\"^7[\",[80,\"^48\",1775980635494,536870913]],[\"^7[\",[80,\"^7H\",\"last visit page\",536870913]],[\"^7[\",[80,\"^7;\",\"d3BGz\",536870913]],[\"^7[\",[80,\"^64\",124,536870913]],[\"^7[\",[80,\"^65\",\"Last visit page\",536870913]],[\"^7[\",[80,\"^3;\",1775980635494,536870913]],[\"^7[\",[80,\"^2T\",\"~u00000002-2107-8035-3500-000000000000\",536870913]],[\"^7[\",[80,\"^<\",\"^=\",536870913]],[\"^7[\",[80,\"^;\",\"^4Y\",536870913]],[\"^7[\",[80,\"^>\",true,536870913]],[\"^7[\",[80,\"^55\",true,536870913]],[\"^7[\",[80,\"^5G\",true,536870913]],[\"^7[\",[80,\"^X\",false,536870913]],[\"^7[\",[80,\"^1K\",\"^8>\",536870913]],[\"^7[\",[81,\"^48\",1775980635495,536870913]],[\"^7[\",[81,\"^7H\",\"used template\",536870913]],[\"^7[\",[81,\"^7;\",\"d3BHN\",536870913]],[\"^7[\",[81,\"^64\",124,536870913]],[\"^7[\",[81,\"^65\",\"Used template\",536870913]],[\"^7[\",[81,\"^3;\",1775980635495,536870913]],[\"^7[\",[81,\"^2T\",\"~u00000002-9803-6990-6000-000000000000\",536870913]],[\"^7[\",[81,\"^<\",\"^=\",536870913]],[\"^7[\",[81,\"^;\",\"^4K\",536870913]],[\"^7[\",[81,\"^>\",true,536870913]],[\"^7[\",[81,\"^C\",\"^D\",536870913]],[\"^7[\",[81,\"^55\",true,536870913]],[\"^7[\",[81,\"^6B\",38,536870913]],[\"^7[\",[81,\"^5G\",true,536870913]],[\"^7[\",[81,\"^X\",false,536870913]],[\"^7[\",[81,\"^1K\",\"^80\",536870913]],[\"^7[\",[82,\"^L\",13,536870913]],[\"^7[\",[82,\"^48\",1775980635492,536870913]],[\"^7[\",[82,\"^7;\",\"d3BGU\",536870913]],[\"^7[\",[82,\"^4P\",13,536870913]],[\"^7[\",[82,\"^3V\",13,536870913]],[\"^7[\",[82,\"^65\",\"Month\",536870913]],[\"^7[\",[82,\"^3;\",1775980635492,536870913]],[\"^7[\",[82,\"^2T\",\"~u00000002-2073-3937-9700-000000000000\",536870913]],[\"^7[\",[82,\"^;\",\"^5C\",536870913]],[\"^7[\",[82,\"^55\",true,536870913]],[\"^7[\",[82,\"^6:\",13,536870913]],[\"^7[\",[83,\"^48\",1775980635494,536870913]],[\"^7[\",[83,\"^7H\",\"history value\",536870913]],[\"^7[\",[83,\"^7;\",\"d3BHD\",536870913]],[\"^7[\",[83,\"^64\",124,536870913]],[\"^7[\",[83,\"^65\",\"History value\",536870913]],[\"^7[\",[83,\"^3;\",1775980635494,536870913]],[\"^7[\",[83,\"^2T\",\"~u00000002-5131-3603-7000-000000000000\",536870913]],[\"^7[\",[83,\"^<\",\"^=\",536870913]],[\"^7[\",[83,\"^;\",\"^5P\",536870913]],[\"^7[\",[83,\"^>\",true,536870913]],[\"^7[\",[83,\"^C\",\"^D\",536870913]],[\"^7[\",[83,\"^55\",true,536870913]],[\"^7[\",[83,\"^5G\",true,536870913]],[\"^7[\",[83,\"^1K\",\"^88\",536870913]],[\"^7[\",[84,\"^48\",1775980635493,536870913]],[\"^7[\",[84,\"^7H\",\"view filters\",536870913]],[\"^7[\",[84,\"^7;\",\"d3BGm\",536870913]],[\"^7[\",[84,\"^64\",124,536870913]],[\"^7[\",[84,\"^65\",\"View filters\",536870913]],[\"^7[\",[84,\"^3;\",1775980635493,536870913]],[\"^7[\",[84,\"^2T\",\"~u00000002-1702-3936-3300-000000000000\",536870913]],[\"^7[\",[84,\"^<\",\"^=\",536870913]],[\"^7[\",[84,\"^;\",\"^5Y\",536870913]],[\"^7[\",[84,\"^>\",true,536870913]],[\"^7[\",[84,\"^55\",true,536870913]],[\"^7[\",[84,\"^5G\",true,536870913]],[\"^7[\",[84,\"^X\",false,536870913]],[\"^7[\",[84,\"^1K\",\"^83\",536870913]],[\"^7[\",[85,\"^48\",1775980635496,536870913]],[\"^7[\",[85,\"^7H\",\"journal\",536870913]],[\"^7[\",[85,\"^64\",14,536870913]],[\"^7[\",[85,\"^65\",\"Journal\",536870913]],[\"^7[\",[85,\"^3;\",1775980635496,536870913]],[\"^7[\",[85,\"^2T\",\"~u00000002-1979-7410-8100-000000000000\",536870913]],[\"^7[\",[85,\"^;\",\"^6<\",536870913]],[\"^7[\",[85,\"^55\",true,536870913]],[\"^7[\",[85,\"^3K\",104,536870913]],[\"^7[\",[85,\"^53\",\"MMM do, yyyy\",536870913]],[\"^7[\",[86,\"^48\",1775980635486,536870913]],[\"^7[\",[86,\"^7H\",\"built in?\",536870913]],[\"^7[\",[86,\"^7;\",\"d3BF7\",536870913]],[\"^7[\",[86,\"^64\",124,536870913]],[\"^7[\",[86,\"^65\",\"Built in?\",536870913]],[\"^7[\",[86,\"^3;\",1775980635486,536870913]],[\"^7[\",[86,\"^2T\",\"~u00000002-1125-9581-6000-000000000000\",536870913]],[\"^7[\",[86,\"^<\",\"^=\",536870913]],[\"^7[\",[86,\"^;\",\"^55\",536870913]],[\"^7[\",[86,\"^>\",true,536870913]],[\"^7[\",[86,\"^55\",true,536870913]],[\"^7[\",[86,\"^5G\",true,536870913]],[\"^7[\",[86,\"^1K\",\"^8:\",536870913]],[\"^7[\",[87,\"^48\",1775980635496,536870913]],[\"^7[\",[87,\"^7H\",\"whiteboard\",536870913]],[\"^7[\",[87,\"^64\",14,536870913]],[\"^7[\",[87,\"^65\",\"Whiteboard\",536870913]],[\"^7[\",[87,\"^3;\",1775980635496,536870913]],[\"^7[\",[87,\"^2T\",\"~u00000002-1013-6984-5200-000000000000\",536870913]],[\"^7[\",[87,\"^;\",\"^67\",536870913]],[\"^7[\",[87,\"^55\",true,536870913]],[\"^7[\",[87,\"^3K\",104,536870913]],[\"^7[\",[88,\"^48\",1775980635494,536870913]],[\"^7[\",[88,\"^7H\",\"asset alignment\",536870913]],[\"^7[\",[88,\"^7;\",\"d3BH3\",536870913]],[\"^7[\",[88,\"^64\",124,536870913]],[\"^7[\",[88,\"^65\",\"Asset alignment\",536870913]],[\"^7[\",[88,\"^3;\",1775980635494,536870913]],[\"^7[\",[88,\"^2T\",\"~u00000002-7135-0412-8000-000000000000\",536870913]],[\"^7[\",[88,\"^<\",\"^=\",536870913]],[\"^7[\",[88,\"^;\",\"^:\",536870913]],[\"^7[\",[88,\"^>\",true,536870913]],[\"^7[\",[88,\"^55\",true,536870913]],[\"^7[\",[88,\"^5G\",true,536870913]],[\"^7[\",[88,\"^X\",false,536870913]],[\"^7[\",[88,\"^1K\",\"^8?\",536870913]],[\"^7[\",[89,\"^48\",1775980635496,536870913]],[\"^7[\",[89,\"^7H\",\"root tag\",536870913]],[\"^7[\",[89,\"^64\",14,536870913]],[\"^7[\",[89,\"^65\",\"Root Tag\",536870913]],[\"^7[\",[89,\"^3;\",1775980635496,536870913]],[\"^7[\",[89,\"^2T\",\"~u00000002-2737-8382-7000-000000000000\",536870913]],[\"^7[\",[89,\"^;\",\"^5M\",536870913]],[\"^7[\",[89,\"^55\",true,536870913]],[\"^7[\",[90,\"^48\",1775980635489,536870913]],[\"^7[\",[90,\"^7H\",\"heading\",536870913]],[\"^7[\",[90,\"^7;\",\"d3BFk\",536870913]],[\"^7[\",[90,\"^64\",124,536870913]],[\"^7[\",[90,\"^65\",\"Heading\",536870913]],[\"^7[\",[90,\"^3;\",1775980635489,536870913]],[\"^7[\",[90,\"^2T\",\"~u00000002-1858-7494-1500-000000000000\",536870913]],[\"^7[\",[90,\"^<\",\"^=\",536870913]],[\"^7[\",[90,\"^;\",\"^2L\",536870913]],[\"^7[\",[90,\"^>\",true,536870913]],[\"^7[\",[90,\"^55\",true,536870913]],[\"^7[\",[90,\"^5G\",true,536870913]],[\"^7[\",[90,\"^1K\",\"^8C\",536870913]],[\"^7[\",[91,\"^48\",1775980635490,536870913]],[\"^7[\",[91,\"^7H\",\"annotation image\",536870913]],[\"^7[\",[91,\"^7;\",\"d3BFv\",536870913]],[\"^7[\",[91,\"^64\",124,536870913]],[\"^7[\",[91,\"^65\",\"Annotation image\",536870913]],[\"^7[\",[91,\"^3;\",1775980635490,536870913]],[\"^7[\",[91,\"^2T\",\"~u00000002-1377-6700-9000-000000000000\",536870913]],[\"^7[\",[91,\"^<\",\"^=\",536870913]],[\"^7[\",[91,\"^;\",\"^M\",536870913]],[\"^7[\",[91,\"^>\",true,536870913]],[\"^7[\",[91,\"^C\",\"^D\",536870913]],[\"^7[\",[91,\"^55\",true,536870913]],[\"^7[\",[91,\"^5G\",true,536870913]],[\"^7[\",[91,\"^1K\",\"^88\",536870913]],[\"^7[\",[92,\"^48\",1775980635487,536870913]],[\"^7[\",[92,\"^7H\",\"property view context\",536870913]],[\"^7[\",[92,\"^7;\",\"d3BFB\",536870913]],[\"^7[\",[92,\"^64\",124,536870913]],[\"^7[\",[92,\"^65\",\"Property view context\",536870913]],[\"^7[\",[92,\"^3;\",1775980635487,536870913]],[\"^7[\",[92,\"^2T\",\"~u00000002-1547-3958-2800-000000000000\",536870913]],[\"^7[\",[92,\"^<\",\"^=\",536870913]],[\"^7[\",[92,\"^;\",\"^44\",536870913]],[\"^7[\",[92,\"^>\",true,536870913]],[\"^7[\",[92,\"^55\",true,536870913]],[\"^7[\",[92,\"^5G\",true,536870913]],[\"^7[\",[92,\"^1K\",\"^8?\",536870913]],[\"^7[\",[93,\"^48\",1775980635492,536870913]],[\"^7[\",[93,\"^7H\",\"view feature type\",536870913]],[\"^7[\",[93,\"^7;\",\"d3BGh\",536870913]],[\"^7[\",[93,\"^64\",124,536870913]],[\"^7[\",[93,\"^65\",\"View Feature Type\",536870913]],[\"^7[\",[93,\"^3;\",1775980635492,536870913]],[\"^7[\",[93,\"^2T\",\"~u00000002-9391-4187-1000-000000000000\",536870913]],[\"^7[\",[93,\"^<\",\"^=\",536870913]],[\"^7[\",[93,\"^;\",\"^5<\",536870913]],[\"^7[\",[93,\"^>\",true,536870913]],[\"^7[\",[93,\"^55\",true,536870913]],[\"^7[\",[93,\"^5G\",true,536870913]],[\"^7[\",[93,\"^X\",false,536870913]],[\"^7[\",[93,\"^1K\",\"^8?\",536870913]],[\"^7[\",[94,\"^L\",32,536870913]],[\"^7[\",[94,\"^48\",1775980635491,536870913]],[\"^7[\",[94,\"^7;\",\"d3BGF\",536870913]],[\"^7[\",[94,\"^4P\",32,536870913]],[\"^7[\",[94,\"^3V\",32,536870913]],[\"^7[\",[94,\"^65\",\"Low\",536870913]],[\"^7[\",[94,\"^3;\",1775980635491,536870913]],[\"^7[\",[94,\"^2T\",\"~u00000002-2107-4537-4800-000000000000\",536870913]],[\"^7[\",[94,\"^;\",\"^63\",536870913]],[\"^7[\",[94,\"^55\",true,536870913]],[\"^7[\",[94,\"^6:\",32,536870913]],[\"^7[\",[94,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlLow\"],536870913]],[\"^7[\",[95,\"^48\",1775980635491,536870913]],[\"^7[\",[95,\"^7H\",\"repeating recur frequency\",536870913]],[\"^7[\",[95,\"^7;\",\"d3BGN\",536870913]],[\"^7[\",[95,\"^64\",124,536870913]],[\"^7[\",[95,\"^65\",\"Repeating recur frequency\",536870913]],[\"^7[\",[95,\"^3;\",1775980635491,536870913]],[\"^7[\",[95,\"^2T\",\"~u00000002-8716-1592-2000-000000000000\",536870913]],[\"^7[\",[95,\"^<\",\"^=\",536870913]],[\"^7[\",[95,\"^;\",\"^5H\",536870913]],[\"^7[\",[95,\"^>\",true,536870913]],[\"^7[\",[95,\"^C\",\"^D\",536870913]],[\"^7[\",[95,\"^55\",true,536870913]],[\"^7[\",[95,\"^5I\",175,536870913]],[\"^7[\",[95,\"^3P\",true,536870913]],[\"^7[\",[95,\"^X\",false,536870913]],[\"^7[\",[95,\"^1K\",\"~:number\",536870913]],[\"^7[\",[96,\"^48\",1775980635492,536870913]],[\"^7[\",[96,\"^7H\",\"node repeats?\",536870913]],[\"^7[\",[96,\"^7;\",\"d3BGW\",536870913]],[\"^7[\",[96,\"^64\",124,536870913]],[\"^7[\",[96,\"^65\",\"Node Repeats?\",536870913]],[\"^7[\",[96,\"^3;\",1775980635492,536870913]],[\"^7[\",[96,\"^2T\",\"~u00000002-1908-1217-8900-000000000000\",536870913]],[\"^7[\",[96,\"^<\",\"^=\",536870913]],[\"^7[\",[96,\"^;\",\"^7\",536870913]],[\"^7[\",[96,\"^>\",true,536870913]],[\"^7[\",[96,\"^55\",true,536870913]],[\"^7[\",[96,\"^5G\",true,536870913]],[\"^7[\",[96,\"^1K\",\"^8:\",536870913]],[\"^7[\",[97,\"^48\",1775980635488,536870913]],[\"^7[\",[97,\"^7H\",\"closed value property\",536870913]],[\"^7[\",[97,\"^7;\",\"d3BFO\",536870913]],[\"^7[\",[97,\"^64\",124,536870913]],[\"^7[\",[97,\"^65\",\"Closed value property\",536870913]],[\"^7[\",[97,\"^3;\",1775980635488,536870913]],[\"^7[\",[97,\"^2T\",\"~u00000002-1157-7928-1300-000000000000\",536870913]],[\"^7[\",[97,\"^<\",\"^=\",536870913]],[\"^7[\",[97,\"^;\",\"^L\",536870913]],[\"^7[\",[97,\"^>\",true,536870913]],[\"^7[\",[97,\"^C\",\"^D\",536870913]],[\"^7[\",[97,\"^55\",true,536870913]],[\"^7[\",[97,\"^5G\",true,536870913]],[\"^7[\",[97,\"^X\",false,536870913]],[\"^7[\",[97,\"^1K\",\"^88\",536870913]],[\"^7[\",[98,\"^48\",1775980635495,536870913]],[\"^7[\",[98,\"^7H\",\"reaction emoji\",536870913]],[\"^7[\",[98,\"^7;\",\"d3BHL\",536870913]],[\"^7[\",[98,\"^64\",124,536870913]],[\"^7[\",[98,\"^65\",\"Reaction emoji\",536870913]],[\"^7[\",[98,\"^3;\",1775980635495,536870913]],[\"^7[\",[98,\"^2T\",\"~u00000002-9877-5864-5000-000000000000\",536870913]],[\"^7[\",[98,\"^<\",\"^=\",536870913]],[\"^7[\",[98,\"^;\",\"^10\",536870913]],[\"^7[\",[98,\"^>\",true,536870913]],[\"^7[\",[98,\"^55\",true,536870913]],[\"^7[\",[98,\"^5G\",true,536870913]],[\"^7[\",[98,\"^X\",false,536870913]],[\"^7[\",[98,\"^1K\",\"^82\",536870913]],[\"^7[\",[99,\"^L\",32,536870913]],[\"^7[\",[99,\"^48\",1775980635491,536870913]],[\"^7[\",[99,\"^7;\",\"d3BGH\",536870913]],[\"^7[\",[99,\"^4P\",32,536870913]],[\"^7[\",[99,\"^3V\",32,536870913]],[\"^7[\",[99,\"^65\",\"High\",536870913]],[\"^7[\",[99,\"^3;\",1775980635491,536870913]],[\"^7[\",[99,\"^2T\",\"~u00000002-5672-2766-8000-000000000000\",536870913]],[\"^7[\",[99,\"^;\",\"^1=\",536870913]],[\"^7[\",[99,\"^55\",true,536870913]],[\"^7[\",[99,\"^6:\",32,536870913]],[\"^7[\",[99,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlHigh\"],536870913]],[\"^7[\",[100,\"^L\",19,536870913]],[\"^7[\",[100,\"^48\",1775980635490,536870913]],[\"^7[\",[100,\"^7;\",\"d3BFs\",536870913]],[\"^7[\",[100,\"^4P\",19,536870913]],[\"^7[\",[100,\"^3V\",19,536870913]],[\"^7[\",[100,\"^65\",\"blue\",536870913]],[\"^7[\",[100,\"^3;\",1775980635490,536870913]],[\"^7[\",[100,\"^2T\",\"~u00000002-1836-0512-4100-000000000000\",536870913]],[\"^7[\",[100,\"^;\",\"^1G\",536870913]],[\"^7[\",[100,\"^55\",true,536870913]],[\"^7[\",[100,\"^6:\",19,536870913]],[\"^7[\",[101,\"^48\",1775980635493,536870913]],[\"^7[\",[101,\"^7H\",\"view ordered columns\",536870913]],[\"^7[\",[101,\"^7;\",\"d3BGo\",536870913]],[\"^7[\",[101,\"^64\",124,536870913]],[\"^7[\",[101,\"^65\",\"View ordered columns\",536870913]],[\"^7[\",[101,\"^3;\",1775980635493,536870913]],[\"^7[\",[101,\"^2T\",\"~u00000002-1485-5871-0000-000000000000\",536870913]],[\"^7[\",[101,\"^<\",\"^=\",536870913]],[\"^7[\",[101,\"^;\",\"^1U\",536870913]],[\"^7[\",[101,\"^>\",true,536870913]],[\"^7[\",[101,\"^55\",true,536870913]],[\"^7[\",[101,\"^5G\",true,536870913]],[\"^7[\",[101,\"^X\",false,536870913]],[\"^7[\",[101,\"^1K\",\"~:coll\",536870913]],[\"^7[\",[102,\"^48\",1775980635489,536870913]],[\"^7[\",[102,\"^7H\",\"background color\",536870913]],[\"^7[\",[102,\"^7;\",\"d3BFj\",536870913]],[\"^7[\",[102,\"^64\",124,536870913]],[\"^7[\",[102,\"^65\",\"Background color\",536870913]],[\"^7[\",[102,\"^3;\",1775980635489,536870913]],[\"^7[\",[102,\"^2T\",\"~u00000002-5191-2660-6000-000000000000\",536870913]],[\"^7[\",[102,\"^<\",\"^=\",536870913]],[\"^7[\",[102,\"^;\",\"^26\",536870913]],[\"^7[\",[102,\"^>\",true,536870913]],[\"^7[\",[102,\"^C\",\"^D\",536870913]],[\"^7[\",[102,\"^55\",true,536870913]],[\"^7[\",[102,\"^5G\",true,536870913]],[\"^7[\",[102,\"^1K\",\"^89\",536870913]],[\"^7[\",[103,\"^48\",1775980635496,536870913]],[\"^7[\",[103,\"^7H\",\"cards\",536870913]],[\"^7[\",[103,\"^64\",14,536870913]],[\"^7[\",[103,\"^65\",\"Cards\",536870913]],[\"^7[\",[103,\"^3;\",1775980635496,536870913]],[\"^7[\",[103,\"^2T\",\"~u00000002-1284-2651-6700-000000000000\",536870913]],[\"^7[\",[103,\"^;\",\"^2C\",536870913]],[\"^7[\",[103,\"^55\",true,536870913]],[\"^7[\",[103,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"search\"],536870913]],[\"^7[\",[103,\"^3K\",142,536870913]],[\"^7[\",[104,\"^48\",1775980635496,536870913]],[\"^7[\",[104,\"^7H\",\"page\",536870913]],[\"^7[\",[104,\"^64\",14,536870913]],[\"^7[\",[104,\"^65\",\"Page\",536870913]],[\"^7[\",[104,\"^3;\",1775980635496,536870913]],[\"^7[\",[104,\"^2T\",\"~u00000002-1484-3403-2900-000000000000\",536870913]],[\"^7[\",[104,\"^;\",\"^2S\",536870913]],[\"^7[\",[104,\"^55\",true,536870913]],[\"^7[\",[104,\"^3K\",89,536870913]],[\"^7[\",[105,\"^L\",13,536870913]],[\"^7[\",[105,\"^48\",1775980635492,536870913]],[\"^7[\",[105,\"^7;\",\"d3BGT\",536870913]],[\"^7[\",[105,\"^4P\",13,536870913]],[\"^7[\",[105,\"^3V\",13,536870913]],[\"^7[\",[105,\"^65\",\"Week\",536870913]],[\"^7[\",[105,\"^3;\",1775980635492,536870913]],[\"^7[\",[105,\"^2T\",\"~u00000002-2130-9244-4900-000000000000\",536870913]],[\"^7[\",[105,\"^;\",\"^U\",536870913]],[\"^7[\",[105,\"^55\",true,536870913]],[\"^7[\",[105,\"^6:\",13,536870913]],[\"^7[\",[106,\"^48\",1775980635490,536870913]],[\"^7[\",[106,\"^7H\",\"tldraw shape\",536870913]],[\"^7[\",[106,\"^7;\",\"d3BG1\",536870913]],[\"^7[\",[106,\"^64\",124,536870913]],[\"^7[\",[106,\"^65\",\"Tldraw Shape\",536870913]],[\"^7[\",[106,\"^3;\",1775980635490,536870913]],[\"^7[\",[106,\"^2T\",\"~u00000002-1313-2454-2000-000000000000\",536870913]],[\"^7[\",[106,\"^<\",\"^=\",536870913]],[\"^7[\",[106,\"^;\",\"^3B\",536870913]],[\"^7[\",[106,\"^>\",true,536870913]],[\"^7[\",[106,\"^55\",true,536870913]],[\"^7[\",[106,\"^5G\",true,536870913]],[\"^7[\",[106,\"^1K\",\"^83\",536870913]],[\"^7[\",[107,\"^48\",1775980635493,536870913]],[\"^7[\",[107,\"^7H\",\"image height\",536870913]],[\"^7[\",[107,\"^7;\",\"d3BGx\",536870913]],[\"^7[\",[107,\"^64\",124,536870913]],[\"^7[\",[107,\"^65\",\"Image height\",536870913]],[\"^7[\",[107,\"^3;\",1775980635493,536870913]],[\"^7[\",[107,\"^2T\",\"~u00000002-3306-1650-4000-000000000000\",536870913]],[\"^7[\",[107,\"^<\",\"^=\",536870913]],[\"^7[\",[107,\"^;\",\"^3M\",536870913]],[\"^7[\",[107,\"^>\",true,536870913]],[\"^7[\",[107,\"^55\",true,536870913]],[\"^7[\",[107,\"^5G\",true,536870913]],[\"^7[\",[107,\"^X\",false,536870913]],[\"^7[\",[107,\"^1K\",\"^8>\",536870913]],[\"^7[\",[108,\"^48\",1775980635488,536870913]],[\"^7[\",[108,\"^7H\",\"code mode\",536870913]],[\"^7[\",[108,\"^7;\",\"d3BFU\",536870913]],[\"^7[\",[108,\"^64\",124,536870913]],[\"^7[\",[108,\"^65\",\"Code Mode\",536870913]],[\"^7[\",[108,\"^3;\",1775980635488,536870913]],[\"^7[\",[108,\"^2T\",\"~u00000002-8578-9716-5000-000000000000\",536870913]],[\"^7[\",[108,\"^<\",\"^=\",536870913]],[\"^7[\",[108,\"^;\",\"^40\",536870913]],[\"^7[\",[108,\"^>\",true,536870913]],[\"^7[\",[108,\"^55\",true,536870913]],[\"^7[\",[108,\"^5G\",true,536870913]],[\"^7[\",[108,\"^X\",false,536870913]],[\"^7[\",[108,\"^1K\",\"^82\",536870913]],[\"^7[\",[108,\"^44\",\"^8@\",536870913]],[\"^7[\",[109,\"^48\",1775980635495,536870913]],[\"^7[\",[109,\"^7H\",\"node created by\",536870913]],[\"^7[\",[109,\"^7;\",\"d3BHF\",536870913]],[\"^7[\",[109,\"^64\",124,536870913]],[\"^7[\",[109,\"^65\",\"Node created by\",536870913]],[\"^7[\",[109,\"^3;\",1775980635495,536870913]],[\"^7[\",[109,\"^2T\",\"~u00000002-8544-3390-8000-000000000000\",536870913]],[\"^7[\",[109,\"^<\",\"^=\",536870913]],[\"^7[\",[109,\"^;\",\"^4<\",536870913]],[\"^7[\",[109,\"^>\",true,536870913]],[\"^7[\",[109,\"^C\",\"^D\",536870913]],[\"^7[\",[109,\"^55\",true,536870913]],[\"^7[\",[109,\"^5G\",true,536870913]],[\"^7[\",[109,\"^1K\",\"^88\",536870913]],[\"^7[\",[110,\"^48\",1775980635491,536870913]],[\"^7[\",[110,\"^7H\",\"choice exclusions\",536870913]],[\"^7[\",[110,\"^7;\",\"d3BG5\",536870913]],[\"^7[\",[110,\"^64\",124,536870913]],[\"^7[\",[110,\"^65\",\"Choice exclusions\",536870913]],[\"^7[\",[110,\"^3;\",1775980635491,536870913]],[\"^7[\",[110,\"^2T\",\"~u00000002-1233-5229-4600-000000000000\",536870913]],[\"^7[\",[110,\"^<\",\"^14\",536870913]],[\"^7[\",[110,\"^;\",\"^13\",536870913]],[\"^7[\",[110,\"^>\",true,536870913]],[\"^7[\",[110,\"^C\",\"^D\",536870913]],[\"^7[\",[110,\"^55\",true,536870913]],[\"^7[\",[110,\"^5G\",true,536870913]],[\"^7[\",[110,\"^X\",false,536870913]],[\"^7[\",[110,\"^1K\",\"^80\",536870913]],[\"^7[\",[110,\"^44\",\"^8E\",536870913]],[\"^7[\",[111,\"^;\",\"^R\",536870913]],[\"^7[\",[111,\"^28\",[\"^ \",\"^8A\",65,\"^8B\",24],536870913]],[\"^7[\",[112,\"^48\",1775980635490,536870913]],[\"^7[\",[112,\"^7H\",\"title format\",536870913]],[\"^7[\",[112,\"^7;\",\"d3BG2\",536870913]],[\"^7[\",[112,\"^64\",124,536870913]],[\"^7[\",[112,\"^65\",\"Title Format\",536870913]],[\"^7[\",[112,\"^3;\",1775980635490,536870913]],[\"^7[\",[112,\"^2T\",\"~u00000002-1536-4979-5400-000000000000\",536870913]],[\"^7[\",[112,\"^<\",\"^=\",536870913]],[\"^7[\",[112,\"^;\",\"^53\",536870913]],[\"^7[\",[112,\"^>\",true,536870913]],[\"^7[\",[112,\"^55\",true,536870913]],[\"^7[\",[112,\"^X\",false,536870913]],[\"^7[\",[112,\"^1K\",\"^82\",536870913]],[\"^7[\",[113,\"^48\",1775980635490,536870913]],[\"^7[\",[113,\"^7H\",\"included references\",536870913]],[\"^7[\",[113,\"^7;\",\"d3BFy\",536870913]],[\"^7[\",[113,\"^64\",124,536870913]],[\"^7[\",[113,\"^65\",\"Included references\",536870913]],[\"^7[\",[113,\"^3;\",1775980635490,536870913]],[\"^7[\",[113,\"^2T\",\"~u00000002-1680-5777-0300-000000000000\",536870913]],[\"^7[\",[113,\"^<\",\"^14\",536870913]],[\"^7[\",[113,\"^;\",\"^2F\",536870913]],[\"^7[\",[113,\"^>\",true,536870913]],[\"^7[\",[113,\"^C\",\"^D\",536870913]],[\"^7[\",[113,\"^55\",true,536870913]],[\"^7[\",[113,\"^5G\",true,536870913]],[\"^7[\",[113,\"^1K\",\"^80\",536870913]],[\"^7[\",[114,\"^L\",26,536870913]],[\"^7[\",[114,\"^48\",1775980635491,536870913]],[\"^7[\",[114,\"^7;\",\"d3BG9\",536870913]],[\"^7[\",[114,\"^4P\",26,536870913]],[\"^7[\",[114,\"^3V\",26,536870913]],[\"^7[\",[114,\"^65\",\"Todo\",536870913]],[\"^7[\",[114,\"^3;\",1775980635491,536870913]],[\"^7[\",[114,\"^2T\",\"~u00000002-1615-5853-7700-000000000000\",536870913]],[\"^7[\",[114,\"^;\",\"^5E\",536870913]],[\"^7[\",[114,\"^55\",true,536870913]],[\"^7[\",[114,\"^4T\",false,536870913]],[\"^7[\",[114,\"^6:\",26,536870913]],[\"^7[\",[114,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Todo\"],536870913]],[\"^7[\",[115,\"^48\",1775980635494,536870913]],[\"^7[\",[115,\"^7H\",\"history property\",536870913]],[\"^7[\",[115,\"^7;\",\"d3BHC\",536870913]],[\"^7[\",[115,\"^64\",124,536870913]],[\"^7[\",[115,\"^65\",\"History property\",536870913]],[\"^7[\",[115,\"^3;\",1775980635494,536870913]],[\"^7[\",[115,\"^2T\",\"~u00000002-1600-4090-8200-000000000000\",536870913]],[\"^7[\",[115,\"^<\",\"^=\",536870913]],[\"^7[\",[115,\"^;\",\"^5R\",536870913]],[\"^7[\",[115,\"^>\",true,536870913]],[\"^7[\",[115,\"^C\",\"^D\",536870913]],[\"^7[\",[115,\"^55\",true,536870913]],[\"^7[\",[115,\"^5G\",true,536870913]],[\"^7[\",[115,\"^1K\",\"^81\",536870913]],[\"^7[\",[116,\"^48\",1775980635495,536870913]],[\"^7[\",[116,\"^7H\",\"recycle original parent\",536870913]],[\"^7[\",[116,\"^7;\",\"d3BHI\",536870913]],[\"^7[\",[116,\"^64\",124,536870913]],[\"^7[\",[116,\"^65\",\"Recycle original parent\",536870913]],[\"^7[\",[116,\"^3;\",1775980635495,536870913]],[\"^7[\",[116,\"^2T\",\"~u00000002-4970-0399-4000-000000000000\",536870913]],[\"^7[\",[116,\"^<\",\"^=\",536870913]],[\"^7[\",[116,\"^;\",\"^5[\",536870913]],[\"^7[\",[116,\"^>\",true,536870913]],[\"^7[\",[116,\"^C\",\"^D\",536870913]],[\"^7[\",[116,\"^55\",true,536870913]],[\"^7[\",[116,\"^5G\",true,536870913]],[\"^7[\",[116,\"^X\",false,536870913]],[\"^7[\",[116,\"^1K\",\"^80\",536870913]],[\"^7[\",[117,\"^48\",1775980635490,536870913]],[\"^7[\",[117,\"^7H\",\"annotation data\",536870913]],[\"^7[\",[117,\"^7;\",\"d3BFw\",536870913]],[\"^7[\",[117,\"^64\",124,536870913]],[\"^7[\",[117,\"^65\",\"Annotation data\",536870913]],[\"^7[\",[117,\"^3;\",1775980635490,536870913]],[\"^7[\",[117,\"^2T\",\"~u00000002-5458-2940-2000-000000000000\",536870913]],[\"^7[\",[117,\"^<\",\"^=\",536870913]],[\"^7[\",[117,\"^;\",\"^6>\",536870913]],[\"^7[\",[117,\"^>\",true,536870913]],[\"^7[\",[117,\"^55\",true,536870913]],[\"^7[\",[117,\"^5G\",true,536870913]],[\"^7[\",[117,\"^1K\",\"^83\",536870913]],[\"^7[\",[118,\"^48\",1775980635492,536870913]],[\"^7[\",[118,\"^7H\",\"view group by property\",536870913]],[\"^7[\",[118,\"^7;\",\"d3BGi\",536870913]],[\"^7[\",[118,\"^64\",124,536870913]],[\"^7[\",[118,\"^65\",\"View group by property\",536870913]],[\"^7[\",[118,\"^3;\",1775980635492,536870913]],[\"^7[\",[118,\"^2T\",\"~u00000002-8092-1623-6000-000000000000\",536870913]],[\"^7[\",[118,\"^<\",\"^=\",536870913]],[\"^7[\",[118,\"^;\",\"^43\",536870913]],[\"^7[\",[118,\"^>\",true,536870913]],[\"^7[\",[118,\"^C\",\"^D\",536870913]],[\"^7[\",[118,\"^55\",true,536870913]],[\"^7[\",[118,\"^5G\",true,536870913]],[\"^7[\",[118,\"^X\",false,536870913]],[\"^7[\",[118,\"^1K\",\"^81\",536870913]],[\"^7[\",[119,\"^48\",1775980635487,536870913]],[\"^7[\",[119,\"^7H\",\"hide this property or page\",536870913]],[\"^7[\",[119,\"^7;\",\"d3BF8\",536870913]],[\"^7[\",[119,\"^64\",124,536870913]],[\"^7[\",[119,\"^65\",\"Hide this property or page\",536870913]],[\"^7[\",[119,\"^3;\",1775980635487,536870913]],[\"^7[\",[119,\"^2T\",\"~u00000002-6836-5746-0000-000000000000\",536870913]],[\"^7[\",[119,\"^<\",\"^=\",536870913]],[\"^7[\",[119,\"^;\",\"^5G\",536870913]],[\"^7[\",[119,\"^>\",true,536870913]],[\"^7[\",[119,\"^55\",true,536870913]],[\"^7[\",[119,\"^5G\",true,536870913]],[\"^7[\",[119,\"^1K\",\"^8:\",536870913]],[\"^7[\",[120,\"^48\",1775980635488,536870913]],[\"^7[\",[120,\"^7H\",\"node links to\",536870913]],[\"^7[\",[120,\"^7;\",\"d3BFM\",536870913]],[\"^7[\",[120,\"^64\",124,536870913]],[\"^7[\",[120,\"^65\",\"Node links to\",536870913]],[\"^7[\",[120,\"^3;\",1775980635488,536870913]],[\"^7[\",[120,\"^2T\",\"~u00000002-1872-3999-9300-000000000000\",536870913]],[\"^7[\",[120,\"^<\",\"^=\",536870913]],[\"^7[\",[120,\"^;\",\"^2I\",536870913]],[\"^7[\",[120,\"^>\",true,536870913]],[\"^7[\",[120,\"^C\",\"^D\",536870913]],[\"^7[\",[120,\"^55\",true,536870913]],[\"^7[\",[120,\"^5G\",true,536870913]],[\"^7[\",[120,\"^X\",false,536870913]],[\"^7[\",[120,\"^1K\",\"^88\",536870913]],[\"^7[\",[121,\"^48\",1775980635489,536870913]],[\"^7[\",[121,\"^7H\",\"tag properties\",536870913]],[\"^7[\",[121,\"^7;\",\"d3BFZ\",536870913]],[\"^7[\",[121,\"^64\",124,536870913]],[\"^7[\",[121,\"^65\",\"Tag Properties\",536870913]],[\"^7[\",[121,\"^3;\",1775980635489,536870913]],[\"^7[\",[121,\"^2T\",\"~u00000002-2123-7120-5000-000000000000\",536870913]],[\"^7[\",[121,\"^<\",\"^14\",536870913]],[\"^7[\",[121,\"^;\",\"^4J\",536870913]],[\"^7[\",[121,\"^>\",true,536870913]],[\"^7[\",[121,\"^C\",\"^D\",536870913]],[\"^7[\",[121,\"^55\",true,536870913]],[\"^7[\",[121,\"^X\",true,536870913]],[\"^7[\",[121,\"^1K\",\"^81\",536870913]],[\"^7[\",[121,\"^44\",\"^8E\",536870913]],[\"^7[\",[122,\"^48\",1775980635488,536870913]],[\"^7[\",[122,\"^7H\",\"node order\",536870913]],[\"^7[\",[122,\"^7;\",\"d3BFI\",536870913]],[\"^7[\",[122,\"^64\",124,536870913]],[\"^7[\",[122,\"^65\",\"Node order\",536870913]],[\"^7[\",[122,\"^3;\",1775980635488,536870913]],[\"^7[\",[122,\"^2T\",\"~u00000002-1429-2824-3700-000000000000\",536870913]],[\"^7[\",[122,\"^<\",\"^=\",536870913]],[\"^7[\",[122,\"^;\",\"^7;\",536870913]],[\"^7[\",[122,\"^>\",true,536870913]],[\"^7[\",[122,\"^55\",true,536870913]],[\"^7[\",[122,\"^5G\",true,536870913]],[\"^7[\",[122,\"^X\",false,536870913]],[\"^7[\",[122,\"^1K\",\"^82\",536870913]],[\"^7[\",[123,\"^48\",1775980635494,536870913]],[\"^7[\",[123,\"^7H\",\"enable property history\",536870913]],[\"^7[\",[123,\"^7;\",\"d3BH9\",536870913]],[\"^7[\",[123,\"^64\",124,536870913]],[\"^7[\",[123,\"^65\",\"Enable property history\",536870913]],[\"^7[\",[123,\"^3;\",1775980635494,536870913]],[\"^7[\",[123,\"^2T\",\"~u00000002-8058-5960-2000-000000000000\",536870913]],[\"^7[\",[123,\"^<\",\"^=\",536870913]],[\"^7[\",[123,\"^;\",\"^4L\",536870913]],[\"^7[\",[123,\"^>\",true,536870913]],[\"^7[\",[123,\"^55\",true,536870913]],[\"^7[\",[123,\"^2A\",162,536870913]],[\"^7[\",[123,\"^X\",true,536870913]],[\"^7[\",[123,\"^1K\",\"^8:\",536870913]],[\"^7[\",[123,\"^44\",\"^81\",536870913]],[\"^7[\",[124,\"^48\",1775980635496,536870913]],[\"^7[\",[124,\"^7H\",\"property\",536870913]],[\"^7[\",[124,\"^64\",14,536870913]],[\"^7[\",[124,\"^65\",\"Property\",536870913]],[\"^7[\",[124,\"^3;\",1775980635496,536870913]],[\"^7[\",[124,\"^2T\",\"~u00000002-1038-7670-4800-000000000000\",536870913]],[\"^7[\",[124,\"^;\",\"^30\",536870913]],[\"^7[\",[124,\"^55\",true,536870913]],[\"^7[\",[124,\"^3K\",89,536870913]],[\"^7[\",[125,\"^48\",1775980635489,536870913]],[\"^7[\",[125,\"^7H\",\"page tags\",536870913]],[\"^7[\",[125,\"^7;\",\"d3BFh\",536870913]],[\"^7[\",[125,\"^64\",124,536870913]],[\"^7[\",[125,\"^65\",\"Page Tags\",536870913]],[\"^7[\",[125,\"^3;\",1775980635489,536870913]],[\"^7[\",[125,\"^2T\",\"~u00000002-2133-5311-8500-000000000000\",536870913]],[\"^7[\",[125,\"^<\",\"^14\",536870913]],[\"^7[\",[125,\"^;\",\"^4V\",536870913]],[\"^7[\",[125,\"^>\",true,536870913]],[\"^7[\",[125,\"^C\",\"^D\",536870913]],[\"^7[\",[125,\"^55\",true,536870913]],[\"^7[\",[125,\"^2A\",164,536870913]],[\"^7[\",[125,\"^X\",true,536870913]],[\"^7[\",[125,\"^1K\",\"^84\",536870913]],[\"^7[\",[125,\"^44\",\"^84\",536870913]],[\"^7[\",[126,\"^L\",26,536870913]],[\"^7[\",[126,\"^48\",1775980635491,536870913]],[\"^7[\",[126,\"^7;\",\"d3BG8\",536870913]],[\"^7[\",[126,\"^4P\",26,536870913]],[\"^7[\",[126,\"^3V\",26,536870913]],[\"^7[\",[126,\"^65\",\"Backlog\",536870913]],[\"^7[\",[126,\"^3;\",1775980635491,536870913]],[\"^7[\",[126,\"^2T\",\"~u00000002-7233-3491-0000-000000000000\",536870913]],[\"^7[\",[126,\"^;\",\"^4@\",536870913]],[\"^7[\",[126,\"^55\",true,536870913]],[\"^7[\",[126,\"^6:\",26,536870913]],[\"^7[\",[126,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Backlog\"],536870913]],[\"^7[\",[127,\"^48\",1775980635492,536870913]],[\"^7[\",[127,\"^7H\",\"view type\",536870913]],[\"^7[\",[127,\"^7;\",\"d3BGd\",536870913]],[\"^7[\",[127,\"^64\",124,536870913]],[\"^7[\",[127,\"^65\",\"View Type\",536870913]],[\"^7[\",[127,\"^3;\",1775980635492,536870913]],[\"^7[\",[127,\"^2T\",\"~u00000002-2182-3760-7000-000000000000\",536870913]],[\"^7[\",[127,\"^<\",\"^=\",536870913]],[\"^7[\",[127,\"^;\",\"^2J\",536870913]],[\"^7[\",[127,\"^>\",true,536870913]],[\"^7[\",[127,\"^C\",\"^D\",536870913]],[\"^7[\",[127,\"^55\",true,536870913]],[\"^7[\",[127,\"^5I\",70,536870913]],[\"^7[\",[127,\"^5G\",true,536870913]],[\"^7[\",[127,\"^X\",false,536870913]],[\"^7[\",[127,\"^1K\",\"^89\",536870913]],[\"^7[\",[128,\"^48\",1775980635493,536870913]],[\"^7[\",[128,\"^7H\",\"this view belongs to\",536870913]],[\"^7[\",[128,\"^7;\",\"d3BGr\",536870913]],[\"^7[\",[128,\"^64\",124,536870913]],[\"^7[\",[128,\"^65\",\"This view belongs to\",536870913]],[\"^7[\",[128,\"^3;\",1775980635493,536870913]],[\"^7[\",[128,\"^2T\",\"~u00000002-3627-4319-0000-000000000000\",536870913]],[\"^7[\",[128,\"^<\",\"^=\",536870913]],[\"^7[\",[128,\"^;\",\"^9\",536870913]],[\"^7[\",[128,\"^>\",true,536870913]],[\"^7[\",[128,\"^C\",\"^D\",536870913]],[\"^7[\",[128,\"^55\",true,536870913]],[\"^7[\",[128,\"^5G\",true,536870913]],[\"^7[\",[128,\"^X\",false,536870913]],[\"^7[\",[128,\"^1K\",\"^80\",536870913]],[\"^7[\",[129,\"^48\",1775980635494,536870913]],[\"^7[\",[129,\"^7H\",\"user avatar\",536870913]],[\"^7[\",[129,\"^7;\",\"d3BH8\",536870913]],[\"^7[\",[129,\"^64\",124,536870913]],[\"^7[\",[129,\"^65\",\"User Avatar\",536870913]],[\"^7[\",[129,\"^3;\",1775980635494,536870913]],[\"^7[\",[129,\"^2T\",\"~u00000002-4165-4885-8000-000000000000\",536870913]],[\"^7[\",[129,\"^<\",\"^=\",536870913]],[\"^7[\",[129,\"^;\",\"^O\",536870913]],[\"^7[\",[129,\"^>\",true,536870913]],[\"^7[\",[129,\"^55\",true,536870913]],[\"^7[\",[129,\"^5G\",false,536870913]],[\"^7[\",[129,\"^X\",true,536870913]],[\"^7[\",[129,\"^1K\",\"^82\",536870913]],[\"^7[\",[130,\"^48\",1775980635494,536870913]],[\"^7[\",[130,\"^7H\",\"user name\",536870913]],[\"^7[\",[130,\"^7;\",\"d3BH6\",536870913]],[\"^7[\",[130,\"^64\",124,536870913]],[\"^7[\",[130,\"^65\",\"User Name\",536870913]],[\"^7[\",[130,\"^3;\",1775980635494,536870913]],[\"^7[\",[130,\"^2T\",\"~u00000002-1360-0260-1600-000000000000\",536870913]],[\"^7[\",[130,\"^<\",\"^=\",536870913]],[\"^7[\",[130,\"^;\",\"^E\",536870913]],[\"^7[\",[130,\"^>\",true,536870913]],[\"^7[\",[130,\"^55\",true,536870913]],[\"^7[\",[130,\"^5G\",false,536870913]],[\"^7[\",[130,\"^X\",true,536870913]],[\"^7[\",[130,\"^1K\",\"^82\",536870913]],[\"^7[\",[131,\"^48\",1775980635493,536870913]],[\"^7[\",[131,\"^7H\",\"file size\",536870913]],[\"^7[\",[131,\"^7;\",\"d3BGv\",536870913]],[\"^7[\",[131,\"^64\",124,536870913]],[\"^7[\",[131,\"^65\",\"File Size\",536870913]],[\"^7[\",[131,\"^3;\",1775980635493,536870913]],[\"^7[\",[131,\"^2T\",\"~u00000002-1167-8621-9000-000000000000\",536870913]],[\"^7[\",[131,\"^<\",\"^=\",536870913]],[\"^7[\",[131,\"^;\",\"^1?\",536870913]],[\"^7[\",[131,\"^>\",true,536870913]],[\"^7[\",[131,\"^55\",true,536870913]],[\"^7[\",[131,\"^5G\",true,536870913]],[\"^7[\",[131,\"^X\",false,536870913]],[\"^7[\",[131,\"^1K\",\"^8>\",536870913]],[\"^7[\",[132,\"^48\",1775980635489,536870913]],[\"^7[\",[132,\"^7H\",\"bidirectional property title\",536870913]],[\"^7[\",[132,\"^7;\",\"d3BFa\",536870913]],[\"^7[\",[132,\"^64\",124,536870913]],[\"^7[\",[132,\"^65\",\"Bidirectional property title\",536870913]],[\"^7[\",[132,\"^3;\",1775980635489,536870913]],[\"^7[\",[132,\"^2T\",\"~u00000002-6050-5418-7000-000000000000\",536870913]],[\"^7[\",[132,\"^<\",\"^=\",536870913]],[\"^7[\",[132,\"^;\",\"^1I\",536870913]],[\"^7[\",[132,\"^>\",true,536870913]],[\"^7[\",[132,\"^55\",true,536870913]],[\"^7[\",[132,\"^X\",true,536870913]],[\"^7[\",[132,\"^1K\",\"^82\",536870913]],[\"^7[\",[132,\"^44\",\"^8;\",536870913]],[\"^7[\",[133,\"^48\",1775980635489,536870913]],[\"^7[\",[133,\"^7H\",\"hide from node\",536870913]],[\"^7[\",[133,\"^7;\",\"d3BFf\",536870913]],[\"^7[\",[133,\"^64\",124,536870913]],[\"^7[\",[133,\"^65\",\"Hide from Node\",536870913]],[\"^7[\",[133,\"^3;\",1775980635489,536870913]],[\"^7[\",[133,\"^2T\",\"~u00000002-2610-3727-0000-000000000000\",536870913]],[\"^7[\",[133,\"^<\",\"^=\",536870913]],[\"^7[\",[133,\"^;\",\"^1X\",536870913]],[\"^7[\",[133,\"^>\",true,536870913]],[\"^7[\",[133,\"^55\",true,536870913]],[\"^7[\",[133,\"^X\",true,536870913]],[\"^7[\",[133,\"^1K\",\"^8:\",536870913]],[\"^7[\",[133,\"^44\",\"^8;\",536870913]],[\"^7[\",[134,\"^48\",1775980635492,536870913]],[\"^7[\",[134,\"^7H\",\"repeating temporal property\",536870913]],[\"^7[\",[134,\"^7;\",\"d3BGX\",536870913]],[\"^7[\",[134,\"^64\",124,536870913]],[\"^7[\",[134,\"^65\",\"Repeating Temporal Property\",536870913]],[\"^7[\",[134,\"^3;\",1775980635492,536870913]],[\"^7[\",[134,\"^2T\",\"~u00000002-8346-1078-4000-000000000000\",536870913]],[\"^7[\",[134,\"^<\",\"^=\",536870913]],[\"^7[\",[134,\"^;\",\"^B\",536870913]],[\"^7[\",[134,\"^>\",true,536870913]],[\"^7[\",[134,\"^C\",\"^D\",536870913]],[\"^7[\",[134,\"^55\",true,536870913]],[\"^7[\",[134,\"^5G\",true,536870913]],[\"^7[\",[134,\"^1K\",\"^81\",536870913]],[\"^7[\",[135,\"^L\",13,536870913]],[\"^7[\",[135,\"^48\",1775980635492,536870913]],[\"^7[\",[135,\"^7;\",\"d3BGQ\",536870913]],[\"^7[\",[135,\"^4P\",13,536870913]],[\"^7[\",[135,\"^3V\",13,536870913]],[\"^7[\",[135,\"^65\",\"Minute\",536870913]],[\"^7[\",[135,\"^3;\",1775980635492,536870913]],[\"^7[\",[135,\"^2T\",\"~u00000002-1513-6550-8500-000000000000\",536870913]],[\"^7[\",[135,\"^;\",\"^2E\",536870913]],[\"^7[\",[135,\"^55\",true,536870913]],[\"^7[\",[135,\"^6:\",13,536870913]],[\"^7[\",[136,\"^48\",1775980635489,536870913]],[\"^7[\",[136,\"^7H\",\"query\",536870913]],[\"^7[\",[136,\"^7;\",\"d3BFg\",536870913]],[\"^7[\",[136,\"^64\",124,536870913]],[\"^7[\",[136,\"^65\",\"Query\",536870913]],[\"^7[\",[136,\"^3;\",1775980635489,536870913]],[\"^7[\",[136,\"^2T\",\"~u00000002-9741-4126-0000-000000000000\",536870913]],[\"^7[\",[136,\"^<\",\"^=\",536870913]],[\"^7[\",[136,\"^;\",\"^2X\",536870913]],[\"^7[\",[136,\"^>\",true,536870913]],[\"^7[\",[136,\"^C\",\"^D\",536870913]],[\"^7[\",[136,\"^55\",true,536870913]],[\"^7[\",[136,\"^5G\",true,536870913]],[\"^7[\",[136,\"^X\",true,536870913]],[\"^7[\",[136,\"^1K\",\"^89\",536870913]],[\"^7[\",[136,\"^44\",\"^8@\",536870913]],[\"^7[\",[137,\"^48\",1775980635494,536870913]],[\"^7[\",[137,\"^7H\",\"user email\",536870913]],[\"^7[\",[137,\"^7;\",\"d3BH7\",536870913]],[\"^7[\",[137,\"^64\",124,536870913]],[\"^7[\",[137,\"^65\",\"User Email\",536870913]],[\"^7[\",[137,\"^3;\",1775980635494,536870913]],[\"^7[\",[137,\"^2T\",\"~u00000002-1655-2060-6300-000000000000\",536870913]],[\"^7[\",[137,\"^<\",\"^=\",536870913]],[\"^7[\",[137,\"^;\",\"^38\",536870913]],[\"^7[\",[137,\"^>\",true,536870913]],[\"^7[\",[137,\"^55\",true,536870913]],[\"^7[\",[137,\"^5G\",false,536870913]],[\"^7[\",[137,\"^X\",true,536870913]],[\"^7[\",[137,\"^1K\",\"^82\",536870913]],[\"^7[\",[138,\"^48\",1775980635488,536870913]],[\"^7[\",[138,\"^7H\",\"node collapsed?\",536870913]],[\"^7[\",[138,\"^7;\",\"d3BFJ\",536870913]],[\"^7[\",[138,\"^64\",124,536870913]],[\"^7[\",[138,\"^65\",\"Node collapsed?\",536870913]],[\"^7[\",[138,\"^3;\",1775980635488,536870913]],[\"^7[\",[138,\"^2T\",\"~u00000002-2140-2109-9100-000000000000\",536870913]],[\"^7[\",[138,\"^<\",\"^=\",536870913]],[\"^7[\",[138,\"^;\",\"^3D\",536870913]],[\"^7[\",[138,\"^>\",true,536870913]],[\"^7[\",[138,\"^55\",true,536870913]],[\"^7[\",[138,\"^5G\",true,536870913]],[\"^7[\",[138,\"^X\",false,536870913]],[\"^7[\",[138,\"^1K\",\"^8:\",536870913]],[\"^7[\",[139,\"^L\",13,536870913]],[\"^7[\",[139,\"^48\",1775980635492,536870913]],[\"^7[\",[139,\"^7;\",\"d3BGR\",536870913]],[\"^7[\",[139,\"^4P\",13,536870913]],[\"^7[\",[139,\"^3V\",13,536870913]],[\"^7[\",[139,\"^65\",\"Hour\",536870913]],[\"^7[\",[139,\"^3;\",1775980635492,536870913]],[\"^7[\",[139,\"^2T\",\"~u00000002-1438-8849-5400-000000000000\",536870913]],[\"^7[\",[139,\"^;\",\"^3O\",536870913]],[\"^7[\",[139,\"^55\",true,536870913]],[\"^7[\",[139,\"^6:\",13,536870913]],[\"^7[\",[140,\"^48\",1775980635490,536870913]],[\"^7[\",[140,\"^7H\",\"tldraw page\",536870913]],[\"^7[\",[140,\"^7;\",\"d3BG0\",536870913]],[\"^7[\",[140,\"^64\",124,536870913]],[\"^7[\",[140,\"^65\",\"Tldraw Page\",536870913]],[\"^7[\",[140,\"^3;\",1775980635490,536870913]],[\"^7[\",[140,\"^2T\",\"~u00000002-3546-2145-7000-000000000000\",536870913]],[\"^7[\",[140,\"^<\",\"^=\",536870913]],[\"^7[\",[140,\"^;\",\"^42\",536870913]],[\"^7[\",[140,\"^>\",true,536870913]],[\"^7[\",[140,\"^55\",true,536870913]],[\"^7[\",[140,\"^5G\",true,536870913]],[\"^7[\",[140,\"^1K\",\"^83\",536870913]],[\"^7[\",[141,\"^;\",\"^31\",536870913]],[\"^7[\",[141,\"^28\",\"bf04d4cf5-dirty\",536870913]],[\"^7[\",[142,\"^48\",1775980635496,536870913]],[\"^7[\",[142,\"^7H\",\"query\",536870913]],[\"^7[\",[142,\"^64\",14,536870913]],[\"^7[\",[142,\"^65\",\"Query\",536870913]],[\"^7[\",[142,\"^3;\",1775980635496,536870913]],[\"^7[\",[142,\"^2T\",\"~u00000002-2324-8016-6000-000000000000\",536870913]],[\"^7[\",[142,\"^;\",\"^4I\",536870913]],[\"^7[\",[142,\"^55\",true,536870913]],[\"^7[\",[142,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"search\"],536870913]],[\"^7[\",[142,\"^3K\",89,536870913]],[\"^7[\",[142,\"^4J\",136,536870913]],[\"^7[\",[143,\"^48\",1775980635491,536870913]],[\"^7[\",[143,\"^7H\",\"choice checkbox state\",536870913]],[\"^7[\",[143,\"^7;\",\"d3BG3\",536870913]],[\"^7[\",[143,\"^64\",124,536870913]],[\"^7[\",[143,\"^65\",\"Choice checkbox state\",536870913]],[\"^7[\",[143,\"^3;\",1775980635491,536870913]],[\"^7[\",[143,\"^2T\",\"~u00000002-1272-4228-6300-000000000000\",536870913]],[\"^7[\",[143,\"^<\",\"^=\",536870913]],[\"^7[\",[143,\"^;\",\"^4T\",536870913]],[\"^7[\",[143,\"^>\",true,536870913]],[\"^7[\",[143,\"^55\",true,536870913]],[\"^7[\",[143,\"^5G\",true,536870913]],[\"^7[\",[143,\"^1K\",\"^8:\",536870913]],[\"^7[\",[144,\"^48\",1775980635489,536870913]],[\"^7[\",[144,\"^7H\",\"hide empty value\",536870913]],[\"^7[\",[144,\"^7;\",\"d3BFd\",536870913]],[\"^7[\",[144,\"^64\",124,536870913]],[\"^7[\",[144,\"^65\",\"Hide empty value\",536870913]],[\"^7[\",[144,\"^3;\",1775980635489,536870913]],[\"^7[\",[144,\"^2T\",\"~u00000002-2062-3258-9900-000000000000\",536870913]],[\"^7[\",[144,\"^<\",\"^=\",536870913]],[\"^7[\",[144,\"^;\",\"^3P\",536870913]],[\"^7[\",[144,\"^>\",true,536870913]],[\"^7[\",[144,\"^55\",true,536870913]],[\"^7[\",[144,\"^2A\",160,536870913]],[\"^7[\",[144,\"^X\",true,536870913]],[\"^7[\",[144,\"^1K\",\"^8:\",536870913]],[\"^7[\",[144,\"^44\",\"^81\",536870913]],[\"^7[\",[145,\"^48\",1775980635488,536870913]],[\"^7[\",[145,\"^7H\",\"node updated at\",536870913]],[\"^7[\",[145,\"^7;\",\"d3BFR\",536870913]],[\"^7[\",[145,\"^64\",124,536870913]],[\"^7[\",[145,\"^65\",\"Node updated at\",536870913]],[\"^7[\",[145,\"^3;\",1775980635488,536870913]],[\"^7[\",[145,\"^2T\",\"~u00000002-1516-5505-5100-000000000000\",536870913]],[\"^7[\",[145,\"^<\",\"^=\",536870913]],[\"^7[\",[145,\"^;\",\"^3;\",536870913]],[\"^7[\",[145,\"^>\",true,536870913]],[\"^7[\",[145,\"^55\",true,536870913]],[\"^7[\",[145,\"^5G\",true,536870913]],[\"^7[\",[145,\"^X\",false,536870913]],[\"^7[\",[145,\"^1K\",\"^8<\",536870913]],[\"^7[\",[146,\"^48\",1775980635493,536870913]],[\"^7[\",[146,\"^7H\",\"external url\",536870913]],[\"^7[\",[146,\"^7;\",\"d3BGt\",536870913]],[\"^7[\",[146,\"^64\",124,536870913]],[\"^7[\",[146,\"^65\",\"External URL\",536870913]],[\"^7[\",[146,\"^3;\",1775980635493,536870913]],[\"^7[\",[146,\"^2T\",\"~u00000002-1364-7751-6300-000000000000\",536870913]],[\"^7[\",[146,\"^<\",\"^=\",536870913]],[\"^7[\",[146,\"^;\",\"^20\",536870913]],[\"^7[\",[146,\"^>\",true,536870913]],[\"^7[\",[146,\"^55\",true,536870913]],[\"^7[\",[146,\"^5G\",false,536870913]],[\"^7[\",[146,\"^X\",true,536870913]],[\"^7[\",[146,\"^1K\",\"^82\",536870913]],[\"^7[\",[147,\"^48\",1775980635495,536870913]],[\"^7[\",[147,\"^7H\",\"apply template to tags\",536870913]],[\"^7[\",[147,\"^7;\",\"d3BHO\",536870913]],[\"^7[\",[147,\"^64\",124,536870913]],[\"^7[\",[147,\"^65\",\"Apply template to tags\",536870913]],[\"^7[\",[147,\"^3;\",1775980635495,536870913]],[\"^7[\",[147,\"^2T\",\"~u00000002-4291-2432-2000-000000000000\",536870913]],[\"^7[\",[147,\"^<\",\"^14\",536870913]],[\"^7[\",[147,\"^;\",\"^5T\",536870913]],[\"^7[\",[147,\"^>\",true,536870913]],[\"^7[\",[147,\"^C\",\"^D\",536870913]],[\"^7[\",[147,\"^55\",true,536870913]],[\"^7[\",[147,\"^X\",true,536870913]],[\"^7[\",[147,\"^1K\",\"^8;\",536870913]],[\"^7[\",[148,\"^L\",26,536870913]],[\"^7[\",[148,\"^48\",1775980635491,536870913]],[\"^7[\",[148,\"^7;\",\"d3BGB\",536870913]],[\"^7[\",[148,\"^4P\",26,536870913]],[\"^7[\",[148,\"^3V\",26,536870913]],[\"^7[\",[148,\"^65\",\"In Review\",536870913]],[\"^7[\",[148,\"^3;\",1775980635491,536870913]],[\"^7[\",[148,\"^2T\",\"~u00000002-1870-0014-4300-000000000000\",536870913]],[\"^7[\",[148,\"^;\",\"^62\",536870913]],[\"^7[\",[148,\"^55\",true,536870913]],[\"^7[\",[148,\"^6:\",26,536870913]],[\"^7[\",[148,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"InReview\"],536870913]],[\"^7[\",[149,\"^48\",1775980635492,536870913]],[\"^7[\",[149,\"^7H\",\"published url\",536870913]],[\"^7[\",[149,\"^7;\",\"d3BGb\",536870913]],[\"^7[\",[149,\"^64\",124,536870913]],[\"^7[\",[149,\"^65\",\"Published URL\",536870913]],[\"^7[\",[149,\"^3;\",1775980635492,536870913]],[\"^7[\",[149,\"^2T\",\"~u00000002-2045-1825-0100-000000000000\",536870913]],[\"^7[\",[149,\"^<\",\"^=\",536870913]],[\"^7[\",[149,\"^;\",\"^1[\",536870913]],[\"^7[\",[149,\"^>\",true,536870913]],[\"^7[\",[149,\"^C\",\"^D\",536870913]],[\"^7[\",[149,\"^55\",true,536870913]],[\"^7[\",[149,\"^X\",true,536870913]],[\"^7[\",[149,\"^1K\",\"~:url\",536870913]],[\"^7[\",[149,\"^44\",\"^84\",536870913]],[\"^7[\",[150,\"^L\",127,536870913]],[\"^7[\",[150,\"^48\",1775980635492,536870913]],[\"^7[\",[150,\"^7;\",\"d3BGg\",536870913]],[\"^7[\",[150,\"^4P\",127,536870913]],[\"^7[\",[150,\"^3V\",127,536870913]],[\"^7[\",[150,\"^65\",\"Gallery View\",536870913]],[\"^7[\",[150,\"^3;\",1775980635492,536870913]],[\"^7[\",[150,\"^2T\",\"~u00000002-1506-0511-2000-000000000000\",536870913]],[\"^7[\",[150,\"^;\",\"^6G\",536870913]],[\"^7[\",[150,\"^55\",true,536870913]],[\"^7[\",[150,\"^6:\",127,536870913]],[\"^7[\",[150,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"layout-grid\"],536870913]],[\"^7[\",[151,\"^2T\",\"~u00000004-1595-0218-3700-000000000000\",536870913]],[\"^7[\",[151,\"^;\",\"^5U\",536870913]],[\"^7[\",[152,\"^48\",1775980635496,536870913]],[\"^7[\",[152,\"^7H\",\"task\",536870913]],[\"^7[\",[152,\"^64\",14,536870913]],[\"^7[\",[152,\"^65\",\"Task\",536870913]],[\"^7[\",[152,\"^3;\",1775980635496,536870913]],[\"^7[\",[152,\"^2T\",\"~u00000002-1282-1814-5700-000000000000\",536870913]],[\"^7[\",[152,\"^;\",\"^4X\",536870913]],[\"^7[\",[152,\"^55\",true,536870913]],[\"^7[\",[152,\"^3K\",89,536870913]],[\"^7[\",[152,\"^4J\",26,536870913]],[\"^7[\",[152,\"^4J\",32,536870913]],[\"^7[\",[152,\"^4J\",75,536870913]],[\"^7[\",[152,\"^4J\",155,536870913]],[\"^7[\",[153,\"^L\",32,536870913]],[\"^7[\",[153,\"^48\",1775980635491,536870913]],[\"^7[\",[153,\"^7;\",\"d3BGG\",536870913]],[\"^7[\",[153,\"^4P\",32,536870913]],[\"^7[\",[153,\"^3V\",32,536870913]],[\"^7[\",[153,\"^65\",\"Medium\",536870913]],[\"^7[\",[153,\"^3;\",1775980635491,536870913]],[\"^7[\",[153,\"^2T\",\"~u00000002-1829-3222-7800-000000000000\",536870913]],[\"^7[\",[153,\"^;\",\"^75\",536870913]],[\"^7[\",[153,\"^55\",true,536870913]],[\"^7[\",[153,\"^6:\",32,536870913]],[\"^7[\",[153,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlMedium\"],536870913]],[\"^7[\",[154,\"^48\",1775980635486,536870913]],[\"^7[\",[154,\"^7H\",\"property type\",536870913]],[\"^7[\",[154,\"^7;\",\"d3BF6\",536870913]],[\"^7[\",[154,\"^64\",124,536870913]],[\"^7[\",[154,\"^65\",\"Property type\",536870913]],[\"^7[\",[154,\"^3;\",1775980635486,536870913]],[\"^7[\",[154,\"^2T\",\"~u00000002-8384-2404-0000-000000000000\",536870913]],[\"^7[\",[154,\"^<\",\"^=\",536870913]],[\"^7[\",[154,\"^;\",\"^1K\",536870913]],[\"^7[\",[154,\"^>\",true,536870913]],[\"^7[\",[154,\"^55\",true,536870913]],[\"^7[\",[154,\"^5G\",true,536870913]],[\"^7[\",[154,\"^1K\",\"^8?\",536870913]],[\"^7[\",[155,\"^48\",1775980635491,536870913]],[\"^7[\",[155,\"^7H\",\"scheduled\",536870913]],[\"^7[\",[155,\"^7;\",\"d3BGL\",536870913]],[\"^7[\",[155,\"^64\",124,536870913]],[\"^7[\",[155,\"^65\",\"Scheduled\",536870913]],[\"^7[\",[155,\"^3;\",1775980635491,536870913]],[\"^7[\",[155,\"^2T\",\"~u00000002-1644-5209-4300-000000000000\",536870913]],[\"^7[\",[155,\"^<\",\"^=\",536870913]],[\"^7[\",[155,\"^;\",\"^4W\",536870913]],[\"^7[\",[155,\"^>\",true,536870913]],[\"^7[\",[155,\"^55\",true,536870913]],[\"^7[\",[155,\"^2A\",177,536870913]],[\"^7[\",[155,\"^3P\",true,536870913]],[\"^7[\",[155,\"^X\",true,536870913]],[\"^7[\",[155,\"^1K\",\"^8<\",536870913]],[\"^7[\",[155,\"^@\",\"^8D\",536870913]],[\"^7[\",[156,\"^48\",1775980635489,536870913]],[\"^7[\",[156,\"^7H\",\"default value\",536870913]],[\"^7[\",[156,\"^7;\",\"d3BFV\",536870913]],[\"^7[\",[156,\"^64\",124,536870913]],[\"^7[\",[156,\"^65\",\"Default value\",536870913]],[\"^7[\",[156,\"^3;\",1775980635489,536870913]],[\"^7[\",[156,\"^2T\",\"~u00000002-8920-7966-2000-000000000000\",536870913]],[\"^7[\",[156,\"^<\",\"^=\",536870913]],[\"^7[\",[156,\"^;\",\"^5I\",536870913]],[\"^7[\",[156,\"^>\",true,536870913]],[\"^7[\",[156,\"^C\",\"^D\",536870913]],[\"^7[\",[156,\"^55\",true,536870913]],[\"^7[\",[156,\"^5G\",true,536870913]],[\"^7[\",[156,\"^X\",false,536870913]],[\"^7[\",[156,\"^1K\",\"^88\",536870913]],[\"^7[\",[156,\"^44\",\"^81\",536870913]],[\"^7[\",[157,\"^48\",1775980635495,536870913]],[\"^7[\",[157,\"^7H\",\"deleted by\",536870913]],[\"^7[\",[157,\"^7;\",\"d3BHH\",536870913]],[\"^7[\",[157,\"^64\",124,536870913]],[\"^7[\",[157,\"^65\",\"Deleted by\",536870913]],[\"^7[\",[157,\"^3;\",1775980635495,536870913]],[\"^7[\",[157,\"^2T\",\"~u00000002-1998-0431-1200-000000000000\",536870913]],[\"^7[\",[157,\"^<\",\"^=\",536870913]],[\"^7[\",[157,\"^;\",\"^6T\",536870913]],[\"^7[\",[157,\"^>\",true,536870913]],[\"^7[\",[157,\"^C\",\"^D\",536870913]],[\"^7[\",[157,\"^55\",true,536870913]],[\"^7[\",[157,\"^5G\",true,536870913]],[\"^7[\",[157,\"^X\",false,536870913]],[\"^7[\",[157,\"^1K\",\"^88\",536870913]],[\"^7[\",[158,\"^48\",1775980635489,536870913]],[\"^7[\",[158,\"^7H\",\"extends\",536870913]],[\"^7[\",[158,\"^7;\",\"d3BFX\",536870913]],[\"^7[\",[158,\"^64\",124,536870913]],[\"^7[\",[158,\"^65\",\"Extends\",536870913]],[\"^7[\",[158,\"^3;\",1775980635489,536870913]],[\"^7[\",[158,\"^2T\",\"~u00000002-7475-9380-3000-000000000000\",536870913]],[\"^7[\",[158,\"^<\",\"^14\",536870913]],[\"^7[\",[158,\"^;\",\"^3K\",536870913]],[\"^7[\",[158,\"^>\",true,536870913]],[\"^7[\",[158,\"^C\",\"^D\",536870913]],[\"^7[\",[158,\"^55\",true,536870913]],[\"^7[\",[158,\"^2A\",163,536870913]],[\"^7[\",[158,\"^X\",true,536870913]],[\"^7[\",[158,\"^1K\",\"^8;\",536870913]],[\"^7[\",[158,\"^44\",\"^8;\",536870913]],[\"^7[\",[159,\"^48\",1775980635493,536870913]],[\"^7[\",[159,\"^7H\",\"view sorting\",536870913]],[\"^7[\",[159,\"^7;\",\"d3BGl\",536870913]],[\"^7[\",[159,\"^64\",124,536870913]],[\"^7[\",[159,\"^65\",\"View sorting\",536870913]],[\"^7[\",[159,\"^3;\",1775980635493,536870913]],[\"^7[\",[159,\"^2T\",\"~u00000002-2081-0259-4000-000000000000\",536870913]],[\"^7[\",[159,\"^<\",\"^=\",536870913]],[\"^7[\",[159,\"^;\",\"^12\",536870913]],[\"^7[\",[159,\"^>\",true,536870913]],[\"^7[\",[159,\"^55\",true,536870913]],[\"^7[\",[159,\"^5G\",true,536870913]],[\"^7[\",[159,\"^X\",false,536870913]],[\"^7[\",[159,\"^1K\",\"^8G\",536870913]],[\"^7[\",[160,\"^48\",1775980635489,536870913]],[\"^7[\",[160,\"^7;\",\"d3BFe\",536870913]],[\"^7[\",[160,\"^4P\",144,536870913]],[\"^7[\",[160,\"^3V\",144,536870913]],[\"^7[\",[160,\"^65\",\"Hides a property's value on any node when empty e.g. when a property appears on a node through a tag.\",536870913]],[\"^7[\",[160,\"^3;\",1775980635489,536870913]],[\"^7[\",[160,\"^2T\",\"~u00000004-1118-8585-8000-000000000000\",536870913]],[\"^7[\",[160,\"^55\",true,536870913]],[\"^7[\",[160,\"^6:\",71,536870913]],[\"^7[\",[161,\"^2T\",\"~u00000004-2116-2824-5200-000000000000\",536870913]],[\"^7[\",[161,\"^1M\",\"\",536870913]],[\"^7[\",[161,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[161,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[161,\"^7I\",\"logseq/publish.js\",536870913]],[\"^7[\",[162,\"^48\",1775980635494,536870913]],[\"^7[\",[162,\"^7;\",\"d3BHA\",536870913]],[\"^7[\",[162,\"^4P\",123,536870913]],[\"^7[\",[162,\"^3V\",123,536870913]],[\"^7[\",[162,\"^65\",\"Records history anytime a property's value changes on a node.\",536870913]],[\"^7[\",[162,\"^3;\",1775980635494,536870913]],[\"^7[\",[162,\"^2T\",\"~u00000004-1438-1481-0000-000000000000\",536870913]],[\"^7[\",[162,\"^55\",true,536870913]],[\"^7[\",[162,\"^6:\",71,536870913]],[\"^7[\",[163,\"^48\",1775980635489,536870913]],[\"^7[\",[163,\"^7;\",\"d3BFY\",536870913]],[\"^7[\",[163,\"^4P\",158,536870913]],[\"^7[\",[163,\"^3V\",158,536870913]],[\"^7[\",[163,\"^65\",\"This enables tags to inherit properties from other tags\",536870913]],[\"^7[\",[163,\"^3;\",1775980635489,536870913]],[\"^7[\",[163,\"^2T\",\"~u00000004-4485-6574-6000-000000000000\",536870913]],[\"^7[\",[163,\"^55\",true,536870913]],[\"^7[\",[163,\"^6:\",71,536870913]],[\"^7[\",[164,\"^48\",1775980635489,536870913]],[\"^7[\",[164,\"^7;\",\"d3BFi\",536870913]],[\"^7[\",[164,\"^4P\",125,536870913]],[\"^7[\",[164,\"^3V\",125,536870913]],[\"^7[\",[164,\"^65\",\"Provides a way for a page to associate to another page i.e. backward compatible tagging.\",536870913]],[\"^7[\",[164,\"^3;\",1775980635489,536870913]],[\"^7[\",[164,\"^2T\",\"~u00000004-1223-8671-8000-000000000000\",536870913]],[\"^7[\",[164,\"^55\",true,536870913]],[\"^7[\",[164,\"^6:\",71,536870913]],[\"^7[\",[165,\"^48\",1775980635496,536870913]],[\"^7[\",[165,\"^7H\",\"library\",536870913]],[\"^7[\",[165,\"^64\",104,536870913]],[\"^7[\",[165,\"^65\",\"Library\",536870913]],[\"^7[\",[165,\"^3;\",1775980635496,536870913]],[\"^7[\",[165,\"^2T\",\"~u00000004-1294-7765-6000-000000000000\",536870913]],[\"^7[\",[165,\"^55\",true,536870913]],[\"^7[\",[166,\"^48\",1775980635491,536870913]],[\"^7[\",[166,\"^7;\",\"d3BGK\",536870913]],[\"^7[\",[166,\"^4P\",75,536870913]],[\"^7[\",[166,\"^3V\",75,536870913]],[\"^7[\",[166,\"^65\",\"Use it to finish something at a specific date(time).\",536870913]],[\"^7[\",[166,\"^3;\",1775980635491,536870913]],[\"^7[\",[166,\"^2T\",\"~u00000004-1356-3664-2200-000000000000\",536870913]],[\"^7[\",[166,\"^55\",true,536870913]],[\"^7[\",[166,\"^6:\",71,536870913]],[\"^7[\",[167,\"^48\",1775980635494,536870913]],[\"^7[\",[167,\"^7;\",\"d3BH1\",536870913]],[\"^7[\",[167,\"^4P\",67,536870913]],[\"^7[\",[167,\"^3V\",67,536870913]],[\"^7[\",[167,\"^65\",\"Metadata of asset in remote storage\",536870913]],[\"^7[\",[167,\"^3;\",1775980635494,536870913]],[\"^7[\",[167,\"^2T\",\"~u00000004-5450-2102-9000-000000000000\",536870913]],[\"^7[\",[167,\"^55\",true,536870913]],[\"^7[\",[167,\"^6:\",71,536870913]],[\"^7[\",[168,\"^48\",1775980635496,536870913]],[\"^7[\",[168,\"^7H\",\"quick add\",536870913]],[\"^7[\",[168,\"^64\",104,536870913]],[\"^7[\",[168,\"^65\",\"Quick add\",536870913]],[\"^7[\",[168,\"^3;\",1775980635496,536870913]],[\"^7[\",[168,\"^2T\",\"~u00000004-7336-8251-3000-000000000000\",536870913]],[\"^7[\",[168,\"^55\",true,536870913]],[\"^7[\",[168,\"^5G\",true,536870913]],[\"^7[\",[169,\"^48\",1775980635489,536870913]],[\"^7[\",[169,\"^7;\",\"d3BFc\",536870913]],[\"^7[\",[169,\"^4P\",20,536870913]],[\"^7[\",[169,\"^3V\",20,536870913]],[\"^7[\",[169,\"^65\",\"When enabled, this tag will show reverse nodes that link to the current node via properties.\",536870913]],[\"^7[\",[169,\"^3;\",1775980635489,536870913]],[\"^7[\",[169,\"^2T\",\"~u00000004-1830-0481-2500-000000000000\",536870913]],[\"^7[\",[169,\"^55\",true,536870913]],[\"^7[\",[169,\"^6:\",71,536870913]],[\"^7[\",[170,\"^48\",1775980635496,536870913]],[\"^7[\",[170,\"^7H\",\"$$$views\",536870913]],[\"^7[\",[170,\"^64\",104,536870913]],[\"^7[\",[170,\"^65\",\"$$$views\",536870913]],[\"^7[\",[170,\"^3;\",1775980635496,536870913]],[\"^7[\",[170,\"^2T\",\"~u00000004-1906-3437-5800-000000000000\",536870913]],[\"^7[\",[170,\"^55\",true,536870913]],[\"^7[\",[170,\"^5G\",true,536870913]],[\"^7[\",[171,\"^48\",1775980635496,536870913]],[\"^7[\",[171,\"^7H\",\"contents\",536870913]],[\"^7[\",[171,\"^64\",104,536870913]],[\"^7[\",[171,\"^65\",\"Contents\",536870913]],[\"^7[\",[171,\"^3;\",1775980635496,536870913]],[\"^7[\",[171,\"^2T\",\"~u00000004-1690-2597-3200-000000000000\",536870913]],[\"^7[\",[171,\"^55\",true,536870913]],[\"^7[\",[172,\"^2T\",\"~u00000004-1713-4660-3800-000000000000\",536870913]],[\"^7[\",[172,\"^1M\",\"\",536870913]],[\"^7[\",[172,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[172,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[172,\"^7I\",\"logseq/custom.css\",536870913]],[\"^7[\",[173,\"^48\",1775980635496,536870913]],[\"^7[\",[173,\"^7H\",\"$$$favorites\",536870913]],[\"^7[\",[173,\"^64\",104,536870913]],[\"^7[\",[173,\"^65\",\"$$$favorites\",536870913]],[\"^7[\",[173,\"^3;\",1775980635496,536870913]],[\"^7[\",[173,\"^2T\",\"~u00000004-1018-5888-4100-000000000000\",536870913]],[\"^7[\",[173,\"^55\",true,536870913]],[\"^7[\",[173,\"^5G\",true,536870913]],[\"^7[\",[174,\"^2T\",\"~u00000004-1335-6485-2300-000000000000\",536870913]],[\"^7[\",[174,\"^1M\",\"\",536870913]],[\"^7[\",[174,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[174,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[174,\"^7I\",\"logseq/custom.js\",536870913]],[\"^7[\",[175,\"^48\",1775980635491,536870913]],[\"^7[\",[175,\"^7;\",\"d3BGO\",536870913]],[\"^7[\",[175,\"^4P\",95,536870913]],[\"^7[\",[175,\"^3V\",95,536870913]],[\"^7[\",[175,\"^3;\",1775980635491,536870913]],[\"^7[\",[175,\"^2T\",\"~u00000004-1654-1034-2600-000000000000\",536870913]],[\"^7[\",[175,\"^55\",true,536870913]],[\"^7[\",[175,\"^6:\",95,536870913]],[\"^7[\",[175,\"^T\",1,536870913]],[\"^7[\",[176,\"^2T\",\"~u00000004-3919-3813-3000-000000000000\",536870913]],[\"^7[\",[176,\"^1M\",\"{:meta/version 1\\n\\n ;; Hide empty block properties\\n ;; Default value: false\\n ;; :ui/hide-empty-properties? false\\n\\n ;; Enable tooltip preview on hover.\\n ;; Default value: true\\n :ui/enable-tooltip? true\\n\\n ;; Display brackets [[]] around page references.\\n ;; Default value: true\\n ;; :ui/show-brackets? true\\n\\n ;; Display all lines of a block when referencing ((block)).\\n ;; Default value: false\\n :ui/show-full-blocks? false\\n\\n ;; Automatically expand block references when zooming in.\\n ;; Default value: true\\n :ui/auto-expand-block-refs? true\\n\\n ;; Disable accent marks when searching.\\n ;; After changing this setting, rebuild the search index by pressing (^C ^S).\\n ;; Default value: true\\n :feature/enable-search-remove-accents? true\\n\\n ;; Enable journals.\\n ;; Default value: true\\n ;; :feature/enable-journals? true\\n\\n ;; Enable flashcards.\\n ;; Default value: true\\n ;; :feature/enable-flashcards? true\\n\\n ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query.\\n ;; Default value: false\\n ;; :feature/disable-scheduled-and-deadline-query? false\\n\\n ;; Specify the number of days displayed in the future for\\n ;; the 'scheduled tasks and deadlines' query.\\n ;; Example usage:\\n ;; Display all scheduled and deadline blocks for the next 14 days:\\n ;; :scheduled/future-days 14\\n ;; Default value: 7\\n ;; :scheduled/future-days 7\\n\\n ;; Specify the first day of the week.\\n ;; Available options:\\n ;; - integer from 0 to 6 (Monday to Sunday)\\n ;; Default value: 6 (Sunday)\\n :start-of-week 6\\n\\n ;; Specify a custom CSS import.\\n ;; This option takes precedence over the local `logseq/custom.css` file.\\n ;; Example usage:\\n ;; :custom-css-url \\\"@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');\\\"\\n\\n ;; Specify a custom JS import.\\n ;; This option takes precedence over the local `logseq/custom.js` file.\\n ;; Example usage:\\n ;; :custom-js-url \\\"https://cdn.logseq.com/custom.js\\\"\\n\\n ;; Set bullet indentation when exporting\\n ;; Available options:\\n ;; - `:eight-spaces` as eight spaces\\n ;; - `:four-spaces` as four spaces\\n ;; - `:two-spaces` as two spaces\\n ;; - `:tab` as a tab character (default)\\n ;; :export/bullet-indentation :tab\\n\\n ;; Publish all pages within the Graph\\n ;; Regardless of whether individual pages have been marked as public.\\n ;; Default value: false\\n ;; :publishing/all-pages-public? false\\n\\n ;; Define the default home page and sidebar status.\\n ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden.\\n ;; The `:page` value represents the name of the page displayed at startup.\\n ;; Available options for `:sidebar` are:\\n ;; - \\\"Contents\\\" to display the Contents page in the right sidebar.\\n ;; - A specific page name to display in the right sidebar.\\n ;; - An array of multiple pages, e.g., [\\\"Contents\\\" \\\"Page A\\\" \\\"Page B\\\"].\\n ;; If `:sidebar` remains unset, the right sidebar will stay hidden.\\n ;; Examples:\\n ;; 1. Set \\\"Changelog\\\" as the home page and display \\\"Contents\\\" in the right sidebar:\\n ;; :default-home {:page \\\"Changelog\\\", :sidebar \\\"Contents\\\"}\\n ;; 2. Set \\\"Jun 3rd, 2021\\\" as the home page without the right sidebar:\\n ;; :default-home {:page \\\"Jun 3rd, 2021\\\"}\\n ;; 3. Set \\\"home\\\" as the home page and display multiple pages in the right sidebar:\\n ;; :default-home {:page \\\"home\\\", :sidebar [\\\"Page A\\\" \\\"Page B\\\"]}\\n\\n ;; Configure custom shortcuts.\\n ;; Syntax:\\n ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`.\\n ;; 2. A space between keys represents key chords, e.g., `t s` means\\n ;; pressing `t` followed by `s`.\\n ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac.\\n ;; 4. Use false to disable a specific shortcut.\\n ;; 5. You can define multiple bindings for a single action, e.g., [\\\"ctrl+j\\\" \\\"down\\\"].\\n ;; The full list of configurable shortcuts is available at:\\n ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs\\n ;; Example:\\n ;; :shortcuts\\n ;; {:editor/new-block \\\"enter\\\"\\n ;; :editor/new-line \\\"shift+enter\\\"\\n ;; :editor/insert-link \\\"mod+shift+k\\\"\\n ;; :editor/highlight false\\n ;; :ui/toggle-settings \\\"t s\\\"\\n ;; :editor/up [\\\"ctrl+k\\\" \\\"up\\\"]\\n ;; :editor/down [\\\"ctrl+j\\\" \\\"down\\\"]\\n ;; :editor/left [\\\"ctrl+h\\\" \\\"left\\\"]\\n ;; :editor/right [\\\"ctrl+l\\\" \\\"right\\\"]}\\n :shortcuts {}\\n\\n ;; Configure the behavior of pressing Enter in document mode.\\n ;; if set to true, pressing Enter will create a new block.\\n ;; Default value: false\\n :shortcut/doc-mode-enter-for-new-block? false\\n\\n ;; Block content larger than `block/title-max-length` will not be searchable\\n ;; or editable for performance.\\n ;; Default value: 10000\\n :block/title-max-length 10000\\n\\n ;; Display command documentation on hover.\\n ;; Default value: true\\n :ui/show-command-doc? true\\n\\n ;; Display empty bullet points.\\n ;; Default value: false\\n :ui/show-empty-bullets? false\\n\\n ;; Pre-defined :view function to use with advanced queries.\\n :query/views\\n {:pprint\\n (fn [r] [:pre.code (pprint r)])}\\n\\n ;; Advanced queries `:result-transform` function.\\n ;; Transform the query result before displaying it.\\n ;; Example usage for DB graphs:\\n;; :query/result-transforms\\n;; {:sort-by-priority\\n;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority \\\"Z\\\")) result))}\\n\\n;; Queries will be displayed at the bottom of today's journal page.\\n;; Example usage:\\n;; :default-queries\\n;; {:journals []}\\n\\n ;; Add custom commands to the command palette\\n ;; Example usage:\\n ;; :commands\\n ;; [\\n ;; [\\\"js\\\" \\\"Javascript\\\"]\\n ;; [\\\"md\\\" \\\"Markdown\\\"]\\n ;; ]\\n :commands []\\n\\n ;; Enable collapsing blocks with titles but no children.\\n ;; By default, only blocks with children can be collapsed.\\n ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing\\n ;; blocks with titles (multiple lines) and content. For example:\\n ;; - block title\\n ;; block content\\n ;; Default value: false\\n :outliner/block-title-collapse-enabled? false\\n\\n ;; Macros replace texts and will make you more productive.\\n ;; Example usage:\\n ;; Change the :macros value below to:\\n ;; {\\\"poem\\\" \\\"Rose is $1, violet's $2. Life's ordered: Org assists you.\\\"}\\n ;; input \\\"{{poem red,blue}}\\\"\\n ;; becomes\\n ;; Rose is red, violet's blue. Life's ordered: Org assists you.\\n :macros {}\\n\\n ;; Configure the default expansion level for linked references.\\n ;; For example, consider the following block hierarchy:\\n ;; - a [[page]] (level 1)\\n ;; - b (level 2)\\n ;; - c (level 3)\\n ;; - d (level 4)\\n ;;\\n ;; With the default value of level 2, block b will be collapsed.\\n ;; If the level's value is set to 3, block c will be collapsed.\\n ;; Default value: 2\\n :ref/default-open-blocks-level 2\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/settings\\n ;; {:orphan-pages? true ; Default value: true\\n ;; :builtin-pages? false ; Default value: false\\n ;; :excluded-pages? false ; Default value: false\\n ;; :journal? false} ; Default value: false\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/forcesettings\\n ;; {:link-dist 180 ; Default value: 180\\n ;; :charge-strength -600 ; Default value: -600\\n ;; :charge-range 600} ; Default value: 600\\n\\n ;; Mobile photo upload configuration.\\n ;; :mobile/photo\\n ;; {:allow-editing? true\\n ;; :quality 80}\\n\\n ;; Mobile features options\\n ;; Gestures\\n ;; Example usage:\\n ;; :mobile\\n ;; {:gestures/disabled-in-block-with-tags [\\\"kanban\\\"]}\\n\\n ;; Extra CodeMirror options\\n ;; See https://codemirror.net/5/doc/manual.html#config for possible options\\n ;; Example usage:\\n ;; :editor/extra-codemirror-options\\n ;; {:lineWrapping false ; Default value: false\\n ;; :lineNumbers true ; Default value: true\\n ;; :readOnly false} ; Default value: false\\n\\n ;; Enable logical outdenting\\n ;; Default value: false\\n ;; :editor/logical-outdenting? false\\n\\n ;; Prefer pasting the file when text and a file are in the clipboard.\\n ;; Default value: false\\n ;; :editor/preferred-pasting-file? false\\n\\n ;; Quick capture templates for receiving content from other apps.\\n ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded\\n ;; by receiving content from other apps. Note: the {} cannot be omitted.\\n ;; - {time}: capture time\\n ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference\\n ;; - {text}: text that users selected before sharing.\\n ;; - {url}: URL or assets path for media files stored in Logseq.\\n ;; You can also reorder them or use only one or two of them in the template.\\n ;; You can also insert or format any text in the template, as shown in the following examples.\\n ;; :quick-capture-templates\\n ;; {:text \\\"[[quick capture]] **{time}**: {text} from {url}\\\"\\n ;; :media \\\"[[quick capture]] **{time}**: {url}\\\"}\\n\\n ;; Quick capture options.\\n ;; - insert-today? Insert the capture at the end of today's journal page (boolean).\\n ;; - redirect-page? Redirect to the quick capture page after capturing (boolean).\\n ;; - default-page The default page to capture to if insert-today? is false (string).\\n ;; :quick-capture-options\\n ;; {:insert-today? false ;; Default value: true\\n ;; :redirect-page? false ;; Default value: false\\n ;; :default-page \\\"quick capture\\\"} ;; Default page: \\\"quick capture\\\"\\n\\n }\\n\",536870913]],[\"^7[\",[176,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[176,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[176,\"^7I\",\"logseq/config.edn\",536870913]],[\"^7[\",[177,\"^48\",1775980635491,536870913]],[\"^7[\",[177,\"^7;\",\"d3BGM\",536870913]],[\"^7[\",[177,\"^4P\",155,536870913]],[\"^7[\",[177,\"^3V\",155,536870913]],[\"^7[\",[177,\"^65\",\"Use it to plan something to start at a specific date(time).\",536870913]],[\"^7[\",[177,\"^3;\",1775980635491,536870913]],[\"^7[\",[177,\"^2T\",\"~u00000004-9817-6380-0000-000000000000\",536870913]],[\"^7[\",[177,\"^55\",true,536870913]],[\"^7[\",[177,\"^6:\",71,536870913]],[\"^7[\",[178,\"^2T\",\"~u00000004-4049-4381-0000-000000000000\",536870913]],[\"^7[\",[178,\"^1M\",\"\",536870913]],[\"^7[\",[178,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[178,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[178,\"^7I\",\"logseq/publish.css\",536870913]],[\"^7[\",[179,\"^48\",1775980635496,536870913]],[\"^7[\",[179,\"^7H\",\"recycle\",536870913]],[\"^7[\",[179,\"^64\",104,536870913]],[\"^7[\",[179,\"^65\",\"Recycle\",536870913]],[\"^7[\",[179,\"^3;\",1775980635496,536870913]],[\"^7[\",[179,\"^2T\",\"~u00000004-7238-1304-3000-000000000000\",536870913]],[\"^7[\",[179,\"^55\",true,536870913]],[\"^7[\",[179,\"^5G\",true,536870913]],[\"^7[\",[180,\"^48\",1775980636002,536870913]],[\"^7[\",[180,\"^1C\",20260412,536870913]],[\"^7[\",[180,\"^7H\",\"apr 12th, 2026\",536870913]],[\"^7[\",[180,\"^1S\",58,536870913]],[\"^7[\",[180,\"^1S\",85,536870913]],[\"^7[\",[180,\"^64\",85,536870913]],[\"^7[\",[180,\"^65\",\"Apr 12th, 2026\",536870913]],[\"^7[\",[180,\"^S\",536870960,536870962]],[\"^7[\",[180,\"^3;\",1775986941534,536870913]],[\"^7[\",[180,\"^2T\",\"~u00000001-2026-0412-0000-000000000000\",536870913]],[\"^7[\",[180,\"^4<\",181,536870913]],[\"^7[\",[181,\"^48\",1775980636008,536870913]],[\"^7[\",[181,\"^7H\",\"tiensonqin\",536870913]],[\"^7[\",[181,\"^1S\",58,536870913]],[\"^7[\",[181,\"^1S\",104,536870913]],[\"^7[\",[181,\"^1S\",130,536870913]],[\"^7[\",[181,\"^1S\",137,536870913]],[\"^7[\",[181,\"^64\",104,536870913]],[\"^7[\",[181,\"^65\",\"tiensonqin\",536870913]],[\"^7[\",[181,\"^3;\",1775980636008,536870913]],[\"^7[\",[181,\"^2T\",\"~ue7cd12e2-f9f7-46ed-a17e-85d95a780003\",536870913]],[\"^7[\",[181,\"^38\",\"tienson@logseq.com\",536870913]],[\"^7[\",[181,\"^E\",\"tiensonqin\",536870913]],[\"^7[\",[182,\"^;\",\"^60\",536870913]],[\"^7[\",[182,\"^28\",\"~u952b4187-ac01-41d3-b5fb-24250ac70d56\",536870913]],[\"^7[\",[183,\"^;\",\"^3:\",536870913]],[\"^7[\",[183,\"^28\",true,536870913]],[\"^7[\",[565,\"^48\",1775980817925,536870913]],[\"^7[\",[565,\"^7H\",\"p1\",536870913]],[\"^7[\",[565,\"^7;\",\"d3BHX\",536870913]],[\"^7[\",[565,\"^1S\",58,536870913]],[\"^7[\",[565,\"^1S\",124,536870913]],[\"^7[\",[565,\"^64\",124,536870913]],[\"^7[\",[565,\"^65\",\"p1\",536870913]],[\"^7[\",[565,\"^3;\",1775980817925,536870913]],[\"^7[\",[565,\"^2T\",\"~u00000002-1157-0363-3400-000000000000\",536870913]],[\"^7[\",[565,\"^<\",\"^=\",536870913]],[\"^7[\",[565,\"^;\",\"^5J\",536870913]],[\"^7[\",[565,\"^>\",true,536870913]],[\"^7[\",[565,\"^C\",\"^D\",536870913]],[\"^7[\",[565,\"^4<\",181,536870913]],[\"^7[\",[565,\"^1K\",\"^89\",536870913]],[\"^7[\",[568,\"^48\",1775980818025,536870913]],[\"^7[\",[568,\"^7H\",\"p2\",536870913]],[\"^7[\",[568,\"^7;\",\"d3BHa\",536870913]],[\"^7[\",[568,\"^1S\",58,536870913]],[\"^7[\",[568,\"^1S\",124,536870913]],[\"^7[\",[568,\"^64\",124,536870913]],[\"^7[\",[568,\"^65\",\"p2\",536870913]],[\"^7[\",[568,\"^3;\",1775980818025,536870913]],[\"^7[\",[568,\"^2T\",\"~u00000002-6891-0936-5000-000000000000\",536870913]],[\"^7[\",[568,\"^<\",\"^=\",536870913]],[\"^7[\",[568,\"^;\",\"^3T\",536870913]],[\"^7[\",[568,\"^>\",true,536870913]],[\"^7[\",[568,\"^C\",\"^D\",536870913]],[\"^7[\",[568,\"^4<\",181,536870913]],[\"^7[\",[568,\"^1K\",\"^8F\",536870913]],[\"^7[\",[569,\"^48\",1775980818039,536870913]],[\"^7[\",[569,\"^7H\",\"p3\",536870913]],[\"^7[\",[569,\"^7;\",\"d3BHb\",536870913]],[\"^7[\",[569,\"^1S\",58,536870913]],[\"^7[\",[569,\"^1S\",104,536870913]],[\"^7[\",[569,\"^1S\",124,536870913]],[\"^7[\",[569,\"^64\",124,536870913]],[\"^7[\",[569,\"^65\",\"p3\",536870913]],[\"^7[\",[569,\"^3;\",1775980818056,536870913]],[\"^7[\",[569,\"^2T\",\"~u00000002-2093-0438-6800-000000000000\",536870913]],[\"^7[\",[569,\"^<\",\"^14\",536870913]],[\"^7[\",[569,\"^;\",\"^1N\",536870913]],[\"^7[\",[569,\"^>\",true,536870913]],[\"^7[\",[569,\"^C\",\"^D\",536870913]],[\"^7[\",[569,\"^6B\",104,536870913]],[\"^7[\",[569,\"^4<\",181,536870913]],[\"^7[\",[569,\"^1K\",\"^80\",536870913]],[\"^7[\",[573,\"^48\",1775980818095,536870913]],[\"^7[\",[573,\"^7H\",\"page 1\",536870913]],[\"^7[\",[573,\"^1S\",58,536870913]],[\"^7[\",[573,\"^1S\",104,536870913]],[\"^7[\",[573,\"^64\",104,536870913]],[\"^7[\",[573,\"^65\",\"page 1\",536870913]],[\"^7[\",[573,\"^3;\",1775980818095,536870913]],[\"^7[\",[573,\"^2T\",\"~u69db5102-b394-49cc-af0d-6e3ee0752cdf\",536870913]],[\"^7[\",[573,\"^4<\",181,536870913]],[\"^7[\",[574,\"^48\",1775980818106,536870913]],[\"^7[\",[574,\"^7H\",\"page 2\",536870913]],[\"^7[\",[574,\"^1S\",58,536870913]],[\"^7[\",[574,\"^1S\",104,536870913]],[\"^7[\",[574,\"^64\",104,536870913]],[\"^7[\",[574,\"^65\",\"page 2\",536870913]],[\"^7[\",[574,\"^3;\",1775980818106,536870913]],[\"^7[\",[574,\"^2T\",\"~u69db5103-ac5c-4d04-b098-88b435041434\",536870913]],[\"^7[\",[574,\"^4<\",181,536870913]],[\"^7[\",[577,\"^48\",1775980800102,536870913]],[\"^7[\",[577,\"^7;\",\"d3BHW\",536870913]],[\"^7[\",[577,\"^4P\",568,536870913]],[\"^7[\",[577,\"^3V\",568,536870913]],[\"^7[\",[577,\"^1S\",568,536870913]],[\"^7[\",[577,\"^3;\",1775980800102,536870913]],[\"^7[\",[577,\"^2T\",\"~u69db5100-05c8-4909-b79e-c56130f4d1e9\",536870913]],[\"^7[\",[577,\"^4<\",181,536870913]],[\"^7[\",[577,\"^6:\",568,536870913]],[\"^7[\",[577,\"^T\",10,536870913]],[\"^7[\",[593,\"^48\",1775980982459,536870913]],[\"^7[\",[593,\"^7;\",\"d3BHf\",536870913]],[\"^7[\",[593,\"^4P\",568,536870913]],[\"^7[\",[593,\"^3V\",568,536870913]],[\"^7[\",[593,\"^1S\",568,536870913]],[\"^7[\",[593,\"^3;\",1775980982459,536870913]],[\"^7[\",[593,\"^2T\",\"~u69db51b6-97ff-4bf6-9547-69dc12a2972e\",536870913]],[\"^7[\",[593,\"^4<\",181,536870913]],[\"^7[\",[593,\"^6:\",568,536870913]],[\"^7[\",[593,\"^T\",10,536870913]],[\"^7[\",[597,\"^48\",1775980958586,536870913]],[\"^7[\",[597,\"^7;\",\"d3BHe\",536870913]],[\"^7[\",[597,\"^4P\",568,536870913]],[\"^7[\",[597,\"^3V\",568,536870913]],[\"^7[\",[597,\"^1S\",568,536870913]],[\"^7[\",[597,\"^3;\",1775980958586,536870913]],[\"^7[\",[597,\"^2T\",\"~u69db519e-9f15-4a6a-a142-fe9d6e954212\",536870913]],[\"^7[\",[597,\"^4<\",181,536870913]],[\"^7[\",[597,\"^6:\",568,536870913]],[\"^7[\",[597,\"^T\",10,536870913]],[\"^7[\",[599,\"^48\",1775981040693,536870913]],[\"^7[\",[599,\"^7H\",\"page 3\",536870913]],[\"^7[\",[599,\"^1S\",58,536870913]],[\"^7[\",[599,\"^1S\",104,536870913]],[\"^7[\",[599,\"^64\",104,536870913]],[\"^7[\",[599,\"^65\",\"page 3\",536870913]],[\"^7[\",[599,\"^3;\",1775981040693,536870913]],[\"^7[\",[599,\"^2T\",\"~u69db51a5-4a8e-4ebf-992e-d31c157eaf5b\",536870913]],[\"^7[\",[599,\"^4<\",181,536870913]],[\"^7[\",[600,\"^48\",1775981040720,536870913]],[\"^7[\",[600,\"^7H\",\"p4\",536870913]],[\"^7[\",[600,\"^7;\",\"d3BHh\",536870913]],[\"^7[\",[600,\"^1S\",58,536870913]],[\"^7[\",[600,\"^1S\",124,536870913]],[\"^7[\",[600,\"^64\",124,536870913]],[\"^7[\",[600,\"^65\",\"p4\",536870913]],[\"^7[\",[600,\"^3;\",1775981028998,536870913]],[\"^7[\",[600,\"^2T\",\"~u00000002-5835-7822-8000-000000000000\",536870913]],[\"^7[\",[600,\"^<\",\"^=\",536870913]],[\"^7[\",[600,\"^;\",\"^66\",536870913]],[\"^7[\",[600,\"^>\",true,536870913]],[\"^7[\",[600,\"^C\",\"^D\",536870913]],[\"^7[\",[600,\"^4<\",181,536870913]],[\"^7[\",[600,\"^1K\",\"^89\",536870913]],[\"^7[\",[605,\"^48\",1775981057423,536870913]],[\"^7[\",[605,\"^7;\",\"Zz\",536870913]],[\"^7[\",[605,\"^4P\",170,536870913]],[\"^7[\",[605,\"^3V\",170,536870913]],[\"^7[\",[605,\"^1S\",170,536870913]],[\"^7[\",[605,\"^65\",\"All\",536870913]],[\"^7[\",[605,\"^3;\",1775981057423,536870913]],[\"^7[\",[605,\"^2T\",\"~u00000006-1867-5878-9000-000000000000\",536870913]],[\"^7[\",[605,\"^4<\",181,536870913]],[\"^7[\",[605,\"^9\",170,536870913]],[\"^7[\",[605,\"^5<\",\"~:all-pages\",536870913]],[\"^7[\",[619,\"^3D\",true,536870913]],[\"^7[\",[619,\"^48\",1775981500541,536870913]],[\"^7[\",[619,\"^7H\",\"tag1\",536870913]],[\"^7[\",[619,\"^1S\",14,536870913]],[\"^7[\",[619,\"^1S\",58,536870913]],[\"^7[\",[619,\"^1S\",89,536870913]],[\"^7[\",[619,\"^1S\",158,536870913]],[\"^7[\",[619,\"^64\",14,536870913]],[\"^7[\",[619,\"^65\",\"tag1\",536870913]],[\"^7[\",[619,\"^3;\",1775981500541,536870913]],[\"^7[\",[619,\"^2T\",\"~u69db50d9-c23a-4aa4-bb79-33d5dbe54593\",536870913]],[\"^7[\",[619,\"^;\",\"^3R\",536870913]],[\"^7[\",[619,\"^4<\",181,536870913]],[\"^7[\",[619,\"^3K\",89,536870913]],[\"^7[\",[620,\"^48\",1775981500540,536870913]],[\"^7[\",[620,\"^7H\",\"page 4\",536870913]],[\"^7[\",[620,\"^1S\",58,536870913]],[\"^7[\",[620,\"^1S\",104,536870913]],[\"^7[\",[620,\"^1S\",619,536870913]],[\"^7[\",[620,\"^64\",104,536870913]],[\"^7[\",[620,\"^64\",619,536870913]],[\"^7[\",[620,\"^65\",\"page 4\",536870913]],[\"^7[\",[620,\"^3;\",1775981500561,536870913]],[\"^7[\",[620,\"^2T\",\"~u69db53ab-5e4a-4039-b2ba-4220ca0b20cd\",536870913]],[\"^7[\",[620,\"^4<\",181,536870913]],[\"^7[\",[621,\"^48\",1775981500553,536870913]],[\"^7[\",[621,\"^7;\",\"a0\",536870913]],[\"^7[\",[621,\"^4P\",620,536870913]],[\"^7[\",[621,\"^3V\",620,536870913]],[\"^7[\",[621,\"^65\",\"test\",536870913]],[\"^7[\",[621,\"^3;\",1775981500560,536870913]],[\"^7[\",[621,\"^2T\",\"~u69db53ac-7264-4191-bac4-eee78a2ff031\",536870913]],[\"^7[\",[621,\"^4<\",181,536870913]],[\"^7[\",[622,\"^48\",1775981513870,536870913]],[\"^7[\",[622,\"^7;\",\"a0\",536870913]],[\"^7[\",[622,\"^4P\",170,536870913]],[\"^7[\",[622,\"^3V\",170,536870913]],[\"^7[\",[622,\"^1S\",619,536870913]],[\"^7[\",[622,\"^65\",\"All\",536870913]],[\"^7[\",[622,\"^3;\",1775981513870,536870913]],[\"^7[\",[622,\"^2T\",\"~u00000006-1742-3452-4000-000000000000\",536870913]],[\"^7[\",[622,\"^4<\",181,536870913]],[\"^7[\",[622,\"^9\",619,536870913]],[\"^7[\",[622,\"^5<\",\"~:class-objects\",536870913]],[\"^7[\",[1530,\"^48\",1775982125944,536870913]],[\"^7[\",[1530,\"^7H\",\"page 5\",536870913]],[\"^7[\",[1530,\"^1S\",58,536870913]],[\"^7[\",[1530,\"^1S\",104,536870913]],[\"^7[\",[1530,\"^64\",104,536870913]],[\"^7[\",[1530,\"^65\",\"page 5\",536870913]],[\"^7[\",[1530,\"^3;\",1775982125944,536870913]],[\"^7[\",[1530,\"^2T\",\"~u69db557e-1cd8-48b3-87aa-1b8a684ca17f\",536870913]],[\"^7[\",[1530,\"^4<\",181,536870913]],[\"^7[\",[1732,\"^48\",1775982384424,536870913]],[\"^7[\",[1732,\"^7H\",\"page 6\",536870913]],[\"^7[\",[1732,\"^1S\",58,536870913]],[\"^7[\",[1732,\"^1S\",104,536870913]],[\"^7[\",[1732,\"^64\",104,536870913]],[\"^7[\",[1732,\"^65\",\"page 6\",536870913]],[\"^7[\",[1732,\"^3;\",1775982413000,536870913]],[\"^7[\",[1732,\"^2T\",\"~u69db5730-ad32-46c0-bb99-f87817d4443a\",536870913]],[\"^7[\",[1732,\"^4<\",181,536870913]],[\"^7[\",[1733,\"^48\",1775982384655,536870913]],[\"^7[\",[1733,\"^7;\",\"a0\",536870913]],[\"^7[\",[1733,\"^4P\",1732,536870913]],[\"^7[\",[1733,\"^3V\",1732,536870913]],[\"^7[\",[1733,\"^65\",\"hello\",536870913]],[\"^7[\",[1733,\"^3;\",1775982389175,536870913]],[\"^7[\",[1733,\"^2T\",\"~u69db5730-c5a5-4116-89f5-26341ef44073\",536870913]],[\"^7[\",[1733,\"^4<\",181,536870913]],[\"^7[\",[1742,\"^48\",1775982389180,536870913]],[\"^7[\",[1742,\"^7;\",\"a1\",536870913]],[\"^7[\",[1742,\"^4P\",1732,536870913]],[\"^7[\",[1742,\"^3V\",1732,536870913]],[\"^7[\",[1742,\"^65\",\"fjdlafda\",536870913]],[\"^7[\",[1742,\"^3;\",1775982389685,536870913]],[\"^7[\",[1742,\"^2T\",\"~u69db5731-9fe9-4c6b-a183-ffb2e6471f45\",536870913]],[\"^7[\",[1743,\"^48\",1775982389690,536870913]],[\"^7[\",[1743,\"^7;\",\"a2\",536870913]],[\"^7[\",[1743,\"^4P\",1732,536870913]],[\"^7[\",[1743,\"^3V\",1732,536870913]],[\"^7[\",[1743,\"^65\",\"f\",536870913]],[\"^7[\",[1743,\"^3;\",1775982389754,536870913]],[\"^7[\",[1743,\"^2T\",\"~u69db5732-3643-4e28-906b-581fbbb2234a\",536870913]],[\"^7[\",[1744,\"^48\",1775982389755,536870913]],[\"^7[\",[1744,\"^7;\",\"a3\",536870913]],[\"^7[\",[1744,\"^4P\",1732,536870913]],[\"^7[\",[1744,\"^3V\",1732,536870913]],[\"^7[\",[1744,\"^65\",\"adf\",536870913]],[\"^7[\",[1744,\"^3;\",1775982389841,536870913]],[\"^7[\",[1744,\"^2T\",\"~u69db5732-9fbc-49f2-8473-db80eee4e879\",536870913]],[\"^7[\",[1745,\"^48\",1775982389843,536870913]],[\"^7[\",[1745,\"^7;\",\"a4\",536870913]],[\"^7[\",[1745,\"^4P\",1732,536870913]],[\"^7[\",[1745,\"^3V\",1732,536870913]],[\"^7[\",[1745,\"^65\",\"f\",536870913]],[\"^7[\",[1745,\"^3;\",1775982389926,536870913]],[\"^7[\",[1745,\"^2T\",\"~u69db5732-a4fa-48bc-b850-1ea2dd125db8\",536870913]],[\"^7[\",[1746,\"^48\",1775982389927,536870913]],[\"^7[\",[1746,\"^7;\",\"a5\",536870913]],[\"^7[\",[1746,\"^4P\",1732,536870913]],[\"^7[\",[1746,\"^3V\",1732,536870913]],[\"^7[\",[1746,\"^65\",\"af\",536870913]],[\"^7[\",[1746,\"^3;\",1775982390009,536870913]],[\"^7[\",[1746,\"^2T\",\"~u69db5732-c89c-439e-91db-385e7cf0884a\",536870913]],[\"^7[\",[1747,\"^48\",1775982390010,536870913]],[\"^7[\",[1747,\"^7;\",\"a6\",536870913]],[\"^7[\",[1747,\"^4P\",1732,536870913]],[\"^7[\",[1747,\"^3V\",1732,536870913]],[\"^7[\",[1747,\"^65\",\"af\",536870913]],[\"^7[\",[1747,\"^3;\",1775982390090,536870913]],[\"^7[\",[1747,\"^2T\",\"~u69db5732-389f-48f7-a3c9-9ab01e4d06b3\",536870913]],[\"^7[\",[1748,\"^48\",1775982390091,536870913]],[\"^7[\",[1748,\"^7;\",\"a7\",536870913]],[\"^7[\",[1748,\"^4P\",1732,536870913]],[\"^7[\",[1748,\"^3V\",1732,536870913]],[\"^7[\",[1748,\"^65\",\"a\",536870913]],[\"^7[\",[1748,\"^3;\",1775982390176,536870913]],[\"^7[\",[1748,\"^2T\",\"~u69db5733-aeac-4823-bd0e-ba84d7fcc3aa\",536870913]],[\"^7[\",[1749,\"^48\",1775982390178,536870913]],[\"^7[\",[1749,\"^7;\",\"a8\",536870913]],[\"^7[\",[1749,\"^4P\",1732,536870913]],[\"^7[\",[1749,\"^3V\",1732,536870913]],[\"^7[\",[1749,\"^65\",\"so cool\",536870913]],[\"^7[\",[1749,\"^3;\",1775982411832,536870913]],[\"^7[\",[1749,\"^2T\",\"~u69db5733-9bfe-49db-96cb-03fe97bce35f\",536870913]],[\"^7[\",[1749,\"^4<\",181,536870913]],[\"^7[\",[1754,\"^48\",1775982411836,536870913]],[\"^7[\",[1754,\"^7;\",\"a0\",536870913]],[\"^7[\",[1754,\"^4P\",1732,536870913]],[\"^7[\",[1754,\"^3V\",1749,536870913]],[\"^7[\",[1754,\"^65\",\"awesome work\",536870913]],[\"^7[\",[1754,\"^3;\",1775982412416,536870913]],[\"^7[\",[1754,\"^2T\",\"~u69db5739-bca6-4992-96ec-45334b44f4c4\",536870913]],[\"^7[\",[1755,\"^48\",1775982412417,536870913]],[\"^7[\",[1755,\"^7;\",\"a1\",536870913]],[\"^7[\",[1755,\"^4P\",1732,536870913]],[\"^7[\",[1755,\"^3V\",1749,536870913]],[\"^7[\",[1755,\"^65\",\"great\",536870913]],[\"^7[\",[1755,\"^3;\",1775982412496,536870913]],[\"^7[\",[1755,\"^2T\",\"~u69db573a-8ec0-46e1-bc26-d7632e93d96f\",536870913]],[\"^7[\",[1756,\"^48\",1775982412499,536870913]],[\"^7[\",[1756,\"^7;\",\"a2\",536870913]],[\"^7[\",[1756,\"^4P\",1732,536870913]],[\"^7[\",[1756,\"^3V\",1749,536870913]],[\"^7[\",[1756,\"^65\",\"sounds amazing\",536870913]],[\"^7[\",[1756,\"^3;\",1775982412747,536870913]],[\"^7[\",[1756,\"^2T\",\"~u69db573b-ec3d-4f66-8e8d-26c9ff157a6b\",536870913]],[\"^7[\",[1757,\"^48\",1775982412833,536870913]],[\"^7[\",[1757,\"^7;\",\"a3\",536870913]],[\"^7[\",[1757,\"^4P\",1732,536870913]],[\"^7[\",[1757,\"^3V\",1749,536870913]],[\"^7[\",[1757,\"^65\",\"stuff really just work\",536870913]],[\"^7[\",[1757,\"^3;\",1775982413000,536870913]],[\"^7[\",[1757,\"^2T\",\"~u69db573f-8a13-43cc-82af-060e89cfd1fa\",536870913]],[\"^7[\",[1836,\"^48\",1775983070467,536870913]],[\"^7[\",[1836,\"^7H\",\"page 7\",536870913]],[\"^7[\",[1836,\"^1S\",58,536870913]],[\"^7[\",[1836,\"^1S\",104,536870913]],[\"^7[\",[1836,\"^64\",104,536870913]],[\"^7[\",[1836,\"^65\",\"page 7\",536870913]],[\"^7[\",[1836,\"^3;\",1775983683136,536870913]],[\"^7[\",[1836,\"^2T\",\"~u69db59de-5992-4e9c-aba5-12bbae69d657\",536870913]],[\"^7[\",[1836,\"^4<\",181,536870913]],[\"^7[\",[1852,\"^48\",1775983078379,536870913]],[\"^7[\",[1852,\"^7;\",\"a0\",536870913]],[\"^7[\",[1852,\"^4P\",1836,536870913]],[\"^7[\",[1852,\"^3V\",1836,536870913]],[\"^7[\",[1852,\"^65\",\"fjdlafjldaf\",536870913]],[\"^7[\",[1852,\"^3;\",1775983078467,536870913]],[\"^7[\",[1852,\"^2T\",\"~u69db59de-1710-4353-9b7f-a6843d27c9f7\",536870913]],[\"^7[\",[1853,\"^48\",1775983078469,536870913]],[\"^7[\",[1853,\"^7;\",\"a1\",536870913]],[\"^7[\",[1853,\"^4P\",1836,536870913]],[\"^7[\",[1853,\"^3V\",1836,536870913]],[\"^7[\",[1853,\"^65\",\"da\",536870913]],[\"^7[\",[1853,\"^3;\",1775983078547,536870913]],[\"^7[\",[1853,\"^2T\",\"~u69db59df-bbd0-4c0b-b89b-40d255373829\",536870913]],[\"^7[\",[1854,\"^48\",1775983078548,536870913]],[\"^7[\",[1854,\"^7;\",\"a2\",536870913]],[\"^7[\",[1854,\"^4P\",1836,536870913]],[\"^7[\",[1854,\"^3V\",1836,536870913]],[\"^7[\",[1854,\"^65\",\"fd\",536870913]],[\"^7[\",[1854,\"^3;\",1775983078649,536870913]],[\"^7[\",[1854,\"^2T\",\"~u69db59df-ecc8-48f2-ab1d-b885294463c8\",536870913]],[\"^7[\",[1855,\"^48\",1775983078652,536870913]],[\"^7[\",[1855,\"^7;\",\"a0\",536870913]],[\"^7[\",[1855,\"^4P\",1836,536870913]],[\"^7[\",[1855,\"^3V\",1854,536870913]],[\"^7[\",[1855,\"^65\",\"aff\",536870913]],[\"^7[\",[1855,\"^3;\",1775983078804,536870913]],[\"^7[\",[1855,\"^2T\",\"~u69db59df-fbd3-4baa-b307-e439f1f660bc\",536870913]],[\"^7[\",[1856,\"^48\",1775983078806,536870913]],[\"^7[\",[1856,\"^7;\",\"a1\",536870913]],[\"^7[\",[1856,\"^4P\",1836,536870913]],[\"^7[\",[1856,\"^3V\",1854,536870913]],[\"^7[\",[1856,\"^65\",\"daqf\",536870913]],[\"^7[\",[1856,\"^3;\",1775983078892,536870913]],[\"^7[\",[1856,\"^2T\",\"~u69db59e0-6768-4059-b647-6b522367a85d\",536870913]],[\"^7[\",[1857,\"^48\",1775983078897,536870913]],[\"^7[\",[1857,\"^7;\",\"a2\",536870913]],[\"^7[\",[1857,\"^4P\",1836,536870913]],[\"^7[\",[1857,\"^3V\",1854,536870913]],[\"^7[\",[1857,\"^65\",\"f\",536870913]],[\"^7[\",[1857,\"^3;\",1775983079138,536870913]],[\"^7[\",[1857,\"^2T\",\"~u69db59e0-16b6-48d5-ae4d-c6832cec5580\",536870913]],[\"^7[\",[1858,\"^48\",1775983079140,536870913]],[\"^7[\",[1858,\"^7;\",\"a0\",536870913]],[\"^7[\",[1858,\"^4P\",1836,536870913]],[\"^7[\",[1858,\"^3V\",1857,536870913]],[\"^7[\",[1858,\"^65\",\"f\",536870913]],[\"^7[\",[1858,\"^3;\",1775983079488,536870913]],[\"^7[\",[1858,\"^2T\",\"~u69db59e0-1a63-4e46-97b2-9696843f7c47\",536870913]],[\"^7[\",[1859,\"^48\",1775983079489,536870913]],[\"^7[\",[1859,\"^7;\",\"a1\",536870913]],[\"^7[\",[1859,\"^4P\",1836,536870913]],[\"^7[\",[1859,\"^3V\",1857,536870913]],[\"^7[\",[1859,\"^65\",\"f\",536870913]],[\"^7[\",[1859,\"^3;\",1775983079572,536870913]],[\"^7[\",[1859,\"^2T\",\"~u69db59e1-da15-45cb-9f42-a87f80023c55\",536870913]],[\"^7[\",[1860,\"^48\",1775983079643,536870913]],[\"^7[\",[1860,\"^7;\",\"a0\",536870913]],[\"^7[\",[1860,\"^4P\",1836,536870913]],[\"^7[\",[1860,\"^3V\",1859,536870913]],[\"^7[\",[1860,\"^65\",\"f\",536870913]],[\"^7[\",[1860,\"^3;\",1775983079815,536870913]],[\"^7[\",[1860,\"^2T\",\"~u69db59e1-f52e-4e72-b110-c313fbd76c44\",536870913]],[\"^7[\",[1861,\"^48\",1775983079817,536870913]],[\"^7[\",[1861,\"^7;\",\"a1\",536870913]],[\"^7[\",[1861,\"^4P\",1836,536870913]],[\"^7[\",[1861,\"^3V\",1859,536870913]],[\"^7[\",[1861,\"^65\",\"f\",536870913]],[\"^7[\",[1861,\"^3;\",1775983682382,536870913]],[\"^7[\",[1861,\"^2T\",\"~u69db59e1-bc2d-49e6-ad28-ade10a3e506c\",536870913]],[\"^7[\",[2033,\"^48\",1775983682383,536870913]],[\"^7[\",[2033,\"^7;\",\"a0V\",536870913]],[\"^7[\",[2033,\"^4P\",1836,536870913]],[\"^7[\",[2033,\"^3V\",1859,536870913]],[\"^7[\",[2033,\"^65\",\"fd\",536870913]],[\"^7[\",[2033,\"^3;\",1775983682880,536870913]],[\"^7[\",[2033,\"^2T\",\"~u69db59e1-5216-491c-b6ff-36ba8fb23f4d\",536870913]],[\"^7[\",[2034,\"^48\",1775983682881,536870913]],[\"^7[\",[2034,\"^7;\",\"a0l\",536870913]],[\"^7[\",[2034,\"^4P\",1836,536870913]],[\"^7[\",[2034,\"^3V\",1859,536870913]],[\"^7[\",[2034,\"^65\",\"af\",536870913]],[\"^7[\",[2034,\"^3;\",1775983682974,536870913]],[\"^7[\",[2034,\"^2T\",\"~u69db59e1-b11f-4372-9155-50825d6fd4ed\",536870913]],[\"^7[\",[2035,\"^48\",1775983682975,536870913]],[\"^7[\",[2035,\"^7;\",\"a0t\",536870913]],[\"^7[\",[2035,\"^4P\",1836,536870913]],[\"^7[\",[2035,\"^3V\",1859,536870913]],[\"^7[\",[2035,\"^65\",\"asf\",536870913]],[\"^7[\",[2035,\"^3;\",1775983683045,536870913]],[\"^7[\",[2035,\"^2T\",\"~u69db59e2-58f7-4f9e-aa44-34b587b0a687\",536870913]],[\"^7[\",[2036,\"^48\",1775983683046,536870913]],[\"^7[\",[2036,\"^7;\",\"a0x\",536870913]],[\"^7[\",[2036,\"^4P\",1836,536870913]],[\"^7[\",[2036,\"^3V\",1859,536870913]],[\"^7[\",[2036,\"^65\",\"sf\",536870913]],[\"^7[\",[2036,\"^3;\",1775983683135,536870913]],[\"^7[\",[2036,\"^2T\",\"~u69db59e2-7991-45be-ba25-1cdb2e3519a9\",536870913]],[\"^7[\",[2037,\"^48\",1775983683137,536870913]],[\"^7[\",[2037,\"^7;\",\"a0z\",536870913]],[\"^7[\",[2037,\"^4P\",1836,536870913]],[\"^7[\",[2037,\"^3V\",1859,536870913]],[\"^7[\",[2037,\"^65\",\"\",536870913]],[\"^7[\",[2037,\"^3;\",1775983683137,536870913]],[\"^7[\",[2037,\"^2T\",\"~u69db59e2-1a51-43ba-8a3b-ceb25f6aa499\",536870913]],[\"^7[\",[2109,\"^48\",1775983689295,536870913]],[\"^7[\",[2109,\"^7H\",\"page 9\",536870913]],[\"^7[\",[2109,\"^1S\",58,536870913]],[\"^7[\",[2109,\"^1S\",104,536870913]],[\"^7[\",[2109,\"^64\",104,536870913]],[\"^7[\",[2109,\"^65\",\"page 9\",536870913]],[\"^7[\",[2109,\"^3;\",1775983693372,536870913]],[\"^7[\",[2109,\"^2T\",\"~u69db5c49-fb5d-4f7a-90a9-cd294f0aef44\",536870913]],[\"^7[\",[2109,\"^4<\",181,536870913]],[\"^7[\",[2110,\"^48\",1775983689528,536870913]],[\"^7[\",[2110,\"^7;\",\"a0\",536870913]],[\"^7[\",[2110,\"^4P\",2109,536870913]],[\"^7[\",[2110,\"^3V\",2109,536870913]],[\"^7[\",[2110,\"^65\",\"fjldafjlas\",536870913]],[\"^7[\",[2110,\"^3;\",1775983690319,536870913]],[\"^7[\",[2110,\"^2T\",\"~u69db5c49-9f9e-49e8-9ead-2c36ec84031b\",536870913]],[\"^7[\",[2110,\"^4<\",181,536870913]],[\"^7[\",[2111,\"^48\",1775983690320,536870913]],[\"^7[\",[2111,\"^7;\",\"a0\",536870913]],[\"^7[\",[2111,\"^4P\",2109,536870913]],[\"^7[\",[2111,\"^3V\",2110,536870913]],[\"^7[\",[2111,\"^65\",\"f\",536870913]],[\"^7[\",[2111,\"^3;\",1775983690437,536870913]],[\"^7[\",[2111,\"^2T\",\"~u69db5c4a-c58e-4a90-9cd6-4f692890f42a\",536870913]],[\"^7[\",[2111,\"^4<\",181,536870913]],[\"^7[\",[2112,\"^48\",1775983690514,536870913]],[\"^7[\",[2112,\"^7;\",\"a0\",536870913]],[\"^7[\",[2112,\"^4P\",2109,536870913]],[\"^7[\",[2112,\"^3V\",2111,536870913]],[\"^7[\",[2112,\"^65\",\"f\",536870913]],[\"^7[\",[2112,\"^3;\",1775983690873,536870913]],[\"^7[\",[2112,\"^2T\",\"~u69db5c4a-0212-4c6f-bee0-24b88a12deee\",536870913]],[\"^7[\",[2112,\"^4<\",181,536870913]],[\"^7[\",[2113,\"^48\",1775983690916,536870913]],[\"^7[\",[2113,\"^7;\",\"Zz\",536870913]],[\"^7[\",[2113,\"^4P\",2109,536870913]],[\"^7[\",[2113,\"^3V\",2111,536870913]],[\"^7[\",[2113,\"^65\",\"\",536870913]],[\"^7[\",[2113,\"^3;\",1775983690916,536870913]],[\"^7[\",[2113,\"^2T\",\"~u69db5c4a-2364-4fb7-bdd1-223745f4c69a\",536870913]],[\"^7[\",[2113,\"^4<\",181,536870913]],[\"^7[\",[2114,\"^48\",1775983691100,536870913]],[\"^7[\",[2114,\"^7;\",\"a0\",536870913]],[\"^7[\",[2114,\"^4P\",2109,536870913]],[\"^7[\",[2114,\"^3V\",2113,536870913]],[\"^7[\",[2114,\"^65\",\"f\",536870913]],[\"^7[\",[2114,\"^3;\",1775983691353,536870913]],[\"^7[\",[2114,\"^2T\",\"~u69db5c4b-932f-49b8-9522-2f5d6a962f25\",536870913]],[\"^7[\",[2114,\"^4<\",181,536870913]],[\"^7[\",[2115,\"^48\",1775983691319,536870913]],[\"^7[\",[2115,\"^7;\",\"a1\",536870913]],[\"^7[\",[2115,\"^4P\",2109,536870913]],[\"^7[\",[2115,\"^3V\",2113,536870913]],[\"^7[\",[2115,\"^65\",\"\",536870913]],[\"^7[\",[2115,\"^3;\",1775983691319,536870913]],[\"^7[\",[2115,\"^2T\",\"~u69db5c4b-9d2c-4516-b00c-25b6e2a7d6de\",536870913]],[\"^7[\",[2115,\"^4<\",181,536870913]],[\"^7[\",[2116,\"^48\",1775983691450,536870913]],[\"^7[\",[2116,\"^7;\",\"a2\",536870913]],[\"^7[\",[2116,\"^4P\",2109,536870913]],[\"^7[\",[2116,\"^3V\",2113,536870913]],[\"^7[\",[2116,\"^65\",\"f\",536870913]],[\"^7[\",[2116,\"^3;\",1775983691677,536870913]],[\"^7[\",[2116,\"^2T\",\"~u69db5c4b-7c2d-4bb4-9aaa-5a9f1a445f52\",536870913]],[\"^7[\",[2116,\"^4<\",181,536870913]],[\"^7[\",[2117,\"^48\",1775983691628,536870913]],[\"^7[\",[2117,\"^7;\",\"Zzx\",536870913]],[\"^7[\",[2117,\"^4P\",2109,536870913]],[\"^7[\",[2117,\"^3V\",2111,536870913]],[\"^7[\",[2117,\"^65\",\"f\",536870913]],[\"^7[\",[2117,\"^3;\",1775983691774,536870913]],[\"^7[\",[2117,\"^2T\",\"~u69db5c4b-56f8-422b-b193-b482957b3b31\",536870913]],[\"^7[\",[2117,\"^4<\",181,536870913]],[\"^7[\",[2118,\"^48\",1775983691775,536870913]],[\"^7[\",[2118,\"^7;\",\"a0\",536870913]],[\"^7[\",[2118,\"^4P\",2109,536870913]],[\"^7[\",[2118,\"^3V\",2117,536870913]],[\"^7[\",[2118,\"^65\",\"f\",536870913]],[\"^7[\",[2118,\"^3;\",1775983691973,536870913]],[\"^7[\",[2118,\"^2T\",\"~u69db5c4b-0f65-4034-9d3e-a53826620eb4\",536870913]],[\"^7[\",[2118,\"^4<\",181,536870913]],[\"^7[\",[2119,\"^48\",1775983691974,536870913]],[\"^7[\",[2119,\"^7;\",\"Zz\",536870913]],[\"^7[\",[2119,\"^4P\",2109,536870913]],[\"^7[\",[2119,\"^3V\",2117,536870913]],[\"^7[\",[2119,\"^65\",\"\",536870913]],[\"^7[\",[2119,\"^3;\",1775983691974,536870913]],[\"^7[\",[2119,\"^2T\",\"~u69db5c4b-8a1c-4a55-9c85-2c33cc41abf3\",536870913]],[\"^7[\",[2119,\"^4<\",181,536870913]],[\"^7[\",[2120,\"^48\",1775983692146,536870913]],[\"^7[\",[2120,\"^7;\",\"a0\",536870913]],[\"^7[\",[2120,\"^4P\",2109,536870913]],[\"^7[\",[2120,\"^3V\",2119,536870913]],[\"^7[\",[2120,\"^65\",\"f\",536870913]],[\"^7[\",[2120,\"^3;\",1775983692532,536870913]],[\"^7[\",[2120,\"^2T\",\"~u69db5c4c-2d33-4497-a4ca-9fa095153cef\",536870913]],[\"^7[\",[2120,\"^4<\",181,536870913]],[\"^7[\",[2121,\"^48\",1775983692485,536870913]],[\"^7[\",[2121,\"^7;\",\"a1\",536870913]],[\"^7[\",[2121,\"^4P\",2109,536870913]],[\"^7[\",[2121,\"^3V\",2119,536870913]],[\"^7[\",[2121,\"^65\",\"f\",536870913]],[\"^7[\",[2121,\"^3;\",1775983692712,536870913]],[\"^7[\",[2121,\"^2T\",\"~u69db5c4c-65af-484b-80c2-221d316cd911\",536870913]],[\"^7[\",[2121,\"^4<\",181,536870913]],[\"^7[\",[2122,\"^48\",1775983692668,536870913]],[\"^7[\",[2122,\"^7;\",\"Zzt\",536870913]],[\"^7[\",[2122,\"^4P\",2109,536870913]],[\"^7[\",[2122,\"^3V\",2117,536870913]],[\"^7[\",[2122,\"^65\",\"f\",536870913]],[\"^7[\",[2122,\"^3;\",1775983692810,536870913]],[\"^7[\",[2122,\"^2T\",\"~u69db5c4c-8ca4-40fa-b76f-65bdfb599fa4\",536870913]],[\"^7[\",[2122,\"^4<\",181,536870913]],[\"^7[\",[2123,\"^48\",1775983692811,536870913]],[\"^7[\",[2123,\"^7;\",\"a0\",536870913]],[\"^7[\",[2123,\"^4P\",2109,536870913]],[\"^7[\",[2123,\"^3V\",2122,536870913]],[\"^7[\",[2123,\"^1S\",58,536870913]],[\"^7[\",[2123,\"^1S\",2124,536870913]],[\"^7[\",[2123,\"^64\",2124,536870913]],[\"^7[\",[2123,\"^65\",\"f\",536870913]],[\"^7[\",[2123,\"^3;\",1775983693372,536870913]],[\"^7[\",[2123,\"^2T\",\"~u69db5c4c-ddf7-4a3a-a05f-8a31dc7acded\",536870913]],[\"^7[\",[2123,\"^4<\",181,536870913]],[\"^7[\",[2124,\"^3D\",false,536870913]],[\"^7[\",[2124,\"^48\",1775983696483,536870913]],[\"^7[\",[2124,\"^7H\",\"tag2\",536870913]],[\"^7[\",[2124,\"^1S\",14,536870913]],[\"^7[\",[2124,\"^1S\",58,536870913]],[\"^7[\",[2124,\"^1S\",89,536870913]],[\"^7[\",[2124,\"^1S\",121,536870913]],[\"^7[\",[2124,\"^1S\",158,536870913]],[\"^7[\",[2124,\"^1S\",565,536870913]],[\"^7[\",[2124,\"^1S\",568,536870913]],[\"^7[\",[2124,\"^1S\",2126,536870913]],[\"^7[\",[2124,\"^64\",14,536870913]],[\"^7[\",[2124,\"^65\",\"tag2\",536870913]],[\"^7[\",[2124,\"^3;\",1775983696483,536870913]],[\"^7[\",[2124,\"^2T\",\"~u69db5c50-3b82-4cb1-a425-271e7c8e6a03\",536870913]],[\"^7[\",[2124,\"^;\",\"^3Z\",536870913]],[\"^7[\",[2124,\"^4<\",181,536870913]],[\"^7[\",[2124,\"^3K\",89,536870913]],[\"^7[\",[2124,\"^4J\",565,536870913]],[\"^7[\",[2124,\"^4J\",568,536870913]],[\"^7[\",[2124,\"^4J\",2126,536870913]],[\"^7[\",[2125,\"^48\",1775983698445,536870913]],[\"^7[\",[2125,\"^7;\",\"Zy\",536870913]],[\"^7[\",[2125,\"^4P\",170,536870913]],[\"^7[\",[2125,\"^3V\",170,536870913]],[\"^7[\",[2125,\"^1S\",2124,536870913]],[\"^7[\",[2125,\"^65\",\"All\",536870913]],[\"^7[\",[2125,\"^3;\",1775983698445,536870913]],[\"^7[\",[2125,\"^2T\",\"~u00000006-2710-8879-5000-000000000000\",536870913]],[\"^7[\",[2125,\"^4<\",181,536870913]],[\"^7[\",[2125,\"^9\",2124,536870913]],[\"^7[\",[2125,\"^5<\",\"^8J\",536870913]],[\"^7[\",[2126,\"^48\",1775983711630,536870913]],[\"^7[\",[2126,\"^7H\",\"p5\",536870913]],[\"^7[\",[2126,\"^7;\",\"d3BK2\",536870913]],[\"^7[\",[2126,\"^1S\",58,536870913]],[\"^7[\",[2126,\"^1S\",104,536870913]],[\"^7[\",[2126,\"^1S\",124,536870913]],[\"^7[\",[2126,\"^64\",124,536870913]],[\"^7[\",[2126,\"^65\",\"p5\",536870913]],[\"^7[\",[2126,\"^3;\",1775983717913,536870913]],[\"^7[\",[2126,\"^2T\",\"~u00000002-1345-4970-5000-000000000000\",536870913]],[\"^7[\",[2126,\"^<\",\"^14\",536870913]],[\"^7[\",[2126,\"^;\",\"^23\",536870913]],[\"^7[\",[2126,\"^>\",true,536870913]],[\"^7[\",[2126,\"^C\",\"^D\",536870913]],[\"^7[\",[2126,\"^6B\",104,536870913]],[\"^7[\",[2126,\"^4<\",181,536870913]],[\"^7[\",[2126,\"^1K\",\"^80\",536870913]],[\"^7[\",[2341,\"^48\",1775983729631,536870913]],[\"^7[\",[2341,\"^7H\",\"page 8\",536870913]],[\"^7[\",[2341,\"^1S\",58,536870913]],[\"^7[\",[2341,\"^1S\",104,536870913]],[\"^7[\",[2341,\"^64\",104,536870913]],[\"^7[\",[2341,\"^65\",\"page 8\",536870913]],[\"^7[\",[2341,\"^3;\",1775983729797,536870913]],[\"^7[\",[2341,\"^2T\",\"~u69db5c2a-601b-45ea-ab8f-13e01ea7787b\",536870913]],[\"^7[\",[2341,\"^4<\",181,536870913]],[\"^7[\",[2342,\"^48\",1775983729638,536870913]],[\"^7[\",[2342,\"^7;\",\"a0\",536870913]],[\"^7[\",[2342,\"^4P\",2341,536870913]],[\"^7[\",[2342,\"^3V\",2341,536870913]],[\"^7[\",[2342,\"^65\",\"hello\",536870913]],[\"^7[\",[2342,\"^3;\",1775983729647,536870913]],[\"^7[\",[2342,\"^2T\",\"~u69db5c2b-3d88-4bad-ae1a-e068d1950520\",536870913]],[\"^7[\",[2342,\"^4<\",181,536870913]],[\"^7[\",[2343,\"^48\",1775983729648,536870913]],[\"^7[\",[2343,\"^7;\",\"a1\",536870913]],[\"^7[\",[2343,\"^4P\",2341,536870913]],[\"^7[\",[2343,\"^3V\",2341,536870913]],[\"^7[\",[2343,\"^65\",\"worjfdaf\",536870913]],[\"^7[\",[2343,\"^3;\",1775983729659,536870913]],[\"^7[\",[2343,\"^2T\",\"~u69db5c2b-6a3d-4726-a69e-41d53fd5a0bf\",536870913]],[\"^7[\",[2343,\"^4<\",181,536870913]],[\"^7[\",[2344,\"^48\",1775983729661,536870913]],[\"^7[\",[2344,\"^7;\",\"a2\",536870913]],[\"^7[\",[2344,\"^4P\",2341,536870913]],[\"^7[\",[2344,\"^3V\",2341,536870913]],[\"^7[\",[2344,\"^65\",\"\",536870913]],[\"^7[\",[2344,\"^3;\",1775983729661,536870913]],[\"^7[\",[2344,\"^2T\",\"~u69db5c2c-b373-49b6-a1f1-029b2f8ca8c8\",536870913]],[\"^7[\",[2344,\"^4<\",181,536870913]],[\"^7[\",[2345,\"^48\",1775983729672,536870913]],[\"^7[\",[2345,\"^7;\",\"a0\",536870913]],[\"^7[\",[2345,\"^4P\",2341,536870913]],[\"^7[\",[2345,\"^3V\",2344,536870913]],[\"^7[\",[2345,\"^65\",\"f\",536870913]],[\"^7[\",[2345,\"^3;\",1775983729688,536870913]],[\"^7[\",[2345,\"^2T\",\"~u69db5c2c-ba52-4f79-85f8-a7655369ba6c\",536870913]],[\"^7[\",[2345,\"^4<\",181,536870913]],[\"^7[\",[2346,\"^48\",1775983729689,536870913]],[\"^7[\",[2346,\"^7;\",\"a1\",536870913]],[\"^7[\",[2346,\"^4P\",2341,536870913]],[\"^7[\",[2346,\"^3V\",2344,536870913]],[\"^7[\",[2346,\"^65\",\"af\",536870913]],[\"^7[\",[2346,\"^3;\",1775983729700,536870913]],[\"^7[\",[2346,\"^2T\",\"~u69db5c2c-f588-4d67-89cf-637ed39de614\",536870913]],[\"^7[\",[2346,\"^4<\",181,536870913]],[\"^7[\",[2347,\"^48\",1775983729701,536870913]],[\"^7[\",[2347,\"^7;\",\"a2\",536870913]],[\"^7[\",[2347,\"^4P\",2341,536870913]],[\"^7[\",[2347,\"^3V\",2344,536870913]],[\"^7[\",[2347,\"^65\",\"as\",536870913]],[\"^7[\",[2347,\"^3;\",1775983729712,536870913]],[\"^7[\",[2347,\"^2T\",\"~u69db5c2d-eb41-41f0-97cb-c9dace0102c2\",536870913]],[\"^7[\",[2347,\"^4<\",181,536870913]],[\"^7[\",[2348,\"^48\",1775983729714,536870913]],[\"^7[\",[2348,\"^7;\",\"a3\",536870913]],[\"^7[\",[2348,\"^4P\",2341,536870913]],[\"^7[\",[2348,\"^3V\",2344,536870913]],[\"^7[\",[2348,\"^65\",\"fa\",536870913]],[\"^7[\",[2348,\"^3;\",1775983729725,536870913]],[\"^7[\",[2348,\"^2T\",\"~u69db5c2d-02cd-4b60-b743-09feff4e4825\",536870913]],[\"^7[\",[2348,\"^4<\",181,536870913]],[\"^7[\",[2349,\"^48\",1775983729726,536870913]],[\"^7[\",[2349,\"^7;\",\"a4\",536870913]],[\"^7[\",[2349,\"^4P\",2341,536870913]],[\"^7[\",[2349,\"^3V\",2344,536870913]],[\"^7[\",[2349,\"^65\",\"f\",536870913]],[\"^7[\",[2349,\"^3;\",1775983729738,536870913]],[\"^7[\",[2349,\"^2T\",\"~u69db5c2d-a17f-4516-b106-304a1befd700\",536870913]],[\"^7[\",[2349,\"^4<\",181,536870913]],[\"^7[\",[2350,\"^48\",1775983729739,536870913]],[\"^7[\",[2350,\"^7;\",\"a5\",536870913]],[\"^7[\",[2350,\"^4P\",2341,536870913]],[\"^7[\",[2350,\"^3V\",2344,536870913]],[\"^7[\",[2350,\"^1S\",2351,536870913]],[\"^7[\",[2350,\"^65\",\"af\",536870913]],[\"^7[\",[2350,\"^3;\",1775983665047,536870913]],[\"^7[\",[2350,\"^2T\",\"~u69db5c2d-167b-455d-9de8-a098caf456e3\",536870913]],[\"^7[\",[2350,\"^4<\",181,536870913]],[\"^7[\",[2350,\"^4?\",2352,536870913]],[\"^7[\",[2351,\"^48\",1775983729759,536870913]],[\"^7[\",[2351,\"^7H\",\"p10\",536870913]],[\"^7[\",[2351,\"^7;\",\"d3BHk\",536870913]],[\"^7[\",[2351,\"^1S\",58,536870913]],[\"^7[\",[2351,\"^1S\",124,536870913]],[\"^7[\",[2351,\"^64\",124,536870913]],[\"^7[\",[2351,\"^65\",\"p10\",536870913]],[\"^7[\",[2351,\"^3;\",1775983729759,536870913]],[\"^7[\",[2351,\"^2T\",\"~u00000002-1449-8430-2100-000000000000\",536870913]],[\"^7[\",[2351,\"^<\",\"^=\",536870913]],[\"^7[\",[2351,\"^;\",\"^4?\",536870913]],[\"^7[\",[2351,\"^>\",true,536870913]],[\"^7[\",[2351,\"^C\",\"^D\",536870913]],[\"^7[\",[2351,\"^4<\",181,536870913]],[\"^7[\",[2351,\"^1K\",\"^89\",536870913]],[\"^7[\",[2352,\"^48\",1775983665044,536870913]],[\"^7[\",[2352,\"^7;\",\"d3BHj\",536870913]],[\"^7[\",[2352,\"^4P\",2341,536870913]],[\"^7[\",[2352,\"^3V\",2350,536870913]],[\"^7[\",[2352,\"^1S\",2351,536870913]],[\"^7[\",[2352,\"^65\",\"hello\",536870913]],[\"^7[\",[2352,\"^3;\",1775983729784,536870913]],[\"^7[\",[2352,\"^2T\",\"~u69db5c31-45d6-443b-8f8f-1b1808c86b4b\",536870913]],[\"^7[\",[2352,\"^4<\",181,536870913]],[\"^7[\",[2352,\"^6:\",2351,536870913]],[\"^7[\",[2353,\"^48\",1775983729785,536870913]],[\"^7[\",[2353,\"^7;\",\"a0\",536870913]],[\"^7[\",[2353,\"^4P\",2341,536870913]],[\"^7[\",[2353,\"^3V\",2352,536870913]],[\"^7[\",[2353,\"^65\",\"fjdalfjklaf\",536870913]],[\"^7[\",[2353,\"^3;\",1775983729797,536870913]],[\"^7[\",[2353,\"^2T\",\"~u69db5c32-c449-4614-89c5-afdad827a965\",536870913]],[\"^7[\",[2353,\"^4<\",181,536870913]],[\"^7[\",[2374,\"^48\",1775984527252,536870913]],[\"^7[\",[2374,\"^7H\",\"page 10\",536870913]],[\"^7[\",[2374,\"^1S\",58,536870913]],[\"^7[\",[2374,\"^1S\",104,536870913]],[\"^7[\",[2374,\"^64\",104,536870913]],[\"^7[\",[2374,\"^65\",\"page 10\",536870913]],[\"^7[\",[2374,\"^3;\",1775984527765,536870913]],[\"^7[\",[2374,\"^2T\",\"~u69db5f78-689c-4382-9a3d-d2caf761adb6\",536870913]],[\"^7[\",[2374,\"^4<\",181,536870913]],[\"^7[\",[2389,\"^48\",1775984527610,536870913]],[\"^7[\",[2389,\"^7;\",\"a0\",536870913]],[\"^7[\",[2389,\"^4P\",2374,536870913]],[\"^7[\",[2389,\"^3V\",2374,536870913]],[\"^7[\",[2389,\"^65\",\"hellofda\",536870913]],[\"^7[\",[2389,\"^3;\",1775984527618,536870913]],[\"^7[\",[2389,\"^2T\",\"~u69db5f78-dcde-4e98-9f1a-1bca6c316e8d\",536870913]],[\"^7[\",[2389,\"^4<\",181,536870913]],[\"^7[\",[2390,\"^48\",1775984527619,536870913]],[\"^7[\",[2390,\"^7;\",\"a1\",536870913]],[\"^7[\",[2390,\"^4P\",2374,536870913]],[\"^7[\",[2390,\"^3V\",2374,536870913]],[\"^7[\",[2390,\"^65\",\"fa\",536870913]],[\"^7[\",[2390,\"^3;\",1775984527629,536870913]],[\"^7[\",[2390,\"^2T\",\"~u69db5f79-a66e-4a39-aecc-339726c89430\",536870913]],[\"^7[\",[2390,\"^4<\",181,536870913]],[\"^7[\",[2391,\"^48\",1775984527630,536870913]],[\"^7[\",[2391,\"^7;\",\"a0\",536870913]],[\"^7[\",[2391,\"^4P\",2374,536870913]],[\"^7[\",[2391,\"^3V\",2390,536870913]],[\"^7[\",[2391,\"^65\",\"fdasf\",536870913]],[\"^7[\",[2391,\"^3;\",1775984527646,536870913]],[\"^7[\",[2391,\"^2T\",\"~u69db5f79-fd04-4c4f-a0d5-726877b307a3\",536870913]],[\"^7[\",[2391,\"^4<\",181,536870913]],[\"^7[\",[2392,\"^48\",1775984527647,536870913]],[\"^7[\",[2392,\"^7;\",\"a1\",536870913]],[\"^7[\",[2392,\"^4P\",2374,536870913]],[\"^7[\",[2392,\"^3V\",2390,536870913]],[\"^7[\",[2392,\"^65\",\"af\",536870913]],[\"^7[\",[2392,\"^3;\",1775984527656,536870913]],[\"^7[\",[2392,\"^2T\",\"~u69db5f7a-d6b1-4bf8-90c6-a70ba0bec381\",536870913]],[\"^7[\",[2392,\"^4<\",181,536870913]],[\"^7[\",[2393,\"^48\",1775984527658,536870913]],[\"^7[\",[2393,\"^7;\",\"a2\",536870913]],[\"^7[\",[2393,\"^4P\",2374,536870913]],[\"^7[\",[2393,\"^3V\",2390,536870913]],[\"^7[\",[2393,\"^65\",\"as\",536870913]],[\"^7[\",[2393,\"^3;\",1775984527666,536870913]],[\"^7[\",[2393,\"^2T\",\"~u69db5f7a-e8a7-4800-bd8c-b2c14b6648a8\",536870913]],[\"^7[\",[2393,\"^4<\",181,536870913]],[\"^7[\",[2394,\"^48\",1775984527668,536870913]],[\"^7[\",[2394,\"^7;\",\"a3\",536870913]],[\"^7[\",[2394,\"^4P\",2374,536870913]],[\"^7[\",[2394,\"^3V\",2390,536870913]],[\"^7[\",[2394,\"^65\",\"fdas\",536870913]],[\"^7[\",[2394,\"^3;\",1775984527677,536870913]],[\"^7[\",[2394,\"^2T\",\"~u69db5f7a-f814-4036-b2d5-06b1688f319f\",536870913]],[\"^7[\",[2394,\"^4<\",181,536870913]],[\"^7[\",[2395,\"^48\",1775984527678,536870913]],[\"^7[\",[2395,\"^7;\",\"a2\",536870913]],[\"^7[\",[2395,\"^4P\",2374,536870913]],[\"^7[\",[2395,\"^3V\",2374,536870913]],[\"^7[\",[2395,\"^65\",\"fs\",536870913]],[\"^7[\",[2395,\"^3;\",1775984527694,536870913]],[\"^7[\",[2395,\"^2T\",\"~u69db5f7b-2941-4704-88d5-3d10f2b585f6\",536870913]],[\"^7[\",[2395,\"^4<\",181,536870913]],[\"^7[\",[2396,\"^48\",1775984527696,536870913]],[\"^7[\",[2396,\"^7;\",\"a3\",536870913]],[\"^7[\",[2396,\"^4P\",2374,536870913]],[\"^7[\",[2396,\"^3V\",2374,536870913]],[\"^7[\",[2396,\"^65\",\"af\",536870913]],[\"^7[\",[2396,\"^3;\",1775984527705,536870913]],[\"^7[\",[2396,\"^2T\",\"~u69db5f7b-11a3-4387-b2d8-e4360d0574b0\",536870913]],[\"^7[\",[2396,\"^4<\",181,536870913]],[\"^7[\",[2397,\"^48\",1775984527706,536870913]],[\"^7[\",[2397,\"^7;\",\"a4\",536870913]],[\"^7[\",[2397,\"^4P\",2374,536870913]],[\"^7[\",[2397,\"^3V\",2374,536870913]],[\"^7[\",[2397,\"^65\",\"as\",536870913]],[\"^7[\",[2397,\"^3;\",1775984527706,536870913]],[\"^7[\",[2397,\"^2T\",\"~u69db5f7b-24f0-4258-b5b5-7c86c9fb264e\",536870913]],[\"^7[\",[2397,\"^4<\",181,536870913]],[\"^7[\",[2398,\"^48\",1775984527715,536870913]],[\"^7[\",[2398,\"^7;\",\"a3V\",536870913]],[\"^7[\",[2398,\"^4P\",2374,536870913]],[\"^7[\",[2398,\"^3V\",2374,536870913]],[\"^7[\",[2398,\"^65\",\"fd\",536870913]],[\"^7[\",[2398,\"^3;\",1775984527722,536870913]],[\"^7[\",[2398,\"^2T\",\"~u69db5f7b-02a5-40e9-baf8-befba0d845c5\",536870913]],[\"^7[\",[2398,\"^4<\",181,536870913]],[\"^7[\",[2399,\"^48\",1775984527723,536870913]],[\"^7[\",[2399,\"^7;\",\"a3l\",536870913]],[\"^7[\",[2399,\"^4P\",2374,536870913]],[\"^7[\",[2399,\"^3V\",2374,536870913]],[\"^7[\",[2399,\"^65\",\"af\",536870913]],[\"^7[\",[2399,\"^3;\",1775984527732,536870913]],[\"^7[\",[2399,\"^2T\",\"~u69db5f7c-4a9c-4a5f-ad77-4d171ab63937\",536870913]],[\"^7[\",[2399,\"^4<\",181,536870913]],[\"^7[\",[2400,\"^48\",1775984527734,536870913]],[\"^7[\",[2400,\"^7;\",\"a3t\",536870913]],[\"^7[\",[2400,\"^4P\",2374,536870913]],[\"^7[\",[2400,\"^3V\",2374,536870913]],[\"^7[\",[2400,\"^65\",\"af\",536870913]],[\"^7[\",[2400,\"^3;\",1775984527743,536870913]],[\"^7[\",[2400,\"^2T\",\"~u69db5f7c-e498-4db4-9791-53e4560eb1cc\",536870913]],[\"^7[\",[2400,\"^4<\",181,536870913]],[\"^7[\",[2401,\"^48\",1775984527744,536870913]],[\"^7[\",[2401,\"^7;\",\"a3x\",536870913]],[\"^7[\",[2401,\"^4P\",2374,536870913]],[\"^7[\",[2401,\"^3V\",2374,536870913]],[\"^7[\",[2401,\"^65\",\"af\",536870913]],[\"^7[\",[2401,\"^3;\",1775984527754,536870913]],[\"^7[\",[2401,\"^2T\",\"~u69db5f7c-7746-45d5-8989-646ee3b9289b\",536870913]],[\"^7[\",[2401,\"^4<\",181,536870913]],[\"^7[\",[2402,\"^48\",1775984527755,536870913]],[\"^7[\",[2402,\"^7;\",\"a3z\",536870913]],[\"^7[\",[2402,\"^4P\",2374,536870913]],[\"^7[\",[2402,\"^3V\",2374,536870913]],[\"^7[\",[2402,\"^65\",\"a\",536870913]],[\"^7[\",[2402,\"^3;\",1775984527764,536870913]],[\"^7[\",[2402,\"^2T\",\"~u69db5f7c-8423-4ed9-bb36-bf5156b4d757\",536870913]],[\"^7[\",[2402,\"^4<\",181,536870913]],[\"^7[\",[2406,\"^48\",1775984610679,536870913]],[\"^7[\",[2406,\"^7H\",\"page 11\",536870913]],[\"^7[\",[2406,\"^1S\",58,536870913]],[\"^7[\",[2406,\"^1S\",104,536870913]],[\"^7[\",[2406,\"^64\",104,536870913]],[\"^7[\",[2406,\"^65\",\"page 11\",536870913]],[\"^7[\",[2406,\"^3;\",1775984651669,536870913]],[\"^7[\",[2406,\"^2T\",\"~u69db5fe2-20d8-4c72-8f3a-96b778acec6f\",536870913]],[\"^7[\",[2406,\"^4<\",181,536870913]],[\"^7[\",[2407,\"^48\",1775984610938,536870913]],[\"^7[\",[2407,\"^7;\",\"a0\",536870913]],[\"^7[\",[2407,\"^4P\",2406,536870913]],[\"^7[\",[2407,\"^3V\",2406,536870913]],[\"^7[\",[2407,\"^65\",\"fjdlafjl\",536870913]],[\"^7[\",[2407,\"^3;\",1775984611435,536870913]],[\"^7[\",[2407,\"^2T\",\"~u69db5fe2-56e1-428d-bf11-06cb38229be2\",536870913]],[\"^7[\",[2407,\"^4<\",181,536870913]],[\"^7[\",[2408,\"^48\",1775984611507,536870913]],[\"^7[\",[2408,\"^7;\",\"a0\",536870913]],[\"^7[\",[2408,\"^4P\",2406,536870913]],[\"^7[\",[2408,\"^3V\",2407,536870913]],[\"^7[\",[2408,\"^65\",\"ff\",536870913]],[\"^7[\",[2408,\"^3;\",1775984611935,536870913]],[\"^7[\",[2408,\"^2T\",\"~u69db5fe3-6de1-4070-96b6-6fb5e16cfd5d\",536870913]],[\"^7[\",[2408,\"^4<\",181,536870913]],[\"^7[\",[2409,\"^48\",1775984611937,536870913]],[\"^7[\",[2409,\"^7;\",\"a0\",536870913]],[\"^7[\",[2409,\"^4P\",2406,536870913]],[\"^7[\",[2409,\"^3V\",2408,536870913]],[\"^7[\",[2409,\"^65\",\"f\",536870913]],[\"^7[\",[2409,\"^3;\",1775984612220,536870913]],[\"^7[\",[2409,\"^2T\",\"~u69db5fe3-0419-47c1-9d77-36ba70846eae\",536870913]],[\"^7[\",[2409,\"^4<\",181,536870913]],[\"^7[\",[2410,\"^48\",1775984612293,536870913]],[\"^7[\",[2410,\"^7;\",\"a0\",536870913]],[\"^7[\",[2410,\"^4P\",2406,536870913]],[\"^7[\",[2410,\"^3V\",2409,536870913]],[\"^7[\",[2410,\"^65\",\"f\",536870913]],[\"^7[\",[2410,\"^3;\",1775984612724,536870913]],[\"^7[\",[2410,\"^2T\",\"~u69db5fe4-4a1d-4a85-8ffc-13fa73cf7772\",536870913]],[\"^7[\",[2410,\"^4<\",181,536870913]],[\"^7[\",[2411,\"^48\",1775984612632,536870913]],[\"^7[\",[2411,\"^7;\",\"a1\",536870913]],[\"^7[\",[2411,\"^4P\",2406,536870913]],[\"^7[\",[2411,\"^3V\",2409,536870913]],[\"^7[\",[2411,\"^65\",\"f\",536870913]],[\"^7[\",[2411,\"^3;\",1775984612903,536870913]],[\"^7[\",[2411,\"^2T\",\"~u69db5fe4-bafe-449d-acb5-c363728d2c3a\",536870913]],[\"^7[\",[2411,\"^4<\",181,536870913]],[\"^7[\",[2412,\"^48\",1775984612812,536870913]],[\"^7[\",[2412,\"^7;\",\"a3\",536870913]],[\"^7[\",[2412,\"^4P\",2406,536870913]],[\"^7[\",[2412,\"^3V\",2408,536870913]],[\"^7[\",[2412,\"^65\",\"f\",536870913]],[\"^7[\",[2412,\"^3;\",1775984613001,536870913]],[\"^7[\",[2412,\"^2T\",\"~u69db5fe4-a665-4b11-82ef-9fa78a1d025c\",536870913]],[\"^7[\",[2412,\"^4<\",181,536870913]],[\"^7[\",[2413,\"^48\",1775984613002,536870913]],[\"^7[\",[2413,\"^7;\",\"a0\",536870913]],[\"^7[\",[2413,\"^4P\",2406,536870913]],[\"^7[\",[2413,\"^3V\",2412,536870913]],[\"^7[\",[2413,\"^65\",\"f\",536870913]],[\"^7[\",[2413,\"^3;\",1775984613203,536870913]],[\"^7[\",[2413,\"^2T\",\"~u69db5fe4-e871-4f41-a9a6-d2783de5b724\",536870913]],[\"^7[\",[2413,\"^4<\",181,536870913]],[\"^7[\",[2414,\"^48\",1775984613203,536870913]],[\"^7[\",[2414,\"^7;\",\"a2\",536870913]],[\"^7[\",[2414,\"^4P\",2406,536870913]],[\"^7[\",[2414,\"^3V\",2425,536870913]],[\"^7[\",[2414,\"^65\",\"q\",536870913]],[\"^7[\",[2414,\"^3;\",1775984613413,536870913]],[\"^7[\",[2414,\"^2T\",\"~u69db5fe5-c93e-4715-a9e2-b311246fe3f1\",536870913]],[\"^7[\",[2414,\"^4<\",181,536870913]],[\"^7[\",[2415,\"^48\",1775984613413,536870913]],[\"^7[\",[2415,\"^7;\",\"a1\",536870913]],[\"^7[\",[2415,\"^4P\",2406,536870913]],[\"^7[\",[2415,\"^3V\",2413,536870913]],[\"^7[\",[2415,\"^65\",\"\",536870913]],[\"^7[\",[2415,\"^3;\",1775984613413,536870913]],[\"^7[\",[2415,\"^2T\",\"~u69db5fe5-b337-41af-9146-6e6e01e72130\",536870913]],[\"^7[\",[2415,\"^4<\",181,536870913]],[\"^7[\",[2416,\"^48\",1775984613561,536870913]],[\"^7[\",[2416,\"^7;\",\"a2\",536870913]],[\"^7[\",[2416,\"^4P\",2406,536870913]],[\"^7[\",[2416,\"^3V\",2406,536870913]],[\"^7[\",[2416,\"^65\",\"fdafdasf\",536870913]],[\"^7[\",[2416,\"^3;\",1775984651668,536870913]],[\"^7[\",[2416,\"^2T\",\"~u69db5fe5-ce1b-4fb0-802c-ca3c7deba63a\",536870913]],[\"^7[\",[2416,\"^4<\",181,536870913]],[\"^7[\",[2417,\"^48\",1775984613782,536870913]],[\"^7[\",[2417,\"^7;\",\"Zz\",536870913]],[\"^7[\",[2417,\"^4P\",2406,536870913]],[\"^7[\",[2417,\"^3V\",2415,536870913]],[\"^7[\",[2417,\"^65\",\"ff\",536870913]],[\"^7[\",[2417,\"^3;\",1775984614190,536870913]],[\"^7[\",[2417,\"^2T\",\"~u69db5fe5-5fad-4cca-bed6-f9e7c1e2a37d\",536870913]],[\"^7[\",[2417,\"^4<\",181,536870913]],[\"^7[\",[2418,\"^48\",1775984613943,536870913]],[\"^7[\",[2418,\"^7;\",\"a1\",536870913]],[\"^7[\",[2418,\"^4P\",2406,536870913]],[\"^7[\",[2418,\"^3V\",2425,536870913]],[\"^7[\",[2418,\"^65\",\"\",536870913]],[\"^7[\",[2418,\"^3;\",1775984613943,536870913]],[\"^7[\",[2418,\"^2T\",\"~u69db5fe5-deee-4706-a78d-aaf4007518c1\",536870913]],[\"^7[\",[2418,\"^4<\",181,536870913]],[\"^7[\",[2419,\"^48\",1775984614111,536870913]],[\"^7[\",[2419,\"^7;\",\"a0\",536870913]],[\"^7[\",[2419,\"^4P\",2406,536870913]],[\"^7[\",[2419,\"^3V\",2417,536870913]],[\"^7[\",[2419,\"^65\",\"f\",536870913]],[\"^7[\",[2419,\"^3;\",1775984614359,536870913]],[\"^7[\",[2419,\"^2T\",\"~u69db5fe6-fd79-4fa8-bfab-30986aa83bcc\",536870913]],[\"^7[\",[2419,\"^4<\",181,536870913]],[\"^7[\",[2420,\"^48\",1775984614283,536870913]],[\"^7[\",[2420,\"^7;\",\"a1\",536870913]],[\"^7[\",[2420,\"^4P\",2406,536870913]],[\"^7[\",[2420,\"^3V\",2417,536870913]],[\"^7[\",[2420,\"^65\",\"f\",536870913]],[\"^7[\",[2420,\"^3;\",1775984614536,536870913]],[\"^7[\",[2420,\"^2T\",\"~u69db5fe6-437d-47c3-91e6-7eed23097b73\",536870913]],[\"^7[\",[2420,\"^4<\",181,536870913]],[\"^7[\",[2421,\"^48\",1775984614454,536870913]],[\"^7[\",[2421,\"^7;\",\"ZzS\",536870913]],[\"^7[\",[2421,\"^4P\",2406,536870913]],[\"^7[\",[2421,\"^3V\",2415,536870913]],[\"^7[\",[2421,\"^65\",\"\",536870913]],[\"^7[\",[2421,\"^3;\",1775984614454,536870913]],[\"^7[\",[2421,\"^2T\",\"~u69db5fe6-a079-4c88-8d9c-bc0609008fe6\",536870913]],[\"^7[\",[2421,\"^4<\",181,536870913]],[\"^7[\",[2422,\"^48\",1775984614634,536870913]],[\"^7[\",[2422,\"^7;\",\"a2\",536870913]],[\"^7[\",[2422,\"^4P\",2406,536870913]],[\"^7[\",[2422,\"^3V\",2421,536870913]],[\"^7[\",[2422,\"^65\",\"ff\",536870913]],[\"^7[\",[2422,\"^3;\",1775984615139,536870913]],[\"^7[\",[2422,\"^2T\",\"~u69db5fe6-9f47-45fd-bd1d-78cdad7e5894\",536870913]],[\"^7[\",[2422,\"^4<\",181,536870913]],[\"^7[\",[2423,\"^48\",1775984614835,536870913]],[\"^7[\",[2423,\"^7;\",\"a1\",536870913]],[\"^7[\",[2423,\"^4P\",2406,536870913]],[\"^7[\",[2423,\"^3V\",2421,536870913]],[\"^7[\",[2423,\"^65\",\"\",536870913]],[\"^7[\",[2423,\"^3;\",1775984614835,536870913]],[\"^7[\",[2423,\"^2T\",\"~u69db5fe6-6b73-4f8c-a89d-3cca819ee63c\",536870913]],[\"^7[\",[2423,\"^4<\",181,536870913]],[\"^7[\",[2424,\"^48\",1775984614992,536870913]],[\"^7[\",[2424,\"^7;\",\"a0\",536870913]],[\"^7[\",[2424,\"^4P\",2406,536870913]],[\"^7[\",[2424,\"^3V\",2425,536870913]],[\"^7[\",[2424,\"^65\",\"\",536870913]],[\"^7[\",[2424,\"^3;\",1775984614992,536870913]],[\"^7[\",[2424,\"^2T\",\"~u69db5fe6-d898-483f-bc9b-0122ac0cffe1\",536870913]],[\"^7[\",[2424,\"^4<\",181,536870913]],[\"^7[\",[2425,\"^48\",1775984615139,536870913]],[\"^7[\",[2425,\"^7;\",\"a1\",536870913]],[\"^7[\",[2425,\"^4P\",2406,536870913]],[\"^7[\",[2425,\"^3V\",2406,536870913]],[\"^7[\",[2425,\"^65\",\"\",536870913]],[\"^7[\",[2425,\"^3;\",1775984615139,536870913]],[\"^7[\",[2425,\"^2T\",\"~u69db5fe7-2d4d-43a8-b50b-aa6625c817ae\",536870913]],[\"^7[\",[2425,\"^4<\",181,536870913]],[\"^7[\",[2635,\"^48\",1775986630570,536870913]],[\"^7[\",[2635,\"^7;\",\"aE8V\",536870913]],[\"^7[\",[2635,\"^4P\",180,536870913]],[\"^7[\",[2635,\"^3V\",180,536870913]],[\"^7[\",[2635,\"^65\",\"\",536870913]],[\"^7[\",[2635,\"^3;\",1775986630570,536870913]],[\"^7[\",[2635,\"^2T\",\"~u69db67c6-1735-44ed-8ddb-ffe957da8b07\",536870913]],[\"^7[\",[2635,\"^4<\",181,536870913]],[\"^7[\",[2675,\"^48\",1775986661080,536870913]],[\"^7[\",[2675,\"^7;\",\"a9ql\",536870913]],[\"^7[\",[2675,\"^4P\",180,536870913]],[\"^7[\",[2675,\"^3V\",180,536870913]],[\"^7[\",[2675,\"^65\",\"op-sim-1775986575351-717315ac-r1-client-1- add-22\",536870913]],[\"^7[\",[2675,\"^3;\",1775986661080,536870913]],[\"^7[\",[2675,\"^2T\",\"~u69db67e4-a86e-4f22-97b1-4e4fa93c3e7d\",536870913]],[\"^7[\",[2675,\"^4<\",181,536870913]],[\"^7[\",[2858,\"^48\",1775986934158,536870913]],[\"^7[\",[2858,\"^7;\",\"a4rwt\",536870913]],[\"^7[\",[2858,\"^4P\",180,536870913]],[\"^7[\",[2858,\"^3V\",180,536870913]],[\"^7[\",[2858,\"^65\",\"fdafda\",536870913]],[\"^7[\",[2858,\"^3;\",1775986935056,536870913]],[\"^7[\",[2858,\"^2T\",\"~u69db68f6-2eef-4122-a53b-15bb3493c3c9\",536870913]],[\"^7[\",[2858,\"^4<\",181,536870913]],[\"^7[\",[2859,\"^48\",1775986935057,536870913]],[\"^7[\",[2859,\"^7;\",\"a4rwx\",536870913]],[\"^7[\",[2859,\"^4P\",180,536870913]],[\"^7[\",[2859,\"^3V\",180,536870913]],[\"^7[\",[2859,\"^65\",\"fdafdas\",536870913]],[\"^7[\",[2859,\"^3;\",1775986935839,536870913]],[\"^7[\",[2859,\"^2T\",\"~u69db68f7-056f-48fc-82e1-31ac7e3deb31\",536870913]],[\"^7[\",[2859,\"^4<\",181,536870913]],[\"^7[\",[2861,\"^48\",1775987261605,536870918]],[\"^7[\",[2861,\"^7;\",\"aEn\",536871024]],[\"^7[\",[2861,\"^4P\",180,536870918]],[\"^7[\",[2861,\"^3V\",180,536870991]],[\"^7[\",[2861,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- anchor\",536870918]],[\"^7[\",[2861,\"^3;\",1775987261605,536870918]],[\"^7[\",[2861,\"^2T\",\"~u69db6a3d-f8f2-4a9e-96fd-e780d99ad812\",536870918]],[\"^7[\",[2861,\"^4<\",181,536870918]],[\"^7[\",[2863,\"^48\",1775987261694,536870923]],[\"^7[\",[2863,\"^7;\",\"Zy\",536871060]],[\"^7[\",[2863,\"^4P\",180,536870923]],[\"^7[\",[2863,\"^3V\",2861,536871005]],[\"^7[\",[2863,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- client-root\",536870923]],[\"^7[\",[2863,\"^3;\",1775987261694,536870923]],[\"^7[\",[2863,\"^2T\",\"~u69db6a3d-5061-4541-9930-caf3ea98778c\",536870923]],[\"^7[\",[2863,\"^4<\",181,536870923]],[\"^7[\",[2872,\"^48\",1775987263592,536870949]],[\"^7[\",[2872,\"^7;\",\"a0\",536871034]],[\"^7[\",[2872,\"^4P\",180,536870949]],[\"^7[\",[2872,\"^3V\",2885,536870995]],[\"^7[\",[2872,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-7\",536870949]],[\"^7[\",[2872,\"^3;\",1775987263592,536870949]],[\"^7[\",[2872,\"^2T\",\"~u69db6a3f-0e20-455a-b093-e822d860c64d\",536870949]],[\"^7[\",[2872,\"^4<\",181,536870949]],[\"^7[\",[2876,\"^48\",1775987264111,536870960]],[\"^7[\",[2876,\"^7;\",\"aF8V\",536871009]],[\"^7[\",[2876,\"^4P\",180,536870960]],[\"^7[\",[2876,\"^3V\",180,536870960]],[\"^7[\",[2876,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-2- anchor\",536870960]],[\"^7[\",[2876,\"^S\",536870960,536870962]],[\"^7[\",[2876,\"^3;\",1775987264111,536870960]],[\"^7[\",[2876,\"^2T\",\"~u69db6a3f-a5df-45de-987e-37922f7d437a\",536870960]],[\"^7[\",[2876,\"^4<\",181,536870961]],[\"^7[\",[2877,\"^48\",1775987264628,536870965]],[\"^7[\",[2877,\"^7;\",\"aF9\",536870993]],[\"^7[\",[2877,\"^4P\",180,536870965]],[\"^7[\",[2877,\"^3V\",180,536870977]],[\"^7[\",[2877,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-12\",536870965]],[\"^7[\",[2877,\"^3;\",1775987264628,536870965]],[\"^7[\",[2877,\"^2T\",\"~u69db6a40-5752-44bc-98d2-8b1cf8eda3d9\",536870965]],[\"^7[\",[2877,\"^4<\",181,536870965]],[\"^7[\",[2879,\"^48\",1775987265106,536870968]],[\"^7[\",[2879,\"^7;\",\"aI\",536871049]],[\"^7[\",[2879,\"^4P\",180,536870968]],[\"^7[\",[2879,\"^3V\",180,536871001]],[\"^7[\",[2879,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-15\",536870968]],[\"^7[\",[2879,\"^3;\",1775987265106,536870968]],[\"^7[\",[2879,\"^2T\",\"~u69db6a41-2502-40fc-aa36-765e01ede4d2\",536870968]],[\"^7[\",[2879,\"^4<\",181,536870968]],[\"^7[\",[2880,\"^48\",1775987265424,536870970]],[\"^7[\",[2880,\"^7;\",\"aJ\",536870991]],[\"^7[\",[2880,\"^4P\",180,536870970]],[\"^7[\",[2880,\"^3V\",180,536870970]],[\"^7[\",[2880,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-17\",536870970]],[\"^7[\",[2880,\"^3;\",1775987265424,536870970]],[\"^7[\",[2880,\"^2T\",\"~u69db6a41-5578-4848-9e6b-fe923b26fd83\",536870970]],[\"^7[\",[2880,\"^4<\",181,536870970]],[\"^7[\",[2883,\"^48\",1775987266071,536870974]],[\"^7[\",[2883,\"^7;\",\"aFA\",536870989]],[\"^7[\",[2883,\"^4P\",180,536870974]],[\"^7[\",[2883,\"^3V\",180,536870975]],[\"^7[\",[2883,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-21\",536870974]],[\"^7[\",[2883,\"^3;\",1775987266071,536870974]],[\"^7[\",[2883,\"^2T\",\"~u69db6a42-105d-4264-9309-e2bb24f6dad6\",536870974]],[\"^7[\",[2883,\"^4<\",181,536870974]],[\"^7[\",[2884,\"^48\",1775987266746,536870978]],[\"^7[\",[2884,\"^7;\",\"aEZ\",536871005]],[\"^7[\",[2884,\"^4P\",180,536870978]],[\"^7[\",[2884,\"^3V\",180,536870978]],[\"^7[\",[2884,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-25\",536870978]],[\"^7[\",[2884,\"^3;\",1775987266746,536870978]],[\"^7[\",[2884,\"^2T\",\"~u69db6a42-b927-46b8-b395-46dc61a85618\",536870978]],[\"^7[\",[2884,\"^4<\",181,536870978]],[\"^7[\",[2885,\"^48\",1775987267368,536870981]],[\"^7[\",[2885,\"^7;\",\"aIK\",536871088]],[\"^7[\",[2885,\"^4P\",180,536870981]],[\"^7[\",[2885,\"^3V\",180,536871016]],[\"^7[\",[2885,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-29\",536870981]],[\"^7[\",[2885,\"^3;\",1775987267368,536870981]],[\"^7[\",[2885,\"^2T\",\"~u69db6a43-3008-4c2a-92d0-84e803f10abc\",536870981]],[\"^7[\",[2885,\"^4<\",181,536870981]],[\"^7[\",[2886,\"^48\",1775987269082,536870990]],[\"^7[\",[2886,\"^7;\",\"Zz\",536871047]],[\"^7[\",[2886,\"^4P\",180,536870990]],[\"^7[\",[2886,\"^3V\",2861,536871000]],[\"^7[\",[2886,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-38\",536870990]],[\"^7[\",[2886,\"^3;\",1775987269082,536870990]],[\"^7[\",[2886,\"^2T\",\"~u69db6a45-d05a-44d8-8b0b-44f7654bc0ab\",536870990]],[\"^7[\",[2886,\"^4<\",181,536870990]],[\"^7[\",[2887,\"^48\",1775987269532,536870992]],[\"^7[\",[2887,\"^7;\",\"aIl\",536870992]],[\"^7[\",[2887,\"^4P\",180,536870992]],[\"^7[\",[2887,\"^3V\",180,536870992]],[\"^7[\",[2887,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-40\",536870992]],[\"^7[\",[2887,\"^3;\",1775987269532,536870992]],[\"^7[\",[2887,\"^2T\",\"~u69db6a45-59b0-46b1-90e2-475241fc3408\",536870992]],[\"^7[\",[2887,\"^4<\",181,536870992]],[\"^7[\",[2889,\"^48\",1775987270302,536870996]],[\"^7[\",[2889,\"^7;\",\"aH\",536870996]],[\"^7[\",[2889,\"^4P\",180,536870996]],[\"^7[\",[2889,\"^3V\",180,536870996]],[\"^7[\",[2889,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-44\",536870996]],[\"^7[\",[2889,\"^3;\",1775987270302,536870996]],[\"^7[\",[2889,\"^2T\",\"~u69db6a46-0a5e-4c33-baf8-43655a9a292d\",536870996]],[\"^7[\",[2889,\"^4<\",181,536870996]],[\"^7[\",[2890,\"^48\",1775987270507,536870997]],[\"^7[\",[2890,\"^7;\",\"aF\",536871083]],[\"^7[\",[2890,\"^4P\",180,536870997]],[\"^7[\",[2890,\"^3V\",180,536871014]],[\"^7[\",[2890,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-45\",536870997]],[\"^7[\",[2890,\"^3;\",1775987270507,536870997]],[\"^7[\",[2890,\"^2T\",\"~u69db6a46-033b-47c5-b670-6089e9c4c2c7\",536870997]],[\"^7[\",[2890,\"^4<\",181,536870997]],[\"^7[\",[2891,\"^48\",1775987270907,536870999]],[\"^7[\",[2891,\"^7;\",\"aId\",536870999]],[\"^7[\",[2891,\"^4P\",180,536870999]],[\"^7[\",[2891,\"^3V\",180,536870999]],[\"^7[\",[2891,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-47\",536870999]],[\"^7[\",[2891,\"^3;\",1775987270907,536870999]],[\"^7[\",[2891,\"^2T\",\"~u69db6a46-df7a-4161-a844-ae9cfcf643b2\",536870999]],[\"^7[\",[2891,\"^4<\",181,536870999]],[\"^7[\",[2892,\"^48\",1775987297369,536871002]],[\"^7[\",[2892,\"^7;\",\"aK\",536871002]],[\"^7[\",[2892,\"^4P\",180,536871002]],[\"^7[\",[2892,\"^3V\",180,536871002]],[\"^7[\",[2892,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- anchor\",536871002]],[\"^7[\",[2892,\"^3;\",1775987297369,536871002]],[\"^7[\",[2892,\"^2T\",\"~u69db6a61-d820-4c84-89a0-85946a90b669\",536871002]],[\"^7[\",[2892,\"^4<\",181,536871002]],[\"^7[\",[2893,\"^48\",1775987298025,536871006]],[\"^7[\",[2893,\"^7;\",\"aIU\",536871112]],[\"^7[\",[2893,\"^4P\",180,536871006]],[\"^7[\",[2893,\"^3V\",180,536871025]],[\"^7[\",[2893,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-3\",536871006]],[\"^7[\",[2893,\"^3;\",1775987298025,536871006]],[\"^7[\",[2893,\"^2T\",\"~u69db6a62-71d6-424a-b41e-d24d6632d997\",536871006]],[\"^7[\",[2893,\"^4<\",181,536871006]],[\"^7[\",[2894,\"^48\",1775987298244,536871007]],[\"^7[\",[2894,\"^7;\",\"a0\",536871068]],[\"^7[\",[2894,\"^4P\",180,536871007]],[\"^7[\",[2894,\"^3V\",2861,536871008]],[\"^7[\",[2894,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-4\",536871007]],[\"^7[\",[2894,\"^3;\",1775987298244,536871007]],[\"^7[\",[2894,\"^2T\",\"~u69db6a62-5bc1-41b0-ad79-b2fde738395f\",536871007]],[\"^7[\",[2894,\"^4<\",181,536871007]],[\"^7[\",[2895,\"^48\",1775987298656,536871009]],[\"^7[\",[2895,\"^7;\",\"aGl\",536871009]],[\"^7[\",[2895,\"^4P\",180,536871009]],[\"^7[\",[2895,\"^3V\",180,536871009]],[\"^7[\",[2895,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-6\",536871009]],[\"^7[\",[2895,\"^3;\",1775987298656,536871009]],[\"^7[\",[2895,\"^2T\",\"~u69db6a62-0da1-475a-a17e-e058661fe360\",536871009]],[\"^7[\",[2895,\"^4<\",181,536871009]],[\"^7[\",[2896,\"^48\",1775987298853,536871010]],[\"^7[\",[2896,\"^7;\",\"a0\",536871010]],[\"^7[\",[2896,\"^4P\",180,536871010]],[\"^7[\",[2896,\"^3V\",2887,536871010]],[\"^7[\",[2896,\"^65\",\"\",536871010]],[\"^7[\",[2896,\"^3;\",1775987298853,536871010]],[\"^7[\",[2896,\"^2T\",\"~u69db6a62-0a49-4815-b61f-29e29fcda901\",536871010]],[\"^7[\",[2896,\"^4<\",181,536871010]],[\"^7[\",[2897,\"^48\",1775987299264,536871012]],[\"^7[\",[2897,\"^7;\",\"aG\",536871012]],[\"^7[\",[2897,\"^4P\",180,536871012]],[\"^7[\",[2897,\"^3V\",180,536871012]],[\"^7[\",[2897,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-9\",536871012]],[\"^7[\",[2897,\"^3;\",1775987299264,536871012]],[\"^7[\",[2897,\"^2T\",\"~u69db6a63-588e-4241-90ca-98997b80c8a0\",536871012]],[\"^7[\",[2897,\"^4<\",181,536871012]],[\"^7[\",[2898,\"^48\",1775987299832,536871015]],[\"^7[\",[2898,\"^7;\",\"a0V\",536871096]],[\"^7[\",[2898,\"^4P\",180,536871015]],[\"^7[\",[2898,\"^3V\",2885,536871019]],[\"^7[\",[2898,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-12\",536871015]],[\"^7[\",[2898,\"^3;\",1775987299832,536871015]],[\"^7[\",[2898,\"^2T\",\"~u69db6a63-fc25-40a9-aeb8-7952fe7c669b\",536871015]],[\"^7[\",[2898,\"^4<\",181,536871015]],[\"^7[\",[2899,\"^48\",1775987300449,536871017]],[\"^7[\",[2899,\"^7;\",\"aGt\",536871104]],[\"^7[\",[2899,\"^4P\",180,536871017]],[\"^7[\",[2899,\"^3V\",180,536871022]],[\"^7[\",[2899,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-14\",536871017]],[\"^7[\",[2899,\"^3;\",1775987300449,536871017]],[\"^7[\",[2899,\"^2T\",\"~u69db6a64-50d3-4c8e-886f-8611aa7fbd8a\",536871017]],[\"^7[\",[2899,\"^4<\",181,536871017]],[\"^7[\",[2900,\"^48\",1775987304021,536871018]],[\"^7[\",[2900,\"^7;\",\"a0\",536871018]],[\"^7[\",[2900,\"^4P\",180,536871018]],[\"^7[\",[2900,\"^3V\",2876,536871018]],[\"^7[\",[2900,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-15\",536871018]],[\"^7[\",[2900,\"^3;\",1775987304021,536871018]],[\"^7[\",[2900,\"^2T\",\"~u69db6a68-891c-4435-aee5-07830b9e3d93\",536871018]],[\"^7[\",[2900,\"^4<\",181,536871018]],[\"^7[\",[2901,\"^48\",1775987304634,536871020]],[\"^7[\",[2901,\"^7;\",\"aHV\",536871020]],[\"^7[\",[2901,\"^4P\",180,536871020]],[\"^7[\",[2901,\"^3V\",180,536871020]],[\"^7[\",[2901,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-17\",536871020]],[\"^7[\",[2901,\"^3;\",1775987304634,536871020]],[\"^7[\",[2901,\"^2T\",\"~u69db6a68-fe4a-4780-84bf-0555ffdb0933\",536871020]],[\"^7[\",[2901,\"^4<\",181,536871020]],[\"^7[\",[2902,\"^48\",1775987305003,536871021]],[\"^7[\",[2902,\"^7;\",\"ZyV\",536871021]],[\"^7[\",[2902,\"^4P\",180,536871021]],[\"^7[\",[2902,\"^3V\",2861,536871021]],[\"^7[\",[2902,\"^65\",\"\",536871021]],[\"^7[\",[2902,\"^3;\",1775987305003,536871021]],[\"^7[\",[2902,\"^2T\",\"~u69db6a68-b629-4cd9-b732-4c4aba8a2fab\",536871021]],[\"^7[\",[2902,\"^4<\",181,536871021]],[\"^7[\",[2903,\"^48\",1775987305653,536871023]],[\"^7[\",[2903,\"^7;\",\"aGp\",536871023]],[\"^7[\",[2903,\"^4P\",180,536871023]],[\"^7[\",[2903,\"^3V\",180,536871023]],[\"^7[\",[2903,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-20\",536871023]],[\"^7[\",[2903,\"^3;\",1775987305653,536871023]],[\"^7[\",[2903,\"^2T\",\"~u69db6a69-6bd9-43a2-bee4-4cddad5d9814\",536871023]],[\"^7[\",[2903,\"^4<\",181,536871023]],[\"^7[\",[2904,\"^48\",1775987305977,536871024]],[\"^7[\",[2904,\"^7;\",\"aF8l\",536871024]],[\"^7[\",[2904,\"^4P\",180,536871024]],[\"^7[\",[2904,\"^3V\",180,536871024]],[\"^7[\",[2904,\"^65\",\"\",536871024]],[\"^7[\",[2904,\"^3;\",1775987305977,536871024]],[\"^7[\",[2904,\"^2T\",\"~u69db6a69-af10-46ea-af0a-b019f994745d\",536871024]],[\"^7[\",[2904,\"^4<\",181,536871024]]]]]]", :db-after "[\"~#datascript/DB\",[\"^ \",\"~:schema\",[\"^ \",\"~i32\",\"~:logseq.property/priority\",\"~i64\",\"~:logseq.property/ls-type\",\"~i96\",\"~:logseq.property.repeat/repeated?\",\"~i128\",\"~:logseq.property/view-for\",\"~:logseq.property.asset/align\",[\"^ \",\"~:db/ident\",\"^:\",\"~:db/cardinality\",\"~:db.cardinality/one\",\"~:db/index\",true],\"~:logseq.property.view/type.table\",[\"^ \",\"^;\",\"^?\"],\"~:logseq.property/ui-position\",[\"^ \",\"^;\",\"^@\",\"^<\",\"^=\",\"^>\",true],\"~:file/created-at\",[\"^ \"],\"~:logseq.property.repeat/temporal-property\",[\"^ \",\"^;\",\"^B\",\"^<\",\"^=\",\"^>\",true,\"~:db/valueType\",\"~:db.type/ref\"],\"~:logseq.property.user/name\",[\"^ \",\"^;\",\"^E\",\"^<\",\"^=\",\"^>\",true],\"~i1\",\"~:logseq.property.reaction/target\",\"~i33\",\"~:logseq.property/priority.urgent\",\"~i65\",\"~:logseq.property/color.yellow\",\"~i97\",\"~:block/closed-value-property\",\"~:logseq.property.pdf/hl-image\",[\"^ \",\"^;\",\"^M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i129\",\"~:logseq.property.user/avatar\",\"~:logseq.property.fsrs/state\",[\"^ \",\"^;\",\"^P\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.pdf/hl-color\",[\"^ \",\"^;\",\"^Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-initial-schema-version\",[\"^ \",\"^;\",\"^R\"],\"~:block/tx-id\",[\"^ \"],\"~:logseq.property/value\",[\"^ \",\"^;\",\"^T\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.week\",[\"^ \",\"^;\",\"^U\"],\"~i2\",\"~:logseq.kv/db-type\",\"~i34\",\"~:logseq.property/public?\",\"~i66\",\"~:logseq.property.asset/width\",\"~i98\",\"~:logseq.property.reaction/emoji-id\",\"~i130\",\"^E\",\"~:logseq.property.table/sorting\",[\"^ \",\"^;\",\"^12\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/choice-exclusions\",[\"^ \",\"^;\",\"^13\",\"^<\",\"~:db.cardinality/many\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.table/pinned-columns\",[\"^ \",\"^;\",\"^15\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit.day\",[\"^ \",\"^;\",\"^16\"],\"~i3\",\"~:logseq.property.repeat/checked-property\",\"~i35\",\"~:logseq.property.recycle/original-order\",\"~i67\",\"~:logseq.property.asset/remote-metadata\",\"~i99\",\"~:logseq.property/priority.high\",\"~i131\",\"~:logseq.property.asset/size\",\"~:logseq.property.sync/large-title-object\",[\"^ \",\"^;\",\"^1@\",\"^<\",\"^=\",\"^>\",true],\"~i4\",\"~:logseq.class/Pdf-annotation\",\"~i36\",\"~:block/journal-day\",\"~i68\",\"~:logseq.property.view/type.list\",\"~i100\",\"~:logseq.property/color.blue\",\"~i132\",\"~:logseq.property.class/bidirectional-property-title\",\"~:logseq.property/deadline\",[\"^ \",\"^;\",\"^1J\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/type\",[\"^ \",\"^;\",\"^1K\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.year\",[\"^ \",\"^;\",\"^1L\"],\"~:file/content\",[\"^ \"],\"~:user.property/p3-Bhx1XTp3\",[\"^ \",\"^;\",\"^1N\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i5\",\"~:logseq.property.asset/checksum\",\"~i37\",\"~:logseq.property.node/display-type\",\"~i69\",\"~:block/refs\",\"~i101\",\"~:logseq.property.table/ordered-columns\",\"~:logseq.property.view/sort-groups-desc?\",[\"^ \",\"^;\",\"^1V\",\"^<\",\"^=\",\"^>\",true],\"~i133\",\"~:logseq.property.class/hide-from-node\",\"~:logseq.property.table/sized-columns\",[\"^ \",\"^;\",\"^1Y\",\"^<\",\"^=\",\"^>\",true],\"~:block/alias\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^>\",true,\"^;\",\"^1Z\"],\"~:logseq.property.publish/published-url\",[\"^ \",\"^;\",\"^1[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/external-url\",[\"^ \",\"^;\",\"^20\",\"^<\",\"^=\",\"^>\",true],\"~i6\",\"^1Y\",\"^O\",[\"^ \",\"^;\",\"^O\",\"^<\",\"^=\",\"^>\",true],\"~i38\",\"~:logseq.class/Template\",\"~:user.property/p5-q5guozE4\",[\"^ \",\"^;\",\"^23\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i70\",\"^?\",\"~i102\",\"~:logseq.property/background-color\",\"~i134\",\"^B\",\"~:kv/value\",[\"^ \"],\"~:logseq.property/scalar-default-value\",[\"^ \",\"^;\",\"^29\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/type\",[\"^ \",\"^;\",\"^2:\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Asset\",[\"^ \",\"^;\",\"^2;\"],\"^19\",[\"^ \",\"^;\",\"^19\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.class/enable-bidirectional?\",[\"^ \",\"^;\",\"^2<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/color.green\",[\"^ \",\"^;\",\"^2=\"],\"~i7\",\"^1Z\",\"~i39\",\"~:logseq.kv/local-graph-uuid\",\"~i71\",\"~:logseq.property/description\",\"~i103\",\"~:logseq.class/Cards\",\"~i135\",\"~:logseq.property.repeat/recur-unit.minute\",\"~:logseq.property.linked-references/includes\",[\"^ \",\"^;\",\"^2F\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit\",[\"^ \",\"^;\",\"^2G\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/checkbox-display-properties\",[\"^ \",\"^;\",\"^2H\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:block/link\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^2I\",\"^<\",\"^=\"],\"~:logseq.property.view/type\",[\"^ \",\"^;\",\"^2J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.class/Quote-block\",[\"^ \",\"^;\",\"^2K\"],\"~:logseq.property/heading\",[\"^ \",\"^;\",\"^2L\",\"^<\",\"^=\",\"^>\",true],\"~i8\",\"~:logseq.property.view/sort-groups-by-property\",\"~i40\",\"~:logseq.property.pdf/hl-page\",\"~i72\",\"~:logseq.property.recycle/original-page\",\"~i104\",\"~:logseq.class/Page\",\"~:block/uuid\",[\"^ \",\"~:db/unique\",\"~:db.unique/identity\"],\"~i136\",\"~:logseq.property/query\",\"~:logseq.property/color.purple\",[\"^ \",\"^;\",\"^2Y\"],\"~:logseq.property.asset/external-file-name\",[\"^ \",\"^;\",\"^2Z\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.table/hidden-columns\",[\"^ \",\"^;\",\"^2[\",\"^<\",\"^14\",\"^>\",true],\"~:logseq.class/Property\",[\"^ \",\"^;\",\"^30\"],\"~:logseq.kv/graph-git-sha\",[\"^ \",\"^;\",\"^31\"],\"~i9\",\"~:logseq.property/status.doing\",\"~i41\",\"^2H\",\"~i73\",\"~:logseq.property.linked-references/excludes\",\"~i105\",\"^U\",\"^2S\",[\"^ \",\"^;\",\"^2S\"],\"~i137\",\"~:logseq.property.user/email\",\"~:logseq.kv/graph-remote?\",[\"^ \",\"^;\",\"^39\"],\"^2?\",[\"^ \",\"^;\",\"^2?\"],\"~:logseq.kv/graph-rtc-e2ee?\",[\"^ \",\"^;\",\"^3:\"],\"~:block/updated-at\",[\"^ \",\"^>\",true,\"^;\",\"^3;\",\"^<\",\"^=\"],\"^1G\",[\"^ \",\"^;\",\"^1G\"],\"~i10\",\"~:logseq.class/Math-block\",\"~i42\",\"^2Y\",\"~i74\",\"~:logseq.property.history/scalar-value\",\"~i106\",\"~:logseq.property.tldraw/shape\",\"~i138\",\"~:block/collapsed?\",\"^1E\",[\"^ \",\"^;\",\"^1E\"],\"^F\",[\"^ \",\"^;\",\"^F\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i11\",\"~:logseq.property/status.canceled\",\"~:file/size\",[\"^ \"],\"~i43\",\"^P\",\"~:logseq.property/status\",[\"^ \",\"^;\",\"^3I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i75\",\"^1J\",\"~:logseq.property.class/extends\",[\"^ \",\"^;\",\"^3K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i107\",\"~:logseq.property.asset/height\",\"~i139\",\"~:logseq.property.repeat/recur-unit.hour\",\"~:logseq.property/hide-empty-value\",[\"^ \",\"^;\",\"^3P\",\"^<\",\"^=\",\"^>\",true],\"^2M\",[\"^ \",\"^;\",\"^2M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1S\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^1S\",\"^>\",true],\"~i619\",\"~:user.class/tag1-bGi52uW6\",\"~:logseq.class/Card\",[\"^ \",\"^;\",\"^3S\"],\"~:user.property/p2-JBrZedg-\",[\"^ \",\"^;\",\"^3T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^10\",[\"^ \",\"^;\",\"^10\",\"^<\",\"^=\",\"^>\",true],\"~i12\",\"~:block/parent\",\"~i44\",\"^1V\",\"~i76\",\"^2=\",\"~i2124\",\"~:user.class/tag2-GCc7Ko89\",\"~i108\",\"~:logseq.property.code/lang\",\"~i140\",\"~:logseq.property.tldraw/page\",\"~:logseq.property.view/group-by-property\",[\"^ \",\"^;\",\"^43\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/view-context\",[\"^ \",\"^;\",\"^44\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/schema-version\",[\"^ \",\"^;\",\"^45\"],\"^2O\",[\"^ \",\"^;\",\"^2O\",\"^<\",\"^=\",\"^>\",true],\"~i13\",\"^2G\",\"~i45\",\"~:block/created-at\",\"^L\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^=\",\"^;\",\"^L\",\"^>\",true],\"~i77\",\"~:logseq.property/choice-classes\",\"~i109\",\"~:logseq.property/created-by-ref\",\"^1O\",[\"^ \",\"^;\",\"^1O\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/publishing-public?\",[\"^ \",\"^;\",\"^4=\",\"^<\",\"^=\",\"^>\",true],\"~i141\",\"^31\",\"~:user.property/p10-QpfPhdWj\",[\"^ \",\"^;\",\"^4?\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1Q\",[\"^ \",\"^;\",\"^1Q\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/status.backlog\",[\"^ \",\"^;\",\"^4@\"],\"~i14\",\"~:logseq.class/Tag\",\"~:file/last-modified-at\",[\"^ \"],\"~i46\",\"^2;\",\"~i78\",\"^2K\",\"~i2126\",\"^23\",\"~i110\",\"^13\",\"~i142\",\"~:logseq.class/Query\",\"~:logseq.property.class/properties\",[\"^ \",\"^;\",\"^4J\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/used-template\",[\"^ \",\"^;\",\"^4K\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/enable-history?\",[\"^ \",\"^;\",\"^4L\",\"^<\",\"^=\",\"^>\",true],\"~i15\",\"~:logseq.kv/graph-created-at\",\"~i47\",\"~:block/page\",\"~i79\",\"^29\",\"^3=\",[\"^ \",\"^;\",\"^3=\"],\"~i111\",\"^R\",\"~i143\",\"~:logseq.property/choice-checkbox-state\",\"^48\",[\"^ \",\"^>\",true,\"^;\",\"^48\",\"^<\",\"^=\"],\"^3@\",[\"^ \",\"^;\",\"^3@\",\"^<\",\"^=\",\"^>\",true],\"~i2351\",\"^4?\",\"~:logseq.property/page-tags\",[\"^ \",\"^;\",\"^4V\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/scheduled\",[\"^ \",\"^;\",\"^4W\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Task\",[\"^ \",\"^;\",\"^4X\"],\"^3D\",[\"^ \",\"^;\",\"^3D\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/last-visit-page\",[\"^ \",\"^;\",\"^4Y\",\"^<\",\"^=\",\"^>\",true],\"^42\",[\"^ \",\"^;\",\"^42\",\"^<\",\"^=\",\"^>\",true],\"~i16\",\"~:logseq.property/icon\",\"~i48\",\"^1L\",\"~i80\",\"^4Y\",\"~i112\",\"~:logseq.property.journal/title-format\",\"~i144\",\"^3P\",\"~:logseq.property/built-in?\",[\"^ \",\"^;\",\"^55\",\"^<\",\"^=\",\"^>\",true],\"~i17\",\"^2Z\",\"~i49\",\"~:logseq.property/exclude-from-graph-view\",\"~i81\",\"^4K\",\"^1X\",[\"^ \",\"^;\",\"^1X\",\"^<\",\"^=\",\"^>\",true],\"~i113\",\"^2F\",\"~i145\",\"^3;\",\"^35\",[\"^ \",\"^;\",\"^35\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.view/feature-type\",[\"^ \",\"^;\",\"^5<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Code-block\",[\"^ \",\"^;\",\"^5=\"],\"^4T\",[\"^ \",\"^;\",\"^4T\",\"^<\",\"^=\",\"^>\",true],\"^17\",[\"^ \",\"^;\",\"^17\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/resize-metadata\",[\"^ \",\"^;\",\"^5>\",\"^<\",\"^=\",\"^>\",true],\"^5\",[\"^ \",\"^;\",\"^5\",\"^<\",\"^=\",\"^>\",true],\"^2C\",[\"^ \",\"^;\",\"^2C\"],\"^38\",[\"^ \",\"^;\",\"^38\",\"^<\",\"^=\",\"^>\",true],\"^9\",[\"^ \",\"^;\",\"^9\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^Z\",[\"^ \",\"^;\",\"^Z\",\"^<\",\"^=\",\"^>\",true],\"~i18\",\"~:logseq.property.history/block\",\"^2X\",[\"^ \",\"^;\",\"^2X\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i50\",\"^15\",\"~i82\",\"~:logseq.property.repeat/recur-unit.month\",\"~i114\",\"~:logseq.property/status.todo\",\"~i146\",\"^20\",\"~:logseq.property/hide?\",[\"^ \",\"^;\",\"^5G\",\"^<\",\"^=\",\"^>\",true],\"^26\",[\"^ \",\"^;\",\"^26\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-frequency\",[\"^ \",\"^;\",\"^5H\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/default-value\",[\"^ \",\"^;\",\"^5I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1C\",[\"^ \",\"^>\",true,\"^;\",\"^1C\",\"^<\",\"^=\"],\"~:user.property/p1-Ka7c_Z9D\",[\"^ \",\"^;\",\"^5J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/color.red\",[\"^ \",\"^;\",\"^5K\"],\"~i19\",\"^Q\",\"~:logseq.class/Root\",[\"^ \",\"^;\",\"^5M\"],\"~i51\",\"^1@\",\"~i83\",\"~:logseq.property.history/ref-value\",\"~i115\",\"~:logseq.property.history/property\",\"~i147\",\"~:logseq.property/template-applied-to\",\"^2E\",[\"^ \",\"^;\",\"^2E\"],\"~:logseq.property/empty-placeholder\",[\"^ \",\"^;\",\"^5U\"],\"^40\",[\"^ \",\"^;\",\"^40\",\"^<\",\"^=\",\"^>\",true],\"~i20\",\"^2<\",\"~i52\",\"^45\",\"~i84\",\"~:logseq.property.table/filters\",\"~i116\",\"~:logseq.property.recycle/original-parent\",\"^4<\",[\"^ \",\"^;\",\"^4<\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-uuid\",[\"^ \",\"^;\",\"^60\"],\"~i148\",\"~:logseq.property/status.in-review\",\"^1=\",[\"^ \",\"^;\",\"^1=\"],\"~:logseq.property/priority.low\",[\"^ \",\"^;\",\"^63\"],\"~:block/tags\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^64\",\"^>\",true],\"~:block/title\",[\"^ \",\"^>\",true,\"^;\",\"^65\",\"^<\",\"^=\"],\"~:user.property/p4-Et0d5LxL\",[\"^ \",\"^;\",\"^66\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^3B\",[\"^ \",\"^;\",\"^3B\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Whiteboard\",[\"^ \",\"^;\",\"^67\"],\"~i21\",\"^65\",\"~i53\",\"~:logseq.property/created-from-property\",\"~i85\",\"~:logseq.class/Journal\",\"~i117\",\"~:logseq.property.pdf/hl-value\",\"~i149\",\"^1[\",\"^6:\",[\"^ \",\"^;\",\"^6:\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i565\",\"^5J\",\"^4[\",[\"^ \",\"^;\",\"^4[\",\"^<\",\"^=\",\"^>\",true],\"^1I\",[\"^ \",\"^;\",\"^1I\",\"^<\",\"^=\",\"^>\",true],\"^H\",[\"^ \",\"^;\",\"^H\"],\"^4:\",[\"^ \",\"^;\",\"^4:\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^1?\",[\"^ \",\"^;\",\"^1?\",\"^<\",\"^=\",\"^>\",true],\"~i22\",\"~:logseq.property/classes\",\"~i54\",\"^4=\",\"~i86\",\"^55\",\"~i118\",\"^43\",\"~i150\",\"~:logseq.property.view/type.gallery\",\"~i182\",\"^60\",\"^6B\",[\"^ \",\"^;\",\"^6B\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/asset\",[\"^ \",\"^;\",\"^6I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^4N\",[\"^ \",\"^;\",\"^4N\"],\"~i23\",\"^39\",\"~i55\",\"~:logseq.property/status.done\",\"~:logseq.property/deleted-at\",[\"^ \",\"^;\",\"^6M\",\"^<\",\"^=\",\"^>\",true],\"~i87\",\"^67\",\"~i119\",\"^5G\",\"~i151\",\"^5U\",\"~i183\",\"^3:\",\"~:logseq.property/order-list-type\",[\"^ \",\"^;\",\"^6R\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^58\",[\"^ \",\"^;\",\"^58\",\"^<\",\"^=\",\"^>\",true],\"~i24\",\"^5>\",\"~:logseq.property/deleted-by-ref\",[\"^ \",\"^;\",\"^6T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i56\",\"^T\",\"~:logseq.property.pdf/hl-type\",[\"^ \",\"^;\",\"^6V\",\"^<\",\"^=\",\"^>\",true],\"~i88\",\"^:\",\"~i120\",\"^2I\",\"~i152\",\"^4X\",\"^;\",[\"^ \",\"^2U\",\"^2V\"],\"^6G\",[\"^ \",\"^;\",\"^6G\"],\"^2A\",[\"^ \",\"^;\",\"^2A\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i568\",\"^3T\",\"^5@\",[\"^ \",\"^;\",\"^5@\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i600\",\"^66\",\"^3M\",[\"^ \",\"^;\",\"^3M\",\"^<\",\"^=\",\"^>\",true],\"^3V\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^3V\",\"^<\",\"^=\"],\"~i25\",\"^6M\",\"~i57\",\"^2:\",\"~i89\",\"^5M\",\"^2Q\",[\"^ \",\"^;\",\"^2Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i121\",\"^4J\",\"~i153\",\"~:logseq.property/priority.medium\",\"~i569\",\"^1N\",\"^6<\",[\"^ \",\"^;\",\"^6<\"],\"~i26\",\"^3I\",\"~i58\",\"^64\",\"~i90\",\"^2L\",\"^3O\",[\"^ \",\"^;\",\"^3O\"],\"^J\",[\"^ \",\"^;\",\"^J\"],\"~i122\",\"~:block/order\",\"~i154\",\"^1K\",\"^5[\",[\"^ \",\"^;\",\"^5[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^75\",[\"^ \",\"^;\",\"^75\"],\"^5R\",[\"^ \",\"^;\",\"^5R\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^6>\",[\"^ \",\"^;\",\"^6>\",\"^<\",\"^=\",\"^>\",true],\"^4I\",[\"^ \",\"^;\",\"^4I\"],\"~i27\",\"^3S\",\"~i59\",\"^6R\",\"~i91\",\"^M\",\"~i123\",\"^4L\",\"~i155\",\"^4W\",\"^1;\",[\"^ \",\"^;\",\"^1;\",\"^<\",\"^=\",\"^>\",true],\"^3Z\",[\"^ \",\"^;\",\"^3Z\"],\"^5C\",[\"^ \",\"^;\",\"^5C\"],\"^7;\",[\"^ \",\"^>\",true,\"^;\",\"^7;\",\"^<\",\"^=\"],\"^3\",[\"^ \",\"^;\",\"^3\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5P\",[\"^ \",\"^;\",\"^5P\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.fsrs/due\",[\"^ \",\"^;\",\"^7B\",\"^<\",\"^=\",\"^>\",true],\"~i28\",\"^5=\",\"^32\",[\"^ \",\"^;\",\"^32\"],\"~i60\",\"^@\",\"~i92\",\"^44\",\"~i124\",\"^30\",\"^4P\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^4P\",\"^<\",\"^=\"],\"~i156\",\"^5I\",\"~:block/name\",[\"^ \",\"^>\",true],\"^1A\",[\"^ \",\"^;\",\"^1A\"],\"^1U\",[\"^ \",\"^;\",\"^1U\",\"^<\",\"^=\",\"^>\",true],\"~:file/path\",[\"^ \",\"^2U\",\"^2V\"],\"~i29\",\"^7B\",\"~i61\",\"^16\",\"~i93\",\"^5<\",\"~i125\",\"^4V\",\"~i157\",\"^6T\",\"^7\",[\"^ \",\"^;\",\"^7\",\"^<\",\"^=\",\"^>\",true],\"^4B\",[\"^ \",\"^;\",\"^4B\"],\"^62\",[\"^ \",\"^;\",\"^62\"],\"^X\",[\"^ \",\"^;\",\"^X\",\"^<\",\"^=\",\"^>\",true],\"~i30\",\"^6I\",\"~i62\",\"^6V\",\"~i94\",\"^63\",\"~i126\",\"^4@\",\"~i158\",\"^3K\",\"^3F\",[\"^ \",\"^;\",\"^3F\"],\"^5T\",[\"^ \",\"^;\",\"^5T\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^22\",[\"^ \",\"^;\",\"^22\"],\"^53\",[\"^ \",\"^;\",\"^53\",\"^<\",\"^=\",\"^>\",true],\"^6L\",[\"^ \",\"^;\",\"^6L\"],\"~i31\",\"^5K\",\"~i63\",\"^2[\",\"^V\",[\"^ \",\"^;\",\"^V\"],\"~i95\",\"^5H\",\"~i127\",\"^2J\",\"~i159\",\"^12\",\"^5Y\",[\"^ \",\"^;\",\"^5Y\",\"^<\",\"^=\",\"^>\",true],\"^3R\",[\"^ \",\"^;\",\"^3R\"],\"^5E\",[\"^ \",\"^;\",\"^5E\"]],\"~:datoms\",[\"~#list\",[[\"~#datascript/Datom\",[1,\"^48\",1775980635495,536870913]],[\"^7[\",[1,\"^7H\",\"reaction target\",536870913]],[\"^7[\",[1,\"^7;\",\"d3BHM\",536870913]],[\"^7[\",[1,\"^64\",124,536870913]],[\"^7[\",[1,\"^65\",\"Reaction target\",536870913]],[\"^7[\",[1,\"^3;\",1775980635495,536870913]],[\"^7[\",[1,\"^2T\",\"~u00000002-1127-5718-5000-000000000000\",536870913]],[\"^7[\",[1,\"^<\",\"^=\",536870913]],[\"^7[\",[1,\"^;\",\"^F\",536870913]],[\"^7[\",[1,\"^>\",true,536870913]],[\"^7[\",[1,\"^C\",\"^D\",536870913]],[\"^7[\",[1,\"^55\",true,536870913]],[\"^7[\",[1,\"^5G\",true,536870913]],[\"^7[\",[1,\"^X\",false,536870913]],[\"^7[\",[1,\"^1K\",\"~:node\",536870913]],[\"^7[\",[2,\"^;\",\"^V\",536870913]],[\"^7[\",[2,\"^28\",\"db\",536870913]],[\"^7[\",[3,\"^48\",1775980635492,536870913]],[\"^7[\",[3,\"^7H\",\"repeating checked property\",536870913]],[\"^7[\",[3,\"^7;\",\"d3BGY\",536870913]],[\"^7[\",[3,\"^64\",124,536870913]],[\"^7[\",[3,\"^65\",\"Repeating Checked Property\",536870913]],[\"^7[\",[3,\"^3;\",1775980635492,536870913]],[\"^7[\",[3,\"^2T\",\"~u00000002-1866-3655-5300-000000000000\",536870913]],[\"^7[\",[3,\"^<\",\"^=\",536870913]],[\"^7[\",[3,\"^;\",\"^17\",536870913]],[\"^7[\",[3,\"^>\",true,536870913]],[\"^7[\",[3,\"^C\",\"^D\",536870913]],[\"^7[\",[3,\"^55\",true,536870913]],[\"^7[\",[3,\"^5G\",true,536870913]],[\"^7[\",[3,\"^1K\",\"~:property\",536870913]],[\"^7[\",[4,\"^48\",1775980635496,536870913]],[\"^7[\",[4,\"^7H\",\"pdf annotation\",536870913]],[\"^7[\",[4,\"^64\",14,536870913]],[\"^7[\",[4,\"^65\",\"PDF Annotation\",536870913]],[\"^7[\",[4,\"^3;\",1775980635496,536870913]],[\"^7[\",[4,\"^2T\",\"~u00000002-5049-5962-0000-000000000000\",536870913]],[\"^7[\",[4,\"^;\",\"^1A\",536870913]],[\"^7[\",[4,\"^55\",true,536870913]],[\"^7[\",[4,\"^3K\",89,536870913]],[\"^7[\",[4,\"^1X\",true,536870913]],[\"^7[\",[4,\"^4J\",19,536870913]],[\"^7[\",[4,\"^4J\",30,536870913]],[\"^7[\",[4,\"^4J\",40,536870913]],[\"^7[\",[4,\"^4J\",62,536870913]],[\"^7[\",[4,\"^4J\",64,536870913]],[\"^7[\",[4,\"^4J\",91,536870913]],[\"^7[\",[4,\"^4J\",117,536870913]],[\"^7[\",[5,\"^48\",1775980635493,536870913]],[\"^7[\",[5,\"^7H\",\"file checksum\",536870913]],[\"^7[\",[5,\"^7;\",\"d3BGy\",536870913]],[\"^7[\",[5,\"^64\",124,536870913]],[\"^7[\",[5,\"^65\",\"File checksum\",536870913]],[\"^7[\",[5,\"^3;\",1775980635493,536870913]],[\"^7[\",[5,\"^2T\",\"~u00000002-1011-4169-7900-000000000000\",536870913]],[\"^7[\",[5,\"^<\",\"^=\",536870913]],[\"^7[\",[5,\"^;\",\"^1O\",536870913]],[\"^7[\",[5,\"^>\",true,536870913]],[\"^7[\",[5,\"^55\",true,536870913]],[\"^7[\",[5,\"^5G\",true,536870913]],[\"^7[\",[5,\"^X\",false,536870913]],[\"^7[\",[5,\"^1K\",\"~:string\",536870913]],[\"^7[\",[6,\"^48\",1775980635493,536870913]],[\"^7[\",[6,\"^7H\",\"view columns settings\",536870913]],[\"^7[\",[6,\"^7;\",\"d3BGp\",536870913]],[\"^7[\",[6,\"^64\",124,536870913]],[\"^7[\",[6,\"^65\",\"View columns settings\",536870913]],[\"^7[\",[6,\"^3;\",1775980635493,536870913]],[\"^7[\",[6,\"^2T\",\"~u00000002-1675-5105-5500-000000000000\",536870913]],[\"^7[\",[6,\"^<\",\"^=\",536870913]],[\"^7[\",[6,\"^;\",\"^1Y\",536870913]],[\"^7[\",[6,\"^>\",true,536870913]],[\"^7[\",[6,\"^55\",true,536870913]],[\"^7[\",[6,\"^5G\",true,536870913]],[\"^7[\",[6,\"^X\",false,536870913]],[\"^7[\",[6,\"^1K\",\"~:map\",536870913]],[\"^7[\",[7,\"^48\",1775980635487,536870913]],[\"^7[\",[7,\"^7H\",\"alias\",536870913]],[\"^7[\",[7,\"^7;\",\"d3BFF\",536870913]],[\"^7[\",[7,\"^64\",124,536870913]],[\"^7[\",[7,\"^65\",\"Alias\",536870913]],[\"^7[\",[7,\"^3;\",1775980635487,536870913]],[\"^7[\",[7,\"^2T\",\"~u00000002-2112-6446-9900-000000000000\",536870913]],[\"^7[\",[7,\"^<\",\"^14\",536870913]],[\"^7[\",[7,\"^;\",\"^1Z\",536870913]],[\"^7[\",[7,\"^>\",true,536870913]],[\"^7[\",[7,\"^C\",\"^D\",536870913]],[\"^7[\",[7,\"^55\",true,536870913]],[\"^7[\",[7,\"^X\",true,536870913]],[\"^7[\",[7,\"^1K\",\"~:page\",536870913]],[\"^7[\",[7,\"^44\",\"^84\",536870913]],[\"^7[\",[8,\"^48\",1775980635492,536870913]],[\"^7[\",[8,\"^7H\",\"view sort groups by\",536870913]],[\"^7[\",[8,\"^7;\",\"d3BGj\",536870913]],[\"^7[\",[8,\"^64\",124,536870913]],[\"^7[\",[8,\"^65\",\"View sort groups by\",536870913]],[\"^7[\",[8,\"^3;\",1775980635492,536870913]],[\"^7[\",[8,\"^2T\",\"~u00000002-6253-4002-1000-000000000000\",536870913]],[\"^7[\",[8,\"^<\",\"^=\",536870913]],[\"^7[\",[8,\"^;\",\"^2M\",536870913]],[\"^7[\",[8,\"^>\",true,536870913]],[\"^7[\",[8,\"^C\",\"^D\",536870913]],[\"^7[\",[8,\"^55\",true,536870913]],[\"^7[\",[8,\"^5G\",true,536870913]],[\"^7[\",[8,\"^X\",false,536870913]],[\"^7[\",[8,\"^1K\",\"^81\",536870913]],[\"^7[\",[9,\"^L\",26,536870913]],[\"^7[\",[9,\"^48\",1775980635491,536870913]],[\"^7[\",[9,\"^7;\",\"d3BGA\",536870913]],[\"^7[\",[9,\"^4P\",26,536870913]],[\"^7[\",[9,\"^3V\",26,536870913]],[\"^7[\",[9,\"^65\",\"Doing\",536870913]],[\"^7[\",[9,\"^3;\",1775980635491,536870913]],[\"^7[\",[9,\"^2T\",\"~u00000002-1840-1229-0800-000000000000\",536870913]],[\"^7[\",[9,\"^;\",\"^32\",536870913]],[\"^7[\",[9,\"^55\",true,536870913]],[\"^7[\",[9,\"^6:\",26,536870913]],[\"^7[\",[9,\"^4[\",[\"^ \",\"~:type\",\"~:tabler-icon\",\"~:id\",\"InProgress50\"],536870913]],[\"^7[\",[10,\"^48\",1775980635496,536870913]],[\"^7[\",[10,\"^7H\",\"math\",536870913]],[\"^7[\",[10,\"^64\",14,536870913]],[\"^7[\",[10,\"^65\",\"Math\",536870913]],[\"^7[\",[10,\"^3;\",1775980635496,536870913]],[\"^7[\",[10,\"^2T\",\"~u00000002-2038-9631-2100-000000000000\",536870913]],[\"^7[\",[10,\"^;\",\"^3=\",536870913]],[\"^7[\",[10,\"^55\",true,536870913]],[\"^7[\",[10,\"^3K\",89,536870913]],[\"^7[\",[10,\"^1X\",true,536870913]],[\"^7[\",[10,\"^4J\",37,536870913]],[\"^7[\",[11,\"^L\",26,536870913]],[\"^7[\",[11,\"^48\",1775980635491,536870913]],[\"^7[\",[11,\"^7;\",\"d3BGD\",536870913]],[\"^7[\",[11,\"^4P\",26,536870913]],[\"^7[\",[11,\"^3V\",26,536870913]],[\"^7[\",[11,\"^65\",\"Canceled\",536870913]],[\"^7[\",[11,\"^3;\",1775980635491,536870913]],[\"^7[\",[11,\"^2T\",\"~u00000002-7526-4326-2000-000000000000\",536870913]],[\"^7[\",[11,\"^;\",\"^3F\",536870913]],[\"^7[\",[11,\"^55\",true,536870913]],[\"^7[\",[11,\"^6:\",26,536870913]],[\"^7[\",[11,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Cancelled\"],536870913]],[\"^7[\",[12,\"^48\",1775980635488,536870913]],[\"^7[\",[12,\"^7H\",\"node parent\",536870913]],[\"^7[\",[12,\"^7;\",\"d3BFH\",536870913]],[\"^7[\",[12,\"^64\",124,536870913]],[\"^7[\",[12,\"^65\",\"Node parent\",536870913]],[\"^7[\",[12,\"^3;\",1775980635488,536870913]],[\"^7[\",[12,\"^2T\",\"~u00000002-9183-0906-4000-000000000000\",536870913]],[\"^7[\",[12,\"^<\",\"^=\",536870913]],[\"^7[\",[12,\"^;\",\"^3V\",536870913]],[\"^7[\",[12,\"^>\",true,536870913]],[\"^7[\",[12,\"^C\",\"^D\",536870913]],[\"^7[\",[12,\"^55\",true,536870913]],[\"^7[\",[12,\"^5G\",true,536870913]],[\"^7[\",[12,\"^X\",false,536870913]],[\"^7[\",[12,\"^1K\",\"~:entity\",536870913]],[\"^7[\",[13,\"^48\",1775980635491,536870913]],[\"^7[\",[13,\"^7H\",\"repeating recur unit\",536870913]],[\"^7[\",[13,\"^7;\",\"d3BGP\",536870913]],[\"^7[\",[13,\"^64\",124,536870913]],[\"^7[\",[13,\"^65\",\"Repeating recur unit\",536870913]],[\"^7[\",[13,\"^3;\",1775980635491,536870913]],[\"^7[\",[13,\"^2T\",\"~u00000002-6903-0624-7000-000000000000\",536870913]],[\"^7[\",[13,\"^<\",\"^=\",536870913]],[\"^7[\",[13,\"^;\",\"^2G\",536870913]],[\"^7[\",[13,\"^>\",true,536870913]],[\"^7[\",[13,\"^C\",\"^D\",536870913]],[\"^7[\",[13,\"^55\",true,536870913]],[\"^7[\",[13,\"^5I\",61,536870913]],[\"^7[\",[13,\"^3P\",true,536870913]],[\"^7[\",[13,\"^X\",false,536870913]],[\"^7[\",[13,\"^1K\",\"~:default\",536870913]],[\"^7[\",[14,\"^48\",1775980635496,536870913]],[\"^7[\",[14,\"^7H\",\"tag\",536870913]],[\"^7[\",[14,\"^64\",14,536870913]],[\"^7[\",[14,\"^65\",\"Tag\",536870913]],[\"^7[\",[14,\"^3;\",1775980635496,536870913]],[\"^7[\",[14,\"^2T\",\"~u00000002-5389-0208-3000-000000000000\",536870913]],[\"^7[\",[14,\"^;\",\"^4B\",536870913]],[\"^7[\",[14,\"^55\",true,536870913]],[\"^7[\",[14,\"^3K\",89,536870913]],[\"^7[\",[15,\"^;\",\"^4N\",536870913]],[\"^7[\",[15,\"^28\",1775980635485,536870913]],[\"^7[\",[16,\"^48\",1775980635492,536870913]],[\"^7[\",[16,\"^7H\",\"icon\",536870913]],[\"^7[\",[16,\"^7;\",\"d3BGZ\",536870913]],[\"^7[\",[16,\"^64\",124,536870913]],[\"^7[\",[16,\"^65\",\"Icon\",536870913]],[\"^7[\",[16,\"^3;\",1775980635492,536870913]],[\"^7[\",[16,\"^2T\",\"~u00000002-5891-2328-5000-000000000000\",536870913]],[\"^7[\",[16,\"^<\",\"^=\",536870913]],[\"^7[\",[16,\"^;\",\"^4[\",536870913]],[\"^7[\",[16,\"^>\",true,536870913]],[\"^7[\",[16,\"^55\",true,536870913]],[\"^7[\",[16,\"^1K\",\"^83\",536870913]],[\"^7[\",[17,\"^48\",1775980635493,536870913]],[\"^7[\",[17,\"^7H\",\"external file name\",536870913]],[\"^7[\",[17,\"^7;\",\"d3BGu\",536870913]],[\"^7[\",[17,\"^64\",124,536870913]],[\"^7[\",[17,\"^65\",\"External file name\",536870913]],[\"^7[\",[17,\"^3;\",1775980635493,536870913]],[\"^7[\",[17,\"^2T\",\"~u00000002-3995-2972-0000-000000000000\",536870913]],[\"^7[\",[17,\"^<\",\"^=\",536870913]],[\"^7[\",[17,\"^;\",\"^2Z\",536870913]],[\"^7[\",[17,\"^>\",true,536870913]],[\"^7[\",[17,\"^55\",true,536870913]],[\"^7[\",[17,\"^5G\",true,536870913]],[\"^7[\",[17,\"^X\",false,536870913]],[\"^7[\",[17,\"^1K\",\"^82\",536870913]],[\"^7[\",[18,\"^48\",1775980635494,536870913]],[\"^7[\",[18,\"^7H\",\"history block\",536870913]],[\"^7[\",[18,\"^7;\",\"d3BHB\",536870913]],[\"^7[\",[18,\"^64\",124,536870913]],[\"^7[\",[18,\"^65\",\"History block\",536870913]],[\"^7[\",[18,\"^3;\",1775980635494,536870913]],[\"^7[\",[18,\"^2T\",\"~u00000002-1142-5541-6000-000000000000\",536870913]],[\"^7[\",[18,\"^<\",\"^=\",536870913]],[\"^7[\",[18,\"^;\",\"^5@\",536870913]],[\"^7[\",[18,\"^>\",true,536870913]],[\"^7[\",[18,\"^C\",\"^D\",536870913]],[\"^7[\",[18,\"^55\",true,536870913]],[\"^7[\",[18,\"^5G\",true,536870913]],[\"^7[\",[18,\"^1K\",\"^88\",536870913]],[\"^7[\",[19,\"^48\",1775980635490,536870913]],[\"^7[\",[19,\"^7H\",\"annotation color\",536870913]],[\"^7[\",[19,\"^7;\",\"d3BFo\",536870913]],[\"^7[\",[19,\"^64\",124,536870913]],[\"^7[\",[19,\"^65\",\"Annotation color\",536870913]],[\"^7[\",[19,\"^3;\",1775980635490,536870913]],[\"^7[\",[19,\"^2T\",\"~u00000002-6747-9388-7000-000000000000\",536870913]],[\"^7[\",[19,\"^<\",\"^=\",536870913]],[\"^7[\",[19,\"^;\",\"^Q\",536870913]],[\"^7[\",[19,\"^>\",true,536870913]],[\"^7[\",[19,\"^C\",\"^D\",536870913]],[\"^7[\",[19,\"^55\",true,536870913]],[\"^7[\",[19,\"^5G\",true,536870913]],[\"^7[\",[19,\"^1K\",\"^89\",536870913]],[\"^7[\",[20,\"^48\",1775980635489,536870913]],[\"^7[\",[20,\"^7H\",\"enable bidirectional properties\",536870913]],[\"^7[\",[20,\"^7;\",\"d3BFb\",536870913]],[\"^7[\",[20,\"^64\",124,536870913]],[\"^7[\",[20,\"^65\",\"Enable bidirectional properties\",536870913]],[\"^7[\",[20,\"^3;\",1775980635489,536870913]],[\"^7[\",[20,\"^2T\",\"~u00000002-2917-8613-8000-000000000000\",536870913]],[\"^7[\",[20,\"^<\",\"^=\",536870913]],[\"^7[\",[20,\"^;\",\"^2<\",536870913]],[\"^7[\",[20,\"^>\",true,536870913]],[\"^7[\",[20,\"^55\",true,536870913]],[\"^7[\",[20,\"^2A\",169,536870913]],[\"^7[\",[20,\"^X\",true,536870913]],[\"^7[\",[20,\"^1K\",\"~:checkbox\",536870913]],[\"^7[\",[20,\"^44\",\"~:class\",536870913]],[\"^7[\",[21,\"^48\",1775980635488,536870913]],[\"^7[\",[21,\"^7H\",\"node title\",536870913]],[\"^7[\",[21,\"^7;\",\"d3BFN\",536870913]],[\"^7[\",[21,\"^64\",124,536870913]],[\"^7[\",[21,\"^65\",\"Node title\",536870913]],[\"^7[\",[21,\"^3;\",1775980635488,536870913]],[\"^7[\",[21,\"^2T\",\"~u00000002-7104-4568-4000-000000000000\",536870913]],[\"^7[\",[21,\"^<\",\"^=\",536870913]],[\"^7[\",[21,\"^;\",\"^65\",536870913]],[\"^7[\",[21,\"^>\",true,536870913]],[\"^7[\",[21,\"^55\",true,536870913]],[\"^7[\",[21,\"^5G\",true,536870913]],[\"^7[\",[21,\"^X\",false,536870913]],[\"^7[\",[21,\"^1K\",\"^82\",536870913]],[\"^7[\",[22,\"^48\",1775980635487,536870913]],[\"^7[\",[22,\"^7H\",\"property classes\",536870913]],[\"^7[\",[22,\"^7;\",\"d3BFD\",536870913]],[\"^7[\",[22,\"^64\",124,536870913]],[\"^7[\",[22,\"^65\",\"Property classes\",536870913]],[\"^7[\",[22,\"^3;\",1775980635487,536870913]],[\"^7[\",[22,\"^2T\",\"~u00000002-9137-5048-6000-000000000000\",536870913]],[\"^7[\",[22,\"^<\",\"^14\",536870913]],[\"^7[\",[22,\"^;\",\"^6B\",536870913]],[\"^7[\",[22,\"^>\",true,536870913]],[\"^7[\",[22,\"^C\",\"^D\",536870913]],[\"^7[\",[22,\"^55\",true,536870913]],[\"^7[\",[22,\"^5G\",true,536870913]],[\"^7[\",[22,\"^X\",false,536870913]],[\"^7[\",[22,\"^1K\",\"^88\",536870913]],[\"^7[\",[23,\"^;\",\"^39\",536870913]],[\"^7[\",[23,\"^28\",true,536870913]],[\"^7[\",[24,\"^48\",1775980635494,536870913]],[\"^7[\",[24,\"^7H\",\"asset resize metadata\",536870913]],[\"^7[\",[24,\"^7;\",\"d3BH2\",536870913]],[\"^7[\",[24,\"^64\",124,536870913]],[\"^7[\",[24,\"^65\",\"Asset resize metadata\",536870913]],[\"^7[\",[24,\"^3;\",1775980635494,536870913]],[\"^7[\",[24,\"^2T\",\"~u00000002-1297-5230-5500-000000000000\",536870913]],[\"^7[\",[24,\"^<\",\"^=\",536870913]],[\"^7[\",[24,\"^;\",\"^5>\",536870913]],[\"^7[\",[24,\"^>\",true,536870913]],[\"^7[\",[24,\"^55\",true,536870913]],[\"^7[\",[24,\"^5G\",true,536870913]],[\"^7[\",[24,\"^X\",false,536870913]],[\"^7[\",[24,\"^1K\",\"^83\",536870913]],[\"^7[\",[25,\"^48\",1775980635495,536870913]],[\"^7[\",[25,\"^7H\",\"deleted at\",536870913]],[\"^7[\",[25,\"^7;\",\"d3BHG\",536870913]],[\"^7[\",[25,\"^64\",124,536870913]],[\"^7[\",[25,\"^65\",\"Deleted at\",536870913]],[\"^7[\",[25,\"^3;\",1775980635495,536870913]],[\"^7[\",[25,\"^2T\",\"~u00000002-6852-1375-1000-000000000000\",536870913]],[\"^7[\",[25,\"^<\",\"^=\",536870913]],[\"^7[\",[25,\"^;\",\"^6M\",536870913]],[\"^7[\",[25,\"^>\",true,536870913]],[\"^7[\",[25,\"^55\",true,536870913]],[\"^7[\",[25,\"^5G\",true,536870913]],[\"^7[\",[25,\"^X\",false,536870913]],[\"^7[\",[25,\"^1K\",\"~:datetime\",536870913]],[\"^7[\",[26,\"^48\",1775980635491,536870913]],[\"^7[\",[26,\"^7H\",\"status\",536870913]],[\"^7[\",[26,\"^7;\",\"d3BG7\",536870913]],[\"^7[\",[26,\"^64\",124,536870913]],[\"^7[\",[26,\"^65\",\"Status\",536870913]],[\"^7[\",[26,\"^3;\",1775980635491,536870913]],[\"^7[\",[26,\"^2T\",\"~u00000002-9072-1685-3000-000000000000\",536870913]],[\"^7[\",[26,\"^<\",\"^=\",536870913]],[\"^7[\",[26,\"^;\",\"^3I\",536870913]],[\"^7[\",[26,\"^>\",true,536870913]],[\"^7[\",[26,\"^C\",\"^D\",536870913]],[\"^7[\",[26,\"^55\",true,536870913]],[\"^7[\",[26,\"^5I\",114,536870913]],[\"^7[\",[26,\"^4L\",true,536870913]],[\"^7[\",[26,\"^3P\",true,536870913]],[\"^7[\",[26,\"^X\",true,536870913]],[\"^7[\",[26,\"^1K\",\"^89\",536870913]],[\"^7[\",[26,\"^@\",\"~:block-left\",536870913]],[\"^7[\",[27,\"^48\",1775980635496,536870913]],[\"^7[\",[27,\"^7H\",\"card\",536870913]],[\"^7[\",[27,\"^64\",14,536870913]],[\"^7[\",[27,\"^65\",\"Card\",536870913]],[\"^7[\",[27,\"^3;\",1775980635496,536870913]],[\"^7[\",[27,\"^2T\",\"~u00000002-1358-2811-0900-000000000000\",536870913]],[\"^7[\",[27,\"^;\",\"^3S\",536870913]],[\"^7[\",[27,\"^55\",true,536870913]],[\"^7[\",[27,\"^3K\",89,536870913]],[\"^7[\",[27,\"^4J\",29,536870913]],[\"^7[\",[27,\"^4J\",43,536870913]],[\"^7[\",[28,\"^48\",1775980635496,536870913]],[\"^7[\",[28,\"^7H\",\"code\",536870913]],[\"^7[\",[28,\"^64\",14,536870913]],[\"^7[\",[28,\"^65\",\"Code\",536870913]],[\"^7[\",[28,\"^3;\",1775980635496,536870913]],[\"^7[\",[28,\"^2T\",\"~u00000002-1454-9866-4100-000000000000\",536870913]],[\"^7[\",[28,\"^;\",\"^5=\",536870913]],[\"^7[\",[28,\"^55\",true,536870913]],[\"^7[\",[28,\"^3K\",89,536870913]],[\"^7[\",[28,\"^1X\",true,536870913]],[\"^7[\",[28,\"^4J\",37,536870913]],[\"^7[\",[28,\"^4J\",108,536870913]],[\"^7[\",[29,\"^48\",1775980635494,536870913]],[\"^7[\",[29,\"^7H\",\"due\",536870913]],[\"^7[\",[29,\"^7;\",\"d3BH4\",536870913]],[\"^7[\",[29,\"^64\",124,536870913]],[\"^7[\",[29,\"^65\",\"Due\",536870913]],[\"^7[\",[29,\"^3;\",1775980635494,536870913]],[\"^7[\",[29,\"^2T\",\"~u00000002-1089-0805-4900-000000000000\",536870913]],[\"^7[\",[29,\"^<\",\"^=\",536870913]],[\"^7[\",[29,\"^;\",\"^7B\",536870913]],[\"^7[\",[29,\"^>\",true,536870913]],[\"^7[\",[29,\"^55\",true,536870913]],[\"^7[\",[29,\"^5G\",false,536870913]],[\"^7[\",[29,\"^X\",false,536870913]],[\"^7[\",[29,\"^1K\",\"^8<\",536870913]],[\"^7[\",[30,\"^48\",1775980635490,536870913]],[\"^7[\",[30,\"^7H\",\"asset\",536870913]],[\"^7[\",[30,\"^7;\",\"d3BFl\",536870913]],[\"^7[\",[30,\"^64\",124,536870913]],[\"^7[\",[30,\"^65\",\"Asset\",536870913]],[\"^7[\",[30,\"^3;\",1775980635490,536870913]],[\"^7[\",[30,\"^2T\",\"~u00000002-8768-5679-0000-000000000000\",536870913]],[\"^7[\",[30,\"^<\",\"^=\",536870913]],[\"^7[\",[30,\"^;\",\"^6I\",536870913]],[\"^7[\",[30,\"^>\",true,536870913]],[\"^7[\",[30,\"^C\",\"^D\",536870913]],[\"^7[\",[30,\"^55\",true,536870913]],[\"^7[\",[30,\"^5G\",true,536870913]],[\"^7[\",[30,\"^1K\",\"^88\",536870913]],[\"^7[\",[31,\"^L\",19,536870913]],[\"^7[\",[31,\"^48\",1775980635490,536870913]],[\"^7[\",[31,\"^7;\",\"d3BFq\",536870913]],[\"^7[\",[31,\"^4P\",19,536870913]],[\"^7[\",[31,\"^3V\",19,536870913]],[\"^7[\",[31,\"^65\",\"red\",536870913]],[\"^7[\",[31,\"^3;\",1775980635490,536870913]],[\"^7[\",[31,\"^2T\",\"~u00000002-4136-2216-2000-000000000000\",536870913]],[\"^7[\",[31,\"^;\",\"^5K\",536870913]],[\"^7[\",[31,\"^55\",true,536870913]],[\"^7[\",[31,\"^6:\",19,536870913]],[\"^7[\",[32,\"^48\",1775980635491,536870913]],[\"^7[\",[32,\"^7H\",\"priority\",536870913]],[\"^7[\",[32,\"^7;\",\"d3BGE\",536870913]],[\"^7[\",[32,\"^64\",124,536870913]],[\"^7[\",[32,\"^65\",\"Priority\",536870913]],[\"^7[\",[32,\"^3;\",1775980635491,536870913]],[\"^7[\",[32,\"^2T\",\"~u00000002-2392-2841-1000-000000000000\",536870913]],[\"^7[\",[32,\"^<\",\"^=\",536870913]],[\"^7[\",[32,\"^;\",\"^3\",536870913]],[\"^7[\",[32,\"^>\",true,536870913]],[\"^7[\",[32,\"^C\",\"^D\",536870913]],[\"^7[\",[32,\"^55\",true,536870913]],[\"^7[\",[32,\"^4L\",true,536870913]],[\"^7[\",[32,\"^3P\",true,536870913]],[\"^7[\",[32,\"^X\",true,536870913]],[\"^7[\",[32,\"^1K\",\"^89\",536870913]],[\"^7[\",[32,\"^@\",\"^8=\",536870913]],[\"^7[\",[33,\"^L\",32,536870913]],[\"^7[\",[33,\"^48\",1775980635491,536870913]],[\"^7[\",[33,\"^7;\",\"d3BGI\",536870913]],[\"^7[\",[33,\"^4P\",32,536870913]],[\"^7[\",[33,\"^3V\",32,536870913]],[\"^7[\",[33,\"^65\",\"Urgent\",536870913]],[\"^7[\",[33,\"^3;\",1775980635491,536870913]],[\"^7[\",[33,\"^2T\",\"~u00000002-1996-4346-6700-000000000000\",536870913]],[\"^7[\",[33,\"^;\",\"^H\",536870913]],[\"^7[\",[33,\"^55\",true,536870913]],[\"^7[\",[33,\"^6:\",32,536870913]],[\"^7[\",[33,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlUrgent\"],536870913]],[\"^7[\",[34,\"^48\",1775980635487,536870913]],[\"^7[\",[34,\"^7H\",\"property public?\",536870913]],[\"^7[\",[34,\"^7;\",\"d3BFA\",536870913]],[\"^7[\",[34,\"^64\",124,536870913]],[\"^7[\",[34,\"^65\",\"Property public?\",536870913]],[\"^7[\",[34,\"^3;\",1775980635487,536870913]],[\"^7[\",[34,\"^2T\",\"~u00000002-1843-0851-4900-000000000000\",536870913]],[\"^7[\",[34,\"^<\",\"^=\",536870913]],[\"^7[\",[34,\"^;\",\"^X\",536870913]],[\"^7[\",[34,\"^>\",true,536870913]],[\"^7[\",[34,\"^55\",true,536870913]],[\"^7[\",[34,\"^5G\",true,536870913]],[\"^7[\",[34,\"^1K\",\"^8:\",536870913]],[\"^7[\",[35,\"^48\",1775980635495,536870913]],[\"^7[\",[35,\"^7H\",\"recycle original order\",536870913]],[\"^7[\",[35,\"^7;\",\"d3BHK\",536870913]],[\"^7[\",[35,\"^64\",124,536870913]],[\"^7[\",[35,\"^65\",\"Recycle original order\",536870913]],[\"^7[\",[35,\"^3;\",1775980635495,536870913]],[\"^7[\",[35,\"^2T\",\"~u00000002-8699-2537-0000-000000000000\",536870913]],[\"^7[\",[35,\"^<\",\"^=\",536870913]],[\"^7[\",[35,\"^;\",\"^19\",536870913]],[\"^7[\",[35,\"^>\",true,536870913]],[\"^7[\",[35,\"^55\",true,536870913]],[\"^7[\",[35,\"^5G\",true,536870913]],[\"^7[\",[35,\"^X\",false,536870913]],[\"^7[\",[35,\"^1K\",\"^82\",536870913]],[\"^7[\",[36,\"^48\",1775980635488,536870913]],[\"^7[\",[36,\"^7H\",\"journal date\",536870913]],[\"^7[\",[36,\"^7;\",\"d3BFP\",536870913]],[\"^7[\",[36,\"^64\",124,536870913]],[\"^7[\",[36,\"^65\",\"Journal date\",536870913]],[\"^7[\",[36,\"^3;\",1775980635488,536870913]],[\"^7[\",[36,\"^2T\",\"~u00000002-1457-4836-6000-000000000000\",536870913]],[\"^7[\",[36,\"^<\",\"^=\",536870913]],[\"^7[\",[36,\"^;\",\"^1C\",536870913]],[\"^7[\",[36,\"^>\",true,536870913]],[\"^7[\",[36,\"^55\",true,536870913]],[\"^7[\",[36,\"^5G\",true,536870913]],[\"^7[\",[36,\"^X\",false,536870913]],[\"^7[\",[36,\"^1K\",\"~:raw-number\",536870913]],[\"^7[\",[37,\"^48\",1775980635488,536870913]],[\"^7[\",[37,\"^7H\",\"node display type\",536870913]],[\"^7[\",[37,\"^7;\",\"d3BFS\",536870913]],[\"^7[\",[37,\"^64\",124,536870913]],[\"^7[\",[37,\"^65\",\"Node Display Type\",536870913]],[\"^7[\",[37,\"^3;\",1775980635488,536870913]],[\"^7[\",[37,\"^2T\",\"~u00000002-4424-4618-9000-000000000000\",536870913]],[\"^7[\",[37,\"^<\",\"^=\",536870913]],[\"^7[\",[37,\"^;\",\"^1Q\",536870913]],[\"^7[\",[37,\"^>\",true,536870913]],[\"^7[\",[37,\"^55\",true,536870913]],[\"^7[\",[37,\"^5G\",true,536870913]],[\"^7[\",[37,\"^X\",false,536870913]],[\"^7[\",[37,\"^1K\",\"~:keyword\",536870913]],[\"^7[\",[37,\"^44\",\"~:block\",536870913]],[\"^7[\",[38,\"^48\",1775980635496,536870913]],[\"^7[\",[38,\"^7H\",\"template\",536870913]],[\"^7[\",[38,\"^64\",14,536870913]],[\"^7[\",[38,\"^65\",\"Template\",536870913]],[\"^7[\",[38,\"^3;\",1775980635496,536870913]],[\"^7[\",[38,\"^2T\",\"~u00000002-1720-8548-4600-000000000000\",536870913]],[\"^7[\",[38,\"^;\",\"^22\",536870913]],[\"^7[\",[38,\"^55\",true,536870913]],[\"^7[\",[38,\"^3K\",89,536870913]],[\"^7[\",[38,\"^4J\",147,536870913]],[\"^7[\",[39,\"^;\",\"^2?\",536870913]],[\"^7[\",[39,\"^28\",\"~u00000000-4735-4d82-94bb-c9bfb8039105\",536870913]],[\"^7[\",[40,\"^48\",1775980635490,536870913]],[\"^7[\",[40,\"^7H\",\"annotation page\",536870913]],[\"^7[\",[40,\"^7;\",\"d3BFu\",536870913]],[\"^7[\",[40,\"^64\",124,536870913]],[\"^7[\",[40,\"^65\",\"Annotation page\",536870913]],[\"^7[\",[40,\"^3;\",1775980635490,536870913]],[\"^7[\",[40,\"^2T\",\"~u00000002-7532-8459-6000-000000000000\",536870913]],[\"^7[\",[40,\"^<\",\"^=\",536870913]],[\"^7[\",[40,\"^;\",\"^2O\",536870913]],[\"^7[\",[40,\"^>\",true,536870913]],[\"^7[\",[40,\"^55\",true,536870913]],[\"^7[\",[40,\"^5G\",true,536870913]],[\"^7[\",[40,\"^1K\",\"^8>\",536870913]],[\"^7[\",[41,\"^48\",1775980635491,536870913]],[\"^7[\",[41,\"^7H\",\"properties displayed as checkbox\",536870913]],[\"^7[\",[41,\"^7;\",\"d3BG6\",536870913]],[\"^7[\",[41,\"^64\",124,536870913]],[\"^7[\",[41,\"^65\",\"Properties displayed as checkbox\",536870913]],[\"^7[\",[41,\"^3;\",1775980635491,536870913]],[\"^7[\",[41,\"^2T\",\"~u00000002-3215-3256-9000-000000000000\",536870913]],[\"^7[\",[41,\"^<\",\"^14\",536870913]],[\"^7[\",[41,\"^;\",\"^2H\",536870913]],[\"^7[\",[41,\"^>\",true,536870913]],[\"^7[\",[41,\"^C\",\"^D\",536870913]],[\"^7[\",[41,\"^55\",true,536870913]],[\"^7[\",[41,\"^5G\",true,536870913]],[\"^7[\",[41,\"^1K\",\"^81\",536870913]],[\"^7[\",[42,\"^L\",19,536870913]],[\"^7[\",[42,\"^48\",1775980635490,536870913]],[\"^7[\",[42,\"^7;\",\"d3BFt\",536870913]],[\"^7[\",[42,\"^4P\",19,536870913]],[\"^7[\",[42,\"^3V\",19,536870913]],[\"^7[\",[42,\"^65\",\"purple\",536870913]],[\"^7[\",[42,\"^3;\",1775980635490,536870913]],[\"^7[\",[42,\"^2T\",\"~u00000002-1104-3848-5600-000000000000\",536870913]],[\"^7[\",[42,\"^;\",\"^2Y\",536870913]],[\"^7[\",[42,\"^55\",true,536870913]],[\"^7[\",[42,\"^6:\",19,536870913]],[\"^7[\",[43,\"^48\",1775980635494,536870913]],[\"^7[\",[43,\"^7H\",\"state\",536870913]],[\"^7[\",[43,\"^7;\",\"d3BH5\",536870913]],[\"^7[\",[43,\"^64\",124,536870913]],[\"^7[\",[43,\"^65\",\"State\",536870913]],[\"^7[\",[43,\"^3;\",1775980635494,536870913]],[\"^7[\",[43,\"^2T\",\"~u00000002-1165-1650-8700-000000000000\",536870913]],[\"^7[\",[43,\"^<\",\"^=\",536870913]],[\"^7[\",[43,\"^;\",\"^P\",536870913]],[\"^7[\",[43,\"^>\",true,536870913]],[\"^7[\",[43,\"^55\",true,536870913]],[\"^7[\",[43,\"^5G\",false,536870913]],[\"^7[\",[43,\"^X\",false,536870913]],[\"^7[\",[43,\"^1K\",\"^83\",536870913]],[\"^7[\",[44,\"^48\",1775980635492,536870913]],[\"^7[\",[44,\"^7H\",\"view sort groups desc\",536870913]],[\"^7[\",[44,\"^7;\",\"d3BGk\",536870913]],[\"^7[\",[44,\"^64\",124,536870913]],[\"^7[\",[44,\"^65\",\"View sort groups DESC\",536870913]],[\"^7[\",[44,\"^3;\",1775980635492,536870913]],[\"^7[\",[44,\"^2T\",\"~u00000002-1081-4073-8700-000000000000\",536870913]],[\"^7[\",[44,\"^<\",\"^=\",536870913]],[\"^7[\",[44,\"^;\",\"^1V\",536870913]],[\"^7[\",[44,\"^>\",true,536870913]],[\"^7[\",[44,\"^55\",true,536870913]],[\"^7[\",[44,\"^5G\",true,536870913]],[\"^7[\",[44,\"^X\",false,536870913]],[\"^7[\",[44,\"^29\",true,536870913]],[\"^7[\",[44,\"^1K\",\"^8:\",536870913]],[\"^7[\",[45,\"^48\",1775980635488,536870913]],[\"^7[\",[45,\"^7H\",\"node created at\",536870913]],[\"^7[\",[45,\"^7;\",\"d3BFQ\",536870913]],[\"^7[\",[45,\"^64\",124,536870913]],[\"^7[\",[45,\"^65\",\"Node created at\",536870913]],[\"^7[\",[45,\"^3;\",1775980635488,536870913]],[\"^7[\",[45,\"^2T\",\"~u00000002-1440-0150-0000-000000000000\",536870913]],[\"^7[\",[45,\"^<\",\"^=\",536870913]],[\"^7[\",[45,\"^;\",\"^48\",536870913]],[\"^7[\",[45,\"^>\",true,536870913]],[\"^7[\",[45,\"^55\",true,536870913]],[\"^7[\",[45,\"^5G\",true,536870913]],[\"^7[\",[45,\"^X\",false,536870913]],[\"^7[\",[45,\"^1K\",\"^8<\",536870913]],[\"^7[\",[46,\"^48\",1775980635496,536870913]],[\"^7[\",[46,\"^7H\",\"asset\",536870913]],[\"^7[\",[46,\"^64\",14,536870913]],[\"^7[\",[46,\"^65\",\"Asset\",536870913]],[\"^7[\",[46,\"^3;\",1775980635496,536870913]],[\"^7[\",[46,\"^2T\",\"~u00000002-7975-0297-0000-000000000000\",536870913]],[\"^7[\",[46,\"^;\",\"^2;\",536870913]],[\"^7[\",[46,\"^55\",true,536870913]],[\"^7[\",[46,\"^3K\",89,536870913]],[\"^7[\",[46,\"^1X\",true,536870913]],[\"^7[\",[46,\"^4J\",5,536870913]],[\"^7[\",[46,\"^4J\",57,536870913]],[\"^7[\",[46,\"^4J\",131,536870913]],[\"^7[\",[46,\"^2J\",150,536870913]],[\"^7[\",[47,\"^48\",1775980635488,536870913]],[\"^7[\",[47,\"^7H\",\"node page\",536870913]],[\"^7[\",[47,\"^7;\",\"d3BFK\",536870913]],[\"^7[\",[47,\"^64\",124,536870913]],[\"^7[\",[47,\"^65\",\"Node page\",536870913]],[\"^7[\",[47,\"^3;\",1775980635488,536870913]],[\"^7[\",[47,\"^2T\",\"~u00000002-8223-1410-8000-000000000000\",536870913]],[\"^7[\",[47,\"^<\",\"^=\",536870913]],[\"^7[\",[47,\"^;\",\"^4P\",536870913]],[\"^7[\",[47,\"^>\",true,536870913]],[\"^7[\",[47,\"^C\",\"^D\",536870913]],[\"^7[\",[47,\"^55\",true,536870913]],[\"^7[\",[47,\"^5G\",true,536870913]],[\"^7[\",[47,\"^X\",false,536870913]],[\"^7[\",[47,\"^1K\",\"^88\",536870913]],[\"^7[\",[48,\"^L\",13,536870913]],[\"^7[\",[48,\"^48\",1775980635492,536870913]],[\"^7[\",[48,\"^7;\",\"d3BGV\",536870913]],[\"^7[\",[48,\"^4P\",13,536870913]],[\"^7[\",[48,\"^3V\",13,536870913]],[\"^7[\",[48,\"^65\",\"Year\",536870913]],[\"^7[\",[48,\"^3;\",1775980635492,536870913]],[\"^7[\",[48,\"^2T\",\"~u00000002-1520-4385-2400-000000000000\",536870913]],[\"^7[\",[48,\"^;\",\"^1L\",536870913]],[\"^7[\",[48,\"^55\",true,536870913]],[\"^7[\",[48,\"^6:\",13,536870913]],[\"^7[\",[49,\"^48\",1775980635492,536870913]],[\"^7[\",[49,\"^7H\",\"excluded from graph view?\",536870913]],[\"^7[\",[49,\"^7;\",\"d3BGc\",536870913]],[\"^7[\",[49,\"^64\",124,536870913]],[\"^7[\",[49,\"^65\",\"Excluded from Graph view?\",536870913]],[\"^7[\",[49,\"^3;\",1775980635492,536870913]],[\"^7[\",[49,\"^2T\",\"~u00000002-4524-3306-5000-000000000000\",536870913]],[\"^7[\",[49,\"^<\",\"^=\",536870913]],[\"^7[\",[49,\"^;\",\"^58\",536870913]],[\"^7[\",[49,\"^>\",true,536870913]],[\"^7[\",[49,\"^55\",true,536870913]],[\"^7[\",[49,\"^5G\",true,536870913]],[\"^7[\",[49,\"^X\",true,536870913]],[\"^7[\",[49,\"^1K\",\"^8:\",536870913]],[\"^7[\",[49,\"^44\",\"^84\",536870913]],[\"^7[\",[50,\"^48\",1775980635493,536870913]],[\"^7[\",[50,\"^7H\",\"table view pinned columns\",536870913]],[\"^7[\",[50,\"^7;\",\"d3BGq\",536870913]],[\"^7[\",[50,\"^64\",124,536870913]],[\"^7[\",[50,\"^65\",\"Table view pinned columns\",536870913]],[\"^7[\",[50,\"^3;\",1775980635493,536870913]],[\"^7[\",[50,\"^2T\",\"~u00000002-2673-7513-8000-000000000000\",536870913]],[\"^7[\",[50,\"^<\",\"^14\",536870913]],[\"^7[\",[50,\"^;\",\"^15\",536870913]],[\"^7[\",[50,\"^>\",true,536870913]],[\"^7[\",[50,\"^C\",\"^D\",536870913]],[\"^7[\",[50,\"^55\",true,536870913]],[\"^7[\",[50,\"^5G\",true,536870913]],[\"^7[\",[50,\"^X\",false,536870913]],[\"^7[\",[50,\"^1K\",\"^81\",536870913]],[\"^7[\",[51,\"^48\",1775980635495,536870913]],[\"^7[\",[51,\"^7H\",\"reference to large block title stored in remote object storage\",536870913]],[\"^7[\",[51,\"^7;\",\"d3BHP\",536870913]],[\"^7[\",[51,\"^64\",124,536870913]],[\"^7[\",[51,\"^65\",\"Reference to large block title stored in remote object storage\",536870913]],[\"^7[\",[51,\"^3;\",1775980635495,536870913]],[\"^7[\",[51,\"^2T\",\"~u00000002-1044-8271-6500-000000000000\",536870913]],[\"^7[\",[51,\"^<\",\"^=\",536870913]],[\"^7[\",[51,\"^;\",\"^1@\",536870913]],[\"^7[\",[51,\"^>\",true,536870913]],[\"^7[\",[51,\"^55\",true,536870913]],[\"^7[\",[51,\"^5G\",true,536870913]],[\"^7[\",[51,\"^X\",false,536870913]],[\"^7[\",[51,\"^1K\",\"^83\",536870913]],[\"^7[\",[52,\"^;\",\"^45\",536870913]],[\"^7[\",[52,\"^28\",[\"^ \",\"~:major\",65,\"~:minor\",24],536870913]],[\"^7[\",[53,\"^48\",1775980635487,536870913]],[\"^7[\",[53,\"^7H\",\"created from property\",536870913]],[\"^7[\",[53,\"^7;\",\"d3BF9\",536870913]],[\"^7[\",[53,\"^64\",124,536870913]],[\"^7[\",[53,\"^65\",\"Created from property\",536870913]],[\"^7[\",[53,\"^3;\",1775980635487,536870913]],[\"^7[\",[53,\"^2T\",\"~u00000002-8618-9226-7000-000000000000\",536870913]],[\"^7[\",[53,\"^<\",\"^=\",536870913]],[\"^7[\",[53,\"^;\",\"^6:\",536870913]],[\"^7[\",[53,\"^>\",true,536870913]],[\"^7[\",[53,\"^C\",\"^D\",536870913]],[\"^7[\",[53,\"^55\",true,536870913]],[\"^7[\",[53,\"^5G\",true,536870913]],[\"^7[\",[53,\"^1K\",\"^88\",536870913]],[\"^7[\",[54,\"^48\",1775980635492,536870913]],[\"^7[\",[54,\"^7H\",\"publishing public?\",536870913]],[\"^7[\",[54,\"^7;\",\"d3BGa\",536870913]],[\"^7[\",[54,\"^64\",124,536870913]],[\"^7[\",[54,\"^65\",\"Publishing Public?\",536870913]],[\"^7[\",[54,\"^3;\",1775980635492,536870913]],[\"^7[\",[54,\"^2T\",\"~u00000002-1094-6579-3900-000000000000\",536870913]],[\"^7[\",[54,\"^<\",\"^=\",536870913]],[\"^7[\",[54,\"^;\",\"^4=\",536870913]],[\"^7[\",[54,\"^>\",true,536870913]],[\"^7[\",[54,\"^55\",true,536870913]],[\"^7[\",[54,\"^5G\",true,536870913]],[\"^7[\",[54,\"^X\",true,536870913]],[\"^7[\",[54,\"^1K\",\"^8:\",536870913]],[\"^7[\",[54,\"^44\",\"^84\",536870913]],[\"^7[\",[55,\"^L\",26,536870913]],[\"^7[\",[55,\"^48\",1775980635491,536870913]],[\"^7[\",[55,\"^7;\",\"d3BGC\",536870913]],[\"^7[\",[55,\"^4P\",26,536870913]],[\"^7[\",[55,\"^3V\",26,536870913]],[\"^7[\",[55,\"^65\",\"Done\",536870913]],[\"^7[\",[55,\"^3;\",1775980635491,536870913]],[\"^7[\",[55,\"^2T\",\"~u00000002-1827-5820-8200-000000000000\",536870913]],[\"^7[\",[55,\"^;\",\"^6L\",536870913]],[\"^7[\",[55,\"^55\",true,536870913]],[\"^7[\",[55,\"^4T\",true,536870913]],[\"^7[\",[55,\"^6:\",26,536870913]],[\"^7[\",[55,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Done\"],536870913]],[\"^7[\",[56,\"^48\",1775980635487,536870913]],[\"^7[\",[56,\"^7H\",\"property value\",536870913]],[\"^7[\",[56,\"^7;\",\"d3BFE\",536870913]],[\"^7[\",[56,\"^64\",124,536870913]],[\"^7[\",[56,\"^65\",\"Property value\",536870913]],[\"^7[\",[56,\"^3;\",1775980635487,536870913]],[\"^7[\",[56,\"^2T\",\"~u00000002-1396-5248-6500-000000000000\",536870913]],[\"^7[\",[56,\"^<\",\"^=\",536870913]],[\"^7[\",[56,\"^;\",\"^T\",536870913]],[\"^7[\",[56,\"^>\",true,536870913]],[\"^7[\",[56,\"^55\",true,536870913]],[\"^7[\",[56,\"^5G\",true,536870913]],[\"^7[\",[56,\"^X\",false,536870913]],[\"^7[\",[56,\"^1K\",\"~:any\",536870913]],[\"^7[\",[57,\"^48\",1775980635493,536870913]],[\"^7[\",[57,\"^7H\",\"file type\",536870913]],[\"^7[\",[57,\"^7;\",\"d3BGs\",536870913]],[\"^7[\",[57,\"^64\",124,536870913]],[\"^7[\",[57,\"^65\",\"File Type\",536870913]],[\"^7[\",[57,\"^3;\",1775980635493,536870913]],[\"^7[\",[57,\"^2T\",\"~u00000002-1142-0830-9800-000000000000\",536870913]],[\"^7[\",[57,\"^<\",\"^=\",536870913]],[\"^7[\",[57,\"^;\",\"^2:\",536870913]],[\"^7[\",[57,\"^>\",true,536870913]],[\"^7[\",[57,\"^55\",true,536870913]],[\"^7[\",[57,\"^5G\",true,536870913]],[\"^7[\",[57,\"^X\",false,536870913]],[\"^7[\",[57,\"^1K\",\"^82\",536870913]],[\"^7[\",[58,\"^48\",1775980635487,536870913]],[\"^7[\",[58,\"^7H\",\"tags\",536870913]],[\"^7[\",[58,\"^7;\",\"d3BFG\",536870913]],[\"^7[\",[58,\"^64\",124,536870913]],[\"^7[\",[58,\"^65\",\"Tags\",536870913]],[\"^7[\",[58,\"^3;\",1775980635487,536870913]],[\"^7[\",[58,\"^2T\",\"~u00000002-1814-9483-4000-000000000000\",536870913]],[\"^7[\",[58,\"^<\",\"^14\",536870913]],[\"^7[\",[58,\"^;\",\"^64\",536870913]],[\"^7[\",[58,\"^>\",true,536870913]],[\"^7[\",[58,\"^C\",\"^D\",536870913]],[\"^7[\",[58,\"^55\",true,536870913]],[\"^7[\",[58,\"^X\",true,536870913]],[\"^7[\",[58,\"^1K\",\"^8;\",536870913]],[\"^7[\",[59,\"^48\",1775980635490,536870913]],[\"^7[\",[59,\"^7H\",\"list type\",536870913]],[\"^7[\",[59,\"^7;\",\"d3BFx\",536870913]],[\"^7[\",[59,\"^64\",124,536870913]],[\"^7[\",[59,\"^65\",\"List type\",536870913]],[\"^7[\",[59,\"^3;\",1775980635490,536870913]],[\"^7[\",[59,\"^2T\",\"~u00000002-6078-1711-1000-000000000000\",536870913]],[\"^7[\",[59,\"^<\",\"^=\",536870913]],[\"^7[\",[59,\"^;\",\"^6R\",536870913]],[\"^7[\",[59,\"^>\",true,536870913]],[\"^7[\",[59,\"^C\",\"^D\",536870913]],[\"^7[\",[59,\"^55\",true,536870913]],[\"^7[\",[59,\"^5G\",true,536870913]],[\"^7[\",[59,\"^1K\",\"^89\",536870913]],[\"^7[\",[60,\"^48\",1775980635487,536870913]],[\"^7[\",[60,\"^7H\",\"property position\",536870913]],[\"^7[\",[60,\"^7;\",\"d3BFC\",536870913]],[\"^7[\",[60,\"^64\",124,536870913]],[\"^7[\",[60,\"^65\",\"Property position\",536870913]],[\"^7[\",[60,\"^3;\",1775980635487,536870913]],[\"^7[\",[60,\"^2T\",\"~u00000002-1869-2008-6400-000000000000\",536870913]],[\"^7[\",[60,\"^<\",\"^=\",536870913]],[\"^7[\",[60,\"^;\",\"^@\",536870913]],[\"^7[\",[60,\"^>\",true,536870913]],[\"^7[\",[60,\"^55\",true,536870913]],[\"^7[\",[60,\"^5G\",true,536870913]],[\"^7[\",[60,\"^1K\",\"^8?\",536870913]],[\"^7[\",[61,\"^L\",13,536870913]],[\"^7[\",[61,\"^48\",1775980635492,536870913]],[\"^7[\",[61,\"^7;\",\"d3BGS\",536870913]],[\"^7[\",[61,\"^4P\",13,536870913]],[\"^7[\",[61,\"^3V\",13,536870913]],[\"^7[\",[61,\"^65\",\"Day\",536870913]],[\"^7[\",[61,\"^3;\",1775980635492,536870913]],[\"^7[\",[61,\"^2T\",\"~u00000002-3924-1785-8000-000000000000\",536870913]],[\"^7[\",[61,\"^;\",\"^16\",536870913]],[\"^7[\",[61,\"^55\",true,536870913]],[\"^7[\",[61,\"^6:\",13,536870913]],[\"^7[\",[62,\"^48\",1775980635490,536870913]],[\"^7[\",[62,\"^7H\",\"annotation type\",536870913]],[\"^7[\",[62,\"^7;\",\"d3BFn\",536870913]],[\"^7[\",[62,\"^64\",124,536870913]],[\"^7[\",[62,\"^65\",\"Annotation type\",536870913]],[\"^7[\",[62,\"^3;\",1775980635490,536870913]],[\"^7[\",[62,\"^2T\",\"~u00000002-9984-3783-2000-000000000000\",536870913]],[\"^7[\",[62,\"^<\",\"^=\",536870913]],[\"^7[\",[62,\"^;\",\"^6V\",536870913]],[\"^7[\",[62,\"^>\",true,536870913]],[\"^7[\",[62,\"^55\",true,536870913]],[\"^7[\",[62,\"^5G\",true,536870913]],[\"^7[\",[62,\"^1K\",\"^8?\",536870913]],[\"^7[\",[63,\"^48\",1775980635493,536870913]],[\"^7[\",[63,\"^7H\",\"view hidden columns\",536870913]],[\"^7[\",[63,\"^7;\",\"d3BGn\",536870913]],[\"^7[\",[63,\"^64\",124,536870913]],[\"^7[\",[63,\"^65\",\"View hidden columns\",536870913]],[\"^7[\",[63,\"^3;\",1775980635493,536870913]],[\"^7[\",[63,\"^2T\",\"~u00000002-9750-5719-2000-000000000000\",536870913]],[\"^7[\",[63,\"^<\",\"^14\",536870913]],[\"^7[\",[63,\"^;\",\"^2[\",536870913]],[\"^7[\",[63,\"^>\",true,536870913]],[\"^7[\",[63,\"^55\",true,536870913]],[\"^7[\",[63,\"^5G\",true,536870913]],[\"^7[\",[63,\"^X\",false,536870913]],[\"^7[\",[63,\"^1K\",\"^8?\",536870913]],[\"^7[\",[64,\"^48\",1775980635490,536870913]],[\"^7[\",[64,\"^7H\",\"ls-type\",536870913]],[\"^7[\",[64,\"^7;\",\"d3BFm\",536870913]],[\"^7[\",[64,\"^64\",124,536870913]],[\"^7[\",[64,\"^65\",\"ls-type\",536870913]],[\"^7[\",[64,\"^3;\",1775980635490,536870913]],[\"^7[\",[64,\"^2T\",\"~u00000002-3269-7934-5000-000000000000\",536870913]],[\"^7[\",[64,\"^<\",\"^=\",536870913]],[\"^7[\",[64,\"^;\",\"^5\",536870913]],[\"^7[\",[64,\"^>\",true,536870913]],[\"^7[\",[64,\"^55\",true,536870913]],[\"^7[\",[64,\"^5G\",true,536870913]],[\"^7[\",[64,\"^1K\",\"^8?\",536870913]],[\"^7[\",[65,\"^L\",19,536870913]],[\"^7[\",[65,\"^48\",1775980635490,536870913]],[\"^7[\",[65,\"^7;\",\"d3BFp\",536870913]],[\"^7[\",[65,\"^4P\",19,536870913]],[\"^7[\",[65,\"^3V\",19,536870913]],[\"^7[\",[65,\"^65\",\"yellow\",536870913]],[\"^7[\",[65,\"^3;\",1775980635490,536870913]],[\"^7[\",[65,\"^2T\",\"~u00000002-1752-1030-0200-000000000000\",536870913]],[\"^7[\",[65,\"^;\",\"^J\",536870913]],[\"^7[\",[65,\"^55\",true,536870913]],[\"^7[\",[65,\"^6:\",19,536870913]],[\"^7[\",[66,\"^48\",1775980635493,536870913]],[\"^7[\",[66,\"^7H\",\"image width\",536870913]],[\"^7[\",[66,\"^7;\",\"d3BGw\",536870913]],[\"^7[\",[66,\"^64\",124,536870913]],[\"^7[\",[66,\"^65\",\"Image width\",536870913]],[\"^7[\",[66,\"^3;\",1775980635493,536870913]],[\"^7[\",[66,\"^2T\",\"~u00000002-1857-8658-3900-000000000000\",536870913]],[\"^7[\",[66,\"^<\",\"^=\",536870913]],[\"^7[\",[66,\"^;\",\"^Z\",536870913]],[\"^7[\",[66,\"^>\",true,536870913]],[\"^7[\",[66,\"^55\",true,536870913]],[\"^7[\",[66,\"^5G\",true,536870913]],[\"^7[\",[66,\"^X\",false,536870913]],[\"^7[\",[66,\"^1K\",\"^8>\",536870913]],[\"^7[\",[67,\"^48\",1775980635494,536870913]],[\"^7[\",[67,\"^7H\",\"file remote metadata\",536870913]],[\"^7[\",[67,\"^7;\",\"d3BH0\",536870913]],[\"^7[\",[67,\"^64\",124,536870913]],[\"^7[\",[67,\"^65\",\"File remote metadata\",536870913]],[\"^7[\",[67,\"^3;\",1775980635494,536870913]],[\"^7[\",[67,\"^2T\",\"~u00000002-9907-5046-9000-000000000000\",536870913]],[\"^7[\",[67,\"^<\",\"^=\",536870913]],[\"^7[\",[67,\"^;\",\"^1;\",536870913]],[\"^7[\",[67,\"^>\",true,536870913]],[\"^7[\",[67,\"^55\",true,536870913]],[\"^7[\",[67,\"^2A\",167,536870913]],[\"^7[\",[67,\"^5G\",true,536870913]],[\"^7[\",[67,\"^X\",false,536870913]],[\"^7[\",[67,\"^1K\",\"^83\",536870913]],[\"^7[\",[68,\"^L\",127,536870913]],[\"^7[\",[68,\"^48\",1775980635492,536870913]],[\"^7[\",[68,\"^7;\",\"d3BGf\",536870913]],[\"^7[\",[68,\"^4P\",127,536870913]],[\"^7[\",[68,\"^3V\",127,536870913]],[\"^7[\",[68,\"^65\",\"List View\",536870913]],[\"^7[\",[68,\"^3;\",1775980635492,536870913]],[\"^7[\",[68,\"^2T\",\"~u00000002-1164-8285-0200-000000000000\",536870913]],[\"^7[\",[68,\"^;\",\"^1E\",536870913]],[\"^7[\",[68,\"^55\",true,536870913]],[\"^7[\",[68,\"^6:\",127,536870913]],[\"^7[\",[68,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"list\"],536870913]],[\"^7[\",[69,\"^48\",1775980635488,536870913]],[\"^7[\",[69,\"^7H\",\"node references\",536870913]],[\"^7[\",[69,\"^7;\",\"d3BFL\",536870913]],[\"^7[\",[69,\"^64\",124,536870913]],[\"^7[\",[69,\"^65\",\"Node references\",536870913]],[\"^7[\",[69,\"^3;\",1775980635488,536870913]],[\"^7[\",[69,\"^2T\",\"~u00000002-1214-4953-4900-000000000000\",536870913]],[\"^7[\",[69,\"^<\",\"^14\",536870913]],[\"^7[\",[69,\"^;\",\"^1S\",536870913]],[\"^7[\",[69,\"^>\",true,536870913]],[\"^7[\",[69,\"^C\",\"^D\",536870913]],[\"^7[\",[69,\"^55\",true,536870913]],[\"^7[\",[69,\"^5G\",true,536870913]],[\"^7[\",[69,\"^X\",false,536870913]],[\"^7[\",[69,\"^1K\",\"^88\",536870913]],[\"^7[\",[70,\"^L\",127,536870913]],[\"^7[\",[70,\"^48\",1775980635492,536870913]],[\"^7[\",[70,\"^7;\",\"d3BGe\",536870913]],[\"^7[\",[70,\"^4P\",127,536870913]],[\"^7[\",[70,\"^3V\",127,536870913]],[\"^7[\",[70,\"^65\",\"Table View\",536870913]],[\"^7[\",[70,\"^3;\",1775980635492,536870913]],[\"^7[\",[70,\"^2T\",\"~u00000002-1942-5424-0000-000000000000\",536870913]],[\"^7[\",[70,\"^;\",\"^?\",536870913]],[\"^7[\",[70,\"^55\",true,536870913]],[\"^7[\",[70,\"^6:\",127,536870913]],[\"^7[\",[70,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"table\"],536870913]],[\"^7[\",[71,\"^48\",1775980635488,536870913]],[\"^7[\",[71,\"^7H\",\"description\",536870913]],[\"^7[\",[71,\"^7;\",\"d3BFT\",536870913]],[\"^7[\",[71,\"^64\",124,536870913]],[\"^7[\",[71,\"^65\",\"Description\",536870913]],[\"^7[\",[71,\"^3;\",1775980635488,536870913]],[\"^7[\",[71,\"^2T\",\"~u00000002-3362-3620-0000-000000000000\",536870913]],[\"^7[\",[71,\"^<\",\"^=\",536870913]],[\"^7[\",[71,\"^;\",\"^2A\",536870913]],[\"^7[\",[71,\"^>\",true,536870913]],[\"^7[\",[71,\"^C\",\"^D\",536870913]],[\"^7[\",[71,\"^55\",true,536870913]],[\"^7[\",[71,\"^X\",true,536870913]],[\"^7[\",[71,\"^1K\",\"^89\",536870913]],[\"^7[\",[72,\"^48\",1775980635495,536870913]],[\"^7[\",[72,\"^7H\",\"recycle original page\",536870913]],[\"^7[\",[72,\"^7;\",\"d3BHJ\",536870913]],[\"^7[\",[72,\"^64\",124,536870913]],[\"^7[\",[72,\"^65\",\"Recycle original page\",536870913]],[\"^7[\",[72,\"^3;\",1775980635495,536870913]],[\"^7[\",[72,\"^2T\",\"~u00000002-1658-7897-9900-000000000000\",536870913]],[\"^7[\",[72,\"^<\",\"^=\",536870913]],[\"^7[\",[72,\"^;\",\"^2Q\",536870913]],[\"^7[\",[72,\"^>\",true,536870913]],[\"^7[\",[72,\"^C\",\"^D\",536870913]],[\"^7[\",[72,\"^55\",true,536870913]],[\"^7[\",[72,\"^5G\",true,536870913]],[\"^7[\",[72,\"^X\",false,536870913]],[\"^7[\",[72,\"^1K\",\"^80\",536870913]],[\"^7[\",[73,\"^48\",1775980635490,536870913]],[\"^7[\",[73,\"^7H\",\"excluded references\",536870913]],[\"^7[\",[73,\"^7;\",\"d3BFz\",536870913]],[\"^7[\",[73,\"^64\",124,536870913]],[\"^7[\",[73,\"^65\",\"Excluded references\",536870913]],[\"^7[\",[73,\"^3;\",1775980635490,536870913]],[\"^7[\",[73,\"^2T\",\"~u00000002-2426-7588-9000-000000000000\",536870913]],[\"^7[\",[73,\"^<\",\"^14\",536870913]],[\"^7[\",[73,\"^;\",\"^35\",536870913]],[\"^7[\",[73,\"^>\",true,536870913]],[\"^7[\",[73,\"^C\",\"^D\",536870913]],[\"^7[\",[73,\"^55\",true,536870913]],[\"^7[\",[73,\"^5G\",true,536870913]],[\"^7[\",[73,\"^1K\",\"^80\",536870913]],[\"^7[\",[74,\"^48\",1775980635494,536870913]],[\"^7[\",[74,\"^7H\",\"history scalar value\",536870913]],[\"^7[\",[74,\"^7;\",\"d3BHE\",536870913]],[\"^7[\",[74,\"^64\",124,536870913]],[\"^7[\",[74,\"^65\",\"History scalar value\",536870913]],[\"^7[\",[74,\"^3;\",1775980635494,536870913]],[\"^7[\",[74,\"^2T\",\"~u00000002-2393-3777-5000-000000000000\",536870913]],[\"^7[\",[74,\"^<\",\"^=\",536870913]],[\"^7[\",[74,\"^;\",\"^3@\",536870913]],[\"^7[\",[74,\"^>\",true,536870913]],[\"^7[\",[74,\"^55\",true,536870913]],[\"^7[\",[74,\"^5G\",true,536870913]],[\"^7[\",[74,\"^1K\",\"^8C\",536870913]],[\"^7[\",[75,\"^48\",1775980635491,536870913]],[\"^7[\",[75,\"^7H\",\"deadline\",536870913]],[\"^7[\",[75,\"^7;\",\"d3BGJ\",536870913]],[\"^7[\",[75,\"^64\",124,536870913]],[\"^7[\",[75,\"^65\",\"Deadline\",536870913]],[\"^7[\",[75,\"^3;\",1775980635491,536870913]],[\"^7[\",[75,\"^2T\",\"~u00000002-1685-9016-0400-000000000000\",536870913]],[\"^7[\",[75,\"^<\",\"^=\",536870913]],[\"^7[\",[75,\"^;\",\"^1J\",536870913]],[\"^7[\",[75,\"^>\",true,536870913]],[\"^7[\",[75,\"^55\",true,536870913]],[\"^7[\",[75,\"^2A\",166,536870913]],[\"^7[\",[75,\"^3P\",true,536870913]],[\"^7[\",[75,\"^X\",true,536870913]],[\"^7[\",[75,\"^1K\",\"^8<\",536870913]],[\"^7[\",[75,\"^@\",\"~:block-below\",536870913]],[\"^7[\",[76,\"^L\",19,536870913]],[\"^7[\",[76,\"^48\",1775980635490,536870913]],[\"^7[\",[76,\"^7;\",\"d3BFr\",536870913]],[\"^7[\",[76,\"^4P\",19,536870913]],[\"^7[\",[76,\"^3V\",19,536870913]],[\"^7[\",[76,\"^65\",\"green\",536870913]],[\"^7[\",[76,\"^3;\",1775980635490,536870913]],[\"^7[\",[76,\"^2T\",\"~u00000002-1992-2016-2600-000000000000\",536870913]],[\"^7[\",[76,\"^;\",\"^2=\",536870913]],[\"^7[\",[76,\"^55\",true,536870913]],[\"^7[\",[76,\"^6:\",19,536870913]],[\"^7[\",[77,\"^48\",1775980635491,536870913]],[\"^7[\",[77,\"^7H\",\"choice classes\",536870913]],[\"^7[\",[77,\"^7;\",\"d3BG4\",536870913]],[\"^7[\",[77,\"^64\",124,536870913]],[\"^7[\",[77,\"^65\",\"Choice classes\",536870913]],[\"^7[\",[77,\"^3;\",1775980635491,536870913]],[\"^7[\",[77,\"^2T\",\"~u00000002-7629-6621-9000-000000000000\",536870913]],[\"^7[\",[77,\"^<\",\"^14\",536870913]],[\"^7[\",[77,\"^;\",\"^4:\",536870913]],[\"^7[\",[77,\"^>\",true,536870913]],[\"^7[\",[77,\"^C\",\"^D\",536870913]],[\"^7[\",[77,\"^55\",true,536870913]],[\"^7[\",[77,\"^5G\",true,536870913]],[\"^7[\",[77,\"^X\",false,536870913]],[\"^7[\",[77,\"^1K\",\"^8;\",536870913]],[\"^7[\",[77,\"^44\",\"~:never\",536870913]],[\"^7[\",[78,\"^48\",1775980635496,536870913]],[\"^7[\",[78,\"^7H\",\"quote\",536870913]],[\"^7[\",[78,\"^64\",14,536870913]],[\"^7[\",[78,\"^65\",\"Quote\",536870913]],[\"^7[\",[78,\"^3;\",1775980635496,536870913]],[\"^7[\",[78,\"^2T\",\"~u00000002-1176-1666-1700-000000000000\",536870913]],[\"^7[\",[78,\"^;\",\"^2K\",536870913]],[\"^7[\",[78,\"^55\",true,536870913]],[\"^7[\",[78,\"^3K\",89,536870913]],[\"^7[\",[78,\"^1X\",true,536870913]],[\"^7[\",[78,\"^4J\",37,536870913]],[\"^7[\",[79,\"^48\",1775980635489,536870913]],[\"^7[\",[79,\"^7H\",\"non ref type default value\",536870913]],[\"^7[\",[79,\"^7;\",\"d3BFW\",536870913]],[\"^7[\",[79,\"^64\",124,536870913]],[\"^7[\",[79,\"^65\",\"Non ref type default value\",536870913]],[\"^7[\",[79,\"^3;\",1775980635489,536870913]],[\"^7[\",[79,\"^2T\",\"~u00000002-1595-7230-1400-000000000000\",536870913]],[\"^7[\",[79,\"^<\",\"^=\",536870913]],[\"^7[\",[79,\"^;\",\"^29\",536870913]],[\"^7[\",[79,\"^>\",true,536870913]],[\"^7[\",[79,\"^55\",true,536870913]],[\"^7[\",[79,\"^5G\",true,536870913]],[\"^7[\",[79,\"^X\",false,536870913]],[\"^7[\",[79,\"^1K\",\"^8C\",536870913]],[\"^7[\",[79,\"^44\",\"^81\",536870913]],[\"^7[\",[80,\"^48\",1775980635494,536870913]],[\"^7[\",[80,\"^7H\",\"last visit page\",536870913]],[\"^7[\",[80,\"^7;\",\"d3BGz\",536870913]],[\"^7[\",[80,\"^64\",124,536870913]],[\"^7[\",[80,\"^65\",\"Last visit page\",536870913]],[\"^7[\",[80,\"^3;\",1775980635494,536870913]],[\"^7[\",[80,\"^2T\",\"~u00000002-2107-8035-3500-000000000000\",536870913]],[\"^7[\",[80,\"^<\",\"^=\",536870913]],[\"^7[\",[80,\"^;\",\"^4Y\",536870913]],[\"^7[\",[80,\"^>\",true,536870913]],[\"^7[\",[80,\"^55\",true,536870913]],[\"^7[\",[80,\"^5G\",true,536870913]],[\"^7[\",[80,\"^X\",false,536870913]],[\"^7[\",[80,\"^1K\",\"^8>\",536870913]],[\"^7[\",[81,\"^48\",1775980635495,536870913]],[\"^7[\",[81,\"^7H\",\"used template\",536870913]],[\"^7[\",[81,\"^7;\",\"d3BHN\",536870913]],[\"^7[\",[81,\"^64\",124,536870913]],[\"^7[\",[81,\"^65\",\"Used template\",536870913]],[\"^7[\",[81,\"^3;\",1775980635495,536870913]],[\"^7[\",[81,\"^2T\",\"~u00000002-9803-6990-6000-000000000000\",536870913]],[\"^7[\",[81,\"^<\",\"^=\",536870913]],[\"^7[\",[81,\"^;\",\"^4K\",536870913]],[\"^7[\",[81,\"^>\",true,536870913]],[\"^7[\",[81,\"^C\",\"^D\",536870913]],[\"^7[\",[81,\"^55\",true,536870913]],[\"^7[\",[81,\"^6B\",38,536870913]],[\"^7[\",[81,\"^5G\",true,536870913]],[\"^7[\",[81,\"^X\",false,536870913]],[\"^7[\",[81,\"^1K\",\"^80\",536870913]],[\"^7[\",[82,\"^L\",13,536870913]],[\"^7[\",[82,\"^48\",1775980635492,536870913]],[\"^7[\",[82,\"^7;\",\"d3BGU\",536870913]],[\"^7[\",[82,\"^4P\",13,536870913]],[\"^7[\",[82,\"^3V\",13,536870913]],[\"^7[\",[82,\"^65\",\"Month\",536870913]],[\"^7[\",[82,\"^3;\",1775980635492,536870913]],[\"^7[\",[82,\"^2T\",\"~u00000002-2073-3937-9700-000000000000\",536870913]],[\"^7[\",[82,\"^;\",\"^5C\",536870913]],[\"^7[\",[82,\"^55\",true,536870913]],[\"^7[\",[82,\"^6:\",13,536870913]],[\"^7[\",[83,\"^48\",1775980635494,536870913]],[\"^7[\",[83,\"^7H\",\"history value\",536870913]],[\"^7[\",[83,\"^7;\",\"d3BHD\",536870913]],[\"^7[\",[83,\"^64\",124,536870913]],[\"^7[\",[83,\"^65\",\"History value\",536870913]],[\"^7[\",[83,\"^3;\",1775980635494,536870913]],[\"^7[\",[83,\"^2T\",\"~u00000002-5131-3603-7000-000000000000\",536870913]],[\"^7[\",[83,\"^<\",\"^=\",536870913]],[\"^7[\",[83,\"^;\",\"^5P\",536870913]],[\"^7[\",[83,\"^>\",true,536870913]],[\"^7[\",[83,\"^C\",\"^D\",536870913]],[\"^7[\",[83,\"^55\",true,536870913]],[\"^7[\",[83,\"^5G\",true,536870913]],[\"^7[\",[83,\"^1K\",\"^88\",536870913]],[\"^7[\",[84,\"^48\",1775980635493,536870913]],[\"^7[\",[84,\"^7H\",\"view filters\",536870913]],[\"^7[\",[84,\"^7;\",\"d3BGm\",536870913]],[\"^7[\",[84,\"^64\",124,536870913]],[\"^7[\",[84,\"^65\",\"View filters\",536870913]],[\"^7[\",[84,\"^3;\",1775980635493,536870913]],[\"^7[\",[84,\"^2T\",\"~u00000002-1702-3936-3300-000000000000\",536870913]],[\"^7[\",[84,\"^<\",\"^=\",536870913]],[\"^7[\",[84,\"^;\",\"^5Y\",536870913]],[\"^7[\",[84,\"^>\",true,536870913]],[\"^7[\",[84,\"^55\",true,536870913]],[\"^7[\",[84,\"^5G\",true,536870913]],[\"^7[\",[84,\"^X\",false,536870913]],[\"^7[\",[84,\"^1K\",\"^83\",536870913]],[\"^7[\",[85,\"^48\",1775980635496,536870913]],[\"^7[\",[85,\"^7H\",\"journal\",536870913]],[\"^7[\",[85,\"^64\",14,536870913]],[\"^7[\",[85,\"^65\",\"Journal\",536870913]],[\"^7[\",[85,\"^3;\",1775980635496,536870913]],[\"^7[\",[85,\"^2T\",\"~u00000002-1979-7410-8100-000000000000\",536870913]],[\"^7[\",[85,\"^;\",\"^6<\",536870913]],[\"^7[\",[85,\"^55\",true,536870913]],[\"^7[\",[85,\"^3K\",104,536870913]],[\"^7[\",[85,\"^53\",\"MMM do, yyyy\",536870913]],[\"^7[\",[86,\"^48\",1775980635486,536870913]],[\"^7[\",[86,\"^7H\",\"built in?\",536870913]],[\"^7[\",[86,\"^7;\",\"d3BF7\",536870913]],[\"^7[\",[86,\"^64\",124,536870913]],[\"^7[\",[86,\"^65\",\"Built in?\",536870913]],[\"^7[\",[86,\"^3;\",1775980635486,536870913]],[\"^7[\",[86,\"^2T\",\"~u00000002-1125-9581-6000-000000000000\",536870913]],[\"^7[\",[86,\"^<\",\"^=\",536870913]],[\"^7[\",[86,\"^;\",\"^55\",536870913]],[\"^7[\",[86,\"^>\",true,536870913]],[\"^7[\",[86,\"^55\",true,536870913]],[\"^7[\",[86,\"^5G\",true,536870913]],[\"^7[\",[86,\"^1K\",\"^8:\",536870913]],[\"^7[\",[87,\"^48\",1775980635496,536870913]],[\"^7[\",[87,\"^7H\",\"whiteboard\",536870913]],[\"^7[\",[87,\"^64\",14,536870913]],[\"^7[\",[87,\"^65\",\"Whiteboard\",536870913]],[\"^7[\",[87,\"^3;\",1775980635496,536870913]],[\"^7[\",[87,\"^2T\",\"~u00000002-1013-6984-5200-000000000000\",536870913]],[\"^7[\",[87,\"^;\",\"^67\",536870913]],[\"^7[\",[87,\"^55\",true,536870913]],[\"^7[\",[87,\"^3K\",104,536870913]],[\"^7[\",[88,\"^48\",1775980635494,536870913]],[\"^7[\",[88,\"^7H\",\"asset alignment\",536870913]],[\"^7[\",[88,\"^7;\",\"d3BH3\",536870913]],[\"^7[\",[88,\"^64\",124,536870913]],[\"^7[\",[88,\"^65\",\"Asset alignment\",536870913]],[\"^7[\",[88,\"^3;\",1775980635494,536870913]],[\"^7[\",[88,\"^2T\",\"~u00000002-7135-0412-8000-000000000000\",536870913]],[\"^7[\",[88,\"^<\",\"^=\",536870913]],[\"^7[\",[88,\"^;\",\"^:\",536870913]],[\"^7[\",[88,\"^>\",true,536870913]],[\"^7[\",[88,\"^55\",true,536870913]],[\"^7[\",[88,\"^5G\",true,536870913]],[\"^7[\",[88,\"^X\",false,536870913]],[\"^7[\",[88,\"^1K\",\"^8?\",536870913]],[\"^7[\",[89,\"^48\",1775980635496,536870913]],[\"^7[\",[89,\"^7H\",\"root tag\",536870913]],[\"^7[\",[89,\"^64\",14,536870913]],[\"^7[\",[89,\"^65\",\"Root Tag\",536870913]],[\"^7[\",[89,\"^3;\",1775980635496,536870913]],[\"^7[\",[89,\"^2T\",\"~u00000002-2737-8382-7000-000000000000\",536870913]],[\"^7[\",[89,\"^;\",\"^5M\",536870913]],[\"^7[\",[89,\"^55\",true,536870913]],[\"^7[\",[90,\"^48\",1775980635489,536870913]],[\"^7[\",[90,\"^7H\",\"heading\",536870913]],[\"^7[\",[90,\"^7;\",\"d3BFk\",536870913]],[\"^7[\",[90,\"^64\",124,536870913]],[\"^7[\",[90,\"^65\",\"Heading\",536870913]],[\"^7[\",[90,\"^3;\",1775980635489,536870913]],[\"^7[\",[90,\"^2T\",\"~u00000002-1858-7494-1500-000000000000\",536870913]],[\"^7[\",[90,\"^<\",\"^=\",536870913]],[\"^7[\",[90,\"^;\",\"^2L\",536870913]],[\"^7[\",[90,\"^>\",true,536870913]],[\"^7[\",[90,\"^55\",true,536870913]],[\"^7[\",[90,\"^5G\",true,536870913]],[\"^7[\",[90,\"^1K\",\"^8C\",536870913]],[\"^7[\",[91,\"^48\",1775980635490,536870913]],[\"^7[\",[91,\"^7H\",\"annotation image\",536870913]],[\"^7[\",[91,\"^7;\",\"d3BFv\",536870913]],[\"^7[\",[91,\"^64\",124,536870913]],[\"^7[\",[91,\"^65\",\"Annotation image\",536870913]],[\"^7[\",[91,\"^3;\",1775980635490,536870913]],[\"^7[\",[91,\"^2T\",\"~u00000002-1377-6700-9000-000000000000\",536870913]],[\"^7[\",[91,\"^<\",\"^=\",536870913]],[\"^7[\",[91,\"^;\",\"^M\",536870913]],[\"^7[\",[91,\"^>\",true,536870913]],[\"^7[\",[91,\"^C\",\"^D\",536870913]],[\"^7[\",[91,\"^55\",true,536870913]],[\"^7[\",[91,\"^5G\",true,536870913]],[\"^7[\",[91,\"^1K\",\"^88\",536870913]],[\"^7[\",[92,\"^48\",1775980635487,536870913]],[\"^7[\",[92,\"^7H\",\"property view context\",536870913]],[\"^7[\",[92,\"^7;\",\"d3BFB\",536870913]],[\"^7[\",[92,\"^64\",124,536870913]],[\"^7[\",[92,\"^65\",\"Property view context\",536870913]],[\"^7[\",[92,\"^3;\",1775980635487,536870913]],[\"^7[\",[92,\"^2T\",\"~u00000002-1547-3958-2800-000000000000\",536870913]],[\"^7[\",[92,\"^<\",\"^=\",536870913]],[\"^7[\",[92,\"^;\",\"^44\",536870913]],[\"^7[\",[92,\"^>\",true,536870913]],[\"^7[\",[92,\"^55\",true,536870913]],[\"^7[\",[92,\"^5G\",true,536870913]],[\"^7[\",[92,\"^1K\",\"^8?\",536870913]],[\"^7[\",[93,\"^48\",1775980635492,536870913]],[\"^7[\",[93,\"^7H\",\"view feature type\",536870913]],[\"^7[\",[93,\"^7;\",\"d3BGh\",536870913]],[\"^7[\",[93,\"^64\",124,536870913]],[\"^7[\",[93,\"^65\",\"View Feature Type\",536870913]],[\"^7[\",[93,\"^3;\",1775980635492,536870913]],[\"^7[\",[93,\"^2T\",\"~u00000002-9391-4187-1000-000000000000\",536870913]],[\"^7[\",[93,\"^<\",\"^=\",536870913]],[\"^7[\",[93,\"^;\",\"^5<\",536870913]],[\"^7[\",[93,\"^>\",true,536870913]],[\"^7[\",[93,\"^55\",true,536870913]],[\"^7[\",[93,\"^5G\",true,536870913]],[\"^7[\",[93,\"^X\",false,536870913]],[\"^7[\",[93,\"^1K\",\"^8?\",536870913]],[\"^7[\",[94,\"^L\",32,536870913]],[\"^7[\",[94,\"^48\",1775980635491,536870913]],[\"^7[\",[94,\"^7;\",\"d3BGF\",536870913]],[\"^7[\",[94,\"^4P\",32,536870913]],[\"^7[\",[94,\"^3V\",32,536870913]],[\"^7[\",[94,\"^65\",\"Low\",536870913]],[\"^7[\",[94,\"^3;\",1775980635491,536870913]],[\"^7[\",[94,\"^2T\",\"~u00000002-2107-4537-4800-000000000000\",536870913]],[\"^7[\",[94,\"^;\",\"^63\",536870913]],[\"^7[\",[94,\"^55\",true,536870913]],[\"^7[\",[94,\"^6:\",32,536870913]],[\"^7[\",[94,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlLow\"],536870913]],[\"^7[\",[95,\"^48\",1775980635491,536870913]],[\"^7[\",[95,\"^7H\",\"repeating recur frequency\",536870913]],[\"^7[\",[95,\"^7;\",\"d3BGN\",536870913]],[\"^7[\",[95,\"^64\",124,536870913]],[\"^7[\",[95,\"^65\",\"Repeating recur frequency\",536870913]],[\"^7[\",[95,\"^3;\",1775980635491,536870913]],[\"^7[\",[95,\"^2T\",\"~u00000002-8716-1592-2000-000000000000\",536870913]],[\"^7[\",[95,\"^<\",\"^=\",536870913]],[\"^7[\",[95,\"^;\",\"^5H\",536870913]],[\"^7[\",[95,\"^>\",true,536870913]],[\"^7[\",[95,\"^C\",\"^D\",536870913]],[\"^7[\",[95,\"^55\",true,536870913]],[\"^7[\",[95,\"^5I\",175,536870913]],[\"^7[\",[95,\"^3P\",true,536870913]],[\"^7[\",[95,\"^X\",false,536870913]],[\"^7[\",[95,\"^1K\",\"~:number\",536870913]],[\"^7[\",[96,\"^48\",1775980635492,536870913]],[\"^7[\",[96,\"^7H\",\"node repeats?\",536870913]],[\"^7[\",[96,\"^7;\",\"d3BGW\",536870913]],[\"^7[\",[96,\"^64\",124,536870913]],[\"^7[\",[96,\"^65\",\"Node Repeats?\",536870913]],[\"^7[\",[96,\"^3;\",1775980635492,536870913]],[\"^7[\",[96,\"^2T\",\"~u00000002-1908-1217-8900-000000000000\",536870913]],[\"^7[\",[96,\"^<\",\"^=\",536870913]],[\"^7[\",[96,\"^;\",\"^7\",536870913]],[\"^7[\",[96,\"^>\",true,536870913]],[\"^7[\",[96,\"^55\",true,536870913]],[\"^7[\",[96,\"^5G\",true,536870913]],[\"^7[\",[96,\"^1K\",\"^8:\",536870913]],[\"^7[\",[97,\"^48\",1775980635488,536870913]],[\"^7[\",[97,\"^7H\",\"closed value property\",536870913]],[\"^7[\",[97,\"^7;\",\"d3BFO\",536870913]],[\"^7[\",[97,\"^64\",124,536870913]],[\"^7[\",[97,\"^65\",\"Closed value property\",536870913]],[\"^7[\",[97,\"^3;\",1775980635488,536870913]],[\"^7[\",[97,\"^2T\",\"~u00000002-1157-7928-1300-000000000000\",536870913]],[\"^7[\",[97,\"^<\",\"^=\",536870913]],[\"^7[\",[97,\"^;\",\"^L\",536870913]],[\"^7[\",[97,\"^>\",true,536870913]],[\"^7[\",[97,\"^C\",\"^D\",536870913]],[\"^7[\",[97,\"^55\",true,536870913]],[\"^7[\",[97,\"^5G\",true,536870913]],[\"^7[\",[97,\"^X\",false,536870913]],[\"^7[\",[97,\"^1K\",\"^88\",536870913]],[\"^7[\",[98,\"^48\",1775980635495,536870913]],[\"^7[\",[98,\"^7H\",\"reaction emoji\",536870913]],[\"^7[\",[98,\"^7;\",\"d3BHL\",536870913]],[\"^7[\",[98,\"^64\",124,536870913]],[\"^7[\",[98,\"^65\",\"Reaction emoji\",536870913]],[\"^7[\",[98,\"^3;\",1775980635495,536870913]],[\"^7[\",[98,\"^2T\",\"~u00000002-9877-5864-5000-000000000000\",536870913]],[\"^7[\",[98,\"^<\",\"^=\",536870913]],[\"^7[\",[98,\"^;\",\"^10\",536870913]],[\"^7[\",[98,\"^>\",true,536870913]],[\"^7[\",[98,\"^55\",true,536870913]],[\"^7[\",[98,\"^5G\",true,536870913]],[\"^7[\",[98,\"^X\",false,536870913]],[\"^7[\",[98,\"^1K\",\"^82\",536870913]],[\"^7[\",[99,\"^L\",32,536870913]],[\"^7[\",[99,\"^48\",1775980635491,536870913]],[\"^7[\",[99,\"^7;\",\"d3BGH\",536870913]],[\"^7[\",[99,\"^4P\",32,536870913]],[\"^7[\",[99,\"^3V\",32,536870913]],[\"^7[\",[99,\"^65\",\"High\",536870913]],[\"^7[\",[99,\"^3;\",1775980635491,536870913]],[\"^7[\",[99,\"^2T\",\"~u00000002-5672-2766-8000-000000000000\",536870913]],[\"^7[\",[99,\"^;\",\"^1=\",536870913]],[\"^7[\",[99,\"^55\",true,536870913]],[\"^7[\",[99,\"^6:\",32,536870913]],[\"^7[\",[99,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlHigh\"],536870913]],[\"^7[\",[100,\"^L\",19,536870913]],[\"^7[\",[100,\"^48\",1775980635490,536870913]],[\"^7[\",[100,\"^7;\",\"d3BFs\",536870913]],[\"^7[\",[100,\"^4P\",19,536870913]],[\"^7[\",[100,\"^3V\",19,536870913]],[\"^7[\",[100,\"^65\",\"blue\",536870913]],[\"^7[\",[100,\"^3;\",1775980635490,536870913]],[\"^7[\",[100,\"^2T\",\"~u00000002-1836-0512-4100-000000000000\",536870913]],[\"^7[\",[100,\"^;\",\"^1G\",536870913]],[\"^7[\",[100,\"^55\",true,536870913]],[\"^7[\",[100,\"^6:\",19,536870913]],[\"^7[\",[101,\"^48\",1775980635493,536870913]],[\"^7[\",[101,\"^7H\",\"view ordered columns\",536870913]],[\"^7[\",[101,\"^7;\",\"d3BGo\",536870913]],[\"^7[\",[101,\"^64\",124,536870913]],[\"^7[\",[101,\"^65\",\"View ordered columns\",536870913]],[\"^7[\",[101,\"^3;\",1775980635493,536870913]],[\"^7[\",[101,\"^2T\",\"~u00000002-1485-5871-0000-000000000000\",536870913]],[\"^7[\",[101,\"^<\",\"^=\",536870913]],[\"^7[\",[101,\"^;\",\"^1U\",536870913]],[\"^7[\",[101,\"^>\",true,536870913]],[\"^7[\",[101,\"^55\",true,536870913]],[\"^7[\",[101,\"^5G\",true,536870913]],[\"^7[\",[101,\"^X\",false,536870913]],[\"^7[\",[101,\"^1K\",\"~:coll\",536870913]],[\"^7[\",[102,\"^48\",1775980635489,536870913]],[\"^7[\",[102,\"^7H\",\"background color\",536870913]],[\"^7[\",[102,\"^7;\",\"d3BFj\",536870913]],[\"^7[\",[102,\"^64\",124,536870913]],[\"^7[\",[102,\"^65\",\"Background color\",536870913]],[\"^7[\",[102,\"^3;\",1775980635489,536870913]],[\"^7[\",[102,\"^2T\",\"~u00000002-5191-2660-6000-000000000000\",536870913]],[\"^7[\",[102,\"^<\",\"^=\",536870913]],[\"^7[\",[102,\"^;\",\"^26\",536870913]],[\"^7[\",[102,\"^>\",true,536870913]],[\"^7[\",[102,\"^C\",\"^D\",536870913]],[\"^7[\",[102,\"^55\",true,536870913]],[\"^7[\",[102,\"^5G\",true,536870913]],[\"^7[\",[102,\"^1K\",\"^89\",536870913]],[\"^7[\",[103,\"^48\",1775980635496,536870913]],[\"^7[\",[103,\"^7H\",\"cards\",536870913]],[\"^7[\",[103,\"^64\",14,536870913]],[\"^7[\",[103,\"^65\",\"Cards\",536870913]],[\"^7[\",[103,\"^3;\",1775980635496,536870913]],[\"^7[\",[103,\"^2T\",\"~u00000002-1284-2651-6700-000000000000\",536870913]],[\"^7[\",[103,\"^;\",\"^2C\",536870913]],[\"^7[\",[103,\"^55\",true,536870913]],[\"^7[\",[103,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"search\"],536870913]],[\"^7[\",[103,\"^3K\",142,536870913]],[\"^7[\",[104,\"^48\",1775980635496,536870913]],[\"^7[\",[104,\"^7H\",\"page\",536870913]],[\"^7[\",[104,\"^64\",14,536870913]],[\"^7[\",[104,\"^65\",\"Page\",536870913]],[\"^7[\",[104,\"^3;\",1775980635496,536870913]],[\"^7[\",[104,\"^2T\",\"~u00000002-1484-3403-2900-000000000000\",536870913]],[\"^7[\",[104,\"^;\",\"^2S\",536870913]],[\"^7[\",[104,\"^55\",true,536870913]],[\"^7[\",[104,\"^3K\",89,536870913]],[\"^7[\",[105,\"^L\",13,536870913]],[\"^7[\",[105,\"^48\",1775980635492,536870913]],[\"^7[\",[105,\"^7;\",\"d3BGT\",536870913]],[\"^7[\",[105,\"^4P\",13,536870913]],[\"^7[\",[105,\"^3V\",13,536870913]],[\"^7[\",[105,\"^65\",\"Week\",536870913]],[\"^7[\",[105,\"^3;\",1775980635492,536870913]],[\"^7[\",[105,\"^2T\",\"~u00000002-2130-9244-4900-000000000000\",536870913]],[\"^7[\",[105,\"^;\",\"^U\",536870913]],[\"^7[\",[105,\"^55\",true,536870913]],[\"^7[\",[105,\"^6:\",13,536870913]],[\"^7[\",[106,\"^48\",1775980635490,536870913]],[\"^7[\",[106,\"^7H\",\"tldraw shape\",536870913]],[\"^7[\",[106,\"^7;\",\"d3BG1\",536870913]],[\"^7[\",[106,\"^64\",124,536870913]],[\"^7[\",[106,\"^65\",\"Tldraw Shape\",536870913]],[\"^7[\",[106,\"^3;\",1775980635490,536870913]],[\"^7[\",[106,\"^2T\",\"~u00000002-1313-2454-2000-000000000000\",536870913]],[\"^7[\",[106,\"^<\",\"^=\",536870913]],[\"^7[\",[106,\"^;\",\"^3B\",536870913]],[\"^7[\",[106,\"^>\",true,536870913]],[\"^7[\",[106,\"^55\",true,536870913]],[\"^7[\",[106,\"^5G\",true,536870913]],[\"^7[\",[106,\"^1K\",\"^83\",536870913]],[\"^7[\",[107,\"^48\",1775980635493,536870913]],[\"^7[\",[107,\"^7H\",\"image height\",536870913]],[\"^7[\",[107,\"^7;\",\"d3BGx\",536870913]],[\"^7[\",[107,\"^64\",124,536870913]],[\"^7[\",[107,\"^65\",\"Image height\",536870913]],[\"^7[\",[107,\"^3;\",1775980635493,536870913]],[\"^7[\",[107,\"^2T\",\"~u00000002-3306-1650-4000-000000000000\",536870913]],[\"^7[\",[107,\"^<\",\"^=\",536870913]],[\"^7[\",[107,\"^;\",\"^3M\",536870913]],[\"^7[\",[107,\"^>\",true,536870913]],[\"^7[\",[107,\"^55\",true,536870913]],[\"^7[\",[107,\"^5G\",true,536870913]],[\"^7[\",[107,\"^X\",false,536870913]],[\"^7[\",[107,\"^1K\",\"^8>\",536870913]],[\"^7[\",[108,\"^48\",1775980635488,536870913]],[\"^7[\",[108,\"^7H\",\"code mode\",536870913]],[\"^7[\",[108,\"^7;\",\"d3BFU\",536870913]],[\"^7[\",[108,\"^64\",124,536870913]],[\"^7[\",[108,\"^65\",\"Code Mode\",536870913]],[\"^7[\",[108,\"^3;\",1775980635488,536870913]],[\"^7[\",[108,\"^2T\",\"~u00000002-8578-9716-5000-000000000000\",536870913]],[\"^7[\",[108,\"^<\",\"^=\",536870913]],[\"^7[\",[108,\"^;\",\"^40\",536870913]],[\"^7[\",[108,\"^>\",true,536870913]],[\"^7[\",[108,\"^55\",true,536870913]],[\"^7[\",[108,\"^5G\",true,536870913]],[\"^7[\",[108,\"^X\",false,536870913]],[\"^7[\",[108,\"^1K\",\"^82\",536870913]],[\"^7[\",[108,\"^44\",\"^8@\",536870913]],[\"^7[\",[109,\"^48\",1775980635495,536870913]],[\"^7[\",[109,\"^7H\",\"node created by\",536870913]],[\"^7[\",[109,\"^7;\",\"d3BHF\",536870913]],[\"^7[\",[109,\"^64\",124,536870913]],[\"^7[\",[109,\"^65\",\"Node created by\",536870913]],[\"^7[\",[109,\"^3;\",1775980635495,536870913]],[\"^7[\",[109,\"^2T\",\"~u00000002-8544-3390-8000-000000000000\",536870913]],[\"^7[\",[109,\"^<\",\"^=\",536870913]],[\"^7[\",[109,\"^;\",\"^4<\",536870913]],[\"^7[\",[109,\"^>\",true,536870913]],[\"^7[\",[109,\"^C\",\"^D\",536870913]],[\"^7[\",[109,\"^55\",true,536870913]],[\"^7[\",[109,\"^5G\",true,536870913]],[\"^7[\",[109,\"^1K\",\"^88\",536870913]],[\"^7[\",[110,\"^48\",1775980635491,536870913]],[\"^7[\",[110,\"^7H\",\"choice exclusions\",536870913]],[\"^7[\",[110,\"^7;\",\"d3BG5\",536870913]],[\"^7[\",[110,\"^64\",124,536870913]],[\"^7[\",[110,\"^65\",\"Choice exclusions\",536870913]],[\"^7[\",[110,\"^3;\",1775980635491,536870913]],[\"^7[\",[110,\"^2T\",\"~u00000002-1233-5229-4600-000000000000\",536870913]],[\"^7[\",[110,\"^<\",\"^14\",536870913]],[\"^7[\",[110,\"^;\",\"^13\",536870913]],[\"^7[\",[110,\"^>\",true,536870913]],[\"^7[\",[110,\"^C\",\"^D\",536870913]],[\"^7[\",[110,\"^55\",true,536870913]],[\"^7[\",[110,\"^5G\",true,536870913]],[\"^7[\",[110,\"^X\",false,536870913]],[\"^7[\",[110,\"^1K\",\"^80\",536870913]],[\"^7[\",[110,\"^44\",\"^8E\",536870913]],[\"^7[\",[111,\"^;\",\"^R\",536870913]],[\"^7[\",[111,\"^28\",[\"^ \",\"^8A\",65,\"^8B\",24],536870913]],[\"^7[\",[112,\"^48\",1775980635490,536870913]],[\"^7[\",[112,\"^7H\",\"title format\",536870913]],[\"^7[\",[112,\"^7;\",\"d3BG2\",536870913]],[\"^7[\",[112,\"^64\",124,536870913]],[\"^7[\",[112,\"^65\",\"Title Format\",536870913]],[\"^7[\",[112,\"^3;\",1775980635490,536870913]],[\"^7[\",[112,\"^2T\",\"~u00000002-1536-4979-5400-000000000000\",536870913]],[\"^7[\",[112,\"^<\",\"^=\",536870913]],[\"^7[\",[112,\"^;\",\"^53\",536870913]],[\"^7[\",[112,\"^>\",true,536870913]],[\"^7[\",[112,\"^55\",true,536870913]],[\"^7[\",[112,\"^X\",false,536870913]],[\"^7[\",[112,\"^1K\",\"^82\",536870913]],[\"^7[\",[113,\"^48\",1775980635490,536870913]],[\"^7[\",[113,\"^7H\",\"included references\",536870913]],[\"^7[\",[113,\"^7;\",\"d3BFy\",536870913]],[\"^7[\",[113,\"^64\",124,536870913]],[\"^7[\",[113,\"^65\",\"Included references\",536870913]],[\"^7[\",[113,\"^3;\",1775980635490,536870913]],[\"^7[\",[113,\"^2T\",\"~u00000002-1680-5777-0300-000000000000\",536870913]],[\"^7[\",[113,\"^<\",\"^14\",536870913]],[\"^7[\",[113,\"^;\",\"^2F\",536870913]],[\"^7[\",[113,\"^>\",true,536870913]],[\"^7[\",[113,\"^C\",\"^D\",536870913]],[\"^7[\",[113,\"^55\",true,536870913]],[\"^7[\",[113,\"^5G\",true,536870913]],[\"^7[\",[113,\"^1K\",\"^80\",536870913]],[\"^7[\",[114,\"^L\",26,536870913]],[\"^7[\",[114,\"^48\",1775980635491,536870913]],[\"^7[\",[114,\"^7;\",\"d3BG9\",536870913]],[\"^7[\",[114,\"^4P\",26,536870913]],[\"^7[\",[114,\"^3V\",26,536870913]],[\"^7[\",[114,\"^65\",\"Todo\",536870913]],[\"^7[\",[114,\"^3;\",1775980635491,536870913]],[\"^7[\",[114,\"^2T\",\"~u00000002-1615-5853-7700-000000000000\",536870913]],[\"^7[\",[114,\"^;\",\"^5E\",536870913]],[\"^7[\",[114,\"^55\",true,536870913]],[\"^7[\",[114,\"^4T\",false,536870913]],[\"^7[\",[114,\"^6:\",26,536870913]],[\"^7[\",[114,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Todo\"],536870913]],[\"^7[\",[115,\"^48\",1775980635494,536870913]],[\"^7[\",[115,\"^7H\",\"history property\",536870913]],[\"^7[\",[115,\"^7;\",\"d3BHC\",536870913]],[\"^7[\",[115,\"^64\",124,536870913]],[\"^7[\",[115,\"^65\",\"History property\",536870913]],[\"^7[\",[115,\"^3;\",1775980635494,536870913]],[\"^7[\",[115,\"^2T\",\"~u00000002-1600-4090-8200-000000000000\",536870913]],[\"^7[\",[115,\"^<\",\"^=\",536870913]],[\"^7[\",[115,\"^;\",\"^5R\",536870913]],[\"^7[\",[115,\"^>\",true,536870913]],[\"^7[\",[115,\"^C\",\"^D\",536870913]],[\"^7[\",[115,\"^55\",true,536870913]],[\"^7[\",[115,\"^5G\",true,536870913]],[\"^7[\",[115,\"^1K\",\"^81\",536870913]],[\"^7[\",[116,\"^48\",1775980635495,536870913]],[\"^7[\",[116,\"^7H\",\"recycle original parent\",536870913]],[\"^7[\",[116,\"^7;\",\"d3BHI\",536870913]],[\"^7[\",[116,\"^64\",124,536870913]],[\"^7[\",[116,\"^65\",\"Recycle original parent\",536870913]],[\"^7[\",[116,\"^3;\",1775980635495,536870913]],[\"^7[\",[116,\"^2T\",\"~u00000002-4970-0399-4000-000000000000\",536870913]],[\"^7[\",[116,\"^<\",\"^=\",536870913]],[\"^7[\",[116,\"^;\",\"^5[\",536870913]],[\"^7[\",[116,\"^>\",true,536870913]],[\"^7[\",[116,\"^C\",\"^D\",536870913]],[\"^7[\",[116,\"^55\",true,536870913]],[\"^7[\",[116,\"^5G\",true,536870913]],[\"^7[\",[116,\"^X\",false,536870913]],[\"^7[\",[116,\"^1K\",\"^80\",536870913]],[\"^7[\",[117,\"^48\",1775980635490,536870913]],[\"^7[\",[117,\"^7H\",\"annotation data\",536870913]],[\"^7[\",[117,\"^7;\",\"d3BFw\",536870913]],[\"^7[\",[117,\"^64\",124,536870913]],[\"^7[\",[117,\"^65\",\"Annotation data\",536870913]],[\"^7[\",[117,\"^3;\",1775980635490,536870913]],[\"^7[\",[117,\"^2T\",\"~u00000002-5458-2940-2000-000000000000\",536870913]],[\"^7[\",[117,\"^<\",\"^=\",536870913]],[\"^7[\",[117,\"^;\",\"^6>\",536870913]],[\"^7[\",[117,\"^>\",true,536870913]],[\"^7[\",[117,\"^55\",true,536870913]],[\"^7[\",[117,\"^5G\",true,536870913]],[\"^7[\",[117,\"^1K\",\"^83\",536870913]],[\"^7[\",[118,\"^48\",1775980635492,536870913]],[\"^7[\",[118,\"^7H\",\"view group by property\",536870913]],[\"^7[\",[118,\"^7;\",\"d3BGi\",536870913]],[\"^7[\",[118,\"^64\",124,536870913]],[\"^7[\",[118,\"^65\",\"View group by property\",536870913]],[\"^7[\",[118,\"^3;\",1775980635492,536870913]],[\"^7[\",[118,\"^2T\",\"~u00000002-8092-1623-6000-000000000000\",536870913]],[\"^7[\",[118,\"^<\",\"^=\",536870913]],[\"^7[\",[118,\"^;\",\"^43\",536870913]],[\"^7[\",[118,\"^>\",true,536870913]],[\"^7[\",[118,\"^C\",\"^D\",536870913]],[\"^7[\",[118,\"^55\",true,536870913]],[\"^7[\",[118,\"^5G\",true,536870913]],[\"^7[\",[118,\"^X\",false,536870913]],[\"^7[\",[118,\"^1K\",\"^81\",536870913]],[\"^7[\",[119,\"^48\",1775980635487,536870913]],[\"^7[\",[119,\"^7H\",\"hide this property or page\",536870913]],[\"^7[\",[119,\"^7;\",\"d3BF8\",536870913]],[\"^7[\",[119,\"^64\",124,536870913]],[\"^7[\",[119,\"^65\",\"Hide this property or page\",536870913]],[\"^7[\",[119,\"^3;\",1775980635487,536870913]],[\"^7[\",[119,\"^2T\",\"~u00000002-6836-5746-0000-000000000000\",536870913]],[\"^7[\",[119,\"^<\",\"^=\",536870913]],[\"^7[\",[119,\"^;\",\"^5G\",536870913]],[\"^7[\",[119,\"^>\",true,536870913]],[\"^7[\",[119,\"^55\",true,536870913]],[\"^7[\",[119,\"^5G\",true,536870913]],[\"^7[\",[119,\"^1K\",\"^8:\",536870913]],[\"^7[\",[120,\"^48\",1775980635488,536870913]],[\"^7[\",[120,\"^7H\",\"node links to\",536870913]],[\"^7[\",[120,\"^7;\",\"d3BFM\",536870913]],[\"^7[\",[120,\"^64\",124,536870913]],[\"^7[\",[120,\"^65\",\"Node links to\",536870913]],[\"^7[\",[120,\"^3;\",1775980635488,536870913]],[\"^7[\",[120,\"^2T\",\"~u00000002-1872-3999-9300-000000000000\",536870913]],[\"^7[\",[120,\"^<\",\"^=\",536870913]],[\"^7[\",[120,\"^;\",\"^2I\",536870913]],[\"^7[\",[120,\"^>\",true,536870913]],[\"^7[\",[120,\"^C\",\"^D\",536870913]],[\"^7[\",[120,\"^55\",true,536870913]],[\"^7[\",[120,\"^5G\",true,536870913]],[\"^7[\",[120,\"^X\",false,536870913]],[\"^7[\",[120,\"^1K\",\"^88\",536870913]],[\"^7[\",[121,\"^48\",1775980635489,536870913]],[\"^7[\",[121,\"^7H\",\"tag properties\",536870913]],[\"^7[\",[121,\"^7;\",\"d3BFZ\",536870913]],[\"^7[\",[121,\"^64\",124,536870913]],[\"^7[\",[121,\"^65\",\"Tag Properties\",536870913]],[\"^7[\",[121,\"^3;\",1775980635489,536870913]],[\"^7[\",[121,\"^2T\",\"~u00000002-2123-7120-5000-000000000000\",536870913]],[\"^7[\",[121,\"^<\",\"^14\",536870913]],[\"^7[\",[121,\"^;\",\"^4J\",536870913]],[\"^7[\",[121,\"^>\",true,536870913]],[\"^7[\",[121,\"^C\",\"^D\",536870913]],[\"^7[\",[121,\"^55\",true,536870913]],[\"^7[\",[121,\"^X\",true,536870913]],[\"^7[\",[121,\"^1K\",\"^81\",536870913]],[\"^7[\",[121,\"^44\",\"^8E\",536870913]],[\"^7[\",[122,\"^48\",1775980635488,536870913]],[\"^7[\",[122,\"^7H\",\"node order\",536870913]],[\"^7[\",[122,\"^7;\",\"d3BFI\",536870913]],[\"^7[\",[122,\"^64\",124,536870913]],[\"^7[\",[122,\"^65\",\"Node order\",536870913]],[\"^7[\",[122,\"^3;\",1775980635488,536870913]],[\"^7[\",[122,\"^2T\",\"~u00000002-1429-2824-3700-000000000000\",536870913]],[\"^7[\",[122,\"^<\",\"^=\",536870913]],[\"^7[\",[122,\"^;\",\"^7;\",536870913]],[\"^7[\",[122,\"^>\",true,536870913]],[\"^7[\",[122,\"^55\",true,536870913]],[\"^7[\",[122,\"^5G\",true,536870913]],[\"^7[\",[122,\"^X\",false,536870913]],[\"^7[\",[122,\"^1K\",\"^82\",536870913]],[\"^7[\",[123,\"^48\",1775980635494,536870913]],[\"^7[\",[123,\"^7H\",\"enable property history\",536870913]],[\"^7[\",[123,\"^7;\",\"d3BH9\",536870913]],[\"^7[\",[123,\"^64\",124,536870913]],[\"^7[\",[123,\"^65\",\"Enable property history\",536870913]],[\"^7[\",[123,\"^3;\",1775980635494,536870913]],[\"^7[\",[123,\"^2T\",\"~u00000002-8058-5960-2000-000000000000\",536870913]],[\"^7[\",[123,\"^<\",\"^=\",536870913]],[\"^7[\",[123,\"^;\",\"^4L\",536870913]],[\"^7[\",[123,\"^>\",true,536870913]],[\"^7[\",[123,\"^55\",true,536870913]],[\"^7[\",[123,\"^2A\",162,536870913]],[\"^7[\",[123,\"^X\",true,536870913]],[\"^7[\",[123,\"^1K\",\"^8:\",536870913]],[\"^7[\",[123,\"^44\",\"^81\",536870913]],[\"^7[\",[124,\"^48\",1775980635496,536870913]],[\"^7[\",[124,\"^7H\",\"property\",536870913]],[\"^7[\",[124,\"^64\",14,536870913]],[\"^7[\",[124,\"^65\",\"Property\",536870913]],[\"^7[\",[124,\"^3;\",1775980635496,536870913]],[\"^7[\",[124,\"^2T\",\"~u00000002-1038-7670-4800-000000000000\",536870913]],[\"^7[\",[124,\"^;\",\"^30\",536870913]],[\"^7[\",[124,\"^55\",true,536870913]],[\"^7[\",[124,\"^3K\",89,536870913]],[\"^7[\",[125,\"^48\",1775980635489,536870913]],[\"^7[\",[125,\"^7H\",\"page tags\",536870913]],[\"^7[\",[125,\"^7;\",\"d3BFh\",536870913]],[\"^7[\",[125,\"^64\",124,536870913]],[\"^7[\",[125,\"^65\",\"Page Tags\",536870913]],[\"^7[\",[125,\"^3;\",1775980635489,536870913]],[\"^7[\",[125,\"^2T\",\"~u00000002-2133-5311-8500-000000000000\",536870913]],[\"^7[\",[125,\"^<\",\"^14\",536870913]],[\"^7[\",[125,\"^;\",\"^4V\",536870913]],[\"^7[\",[125,\"^>\",true,536870913]],[\"^7[\",[125,\"^C\",\"^D\",536870913]],[\"^7[\",[125,\"^55\",true,536870913]],[\"^7[\",[125,\"^2A\",164,536870913]],[\"^7[\",[125,\"^X\",true,536870913]],[\"^7[\",[125,\"^1K\",\"^84\",536870913]],[\"^7[\",[125,\"^44\",\"^84\",536870913]],[\"^7[\",[126,\"^L\",26,536870913]],[\"^7[\",[126,\"^48\",1775980635491,536870913]],[\"^7[\",[126,\"^7;\",\"d3BG8\",536870913]],[\"^7[\",[126,\"^4P\",26,536870913]],[\"^7[\",[126,\"^3V\",26,536870913]],[\"^7[\",[126,\"^65\",\"Backlog\",536870913]],[\"^7[\",[126,\"^3;\",1775980635491,536870913]],[\"^7[\",[126,\"^2T\",\"~u00000002-7233-3491-0000-000000000000\",536870913]],[\"^7[\",[126,\"^;\",\"^4@\",536870913]],[\"^7[\",[126,\"^55\",true,536870913]],[\"^7[\",[126,\"^6:\",26,536870913]],[\"^7[\",[126,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"Backlog\"],536870913]],[\"^7[\",[127,\"^48\",1775980635492,536870913]],[\"^7[\",[127,\"^7H\",\"view type\",536870913]],[\"^7[\",[127,\"^7;\",\"d3BGd\",536870913]],[\"^7[\",[127,\"^64\",124,536870913]],[\"^7[\",[127,\"^65\",\"View Type\",536870913]],[\"^7[\",[127,\"^3;\",1775980635492,536870913]],[\"^7[\",[127,\"^2T\",\"~u00000002-2182-3760-7000-000000000000\",536870913]],[\"^7[\",[127,\"^<\",\"^=\",536870913]],[\"^7[\",[127,\"^;\",\"^2J\",536870913]],[\"^7[\",[127,\"^>\",true,536870913]],[\"^7[\",[127,\"^C\",\"^D\",536870913]],[\"^7[\",[127,\"^55\",true,536870913]],[\"^7[\",[127,\"^5I\",70,536870913]],[\"^7[\",[127,\"^5G\",true,536870913]],[\"^7[\",[127,\"^X\",false,536870913]],[\"^7[\",[127,\"^1K\",\"^89\",536870913]],[\"^7[\",[128,\"^48\",1775980635493,536870913]],[\"^7[\",[128,\"^7H\",\"this view belongs to\",536870913]],[\"^7[\",[128,\"^7;\",\"d3BGr\",536870913]],[\"^7[\",[128,\"^64\",124,536870913]],[\"^7[\",[128,\"^65\",\"This view belongs to\",536870913]],[\"^7[\",[128,\"^3;\",1775980635493,536870913]],[\"^7[\",[128,\"^2T\",\"~u00000002-3627-4319-0000-000000000000\",536870913]],[\"^7[\",[128,\"^<\",\"^=\",536870913]],[\"^7[\",[128,\"^;\",\"^9\",536870913]],[\"^7[\",[128,\"^>\",true,536870913]],[\"^7[\",[128,\"^C\",\"^D\",536870913]],[\"^7[\",[128,\"^55\",true,536870913]],[\"^7[\",[128,\"^5G\",true,536870913]],[\"^7[\",[128,\"^X\",false,536870913]],[\"^7[\",[128,\"^1K\",\"^80\",536870913]],[\"^7[\",[129,\"^48\",1775980635494,536870913]],[\"^7[\",[129,\"^7H\",\"user avatar\",536870913]],[\"^7[\",[129,\"^7;\",\"d3BH8\",536870913]],[\"^7[\",[129,\"^64\",124,536870913]],[\"^7[\",[129,\"^65\",\"User Avatar\",536870913]],[\"^7[\",[129,\"^3;\",1775980635494,536870913]],[\"^7[\",[129,\"^2T\",\"~u00000002-4165-4885-8000-000000000000\",536870913]],[\"^7[\",[129,\"^<\",\"^=\",536870913]],[\"^7[\",[129,\"^;\",\"^O\",536870913]],[\"^7[\",[129,\"^>\",true,536870913]],[\"^7[\",[129,\"^55\",true,536870913]],[\"^7[\",[129,\"^5G\",false,536870913]],[\"^7[\",[129,\"^X\",true,536870913]],[\"^7[\",[129,\"^1K\",\"^82\",536870913]],[\"^7[\",[130,\"^48\",1775980635494,536870913]],[\"^7[\",[130,\"^7H\",\"user name\",536870913]],[\"^7[\",[130,\"^7;\",\"d3BH6\",536870913]],[\"^7[\",[130,\"^64\",124,536870913]],[\"^7[\",[130,\"^65\",\"User Name\",536870913]],[\"^7[\",[130,\"^3;\",1775980635494,536870913]],[\"^7[\",[130,\"^2T\",\"~u00000002-1360-0260-1600-000000000000\",536870913]],[\"^7[\",[130,\"^<\",\"^=\",536870913]],[\"^7[\",[130,\"^;\",\"^E\",536870913]],[\"^7[\",[130,\"^>\",true,536870913]],[\"^7[\",[130,\"^55\",true,536870913]],[\"^7[\",[130,\"^5G\",false,536870913]],[\"^7[\",[130,\"^X\",true,536870913]],[\"^7[\",[130,\"^1K\",\"^82\",536870913]],[\"^7[\",[131,\"^48\",1775980635493,536870913]],[\"^7[\",[131,\"^7H\",\"file size\",536870913]],[\"^7[\",[131,\"^7;\",\"d3BGv\",536870913]],[\"^7[\",[131,\"^64\",124,536870913]],[\"^7[\",[131,\"^65\",\"File Size\",536870913]],[\"^7[\",[131,\"^3;\",1775980635493,536870913]],[\"^7[\",[131,\"^2T\",\"~u00000002-1167-8621-9000-000000000000\",536870913]],[\"^7[\",[131,\"^<\",\"^=\",536870913]],[\"^7[\",[131,\"^;\",\"^1?\",536870913]],[\"^7[\",[131,\"^>\",true,536870913]],[\"^7[\",[131,\"^55\",true,536870913]],[\"^7[\",[131,\"^5G\",true,536870913]],[\"^7[\",[131,\"^X\",false,536870913]],[\"^7[\",[131,\"^1K\",\"^8>\",536870913]],[\"^7[\",[132,\"^48\",1775980635489,536870913]],[\"^7[\",[132,\"^7H\",\"bidirectional property title\",536870913]],[\"^7[\",[132,\"^7;\",\"d3BFa\",536870913]],[\"^7[\",[132,\"^64\",124,536870913]],[\"^7[\",[132,\"^65\",\"Bidirectional property title\",536870913]],[\"^7[\",[132,\"^3;\",1775980635489,536870913]],[\"^7[\",[132,\"^2T\",\"~u00000002-6050-5418-7000-000000000000\",536870913]],[\"^7[\",[132,\"^<\",\"^=\",536870913]],[\"^7[\",[132,\"^;\",\"^1I\",536870913]],[\"^7[\",[132,\"^>\",true,536870913]],[\"^7[\",[132,\"^55\",true,536870913]],[\"^7[\",[132,\"^X\",true,536870913]],[\"^7[\",[132,\"^1K\",\"^82\",536870913]],[\"^7[\",[132,\"^44\",\"^8;\",536870913]],[\"^7[\",[133,\"^48\",1775980635489,536870913]],[\"^7[\",[133,\"^7H\",\"hide from node\",536870913]],[\"^7[\",[133,\"^7;\",\"d3BFf\",536870913]],[\"^7[\",[133,\"^64\",124,536870913]],[\"^7[\",[133,\"^65\",\"Hide from Node\",536870913]],[\"^7[\",[133,\"^3;\",1775980635489,536870913]],[\"^7[\",[133,\"^2T\",\"~u00000002-2610-3727-0000-000000000000\",536870913]],[\"^7[\",[133,\"^<\",\"^=\",536870913]],[\"^7[\",[133,\"^;\",\"^1X\",536870913]],[\"^7[\",[133,\"^>\",true,536870913]],[\"^7[\",[133,\"^55\",true,536870913]],[\"^7[\",[133,\"^X\",true,536870913]],[\"^7[\",[133,\"^1K\",\"^8:\",536870913]],[\"^7[\",[133,\"^44\",\"^8;\",536870913]],[\"^7[\",[134,\"^48\",1775980635492,536870913]],[\"^7[\",[134,\"^7H\",\"repeating temporal property\",536870913]],[\"^7[\",[134,\"^7;\",\"d3BGX\",536870913]],[\"^7[\",[134,\"^64\",124,536870913]],[\"^7[\",[134,\"^65\",\"Repeating Temporal Property\",536870913]],[\"^7[\",[134,\"^3;\",1775980635492,536870913]],[\"^7[\",[134,\"^2T\",\"~u00000002-8346-1078-4000-000000000000\",536870913]],[\"^7[\",[134,\"^<\",\"^=\",536870913]],[\"^7[\",[134,\"^;\",\"^B\",536870913]],[\"^7[\",[134,\"^>\",true,536870913]],[\"^7[\",[134,\"^C\",\"^D\",536870913]],[\"^7[\",[134,\"^55\",true,536870913]],[\"^7[\",[134,\"^5G\",true,536870913]],[\"^7[\",[134,\"^1K\",\"^81\",536870913]],[\"^7[\",[135,\"^L\",13,536870913]],[\"^7[\",[135,\"^48\",1775980635492,536870913]],[\"^7[\",[135,\"^7;\",\"d3BGQ\",536870913]],[\"^7[\",[135,\"^4P\",13,536870913]],[\"^7[\",[135,\"^3V\",13,536870913]],[\"^7[\",[135,\"^65\",\"Minute\",536870913]],[\"^7[\",[135,\"^3;\",1775980635492,536870913]],[\"^7[\",[135,\"^2T\",\"~u00000002-1513-6550-8500-000000000000\",536870913]],[\"^7[\",[135,\"^;\",\"^2E\",536870913]],[\"^7[\",[135,\"^55\",true,536870913]],[\"^7[\",[135,\"^6:\",13,536870913]],[\"^7[\",[136,\"^48\",1775980635489,536870913]],[\"^7[\",[136,\"^7H\",\"query\",536870913]],[\"^7[\",[136,\"^7;\",\"d3BFg\",536870913]],[\"^7[\",[136,\"^64\",124,536870913]],[\"^7[\",[136,\"^65\",\"Query\",536870913]],[\"^7[\",[136,\"^3;\",1775980635489,536870913]],[\"^7[\",[136,\"^2T\",\"~u00000002-9741-4126-0000-000000000000\",536870913]],[\"^7[\",[136,\"^<\",\"^=\",536870913]],[\"^7[\",[136,\"^;\",\"^2X\",536870913]],[\"^7[\",[136,\"^>\",true,536870913]],[\"^7[\",[136,\"^C\",\"^D\",536870913]],[\"^7[\",[136,\"^55\",true,536870913]],[\"^7[\",[136,\"^5G\",true,536870913]],[\"^7[\",[136,\"^X\",true,536870913]],[\"^7[\",[136,\"^1K\",\"^89\",536870913]],[\"^7[\",[136,\"^44\",\"^8@\",536870913]],[\"^7[\",[137,\"^48\",1775980635494,536870913]],[\"^7[\",[137,\"^7H\",\"user email\",536870913]],[\"^7[\",[137,\"^7;\",\"d3BH7\",536870913]],[\"^7[\",[137,\"^64\",124,536870913]],[\"^7[\",[137,\"^65\",\"User Email\",536870913]],[\"^7[\",[137,\"^3;\",1775980635494,536870913]],[\"^7[\",[137,\"^2T\",\"~u00000002-1655-2060-6300-000000000000\",536870913]],[\"^7[\",[137,\"^<\",\"^=\",536870913]],[\"^7[\",[137,\"^;\",\"^38\",536870913]],[\"^7[\",[137,\"^>\",true,536870913]],[\"^7[\",[137,\"^55\",true,536870913]],[\"^7[\",[137,\"^5G\",false,536870913]],[\"^7[\",[137,\"^X\",true,536870913]],[\"^7[\",[137,\"^1K\",\"^82\",536870913]],[\"^7[\",[138,\"^48\",1775980635488,536870913]],[\"^7[\",[138,\"^7H\",\"node collapsed?\",536870913]],[\"^7[\",[138,\"^7;\",\"d3BFJ\",536870913]],[\"^7[\",[138,\"^64\",124,536870913]],[\"^7[\",[138,\"^65\",\"Node collapsed?\",536870913]],[\"^7[\",[138,\"^3;\",1775980635488,536870913]],[\"^7[\",[138,\"^2T\",\"~u00000002-2140-2109-9100-000000000000\",536870913]],[\"^7[\",[138,\"^<\",\"^=\",536870913]],[\"^7[\",[138,\"^;\",\"^3D\",536870913]],[\"^7[\",[138,\"^>\",true,536870913]],[\"^7[\",[138,\"^55\",true,536870913]],[\"^7[\",[138,\"^5G\",true,536870913]],[\"^7[\",[138,\"^X\",false,536870913]],[\"^7[\",[138,\"^1K\",\"^8:\",536870913]],[\"^7[\",[139,\"^L\",13,536870913]],[\"^7[\",[139,\"^48\",1775980635492,536870913]],[\"^7[\",[139,\"^7;\",\"d3BGR\",536870913]],[\"^7[\",[139,\"^4P\",13,536870913]],[\"^7[\",[139,\"^3V\",13,536870913]],[\"^7[\",[139,\"^65\",\"Hour\",536870913]],[\"^7[\",[139,\"^3;\",1775980635492,536870913]],[\"^7[\",[139,\"^2T\",\"~u00000002-1438-8849-5400-000000000000\",536870913]],[\"^7[\",[139,\"^;\",\"^3O\",536870913]],[\"^7[\",[139,\"^55\",true,536870913]],[\"^7[\",[139,\"^6:\",13,536870913]],[\"^7[\",[140,\"^48\",1775980635490,536870913]],[\"^7[\",[140,\"^7H\",\"tldraw page\",536870913]],[\"^7[\",[140,\"^7;\",\"d3BG0\",536870913]],[\"^7[\",[140,\"^64\",124,536870913]],[\"^7[\",[140,\"^65\",\"Tldraw Page\",536870913]],[\"^7[\",[140,\"^3;\",1775980635490,536870913]],[\"^7[\",[140,\"^2T\",\"~u00000002-3546-2145-7000-000000000000\",536870913]],[\"^7[\",[140,\"^<\",\"^=\",536870913]],[\"^7[\",[140,\"^;\",\"^42\",536870913]],[\"^7[\",[140,\"^>\",true,536870913]],[\"^7[\",[140,\"^55\",true,536870913]],[\"^7[\",[140,\"^5G\",true,536870913]],[\"^7[\",[140,\"^1K\",\"^83\",536870913]],[\"^7[\",[141,\"^;\",\"^31\",536870913]],[\"^7[\",[141,\"^28\",\"bf04d4cf5-dirty\",536870913]],[\"^7[\",[142,\"^48\",1775980635496,536870913]],[\"^7[\",[142,\"^7H\",\"query\",536870913]],[\"^7[\",[142,\"^64\",14,536870913]],[\"^7[\",[142,\"^65\",\"Query\",536870913]],[\"^7[\",[142,\"^3;\",1775980635496,536870913]],[\"^7[\",[142,\"^2T\",\"~u00000002-2324-8016-6000-000000000000\",536870913]],[\"^7[\",[142,\"^;\",\"^4I\",536870913]],[\"^7[\",[142,\"^55\",true,536870913]],[\"^7[\",[142,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"search\"],536870913]],[\"^7[\",[142,\"^3K\",89,536870913]],[\"^7[\",[142,\"^4J\",136,536870913]],[\"^7[\",[143,\"^48\",1775980635491,536870913]],[\"^7[\",[143,\"^7H\",\"choice checkbox state\",536870913]],[\"^7[\",[143,\"^7;\",\"d3BG3\",536870913]],[\"^7[\",[143,\"^64\",124,536870913]],[\"^7[\",[143,\"^65\",\"Choice checkbox state\",536870913]],[\"^7[\",[143,\"^3;\",1775980635491,536870913]],[\"^7[\",[143,\"^2T\",\"~u00000002-1272-4228-6300-000000000000\",536870913]],[\"^7[\",[143,\"^<\",\"^=\",536870913]],[\"^7[\",[143,\"^;\",\"^4T\",536870913]],[\"^7[\",[143,\"^>\",true,536870913]],[\"^7[\",[143,\"^55\",true,536870913]],[\"^7[\",[143,\"^5G\",true,536870913]],[\"^7[\",[143,\"^1K\",\"^8:\",536870913]],[\"^7[\",[144,\"^48\",1775980635489,536870913]],[\"^7[\",[144,\"^7H\",\"hide empty value\",536870913]],[\"^7[\",[144,\"^7;\",\"d3BFd\",536870913]],[\"^7[\",[144,\"^64\",124,536870913]],[\"^7[\",[144,\"^65\",\"Hide empty value\",536870913]],[\"^7[\",[144,\"^3;\",1775980635489,536870913]],[\"^7[\",[144,\"^2T\",\"~u00000002-2062-3258-9900-000000000000\",536870913]],[\"^7[\",[144,\"^<\",\"^=\",536870913]],[\"^7[\",[144,\"^;\",\"^3P\",536870913]],[\"^7[\",[144,\"^>\",true,536870913]],[\"^7[\",[144,\"^55\",true,536870913]],[\"^7[\",[144,\"^2A\",160,536870913]],[\"^7[\",[144,\"^X\",true,536870913]],[\"^7[\",[144,\"^1K\",\"^8:\",536870913]],[\"^7[\",[144,\"^44\",\"^81\",536870913]],[\"^7[\",[145,\"^48\",1775980635488,536870913]],[\"^7[\",[145,\"^7H\",\"node updated at\",536870913]],[\"^7[\",[145,\"^7;\",\"d3BFR\",536870913]],[\"^7[\",[145,\"^64\",124,536870913]],[\"^7[\",[145,\"^65\",\"Node updated at\",536870913]],[\"^7[\",[145,\"^3;\",1775980635488,536870913]],[\"^7[\",[145,\"^2T\",\"~u00000002-1516-5505-5100-000000000000\",536870913]],[\"^7[\",[145,\"^<\",\"^=\",536870913]],[\"^7[\",[145,\"^;\",\"^3;\",536870913]],[\"^7[\",[145,\"^>\",true,536870913]],[\"^7[\",[145,\"^55\",true,536870913]],[\"^7[\",[145,\"^5G\",true,536870913]],[\"^7[\",[145,\"^X\",false,536870913]],[\"^7[\",[145,\"^1K\",\"^8<\",536870913]],[\"^7[\",[146,\"^48\",1775980635493,536870913]],[\"^7[\",[146,\"^7H\",\"external url\",536870913]],[\"^7[\",[146,\"^7;\",\"d3BGt\",536870913]],[\"^7[\",[146,\"^64\",124,536870913]],[\"^7[\",[146,\"^65\",\"External URL\",536870913]],[\"^7[\",[146,\"^3;\",1775980635493,536870913]],[\"^7[\",[146,\"^2T\",\"~u00000002-1364-7751-6300-000000000000\",536870913]],[\"^7[\",[146,\"^<\",\"^=\",536870913]],[\"^7[\",[146,\"^;\",\"^20\",536870913]],[\"^7[\",[146,\"^>\",true,536870913]],[\"^7[\",[146,\"^55\",true,536870913]],[\"^7[\",[146,\"^5G\",false,536870913]],[\"^7[\",[146,\"^X\",true,536870913]],[\"^7[\",[146,\"^1K\",\"^82\",536870913]],[\"^7[\",[147,\"^48\",1775980635495,536870913]],[\"^7[\",[147,\"^7H\",\"apply template to tags\",536870913]],[\"^7[\",[147,\"^7;\",\"d3BHO\",536870913]],[\"^7[\",[147,\"^64\",124,536870913]],[\"^7[\",[147,\"^65\",\"Apply template to tags\",536870913]],[\"^7[\",[147,\"^3;\",1775980635495,536870913]],[\"^7[\",[147,\"^2T\",\"~u00000002-4291-2432-2000-000000000000\",536870913]],[\"^7[\",[147,\"^<\",\"^14\",536870913]],[\"^7[\",[147,\"^;\",\"^5T\",536870913]],[\"^7[\",[147,\"^>\",true,536870913]],[\"^7[\",[147,\"^C\",\"^D\",536870913]],[\"^7[\",[147,\"^55\",true,536870913]],[\"^7[\",[147,\"^X\",true,536870913]],[\"^7[\",[147,\"^1K\",\"^8;\",536870913]],[\"^7[\",[148,\"^L\",26,536870913]],[\"^7[\",[148,\"^48\",1775980635491,536870913]],[\"^7[\",[148,\"^7;\",\"d3BGB\",536870913]],[\"^7[\",[148,\"^4P\",26,536870913]],[\"^7[\",[148,\"^3V\",26,536870913]],[\"^7[\",[148,\"^65\",\"In Review\",536870913]],[\"^7[\",[148,\"^3;\",1775980635491,536870913]],[\"^7[\",[148,\"^2T\",\"~u00000002-1870-0014-4300-000000000000\",536870913]],[\"^7[\",[148,\"^;\",\"^62\",536870913]],[\"^7[\",[148,\"^55\",true,536870913]],[\"^7[\",[148,\"^6:\",26,536870913]],[\"^7[\",[148,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"InReview\"],536870913]],[\"^7[\",[149,\"^48\",1775980635492,536870913]],[\"^7[\",[149,\"^7H\",\"published url\",536870913]],[\"^7[\",[149,\"^7;\",\"d3BGb\",536870913]],[\"^7[\",[149,\"^64\",124,536870913]],[\"^7[\",[149,\"^65\",\"Published URL\",536870913]],[\"^7[\",[149,\"^3;\",1775980635492,536870913]],[\"^7[\",[149,\"^2T\",\"~u00000002-2045-1825-0100-000000000000\",536870913]],[\"^7[\",[149,\"^<\",\"^=\",536870913]],[\"^7[\",[149,\"^;\",\"^1[\",536870913]],[\"^7[\",[149,\"^>\",true,536870913]],[\"^7[\",[149,\"^C\",\"^D\",536870913]],[\"^7[\",[149,\"^55\",true,536870913]],[\"^7[\",[149,\"^X\",true,536870913]],[\"^7[\",[149,\"^1K\",\"~:url\",536870913]],[\"^7[\",[149,\"^44\",\"^84\",536870913]],[\"^7[\",[150,\"^L\",127,536870913]],[\"^7[\",[150,\"^48\",1775980635492,536870913]],[\"^7[\",[150,\"^7;\",\"d3BGg\",536870913]],[\"^7[\",[150,\"^4P\",127,536870913]],[\"^7[\",[150,\"^3V\",127,536870913]],[\"^7[\",[150,\"^65\",\"Gallery View\",536870913]],[\"^7[\",[150,\"^3;\",1775980635492,536870913]],[\"^7[\",[150,\"^2T\",\"~u00000002-1506-0511-2000-000000000000\",536870913]],[\"^7[\",[150,\"^;\",\"^6G\",536870913]],[\"^7[\",[150,\"^55\",true,536870913]],[\"^7[\",[150,\"^6:\",127,536870913]],[\"^7[\",[150,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"layout-grid\"],536870913]],[\"^7[\",[151,\"^2T\",\"~u00000004-1595-0218-3700-000000000000\",536870913]],[\"^7[\",[151,\"^;\",\"^5U\",536870913]],[\"^7[\",[152,\"^48\",1775980635496,536870913]],[\"^7[\",[152,\"^7H\",\"task\",536870913]],[\"^7[\",[152,\"^64\",14,536870913]],[\"^7[\",[152,\"^65\",\"Task\",536870913]],[\"^7[\",[152,\"^3;\",1775980635496,536870913]],[\"^7[\",[152,\"^2T\",\"~u00000002-1282-1814-5700-000000000000\",536870913]],[\"^7[\",[152,\"^;\",\"^4X\",536870913]],[\"^7[\",[152,\"^55\",true,536870913]],[\"^7[\",[152,\"^3K\",89,536870913]],[\"^7[\",[152,\"^4J\",26,536870913]],[\"^7[\",[152,\"^4J\",32,536870913]],[\"^7[\",[152,\"^4J\",75,536870913]],[\"^7[\",[152,\"^4J\",155,536870913]],[\"^7[\",[153,\"^L\",32,536870913]],[\"^7[\",[153,\"^48\",1775980635491,536870913]],[\"^7[\",[153,\"^7;\",\"d3BGG\",536870913]],[\"^7[\",[153,\"^4P\",32,536870913]],[\"^7[\",[153,\"^3V\",32,536870913]],[\"^7[\",[153,\"^65\",\"Medium\",536870913]],[\"^7[\",[153,\"^3;\",1775980635491,536870913]],[\"^7[\",[153,\"^2T\",\"~u00000002-1829-3222-7800-000000000000\",536870913]],[\"^7[\",[153,\"^;\",\"^75\",536870913]],[\"^7[\",[153,\"^55\",true,536870913]],[\"^7[\",[153,\"^6:\",32,536870913]],[\"^7[\",[153,\"^4[\",[\"^ \",\"^85\",\"^86\",\"^87\",\"priorityLvlMedium\"],536870913]],[\"^7[\",[154,\"^48\",1775980635486,536870913]],[\"^7[\",[154,\"^7H\",\"property type\",536870913]],[\"^7[\",[154,\"^7;\",\"d3BF6\",536870913]],[\"^7[\",[154,\"^64\",124,536870913]],[\"^7[\",[154,\"^65\",\"Property type\",536870913]],[\"^7[\",[154,\"^3;\",1775980635486,536870913]],[\"^7[\",[154,\"^2T\",\"~u00000002-8384-2404-0000-000000000000\",536870913]],[\"^7[\",[154,\"^<\",\"^=\",536870913]],[\"^7[\",[154,\"^;\",\"^1K\",536870913]],[\"^7[\",[154,\"^>\",true,536870913]],[\"^7[\",[154,\"^55\",true,536870913]],[\"^7[\",[154,\"^5G\",true,536870913]],[\"^7[\",[154,\"^1K\",\"^8?\",536870913]],[\"^7[\",[155,\"^48\",1775980635491,536870913]],[\"^7[\",[155,\"^7H\",\"scheduled\",536870913]],[\"^7[\",[155,\"^7;\",\"d3BGL\",536870913]],[\"^7[\",[155,\"^64\",124,536870913]],[\"^7[\",[155,\"^65\",\"Scheduled\",536870913]],[\"^7[\",[155,\"^3;\",1775980635491,536870913]],[\"^7[\",[155,\"^2T\",\"~u00000002-1644-5209-4300-000000000000\",536870913]],[\"^7[\",[155,\"^<\",\"^=\",536870913]],[\"^7[\",[155,\"^;\",\"^4W\",536870913]],[\"^7[\",[155,\"^>\",true,536870913]],[\"^7[\",[155,\"^55\",true,536870913]],[\"^7[\",[155,\"^2A\",177,536870913]],[\"^7[\",[155,\"^3P\",true,536870913]],[\"^7[\",[155,\"^X\",true,536870913]],[\"^7[\",[155,\"^1K\",\"^8<\",536870913]],[\"^7[\",[155,\"^@\",\"^8D\",536870913]],[\"^7[\",[156,\"^48\",1775980635489,536870913]],[\"^7[\",[156,\"^7H\",\"default value\",536870913]],[\"^7[\",[156,\"^7;\",\"d3BFV\",536870913]],[\"^7[\",[156,\"^64\",124,536870913]],[\"^7[\",[156,\"^65\",\"Default value\",536870913]],[\"^7[\",[156,\"^3;\",1775980635489,536870913]],[\"^7[\",[156,\"^2T\",\"~u00000002-8920-7966-2000-000000000000\",536870913]],[\"^7[\",[156,\"^<\",\"^=\",536870913]],[\"^7[\",[156,\"^;\",\"^5I\",536870913]],[\"^7[\",[156,\"^>\",true,536870913]],[\"^7[\",[156,\"^C\",\"^D\",536870913]],[\"^7[\",[156,\"^55\",true,536870913]],[\"^7[\",[156,\"^5G\",true,536870913]],[\"^7[\",[156,\"^X\",false,536870913]],[\"^7[\",[156,\"^1K\",\"^88\",536870913]],[\"^7[\",[156,\"^44\",\"^81\",536870913]],[\"^7[\",[157,\"^48\",1775980635495,536870913]],[\"^7[\",[157,\"^7H\",\"deleted by\",536870913]],[\"^7[\",[157,\"^7;\",\"d3BHH\",536870913]],[\"^7[\",[157,\"^64\",124,536870913]],[\"^7[\",[157,\"^65\",\"Deleted by\",536870913]],[\"^7[\",[157,\"^3;\",1775980635495,536870913]],[\"^7[\",[157,\"^2T\",\"~u00000002-1998-0431-1200-000000000000\",536870913]],[\"^7[\",[157,\"^<\",\"^=\",536870913]],[\"^7[\",[157,\"^;\",\"^6T\",536870913]],[\"^7[\",[157,\"^>\",true,536870913]],[\"^7[\",[157,\"^C\",\"^D\",536870913]],[\"^7[\",[157,\"^55\",true,536870913]],[\"^7[\",[157,\"^5G\",true,536870913]],[\"^7[\",[157,\"^X\",false,536870913]],[\"^7[\",[157,\"^1K\",\"^88\",536870913]],[\"^7[\",[158,\"^48\",1775980635489,536870913]],[\"^7[\",[158,\"^7H\",\"extends\",536870913]],[\"^7[\",[158,\"^7;\",\"d3BFX\",536870913]],[\"^7[\",[158,\"^64\",124,536870913]],[\"^7[\",[158,\"^65\",\"Extends\",536870913]],[\"^7[\",[158,\"^3;\",1775980635489,536870913]],[\"^7[\",[158,\"^2T\",\"~u00000002-7475-9380-3000-000000000000\",536870913]],[\"^7[\",[158,\"^<\",\"^14\",536870913]],[\"^7[\",[158,\"^;\",\"^3K\",536870913]],[\"^7[\",[158,\"^>\",true,536870913]],[\"^7[\",[158,\"^C\",\"^D\",536870913]],[\"^7[\",[158,\"^55\",true,536870913]],[\"^7[\",[158,\"^2A\",163,536870913]],[\"^7[\",[158,\"^X\",true,536870913]],[\"^7[\",[158,\"^1K\",\"^8;\",536870913]],[\"^7[\",[158,\"^44\",\"^8;\",536870913]],[\"^7[\",[159,\"^48\",1775980635493,536870913]],[\"^7[\",[159,\"^7H\",\"view sorting\",536870913]],[\"^7[\",[159,\"^7;\",\"d3BGl\",536870913]],[\"^7[\",[159,\"^64\",124,536870913]],[\"^7[\",[159,\"^65\",\"View sorting\",536870913]],[\"^7[\",[159,\"^3;\",1775980635493,536870913]],[\"^7[\",[159,\"^2T\",\"~u00000002-2081-0259-4000-000000000000\",536870913]],[\"^7[\",[159,\"^<\",\"^=\",536870913]],[\"^7[\",[159,\"^;\",\"^12\",536870913]],[\"^7[\",[159,\"^>\",true,536870913]],[\"^7[\",[159,\"^55\",true,536870913]],[\"^7[\",[159,\"^5G\",true,536870913]],[\"^7[\",[159,\"^X\",false,536870913]],[\"^7[\",[159,\"^1K\",\"^8G\",536870913]],[\"^7[\",[160,\"^48\",1775980635489,536870913]],[\"^7[\",[160,\"^7;\",\"d3BFe\",536870913]],[\"^7[\",[160,\"^4P\",144,536870913]],[\"^7[\",[160,\"^3V\",144,536870913]],[\"^7[\",[160,\"^65\",\"Hides a property's value on any node when empty e.g. when a property appears on a node through a tag.\",536870913]],[\"^7[\",[160,\"^3;\",1775980635489,536870913]],[\"^7[\",[160,\"^2T\",\"~u00000004-1118-8585-8000-000000000000\",536870913]],[\"^7[\",[160,\"^55\",true,536870913]],[\"^7[\",[160,\"^6:\",71,536870913]],[\"^7[\",[161,\"^2T\",\"~u00000004-2116-2824-5200-000000000000\",536870913]],[\"^7[\",[161,\"^1M\",\"\",536870913]],[\"^7[\",[161,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[161,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[161,\"^7I\",\"logseq/publish.js\",536870913]],[\"^7[\",[162,\"^48\",1775980635494,536870913]],[\"^7[\",[162,\"^7;\",\"d3BHA\",536870913]],[\"^7[\",[162,\"^4P\",123,536870913]],[\"^7[\",[162,\"^3V\",123,536870913]],[\"^7[\",[162,\"^65\",\"Records history anytime a property's value changes on a node.\",536870913]],[\"^7[\",[162,\"^3;\",1775980635494,536870913]],[\"^7[\",[162,\"^2T\",\"~u00000004-1438-1481-0000-000000000000\",536870913]],[\"^7[\",[162,\"^55\",true,536870913]],[\"^7[\",[162,\"^6:\",71,536870913]],[\"^7[\",[163,\"^48\",1775980635489,536870913]],[\"^7[\",[163,\"^7;\",\"d3BFY\",536870913]],[\"^7[\",[163,\"^4P\",158,536870913]],[\"^7[\",[163,\"^3V\",158,536870913]],[\"^7[\",[163,\"^65\",\"This enables tags to inherit properties from other tags\",536870913]],[\"^7[\",[163,\"^3;\",1775980635489,536870913]],[\"^7[\",[163,\"^2T\",\"~u00000004-4485-6574-6000-000000000000\",536870913]],[\"^7[\",[163,\"^55\",true,536870913]],[\"^7[\",[163,\"^6:\",71,536870913]],[\"^7[\",[164,\"^48\",1775980635489,536870913]],[\"^7[\",[164,\"^7;\",\"d3BFi\",536870913]],[\"^7[\",[164,\"^4P\",125,536870913]],[\"^7[\",[164,\"^3V\",125,536870913]],[\"^7[\",[164,\"^65\",\"Provides a way for a page to associate to another page i.e. backward compatible tagging.\",536870913]],[\"^7[\",[164,\"^3;\",1775980635489,536870913]],[\"^7[\",[164,\"^2T\",\"~u00000004-1223-8671-8000-000000000000\",536870913]],[\"^7[\",[164,\"^55\",true,536870913]],[\"^7[\",[164,\"^6:\",71,536870913]],[\"^7[\",[165,\"^48\",1775980635496,536870913]],[\"^7[\",[165,\"^7H\",\"library\",536870913]],[\"^7[\",[165,\"^64\",104,536870913]],[\"^7[\",[165,\"^65\",\"Library\",536870913]],[\"^7[\",[165,\"^3;\",1775980635496,536870913]],[\"^7[\",[165,\"^2T\",\"~u00000004-1294-7765-6000-000000000000\",536870913]],[\"^7[\",[165,\"^55\",true,536870913]],[\"^7[\",[166,\"^48\",1775980635491,536870913]],[\"^7[\",[166,\"^7;\",\"d3BGK\",536870913]],[\"^7[\",[166,\"^4P\",75,536870913]],[\"^7[\",[166,\"^3V\",75,536870913]],[\"^7[\",[166,\"^65\",\"Use it to finish something at a specific date(time).\",536870913]],[\"^7[\",[166,\"^3;\",1775980635491,536870913]],[\"^7[\",[166,\"^2T\",\"~u00000004-1356-3664-2200-000000000000\",536870913]],[\"^7[\",[166,\"^55\",true,536870913]],[\"^7[\",[166,\"^6:\",71,536870913]],[\"^7[\",[167,\"^48\",1775980635494,536870913]],[\"^7[\",[167,\"^7;\",\"d3BH1\",536870913]],[\"^7[\",[167,\"^4P\",67,536870913]],[\"^7[\",[167,\"^3V\",67,536870913]],[\"^7[\",[167,\"^65\",\"Metadata of asset in remote storage\",536870913]],[\"^7[\",[167,\"^3;\",1775980635494,536870913]],[\"^7[\",[167,\"^2T\",\"~u00000004-5450-2102-9000-000000000000\",536870913]],[\"^7[\",[167,\"^55\",true,536870913]],[\"^7[\",[167,\"^6:\",71,536870913]],[\"^7[\",[168,\"^48\",1775980635496,536870913]],[\"^7[\",[168,\"^7H\",\"quick add\",536870913]],[\"^7[\",[168,\"^64\",104,536870913]],[\"^7[\",[168,\"^65\",\"Quick add\",536870913]],[\"^7[\",[168,\"^3;\",1775980635496,536870913]],[\"^7[\",[168,\"^2T\",\"~u00000004-7336-8251-3000-000000000000\",536870913]],[\"^7[\",[168,\"^55\",true,536870913]],[\"^7[\",[168,\"^5G\",true,536870913]],[\"^7[\",[169,\"^48\",1775980635489,536870913]],[\"^7[\",[169,\"^7;\",\"d3BFc\",536870913]],[\"^7[\",[169,\"^4P\",20,536870913]],[\"^7[\",[169,\"^3V\",20,536870913]],[\"^7[\",[169,\"^65\",\"When enabled, this tag will show reverse nodes that link to the current node via properties.\",536870913]],[\"^7[\",[169,\"^3;\",1775980635489,536870913]],[\"^7[\",[169,\"^2T\",\"~u00000004-1830-0481-2500-000000000000\",536870913]],[\"^7[\",[169,\"^55\",true,536870913]],[\"^7[\",[169,\"^6:\",71,536870913]],[\"^7[\",[170,\"^48\",1775980635496,536870913]],[\"^7[\",[170,\"^7H\",\"$$$views\",536870913]],[\"^7[\",[170,\"^64\",104,536870913]],[\"^7[\",[170,\"^65\",\"$$$views\",536870913]],[\"^7[\",[170,\"^3;\",1775980635496,536870913]],[\"^7[\",[170,\"^2T\",\"~u00000004-1906-3437-5800-000000000000\",536870913]],[\"^7[\",[170,\"^55\",true,536870913]],[\"^7[\",[170,\"^5G\",true,536870913]],[\"^7[\",[171,\"^48\",1775980635496,536870913]],[\"^7[\",[171,\"^7H\",\"contents\",536870913]],[\"^7[\",[171,\"^64\",104,536870913]],[\"^7[\",[171,\"^65\",\"Contents\",536870913]],[\"^7[\",[171,\"^3;\",1775980635496,536870913]],[\"^7[\",[171,\"^2T\",\"~u00000004-1690-2597-3200-000000000000\",536870913]],[\"^7[\",[171,\"^55\",true,536870913]],[\"^7[\",[172,\"^2T\",\"~u00000004-1713-4660-3800-000000000000\",536870913]],[\"^7[\",[172,\"^1M\",\"\",536870913]],[\"^7[\",[172,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[172,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[172,\"^7I\",\"logseq/custom.css\",536870913]],[\"^7[\",[173,\"^48\",1775980635496,536870913]],[\"^7[\",[173,\"^7H\",\"$$$favorites\",536870913]],[\"^7[\",[173,\"^64\",104,536870913]],[\"^7[\",[173,\"^65\",\"$$$favorites\",536870913]],[\"^7[\",[173,\"^3;\",1775980635496,536870913]],[\"^7[\",[173,\"^2T\",\"~u00000004-1018-5888-4100-000000000000\",536870913]],[\"^7[\",[173,\"^55\",true,536870913]],[\"^7[\",[173,\"^5G\",true,536870913]],[\"^7[\",[174,\"^2T\",\"~u00000004-1335-6485-2300-000000000000\",536870913]],[\"^7[\",[174,\"^1M\",\"\",536870913]],[\"^7[\",[174,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[174,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[174,\"^7I\",\"logseq/custom.js\",536870913]],[\"^7[\",[175,\"^48\",1775980635491,536870913]],[\"^7[\",[175,\"^7;\",\"d3BGO\",536870913]],[\"^7[\",[175,\"^4P\",95,536870913]],[\"^7[\",[175,\"^3V\",95,536870913]],[\"^7[\",[175,\"^3;\",1775980635491,536870913]],[\"^7[\",[175,\"^2T\",\"~u00000004-1654-1034-2600-000000000000\",536870913]],[\"^7[\",[175,\"^55\",true,536870913]],[\"^7[\",[175,\"^6:\",95,536870913]],[\"^7[\",[175,\"^T\",1,536870913]],[\"^7[\",[176,\"^2T\",\"~u00000004-3919-3813-3000-000000000000\",536870913]],[\"^7[\",[176,\"^1M\",\"{:meta/version 1\\n\\n ;; Hide empty block properties\\n ;; Default value: false\\n ;; :ui/hide-empty-properties? false\\n\\n ;; Enable tooltip preview on hover.\\n ;; Default value: true\\n :ui/enable-tooltip? true\\n\\n ;; Display brackets [[]] around page references.\\n ;; Default value: true\\n ;; :ui/show-brackets? true\\n\\n ;; Display all lines of a block when referencing ((block)).\\n ;; Default value: false\\n :ui/show-full-blocks? false\\n\\n ;; Automatically expand block references when zooming in.\\n ;; Default value: true\\n :ui/auto-expand-block-refs? true\\n\\n ;; Disable accent marks when searching.\\n ;; After changing this setting, rebuild the search index by pressing (^C ^S).\\n ;; Default value: true\\n :feature/enable-search-remove-accents? true\\n\\n ;; Enable journals.\\n ;; Default value: true\\n ;; :feature/enable-journals? true\\n\\n ;; Enable flashcards.\\n ;; Default value: true\\n ;; :feature/enable-flashcards? true\\n\\n ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query.\\n ;; Default value: false\\n ;; :feature/disable-scheduled-and-deadline-query? false\\n\\n ;; Specify the number of days displayed in the future for\\n ;; the 'scheduled tasks and deadlines' query.\\n ;; Example usage:\\n ;; Display all scheduled and deadline blocks for the next 14 days:\\n ;; :scheduled/future-days 14\\n ;; Default value: 7\\n ;; :scheduled/future-days 7\\n\\n ;; Specify the first day of the week.\\n ;; Available options:\\n ;; - integer from 0 to 6 (Monday to Sunday)\\n ;; Default value: 6 (Sunday)\\n :start-of-week 6\\n\\n ;; Specify a custom CSS import.\\n ;; This option takes precedence over the local `logseq/custom.css` file.\\n ;; Example usage:\\n ;; :custom-css-url \\\"@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');\\\"\\n\\n ;; Specify a custom JS import.\\n ;; This option takes precedence over the local `logseq/custom.js` file.\\n ;; Example usage:\\n ;; :custom-js-url \\\"https://cdn.logseq.com/custom.js\\\"\\n\\n ;; Set bullet indentation when exporting\\n ;; Available options:\\n ;; - `:eight-spaces` as eight spaces\\n ;; - `:four-spaces` as four spaces\\n ;; - `:two-spaces` as two spaces\\n ;; - `:tab` as a tab character (default)\\n ;; :export/bullet-indentation :tab\\n\\n ;; Publish all pages within the Graph\\n ;; Regardless of whether individual pages have been marked as public.\\n ;; Default value: false\\n ;; :publishing/all-pages-public? false\\n\\n ;; Define the default home page and sidebar status.\\n ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden.\\n ;; The `:page` value represents the name of the page displayed at startup.\\n ;; Available options for `:sidebar` are:\\n ;; - \\\"Contents\\\" to display the Contents page in the right sidebar.\\n ;; - A specific page name to display in the right sidebar.\\n ;; - An array of multiple pages, e.g., [\\\"Contents\\\" \\\"Page A\\\" \\\"Page B\\\"].\\n ;; If `:sidebar` remains unset, the right sidebar will stay hidden.\\n ;; Examples:\\n ;; 1. Set \\\"Changelog\\\" as the home page and display \\\"Contents\\\" in the right sidebar:\\n ;; :default-home {:page \\\"Changelog\\\", :sidebar \\\"Contents\\\"}\\n ;; 2. Set \\\"Jun 3rd, 2021\\\" as the home page without the right sidebar:\\n ;; :default-home {:page \\\"Jun 3rd, 2021\\\"}\\n ;; 3. Set \\\"home\\\" as the home page and display multiple pages in the right sidebar:\\n ;; :default-home {:page \\\"home\\\", :sidebar [\\\"Page A\\\" \\\"Page B\\\"]}\\n\\n ;; Configure custom shortcuts.\\n ;; Syntax:\\n ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`.\\n ;; 2. A space between keys represents key chords, e.g., `t s` means\\n ;; pressing `t` followed by `s`.\\n ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac.\\n ;; 4. Use false to disable a specific shortcut.\\n ;; 5. You can define multiple bindings for a single action, e.g., [\\\"ctrl+j\\\" \\\"down\\\"].\\n ;; The full list of configurable shortcuts is available at:\\n ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs\\n ;; Example:\\n ;; :shortcuts\\n ;; {:editor/new-block \\\"enter\\\"\\n ;; :editor/new-line \\\"shift+enter\\\"\\n ;; :editor/insert-link \\\"mod+shift+k\\\"\\n ;; :editor/highlight false\\n ;; :ui/toggle-settings \\\"t s\\\"\\n ;; :editor/up [\\\"ctrl+k\\\" \\\"up\\\"]\\n ;; :editor/down [\\\"ctrl+j\\\" \\\"down\\\"]\\n ;; :editor/left [\\\"ctrl+h\\\" \\\"left\\\"]\\n ;; :editor/right [\\\"ctrl+l\\\" \\\"right\\\"]}\\n :shortcuts {}\\n\\n ;; Configure the behavior of pressing Enter in document mode.\\n ;; if set to true, pressing Enter will create a new block.\\n ;; Default value: false\\n :shortcut/doc-mode-enter-for-new-block? false\\n\\n ;; Block content larger than `block/title-max-length` will not be searchable\\n ;; or editable for performance.\\n ;; Default value: 10000\\n :block/title-max-length 10000\\n\\n ;; Display command documentation on hover.\\n ;; Default value: true\\n :ui/show-command-doc? true\\n\\n ;; Display empty bullet points.\\n ;; Default value: false\\n :ui/show-empty-bullets? false\\n\\n ;; Pre-defined :view function to use with advanced queries.\\n :query/views\\n {:pprint\\n (fn [r] [:pre.code (pprint r)])}\\n\\n ;; Advanced queries `:result-transform` function.\\n ;; Transform the query result before displaying it.\\n ;; Example usage for DB graphs:\\n;; :query/result-transforms\\n;; {:sort-by-priority\\n;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority \\\"Z\\\")) result))}\\n\\n;; Queries will be displayed at the bottom of today's journal page.\\n;; Example usage:\\n;; :default-queries\\n;; {:journals []}\\n\\n ;; Add custom commands to the command palette\\n ;; Example usage:\\n ;; :commands\\n ;; [\\n ;; [\\\"js\\\" \\\"Javascript\\\"]\\n ;; [\\\"md\\\" \\\"Markdown\\\"]\\n ;; ]\\n :commands []\\n\\n ;; Enable collapsing blocks with titles but no children.\\n ;; By default, only blocks with children can be collapsed.\\n ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing\\n ;; blocks with titles (multiple lines) and content. For example:\\n ;; - block title\\n ;; block content\\n ;; Default value: false\\n :outliner/block-title-collapse-enabled? false\\n\\n ;; Macros replace texts and will make you more productive.\\n ;; Example usage:\\n ;; Change the :macros value below to:\\n ;; {\\\"poem\\\" \\\"Rose is $1, violet's $2. Life's ordered: Org assists you.\\\"}\\n ;; input \\\"{{poem red,blue}}\\\"\\n ;; becomes\\n ;; Rose is red, violet's blue. Life's ordered: Org assists you.\\n :macros {}\\n\\n ;; Configure the default expansion level for linked references.\\n ;; For example, consider the following block hierarchy:\\n ;; - a [[page]] (level 1)\\n ;; - b (level 2)\\n ;; - c (level 3)\\n ;; - d (level 4)\\n ;;\\n ;; With the default value of level 2, block b will be collapsed.\\n ;; If the level's value is set to 3, block c will be collapsed.\\n ;; Default value: 2\\n :ref/default-open-blocks-level 2\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/settings\\n ;; {:orphan-pages? true ; Default value: true\\n ;; :builtin-pages? false ; Default value: false\\n ;; :excluded-pages? false ; Default value: false\\n ;; :journal? false} ; Default value: false\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/forcesettings\\n ;; {:link-dist 180 ; Default value: 180\\n ;; :charge-strength -600 ; Default value: -600\\n ;; :charge-range 600} ; Default value: 600\\n\\n ;; Mobile photo upload configuration.\\n ;; :mobile/photo\\n ;; {:allow-editing? true\\n ;; :quality 80}\\n\\n ;; Mobile features options\\n ;; Gestures\\n ;; Example usage:\\n ;; :mobile\\n ;; {:gestures/disabled-in-block-with-tags [\\\"kanban\\\"]}\\n\\n ;; Extra CodeMirror options\\n ;; See https://codemirror.net/5/doc/manual.html#config for possible options\\n ;; Example usage:\\n ;; :editor/extra-codemirror-options\\n ;; {:lineWrapping false ; Default value: false\\n ;; :lineNumbers true ; Default value: true\\n ;; :readOnly false} ; Default value: false\\n\\n ;; Enable logical outdenting\\n ;; Default value: false\\n ;; :editor/logical-outdenting? false\\n\\n ;; Prefer pasting the file when text and a file are in the clipboard.\\n ;; Default value: false\\n ;; :editor/preferred-pasting-file? false\\n\\n ;; Quick capture templates for receiving content from other apps.\\n ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded\\n ;; by receiving content from other apps. Note: the {} cannot be omitted.\\n ;; - {time}: capture time\\n ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference\\n ;; - {text}: text that users selected before sharing.\\n ;; - {url}: URL or assets path for media files stored in Logseq.\\n ;; You can also reorder them or use only one or two of them in the template.\\n ;; You can also insert or format any text in the template, as shown in the following examples.\\n ;; :quick-capture-templates\\n ;; {:text \\\"[[quick capture]] **{time}**: {text} from {url}\\\"\\n ;; :media \\\"[[quick capture]] **{time}**: {url}\\\"}\\n\\n ;; Quick capture options.\\n ;; - insert-today? Insert the capture at the end of today's journal page (boolean).\\n ;; - redirect-page? Redirect to the quick capture page after capturing (boolean).\\n ;; - default-page The default page to capture to if insert-today? is false (string).\\n ;; :quick-capture-options\\n ;; {:insert-today? false ;; Default value: true\\n ;; :redirect-page? false ;; Default value: false\\n ;; :default-page \\\"quick capture\\\"} ;; Default page: \\\"quick capture\\\"\\n\\n }\\n\",536870913]],[\"^7[\",[176,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[176,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[176,\"^7I\",\"logseq/config.edn\",536870913]],[\"^7[\",[177,\"^48\",1775980635491,536870913]],[\"^7[\",[177,\"^7;\",\"d3BGM\",536870913]],[\"^7[\",[177,\"^4P\",155,536870913]],[\"^7[\",[177,\"^3V\",155,536870913]],[\"^7[\",[177,\"^65\",\"Use it to plan something to start at a specific date(time).\",536870913]],[\"^7[\",[177,\"^3;\",1775980635491,536870913]],[\"^7[\",[177,\"^2T\",\"~u00000004-9817-6380-0000-000000000000\",536870913]],[\"^7[\",[177,\"^55\",true,536870913]],[\"^7[\",[177,\"^6:\",71,536870913]],[\"^7[\",[178,\"^2T\",\"~u00000004-4049-4381-0000-000000000000\",536870913]],[\"^7[\",[178,\"^1M\",\"\",536870913]],[\"^7[\",[178,\"^A\",\"~m1775980635486\",536870913]],[\"^7[\",[178,\"^4C\",\"~m1775980635486\",536870913]],[\"^7[\",[178,\"^7I\",\"logseq/publish.css\",536870913]],[\"^7[\",[179,\"^48\",1775980635496,536870913]],[\"^7[\",[179,\"^7H\",\"recycle\",536870913]],[\"^7[\",[179,\"^64\",104,536870913]],[\"^7[\",[179,\"^65\",\"Recycle\",536870913]],[\"^7[\",[179,\"^3;\",1775980635496,536870913]],[\"^7[\",[179,\"^2T\",\"~u00000004-7238-1304-3000-000000000000\",536870913]],[\"^7[\",[179,\"^55\",true,536870913]],[\"^7[\",[179,\"^5G\",true,536870913]],[\"^7[\",[180,\"^48\",1775980636002,536870913]],[\"^7[\",[180,\"^1C\",20260412,536870913]],[\"^7[\",[180,\"^7H\",\"apr 12th, 2026\",536870913]],[\"^7[\",[180,\"^1S\",58,536870913]],[\"^7[\",[180,\"^1S\",85,536870913]],[\"^7[\",[180,\"^64\",85,536870913]],[\"^7[\",[180,\"^65\",\"Apr 12th, 2026\",536870913]],[\"^7[\",[180,\"^S\",536870960,536870962]],[\"^7[\",[180,\"^3;\",1775986941534,536870913]],[\"^7[\",[180,\"^2T\",\"~u00000001-2026-0412-0000-000000000000\",536870913]],[\"^7[\",[180,\"^4<\",181,536870913]],[\"^7[\",[181,\"^48\",1775980636008,536870913]],[\"^7[\",[181,\"^7H\",\"tiensonqin\",536870913]],[\"^7[\",[181,\"^1S\",58,536870913]],[\"^7[\",[181,\"^1S\",104,536870913]],[\"^7[\",[181,\"^1S\",130,536870913]],[\"^7[\",[181,\"^1S\",137,536870913]],[\"^7[\",[181,\"^64\",104,536870913]],[\"^7[\",[181,\"^65\",\"tiensonqin\",536870913]],[\"^7[\",[181,\"^3;\",1775980636008,536870913]],[\"^7[\",[181,\"^2T\",\"~ue7cd12e2-f9f7-46ed-a17e-85d95a780003\",536870913]],[\"^7[\",[181,\"^38\",\"tienson@logseq.com\",536870913]],[\"^7[\",[181,\"^E\",\"tiensonqin\",536870913]],[\"^7[\",[182,\"^;\",\"^60\",536870913]],[\"^7[\",[182,\"^28\",\"~u952b4187-ac01-41d3-b5fb-24250ac70d56\",536870913]],[\"^7[\",[183,\"^;\",\"^3:\",536870913]],[\"^7[\",[183,\"^28\",true,536870913]],[\"^7[\",[565,\"^48\",1775980817925,536870913]],[\"^7[\",[565,\"^7H\",\"p1\",536870913]],[\"^7[\",[565,\"^7;\",\"d3BHX\",536870913]],[\"^7[\",[565,\"^1S\",58,536870913]],[\"^7[\",[565,\"^1S\",124,536870913]],[\"^7[\",[565,\"^64\",124,536870913]],[\"^7[\",[565,\"^65\",\"p1\",536870913]],[\"^7[\",[565,\"^3;\",1775980817925,536870913]],[\"^7[\",[565,\"^2T\",\"~u00000002-1157-0363-3400-000000000000\",536870913]],[\"^7[\",[565,\"^<\",\"^=\",536870913]],[\"^7[\",[565,\"^;\",\"^5J\",536870913]],[\"^7[\",[565,\"^>\",true,536870913]],[\"^7[\",[565,\"^C\",\"^D\",536870913]],[\"^7[\",[565,\"^4<\",181,536870913]],[\"^7[\",[565,\"^1K\",\"^89\",536870913]],[\"^7[\",[568,\"^48\",1775980818025,536870913]],[\"^7[\",[568,\"^7H\",\"p2\",536870913]],[\"^7[\",[568,\"^7;\",\"d3BHa\",536870913]],[\"^7[\",[568,\"^1S\",58,536870913]],[\"^7[\",[568,\"^1S\",124,536870913]],[\"^7[\",[568,\"^64\",124,536870913]],[\"^7[\",[568,\"^65\",\"p2\",536870913]],[\"^7[\",[568,\"^3;\",1775980818025,536870913]],[\"^7[\",[568,\"^2T\",\"~u00000002-6891-0936-5000-000000000000\",536870913]],[\"^7[\",[568,\"^<\",\"^=\",536870913]],[\"^7[\",[568,\"^;\",\"^3T\",536870913]],[\"^7[\",[568,\"^>\",true,536870913]],[\"^7[\",[568,\"^C\",\"^D\",536870913]],[\"^7[\",[568,\"^4<\",181,536870913]],[\"^7[\",[568,\"^1K\",\"^8F\",536870913]],[\"^7[\",[569,\"^48\",1775980818039,536870913]],[\"^7[\",[569,\"^7H\",\"p3\",536870913]],[\"^7[\",[569,\"^7;\",\"d3BHb\",536870913]],[\"^7[\",[569,\"^1S\",58,536870913]],[\"^7[\",[569,\"^1S\",104,536870913]],[\"^7[\",[569,\"^1S\",124,536870913]],[\"^7[\",[569,\"^64\",124,536870913]],[\"^7[\",[569,\"^65\",\"p3\",536870913]],[\"^7[\",[569,\"^3;\",1775980818056,536870913]],[\"^7[\",[569,\"^2T\",\"~u00000002-2093-0438-6800-000000000000\",536870913]],[\"^7[\",[569,\"^<\",\"^14\",536870913]],[\"^7[\",[569,\"^;\",\"^1N\",536870913]],[\"^7[\",[569,\"^>\",true,536870913]],[\"^7[\",[569,\"^C\",\"^D\",536870913]],[\"^7[\",[569,\"^6B\",104,536870913]],[\"^7[\",[569,\"^4<\",181,536870913]],[\"^7[\",[569,\"^1K\",\"^80\",536870913]],[\"^7[\",[573,\"^48\",1775980818095,536870913]],[\"^7[\",[573,\"^7H\",\"page 1\",536870913]],[\"^7[\",[573,\"^1S\",58,536870913]],[\"^7[\",[573,\"^1S\",104,536870913]],[\"^7[\",[573,\"^64\",104,536870913]],[\"^7[\",[573,\"^65\",\"page 1\",536870913]],[\"^7[\",[573,\"^3;\",1775980818095,536870913]],[\"^7[\",[573,\"^2T\",\"~u69db5102-b394-49cc-af0d-6e3ee0752cdf\",536870913]],[\"^7[\",[573,\"^4<\",181,536870913]],[\"^7[\",[574,\"^48\",1775980818106,536870913]],[\"^7[\",[574,\"^7H\",\"page 2\",536870913]],[\"^7[\",[574,\"^1S\",58,536870913]],[\"^7[\",[574,\"^1S\",104,536870913]],[\"^7[\",[574,\"^64\",104,536870913]],[\"^7[\",[574,\"^65\",\"page 2\",536870913]],[\"^7[\",[574,\"^3;\",1775980818106,536870913]],[\"^7[\",[574,\"^2T\",\"~u69db5103-ac5c-4d04-b098-88b435041434\",536870913]],[\"^7[\",[574,\"^4<\",181,536870913]],[\"^7[\",[577,\"^48\",1775980800102,536870913]],[\"^7[\",[577,\"^7;\",\"d3BHW\",536870913]],[\"^7[\",[577,\"^4P\",568,536870913]],[\"^7[\",[577,\"^3V\",568,536870913]],[\"^7[\",[577,\"^1S\",568,536870913]],[\"^7[\",[577,\"^3;\",1775980800102,536870913]],[\"^7[\",[577,\"^2T\",\"~u69db5100-05c8-4909-b79e-c56130f4d1e9\",536870913]],[\"^7[\",[577,\"^4<\",181,536870913]],[\"^7[\",[577,\"^6:\",568,536870913]],[\"^7[\",[577,\"^T\",10,536870913]],[\"^7[\",[593,\"^48\",1775980982459,536870913]],[\"^7[\",[593,\"^7;\",\"d3BHf\",536870913]],[\"^7[\",[593,\"^4P\",568,536870913]],[\"^7[\",[593,\"^3V\",568,536870913]],[\"^7[\",[593,\"^1S\",568,536870913]],[\"^7[\",[593,\"^3;\",1775980982459,536870913]],[\"^7[\",[593,\"^2T\",\"~u69db51b6-97ff-4bf6-9547-69dc12a2972e\",536870913]],[\"^7[\",[593,\"^4<\",181,536870913]],[\"^7[\",[593,\"^6:\",568,536870913]],[\"^7[\",[593,\"^T\",10,536870913]],[\"^7[\",[597,\"^48\",1775980958586,536870913]],[\"^7[\",[597,\"^7;\",\"d3BHe\",536870913]],[\"^7[\",[597,\"^4P\",568,536870913]],[\"^7[\",[597,\"^3V\",568,536870913]],[\"^7[\",[597,\"^1S\",568,536870913]],[\"^7[\",[597,\"^3;\",1775980958586,536870913]],[\"^7[\",[597,\"^2T\",\"~u69db519e-9f15-4a6a-a142-fe9d6e954212\",536870913]],[\"^7[\",[597,\"^4<\",181,536870913]],[\"^7[\",[597,\"^6:\",568,536870913]],[\"^7[\",[597,\"^T\",10,536870913]],[\"^7[\",[599,\"^48\",1775981040693,536870913]],[\"^7[\",[599,\"^7H\",\"page 3\",536870913]],[\"^7[\",[599,\"^1S\",58,536870913]],[\"^7[\",[599,\"^1S\",104,536870913]],[\"^7[\",[599,\"^64\",104,536870913]],[\"^7[\",[599,\"^65\",\"page 3\",536870913]],[\"^7[\",[599,\"^3;\",1775981040693,536870913]],[\"^7[\",[599,\"^2T\",\"~u69db51a5-4a8e-4ebf-992e-d31c157eaf5b\",536870913]],[\"^7[\",[599,\"^4<\",181,536870913]],[\"^7[\",[600,\"^48\",1775981040720,536870913]],[\"^7[\",[600,\"^7H\",\"p4\",536870913]],[\"^7[\",[600,\"^7;\",\"d3BHh\",536870913]],[\"^7[\",[600,\"^1S\",58,536870913]],[\"^7[\",[600,\"^1S\",124,536870913]],[\"^7[\",[600,\"^64\",124,536870913]],[\"^7[\",[600,\"^65\",\"p4\",536870913]],[\"^7[\",[600,\"^3;\",1775981028998,536870913]],[\"^7[\",[600,\"^2T\",\"~u00000002-5835-7822-8000-000000000000\",536870913]],[\"^7[\",[600,\"^<\",\"^=\",536870913]],[\"^7[\",[600,\"^;\",\"^66\",536870913]],[\"^7[\",[600,\"^>\",true,536870913]],[\"^7[\",[600,\"^C\",\"^D\",536870913]],[\"^7[\",[600,\"^4<\",181,536870913]],[\"^7[\",[600,\"^1K\",\"^89\",536870913]],[\"^7[\",[605,\"^48\",1775981057423,536870913]],[\"^7[\",[605,\"^7;\",\"Zz\",536870913]],[\"^7[\",[605,\"^4P\",170,536870913]],[\"^7[\",[605,\"^3V\",170,536870913]],[\"^7[\",[605,\"^1S\",170,536870913]],[\"^7[\",[605,\"^65\",\"All\",536870913]],[\"^7[\",[605,\"^3;\",1775981057423,536870913]],[\"^7[\",[605,\"^2T\",\"~u00000006-1867-5878-9000-000000000000\",536870913]],[\"^7[\",[605,\"^4<\",181,536870913]],[\"^7[\",[605,\"^9\",170,536870913]],[\"^7[\",[605,\"^5<\",\"~:all-pages\",536870913]],[\"^7[\",[619,\"^3D\",true,536870913]],[\"^7[\",[619,\"^48\",1775981500541,536870913]],[\"^7[\",[619,\"^7H\",\"tag1\",536870913]],[\"^7[\",[619,\"^1S\",14,536870913]],[\"^7[\",[619,\"^1S\",58,536870913]],[\"^7[\",[619,\"^1S\",89,536870913]],[\"^7[\",[619,\"^1S\",158,536870913]],[\"^7[\",[619,\"^64\",14,536870913]],[\"^7[\",[619,\"^65\",\"tag1\",536870913]],[\"^7[\",[619,\"^3;\",1775981500541,536870913]],[\"^7[\",[619,\"^2T\",\"~u69db50d9-c23a-4aa4-bb79-33d5dbe54593\",536870913]],[\"^7[\",[619,\"^;\",\"^3R\",536870913]],[\"^7[\",[619,\"^4<\",181,536870913]],[\"^7[\",[619,\"^3K\",89,536870913]],[\"^7[\",[620,\"^48\",1775981500540,536870913]],[\"^7[\",[620,\"^7H\",\"page 4\",536870913]],[\"^7[\",[620,\"^1S\",58,536870913]],[\"^7[\",[620,\"^1S\",104,536870913]],[\"^7[\",[620,\"^1S\",619,536870913]],[\"^7[\",[620,\"^64\",104,536870913]],[\"^7[\",[620,\"^64\",619,536870913]],[\"^7[\",[620,\"^65\",\"page 4\",536870913]],[\"^7[\",[620,\"^3;\",1775981500561,536870913]],[\"^7[\",[620,\"^2T\",\"~u69db53ab-5e4a-4039-b2ba-4220ca0b20cd\",536870913]],[\"^7[\",[620,\"^4<\",181,536870913]],[\"^7[\",[621,\"^48\",1775981500553,536870913]],[\"^7[\",[621,\"^7;\",\"a0\",536870913]],[\"^7[\",[621,\"^4P\",620,536870913]],[\"^7[\",[621,\"^3V\",620,536870913]],[\"^7[\",[621,\"^65\",\"test\",536870913]],[\"^7[\",[621,\"^3;\",1775981500560,536870913]],[\"^7[\",[621,\"^2T\",\"~u69db53ac-7264-4191-bac4-eee78a2ff031\",536870913]],[\"^7[\",[621,\"^4<\",181,536870913]],[\"^7[\",[622,\"^48\",1775981513870,536870913]],[\"^7[\",[622,\"^7;\",\"a0\",536870913]],[\"^7[\",[622,\"^4P\",170,536870913]],[\"^7[\",[622,\"^3V\",170,536870913]],[\"^7[\",[622,\"^1S\",619,536870913]],[\"^7[\",[622,\"^65\",\"All\",536870913]],[\"^7[\",[622,\"^3;\",1775981513870,536870913]],[\"^7[\",[622,\"^2T\",\"~u00000006-1742-3452-4000-000000000000\",536870913]],[\"^7[\",[622,\"^4<\",181,536870913]],[\"^7[\",[622,\"^9\",619,536870913]],[\"^7[\",[622,\"^5<\",\"~:class-objects\",536870913]],[\"^7[\",[1530,\"^48\",1775982125944,536870913]],[\"^7[\",[1530,\"^7H\",\"page 5\",536870913]],[\"^7[\",[1530,\"^1S\",58,536870913]],[\"^7[\",[1530,\"^1S\",104,536870913]],[\"^7[\",[1530,\"^64\",104,536870913]],[\"^7[\",[1530,\"^65\",\"page 5\",536870913]],[\"^7[\",[1530,\"^3;\",1775982125944,536870913]],[\"^7[\",[1530,\"^2T\",\"~u69db557e-1cd8-48b3-87aa-1b8a684ca17f\",536870913]],[\"^7[\",[1530,\"^4<\",181,536870913]],[\"^7[\",[1732,\"^48\",1775982384424,536870913]],[\"^7[\",[1732,\"^7H\",\"page 6\",536870913]],[\"^7[\",[1732,\"^1S\",58,536870913]],[\"^7[\",[1732,\"^1S\",104,536870913]],[\"^7[\",[1732,\"^64\",104,536870913]],[\"^7[\",[1732,\"^65\",\"page 6\",536870913]],[\"^7[\",[1732,\"^3;\",1775982413000,536870913]],[\"^7[\",[1732,\"^2T\",\"~u69db5730-ad32-46c0-bb99-f87817d4443a\",536870913]],[\"^7[\",[1732,\"^4<\",181,536870913]],[\"^7[\",[1733,\"^48\",1775982384655,536870913]],[\"^7[\",[1733,\"^7;\",\"a0\",536870913]],[\"^7[\",[1733,\"^4P\",1732,536870913]],[\"^7[\",[1733,\"^3V\",1732,536870913]],[\"^7[\",[1733,\"^65\",\"hello\",536870913]],[\"^7[\",[1733,\"^3;\",1775982389175,536870913]],[\"^7[\",[1733,\"^2T\",\"~u69db5730-c5a5-4116-89f5-26341ef44073\",536870913]],[\"^7[\",[1733,\"^4<\",181,536870913]],[\"^7[\",[1742,\"^48\",1775982389180,536870913]],[\"^7[\",[1742,\"^7;\",\"a1\",536870913]],[\"^7[\",[1742,\"^4P\",1732,536870913]],[\"^7[\",[1742,\"^3V\",1732,536870913]],[\"^7[\",[1742,\"^65\",\"fjdlafda\",536870913]],[\"^7[\",[1742,\"^3;\",1775982389685,536870913]],[\"^7[\",[1742,\"^2T\",\"~u69db5731-9fe9-4c6b-a183-ffb2e6471f45\",536870913]],[\"^7[\",[1743,\"^48\",1775982389690,536870913]],[\"^7[\",[1743,\"^7;\",\"a2\",536870913]],[\"^7[\",[1743,\"^4P\",1732,536870913]],[\"^7[\",[1743,\"^3V\",1732,536870913]],[\"^7[\",[1743,\"^65\",\"f\",536870913]],[\"^7[\",[1743,\"^3;\",1775982389754,536870913]],[\"^7[\",[1743,\"^2T\",\"~u69db5732-3643-4e28-906b-581fbbb2234a\",536870913]],[\"^7[\",[1744,\"^48\",1775982389755,536870913]],[\"^7[\",[1744,\"^7;\",\"a3\",536870913]],[\"^7[\",[1744,\"^4P\",1732,536870913]],[\"^7[\",[1744,\"^3V\",1732,536870913]],[\"^7[\",[1744,\"^65\",\"adf\",536870913]],[\"^7[\",[1744,\"^3;\",1775982389841,536870913]],[\"^7[\",[1744,\"^2T\",\"~u69db5732-9fbc-49f2-8473-db80eee4e879\",536870913]],[\"^7[\",[1745,\"^48\",1775982389843,536870913]],[\"^7[\",[1745,\"^7;\",\"a4\",536870913]],[\"^7[\",[1745,\"^4P\",1732,536870913]],[\"^7[\",[1745,\"^3V\",1732,536870913]],[\"^7[\",[1745,\"^65\",\"f\",536870913]],[\"^7[\",[1745,\"^3;\",1775982389926,536870913]],[\"^7[\",[1745,\"^2T\",\"~u69db5732-a4fa-48bc-b850-1ea2dd125db8\",536870913]],[\"^7[\",[1746,\"^48\",1775982389927,536870913]],[\"^7[\",[1746,\"^7;\",\"a5\",536870913]],[\"^7[\",[1746,\"^4P\",1732,536870913]],[\"^7[\",[1746,\"^3V\",1732,536870913]],[\"^7[\",[1746,\"^65\",\"af\",536870913]],[\"^7[\",[1746,\"^3;\",1775982390009,536870913]],[\"^7[\",[1746,\"^2T\",\"~u69db5732-c89c-439e-91db-385e7cf0884a\",536870913]],[\"^7[\",[1747,\"^48\",1775982390010,536870913]],[\"^7[\",[1747,\"^7;\",\"a6\",536870913]],[\"^7[\",[1747,\"^4P\",1732,536870913]],[\"^7[\",[1747,\"^3V\",1732,536870913]],[\"^7[\",[1747,\"^65\",\"af\",536870913]],[\"^7[\",[1747,\"^3;\",1775982390090,536870913]],[\"^7[\",[1747,\"^2T\",\"~u69db5732-389f-48f7-a3c9-9ab01e4d06b3\",536870913]],[\"^7[\",[1748,\"^48\",1775982390091,536870913]],[\"^7[\",[1748,\"^7;\",\"a7\",536870913]],[\"^7[\",[1748,\"^4P\",1732,536870913]],[\"^7[\",[1748,\"^3V\",1732,536870913]],[\"^7[\",[1748,\"^65\",\"a\",536870913]],[\"^7[\",[1748,\"^3;\",1775982390176,536870913]],[\"^7[\",[1748,\"^2T\",\"~u69db5733-aeac-4823-bd0e-ba84d7fcc3aa\",536870913]],[\"^7[\",[1749,\"^48\",1775982390178,536870913]],[\"^7[\",[1749,\"^7;\",\"a8\",536870913]],[\"^7[\",[1749,\"^4P\",1732,536870913]],[\"^7[\",[1749,\"^3V\",1732,536870913]],[\"^7[\",[1749,\"^65\",\"so cool\",536870913]],[\"^7[\",[1749,\"^3;\",1775982411832,536870913]],[\"^7[\",[1749,\"^2T\",\"~u69db5733-9bfe-49db-96cb-03fe97bce35f\",536870913]],[\"^7[\",[1749,\"^4<\",181,536870913]],[\"^7[\",[1754,\"^48\",1775982411836,536870913]],[\"^7[\",[1754,\"^7;\",\"a0\",536870913]],[\"^7[\",[1754,\"^4P\",1732,536870913]],[\"^7[\",[1754,\"^3V\",1749,536870913]],[\"^7[\",[1754,\"^65\",\"awesome work\",536870913]],[\"^7[\",[1754,\"^3;\",1775982412416,536870913]],[\"^7[\",[1754,\"^2T\",\"~u69db5739-bca6-4992-96ec-45334b44f4c4\",536870913]],[\"^7[\",[1755,\"^48\",1775982412417,536870913]],[\"^7[\",[1755,\"^7;\",\"a1\",536870913]],[\"^7[\",[1755,\"^4P\",1732,536870913]],[\"^7[\",[1755,\"^3V\",1749,536870913]],[\"^7[\",[1755,\"^65\",\"great\",536870913]],[\"^7[\",[1755,\"^3;\",1775982412496,536870913]],[\"^7[\",[1755,\"^2T\",\"~u69db573a-8ec0-46e1-bc26-d7632e93d96f\",536870913]],[\"^7[\",[1756,\"^48\",1775982412499,536870913]],[\"^7[\",[1756,\"^7;\",\"a2\",536870913]],[\"^7[\",[1756,\"^4P\",1732,536870913]],[\"^7[\",[1756,\"^3V\",1749,536870913]],[\"^7[\",[1756,\"^65\",\"sounds amazing\",536870913]],[\"^7[\",[1756,\"^3;\",1775982412747,536870913]],[\"^7[\",[1756,\"^2T\",\"~u69db573b-ec3d-4f66-8e8d-26c9ff157a6b\",536870913]],[\"^7[\",[1757,\"^48\",1775982412833,536870913]],[\"^7[\",[1757,\"^7;\",\"a3\",536870913]],[\"^7[\",[1757,\"^4P\",1732,536870913]],[\"^7[\",[1757,\"^3V\",1749,536870913]],[\"^7[\",[1757,\"^65\",\"stuff really just work\",536870913]],[\"^7[\",[1757,\"^3;\",1775982413000,536870913]],[\"^7[\",[1757,\"^2T\",\"~u69db573f-8a13-43cc-82af-060e89cfd1fa\",536870913]],[\"^7[\",[1836,\"^48\",1775983070467,536870913]],[\"^7[\",[1836,\"^7H\",\"page 7\",536870913]],[\"^7[\",[1836,\"^1S\",58,536870913]],[\"^7[\",[1836,\"^1S\",104,536870913]],[\"^7[\",[1836,\"^64\",104,536870913]],[\"^7[\",[1836,\"^65\",\"page 7\",536870913]],[\"^7[\",[1836,\"^3;\",1775983683136,536870913]],[\"^7[\",[1836,\"^2T\",\"~u69db59de-5992-4e9c-aba5-12bbae69d657\",536870913]],[\"^7[\",[1836,\"^4<\",181,536870913]],[\"^7[\",[1852,\"^48\",1775983078379,536870913]],[\"^7[\",[1852,\"^7;\",\"a0\",536870913]],[\"^7[\",[1852,\"^4P\",1836,536870913]],[\"^7[\",[1852,\"^3V\",1836,536870913]],[\"^7[\",[1852,\"^65\",\"fjdlafjldaf\",536870913]],[\"^7[\",[1852,\"^3;\",1775983078467,536870913]],[\"^7[\",[1852,\"^2T\",\"~u69db59de-1710-4353-9b7f-a6843d27c9f7\",536870913]],[\"^7[\",[1853,\"^48\",1775983078469,536870913]],[\"^7[\",[1853,\"^7;\",\"a1\",536870913]],[\"^7[\",[1853,\"^4P\",1836,536870913]],[\"^7[\",[1853,\"^3V\",1836,536870913]],[\"^7[\",[1853,\"^65\",\"da\",536870913]],[\"^7[\",[1853,\"^3;\",1775983078547,536870913]],[\"^7[\",[1853,\"^2T\",\"~u69db59df-bbd0-4c0b-b89b-40d255373829\",536870913]],[\"^7[\",[1854,\"^48\",1775983078548,536870913]],[\"^7[\",[1854,\"^7;\",\"a2\",536870913]],[\"^7[\",[1854,\"^4P\",1836,536870913]],[\"^7[\",[1854,\"^3V\",1836,536870913]],[\"^7[\",[1854,\"^65\",\"fd\",536870913]],[\"^7[\",[1854,\"^3;\",1775983078649,536870913]],[\"^7[\",[1854,\"^2T\",\"~u69db59df-ecc8-48f2-ab1d-b885294463c8\",536870913]],[\"^7[\",[1855,\"^48\",1775983078652,536870913]],[\"^7[\",[1855,\"^7;\",\"a0\",536870913]],[\"^7[\",[1855,\"^4P\",1836,536870913]],[\"^7[\",[1855,\"^3V\",1854,536870913]],[\"^7[\",[1855,\"^65\",\"aff\",536870913]],[\"^7[\",[1855,\"^3;\",1775983078804,536870913]],[\"^7[\",[1855,\"^2T\",\"~u69db59df-fbd3-4baa-b307-e439f1f660bc\",536870913]],[\"^7[\",[1856,\"^48\",1775983078806,536870913]],[\"^7[\",[1856,\"^7;\",\"a1\",536870913]],[\"^7[\",[1856,\"^4P\",1836,536870913]],[\"^7[\",[1856,\"^3V\",1854,536870913]],[\"^7[\",[1856,\"^65\",\"daqf\",536870913]],[\"^7[\",[1856,\"^3;\",1775983078892,536870913]],[\"^7[\",[1856,\"^2T\",\"~u69db59e0-6768-4059-b647-6b522367a85d\",536870913]],[\"^7[\",[1857,\"^48\",1775983078897,536870913]],[\"^7[\",[1857,\"^7;\",\"a2\",536870913]],[\"^7[\",[1857,\"^4P\",1836,536870913]],[\"^7[\",[1857,\"^3V\",1854,536870913]],[\"^7[\",[1857,\"^65\",\"f\",536870913]],[\"^7[\",[1857,\"^3;\",1775983079138,536870913]],[\"^7[\",[1857,\"^2T\",\"~u69db59e0-16b6-48d5-ae4d-c6832cec5580\",536870913]],[\"^7[\",[1858,\"^48\",1775983079140,536870913]],[\"^7[\",[1858,\"^7;\",\"a0\",536870913]],[\"^7[\",[1858,\"^4P\",1836,536870913]],[\"^7[\",[1858,\"^3V\",1857,536870913]],[\"^7[\",[1858,\"^65\",\"f\",536870913]],[\"^7[\",[1858,\"^3;\",1775983079488,536870913]],[\"^7[\",[1858,\"^2T\",\"~u69db59e0-1a63-4e46-97b2-9696843f7c47\",536870913]],[\"^7[\",[1859,\"^48\",1775983079489,536870913]],[\"^7[\",[1859,\"^7;\",\"a1\",536870913]],[\"^7[\",[1859,\"^4P\",1836,536870913]],[\"^7[\",[1859,\"^3V\",1857,536870913]],[\"^7[\",[1859,\"^65\",\"f\",536870913]],[\"^7[\",[1859,\"^3;\",1775983079572,536870913]],[\"^7[\",[1859,\"^2T\",\"~u69db59e1-da15-45cb-9f42-a87f80023c55\",536870913]],[\"^7[\",[1860,\"^48\",1775983079643,536870913]],[\"^7[\",[1860,\"^7;\",\"a0\",536870913]],[\"^7[\",[1860,\"^4P\",1836,536870913]],[\"^7[\",[1860,\"^3V\",1859,536870913]],[\"^7[\",[1860,\"^65\",\"f\",536870913]],[\"^7[\",[1860,\"^3;\",1775983079815,536870913]],[\"^7[\",[1860,\"^2T\",\"~u69db59e1-f52e-4e72-b110-c313fbd76c44\",536870913]],[\"^7[\",[1861,\"^48\",1775983079817,536870913]],[\"^7[\",[1861,\"^7;\",\"a1\",536870913]],[\"^7[\",[1861,\"^4P\",1836,536870913]],[\"^7[\",[1861,\"^3V\",1859,536870913]],[\"^7[\",[1861,\"^65\",\"f\",536870913]],[\"^7[\",[1861,\"^3;\",1775983682382,536870913]],[\"^7[\",[1861,\"^2T\",\"~u69db59e1-bc2d-49e6-ad28-ade10a3e506c\",536870913]],[\"^7[\",[2033,\"^48\",1775983682383,536870913]],[\"^7[\",[2033,\"^7;\",\"a0V\",536870913]],[\"^7[\",[2033,\"^4P\",1836,536870913]],[\"^7[\",[2033,\"^3V\",1859,536870913]],[\"^7[\",[2033,\"^65\",\"fd\",536870913]],[\"^7[\",[2033,\"^3;\",1775983682880,536870913]],[\"^7[\",[2033,\"^2T\",\"~u69db59e1-5216-491c-b6ff-36ba8fb23f4d\",536870913]],[\"^7[\",[2034,\"^48\",1775983682881,536870913]],[\"^7[\",[2034,\"^7;\",\"a0l\",536870913]],[\"^7[\",[2034,\"^4P\",1836,536870913]],[\"^7[\",[2034,\"^3V\",1859,536870913]],[\"^7[\",[2034,\"^65\",\"af\",536870913]],[\"^7[\",[2034,\"^3;\",1775983682974,536870913]],[\"^7[\",[2034,\"^2T\",\"~u69db59e1-b11f-4372-9155-50825d6fd4ed\",536870913]],[\"^7[\",[2035,\"^48\",1775983682975,536870913]],[\"^7[\",[2035,\"^7;\",\"a0t\",536870913]],[\"^7[\",[2035,\"^4P\",1836,536870913]],[\"^7[\",[2035,\"^3V\",1859,536870913]],[\"^7[\",[2035,\"^65\",\"asf\",536870913]],[\"^7[\",[2035,\"^3;\",1775983683045,536870913]],[\"^7[\",[2035,\"^2T\",\"~u69db59e2-58f7-4f9e-aa44-34b587b0a687\",536870913]],[\"^7[\",[2036,\"^48\",1775983683046,536870913]],[\"^7[\",[2036,\"^7;\",\"a0x\",536870913]],[\"^7[\",[2036,\"^4P\",1836,536870913]],[\"^7[\",[2036,\"^3V\",1859,536870913]],[\"^7[\",[2036,\"^65\",\"sf\",536870913]],[\"^7[\",[2036,\"^3;\",1775983683135,536870913]],[\"^7[\",[2036,\"^2T\",\"~u69db59e2-7991-45be-ba25-1cdb2e3519a9\",536870913]],[\"^7[\",[2037,\"^48\",1775983683137,536870913]],[\"^7[\",[2037,\"^7;\",\"a0z\",536870913]],[\"^7[\",[2037,\"^4P\",1836,536870913]],[\"^7[\",[2037,\"^3V\",1859,536870913]],[\"^7[\",[2037,\"^65\",\"\",536870913]],[\"^7[\",[2037,\"^3;\",1775983683137,536870913]],[\"^7[\",[2037,\"^2T\",\"~u69db59e2-1a51-43ba-8a3b-ceb25f6aa499\",536870913]],[\"^7[\",[2109,\"^48\",1775983689295,536870913]],[\"^7[\",[2109,\"^7H\",\"page 9\",536870913]],[\"^7[\",[2109,\"^1S\",58,536870913]],[\"^7[\",[2109,\"^1S\",104,536870913]],[\"^7[\",[2109,\"^64\",104,536870913]],[\"^7[\",[2109,\"^65\",\"page 9\",536870913]],[\"^7[\",[2109,\"^3;\",1775983693372,536870913]],[\"^7[\",[2109,\"^2T\",\"~u69db5c49-fb5d-4f7a-90a9-cd294f0aef44\",536870913]],[\"^7[\",[2109,\"^4<\",181,536870913]],[\"^7[\",[2110,\"^48\",1775983689528,536870913]],[\"^7[\",[2110,\"^7;\",\"a0\",536870913]],[\"^7[\",[2110,\"^4P\",2109,536870913]],[\"^7[\",[2110,\"^3V\",2109,536870913]],[\"^7[\",[2110,\"^65\",\"fjldafjlas\",536870913]],[\"^7[\",[2110,\"^3;\",1775983690319,536870913]],[\"^7[\",[2110,\"^2T\",\"~u69db5c49-9f9e-49e8-9ead-2c36ec84031b\",536870913]],[\"^7[\",[2110,\"^4<\",181,536870913]],[\"^7[\",[2111,\"^48\",1775983690320,536870913]],[\"^7[\",[2111,\"^7;\",\"a0\",536870913]],[\"^7[\",[2111,\"^4P\",2109,536870913]],[\"^7[\",[2111,\"^3V\",2110,536870913]],[\"^7[\",[2111,\"^65\",\"f\",536870913]],[\"^7[\",[2111,\"^3;\",1775983690437,536870913]],[\"^7[\",[2111,\"^2T\",\"~u69db5c4a-c58e-4a90-9cd6-4f692890f42a\",536870913]],[\"^7[\",[2111,\"^4<\",181,536870913]],[\"^7[\",[2112,\"^48\",1775983690514,536870913]],[\"^7[\",[2112,\"^7;\",\"a0\",536870913]],[\"^7[\",[2112,\"^4P\",2109,536870913]],[\"^7[\",[2112,\"^3V\",2111,536870913]],[\"^7[\",[2112,\"^65\",\"f\",536870913]],[\"^7[\",[2112,\"^3;\",1775983690873,536870913]],[\"^7[\",[2112,\"^2T\",\"~u69db5c4a-0212-4c6f-bee0-24b88a12deee\",536870913]],[\"^7[\",[2112,\"^4<\",181,536870913]],[\"^7[\",[2113,\"^48\",1775983690916,536870913]],[\"^7[\",[2113,\"^7;\",\"Zz\",536870913]],[\"^7[\",[2113,\"^4P\",2109,536870913]],[\"^7[\",[2113,\"^3V\",2111,536870913]],[\"^7[\",[2113,\"^65\",\"\",536870913]],[\"^7[\",[2113,\"^3;\",1775983690916,536870913]],[\"^7[\",[2113,\"^2T\",\"~u69db5c4a-2364-4fb7-bdd1-223745f4c69a\",536870913]],[\"^7[\",[2113,\"^4<\",181,536870913]],[\"^7[\",[2114,\"^48\",1775983691100,536870913]],[\"^7[\",[2114,\"^7;\",\"a0\",536870913]],[\"^7[\",[2114,\"^4P\",2109,536870913]],[\"^7[\",[2114,\"^3V\",2113,536870913]],[\"^7[\",[2114,\"^65\",\"f\",536870913]],[\"^7[\",[2114,\"^3;\",1775983691353,536870913]],[\"^7[\",[2114,\"^2T\",\"~u69db5c4b-932f-49b8-9522-2f5d6a962f25\",536870913]],[\"^7[\",[2114,\"^4<\",181,536870913]],[\"^7[\",[2115,\"^48\",1775983691319,536870913]],[\"^7[\",[2115,\"^7;\",\"a1\",536870913]],[\"^7[\",[2115,\"^4P\",2109,536870913]],[\"^7[\",[2115,\"^3V\",2113,536870913]],[\"^7[\",[2115,\"^65\",\"\",536870913]],[\"^7[\",[2115,\"^3;\",1775983691319,536870913]],[\"^7[\",[2115,\"^2T\",\"~u69db5c4b-9d2c-4516-b00c-25b6e2a7d6de\",536870913]],[\"^7[\",[2115,\"^4<\",181,536870913]],[\"^7[\",[2116,\"^48\",1775983691450,536870913]],[\"^7[\",[2116,\"^7;\",\"a2\",536870913]],[\"^7[\",[2116,\"^4P\",2109,536870913]],[\"^7[\",[2116,\"^3V\",2113,536870913]],[\"^7[\",[2116,\"^65\",\"f\",536870913]],[\"^7[\",[2116,\"^3;\",1775983691677,536870913]],[\"^7[\",[2116,\"^2T\",\"~u69db5c4b-7c2d-4bb4-9aaa-5a9f1a445f52\",536870913]],[\"^7[\",[2116,\"^4<\",181,536870913]],[\"^7[\",[2117,\"^48\",1775983691628,536870913]],[\"^7[\",[2117,\"^7;\",\"Zzx\",536870913]],[\"^7[\",[2117,\"^4P\",2109,536870913]],[\"^7[\",[2117,\"^3V\",2111,536870913]],[\"^7[\",[2117,\"^65\",\"f\",536870913]],[\"^7[\",[2117,\"^3;\",1775983691774,536870913]],[\"^7[\",[2117,\"^2T\",\"~u69db5c4b-56f8-422b-b193-b482957b3b31\",536870913]],[\"^7[\",[2117,\"^4<\",181,536870913]],[\"^7[\",[2118,\"^48\",1775983691775,536870913]],[\"^7[\",[2118,\"^7;\",\"a0\",536870913]],[\"^7[\",[2118,\"^4P\",2109,536870913]],[\"^7[\",[2118,\"^3V\",2117,536870913]],[\"^7[\",[2118,\"^65\",\"f\",536870913]],[\"^7[\",[2118,\"^3;\",1775983691973,536870913]],[\"^7[\",[2118,\"^2T\",\"~u69db5c4b-0f65-4034-9d3e-a53826620eb4\",536870913]],[\"^7[\",[2118,\"^4<\",181,536870913]],[\"^7[\",[2119,\"^48\",1775983691974,536870913]],[\"^7[\",[2119,\"^7;\",\"Zz\",536870913]],[\"^7[\",[2119,\"^4P\",2109,536870913]],[\"^7[\",[2119,\"^3V\",2117,536870913]],[\"^7[\",[2119,\"^65\",\"\",536870913]],[\"^7[\",[2119,\"^3;\",1775983691974,536870913]],[\"^7[\",[2119,\"^2T\",\"~u69db5c4b-8a1c-4a55-9c85-2c33cc41abf3\",536870913]],[\"^7[\",[2119,\"^4<\",181,536870913]],[\"^7[\",[2120,\"^48\",1775983692146,536870913]],[\"^7[\",[2120,\"^7;\",\"a0\",536870913]],[\"^7[\",[2120,\"^4P\",2109,536870913]],[\"^7[\",[2120,\"^3V\",2119,536870913]],[\"^7[\",[2120,\"^65\",\"f\",536870913]],[\"^7[\",[2120,\"^3;\",1775983692532,536870913]],[\"^7[\",[2120,\"^2T\",\"~u69db5c4c-2d33-4497-a4ca-9fa095153cef\",536870913]],[\"^7[\",[2120,\"^4<\",181,536870913]],[\"^7[\",[2121,\"^48\",1775983692485,536870913]],[\"^7[\",[2121,\"^7;\",\"a1\",536870913]],[\"^7[\",[2121,\"^4P\",2109,536870913]],[\"^7[\",[2121,\"^3V\",2119,536870913]],[\"^7[\",[2121,\"^65\",\"f\",536870913]],[\"^7[\",[2121,\"^3;\",1775983692712,536870913]],[\"^7[\",[2121,\"^2T\",\"~u69db5c4c-65af-484b-80c2-221d316cd911\",536870913]],[\"^7[\",[2121,\"^4<\",181,536870913]],[\"^7[\",[2122,\"^48\",1775983692668,536870913]],[\"^7[\",[2122,\"^7;\",\"Zzt\",536870913]],[\"^7[\",[2122,\"^4P\",2109,536870913]],[\"^7[\",[2122,\"^3V\",2117,536870913]],[\"^7[\",[2122,\"^65\",\"f\",536870913]],[\"^7[\",[2122,\"^3;\",1775983692810,536870913]],[\"^7[\",[2122,\"^2T\",\"~u69db5c4c-8ca4-40fa-b76f-65bdfb599fa4\",536870913]],[\"^7[\",[2122,\"^4<\",181,536870913]],[\"^7[\",[2123,\"^48\",1775983692811,536870913]],[\"^7[\",[2123,\"^7;\",\"a0\",536870913]],[\"^7[\",[2123,\"^4P\",2109,536870913]],[\"^7[\",[2123,\"^3V\",2122,536870913]],[\"^7[\",[2123,\"^1S\",58,536870913]],[\"^7[\",[2123,\"^1S\",2124,536870913]],[\"^7[\",[2123,\"^64\",2124,536870913]],[\"^7[\",[2123,\"^65\",\"f\",536870913]],[\"^7[\",[2123,\"^3;\",1775983693372,536870913]],[\"^7[\",[2123,\"^2T\",\"~u69db5c4c-ddf7-4a3a-a05f-8a31dc7acded\",536870913]],[\"^7[\",[2123,\"^4<\",181,536870913]],[\"^7[\",[2124,\"^3D\",false,536870913]],[\"^7[\",[2124,\"^48\",1775983696483,536870913]],[\"^7[\",[2124,\"^7H\",\"tag2\",536870913]],[\"^7[\",[2124,\"^1S\",14,536870913]],[\"^7[\",[2124,\"^1S\",58,536870913]],[\"^7[\",[2124,\"^1S\",89,536870913]],[\"^7[\",[2124,\"^1S\",121,536870913]],[\"^7[\",[2124,\"^1S\",158,536870913]],[\"^7[\",[2124,\"^1S\",565,536870913]],[\"^7[\",[2124,\"^1S\",568,536870913]],[\"^7[\",[2124,\"^1S\",2126,536870913]],[\"^7[\",[2124,\"^64\",14,536870913]],[\"^7[\",[2124,\"^65\",\"tag2\",536870913]],[\"^7[\",[2124,\"^3;\",1775983696483,536870913]],[\"^7[\",[2124,\"^2T\",\"~u69db5c50-3b82-4cb1-a425-271e7c8e6a03\",536870913]],[\"^7[\",[2124,\"^;\",\"^3Z\",536870913]],[\"^7[\",[2124,\"^4<\",181,536870913]],[\"^7[\",[2124,\"^3K\",89,536870913]],[\"^7[\",[2124,\"^4J\",565,536870913]],[\"^7[\",[2124,\"^4J\",568,536870913]],[\"^7[\",[2124,\"^4J\",2126,536870913]],[\"^7[\",[2125,\"^48\",1775983698445,536870913]],[\"^7[\",[2125,\"^7;\",\"Zy\",536870913]],[\"^7[\",[2125,\"^4P\",170,536870913]],[\"^7[\",[2125,\"^3V\",170,536870913]],[\"^7[\",[2125,\"^1S\",2124,536870913]],[\"^7[\",[2125,\"^65\",\"All\",536870913]],[\"^7[\",[2125,\"^3;\",1775983698445,536870913]],[\"^7[\",[2125,\"^2T\",\"~u00000006-2710-8879-5000-000000000000\",536870913]],[\"^7[\",[2125,\"^4<\",181,536870913]],[\"^7[\",[2125,\"^9\",2124,536870913]],[\"^7[\",[2125,\"^5<\",\"^8J\",536870913]],[\"^7[\",[2126,\"^48\",1775983711630,536870913]],[\"^7[\",[2126,\"^7H\",\"p5\",536870913]],[\"^7[\",[2126,\"^7;\",\"d3BK2\",536870913]],[\"^7[\",[2126,\"^1S\",58,536870913]],[\"^7[\",[2126,\"^1S\",104,536870913]],[\"^7[\",[2126,\"^1S\",124,536870913]],[\"^7[\",[2126,\"^64\",124,536870913]],[\"^7[\",[2126,\"^65\",\"p5\",536870913]],[\"^7[\",[2126,\"^3;\",1775983717913,536870913]],[\"^7[\",[2126,\"^2T\",\"~u00000002-1345-4970-5000-000000000000\",536870913]],[\"^7[\",[2126,\"^<\",\"^14\",536870913]],[\"^7[\",[2126,\"^;\",\"^23\",536870913]],[\"^7[\",[2126,\"^>\",true,536870913]],[\"^7[\",[2126,\"^C\",\"^D\",536870913]],[\"^7[\",[2126,\"^6B\",104,536870913]],[\"^7[\",[2126,\"^4<\",181,536870913]],[\"^7[\",[2126,\"^1K\",\"^80\",536870913]],[\"^7[\",[2341,\"^48\",1775983729631,536870913]],[\"^7[\",[2341,\"^7H\",\"page 8\",536870913]],[\"^7[\",[2341,\"^1S\",58,536870913]],[\"^7[\",[2341,\"^1S\",104,536870913]],[\"^7[\",[2341,\"^64\",104,536870913]],[\"^7[\",[2341,\"^65\",\"page 8\",536870913]],[\"^7[\",[2341,\"^3;\",1775983729797,536870913]],[\"^7[\",[2341,\"^2T\",\"~u69db5c2a-601b-45ea-ab8f-13e01ea7787b\",536870913]],[\"^7[\",[2341,\"^4<\",181,536870913]],[\"^7[\",[2342,\"^48\",1775983729638,536870913]],[\"^7[\",[2342,\"^7;\",\"a0\",536870913]],[\"^7[\",[2342,\"^4P\",2341,536870913]],[\"^7[\",[2342,\"^3V\",2341,536870913]],[\"^7[\",[2342,\"^65\",\"hello\",536870913]],[\"^7[\",[2342,\"^3;\",1775983729647,536870913]],[\"^7[\",[2342,\"^2T\",\"~u69db5c2b-3d88-4bad-ae1a-e068d1950520\",536870913]],[\"^7[\",[2342,\"^4<\",181,536870913]],[\"^7[\",[2343,\"^48\",1775983729648,536870913]],[\"^7[\",[2343,\"^7;\",\"a1\",536870913]],[\"^7[\",[2343,\"^4P\",2341,536870913]],[\"^7[\",[2343,\"^3V\",2341,536870913]],[\"^7[\",[2343,\"^65\",\"worjfdaf\",536870913]],[\"^7[\",[2343,\"^3;\",1775983729659,536870913]],[\"^7[\",[2343,\"^2T\",\"~u69db5c2b-6a3d-4726-a69e-41d53fd5a0bf\",536870913]],[\"^7[\",[2343,\"^4<\",181,536870913]],[\"^7[\",[2344,\"^48\",1775983729661,536870913]],[\"^7[\",[2344,\"^7;\",\"a2\",536870913]],[\"^7[\",[2344,\"^4P\",2341,536870913]],[\"^7[\",[2344,\"^3V\",2341,536870913]],[\"^7[\",[2344,\"^65\",\"\",536870913]],[\"^7[\",[2344,\"^3;\",1775983729661,536870913]],[\"^7[\",[2344,\"^2T\",\"~u69db5c2c-b373-49b6-a1f1-029b2f8ca8c8\",536870913]],[\"^7[\",[2344,\"^4<\",181,536870913]],[\"^7[\",[2345,\"^48\",1775983729672,536870913]],[\"^7[\",[2345,\"^7;\",\"a0\",536870913]],[\"^7[\",[2345,\"^4P\",2341,536870913]],[\"^7[\",[2345,\"^3V\",2344,536870913]],[\"^7[\",[2345,\"^65\",\"f\",536870913]],[\"^7[\",[2345,\"^3;\",1775983729688,536870913]],[\"^7[\",[2345,\"^2T\",\"~u69db5c2c-ba52-4f79-85f8-a7655369ba6c\",536870913]],[\"^7[\",[2345,\"^4<\",181,536870913]],[\"^7[\",[2346,\"^48\",1775983729689,536870913]],[\"^7[\",[2346,\"^7;\",\"a1\",536870913]],[\"^7[\",[2346,\"^4P\",2341,536870913]],[\"^7[\",[2346,\"^3V\",2344,536870913]],[\"^7[\",[2346,\"^65\",\"af\",536870913]],[\"^7[\",[2346,\"^3;\",1775983729700,536870913]],[\"^7[\",[2346,\"^2T\",\"~u69db5c2c-f588-4d67-89cf-637ed39de614\",536870913]],[\"^7[\",[2346,\"^4<\",181,536870913]],[\"^7[\",[2347,\"^48\",1775983729701,536870913]],[\"^7[\",[2347,\"^7;\",\"a2\",536870913]],[\"^7[\",[2347,\"^4P\",2341,536870913]],[\"^7[\",[2347,\"^3V\",2344,536870913]],[\"^7[\",[2347,\"^65\",\"as\",536870913]],[\"^7[\",[2347,\"^3;\",1775983729712,536870913]],[\"^7[\",[2347,\"^2T\",\"~u69db5c2d-eb41-41f0-97cb-c9dace0102c2\",536870913]],[\"^7[\",[2347,\"^4<\",181,536870913]],[\"^7[\",[2348,\"^48\",1775983729714,536870913]],[\"^7[\",[2348,\"^7;\",\"a3\",536870913]],[\"^7[\",[2348,\"^4P\",2341,536870913]],[\"^7[\",[2348,\"^3V\",2344,536870913]],[\"^7[\",[2348,\"^65\",\"fa\",536870913]],[\"^7[\",[2348,\"^3;\",1775983729725,536870913]],[\"^7[\",[2348,\"^2T\",\"~u69db5c2d-02cd-4b60-b743-09feff4e4825\",536870913]],[\"^7[\",[2348,\"^4<\",181,536870913]],[\"^7[\",[2349,\"^48\",1775983729726,536870913]],[\"^7[\",[2349,\"^7;\",\"a4\",536870913]],[\"^7[\",[2349,\"^4P\",2341,536870913]],[\"^7[\",[2349,\"^3V\",2344,536870913]],[\"^7[\",[2349,\"^65\",\"f\",536870913]],[\"^7[\",[2349,\"^3;\",1775983729738,536870913]],[\"^7[\",[2349,\"^2T\",\"~u69db5c2d-a17f-4516-b106-304a1befd700\",536870913]],[\"^7[\",[2349,\"^4<\",181,536870913]],[\"^7[\",[2350,\"^48\",1775983729739,536870913]],[\"^7[\",[2350,\"^7;\",\"a5\",536870913]],[\"^7[\",[2350,\"^4P\",2341,536870913]],[\"^7[\",[2350,\"^3V\",2344,536870913]],[\"^7[\",[2350,\"^1S\",2351,536870913]],[\"^7[\",[2350,\"^65\",\"af\",536870913]],[\"^7[\",[2350,\"^3;\",1775983665047,536870913]],[\"^7[\",[2350,\"^2T\",\"~u69db5c2d-167b-455d-9de8-a098caf456e3\",536870913]],[\"^7[\",[2350,\"^4<\",181,536870913]],[\"^7[\",[2350,\"^4?\",2352,536870913]],[\"^7[\",[2351,\"^48\",1775983729759,536870913]],[\"^7[\",[2351,\"^7H\",\"p10\",536870913]],[\"^7[\",[2351,\"^7;\",\"d3BHk\",536870913]],[\"^7[\",[2351,\"^1S\",58,536870913]],[\"^7[\",[2351,\"^1S\",124,536870913]],[\"^7[\",[2351,\"^64\",124,536870913]],[\"^7[\",[2351,\"^65\",\"p10\",536870913]],[\"^7[\",[2351,\"^3;\",1775983729759,536870913]],[\"^7[\",[2351,\"^2T\",\"~u00000002-1449-8430-2100-000000000000\",536870913]],[\"^7[\",[2351,\"^<\",\"^=\",536870913]],[\"^7[\",[2351,\"^;\",\"^4?\",536870913]],[\"^7[\",[2351,\"^>\",true,536870913]],[\"^7[\",[2351,\"^C\",\"^D\",536870913]],[\"^7[\",[2351,\"^4<\",181,536870913]],[\"^7[\",[2351,\"^1K\",\"^89\",536870913]],[\"^7[\",[2352,\"^48\",1775983665044,536870913]],[\"^7[\",[2352,\"^7;\",\"d3BHj\",536870913]],[\"^7[\",[2352,\"^4P\",2341,536870913]],[\"^7[\",[2352,\"^3V\",2350,536870913]],[\"^7[\",[2352,\"^1S\",2351,536870913]],[\"^7[\",[2352,\"^65\",\"hello\",536870913]],[\"^7[\",[2352,\"^3;\",1775983729784,536870913]],[\"^7[\",[2352,\"^2T\",\"~u69db5c31-45d6-443b-8f8f-1b1808c86b4b\",536870913]],[\"^7[\",[2352,\"^4<\",181,536870913]],[\"^7[\",[2352,\"^6:\",2351,536870913]],[\"^7[\",[2353,\"^48\",1775983729785,536870913]],[\"^7[\",[2353,\"^7;\",\"a0\",536870913]],[\"^7[\",[2353,\"^4P\",2341,536870913]],[\"^7[\",[2353,\"^3V\",2352,536870913]],[\"^7[\",[2353,\"^65\",\"fjdalfjklaf\",536870913]],[\"^7[\",[2353,\"^3;\",1775983729797,536870913]],[\"^7[\",[2353,\"^2T\",\"~u69db5c32-c449-4614-89c5-afdad827a965\",536870913]],[\"^7[\",[2353,\"^4<\",181,536870913]],[\"^7[\",[2374,\"^48\",1775984527252,536870913]],[\"^7[\",[2374,\"^7H\",\"page 10\",536870913]],[\"^7[\",[2374,\"^1S\",58,536870913]],[\"^7[\",[2374,\"^1S\",104,536870913]],[\"^7[\",[2374,\"^64\",104,536870913]],[\"^7[\",[2374,\"^65\",\"page 10\",536870913]],[\"^7[\",[2374,\"^3;\",1775984527765,536870913]],[\"^7[\",[2374,\"^2T\",\"~u69db5f78-689c-4382-9a3d-d2caf761adb6\",536870913]],[\"^7[\",[2374,\"^4<\",181,536870913]],[\"^7[\",[2389,\"^48\",1775984527610,536870913]],[\"^7[\",[2389,\"^7;\",\"a0\",536870913]],[\"^7[\",[2389,\"^4P\",2374,536870913]],[\"^7[\",[2389,\"^3V\",2374,536870913]],[\"^7[\",[2389,\"^65\",\"hellofda\",536870913]],[\"^7[\",[2389,\"^3;\",1775984527618,536870913]],[\"^7[\",[2389,\"^2T\",\"~u69db5f78-dcde-4e98-9f1a-1bca6c316e8d\",536870913]],[\"^7[\",[2389,\"^4<\",181,536870913]],[\"^7[\",[2390,\"^48\",1775984527619,536870913]],[\"^7[\",[2390,\"^7;\",\"a1\",536870913]],[\"^7[\",[2390,\"^4P\",2374,536870913]],[\"^7[\",[2390,\"^3V\",2374,536870913]],[\"^7[\",[2390,\"^65\",\"fa\",536870913]],[\"^7[\",[2390,\"^3;\",1775984527629,536870913]],[\"^7[\",[2390,\"^2T\",\"~u69db5f79-a66e-4a39-aecc-339726c89430\",536870913]],[\"^7[\",[2390,\"^4<\",181,536870913]],[\"^7[\",[2391,\"^48\",1775984527630,536870913]],[\"^7[\",[2391,\"^7;\",\"a0\",536870913]],[\"^7[\",[2391,\"^4P\",2374,536870913]],[\"^7[\",[2391,\"^3V\",2390,536870913]],[\"^7[\",[2391,\"^65\",\"fdasf\",536870913]],[\"^7[\",[2391,\"^3;\",1775984527646,536870913]],[\"^7[\",[2391,\"^2T\",\"~u69db5f79-fd04-4c4f-a0d5-726877b307a3\",536870913]],[\"^7[\",[2391,\"^4<\",181,536870913]],[\"^7[\",[2392,\"^48\",1775984527647,536870913]],[\"^7[\",[2392,\"^7;\",\"a1\",536870913]],[\"^7[\",[2392,\"^4P\",2374,536870913]],[\"^7[\",[2392,\"^3V\",2390,536870913]],[\"^7[\",[2392,\"^65\",\"af\",536870913]],[\"^7[\",[2392,\"^3;\",1775984527656,536870913]],[\"^7[\",[2392,\"^2T\",\"~u69db5f7a-d6b1-4bf8-90c6-a70ba0bec381\",536870913]],[\"^7[\",[2392,\"^4<\",181,536870913]],[\"^7[\",[2393,\"^48\",1775984527658,536870913]],[\"^7[\",[2393,\"^7;\",\"a2\",536870913]],[\"^7[\",[2393,\"^4P\",2374,536870913]],[\"^7[\",[2393,\"^3V\",2390,536870913]],[\"^7[\",[2393,\"^65\",\"as\",536870913]],[\"^7[\",[2393,\"^3;\",1775984527666,536870913]],[\"^7[\",[2393,\"^2T\",\"~u69db5f7a-e8a7-4800-bd8c-b2c14b6648a8\",536870913]],[\"^7[\",[2393,\"^4<\",181,536870913]],[\"^7[\",[2394,\"^48\",1775984527668,536870913]],[\"^7[\",[2394,\"^7;\",\"a3\",536870913]],[\"^7[\",[2394,\"^4P\",2374,536870913]],[\"^7[\",[2394,\"^3V\",2390,536870913]],[\"^7[\",[2394,\"^65\",\"fdas\",536870913]],[\"^7[\",[2394,\"^3;\",1775984527677,536870913]],[\"^7[\",[2394,\"^2T\",\"~u69db5f7a-f814-4036-b2d5-06b1688f319f\",536870913]],[\"^7[\",[2394,\"^4<\",181,536870913]],[\"^7[\",[2395,\"^48\",1775984527678,536870913]],[\"^7[\",[2395,\"^7;\",\"a2\",536870913]],[\"^7[\",[2395,\"^4P\",2374,536870913]],[\"^7[\",[2395,\"^3V\",2374,536870913]],[\"^7[\",[2395,\"^65\",\"fs\",536870913]],[\"^7[\",[2395,\"^3;\",1775984527694,536870913]],[\"^7[\",[2395,\"^2T\",\"~u69db5f7b-2941-4704-88d5-3d10f2b585f6\",536870913]],[\"^7[\",[2395,\"^4<\",181,536870913]],[\"^7[\",[2396,\"^48\",1775984527696,536870913]],[\"^7[\",[2396,\"^7;\",\"a3\",536870913]],[\"^7[\",[2396,\"^4P\",2374,536870913]],[\"^7[\",[2396,\"^3V\",2374,536870913]],[\"^7[\",[2396,\"^65\",\"af\",536870913]],[\"^7[\",[2396,\"^3;\",1775984527705,536870913]],[\"^7[\",[2396,\"^2T\",\"~u69db5f7b-11a3-4387-b2d8-e4360d0574b0\",536870913]],[\"^7[\",[2396,\"^4<\",181,536870913]],[\"^7[\",[2397,\"^48\",1775984527706,536870913]],[\"^7[\",[2397,\"^7;\",\"a4\",536870913]],[\"^7[\",[2397,\"^4P\",2374,536870913]],[\"^7[\",[2397,\"^3V\",2374,536870913]],[\"^7[\",[2397,\"^65\",\"as\",536870913]],[\"^7[\",[2397,\"^3;\",1775984527706,536870913]],[\"^7[\",[2397,\"^2T\",\"~u69db5f7b-24f0-4258-b5b5-7c86c9fb264e\",536870913]],[\"^7[\",[2397,\"^4<\",181,536870913]],[\"^7[\",[2398,\"^48\",1775984527715,536870913]],[\"^7[\",[2398,\"^7;\",\"a3V\",536870913]],[\"^7[\",[2398,\"^4P\",2374,536870913]],[\"^7[\",[2398,\"^3V\",2374,536870913]],[\"^7[\",[2398,\"^65\",\"fd\",536870913]],[\"^7[\",[2398,\"^3;\",1775984527722,536870913]],[\"^7[\",[2398,\"^2T\",\"~u69db5f7b-02a5-40e9-baf8-befba0d845c5\",536870913]],[\"^7[\",[2398,\"^4<\",181,536870913]],[\"^7[\",[2399,\"^48\",1775984527723,536870913]],[\"^7[\",[2399,\"^7;\",\"a3l\",536870913]],[\"^7[\",[2399,\"^4P\",2374,536870913]],[\"^7[\",[2399,\"^3V\",2374,536870913]],[\"^7[\",[2399,\"^65\",\"af\",536870913]],[\"^7[\",[2399,\"^3;\",1775984527732,536870913]],[\"^7[\",[2399,\"^2T\",\"~u69db5f7c-4a9c-4a5f-ad77-4d171ab63937\",536870913]],[\"^7[\",[2399,\"^4<\",181,536870913]],[\"^7[\",[2400,\"^48\",1775984527734,536870913]],[\"^7[\",[2400,\"^7;\",\"a3t\",536870913]],[\"^7[\",[2400,\"^4P\",2374,536870913]],[\"^7[\",[2400,\"^3V\",2374,536870913]],[\"^7[\",[2400,\"^65\",\"af\",536870913]],[\"^7[\",[2400,\"^3;\",1775984527743,536870913]],[\"^7[\",[2400,\"^2T\",\"~u69db5f7c-e498-4db4-9791-53e4560eb1cc\",536870913]],[\"^7[\",[2400,\"^4<\",181,536870913]],[\"^7[\",[2401,\"^48\",1775984527744,536870913]],[\"^7[\",[2401,\"^7;\",\"a3x\",536870913]],[\"^7[\",[2401,\"^4P\",2374,536870913]],[\"^7[\",[2401,\"^3V\",2374,536870913]],[\"^7[\",[2401,\"^65\",\"af\",536870913]],[\"^7[\",[2401,\"^3;\",1775984527754,536870913]],[\"^7[\",[2401,\"^2T\",\"~u69db5f7c-7746-45d5-8989-646ee3b9289b\",536870913]],[\"^7[\",[2401,\"^4<\",181,536870913]],[\"^7[\",[2402,\"^48\",1775984527755,536870913]],[\"^7[\",[2402,\"^7;\",\"a3z\",536870913]],[\"^7[\",[2402,\"^4P\",2374,536870913]],[\"^7[\",[2402,\"^3V\",2374,536870913]],[\"^7[\",[2402,\"^65\",\"a\",536870913]],[\"^7[\",[2402,\"^3;\",1775984527764,536870913]],[\"^7[\",[2402,\"^2T\",\"~u69db5f7c-8423-4ed9-bb36-bf5156b4d757\",536870913]],[\"^7[\",[2402,\"^4<\",181,536870913]],[\"^7[\",[2406,\"^48\",1775984610679,536870913]],[\"^7[\",[2406,\"^7H\",\"page 11\",536870913]],[\"^7[\",[2406,\"^1S\",58,536870913]],[\"^7[\",[2406,\"^1S\",104,536870913]],[\"^7[\",[2406,\"^64\",104,536870913]],[\"^7[\",[2406,\"^65\",\"page 11\",536870913]],[\"^7[\",[2406,\"^3;\",1775984651669,536870913]],[\"^7[\",[2406,\"^2T\",\"~u69db5fe2-20d8-4c72-8f3a-96b778acec6f\",536870913]],[\"^7[\",[2406,\"^4<\",181,536870913]],[\"^7[\",[2407,\"^48\",1775984610938,536870913]],[\"^7[\",[2407,\"^7;\",\"a0\",536870913]],[\"^7[\",[2407,\"^4P\",2406,536870913]],[\"^7[\",[2407,\"^3V\",2406,536870913]],[\"^7[\",[2407,\"^65\",\"fjdlafjl\",536870913]],[\"^7[\",[2407,\"^3;\",1775984611435,536870913]],[\"^7[\",[2407,\"^2T\",\"~u69db5fe2-56e1-428d-bf11-06cb38229be2\",536870913]],[\"^7[\",[2407,\"^4<\",181,536870913]],[\"^7[\",[2408,\"^48\",1775984611507,536870913]],[\"^7[\",[2408,\"^7;\",\"a0\",536870913]],[\"^7[\",[2408,\"^4P\",2406,536870913]],[\"^7[\",[2408,\"^3V\",2407,536870913]],[\"^7[\",[2408,\"^65\",\"ff\",536870913]],[\"^7[\",[2408,\"^3;\",1775984611935,536870913]],[\"^7[\",[2408,\"^2T\",\"~u69db5fe3-6de1-4070-96b6-6fb5e16cfd5d\",536870913]],[\"^7[\",[2408,\"^4<\",181,536870913]],[\"^7[\",[2409,\"^48\",1775984611937,536870913]],[\"^7[\",[2409,\"^7;\",\"a0\",536870913]],[\"^7[\",[2409,\"^4P\",2406,536870913]],[\"^7[\",[2409,\"^3V\",2408,536870913]],[\"^7[\",[2409,\"^65\",\"f\",536870913]],[\"^7[\",[2409,\"^3;\",1775984612220,536870913]],[\"^7[\",[2409,\"^2T\",\"~u69db5fe3-0419-47c1-9d77-36ba70846eae\",536870913]],[\"^7[\",[2409,\"^4<\",181,536870913]],[\"^7[\",[2410,\"^48\",1775984612293,536870913]],[\"^7[\",[2410,\"^7;\",\"a0\",536870913]],[\"^7[\",[2410,\"^4P\",2406,536870913]],[\"^7[\",[2410,\"^3V\",2409,536870913]],[\"^7[\",[2410,\"^65\",\"f\",536870913]],[\"^7[\",[2410,\"^3;\",1775984612724,536870913]],[\"^7[\",[2410,\"^2T\",\"~u69db5fe4-4a1d-4a85-8ffc-13fa73cf7772\",536870913]],[\"^7[\",[2410,\"^4<\",181,536870913]],[\"^7[\",[2411,\"^48\",1775984612632,536870913]],[\"^7[\",[2411,\"^7;\",\"a1\",536870913]],[\"^7[\",[2411,\"^4P\",2406,536870913]],[\"^7[\",[2411,\"^3V\",2409,536870913]],[\"^7[\",[2411,\"^65\",\"f\",536870913]],[\"^7[\",[2411,\"^3;\",1775984612903,536870913]],[\"^7[\",[2411,\"^2T\",\"~u69db5fe4-bafe-449d-acb5-c363728d2c3a\",536870913]],[\"^7[\",[2411,\"^4<\",181,536870913]],[\"^7[\",[2412,\"^48\",1775984612812,536870913]],[\"^7[\",[2412,\"^7;\",\"a3\",536870913]],[\"^7[\",[2412,\"^4P\",2406,536870913]],[\"^7[\",[2412,\"^3V\",2408,536870913]],[\"^7[\",[2412,\"^65\",\"f\",536870913]],[\"^7[\",[2412,\"^3;\",1775984613001,536870913]],[\"^7[\",[2412,\"^2T\",\"~u69db5fe4-a665-4b11-82ef-9fa78a1d025c\",536870913]],[\"^7[\",[2412,\"^4<\",181,536870913]],[\"^7[\",[2413,\"^48\",1775984613002,536870913]],[\"^7[\",[2413,\"^7;\",\"a0\",536870913]],[\"^7[\",[2413,\"^4P\",2406,536870913]],[\"^7[\",[2413,\"^3V\",2412,536870913]],[\"^7[\",[2413,\"^65\",\"f\",536870913]],[\"^7[\",[2413,\"^3;\",1775984613203,536870913]],[\"^7[\",[2413,\"^2T\",\"~u69db5fe4-e871-4f41-a9a6-d2783de5b724\",536870913]],[\"^7[\",[2413,\"^4<\",181,536870913]],[\"^7[\",[2414,\"^48\",1775984613203,536870913]],[\"^7[\",[2414,\"^7;\",\"a2\",536870913]],[\"^7[\",[2414,\"^4P\",2406,536870913]],[\"^7[\",[2414,\"^3V\",2425,536870913]],[\"^7[\",[2414,\"^65\",\"q\",536870913]],[\"^7[\",[2414,\"^3;\",1775984613413,536870913]],[\"^7[\",[2414,\"^2T\",\"~u69db5fe5-c93e-4715-a9e2-b311246fe3f1\",536870913]],[\"^7[\",[2414,\"^4<\",181,536870913]],[\"^7[\",[2415,\"^48\",1775984613413,536870913]],[\"^7[\",[2415,\"^7;\",\"a1\",536870913]],[\"^7[\",[2415,\"^4P\",2406,536870913]],[\"^7[\",[2415,\"^3V\",2413,536870913]],[\"^7[\",[2415,\"^65\",\"\",536870913]],[\"^7[\",[2415,\"^3;\",1775984613413,536870913]],[\"^7[\",[2415,\"^2T\",\"~u69db5fe5-b337-41af-9146-6e6e01e72130\",536870913]],[\"^7[\",[2415,\"^4<\",181,536870913]],[\"^7[\",[2416,\"^48\",1775984613561,536870913]],[\"^7[\",[2416,\"^7;\",\"a2\",536870913]],[\"^7[\",[2416,\"^4P\",2406,536870913]],[\"^7[\",[2416,\"^3V\",2406,536870913]],[\"^7[\",[2416,\"^65\",\"fdafdasf\",536870913]],[\"^7[\",[2416,\"^3;\",1775984651668,536870913]],[\"^7[\",[2416,\"^2T\",\"~u69db5fe5-ce1b-4fb0-802c-ca3c7deba63a\",536870913]],[\"^7[\",[2416,\"^4<\",181,536870913]],[\"^7[\",[2417,\"^48\",1775984613782,536870913]],[\"^7[\",[2417,\"^7;\",\"Zz\",536870913]],[\"^7[\",[2417,\"^4P\",2406,536870913]],[\"^7[\",[2417,\"^3V\",2415,536870913]],[\"^7[\",[2417,\"^65\",\"ff\",536870913]],[\"^7[\",[2417,\"^3;\",1775984614190,536870913]],[\"^7[\",[2417,\"^2T\",\"~u69db5fe5-5fad-4cca-bed6-f9e7c1e2a37d\",536870913]],[\"^7[\",[2417,\"^4<\",181,536870913]],[\"^7[\",[2418,\"^48\",1775984613943,536870913]],[\"^7[\",[2418,\"^7;\",\"a1\",536870913]],[\"^7[\",[2418,\"^4P\",2406,536870913]],[\"^7[\",[2418,\"^3V\",2425,536870913]],[\"^7[\",[2418,\"^65\",\"\",536870913]],[\"^7[\",[2418,\"^3;\",1775984613943,536870913]],[\"^7[\",[2418,\"^2T\",\"~u69db5fe5-deee-4706-a78d-aaf4007518c1\",536870913]],[\"^7[\",[2418,\"^4<\",181,536870913]],[\"^7[\",[2419,\"^48\",1775984614111,536870913]],[\"^7[\",[2419,\"^7;\",\"a0\",536870913]],[\"^7[\",[2419,\"^4P\",2406,536870913]],[\"^7[\",[2419,\"^3V\",2417,536870913]],[\"^7[\",[2419,\"^65\",\"f\",536870913]],[\"^7[\",[2419,\"^3;\",1775984614359,536870913]],[\"^7[\",[2419,\"^2T\",\"~u69db5fe6-fd79-4fa8-bfab-30986aa83bcc\",536870913]],[\"^7[\",[2419,\"^4<\",181,536870913]],[\"^7[\",[2420,\"^48\",1775984614283,536870913]],[\"^7[\",[2420,\"^7;\",\"a1\",536870913]],[\"^7[\",[2420,\"^4P\",2406,536870913]],[\"^7[\",[2420,\"^3V\",2417,536870913]],[\"^7[\",[2420,\"^65\",\"f\",536870913]],[\"^7[\",[2420,\"^3;\",1775984614536,536870913]],[\"^7[\",[2420,\"^2T\",\"~u69db5fe6-437d-47c3-91e6-7eed23097b73\",536870913]],[\"^7[\",[2420,\"^4<\",181,536870913]],[\"^7[\",[2421,\"^48\",1775984614454,536870913]],[\"^7[\",[2421,\"^7;\",\"ZzS\",536870913]],[\"^7[\",[2421,\"^4P\",2406,536870913]],[\"^7[\",[2421,\"^3V\",2415,536870913]],[\"^7[\",[2421,\"^65\",\"\",536870913]],[\"^7[\",[2421,\"^3;\",1775984614454,536870913]],[\"^7[\",[2421,\"^2T\",\"~u69db5fe6-a079-4c88-8d9c-bc0609008fe6\",536870913]],[\"^7[\",[2421,\"^4<\",181,536870913]],[\"^7[\",[2422,\"^48\",1775984614634,536870913]],[\"^7[\",[2422,\"^7;\",\"a2\",536870913]],[\"^7[\",[2422,\"^4P\",2406,536870913]],[\"^7[\",[2422,\"^3V\",2421,536870913]],[\"^7[\",[2422,\"^65\",\"ff\",536870913]],[\"^7[\",[2422,\"^3;\",1775984615139,536870913]],[\"^7[\",[2422,\"^2T\",\"~u69db5fe6-9f47-45fd-bd1d-78cdad7e5894\",536870913]],[\"^7[\",[2422,\"^4<\",181,536870913]],[\"^7[\",[2423,\"^48\",1775984614835,536870913]],[\"^7[\",[2423,\"^7;\",\"a1\",536870913]],[\"^7[\",[2423,\"^4P\",2406,536870913]],[\"^7[\",[2423,\"^3V\",2421,536870913]],[\"^7[\",[2423,\"^65\",\"\",536870913]],[\"^7[\",[2423,\"^3;\",1775984614835,536870913]],[\"^7[\",[2423,\"^2T\",\"~u69db5fe6-6b73-4f8c-a89d-3cca819ee63c\",536870913]],[\"^7[\",[2423,\"^4<\",181,536870913]],[\"^7[\",[2424,\"^48\",1775984614992,536870913]],[\"^7[\",[2424,\"^7;\",\"a0\",536870913]],[\"^7[\",[2424,\"^4P\",2406,536870913]],[\"^7[\",[2424,\"^3V\",2425,536870913]],[\"^7[\",[2424,\"^65\",\"\",536870913]],[\"^7[\",[2424,\"^3;\",1775984614992,536870913]],[\"^7[\",[2424,\"^2T\",\"~u69db5fe6-d898-483f-bc9b-0122ac0cffe1\",536870913]],[\"^7[\",[2424,\"^4<\",181,536870913]],[\"^7[\",[2425,\"^48\",1775984615139,536870913]],[\"^7[\",[2425,\"^7;\",\"a1\",536870913]],[\"^7[\",[2425,\"^4P\",2406,536870913]],[\"^7[\",[2425,\"^3V\",2406,536870913]],[\"^7[\",[2425,\"^65\",\"\",536870913]],[\"^7[\",[2425,\"^3;\",1775984615139,536870913]],[\"^7[\",[2425,\"^2T\",\"~u69db5fe7-2d4d-43a8-b50b-aa6625c817ae\",536870913]],[\"^7[\",[2425,\"^4<\",181,536870913]],[\"^7[\",[2635,\"^48\",1775986630570,536870913]],[\"^7[\",[2635,\"^7;\",\"aE8V\",536870913]],[\"^7[\",[2635,\"^4P\",180,536870913]],[\"^7[\",[2635,\"^3V\",180,536870913]],[\"^7[\",[2635,\"^65\",\"\",536870913]],[\"^7[\",[2635,\"^3;\",1775986630570,536870913]],[\"^7[\",[2635,\"^2T\",\"~u69db67c6-1735-44ed-8ddb-ffe957da8b07\",536870913]],[\"^7[\",[2635,\"^4<\",181,536870913]],[\"^7[\",[2675,\"^48\",1775986661080,536870913]],[\"^7[\",[2675,\"^7;\",\"a9ql\",536870913]],[\"^7[\",[2675,\"^4P\",180,536870913]],[\"^7[\",[2675,\"^3V\",180,536870913]],[\"^7[\",[2675,\"^65\",\"op-sim-1775986575351-717315ac-r1-client-1- add-22\",536870913]],[\"^7[\",[2675,\"^3;\",1775986661080,536870913]],[\"^7[\",[2675,\"^2T\",\"~u69db67e4-a86e-4f22-97b1-4e4fa93c3e7d\",536870913]],[\"^7[\",[2675,\"^4<\",181,536870913]],[\"^7[\",[2858,\"^48\",1775986934158,536870913]],[\"^7[\",[2858,\"^7;\",\"a4rwt\",536870913]],[\"^7[\",[2858,\"^4P\",180,536870913]],[\"^7[\",[2858,\"^3V\",180,536870913]],[\"^7[\",[2858,\"^65\",\"fdafda\",536870913]],[\"^7[\",[2858,\"^3;\",1775986935056,536870913]],[\"^7[\",[2858,\"^2T\",\"~u69db68f6-2eef-4122-a53b-15bb3493c3c9\",536870913]],[\"^7[\",[2858,\"^4<\",181,536870913]],[\"^7[\",[2859,\"^48\",1775986935057,536870913]],[\"^7[\",[2859,\"^7;\",\"a4rwx\",536870913]],[\"^7[\",[2859,\"^4P\",180,536870913]],[\"^7[\",[2859,\"^3V\",180,536870913]],[\"^7[\",[2859,\"^65\",\"fdafdas\",536870913]],[\"^7[\",[2859,\"^3;\",1775986935839,536870913]],[\"^7[\",[2859,\"^2T\",\"~u69db68f7-056f-48fc-82e1-31ac7e3deb31\",536870913]],[\"^7[\",[2859,\"^4<\",181,536870913]],[\"^7[\",[2861,\"^48\",1775987261605,536870918]],[\"^7[\",[2861,\"^7;\",\"aEn\",536871024]],[\"^7[\",[2861,\"^4P\",180,536870918]],[\"^7[\",[2861,\"^3V\",180,536870991]],[\"^7[\",[2861,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- anchor\",536870918]],[\"^7[\",[2861,\"^3;\",1775987261605,536870918]],[\"^7[\",[2861,\"^2T\",\"~u69db6a3d-f8f2-4a9e-96fd-e780d99ad812\",536870918]],[\"^7[\",[2861,\"^4<\",181,536870918]],[\"^7[\",[2863,\"^48\",1775987261694,536870923]],[\"^7[\",[2863,\"^7;\",\"Zy\",536871060]],[\"^7[\",[2863,\"^4P\",180,536870923]],[\"^7[\",[2863,\"^3V\",2861,536871005]],[\"^7[\",[2863,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- client-root\",536870923]],[\"^7[\",[2863,\"^3;\",1775987261694,536870923]],[\"^7[\",[2863,\"^2T\",\"~u69db6a3d-5061-4541-9930-caf3ea98778c\",536870923]],[\"^7[\",[2863,\"^4<\",181,536870923]],[\"^7[\",[2872,\"^48\",1775987263592,536870949]],[\"^7[\",[2872,\"^7;\",\"a0\",536871034]],[\"^7[\",[2872,\"^4P\",180,536870949]],[\"^7[\",[2872,\"^3V\",2885,536870995]],[\"^7[\",[2872,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-7\",536870949]],[\"^7[\",[2872,\"^3;\",1775987263592,536870949]],[\"^7[\",[2872,\"^2T\",\"~u69db6a3f-0e20-455a-b093-e822d860c64d\",536870949]],[\"^7[\",[2872,\"^4<\",181,536870949]],[\"^7[\",[2876,\"^48\",1775987264111,536870960]],[\"^7[\",[2876,\"^7;\",\"aF8V\",536871009]],[\"^7[\",[2876,\"^4P\",180,536870960]],[\"^7[\",[2876,\"^3V\",180,536870960]],[\"^7[\",[2876,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-2- anchor\",536870960]],[\"^7[\",[2876,\"^S\",536870960,536870962]],[\"^7[\",[2876,\"^3;\",1775987264111,536870960]],[\"^7[\",[2876,\"^2T\",\"~u69db6a3f-a5df-45de-987e-37922f7d437a\",536870960]],[\"^7[\",[2876,\"^4<\",181,536870961]],[\"^7[\",[2877,\"^48\",1775987264628,536870965]],[\"^7[\",[2877,\"^7;\",\"aF9\",536870993]],[\"^7[\",[2877,\"^4P\",180,536870965]],[\"^7[\",[2877,\"^3V\",180,536870977]],[\"^7[\",[2877,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-12\",536870965]],[\"^7[\",[2877,\"^3;\",1775987264628,536870965]],[\"^7[\",[2877,\"^2T\",\"~u69db6a40-5752-44bc-98d2-8b1cf8eda3d9\",536870965]],[\"^7[\",[2877,\"^4<\",181,536870965]],[\"^7[\",[2879,\"^48\",1775987265106,536870968]],[\"^7[\",[2879,\"^7;\",\"aI\",536871049]],[\"^7[\",[2879,\"^4P\",180,536870968]],[\"^7[\",[2879,\"^3V\",180,536871001]],[\"^7[\",[2879,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-15\",536870968]],[\"^7[\",[2879,\"^3;\",1775987265106,536870968]],[\"^7[\",[2879,\"^2T\",\"~u69db6a41-2502-40fc-aa36-765e01ede4d2\",536870968]],[\"^7[\",[2879,\"^4<\",181,536870968]],[\"^7[\",[2880,\"^48\",1775987265424,536870970]],[\"^7[\",[2880,\"^7;\",\"aJ\",536870991]],[\"^7[\",[2880,\"^4P\",180,536870970]],[\"^7[\",[2880,\"^3V\",180,536870970]],[\"^7[\",[2880,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-17\",536870970]],[\"^7[\",[2880,\"^3;\",1775987265424,536870970]],[\"^7[\",[2880,\"^2T\",\"~u69db6a41-5578-4848-9e6b-fe923b26fd83\",536870970]],[\"^7[\",[2880,\"^4<\",181,536870970]],[\"^7[\",[2883,\"^48\",1775987266071,536870974]],[\"^7[\",[2883,\"^7;\",\"aFA\",536870989]],[\"^7[\",[2883,\"^4P\",180,536870974]],[\"^7[\",[2883,\"^3V\",180,536870975]],[\"^7[\",[2883,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-21\",536870974]],[\"^7[\",[2883,\"^3;\",1775987266071,536870974]],[\"^7[\",[2883,\"^2T\",\"~u69db6a42-105d-4264-9309-e2bb24f6dad6\",536870974]],[\"^7[\",[2883,\"^4<\",181,536870974]],[\"^7[\",[2884,\"^48\",1775987266746,536870978]],[\"^7[\",[2884,\"^7;\",\"aEZ\",536871005]],[\"^7[\",[2884,\"^4P\",180,536870978]],[\"^7[\",[2884,\"^3V\",180,536870978]],[\"^7[\",[2884,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-25\",536870978]],[\"^7[\",[2884,\"^3;\",1775987266746,536870978]],[\"^7[\",[2884,\"^2T\",\"~u69db6a42-b927-46b8-b395-46dc61a85618\",536870978]],[\"^7[\",[2884,\"^4<\",181,536870978]],[\"^7[\",[2885,\"^48\",1775987267368,536870981]],[\"^7[\",[2885,\"^7;\",\"aIK\",536871088]],[\"^7[\",[2885,\"^4P\",180,536870981]],[\"^7[\",[2885,\"^3V\",180,536871016]],[\"^7[\",[2885,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-29\",536870981]],[\"^7[\",[2885,\"^3;\",1775987267368,536870981]],[\"^7[\",[2885,\"^2T\",\"~u69db6a43-3008-4c2a-92d0-84e803f10abc\",536870981]],[\"^7[\",[2885,\"^4<\",181,536870981]],[\"^7[\",[2886,\"^48\",1775987269082,536870990]],[\"^7[\",[2886,\"^7;\",\"Zz\",536871047]],[\"^7[\",[2886,\"^4P\",180,536870990]],[\"^7[\",[2886,\"^3V\",2861,536871000]],[\"^7[\",[2886,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-38\",536870990]],[\"^7[\",[2886,\"^3;\",1775987269082,536870990]],[\"^7[\",[2886,\"^2T\",\"~u69db6a45-d05a-44d8-8b0b-44f7654bc0ab\",536870990]],[\"^7[\",[2886,\"^4<\",181,536870990]],[\"^7[\",[2887,\"^48\",1775987269532,536870992]],[\"^7[\",[2887,\"^7;\",\"aIl\",536870992]],[\"^7[\",[2887,\"^4P\",180,536870992]],[\"^7[\",[2887,\"^3V\",180,536870992]],[\"^7[\",[2887,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-40\",536870992]],[\"^7[\",[2887,\"^3;\",1775987269532,536870992]],[\"^7[\",[2887,\"^2T\",\"~u69db6a45-59b0-46b1-90e2-475241fc3408\",536870992]],[\"^7[\",[2887,\"^4<\",181,536870992]],[\"^7[\",[2889,\"^48\",1775987270302,536870996]],[\"^7[\",[2889,\"^7;\",\"aH\",536870996]],[\"^7[\",[2889,\"^4P\",180,536870996]],[\"^7[\",[2889,\"^3V\",180,536870996]],[\"^7[\",[2889,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-44\",536870996]],[\"^7[\",[2889,\"^3;\",1775987270302,536870996]],[\"^7[\",[2889,\"^2T\",\"~u69db6a46-0a5e-4c33-baf8-43655a9a292d\",536870996]],[\"^7[\",[2889,\"^4<\",181,536870996]],[\"^7[\",[2890,\"^48\",1775987270507,536870997]],[\"^7[\",[2890,\"^7;\",\"aF\",536871083]],[\"^7[\",[2890,\"^4P\",180,536870997]],[\"^7[\",[2890,\"^3V\",180,536871014]],[\"^7[\",[2890,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-45\",536870997]],[\"^7[\",[2890,\"^3;\",1775987270507,536870997]],[\"^7[\",[2890,\"^2T\",\"~u69db6a46-033b-47c5-b670-6089e9c4c2c7\",536870997]],[\"^7[\",[2890,\"^4<\",181,536870997]],[\"^7[\",[2891,\"^48\",1775987270907,536870999]],[\"^7[\",[2891,\"^7;\",\"aId\",536870999]],[\"^7[\",[2891,\"^4P\",180,536870999]],[\"^7[\",[2891,\"^3V\",180,536870999]],[\"^7[\",[2891,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-47\",536870999]],[\"^7[\",[2891,\"^3;\",1775987270907,536870999]],[\"^7[\",[2891,\"^2T\",\"~u69db6a46-df7a-4161-a844-ae9cfcf643b2\",536870999]],[\"^7[\",[2891,\"^4<\",181,536870999]],[\"^7[\",[2892,\"^48\",1775987297369,536871002]],[\"^7[\",[2892,\"^7;\",\"aK\",536871002]],[\"^7[\",[2892,\"^4P\",180,536871002]],[\"^7[\",[2892,\"^3V\",180,536871002]],[\"^7[\",[2892,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- anchor\",536871002]],[\"^7[\",[2892,\"^3;\",1775987297369,536871002]],[\"^7[\",[2892,\"^2T\",\"~u69db6a61-d820-4c84-89a0-85946a90b669\",536871002]],[\"^7[\",[2892,\"^4<\",181,536871002]],[\"^7[\",[2893,\"^48\",1775987298025,536871006]],[\"^7[\",[2893,\"^7;\",\"aIU\",536871112]],[\"^7[\",[2893,\"^4P\",180,536871006]],[\"^7[\",[2893,\"^3V\",180,536871025]],[\"^7[\",[2893,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-3\",536871006]],[\"^7[\",[2893,\"^3;\",1775987298025,536871006]],[\"^7[\",[2893,\"^2T\",\"~u69db6a62-71d6-424a-b41e-d24d6632d997\",536871006]],[\"^7[\",[2893,\"^4<\",181,536871006]],[\"^7[\",[2894,\"^48\",1775987298244,536871007]],[\"^7[\",[2894,\"^7;\",\"a0\",536871068]],[\"^7[\",[2894,\"^4P\",180,536871007]],[\"^7[\",[2894,\"^3V\",2861,536871008]],[\"^7[\",[2894,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-4\",536871007]],[\"^7[\",[2894,\"^3;\",1775987298244,536871007]],[\"^7[\",[2894,\"^2T\",\"~u69db6a62-5bc1-41b0-ad79-b2fde738395f\",536871007]],[\"^7[\",[2894,\"^4<\",181,536871007]],[\"^7[\",[2895,\"^48\",1775987298656,536871009]],[\"^7[\",[2895,\"^7;\",\"aGl\",536871009]],[\"^7[\",[2895,\"^4P\",180,536871009]],[\"^7[\",[2895,\"^3V\",180,536871009]],[\"^7[\",[2895,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-6\",536871009]],[\"^7[\",[2895,\"^3;\",1775987298656,536871009]],[\"^7[\",[2895,\"^2T\",\"~u69db6a62-0da1-475a-a17e-e058661fe360\",536871009]],[\"^7[\",[2895,\"^4<\",181,536871009]],[\"^7[\",[2896,\"^48\",1775987298853,536871010]],[\"^7[\",[2896,\"^7;\",\"a0\",536871010]],[\"^7[\",[2896,\"^4P\",180,536871010]],[\"^7[\",[2896,\"^3V\",2887,536871010]],[\"^7[\",[2896,\"^65\",\"\",536871010]],[\"^7[\",[2896,\"^3;\",1775987298853,536871010]],[\"^7[\",[2896,\"^2T\",\"~u69db6a62-0a49-4815-b61f-29e29fcda901\",536871010]],[\"^7[\",[2896,\"^4<\",181,536871010]],[\"^7[\",[2897,\"^48\",1775987299264,536871012]],[\"^7[\",[2897,\"^7;\",\"aG\",536871012]],[\"^7[\",[2897,\"^4P\",180,536871012]],[\"^7[\",[2897,\"^3V\",180,536871012]],[\"^7[\",[2897,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-9\",536871012]],[\"^7[\",[2897,\"^3;\",1775987299264,536871012]],[\"^7[\",[2897,\"^2T\",\"~u69db6a63-588e-4241-90ca-98997b80c8a0\",536871012]],[\"^7[\",[2897,\"^4<\",181,536871012]],[\"^7[\",[2898,\"^48\",1775987299832,536871015]],[\"^7[\",[2898,\"^7;\",\"a0V\",536871096]],[\"^7[\",[2898,\"^4P\",180,536871015]],[\"^7[\",[2898,\"^3V\",2885,536871019]],[\"^7[\",[2898,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-12\",536871015]],[\"^7[\",[2898,\"^3;\",1775987299832,536871015]],[\"^7[\",[2898,\"^2T\",\"~u69db6a63-fc25-40a9-aeb8-7952fe7c669b\",536871015]],[\"^7[\",[2898,\"^4<\",181,536871015]],[\"^7[\",[2899,\"^48\",1775987300449,536871017]],[\"^7[\",[2899,\"^7;\",\"aGt\",536871104]],[\"^7[\",[2899,\"^4P\",180,536871017]],[\"^7[\",[2899,\"^3V\",180,536871022]],[\"^7[\",[2899,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-14\",536871017]],[\"^7[\",[2899,\"^3;\",1775987300449,536871017]],[\"^7[\",[2899,\"^2T\",\"~u69db6a64-50d3-4c8e-886f-8611aa7fbd8a\",536871017]],[\"^7[\",[2899,\"^4<\",181,536871017]],[\"^7[\",[2900,\"^48\",1775987304021,536871018]],[\"^7[\",[2900,\"^7;\",\"aHl\",536871114]],[\"^7[\",[2900,\"^4P\",180,536871018]],[\"^7[\",[2900,\"^3V\",180,536871026]],[\"^7[\",[2900,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-15\",536871018]],[\"^7[\",[2900,\"^3;\",1775987304021,536871018]],[\"^7[\",[2900,\"^2T\",\"~u69db6a68-891c-4435-aee5-07830b9e3d93\",536871018]],[\"^7[\",[2900,\"^4<\",181,536871018]],[\"^7[\",[2901,\"^48\",1775987304634,536871020]],[\"^7[\",[2901,\"^7;\",\"aHV\",536871020]],[\"^7[\",[2901,\"^4P\",180,536871020]],[\"^7[\",[2901,\"^3V\",180,536871020]],[\"^7[\",[2901,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-17\",536871020]],[\"^7[\",[2901,\"^3;\",1775987304634,536871020]],[\"^7[\",[2901,\"^2T\",\"~u69db6a68-fe4a-4780-84bf-0555ffdb0933\",536871020]],[\"^7[\",[2901,\"^4<\",181,536871020]],[\"^7[\",[2902,\"^48\",1775987305003,536871021]],[\"^7[\",[2902,\"^7;\",\"ZyV\",536871021]],[\"^7[\",[2902,\"^4P\",180,536871021]],[\"^7[\",[2902,\"^3V\",2861,536871021]],[\"^7[\",[2902,\"^65\",\"\",536871021]],[\"^7[\",[2902,\"^3;\",1775987305003,536871021]],[\"^7[\",[2902,\"^2T\",\"~u69db6a68-b629-4cd9-b732-4c4aba8a2fab\",536871021]],[\"^7[\",[2902,\"^4<\",181,536871021]],[\"^7[\",[2903,\"^48\",1775987305653,536871023]],[\"^7[\",[2903,\"^7;\",\"aGp\",536871023]],[\"^7[\",[2903,\"^4P\",180,536871023]],[\"^7[\",[2903,\"^3V\",180,536871023]],[\"^7[\",[2903,\"^65\",\"op-sim-1775987249544-e63db5e4-r1-client-1- add-20\",536871023]],[\"^7[\",[2903,\"^3;\",1775987305653,536871023]],[\"^7[\",[2903,\"^2T\",\"~u69db6a69-6bd9-43a2-bee4-4cddad5d9814\",536871023]],[\"^7[\",[2903,\"^4<\",181,536871023]],[\"^7[\",[2904,\"^48\",1775987305977,536871024]],[\"^7[\",[2904,\"^7;\",\"aF8l\",536871024]],[\"^7[\",[2904,\"^4P\",180,536871024]],[\"^7[\",[2904,\"^3V\",180,536871024]],[\"^7[\",[2904,\"^65\",\"\",536871024]],[\"^7[\",[2904,\"^3;\",1775987305977,536871024]],[\"^7[\",[2904,\"^2T\",\"~u69db6a69-af10-46ea-af0a-b019f994745d\",536871024]],[\"^7[\",[2904,\"^4<\",181,536871024]]]]]]", :tx-data "[[\"~#datascript/Datom\",[2900,\"~:block/order\",\"a0\",536871114,false]],[\"^0\",[2900,\"^1\",\"aHl\",536871114]],[\"^0\",[2900,\"~:block/parent\",2876,536871026,false]],[\"^0\",[2900,\"^2\",180,536871026]]]"}:debug {:db-before "[\"~#datascript/DB\",[\"^ \",\"~:schema\",[\"^ \",\"~i32\",\"~:logseq.property/priority\",\"~i64\",\"~:logseq.property/ls-type\",\"~i96\",\"~:logseq.property.repeat/repeated?\",\"~i128\",\"~:logseq.property/view-for\",\"~:logseq.property.asset/align\",[\"^ \",\"~:db/ident\",\"^:\",\"~:db/cardinality\",\"~:db.cardinality/one\",\"~:db/index\",true],\"~:logseq.property.view/type.table\",[\"^ \",\"^;\",\"^?\"],\"~:logseq.property/ui-position\",[\"^ \",\"^;\",\"^@\",\"^<\",\"^=\",\"^>\",true],\"~:file/created-at\",[\"^ \"],\"~:logseq.property.repeat/temporal-property\",[\"^ \",\"^;\",\"^B\",\"^<\",\"^=\",\"^>\",true,\"~:db/valueType\",\"~:db.type/ref\"],\"~:logseq.property.user/name\",[\"^ \",\"^;\",\"^E\",\"^<\",\"^=\",\"^>\",true],\"~i1\",\"~:logseq.property.reaction/target\",\"~i33\",\"~:logseq.property/priority.urgent\",\"~i65\",\"~:logseq.property/color.yellow\",\"~i97\",\"~:block/closed-value-property\",\"~:logseq.property.pdf/hl-image\",[\"^ \",\"^;\",\"^M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i129\",\"~:logseq.property.user/avatar\",\"~:logseq.property.fsrs/state\",[\"^ \",\"^;\",\"^P\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.pdf/hl-color\",[\"^ \",\"^;\",\"^Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-initial-schema-version\",[\"^ \",\"^;\",\"^R\"],\"~:block/tx-id\",[\"^ \"],\"~:logseq.property/value\",[\"^ \",\"^;\",\"^T\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.week\",[\"^ \",\"^;\",\"^U\"],\"~i2\",\"~:logseq.kv/db-type\",\"~i34\",\"~:logseq.property/public?\",\"~i66\",\"~:logseq.property.asset/width\",\"~i98\",\"~:logseq.property.reaction/emoji-id\",\"~i130\",\"^E\",\"~:logseq.property.table/sorting\",[\"^ \",\"^;\",\"^12\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/choice-exclusions\",[\"^ \",\"^;\",\"^13\",\"^<\",\"~:db.cardinality/many\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.table/pinned-columns\",[\"^ \",\"^;\",\"^15\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit.day\",[\"^ \",\"^;\",\"^16\"],\"~i3\",\"~:logseq.property.repeat/checked-property\",\"~i35\",\"~:logseq.property.recycle/original-order\",\"~i67\",\"~:logseq.property.asset/remote-metadata\",\"~i99\",\"~:logseq.property/priority.high\",\"~i131\",\"~:logseq.property.asset/size\",\"~:logseq.property.sync/large-title-object\",[\"^ \",\"^;\",\"^1@\",\"^<\",\"^=\",\"^>\",true],\"~i4\",\"~:logseq.class/Pdf-annotation\",\"~i36\",\"~:block/journal-day\",\"~i68\",\"~:logseq.property.view/type.list\",\"~i100\",\"~:logseq.property/color.blue\",\"~i132\",\"~:logseq.property.class/bidirectional-property-title\",\"~:logseq.property/deadline\",[\"^ \",\"^;\",\"^1J\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/type\",[\"^ \",\"^;\",\"^1K\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.year\",[\"^ \",\"^;\",\"^1L\"],\"~:file/content\",[\"^ \"],\"~:user.property/p3-Bhx1XTp3\",[\"^ \",\"^;\",\"^1N\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i5\",\"~:logseq.property.asset/checksum\",\"~i37\",\"~:logseq.property.node/display-type\",\"~i69\",\"~:block/refs\",\"~i101\",\"~:logseq.property.table/ordered-columns\",\"~:logseq.property.view/sort-groups-desc?\",[\"^ \",\"^;\",\"^1V\",\"^<\",\"^=\",\"^>\",true],\"~i133\",\"~:logseq.property.class/hide-from-node\",\"~:logseq.property.table/sized-columns\",[\"^ \",\"^;\",\"^1Y\",\"^<\",\"^=\",\"^>\",true],\"~:block/alias\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^>\",true,\"^;\",\"^1Z\"],\"~:logseq.property.publish/published-url\",[\"^ \",\"^;\",\"^1[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/external-url\",[\"^ \",\"^;\",\"^20\",\"^<\",\"^=\",\"^>\",true],\"~i6\",\"^1Y\",\"^O\",[\"^ \",\"^;\",\"^O\",\"^<\",\"^=\",\"^>\",true],\"~i38\",\"~:logseq.class/Template\",\"~:user.property/p5-q5guozE4\",[\"^ \",\"^;\",\"^23\",\"^C\",\"^D\",\"^>\",true,\"^<\",\"^14\"],\"~i70\",\"^?\",\"~i102\",\"~:logseq.property/background-color\",\"~i134\",\"^B\",\"~:kv/value\",[\"^ \"],\"~:logseq.property/scalar-default-value\",[\"^ \",\"^;\",\"^29\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/type\",[\"^ \",\"^;\",\"^2:\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Asset\",[\"^ \",\"^;\",\"^2;\"],\"^19\",[\"^ \",\"^;\",\"^19\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.class/enable-bidirectional?\",[\"^ \",\"^;\",\"^2<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/color.green\",[\"^ \",\"^;\",\"^2=\"],\"~i7\",\"^1Z\",\"~i39\",\"~:logseq.kv/local-graph-uuid\",\"~i71\",\"~:logseq.property/description\",\"~i103\",\"~:logseq.class/Cards\",\"~i135\",\"~:logseq.property.repeat/recur-unit.minute\",\"~:logseq.property.linked-references/includes\",[\"^ \",\"^;\",\"^2F\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit\",[\"^ \",\"^;\",\"^2G\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/checkbox-display-properties\",[\"^ \",\"^;\",\"^2H\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:block/link\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^2I\",\"^<\",\"^=\"],\"~:logseq.property.view/type\",[\"^ \",\"^;\",\"^2J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.class/Quote-block\",[\"^ \",\"^;\",\"^2K\"],\"~:logseq.property/heading\",[\"^ \",\"^;\",\"^2L\",\"^<\",\"^=\",\"^>\",true],\"~i8\",\"~:logseq.property.view/sort-groups-by-property\",\"~i40\",\"~:logseq.property.pdf/hl-page\",\"~i72\",\"~:logseq.property.recycle/original-page\",\"~i104\",\"~:logseq.class/Page\",\"~:block/uuid\",[\"^ \",\"~:db/unique\",\"~:db.unique/identity\"],\"~i136\",\"~:logseq.property/query\",\"~:logseq.property/color.purple\",[\"^ \",\"^;\",\"^2Y\"],\"~:logseq.property.asset/external-file-name\",[\"^ \",\"^;\",\"^2Z\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.table/hidden-columns\",[\"^ \",\"^;\",\"^2[\",\"^<\",\"^14\",\"^>\",true],\"~:logseq.class/Property\",[\"^ \",\"^;\",\"^30\"],\"~:logseq.kv/graph-git-sha\",[\"^ \",\"^;\",\"^31\"],\"~i9\",\"~:logseq.property/status.doing\",\"~i41\",\"^2H\",\"~i73\",\"~:logseq.property.linked-references/excludes\",\"~i105\",\"^U\",\"^2S\",[\"^ \",\"^;\",\"^2S\"],\"~i137\",\"~:logseq.property.user/email\",\"~:logseq.kv/graph-remote?\",[\"^ \",\"^;\",\"^39\"],\"^2?\",[\"^ \",\"^;\",\"^2?\"],\"~:logseq.kv/graph-rtc-e2ee?\",[\"^ \",\"^;\",\"^3:\"],\"~:block/updated-at\",[\"^ \",\"^>\",true,\"^;\",\"^3;\",\"^<\",\"^=\"],\"^1G\",[\"^ \",\"^;\",\"^1G\"],\"~i10\",\"~:logseq.class/Math-block\",\"~i42\",\"^2Y\",\"~i74\",\"~:logseq.property.history/scalar-value\",\"~i106\",\"~:logseq.property.tldraw/shape\",\"~i138\",\"~:block/collapsed?\",\"^1E\",[\"^ \",\"^;\",\"^1E\"],\"^F\",[\"^ \",\"^;\",\"^F\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i11\",\"~:logseq.property/status.canceled\",\"~:file/size\",[\"^ \"],\"~i43\",\"^P\",\"~:logseq.property/status\",[\"^ \",\"^;\",\"^3I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i75\",\"^1J\",\"~:logseq.property.class/extends\",[\"^ \",\"^;\",\"^3K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i107\",\"~:logseq.property.asset/height\",\"~i139\",\"~:logseq.property.repeat/recur-unit.hour\",\"~:logseq.property/hide-empty-value\",[\"^ \",\"^;\",\"^3P\",\"^<\",\"^=\",\"^>\",true],\"^2M\",[\"^ \",\"^;\",\"^2M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1S\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^1S\",\"^>\",true],\"~i2475\",\"~:user.class/tag2-GCc7Ko89\",\"~i619\",\"~:user.class/tag1-bGi52uW6\",\"~:logseq.class/Card\",[\"^ \",\"^;\",\"^3U\"],\"~:user.property/p2-JBrZedg-\",[\"^ \",\"^;\",\"^3V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^10\",[\"^ \",\"^;\",\"^10\",\"^<\",\"^=\",\"^>\",true],\"~i12\",\"~:block/parent\",\"~i44\",\"^1V\",\"~i76\",\"^2=\",\"~i108\",\"~:logseq.property.code/lang\",\"~i140\",\"~:logseq.property.tldraw/page\",\"~:logseq.property.view/group-by-property\",[\"^ \",\"^;\",\"^43\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/view-context\",[\"^ \",\"^;\",\"^44\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/schema-version\",[\"^ \",\"^;\",\"^45\"],\"^2O\",[\"^ \",\"^;\",\"^2O\",\"^<\",\"^=\",\"^>\",true],\"~i13\",\"^2G\",\"~i45\",\"~:block/created-at\",\"^L\",[\"^ \",\"^C\",\"^D\",\"^;\",\"^L\",\"^>\",true,\"^<\",\"^14\"],\"~i77\",\"~:logseq.property/choice-classes\",\"~i109\",\"~:logseq.property/created-by-ref\",\"^1O\",[\"^ \",\"^;\",\"^1O\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/publishing-public?\",[\"^ \",\"^;\",\"^4=\",\"^<\",\"^=\",\"^>\",true],\"~i141\",\"^31\",\"~:user.property/p10-QpfPhdWj\",[\"^ \",\"^>\",true,\"^C\",\"^D\",\"^<\",\"^=\",\"^;\",\"^4?\"],\"^1Q\",[\"^ \",\"^;\",\"^1Q\",\"^<\",\"^=\",\"^>\",true],\"~i2477\",\"^23\",\"~:logseq.property/status.backlog\",[\"^ \",\"^;\",\"^4A\"],\"~i14\",\"~:logseq.class/Tag\",\"~:file/last-modified-at\",[\"^ \"],\"~i46\",\"^2;\",\"~i78\",\"^2K\",\"~i110\",\"^13\",\"~i142\",\"~:logseq.class/Query\",\"~:logseq.kv/recycle-last-gc-at\",[\"^ \",\"^;\",\"^4J\"],\"~:logseq.property.class/properties\",[\"^ \",\"^;\",\"^4K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/used-template\",[\"^ \",\"^;\",\"^4L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2702\",\"^4?\",\"~:logseq.property/enable-history?\",[\"^ \",\"^;\",\"^4N\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/graph-last-gc-at\",[\"^ \",\"^;\",\"^4O\"],\"~i15\",\"~:logseq.kv/graph-created-at\",\"~i47\",\"~:block/page\",\"~i79\",\"^29\",\"^3=\",[\"^ \",\"^;\",\"^3=\"],\"~i111\",\"^R\",\"~i143\",\"~:logseq.property/choice-checkbox-state\",\"^48\",[\"^ \",\"^>\",true,\"^;\",\"^48\",\"^<\",\"^=\"],\"^3@\",[\"^ \",\"^;\",\"^3@\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/page-tags\",[\"^ \",\"^;\",\"^4X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/scheduled\",[\"^ \",\"^;\",\"^4Y\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Task\",[\"^ \",\"^;\",\"^4Z\"],\"^3D\",[\"^ \",\"^;\",\"^3D\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/last-visit-page\",[\"^ \",\"^;\",\"^4[\",\"^<\",\"^=\",\"^>\",true],\"^42\",[\"^ \",\"^;\",\"^42\",\"^<\",\"^=\",\"^>\",true],\"~i16\",\"~:logseq.property/icon\",\"~i48\",\"^1L\",\"~i80\",\"^4[\",\"~i112\",\"~:logseq.property.journal/title-format\",\"~i144\",\"^3P\",\"~:logseq.property/built-in?\",[\"^ \",\"^;\",\"^57\",\"^<\",\"^=\",\"^>\",true],\"~i17\",\"^2Z\",\"~i49\",\"~:logseq.property/exclude-from-graph-view\",\"~i81\",\"^4L\",\"^1X\",[\"^ \",\"^;\",\"^1X\",\"^<\",\"^=\",\"^>\",true],\"~i113\",\"^2F\",\"~i145\",\"^3;\",\"^35\",[\"^ \",\"^;\",\"^35\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.view/feature-type\",[\"^ \",\"^;\",\"^5>\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Code-block\",[\"^ \",\"^;\",\"^5?\"],\"^4W\",[\"^ \",\"^;\",\"^4W\",\"^<\",\"^=\",\"^>\",true],\"^17\",[\"^ \",\"^;\",\"^17\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2705\",\"^4O\",\"~:logseq.property.asset/resize-metadata\",[\"^ \",\"^;\",\"^5A\",\"^<\",\"^=\",\"^>\",true],\"^5\",[\"^ \",\"^;\",\"^5\",\"^<\",\"^=\",\"^>\",true],\"^2C\",[\"^ \",\"^;\",\"^2C\"],\"^38\",[\"^ \",\"^;\",\"^38\",\"^<\",\"^=\",\"^>\",true],\"^9\",[\"^ \",\"^;\",\"^9\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^Z\",[\"^ \",\"^;\",\"^Z\",\"^<\",\"^=\",\"^>\",true],\"~i18\",\"~:logseq.property.history/block\",\"^2X\",[\"^ \",\"^;\",\"^2X\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i50\",\"^15\",\"~i82\",\"~:logseq.property.repeat/recur-unit.month\",\"~i114\",\"~:logseq.property/status.todo\",\"~i146\",\"^20\",\"~:logseq.property/hide?\",[\"^ \",\"^;\",\"^5J\",\"^<\",\"^=\",\"^>\",true],\"^26\",[\"^ \",\"^;\",\"^26\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-frequency\",[\"^ \",\"^;\",\"^5K\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/default-value\",[\"^ \",\"^;\",\"^5L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1C\",[\"^ \",\"^>\",true,\"^;\",\"^1C\",\"^<\",\"^=\"],\"~i2706\",\"^4J\",\"~:user.property/p1-Ka7c_Z9D\",[\"^ \",\"^;\",\"^5N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/color.red\",[\"^ \",\"^;\",\"^5O\"],\"~i19\",\"^Q\",\"~:logseq.class/Root\",[\"^ \",\"^;\",\"^5Q\"],\"~i51\",\"^1@\",\"~i83\",\"~:logseq.property.history/ref-value\",\"~i115\",\"~:logseq.property.history/property\",\"~i147\",\"~:logseq.property/template-applied-to\",\"^2E\",[\"^ \",\"^;\",\"^2E\"],\"~:logseq.property/empty-placeholder\",[\"^ \",\"^;\",\"^5Y\"],\"^40\",[\"^ \",\"^;\",\"^40\",\"^<\",\"^=\",\"^>\",true],\"~i20\",\"^2<\",\"~i52\",\"^45\",\"~i84\",\"~:logseq.property.table/filters\",\"~i116\",\"~:logseq.property.recycle/original-parent\",\"^4<\",[\"^ \",\"^;\",\"^4<\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-uuid\",[\"^ \",\"^;\",\"^64\"],\"~i148\",\"~:logseq.property/status.in-review\",\"~i2196\",[\"^ \"],\"^1=\",[\"^ \",\"^;\",\"^1=\"],\"~:logseq.property/priority.low\",[\"^ \",\"^;\",\"^68\"],\"~:block/tags\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^69\",\"^>\",true],\"~:block/title\",[\"^ \",\"^>\",true,\"^;\",\"^6:\",\"^<\",\"^=\"],\"~:user.property/p4-Et0d5LxL\",[\"^ \",\"^;\",\"^6;\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^3B\",[\"^ \",\"^;\",\"^3B\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Whiteboard\",[\"^ \",\"^;\",\"^6<\"],\"~i21\",\"^6:\",\"~i53\",\"~:logseq.property/created-from-property\",\"~i85\",\"~:logseq.class/Journal\",\"~i117\",\"~:logseq.property.pdf/hl-value\",\"~i149\",\"^1[\",\"^6?\",[\"^ \",\"^;\",\"^6?\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i565\",\"^5N\",\"^51\",[\"^ \",\"^;\",\"^51\",\"^<\",\"^=\",\"^>\",true],\"^1I\",[\"^ \",\"^;\",\"^1I\",\"^<\",\"^=\",\"^>\",true],\"^H\",[\"^ \",\"^;\",\"^H\"],\"^4:\",[\"^ \",\"^;\",\"^4:\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^1?\",[\"^ \",\"^;\",\"^1?\",\"^<\",\"^=\",\"^>\",true],\"~i22\",\"~:logseq.property/classes\",\"~i54\",\"^4=\",\"~i86\",\"^57\",\"~i118\",\"^43\",\"~i150\",\"~:logseq.property.view/type.gallery\",\"~i182\",\"^64\",\"^6G\",[\"^ \",\"^;\",\"^6G\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/asset\",[\"^ \",\"^;\",\"^6N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^4Q\",[\"^ \",\"^;\",\"^4Q\"],\"~i23\",\"^39\",\"~i55\",\"~:logseq.property/status.done\",\"~:logseq.property/deleted-at\",[\"^ \",\"^;\",\"^6R\",\"^<\",\"^=\",\"^>\",true],\"~i87\",\"^6<\",\"~i119\",\"^5J\",\"~i151\",\"^5Y\",\"~i183\",\"^3:\",\"~:logseq.property/order-list-type\",[\"^ \",\"^;\",\"^6W\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5:\",[\"^ \",\"^;\",\"^5:\",\"^<\",\"^=\",\"^>\",true],\"~i24\",\"^5A\",\"~:logseq.property/deleted-by-ref\",[\"^ \",\"^;\",\"^6Y\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i56\",\"^T\",\"~:logseq.property.pdf/hl-type\",[\"^ \",\"^;\",\"^6[\",\"^<\",\"^=\",\"^>\",true],\"~i88\",\"^:\",\"~i120\",\"^2I\",\"~i152\",\"^4Z\",\"^;\",[\"^ \",\"^2U\",\"^2V\"],\"^6L\",[\"^ \",\"^;\",\"^6L\"],\"^2A\",[\"^ \",\"^;\",\"^2A\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i568\",\"^3V\",\"^5C\",[\"^ \",\"^;\",\"^5C\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i600\",\"^6;\",\"^3M\",[\"^ \",\"^;\",\"^3M\",\"^<\",\"^=\",\"^>\",true],\"^3X\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^3X\",\"^<\",\"^=\"],\"~i25\",\"^6R\",\"~i57\",\"^2:\",\"~i89\",\"^5Q\",\"^2Q\",[\"^ \",\"^;\",\"^2Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i121\",\"^4K\",\"~i153\",\"~:logseq.property/priority.medium\",\"~i569\",\"^1N\",\"^6A\",[\"^ \",\"^;\",\"^6A\"],\"~i26\",\"^3I\",\"~i58\",\"^69\",\"~i90\",\"^2L\",\"^3O\",[\"^ \",\"^;\",\"^3O\"],\"^J\",[\"^ \",\"^;\",\"^J\"],\"~i122\",\"~:block/order\",\"~i154\",\"^1K\",\"^63\",[\"^ \",\"^;\",\"^63\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^7:\",[\"^ \",\"^;\",\"^7:\"],\"^5V\",[\"^ \",\"^;\",\"^5V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^6C\",[\"^ \",\"^;\",\"^6C\",\"^<\",\"^=\",\"^>\",true],\"^4I\",[\"^ \",\"^;\",\"^4I\"],\"~i27\",\"^3U\",\"~i59\",\"^6W\",\"~i91\",\"^M\",\"~i123\",\"^4N\",\"~i155\",\"^4Y\",\"^1;\",[\"^ \",\"^;\",\"^1;\",\"^<\",\"^=\",\"^>\",true],\"^3R\",[\"^ \",\"^;\",\"^3R\"],\"^5F\",[\"^ \",\"^;\",\"^5F\"],\"^7@\",[\"^ \",\"^>\",true,\"^;\",\"^7@\",\"^<\",\"^=\"],\"^3\",[\"^ \",\"^;\",\"^3\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5T\",[\"^ \",\"^;\",\"^5T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.fsrs/due\",[\"^ \",\"^;\",\"^7G\",\"^<\",\"^=\",\"^>\",true],\"~i28\",\"^5?\",\"^32\",[\"^ \",\"^;\",\"^32\"],\"~i60\",\"^@\",\"~i92\",\"^44\",\"~i124\",\"^30\",\"^4S\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^4S\",\"^<\",\"^=\"],\"~i156\",\"^5L\",\"~:block/name\",[\"^ \",\"^>\",true],\"^1A\",[\"^ \",\"^;\",\"^1A\"],\"^1U\",[\"^ \",\"^;\",\"^1U\",\"^<\",\"^=\",\"^>\",true],\"~:file/path\",[\"^ \",\"^2U\",\"^2V\"],\"~i29\",\"^7G\",\"~i61\",\"^16\",\"~i93\",\"^5>\",\"~i125\",\"^4X\",\"~i157\",\"^6Y\",\"^7\",[\"^ \",\"^;\",\"^7\",\"^<\",\"^=\",\"^>\",true],\"^4C\",[\"^ \",\"^;\",\"^4C\"],\"^66\",[\"^ \",\"^;\",\"^66\"],\"^X\",[\"^ \",\"^;\",\"^X\",\"^<\",\"^=\",\"^>\",true],\"~i30\",\"^6N\",\"~i62\",\"^6[\",\"~i94\",\"^68\",\"~i126\",\"^4A\",\"~i158\",\"^3K\",\"^3F\",[\"^ \",\"^;\",\"^3F\"],\"^5X\",[\"^ \",\"^;\",\"^5X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^22\",[\"^ \",\"^;\",\"^22\"],\"^55\",[\"^ \",\"^;\",\"^55\",\"^<\",\"^=\",\"^>\",true],\"^6Q\",[\"^ \",\"^;\",\"^6Q\"],\"~i31\",\"^5O\",\"~i63\",\"^2[\",\"^V\",[\"^ \",\"^;\",\"^V\"],\"~i95\",\"^5K\",\"~i127\",\"^2J\",\"~i159\",\"^12\",\"^61\",[\"^ \",\"^;\",\"^61\",\"^<\",\"^=\",\"^>\",true],\"^3T\",[\"^ \",\"^;\",\"^3T\"],\"^5H\",[\"^ \",\"^;\",\"^5H\"]],\"~:datoms\",[\"~#list\",[[\"~#datascript/Datom\",[1,\"^48\",1775980635495,536870913]],[\"^84\",[1,\"^7M\",\"reaction target\",536870913]],[\"^84\",[1,\"^7@\",\"d3BHM\",536870913]],[\"^84\",[1,\"^69\",124,536870913]],[\"^84\",[1,\"^6:\",\"Reaction target\",536870913]],[\"^84\",[1,\"^3;\",1775980635495,536870913]],[\"^84\",[1,\"^2T\",\"~u00000002-1127-5718-5000-000000000000\",536870913]],[\"^84\",[1,\"^<\",\"^=\",536870913]],[\"^84\",[1,\"^;\",\"^F\",536870913]],[\"^84\",[1,\"^>\",true,536870913]],[\"^84\",[1,\"^C\",\"^D\",536870913]],[\"^84\",[1,\"^57\",true,536870913]],[\"^84\",[1,\"^5J\",true,536870913]],[\"^84\",[1,\"^X\",false,536870913]],[\"^84\",[1,\"^1K\",\"~:node\",536870913]],[\"^84\",[2,\"^;\",\"^V\",536870913]],[\"^84\",[2,\"^28\",\"db\",536870913]],[\"^84\",[3,\"^48\",1775980635492,536870913]],[\"^84\",[3,\"^7M\",\"repeating checked property\",536870913]],[\"^84\",[3,\"^7@\",\"d3BGY\",536870913]],[\"^84\",[3,\"^69\",124,536870913]],[\"^84\",[3,\"^6:\",\"Repeating Checked Property\",536870913]],[\"^84\",[3,\"^3;\",1775980635492,536870913]],[\"^84\",[3,\"^2T\",\"~u00000002-1866-3655-5300-000000000000\",536870913]],[\"^84\",[3,\"^<\",\"^=\",536870913]],[\"^84\",[3,\"^;\",\"^17\",536870913]],[\"^84\",[3,\"^>\",true,536870913]],[\"^84\",[3,\"^C\",\"^D\",536870913]],[\"^84\",[3,\"^57\",true,536870913]],[\"^84\",[3,\"^5J\",true,536870913]],[\"^84\",[3,\"^1K\",\"~:property\",536870913]],[\"^84\",[4,\"^48\",1775980635496,536870913]],[\"^84\",[4,\"^7M\",\"pdf annotation\",536870913]],[\"^84\",[4,\"^69\",14,536870913]],[\"^84\",[4,\"^6:\",\"PDF Annotation\",536870913]],[\"^84\",[4,\"^3;\",1775980635496,536870913]],[\"^84\",[4,\"^2T\",\"~u00000002-5049-5962-0000-000000000000\",536870913]],[\"^84\",[4,\"^;\",\"^1A\",536870913]],[\"^84\",[4,\"^57\",true,536870913]],[\"^84\",[4,\"^3K\",89,536870913]],[\"^84\",[4,\"^1X\",true,536870913]],[\"^84\",[4,\"^4K\",19,536870913]],[\"^84\",[4,\"^4K\",30,536870913]],[\"^84\",[4,\"^4K\",40,536870913]],[\"^84\",[4,\"^4K\",62,536870913]],[\"^84\",[4,\"^4K\",64,536870913]],[\"^84\",[4,\"^4K\",91,536870913]],[\"^84\",[4,\"^4K\",117,536870913]],[\"^84\",[5,\"^48\",1775980635493,536870913]],[\"^84\",[5,\"^7M\",\"file checksum\",536870913]],[\"^84\",[5,\"^7@\",\"d3BGy\",536870913]],[\"^84\",[5,\"^69\",124,536870913]],[\"^84\",[5,\"^6:\",\"File checksum\",536870913]],[\"^84\",[5,\"^3;\",1775980635493,536870913]],[\"^84\",[5,\"^2T\",\"~u00000002-1011-4169-7900-000000000000\",536870913]],[\"^84\",[5,\"^<\",\"^=\",536870913]],[\"^84\",[5,\"^;\",\"^1O\",536870913]],[\"^84\",[5,\"^>\",true,536870913]],[\"^84\",[5,\"^57\",true,536870913]],[\"^84\",[5,\"^5J\",true,536870913]],[\"^84\",[5,\"^X\",false,536870913]],[\"^84\",[5,\"^1K\",\"~:string\",536870913]],[\"^84\",[6,\"^48\",1775980635493,536870913]],[\"^84\",[6,\"^7M\",\"view columns settings\",536870913]],[\"^84\",[6,\"^7@\",\"d3BGp\",536870913]],[\"^84\",[6,\"^69\",124,536870913]],[\"^84\",[6,\"^6:\",\"View columns settings\",536870913]],[\"^84\",[6,\"^3;\",1775980635493,536870913]],[\"^84\",[6,\"^2T\",\"~u00000002-1675-5105-5500-000000000000\",536870913]],[\"^84\",[6,\"^<\",\"^=\",536870913]],[\"^84\",[6,\"^;\",\"^1Y\",536870913]],[\"^84\",[6,\"^>\",true,536870913]],[\"^84\",[6,\"^57\",true,536870913]],[\"^84\",[6,\"^5J\",true,536870913]],[\"^84\",[6,\"^X\",false,536870913]],[\"^84\",[6,\"^1K\",\"~:map\",536870913]],[\"^84\",[7,\"^48\",1775980635487,536870913]],[\"^84\",[7,\"^7M\",\"alias\",536870913]],[\"^84\",[7,\"^7@\",\"d3BFF\",536870913]],[\"^84\",[7,\"^69\",124,536870913]],[\"^84\",[7,\"^6:\",\"Alias\",536870913]],[\"^84\",[7,\"^3;\",1775980635487,536870913]],[\"^84\",[7,\"^2T\",\"~u00000002-2112-6446-9900-000000000000\",536870913]],[\"^84\",[7,\"^<\",\"^14\",536870913]],[\"^84\",[7,\"^;\",\"^1Z\",536870913]],[\"^84\",[7,\"^>\",true,536870913]],[\"^84\",[7,\"^C\",\"^D\",536870913]],[\"^84\",[7,\"^57\",true,536870913]],[\"^84\",[7,\"^X\",true,536870913]],[\"^84\",[7,\"^1K\",\"~:page\",536870913]],[\"^84\",[7,\"^44\",\"^89\",536870913]],[\"^84\",[8,\"^48\",1775980635492,536870913]],[\"^84\",[8,\"^7M\",\"view sort groups by\",536870913]],[\"^84\",[8,\"^7@\",\"d3BGj\",536870913]],[\"^84\",[8,\"^69\",124,536870913]],[\"^84\",[8,\"^6:\",\"View sort groups by\",536870913]],[\"^84\",[8,\"^3;\",1775980635492,536870913]],[\"^84\",[8,\"^2T\",\"~u00000002-6253-4002-1000-000000000000\",536870913]],[\"^84\",[8,\"^<\",\"^=\",536870913]],[\"^84\",[8,\"^;\",\"^2M\",536870913]],[\"^84\",[8,\"^>\",true,536870913]],[\"^84\",[8,\"^C\",\"^D\",536870913]],[\"^84\",[8,\"^57\",true,536870913]],[\"^84\",[8,\"^5J\",true,536870913]],[\"^84\",[8,\"^X\",false,536870913]],[\"^84\",[8,\"^1K\",\"^86\",536870913]],[\"^84\",[9,\"^L\",26,536870913]],[\"^84\",[9,\"^48\",1775980635491,536870913]],[\"^84\",[9,\"^7@\",\"d3BGA\",536870913]],[\"^84\",[9,\"^4S\",26,536870913]],[\"^84\",[9,\"^3X\",26,536870913]],[\"^84\",[9,\"^6:\",\"Doing\",536870913]],[\"^84\",[9,\"^3;\",1775980635491,536870913]],[\"^84\",[9,\"^2T\",\"~u00000002-1840-1229-0800-000000000000\",536870913]],[\"^84\",[9,\"^;\",\"^32\",536870913]],[\"^84\",[9,\"^57\",true,536870913]],[\"^84\",[9,\"^6?\",26,536870913]],[\"^84\",[9,\"^51\",[\"^ \",\"~:type\",\"~:tabler-icon\",\"~:id\",\"InProgress50\"],536870913]],[\"^84\",[10,\"^48\",1775980635496,536870913]],[\"^84\",[10,\"^7M\",\"math\",536870913]],[\"^84\",[10,\"^69\",14,536870913]],[\"^84\",[10,\"^6:\",\"Math\",536870913]],[\"^84\",[10,\"^3;\",1775980635496,536870913]],[\"^84\",[10,\"^2T\",\"~u00000002-2038-9631-2100-000000000000\",536870913]],[\"^84\",[10,\"^;\",\"^3=\",536870913]],[\"^84\",[10,\"^57\",true,536870913]],[\"^84\",[10,\"^3K\",89,536870913]],[\"^84\",[10,\"^1X\",true,536870913]],[\"^84\",[10,\"^4K\",37,536870913]],[\"^84\",[11,\"^L\",26,536870913]],[\"^84\",[11,\"^48\",1775980635491,536870913]],[\"^84\",[11,\"^7@\",\"d3BGD\",536870913]],[\"^84\",[11,\"^4S\",26,536870913]],[\"^84\",[11,\"^3X\",26,536870913]],[\"^84\",[11,\"^6:\",\"Canceled\",536870913]],[\"^84\",[11,\"^3;\",1775980635491,536870913]],[\"^84\",[11,\"^2T\",\"~u00000002-7526-4326-2000-000000000000\",536870913]],[\"^84\",[11,\"^;\",\"^3F\",536870913]],[\"^84\",[11,\"^57\",true,536870913]],[\"^84\",[11,\"^6?\",26,536870913]],[\"^84\",[11,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Cancelled\"],536870913]],[\"^84\",[12,\"^48\",1775980635488,536870913]],[\"^84\",[12,\"^7M\",\"node parent\",536870913]],[\"^84\",[12,\"^7@\",\"d3BFH\",536870913]],[\"^84\",[12,\"^69\",124,536870913]],[\"^84\",[12,\"^6:\",\"Node parent\",536870913]],[\"^84\",[12,\"^3;\",1775980635488,536870913]],[\"^84\",[12,\"^2T\",\"~u00000002-9183-0906-4000-000000000000\",536870913]],[\"^84\",[12,\"^<\",\"^=\",536870913]],[\"^84\",[12,\"^;\",\"^3X\",536870913]],[\"^84\",[12,\"^>\",true,536870913]],[\"^84\",[12,\"^C\",\"^D\",536870913]],[\"^84\",[12,\"^57\",true,536870913]],[\"^84\",[12,\"^5J\",true,536870913]],[\"^84\",[12,\"^X\",false,536870913]],[\"^84\",[12,\"^1K\",\"~:entity\",536870913]],[\"^84\",[13,\"^48\",1775980635491,536870913]],[\"^84\",[13,\"^7M\",\"repeating recur unit\",536870913]],[\"^84\",[13,\"^7@\",\"d3BGP\",536870913]],[\"^84\",[13,\"^69\",124,536870913]],[\"^84\",[13,\"^6:\",\"Repeating recur unit\",536870913]],[\"^84\",[13,\"^3;\",1775980635491,536870913]],[\"^84\",[13,\"^2T\",\"~u00000002-6903-0624-7000-000000000000\",536870913]],[\"^84\",[13,\"^<\",\"^=\",536870913]],[\"^84\",[13,\"^;\",\"^2G\",536870913]],[\"^84\",[13,\"^>\",true,536870913]],[\"^84\",[13,\"^C\",\"^D\",536870913]],[\"^84\",[13,\"^57\",true,536870913]],[\"^84\",[13,\"^5L\",61,536870913]],[\"^84\",[13,\"^3P\",true,536870913]],[\"^84\",[13,\"^X\",false,536870913]],[\"^84\",[13,\"^1K\",\"~:default\",536870913]],[\"^84\",[14,\"^48\",1775980635496,536870913]],[\"^84\",[14,\"^7M\",\"tag\",536870913]],[\"^84\",[14,\"^69\",14,536870913]],[\"^84\",[14,\"^6:\",\"Tag\",536870913]],[\"^84\",[14,\"^3;\",1775980635496,536870913]],[\"^84\",[14,\"^2T\",\"~u00000002-5389-0208-3000-000000000000\",536870913]],[\"^84\",[14,\"^;\",\"^4C\",536870913]],[\"^84\",[14,\"^57\",true,536870913]],[\"^84\",[14,\"^3K\",89,536870913]],[\"^84\",[15,\"^;\",\"^4Q\",536870913]],[\"^84\",[15,\"^28\",1775980635485,536870913]],[\"^84\",[16,\"^48\",1775980635492,536870913]],[\"^84\",[16,\"^7M\",\"icon\",536870913]],[\"^84\",[16,\"^7@\",\"d3BGZ\",536870913]],[\"^84\",[16,\"^69\",124,536870913]],[\"^84\",[16,\"^6:\",\"Icon\",536870913]],[\"^84\",[16,\"^3;\",1775980635492,536870913]],[\"^84\",[16,\"^2T\",\"~u00000002-5891-2328-5000-000000000000\",536870913]],[\"^84\",[16,\"^<\",\"^=\",536870913]],[\"^84\",[16,\"^;\",\"^51\",536870913]],[\"^84\",[16,\"^>\",true,536870913]],[\"^84\",[16,\"^57\",true,536870913]],[\"^84\",[16,\"^1K\",\"^88\",536870913]],[\"^84\",[17,\"^48\",1775980635493,536870913]],[\"^84\",[17,\"^7M\",\"external file name\",536870913]],[\"^84\",[17,\"^7@\",\"d3BGu\",536870913]],[\"^84\",[17,\"^69\",124,536870913]],[\"^84\",[17,\"^6:\",\"External file name\",536870913]],[\"^84\",[17,\"^3;\",1775980635493,536870913]],[\"^84\",[17,\"^2T\",\"~u00000002-3995-2972-0000-000000000000\",536870913]],[\"^84\",[17,\"^<\",\"^=\",536870913]],[\"^84\",[17,\"^;\",\"^2Z\",536870913]],[\"^84\",[17,\"^>\",true,536870913]],[\"^84\",[17,\"^57\",true,536870913]],[\"^84\",[17,\"^5J\",true,536870913]],[\"^84\",[17,\"^X\",false,536870913]],[\"^84\",[17,\"^1K\",\"^87\",536870913]],[\"^84\",[18,\"^48\",1775980635494,536870913]],[\"^84\",[18,\"^7M\",\"history block\",536870913]],[\"^84\",[18,\"^7@\",\"d3BHB\",536870913]],[\"^84\",[18,\"^69\",124,536870913]],[\"^84\",[18,\"^6:\",\"History block\",536870913]],[\"^84\",[18,\"^3;\",1775980635494,536870913]],[\"^84\",[18,\"^2T\",\"~u00000002-1142-5541-6000-000000000000\",536870913]],[\"^84\",[18,\"^<\",\"^=\",536870913]],[\"^84\",[18,\"^;\",\"^5C\",536870913]],[\"^84\",[18,\"^>\",true,536870913]],[\"^84\",[18,\"^C\",\"^D\",536870913]],[\"^84\",[18,\"^57\",true,536870913]],[\"^84\",[18,\"^5J\",true,536870913]],[\"^84\",[18,\"^1K\",\"^8=\",536870913]],[\"^84\",[19,\"^48\",1775980635490,536870913]],[\"^84\",[19,\"^7M\",\"annotation color\",536870913]],[\"^84\",[19,\"^7@\",\"d3BFo\",536870913]],[\"^84\",[19,\"^69\",124,536870913]],[\"^84\",[19,\"^6:\",\"Annotation color\",536870913]],[\"^84\",[19,\"^3;\",1775980635490,536870913]],[\"^84\",[19,\"^2T\",\"~u00000002-6747-9388-7000-000000000000\",536870913]],[\"^84\",[19,\"^<\",\"^=\",536870913]],[\"^84\",[19,\"^;\",\"^Q\",536870913]],[\"^84\",[19,\"^>\",true,536870913]],[\"^84\",[19,\"^C\",\"^D\",536870913]],[\"^84\",[19,\"^57\",true,536870913]],[\"^84\",[19,\"^5J\",true,536870913]],[\"^84\",[19,\"^1K\",\"^8>\",536870913]],[\"^84\",[20,\"^48\",1775980635489,536870913]],[\"^84\",[20,\"^7M\",\"enable bidirectional properties\",536870913]],[\"^84\",[20,\"^7@\",\"d3BFb\",536870913]],[\"^84\",[20,\"^69\",124,536870913]],[\"^84\",[20,\"^6:\",\"Enable bidirectional properties\",536870913]],[\"^84\",[20,\"^3;\",1775980635489,536870913]],[\"^84\",[20,\"^2T\",\"~u00000002-2917-8613-8000-000000000000\",536870913]],[\"^84\",[20,\"^<\",\"^=\",536870913]],[\"^84\",[20,\"^;\",\"^2<\",536870913]],[\"^84\",[20,\"^>\",true,536870913]],[\"^84\",[20,\"^57\",true,536870913]],[\"^84\",[20,\"^2A\",169,536870913]],[\"^84\",[20,\"^X\",true,536870913]],[\"^84\",[20,\"^1K\",\"~:checkbox\",536870913]],[\"^84\",[20,\"^44\",\"~:class\",536870913]],[\"^84\",[21,\"^48\",1775980635488,536870913]],[\"^84\",[21,\"^7M\",\"node title\",536870913]],[\"^84\",[21,\"^7@\",\"d3BFN\",536870913]],[\"^84\",[21,\"^69\",124,536870913]],[\"^84\",[21,\"^6:\",\"Node title\",536870913]],[\"^84\",[21,\"^3;\",1775980635488,536870913]],[\"^84\",[21,\"^2T\",\"~u00000002-7104-4568-4000-000000000000\",536870913]],[\"^84\",[21,\"^<\",\"^=\",536870913]],[\"^84\",[21,\"^;\",\"^6:\",536870913]],[\"^84\",[21,\"^>\",true,536870913]],[\"^84\",[21,\"^57\",true,536870913]],[\"^84\",[21,\"^5J\",true,536870913]],[\"^84\",[21,\"^X\",false,536870913]],[\"^84\",[21,\"^1K\",\"^87\",536870913]],[\"^84\",[22,\"^48\",1775980635487,536870913]],[\"^84\",[22,\"^7M\",\"property classes\",536870913]],[\"^84\",[22,\"^7@\",\"d3BFD\",536870913]],[\"^84\",[22,\"^69\",124,536870913]],[\"^84\",[22,\"^6:\",\"Property classes\",536870913]],[\"^84\",[22,\"^3;\",1775980635487,536870913]],[\"^84\",[22,\"^2T\",\"~u00000002-9137-5048-6000-000000000000\",536870913]],[\"^84\",[22,\"^<\",\"^14\",536870913]],[\"^84\",[22,\"^;\",\"^6G\",536870913]],[\"^84\",[22,\"^>\",true,536870913]],[\"^84\",[22,\"^C\",\"^D\",536870913]],[\"^84\",[22,\"^57\",true,536870913]],[\"^84\",[22,\"^5J\",true,536870913]],[\"^84\",[22,\"^X\",false,536870913]],[\"^84\",[22,\"^1K\",\"^8=\",536870913]],[\"^84\",[23,\"^;\",\"^39\",536870913]],[\"^84\",[23,\"^28\",true,536870913]],[\"^84\",[24,\"^48\",1775980635494,536870913]],[\"^84\",[24,\"^7M\",\"asset resize metadata\",536870913]],[\"^84\",[24,\"^7@\",\"d3BH2\",536870913]],[\"^84\",[24,\"^69\",124,536870913]],[\"^84\",[24,\"^6:\",\"Asset resize metadata\",536870913]],[\"^84\",[24,\"^3;\",1775980635494,536870913]],[\"^84\",[24,\"^2T\",\"~u00000002-1297-5230-5500-000000000000\",536870913]],[\"^84\",[24,\"^<\",\"^=\",536870913]],[\"^84\",[24,\"^;\",\"^5A\",536870913]],[\"^84\",[24,\"^>\",true,536870913]],[\"^84\",[24,\"^57\",true,536870913]],[\"^84\",[24,\"^5J\",true,536870913]],[\"^84\",[24,\"^X\",false,536870913]],[\"^84\",[24,\"^1K\",\"^88\",536870913]],[\"^84\",[25,\"^48\",1775980635495,536870913]],[\"^84\",[25,\"^7M\",\"deleted at\",536870913]],[\"^84\",[25,\"^7@\",\"d3BHG\",536870913]],[\"^84\",[25,\"^69\",124,536870913]],[\"^84\",[25,\"^6:\",\"Deleted at\",536870913]],[\"^84\",[25,\"^3;\",1775980635495,536870913]],[\"^84\",[25,\"^2T\",\"~u00000002-6852-1375-1000-000000000000\",536870913]],[\"^84\",[25,\"^<\",\"^=\",536870913]],[\"^84\",[25,\"^;\",\"^6R\",536870913]],[\"^84\",[25,\"^>\",true,536870913]],[\"^84\",[25,\"^57\",true,536870913]],[\"^84\",[25,\"^5J\",true,536870913]],[\"^84\",[25,\"^X\",false,536870913]],[\"^84\",[25,\"^1K\",\"~:datetime\",536870913]],[\"^84\",[26,\"^48\",1775980635491,536870913]],[\"^84\",[26,\"^7M\",\"status\",536870913]],[\"^84\",[26,\"^7@\",\"d3BG7\",536870913]],[\"^84\",[26,\"^69\",124,536870913]],[\"^84\",[26,\"^6:\",\"Status\",536870913]],[\"^84\",[26,\"^3;\",1775980635491,536870913]],[\"^84\",[26,\"^2T\",\"~u00000002-9072-1685-3000-000000000000\",536870913]],[\"^84\",[26,\"^<\",\"^=\",536870913]],[\"^84\",[26,\"^;\",\"^3I\",536870913]],[\"^84\",[26,\"^>\",true,536870913]],[\"^84\",[26,\"^C\",\"^D\",536870913]],[\"^84\",[26,\"^57\",true,536870913]],[\"^84\",[26,\"^5L\",114,536870913]],[\"^84\",[26,\"^4N\",true,536870913]],[\"^84\",[26,\"^3P\",true,536870913]],[\"^84\",[26,\"^X\",true,536870913]],[\"^84\",[26,\"^1K\",\"^8>\",536870913]],[\"^84\",[26,\"^@\",\"~:block-left\",536870913]],[\"^84\",[27,\"^48\",1775980635496,536870913]],[\"^84\",[27,\"^7M\",\"card\",536870913]],[\"^84\",[27,\"^69\",14,536870913]],[\"^84\",[27,\"^6:\",\"Card\",536870913]],[\"^84\",[27,\"^3;\",1775980635496,536870913]],[\"^84\",[27,\"^2T\",\"~u00000002-1358-2811-0900-000000000000\",536870913]],[\"^84\",[27,\"^;\",\"^3U\",536870913]],[\"^84\",[27,\"^57\",true,536870913]],[\"^84\",[27,\"^3K\",89,536870913]],[\"^84\",[27,\"^4K\",29,536870913]],[\"^84\",[27,\"^4K\",43,536870913]],[\"^84\",[28,\"^48\",1775980635496,536870913]],[\"^84\",[28,\"^7M\",\"code\",536870913]],[\"^84\",[28,\"^69\",14,536870913]],[\"^84\",[28,\"^6:\",\"Code\",536870913]],[\"^84\",[28,\"^3;\",1775980635496,536870913]],[\"^84\",[28,\"^2T\",\"~u00000002-1454-9866-4100-000000000000\",536870913]],[\"^84\",[28,\"^;\",\"^5?\",536870913]],[\"^84\",[28,\"^57\",true,536870913]],[\"^84\",[28,\"^3K\",89,536870913]],[\"^84\",[28,\"^1X\",true,536870913]],[\"^84\",[28,\"^4K\",37,536870913]],[\"^84\",[28,\"^4K\",108,536870913]],[\"^84\",[29,\"^48\",1775980635494,536870913]],[\"^84\",[29,\"^7M\",\"due\",536870913]],[\"^84\",[29,\"^7@\",\"d3BH4\",536870913]],[\"^84\",[29,\"^69\",124,536870913]],[\"^84\",[29,\"^6:\",\"Due\",536870913]],[\"^84\",[29,\"^3;\",1775980635494,536870913]],[\"^84\",[29,\"^2T\",\"~u00000002-1089-0805-4900-000000000000\",536870913]],[\"^84\",[29,\"^<\",\"^=\",536870913]],[\"^84\",[29,\"^;\",\"^7G\",536870913]],[\"^84\",[29,\"^>\",true,536870913]],[\"^84\",[29,\"^57\",true,536870913]],[\"^84\",[29,\"^5J\",false,536870913]],[\"^84\",[29,\"^X\",false,536870913]],[\"^84\",[29,\"^1K\",\"^8A\",536870913]],[\"^84\",[30,\"^48\",1775980635490,536870913]],[\"^84\",[30,\"^7M\",\"asset\",536870913]],[\"^84\",[30,\"^7@\",\"d3BFl\",536870913]],[\"^84\",[30,\"^69\",124,536870913]],[\"^84\",[30,\"^6:\",\"Asset\",536870913]],[\"^84\",[30,\"^3;\",1775980635490,536870913]],[\"^84\",[30,\"^2T\",\"~u00000002-8768-5679-0000-000000000000\",536870913]],[\"^84\",[30,\"^<\",\"^=\",536870913]],[\"^84\",[30,\"^;\",\"^6N\",536870913]],[\"^84\",[30,\"^>\",true,536870913]],[\"^84\",[30,\"^C\",\"^D\",536870913]],[\"^84\",[30,\"^57\",true,536870913]],[\"^84\",[30,\"^5J\",true,536870913]],[\"^84\",[30,\"^1K\",\"^8=\",536870913]],[\"^84\",[31,\"^L\",19,536870913]],[\"^84\",[31,\"^48\",1775980635490,536870913]],[\"^84\",[31,\"^7@\",\"d3BFq\",536870913]],[\"^84\",[31,\"^4S\",19,536870913]],[\"^84\",[31,\"^3X\",19,536870913]],[\"^84\",[31,\"^6:\",\"red\",536870913]],[\"^84\",[31,\"^3;\",1775980635490,536870913]],[\"^84\",[31,\"^2T\",\"~u00000002-4136-2216-2000-000000000000\",536870913]],[\"^84\",[31,\"^;\",\"^5O\",536870913]],[\"^84\",[31,\"^57\",true,536870913]],[\"^84\",[31,\"^6?\",19,536870913]],[\"^84\",[32,\"^48\",1775980635491,536870913]],[\"^84\",[32,\"^7M\",\"priority\",536870913]],[\"^84\",[32,\"^7@\",\"d3BGE\",536870913]],[\"^84\",[32,\"^69\",124,536870913]],[\"^84\",[32,\"^6:\",\"Priority\",536870913]],[\"^84\",[32,\"^3;\",1775980635491,536870913]],[\"^84\",[32,\"^2T\",\"~u00000002-2392-2841-1000-000000000000\",536870913]],[\"^84\",[32,\"^<\",\"^=\",536870913]],[\"^84\",[32,\"^;\",\"^3\",536870913]],[\"^84\",[32,\"^>\",true,536870913]],[\"^84\",[32,\"^C\",\"^D\",536870913]],[\"^84\",[32,\"^57\",true,536870913]],[\"^84\",[32,\"^4N\",true,536870913]],[\"^84\",[32,\"^3P\",true,536870913]],[\"^84\",[32,\"^X\",true,536870913]],[\"^84\",[32,\"^1K\",\"^8>\",536870913]],[\"^84\",[32,\"^@\",\"^8B\",536870913]],[\"^84\",[33,\"^L\",32,536870913]],[\"^84\",[33,\"^48\",1775980635491,536870913]],[\"^84\",[33,\"^7@\",\"d3BGI\",536870913]],[\"^84\",[33,\"^4S\",32,536870913]],[\"^84\",[33,\"^3X\",32,536870913]],[\"^84\",[33,\"^6:\",\"Urgent\",536870913]],[\"^84\",[33,\"^3;\",1775980635491,536870913]],[\"^84\",[33,\"^2T\",\"~u00000002-1996-4346-6700-000000000000\",536870913]],[\"^84\",[33,\"^;\",\"^H\",536870913]],[\"^84\",[33,\"^57\",true,536870913]],[\"^84\",[33,\"^6?\",32,536870913]],[\"^84\",[33,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlUrgent\"],536870913]],[\"^84\",[34,\"^48\",1775980635487,536870913]],[\"^84\",[34,\"^7M\",\"property public?\",536870913]],[\"^84\",[34,\"^7@\",\"d3BFA\",536870913]],[\"^84\",[34,\"^69\",124,536870913]],[\"^84\",[34,\"^6:\",\"Property public?\",536870913]],[\"^84\",[34,\"^3;\",1775980635487,536870913]],[\"^84\",[34,\"^2T\",\"~u00000002-1843-0851-4900-000000000000\",536870913]],[\"^84\",[34,\"^<\",\"^=\",536870913]],[\"^84\",[34,\"^;\",\"^X\",536870913]],[\"^84\",[34,\"^>\",true,536870913]],[\"^84\",[34,\"^57\",true,536870913]],[\"^84\",[34,\"^5J\",true,536870913]],[\"^84\",[34,\"^1K\",\"^8?\",536870913]],[\"^84\",[35,\"^48\",1775980635495,536870913]],[\"^84\",[35,\"^7M\",\"recycle original order\",536870913]],[\"^84\",[35,\"^7@\",\"d3BHK\",536870913]],[\"^84\",[35,\"^69\",124,536870913]],[\"^84\",[35,\"^6:\",\"Recycle original order\",536870913]],[\"^84\",[35,\"^3;\",1775980635495,536870913]],[\"^84\",[35,\"^2T\",\"~u00000002-8699-2537-0000-000000000000\",536870913]],[\"^84\",[35,\"^<\",\"^=\",536870913]],[\"^84\",[35,\"^;\",\"^19\",536870913]],[\"^84\",[35,\"^>\",true,536870913]],[\"^84\",[35,\"^57\",true,536870913]],[\"^84\",[35,\"^5J\",true,536870913]],[\"^84\",[35,\"^X\",false,536870913]],[\"^84\",[35,\"^1K\",\"^87\",536870913]],[\"^84\",[36,\"^48\",1775980635488,536870913]],[\"^84\",[36,\"^7M\",\"journal date\",536870913]],[\"^84\",[36,\"^7@\",\"d3BFP\",536870913]],[\"^84\",[36,\"^69\",124,536870913]],[\"^84\",[36,\"^6:\",\"Journal date\",536870913]],[\"^84\",[36,\"^3;\",1775980635488,536870913]],[\"^84\",[36,\"^2T\",\"~u00000002-1457-4836-6000-000000000000\",536870913]],[\"^84\",[36,\"^<\",\"^=\",536870913]],[\"^84\",[36,\"^;\",\"^1C\",536870913]],[\"^84\",[36,\"^>\",true,536870913]],[\"^84\",[36,\"^57\",true,536870913]],[\"^84\",[36,\"^5J\",true,536870913]],[\"^84\",[36,\"^X\",false,536870913]],[\"^84\",[36,\"^1K\",\"~:raw-number\",536870913]],[\"^84\",[37,\"^48\",1775980635488,536870913]],[\"^84\",[37,\"^7M\",\"node display type\",536870913]],[\"^84\",[37,\"^7@\",\"d3BFS\",536870913]],[\"^84\",[37,\"^69\",124,536870913]],[\"^84\",[37,\"^6:\",\"Node Display Type\",536870913]],[\"^84\",[37,\"^3;\",1775980635488,536870913]],[\"^84\",[37,\"^2T\",\"~u00000002-4424-4618-9000-000000000000\",536870913]],[\"^84\",[37,\"^<\",\"^=\",536870913]],[\"^84\",[37,\"^;\",\"^1Q\",536870913]],[\"^84\",[37,\"^>\",true,536870913]],[\"^84\",[37,\"^57\",true,536870913]],[\"^84\",[37,\"^5J\",true,536870913]],[\"^84\",[37,\"^X\",false,536870913]],[\"^84\",[37,\"^1K\",\"~:keyword\",536870913]],[\"^84\",[37,\"^44\",\"~:block\",536870913]],[\"^84\",[38,\"^48\",1775980635496,536870913]],[\"^84\",[38,\"^7M\",\"template\",536870913]],[\"^84\",[38,\"^69\",14,536870913]],[\"^84\",[38,\"^6:\",\"Template\",536870913]],[\"^84\",[38,\"^3;\",1775980635496,536870913]],[\"^84\",[38,\"^2T\",\"~u00000002-1720-8548-4600-000000000000\",536870913]],[\"^84\",[38,\"^;\",\"^22\",536870913]],[\"^84\",[38,\"^57\",true,536870913]],[\"^84\",[38,\"^3K\",89,536870913]],[\"^84\",[38,\"^4K\",147,536870913]],[\"^84\",[39,\"^;\",\"^2?\",536870913]],[\"^84\",[39,\"^28\",\"~u00000000-4735-4d82-94bb-c9bfb8039105\",536870913]],[\"^84\",[40,\"^48\",1775980635490,536870913]],[\"^84\",[40,\"^7M\",\"annotation page\",536870913]],[\"^84\",[40,\"^7@\",\"d3BFu\",536870913]],[\"^84\",[40,\"^69\",124,536870913]],[\"^84\",[40,\"^6:\",\"Annotation page\",536870913]],[\"^84\",[40,\"^3;\",1775980635490,536870913]],[\"^84\",[40,\"^2T\",\"~u00000002-7532-8459-6000-000000000000\",536870913]],[\"^84\",[40,\"^<\",\"^=\",536870913]],[\"^84\",[40,\"^;\",\"^2O\",536870913]],[\"^84\",[40,\"^>\",true,536870913]],[\"^84\",[40,\"^57\",true,536870913]],[\"^84\",[40,\"^5J\",true,536870913]],[\"^84\",[40,\"^1K\",\"^8C\",536870913]],[\"^84\",[41,\"^48\",1775980635491,536870913]],[\"^84\",[41,\"^7M\",\"properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^7@\",\"d3BG6\",536870913]],[\"^84\",[41,\"^69\",124,536870913]],[\"^84\",[41,\"^6:\",\"Properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^3;\",1775980635491,536870913]],[\"^84\",[41,\"^2T\",\"~u00000002-3215-3256-9000-000000000000\",536870913]],[\"^84\",[41,\"^<\",\"^14\",536870913]],[\"^84\",[41,\"^;\",\"^2H\",536870913]],[\"^84\",[41,\"^>\",true,536870913]],[\"^84\",[41,\"^C\",\"^D\",536870913]],[\"^84\",[41,\"^57\",true,536870913]],[\"^84\",[41,\"^5J\",true,536870913]],[\"^84\",[41,\"^1K\",\"^86\",536870913]],[\"^84\",[42,\"^L\",19,536870913]],[\"^84\",[42,\"^48\",1775980635490,536870913]],[\"^84\",[42,\"^7@\",\"d3BFt\",536870913]],[\"^84\",[42,\"^4S\",19,536870913]],[\"^84\",[42,\"^3X\",19,536870913]],[\"^84\",[42,\"^6:\",\"purple\",536870913]],[\"^84\",[42,\"^3;\",1775980635490,536870913]],[\"^84\",[42,\"^2T\",\"~u00000002-1104-3848-5600-000000000000\",536870913]],[\"^84\",[42,\"^;\",\"^2Y\",536870913]],[\"^84\",[42,\"^57\",true,536870913]],[\"^84\",[42,\"^6?\",19,536870913]],[\"^84\",[43,\"^48\",1775980635494,536870913]],[\"^84\",[43,\"^7M\",\"state\",536870913]],[\"^84\",[43,\"^7@\",\"d3BH5\",536870913]],[\"^84\",[43,\"^69\",124,536870913]],[\"^84\",[43,\"^6:\",\"State\",536870913]],[\"^84\",[43,\"^3;\",1775980635494,536870913]],[\"^84\",[43,\"^2T\",\"~u00000002-1165-1650-8700-000000000000\",536870913]],[\"^84\",[43,\"^<\",\"^=\",536870913]],[\"^84\",[43,\"^;\",\"^P\",536870913]],[\"^84\",[43,\"^>\",true,536870913]],[\"^84\",[43,\"^57\",true,536870913]],[\"^84\",[43,\"^5J\",false,536870913]],[\"^84\",[43,\"^X\",false,536870913]],[\"^84\",[43,\"^1K\",\"^88\",536870913]],[\"^84\",[44,\"^48\",1775980635492,536870913]],[\"^84\",[44,\"^7M\",\"view sort groups desc\",536870913]],[\"^84\",[44,\"^7@\",\"d3BGk\",536870913]],[\"^84\",[44,\"^69\",124,536870913]],[\"^84\",[44,\"^6:\",\"View sort groups DESC\",536870913]],[\"^84\",[44,\"^3;\",1775980635492,536870913]],[\"^84\",[44,\"^2T\",\"~u00000002-1081-4073-8700-000000000000\",536870913]],[\"^84\",[44,\"^<\",\"^=\",536870913]],[\"^84\",[44,\"^;\",\"^1V\",536870913]],[\"^84\",[44,\"^>\",true,536870913]],[\"^84\",[44,\"^57\",true,536870913]],[\"^84\",[44,\"^5J\",true,536870913]],[\"^84\",[44,\"^X\",false,536870913]],[\"^84\",[44,\"^29\",true,536870913]],[\"^84\",[44,\"^1K\",\"^8?\",536870913]],[\"^84\",[45,\"^48\",1775980635488,536870913]],[\"^84\",[45,\"^7M\",\"node created at\",536870913]],[\"^84\",[45,\"^7@\",\"d3BFQ\",536870913]],[\"^84\",[45,\"^69\",124,536870913]],[\"^84\",[45,\"^6:\",\"Node created at\",536870913]],[\"^84\",[45,\"^3;\",1775980635488,536870913]],[\"^84\",[45,\"^2T\",\"~u00000002-1440-0150-0000-000000000000\",536870913]],[\"^84\",[45,\"^<\",\"^=\",536870913]],[\"^84\",[45,\"^;\",\"^48\",536870913]],[\"^84\",[45,\"^>\",true,536870913]],[\"^84\",[45,\"^57\",true,536870913]],[\"^84\",[45,\"^5J\",true,536870913]],[\"^84\",[45,\"^X\",false,536870913]],[\"^84\",[45,\"^1K\",\"^8A\",536870913]],[\"^84\",[46,\"^48\",1775980635496,536870913]],[\"^84\",[46,\"^7M\",\"asset\",536870913]],[\"^84\",[46,\"^69\",14,536870913]],[\"^84\",[46,\"^6:\",\"Asset\",536870913]],[\"^84\",[46,\"^3;\",1775980635496,536870913]],[\"^84\",[46,\"^2T\",\"~u00000002-7975-0297-0000-000000000000\",536870913]],[\"^84\",[46,\"^;\",\"^2;\",536870913]],[\"^84\",[46,\"^57\",true,536870913]],[\"^84\",[46,\"^3K\",89,536870913]],[\"^84\",[46,\"^1X\",true,536870913]],[\"^84\",[46,\"^4K\",5,536870913]],[\"^84\",[46,\"^4K\",57,536870913]],[\"^84\",[46,\"^4K\",131,536870913]],[\"^84\",[46,\"^2J\",150,536870913]],[\"^84\",[47,\"^48\",1775980635488,536870913]],[\"^84\",[47,\"^7M\",\"node page\",536870913]],[\"^84\",[47,\"^7@\",\"d3BFK\",536870913]],[\"^84\",[47,\"^69\",124,536870913]],[\"^84\",[47,\"^6:\",\"Node page\",536870913]],[\"^84\",[47,\"^3;\",1775980635488,536870913]],[\"^84\",[47,\"^2T\",\"~u00000002-8223-1410-8000-000000000000\",536870913]],[\"^84\",[47,\"^<\",\"^=\",536870913]],[\"^84\",[47,\"^;\",\"^4S\",536870913]],[\"^84\",[47,\"^>\",true,536870913]],[\"^84\",[47,\"^C\",\"^D\",536870913]],[\"^84\",[47,\"^57\",true,536870913]],[\"^84\",[47,\"^5J\",true,536870913]],[\"^84\",[47,\"^X\",false,536870913]],[\"^84\",[47,\"^1K\",\"^8=\",536870913]],[\"^84\",[48,\"^L\",13,536870913]],[\"^84\",[48,\"^48\",1775980635492,536870913]],[\"^84\",[48,\"^7@\",\"d3BGV\",536870913]],[\"^84\",[48,\"^4S\",13,536870913]],[\"^84\",[48,\"^3X\",13,536870913]],[\"^84\",[48,\"^6:\",\"Year\",536870913]],[\"^84\",[48,\"^3;\",1775980635492,536870913]],[\"^84\",[48,\"^2T\",\"~u00000002-1520-4385-2400-000000000000\",536870913]],[\"^84\",[48,\"^;\",\"^1L\",536870913]],[\"^84\",[48,\"^57\",true,536870913]],[\"^84\",[48,\"^6?\",13,536870913]],[\"^84\",[49,\"^48\",1775980635492,536870913]],[\"^84\",[49,\"^7M\",\"excluded from graph view?\",536870913]],[\"^84\",[49,\"^7@\",\"d3BGc\",536870913]],[\"^84\",[49,\"^69\",124,536870913]],[\"^84\",[49,\"^6:\",\"Excluded from Graph view?\",536870913]],[\"^84\",[49,\"^3;\",1775980635492,536870913]],[\"^84\",[49,\"^2T\",\"~u00000002-4524-3306-5000-000000000000\",536870913]],[\"^84\",[49,\"^<\",\"^=\",536870913]],[\"^84\",[49,\"^;\",\"^5:\",536870913]],[\"^84\",[49,\"^>\",true,536870913]],[\"^84\",[49,\"^57\",true,536870913]],[\"^84\",[49,\"^5J\",true,536870913]],[\"^84\",[49,\"^X\",true,536870913]],[\"^84\",[49,\"^1K\",\"^8?\",536870913]],[\"^84\",[49,\"^44\",\"^89\",536870913]],[\"^84\",[50,\"^48\",1775980635493,536870913]],[\"^84\",[50,\"^7M\",\"table view pinned columns\",536870913]],[\"^84\",[50,\"^7@\",\"d3BGq\",536870913]],[\"^84\",[50,\"^69\",124,536870913]],[\"^84\",[50,\"^6:\",\"Table view pinned columns\",536870913]],[\"^84\",[50,\"^3;\",1775980635493,536870913]],[\"^84\",[50,\"^2T\",\"~u00000002-2673-7513-8000-000000000000\",536870913]],[\"^84\",[50,\"^<\",\"^14\",536870913]],[\"^84\",[50,\"^;\",\"^15\",536870913]],[\"^84\",[50,\"^>\",true,536870913]],[\"^84\",[50,\"^C\",\"^D\",536870913]],[\"^84\",[50,\"^57\",true,536870913]],[\"^84\",[50,\"^5J\",true,536870913]],[\"^84\",[50,\"^X\",false,536870913]],[\"^84\",[50,\"^1K\",\"^86\",536870913]],[\"^84\",[51,\"^48\",1775980635495,536870913]],[\"^84\",[51,\"^7M\",\"reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^7@\",\"d3BHP\",536870913]],[\"^84\",[51,\"^69\",124,536870913]],[\"^84\",[51,\"^6:\",\"Reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^3;\",1775980635495,536870913]],[\"^84\",[51,\"^2T\",\"~u00000002-1044-8271-6500-000000000000\",536870913]],[\"^84\",[51,\"^<\",\"^=\",536870913]],[\"^84\",[51,\"^;\",\"^1@\",536870913]],[\"^84\",[51,\"^>\",true,536870913]],[\"^84\",[51,\"^57\",true,536870913]],[\"^84\",[51,\"^5J\",true,536870913]],[\"^84\",[51,\"^X\",false,536870913]],[\"^84\",[51,\"^1K\",\"^88\",536870913]],[\"^84\",[52,\"^;\",\"^45\",536870913]],[\"^84\",[52,\"^28\",[\"^ \",\"~:major\",65,\"~:minor\",24],536870913]],[\"^84\",[53,\"^48\",1775980635487,536870913]],[\"^84\",[53,\"^7M\",\"created from property\",536870913]],[\"^84\",[53,\"^7@\",\"d3BF9\",536870913]],[\"^84\",[53,\"^69\",124,536870913]],[\"^84\",[53,\"^6:\",\"Created from property\",536870913]],[\"^84\",[53,\"^3;\",1775980635487,536870913]],[\"^84\",[53,\"^2T\",\"~u00000002-8618-9226-7000-000000000000\",536870913]],[\"^84\",[53,\"^<\",\"^=\",536870913]],[\"^84\",[53,\"^;\",\"^6?\",536870913]],[\"^84\",[53,\"^>\",true,536870913]],[\"^84\",[53,\"^C\",\"^D\",536870913]],[\"^84\",[53,\"^57\",true,536870913]],[\"^84\",[53,\"^5J\",true,536870913]],[\"^84\",[53,\"^1K\",\"^8=\",536870913]],[\"^84\",[54,\"^48\",1775980635492,536870913]],[\"^84\",[54,\"^7M\",\"publishing public?\",536870913]],[\"^84\",[54,\"^7@\",\"d3BGa\",536870913]],[\"^84\",[54,\"^69\",124,536870913]],[\"^84\",[54,\"^6:\",\"Publishing Public?\",536870913]],[\"^84\",[54,\"^3;\",1775980635492,536870913]],[\"^84\",[54,\"^2T\",\"~u00000002-1094-6579-3900-000000000000\",536870913]],[\"^84\",[54,\"^<\",\"^=\",536870913]],[\"^84\",[54,\"^;\",\"^4=\",536870913]],[\"^84\",[54,\"^>\",true,536870913]],[\"^84\",[54,\"^57\",true,536870913]],[\"^84\",[54,\"^5J\",true,536870913]],[\"^84\",[54,\"^X\",true,536870913]],[\"^84\",[54,\"^1K\",\"^8?\",536870913]],[\"^84\",[54,\"^44\",\"^89\",536870913]],[\"^84\",[55,\"^L\",26,536870913]],[\"^84\",[55,\"^48\",1775980635491,536870913]],[\"^84\",[55,\"^7@\",\"d3BGC\",536870913]],[\"^84\",[55,\"^4S\",26,536870913]],[\"^84\",[55,\"^3X\",26,536870913]],[\"^84\",[55,\"^6:\",\"Done\",536870913]],[\"^84\",[55,\"^3;\",1775980635491,536870913]],[\"^84\",[55,\"^2T\",\"~u00000002-1827-5820-8200-000000000000\",536870913]],[\"^84\",[55,\"^;\",\"^6Q\",536870913]],[\"^84\",[55,\"^57\",true,536870913]],[\"^84\",[55,\"^4W\",true,536870913]],[\"^84\",[55,\"^6?\",26,536870913]],[\"^84\",[55,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Done\"],536870913]],[\"^84\",[56,\"^48\",1775980635487,536870913]],[\"^84\",[56,\"^7M\",\"property value\",536870913]],[\"^84\",[56,\"^7@\",\"d3BFE\",536870913]],[\"^84\",[56,\"^69\",124,536870913]],[\"^84\",[56,\"^6:\",\"Property value\",536870913]],[\"^84\",[56,\"^3;\",1775980635487,536870913]],[\"^84\",[56,\"^2T\",\"~u00000002-1396-5248-6500-000000000000\",536870913]],[\"^84\",[56,\"^<\",\"^=\",536870913]],[\"^84\",[56,\"^;\",\"^T\",536870913]],[\"^84\",[56,\"^>\",true,536870913]],[\"^84\",[56,\"^57\",true,536870913]],[\"^84\",[56,\"^5J\",true,536870913]],[\"^84\",[56,\"^X\",false,536870913]],[\"^84\",[56,\"^1K\",\"~:any\",536870913]],[\"^84\",[57,\"^48\",1775980635493,536870913]],[\"^84\",[57,\"^7M\",\"file type\",536870913]],[\"^84\",[57,\"^7@\",\"d3BGs\",536870913]],[\"^84\",[57,\"^69\",124,536870913]],[\"^84\",[57,\"^6:\",\"File Type\",536870913]],[\"^84\",[57,\"^3;\",1775980635493,536870913]],[\"^84\",[57,\"^2T\",\"~u00000002-1142-0830-9800-000000000000\",536870913]],[\"^84\",[57,\"^<\",\"^=\",536870913]],[\"^84\",[57,\"^;\",\"^2:\",536870913]],[\"^84\",[57,\"^>\",true,536870913]],[\"^84\",[57,\"^57\",true,536870913]],[\"^84\",[57,\"^5J\",true,536870913]],[\"^84\",[57,\"^X\",false,536870913]],[\"^84\",[57,\"^1K\",\"^87\",536870913]],[\"^84\",[58,\"^48\",1775980635487,536870913]],[\"^84\",[58,\"^7M\",\"tags\",536870913]],[\"^84\",[58,\"^7@\",\"d3BFG\",536870913]],[\"^84\",[58,\"^69\",124,536870913]],[\"^84\",[58,\"^6:\",\"Tags\",536870913]],[\"^84\",[58,\"^3;\",1775980635487,536870913]],[\"^84\",[58,\"^2T\",\"~u00000002-1814-9483-4000-000000000000\",536870913]],[\"^84\",[58,\"^<\",\"^14\",536870913]],[\"^84\",[58,\"^;\",\"^69\",536870913]],[\"^84\",[58,\"^>\",true,536870913]],[\"^84\",[58,\"^C\",\"^D\",536870913]],[\"^84\",[58,\"^57\",true,536870913]],[\"^84\",[58,\"^X\",true,536870913]],[\"^84\",[58,\"^1K\",\"^8@\",536870913]],[\"^84\",[59,\"^48\",1775980635490,536870913]],[\"^84\",[59,\"^7M\",\"list type\",536870913]],[\"^84\",[59,\"^7@\",\"d3BFx\",536870913]],[\"^84\",[59,\"^69\",124,536870913]],[\"^84\",[59,\"^6:\",\"List type\",536870913]],[\"^84\",[59,\"^3;\",1775980635490,536870913]],[\"^84\",[59,\"^2T\",\"~u00000002-6078-1711-1000-000000000000\",536870913]],[\"^84\",[59,\"^<\",\"^=\",536870913]],[\"^84\",[59,\"^;\",\"^6W\",536870913]],[\"^84\",[59,\"^>\",true,536870913]],[\"^84\",[59,\"^C\",\"^D\",536870913]],[\"^84\",[59,\"^57\",true,536870913]],[\"^84\",[59,\"^5J\",true,536870913]],[\"^84\",[59,\"^1K\",\"^8>\",536870913]],[\"^84\",[60,\"^48\",1775980635487,536870913]],[\"^84\",[60,\"^7M\",\"property position\",536870913]],[\"^84\",[60,\"^7@\",\"d3BFC\",536870913]],[\"^84\",[60,\"^69\",124,536870913]],[\"^84\",[60,\"^6:\",\"Property position\",536870913]],[\"^84\",[60,\"^3;\",1775980635487,536870913]],[\"^84\",[60,\"^2T\",\"~u00000002-1869-2008-6400-000000000000\",536870913]],[\"^84\",[60,\"^<\",\"^=\",536870913]],[\"^84\",[60,\"^;\",\"^@\",536870913]],[\"^84\",[60,\"^>\",true,536870913]],[\"^84\",[60,\"^57\",true,536870913]],[\"^84\",[60,\"^5J\",true,536870913]],[\"^84\",[60,\"^1K\",\"^8D\",536870913]],[\"^84\",[61,\"^L\",13,536870913]],[\"^84\",[61,\"^48\",1775980635492,536870913]],[\"^84\",[61,\"^7@\",\"d3BGS\",536870913]],[\"^84\",[61,\"^4S\",13,536870913]],[\"^84\",[61,\"^3X\",13,536870913]],[\"^84\",[61,\"^6:\",\"Day\",536870913]],[\"^84\",[61,\"^3;\",1775980635492,536870913]],[\"^84\",[61,\"^2T\",\"~u00000002-3924-1785-8000-000000000000\",536870913]],[\"^84\",[61,\"^;\",\"^16\",536870913]],[\"^84\",[61,\"^57\",true,536870913]],[\"^84\",[61,\"^6?\",13,536870913]],[\"^84\",[62,\"^48\",1775980635490,536870913]],[\"^84\",[62,\"^7M\",\"annotation type\",536870913]],[\"^84\",[62,\"^7@\",\"d3BFn\",536870913]],[\"^84\",[62,\"^69\",124,536870913]],[\"^84\",[62,\"^6:\",\"Annotation type\",536870913]],[\"^84\",[62,\"^3;\",1775980635490,536870913]],[\"^84\",[62,\"^2T\",\"~u00000002-9984-3783-2000-000000000000\",536870913]],[\"^84\",[62,\"^<\",\"^=\",536870913]],[\"^84\",[62,\"^;\",\"^6[\",536870913]],[\"^84\",[62,\"^>\",true,536870913]],[\"^84\",[62,\"^57\",true,536870913]],[\"^84\",[62,\"^5J\",true,536870913]],[\"^84\",[62,\"^1K\",\"^8D\",536870913]],[\"^84\",[63,\"^48\",1775980635493,536870913]],[\"^84\",[63,\"^7M\",\"view hidden columns\",536870913]],[\"^84\",[63,\"^7@\",\"d3BGn\",536870913]],[\"^84\",[63,\"^69\",124,536870913]],[\"^84\",[63,\"^6:\",\"View hidden columns\",536870913]],[\"^84\",[63,\"^3;\",1775980635493,536870913]],[\"^84\",[63,\"^2T\",\"~u00000002-9750-5719-2000-000000000000\",536870913]],[\"^84\",[63,\"^<\",\"^14\",536870913]],[\"^84\",[63,\"^;\",\"^2[\",536870913]],[\"^84\",[63,\"^>\",true,536870913]],[\"^84\",[63,\"^57\",true,536870913]],[\"^84\",[63,\"^5J\",true,536870913]],[\"^84\",[63,\"^X\",false,536870913]],[\"^84\",[63,\"^1K\",\"^8D\",536870913]],[\"^84\",[64,\"^48\",1775980635490,536870913]],[\"^84\",[64,\"^7M\",\"ls-type\",536870913]],[\"^84\",[64,\"^7@\",\"d3BFm\",536870913]],[\"^84\",[64,\"^69\",124,536870913]],[\"^84\",[64,\"^6:\",\"ls-type\",536870913]],[\"^84\",[64,\"^3;\",1775980635490,536870913]],[\"^84\",[64,\"^2T\",\"~u00000002-3269-7934-5000-000000000000\",536870913]],[\"^84\",[64,\"^<\",\"^=\",536870913]],[\"^84\",[64,\"^;\",\"^5\",536870913]],[\"^84\",[64,\"^>\",true,536870913]],[\"^84\",[64,\"^57\",true,536870913]],[\"^84\",[64,\"^5J\",true,536870913]],[\"^84\",[64,\"^1K\",\"^8D\",536870913]],[\"^84\",[65,\"^L\",19,536870913]],[\"^84\",[65,\"^48\",1775980635490,536870913]],[\"^84\",[65,\"^7@\",\"d3BFp\",536870913]],[\"^84\",[65,\"^4S\",19,536870913]],[\"^84\",[65,\"^3X\",19,536870913]],[\"^84\",[65,\"^6:\",\"yellow\",536870913]],[\"^84\",[65,\"^3;\",1775980635490,536870913]],[\"^84\",[65,\"^2T\",\"~u00000002-1752-1030-0200-000000000000\",536870913]],[\"^84\",[65,\"^;\",\"^J\",536870913]],[\"^84\",[65,\"^57\",true,536870913]],[\"^84\",[65,\"^6?\",19,536870913]],[\"^84\",[66,\"^48\",1775980635493,536870913]],[\"^84\",[66,\"^7M\",\"image width\",536870913]],[\"^84\",[66,\"^7@\",\"d3BGw\",536870913]],[\"^84\",[66,\"^69\",124,536870913]],[\"^84\",[66,\"^6:\",\"Image width\",536870913]],[\"^84\",[66,\"^3;\",1775980635493,536870913]],[\"^84\",[66,\"^2T\",\"~u00000002-1857-8658-3900-000000000000\",536870913]],[\"^84\",[66,\"^<\",\"^=\",536870913]],[\"^84\",[66,\"^;\",\"^Z\",536870913]],[\"^84\",[66,\"^>\",true,536870913]],[\"^84\",[66,\"^57\",true,536870913]],[\"^84\",[66,\"^5J\",true,536870913]],[\"^84\",[66,\"^X\",false,536870913]],[\"^84\",[66,\"^1K\",\"^8C\",536870913]],[\"^84\",[67,\"^48\",1775980635494,536870913]],[\"^84\",[67,\"^7M\",\"file remote metadata\",536870913]],[\"^84\",[67,\"^7@\",\"d3BH0\",536870913]],[\"^84\",[67,\"^69\",124,536870913]],[\"^84\",[67,\"^6:\",\"File remote metadata\",536870913]],[\"^84\",[67,\"^3;\",1775980635494,536870913]],[\"^84\",[67,\"^2T\",\"~u00000002-9907-5046-9000-000000000000\",536870913]],[\"^84\",[67,\"^<\",\"^=\",536870913]],[\"^84\",[67,\"^;\",\"^1;\",536870913]],[\"^84\",[67,\"^>\",true,536870913]],[\"^84\",[67,\"^57\",true,536870913]],[\"^84\",[67,\"^2A\",167,536870913]],[\"^84\",[67,\"^5J\",true,536870913]],[\"^84\",[67,\"^X\",false,536870913]],[\"^84\",[67,\"^1K\",\"^88\",536870913]],[\"^84\",[68,\"^L\",127,536870913]],[\"^84\",[68,\"^48\",1775980635492,536870913]],[\"^84\",[68,\"^7@\",\"d3BGf\",536870913]],[\"^84\",[68,\"^4S\",127,536870913]],[\"^84\",[68,\"^3X\",127,536870913]],[\"^84\",[68,\"^6:\",\"List View\",536870913]],[\"^84\",[68,\"^3;\",1775980635492,536870913]],[\"^84\",[68,\"^2T\",\"~u00000002-1164-8285-0200-000000000000\",536870913]],[\"^84\",[68,\"^;\",\"^1E\",536870913]],[\"^84\",[68,\"^57\",true,536870913]],[\"^84\",[68,\"^6?\",127,536870913]],[\"^84\",[68,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"list\"],536870913]],[\"^84\",[69,\"^48\",1775980635488,536870913]],[\"^84\",[69,\"^7M\",\"node references\",536870913]],[\"^84\",[69,\"^7@\",\"d3BFL\",536870913]],[\"^84\",[69,\"^69\",124,536870913]],[\"^84\",[69,\"^6:\",\"Node references\",536870913]],[\"^84\",[69,\"^3;\",1775980635488,536870913]],[\"^84\",[69,\"^2T\",\"~u00000002-1214-4953-4900-000000000000\",536870913]],[\"^84\",[69,\"^<\",\"^14\",536870913]],[\"^84\",[69,\"^;\",\"^1S\",536870913]],[\"^84\",[69,\"^>\",true,536870913]],[\"^84\",[69,\"^C\",\"^D\",536870913]],[\"^84\",[69,\"^57\",true,536870913]],[\"^84\",[69,\"^5J\",true,536870913]],[\"^84\",[69,\"^X\",false,536870913]],[\"^84\",[69,\"^1K\",\"^8=\",536870913]],[\"^84\",[70,\"^L\",127,536870913]],[\"^84\",[70,\"^48\",1775980635492,536870913]],[\"^84\",[70,\"^7@\",\"d3BGe\",536870913]],[\"^84\",[70,\"^4S\",127,536870913]],[\"^84\",[70,\"^3X\",127,536870913]],[\"^84\",[70,\"^6:\",\"Table View\",536870913]],[\"^84\",[70,\"^3;\",1775980635492,536870913]],[\"^84\",[70,\"^2T\",\"~u00000002-1942-5424-0000-000000000000\",536870913]],[\"^84\",[70,\"^;\",\"^?\",536870913]],[\"^84\",[70,\"^57\",true,536870913]],[\"^84\",[70,\"^6?\",127,536870913]],[\"^84\",[70,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"table\"],536870913]],[\"^84\",[71,\"^48\",1775980635488,536870913]],[\"^84\",[71,\"^7M\",\"description\",536870913]],[\"^84\",[71,\"^7@\",\"d3BFT\",536870913]],[\"^84\",[71,\"^69\",124,536870913]],[\"^84\",[71,\"^6:\",\"Description\",536870913]],[\"^84\",[71,\"^3;\",1775980635488,536870913]],[\"^84\",[71,\"^2T\",\"~u00000002-3362-3620-0000-000000000000\",536870913]],[\"^84\",[71,\"^<\",\"^=\",536870913]],[\"^84\",[71,\"^;\",\"^2A\",536870913]],[\"^84\",[71,\"^>\",true,536870913]],[\"^84\",[71,\"^C\",\"^D\",536870913]],[\"^84\",[71,\"^57\",true,536870913]],[\"^84\",[71,\"^X\",true,536870913]],[\"^84\",[71,\"^1K\",\"^8>\",536870913]],[\"^84\",[72,\"^48\",1775980635495,536870913]],[\"^84\",[72,\"^7M\",\"recycle original page\",536870913]],[\"^84\",[72,\"^7@\",\"d3BHJ\",536870913]],[\"^84\",[72,\"^69\",124,536870913]],[\"^84\",[72,\"^6:\",\"Recycle original page\",536870913]],[\"^84\",[72,\"^3;\",1775980635495,536870913]],[\"^84\",[72,\"^2T\",\"~u00000002-1658-7897-9900-000000000000\",536870913]],[\"^84\",[72,\"^<\",\"^=\",536870913]],[\"^84\",[72,\"^;\",\"^2Q\",536870913]],[\"^84\",[72,\"^>\",true,536870913]],[\"^84\",[72,\"^C\",\"^D\",536870913]],[\"^84\",[72,\"^57\",true,536870913]],[\"^84\",[72,\"^5J\",true,536870913]],[\"^84\",[72,\"^X\",false,536870913]],[\"^84\",[72,\"^1K\",\"^85\",536870913]],[\"^84\",[73,\"^48\",1775980635490,536870913]],[\"^84\",[73,\"^7M\",\"excluded references\",536870913]],[\"^84\",[73,\"^7@\",\"d3BFz\",536870913]],[\"^84\",[73,\"^69\",124,536870913]],[\"^84\",[73,\"^6:\",\"Excluded references\",536870913]],[\"^84\",[73,\"^3;\",1775980635490,536870913]],[\"^84\",[73,\"^2T\",\"~u00000002-2426-7588-9000-000000000000\",536870913]],[\"^84\",[73,\"^<\",\"^14\",536870913]],[\"^84\",[73,\"^;\",\"^35\",536870913]],[\"^84\",[73,\"^>\",true,536870913]],[\"^84\",[73,\"^C\",\"^D\",536870913]],[\"^84\",[73,\"^57\",true,536870913]],[\"^84\",[73,\"^5J\",true,536870913]],[\"^84\",[73,\"^1K\",\"^85\",536870913]],[\"^84\",[74,\"^48\",1775980635494,536870913]],[\"^84\",[74,\"^7M\",\"history scalar value\",536870913]],[\"^84\",[74,\"^7@\",\"d3BHE\",536870913]],[\"^84\",[74,\"^69\",124,536870913]],[\"^84\",[74,\"^6:\",\"History scalar value\",536870913]],[\"^84\",[74,\"^3;\",1775980635494,536870913]],[\"^84\",[74,\"^2T\",\"~u00000002-2393-3777-5000-000000000000\",536870913]],[\"^84\",[74,\"^<\",\"^=\",536870913]],[\"^84\",[74,\"^;\",\"^3@\",536870913]],[\"^84\",[74,\"^>\",true,536870913]],[\"^84\",[74,\"^57\",true,536870913]],[\"^84\",[74,\"^5J\",true,536870913]],[\"^84\",[74,\"^1K\",\"^8H\",536870913]],[\"^84\",[75,\"^48\",1775980635491,536870913]],[\"^84\",[75,\"^7M\",\"deadline\",536870913]],[\"^84\",[75,\"^7@\",\"d3BGJ\",536870913]],[\"^84\",[75,\"^69\",124,536870913]],[\"^84\",[75,\"^6:\",\"Deadline\",536870913]],[\"^84\",[75,\"^3;\",1775980635491,536870913]],[\"^84\",[75,\"^2T\",\"~u00000002-1685-9016-0400-000000000000\",536870913]],[\"^84\",[75,\"^<\",\"^=\",536870913]],[\"^84\",[75,\"^;\",\"^1J\",536870913]],[\"^84\",[75,\"^>\",true,536870913]],[\"^84\",[75,\"^57\",true,536870913]],[\"^84\",[75,\"^2A\",166,536870913]],[\"^84\",[75,\"^3P\",true,536870913]],[\"^84\",[75,\"^X\",true,536870913]],[\"^84\",[75,\"^1K\",\"^8A\",536870913]],[\"^84\",[75,\"^@\",\"~:block-below\",536870913]],[\"^84\",[76,\"^L\",19,536870913]],[\"^84\",[76,\"^48\",1775980635490,536870913]],[\"^84\",[76,\"^7@\",\"d3BFr\",536870913]],[\"^84\",[76,\"^4S\",19,536870913]],[\"^84\",[76,\"^3X\",19,536870913]],[\"^84\",[76,\"^6:\",\"green\",536870913]],[\"^84\",[76,\"^3;\",1775980635490,536870913]],[\"^84\",[76,\"^2T\",\"~u00000002-1992-2016-2600-000000000000\",536870913]],[\"^84\",[76,\"^;\",\"^2=\",536870913]],[\"^84\",[76,\"^57\",true,536870913]],[\"^84\",[76,\"^6?\",19,536870913]],[\"^84\",[77,\"^48\",1775980635491,536870913]],[\"^84\",[77,\"^7M\",\"choice classes\",536870913]],[\"^84\",[77,\"^7@\",\"d3BG4\",536870913]],[\"^84\",[77,\"^69\",124,536870913]],[\"^84\",[77,\"^6:\",\"Choice classes\",536870913]],[\"^84\",[77,\"^3;\",1775980635491,536870913]],[\"^84\",[77,\"^2T\",\"~u00000002-7629-6621-9000-000000000000\",536870913]],[\"^84\",[77,\"^<\",\"^14\",536870913]],[\"^84\",[77,\"^;\",\"^4:\",536870913]],[\"^84\",[77,\"^>\",true,536870913]],[\"^84\",[77,\"^C\",\"^D\",536870913]],[\"^84\",[77,\"^57\",true,536870913]],[\"^84\",[77,\"^5J\",true,536870913]],[\"^84\",[77,\"^X\",false,536870913]],[\"^84\",[77,\"^1K\",\"^8@\",536870913]],[\"^84\",[77,\"^44\",\"~:never\",536870913]],[\"^84\",[78,\"^48\",1775980635496,536870913]],[\"^84\",[78,\"^7M\",\"quote\",536870913]],[\"^84\",[78,\"^69\",14,536870913]],[\"^84\",[78,\"^6:\",\"Quote\",536870913]],[\"^84\",[78,\"^3;\",1775980635496,536870913]],[\"^84\",[78,\"^2T\",\"~u00000002-1176-1666-1700-000000000000\",536870913]],[\"^84\",[78,\"^;\",\"^2K\",536870913]],[\"^84\",[78,\"^57\",true,536870913]],[\"^84\",[78,\"^3K\",89,536870913]],[\"^84\",[78,\"^1X\",true,536870913]],[\"^84\",[78,\"^4K\",37,536870913]],[\"^84\",[79,\"^48\",1775980635489,536870913]],[\"^84\",[79,\"^7M\",\"non ref type default value\",536870913]],[\"^84\",[79,\"^7@\",\"d3BFW\",536870913]],[\"^84\",[79,\"^69\",124,536870913]],[\"^84\",[79,\"^6:\",\"Non ref type default value\",536870913]],[\"^84\",[79,\"^3;\",1775980635489,536870913]],[\"^84\",[79,\"^2T\",\"~u00000002-1595-7230-1400-000000000000\",536870913]],[\"^84\",[79,\"^<\",\"^=\",536870913]],[\"^84\",[79,\"^;\",\"^29\",536870913]],[\"^84\",[79,\"^>\",true,536870913]],[\"^84\",[79,\"^57\",true,536870913]],[\"^84\",[79,\"^5J\",true,536870913]],[\"^84\",[79,\"^X\",false,536870913]],[\"^84\",[79,\"^1K\",\"^8H\",536870913]],[\"^84\",[79,\"^44\",\"^86\",536870913]],[\"^84\",[80,\"^48\",1775980635494,536870913]],[\"^84\",[80,\"^7M\",\"last visit page\",536870913]],[\"^84\",[80,\"^7@\",\"d3BGz\",536870913]],[\"^84\",[80,\"^69\",124,536870913]],[\"^84\",[80,\"^6:\",\"Last visit page\",536870913]],[\"^84\",[80,\"^3;\",1775980635494,536870913]],[\"^84\",[80,\"^2T\",\"~u00000002-2107-8035-3500-000000000000\",536870913]],[\"^84\",[80,\"^<\",\"^=\",536870913]],[\"^84\",[80,\"^;\",\"^4[\",536870913]],[\"^84\",[80,\"^>\",true,536870913]],[\"^84\",[80,\"^57\",true,536870913]],[\"^84\",[80,\"^5J\",true,536870913]],[\"^84\",[80,\"^X\",false,536870913]],[\"^84\",[80,\"^1K\",\"^8C\",536870913]],[\"^84\",[81,\"^48\",1775980635495,536870913]],[\"^84\",[81,\"^7M\",\"used template\",536870913]],[\"^84\",[81,\"^7@\",\"d3BHN\",536870913]],[\"^84\",[81,\"^69\",124,536870913]],[\"^84\",[81,\"^6:\",\"Used template\",536870913]],[\"^84\",[81,\"^3;\",1775980635495,536870913]],[\"^84\",[81,\"^2T\",\"~u00000002-9803-6990-6000-000000000000\",536870913]],[\"^84\",[81,\"^<\",\"^=\",536870913]],[\"^84\",[81,\"^;\",\"^4L\",536870913]],[\"^84\",[81,\"^>\",true,536870913]],[\"^84\",[81,\"^C\",\"^D\",536870913]],[\"^84\",[81,\"^57\",true,536870913]],[\"^84\",[81,\"^6G\",38,536870913]],[\"^84\",[81,\"^5J\",true,536870913]],[\"^84\",[81,\"^X\",false,536870913]],[\"^84\",[81,\"^1K\",\"^85\",536870913]],[\"^84\",[82,\"^L\",13,536870913]],[\"^84\",[82,\"^48\",1775980635492,536870913]],[\"^84\",[82,\"^7@\",\"d3BGU\",536870913]],[\"^84\",[82,\"^4S\",13,536870913]],[\"^84\",[82,\"^3X\",13,536870913]],[\"^84\",[82,\"^6:\",\"Month\",536870913]],[\"^84\",[82,\"^3;\",1775980635492,536870913]],[\"^84\",[82,\"^2T\",\"~u00000002-2073-3937-9700-000000000000\",536870913]],[\"^84\",[82,\"^;\",\"^5F\",536870913]],[\"^84\",[82,\"^57\",true,536870913]],[\"^84\",[82,\"^6?\",13,536870913]],[\"^84\",[83,\"^48\",1775980635494,536870913]],[\"^84\",[83,\"^7M\",\"history value\",536870913]],[\"^84\",[83,\"^7@\",\"d3BHD\",536870913]],[\"^84\",[83,\"^69\",124,536870913]],[\"^84\",[83,\"^6:\",\"History value\",536870913]],[\"^84\",[83,\"^3;\",1775980635494,536870913]],[\"^84\",[83,\"^2T\",\"~u00000002-5131-3603-7000-000000000000\",536870913]],[\"^84\",[83,\"^<\",\"^=\",536870913]],[\"^84\",[83,\"^;\",\"^5T\",536870913]],[\"^84\",[83,\"^>\",true,536870913]],[\"^84\",[83,\"^C\",\"^D\",536870913]],[\"^84\",[83,\"^57\",true,536870913]],[\"^84\",[83,\"^5J\",true,536870913]],[\"^84\",[83,\"^1K\",\"^8=\",536870913]],[\"^84\",[84,\"^48\",1775980635493,536870913]],[\"^84\",[84,\"^7M\",\"view filters\",536870913]],[\"^84\",[84,\"^7@\",\"d3BGm\",536870913]],[\"^84\",[84,\"^69\",124,536870913]],[\"^84\",[84,\"^6:\",\"View filters\",536870913]],[\"^84\",[84,\"^3;\",1775980635493,536870913]],[\"^84\",[84,\"^2T\",\"~u00000002-1702-3936-3300-000000000000\",536870913]],[\"^84\",[84,\"^<\",\"^=\",536870913]],[\"^84\",[84,\"^;\",\"^61\",536870913]],[\"^84\",[84,\"^>\",true,536870913]],[\"^84\",[84,\"^57\",true,536870913]],[\"^84\",[84,\"^5J\",true,536870913]],[\"^84\",[84,\"^X\",false,536870913]],[\"^84\",[84,\"^1K\",\"^88\",536870913]],[\"^84\",[85,\"^48\",1775980635496,536870913]],[\"^84\",[85,\"^7M\",\"journal\",536870913]],[\"^84\",[85,\"^69\",14,536870913]],[\"^84\",[85,\"^6:\",\"Journal\",536870913]],[\"^84\",[85,\"^3;\",1775980635496,536870913]],[\"^84\",[85,\"^2T\",\"~u00000002-1979-7410-8100-000000000000\",536870913]],[\"^84\",[85,\"^;\",\"^6A\",536870913]],[\"^84\",[85,\"^57\",true,536870913]],[\"^84\",[85,\"^3K\",104,536870913]],[\"^84\",[85,\"^55\",\"MMM do, yyyy\",536870913]],[\"^84\",[86,\"^48\",1775980635486,536870913]],[\"^84\",[86,\"^7M\",\"built in?\",536870913]],[\"^84\",[86,\"^7@\",\"d3BF7\",536870913]],[\"^84\",[86,\"^69\",124,536870913]],[\"^84\",[86,\"^6:\",\"Built in?\",536870913]],[\"^84\",[86,\"^3;\",1775980635486,536870913]],[\"^84\",[86,\"^2T\",\"~u00000002-1125-9581-6000-000000000000\",536870913]],[\"^84\",[86,\"^<\",\"^=\",536870913]],[\"^84\",[86,\"^;\",\"^57\",536870913]],[\"^84\",[86,\"^>\",true,536870913]],[\"^84\",[86,\"^57\",true,536870913]],[\"^84\",[86,\"^5J\",true,536870913]],[\"^84\",[86,\"^1K\",\"^8?\",536870913]],[\"^84\",[87,\"^48\",1775980635496,536870913]],[\"^84\",[87,\"^7M\",\"whiteboard\",536870913]],[\"^84\",[87,\"^69\",14,536870913]],[\"^84\",[87,\"^6:\",\"Whiteboard\",536870913]],[\"^84\",[87,\"^3;\",1775980635496,536870913]],[\"^84\",[87,\"^2T\",\"~u00000002-1013-6984-5200-000000000000\",536870913]],[\"^84\",[87,\"^;\",\"^6<\",536870913]],[\"^84\",[87,\"^57\",true,536870913]],[\"^84\",[87,\"^3K\",104,536870913]],[\"^84\",[88,\"^48\",1775980635494,536870913]],[\"^84\",[88,\"^7M\",\"asset alignment\",536870913]],[\"^84\",[88,\"^7@\",\"d3BH3\",536870913]],[\"^84\",[88,\"^69\",124,536870913]],[\"^84\",[88,\"^6:\",\"Asset alignment\",536870913]],[\"^84\",[88,\"^3;\",1775980635494,536870913]],[\"^84\",[88,\"^2T\",\"~u00000002-7135-0412-8000-000000000000\",536870913]],[\"^84\",[88,\"^<\",\"^=\",536870913]],[\"^84\",[88,\"^;\",\"^:\",536870913]],[\"^84\",[88,\"^>\",true,536870913]],[\"^84\",[88,\"^57\",true,536870913]],[\"^84\",[88,\"^5J\",true,536870913]],[\"^84\",[88,\"^X\",false,536870913]],[\"^84\",[88,\"^1K\",\"^8D\",536870913]],[\"^84\",[89,\"^48\",1775980635496,536870913]],[\"^84\",[89,\"^7M\",\"root tag\",536870913]],[\"^84\",[89,\"^69\",14,536870913]],[\"^84\",[89,\"^6:\",\"Root Tag\",536870913]],[\"^84\",[89,\"^3;\",1775980635496,536870913]],[\"^84\",[89,\"^2T\",\"~u00000002-2737-8382-7000-000000000000\",536870913]],[\"^84\",[89,\"^;\",\"^5Q\",536870913]],[\"^84\",[89,\"^57\",true,536870913]],[\"^84\",[90,\"^48\",1775980635489,536870913]],[\"^84\",[90,\"^7M\",\"heading\",536870913]],[\"^84\",[90,\"^7@\",\"d3BFk\",536870913]],[\"^84\",[90,\"^69\",124,536870913]],[\"^84\",[90,\"^6:\",\"Heading\",536870913]],[\"^84\",[90,\"^3;\",1775980635489,536870913]],[\"^84\",[90,\"^2T\",\"~u00000002-1858-7494-1500-000000000000\",536870913]],[\"^84\",[90,\"^<\",\"^=\",536870913]],[\"^84\",[90,\"^;\",\"^2L\",536870913]],[\"^84\",[90,\"^>\",true,536870913]],[\"^84\",[90,\"^57\",true,536870913]],[\"^84\",[90,\"^5J\",true,536870913]],[\"^84\",[90,\"^1K\",\"^8H\",536870913]],[\"^84\",[91,\"^48\",1775980635490,536870913]],[\"^84\",[91,\"^7M\",\"annotation image\",536870913]],[\"^84\",[91,\"^7@\",\"d3BFv\",536870913]],[\"^84\",[91,\"^69\",124,536870913]],[\"^84\",[91,\"^6:\",\"Annotation image\",536870913]],[\"^84\",[91,\"^3;\",1775980635490,536870913]],[\"^84\",[91,\"^2T\",\"~u00000002-1377-6700-9000-000000000000\",536870913]],[\"^84\",[91,\"^<\",\"^=\",536870913]],[\"^84\",[91,\"^;\",\"^M\",536870913]],[\"^84\",[91,\"^>\",true,536870913]],[\"^84\",[91,\"^C\",\"^D\",536870913]],[\"^84\",[91,\"^57\",true,536870913]],[\"^84\",[91,\"^5J\",true,536870913]],[\"^84\",[91,\"^1K\",\"^8=\",536870913]],[\"^84\",[92,\"^48\",1775980635487,536870913]],[\"^84\",[92,\"^7M\",\"property view context\",536870913]],[\"^84\",[92,\"^7@\",\"d3BFB\",536870913]],[\"^84\",[92,\"^69\",124,536870913]],[\"^84\",[92,\"^6:\",\"Property view context\",536870913]],[\"^84\",[92,\"^3;\",1775980635487,536870913]],[\"^84\",[92,\"^2T\",\"~u00000002-1547-3958-2800-000000000000\",536870913]],[\"^84\",[92,\"^<\",\"^=\",536870913]],[\"^84\",[92,\"^;\",\"^44\",536870913]],[\"^84\",[92,\"^>\",true,536870913]],[\"^84\",[92,\"^57\",true,536870913]],[\"^84\",[92,\"^5J\",true,536870913]],[\"^84\",[92,\"^1K\",\"^8D\",536870913]],[\"^84\",[93,\"^48\",1775980635492,536870913]],[\"^84\",[93,\"^7M\",\"view feature type\",536870913]],[\"^84\",[93,\"^7@\",\"d3BGh\",536870913]],[\"^84\",[93,\"^69\",124,536870913]],[\"^84\",[93,\"^6:\",\"View Feature Type\",536870913]],[\"^84\",[93,\"^3;\",1775980635492,536870913]],[\"^84\",[93,\"^2T\",\"~u00000002-9391-4187-1000-000000000000\",536870913]],[\"^84\",[93,\"^<\",\"^=\",536870913]],[\"^84\",[93,\"^;\",\"^5>\",536870913]],[\"^84\",[93,\"^>\",true,536870913]],[\"^84\",[93,\"^57\",true,536870913]],[\"^84\",[93,\"^5J\",true,536870913]],[\"^84\",[93,\"^X\",false,536870913]],[\"^84\",[93,\"^1K\",\"^8D\",536870913]],[\"^84\",[94,\"^L\",32,536870913]],[\"^84\",[94,\"^48\",1775980635491,536870913]],[\"^84\",[94,\"^7@\",\"d3BGF\",536870913]],[\"^84\",[94,\"^4S\",32,536870913]],[\"^84\",[94,\"^3X\",32,536870913]],[\"^84\",[94,\"^6:\",\"Low\",536870913]],[\"^84\",[94,\"^3;\",1775980635491,536870913]],[\"^84\",[94,\"^2T\",\"~u00000002-2107-4537-4800-000000000000\",536870913]],[\"^84\",[94,\"^;\",\"^68\",536870913]],[\"^84\",[94,\"^57\",true,536870913]],[\"^84\",[94,\"^6?\",32,536870913]],[\"^84\",[94,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlLow\"],536870913]],[\"^84\",[95,\"^48\",1775980635491,536870913]],[\"^84\",[95,\"^7M\",\"repeating recur frequency\",536870913]],[\"^84\",[95,\"^7@\",\"d3BGN\",536870913]],[\"^84\",[95,\"^69\",124,536870913]],[\"^84\",[95,\"^6:\",\"Repeating recur frequency\",536870913]],[\"^84\",[95,\"^3;\",1775980635491,536870913]],[\"^84\",[95,\"^2T\",\"~u00000002-8716-1592-2000-000000000000\",536870913]],[\"^84\",[95,\"^<\",\"^=\",536870913]],[\"^84\",[95,\"^;\",\"^5K\",536870913]],[\"^84\",[95,\"^>\",true,536870913]],[\"^84\",[95,\"^C\",\"^D\",536870913]],[\"^84\",[95,\"^57\",true,536870913]],[\"^84\",[95,\"^5L\",175,536870913]],[\"^84\",[95,\"^3P\",true,536870913]],[\"^84\",[95,\"^X\",false,536870913]],[\"^84\",[95,\"^1K\",\"~:number\",536870913]],[\"^84\",[96,\"^48\",1775980635492,536870913]],[\"^84\",[96,\"^7M\",\"node repeats?\",536870913]],[\"^84\",[96,\"^7@\",\"d3BGW\",536870913]],[\"^84\",[96,\"^69\",124,536870913]],[\"^84\",[96,\"^6:\",\"Node Repeats?\",536870913]],[\"^84\",[96,\"^3;\",1775980635492,536870913]],[\"^84\",[96,\"^2T\",\"~u00000002-1908-1217-8900-000000000000\",536870913]],[\"^84\",[96,\"^<\",\"^=\",536870913]],[\"^84\",[96,\"^;\",\"^7\",536870913]],[\"^84\",[96,\"^>\",true,536870913]],[\"^84\",[96,\"^57\",true,536870913]],[\"^84\",[96,\"^5J\",true,536870913]],[\"^84\",[96,\"^1K\",\"^8?\",536870913]],[\"^84\",[97,\"^48\",1775980635488,536870913]],[\"^84\",[97,\"^7M\",\"closed value property\",536870913]],[\"^84\",[97,\"^7@\",\"d3BFO\",536870913]],[\"^84\",[97,\"^69\",124,536870913]],[\"^84\",[97,\"^6:\",\"Closed value property\",536870913]],[\"^84\",[97,\"^3;\",1775980635488,536870913]],[\"^84\",[97,\"^2T\",\"~u00000002-1157-7928-1300-000000000000\",536870913]],[\"^84\",[97,\"^<\",\"^14\",536872483]],[\"^84\",[97,\"^;\",\"^L\",536870913]],[\"^84\",[97,\"^>\",true,536870913]],[\"^84\",[97,\"^C\",\"^D\",536870913]],[\"^84\",[97,\"^57\",true,536870913]],[\"^84\",[97,\"^5J\",true,536870913]],[\"^84\",[97,\"^X\",false,536870913]],[\"^84\",[97,\"^1K\",\"^8=\",536870913]],[\"^84\",[98,\"^48\",1775980635495,536870913]],[\"^84\",[98,\"^7M\",\"reaction emoji\",536870913]],[\"^84\",[98,\"^7@\",\"d3BHL\",536870913]],[\"^84\",[98,\"^69\",124,536870913]],[\"^84\",[98,\"^6:\",\"Reaction emoji\",536870913]],[\"^84\",[98,\"^3;\",1775980635495,536870913]],[\"^84\",[98,\"^2T\",\"~u00000002-9877-5864-5000-000000000000\",536870913]],[\"^84\",[98,\"^<\",\"^=\",536870913]],[\"^84\",[98,\"^;\",\"^10\",536870913]],[\"^84\",[98,\"^>\",true,536870913]],[\"^84\",[98,\"^57\",true,536870913]],[\"^84\",[98,\"^5J\",true,536870913]],[\"^84\",[98,\"^X\",false,536870913]],[\"^84\",[98,\"^1K\",\"^87\",536870913]],[\"^84\",[99,\"^L\",32,536870913]],[\"^84\",[99,\"^48\",1775980635491,536870913]],[\"^84\",[99,\"^7@\",\"d3BGH\",536870913]],[\"^84\",[99,\"^4S\",32,536870913]],[\"^84\",[99,\"^3X\",32,536870913]],[\"^84\",[99,\"^6:\",\"High\",536870913]],[\"^84\",[99,\"^3;\",1775980635491,536870913]],[\"^84\",[99,\"^2T\",\"~u00000002-5672-2766-8000-000000000000\",536870913]],[\"^84\",[99,\"^;\",\"^1=\",536870913]],[\"^84\",[99,\"^57\",true,536870913]],[\"^84\",[99,\"^6?\",32,536870913]],[\"^84\",[99,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlHigh\"],536870913]],[\"^84\",[100,\"^L\",19,536870913]],[\"^84\",[100,\"^48\",1775980635490,536870913]],[\"^84\",[100,\"^7@\",\"d3BFs\",536870913]],[\"^84\",[100,\"^4S\",19,536870913]],[\"^84\",[100,\"^3X\",19,536870913]],[\"^84\",[100,\"^6:\",\"blue\",536870913]],[\"^84\",[100,\"^3;\",1775980635490,536870913]],[\"^84\",[100,\"^2T\",\"~u00000002-1836-0512-4100-000000000000\",536870913]],[\"^84\",[100,\"^;\",\"^1G\",536870913]],[\"^84\",[100,\"^57\",true,536870913]],[\"^84\",[100,\"^6?\",19,536870913]],[\"^84\",[101,\"^48\",1775980635493,536870913]],[\"^84\",[101,\"^7M\",\"view ordered columns\",536870913]],[\"^84\",[101,\"^7@\",\"d3BGo\",536870913]],[\"^84\",[101,\"^69\",124,536870913]],[\"^84\",[101,\"^6:\",\"View ordered columns\",536870913]],[\"^84\",[101,\"^3;\",1775980635493,536870913]],[\"^84\",[101,\"^2T\",\"~u00000002-1485-5871-0000-000000000000\",536870913]],[\"^84\",[101,\"^<\",\"^=\",536870913]],[\"^84\",[101,\"^;\",\"^1U\",536870913]],[\"^84\",[101,\"^>\",true,536870913]],[\"^84\",[101,\"^57\",true,536870913]],[\"^84\",[101,\"^5J\",true,536870913]],[\"^84\",[101,\"^X\",false,536870913]],[\"^84\",[101,\"^1K\",\"~:coll\",536870913]],[\"^84\",[102,\"^48\",1775980635489,536870913]],[\"^84\",[102,\"^7M\",\"background color\",536870913]],[\"^84\",[102,\"^7@\",\"d3BFj\",536870913]],[\"^84\",[102,\"^69\",124,536870913]],[\"^84\",[102,\"^6:\",\"Background color\",536870913]],[\"^84\",[102,\"^3;\",1775980635489,536870913]],[\"^84\",[102,\"^2T\",\"~u00000002-5191-2660-6000-000000000000\",536870913]],[\"^84\",[102,\"^<\",\"^=\",536870913]],[\"^84\",[102,\"^;\",\"^26\",536870913]],[\"^84\",[102,\"^>\",true,536870913]],[\"^84\",[102,\"^C\",\"^D\",536870913]],[\"^84\",[102,\"^57\",true,536870913]],[\"^84\",[102,\"^5J\",true,536870913]],[\"^84\",[102,\"^1K\",\"^8>\",536870913]],[\"^84\",[103,\"^48\",1775980635496,536870913]],[\"^84\",[103,\"^7M\",\"cards\",536870913]],[\"^84\",[103,\"^69\",14,536870913]],[\"^84\",[103,\"^6:\",\"Cards\",536870913]],[\"^84\",[103,\"^3;\",1775980635496,536870913]],[\"^84\",[103,\"^2T\",\"~u00000002-1284-2651-6700-000000000000\",536870913]],[\"^84\",[103,\"^;\",\"^2C\",536870913]],[\"^84\",[103,\"^57\",true,536870913]],[\"^84\",[103,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[103,\"^3K\",142,536870913]],[\"^84\",[104,\"^48\",1775980635496,536870913]],[\"^84\",[104,\"^7M\",\"page\",536870913]],[\"^84\",[104,\"^69\",14,536870913]],[\"^84\",[104,\"^6:\",\"Page\",536870913]],[\"^84\",[104,\"^3;\",1775980635496,536870913]],[\"^84\",[104,\"^2T\",\"~u00000002-1484-3403-2900-000000000000\",536870913]],[\"^84\",[104,\"^;\",\"^2S\",536870913]],[\"^84\",[104,\"^57\",true,536870913]],[\"^84\",[104,\"^3K\",89,536870913]],[\"^84\",[105,\"^L\",13,536870913]],[\"^84\",[105,\"^48\",1775980635492,536870913]],[\"^84\",[105,\"^7@\",\"d3BGT\",536870913]],[\"^84\",[105,\"^4S\",13,536870913]],[\"^84\",[105,\"^3X\",13,536870913]],[\"^84\",[105,\"^6:\",\"Week\",536870913]],[\"^84\",[105,\"^3;\",1775980635492,536870913]],[\"^84\",[105,\"^2T\",\"~u00000002-2130-9244-4900-000000000000\",536870913]],[\"^84\",[105,\"^;\",\"^U\",536870913]],[\"^84\",[105,\"^57\",true,536870913]],[\"^84\",[105,\"^6?\",13,536870913]],[\"^84\",[106,\"^48\",1775980635490,536870913]],[\"^84\",[106,\"^7M\",\"tldraw shape\",536870913]],[\"^84\",[106,\"^7@\",\"d3BG1\",536870913]],[\"^84\",[106,\"^69\",124,536870913]],[\"^84\",[106,\"^6:\",\"Tldraw Shape\",536870913]],[\"^84\",[106,\"^3;\",1775980635490,536870913]],[\"^84\",[106,\"^2T\",\"~u00000002-1313-2454-2000-000000000000\",536870913]],[\"^84\",[106,\"^<\",\"^=\",536870913]],[\"^84\",[106,\"^;\",\"^3B\",536870913]],[\"^84\",[106,\"^>\",true,536870913]],[\"^84\",[106,\"^57\",true,536870913]],[\"^84\",[106,\"^5J\",true,536870913]],[\"^84\",[106,\"^1K\",\"^88\",536870913]],[\"^84\",[107,\"^48\",1775980635493,536870913]],[\"^84\",[107,\"^7M\",\"image height\",536870913]],[\"^84\",[107,\"^7@\",\"d3BGx\",536870913]],[\"^84\",[107,\"^69\",124,536870913]],[\"^84\",[107,\"^6:\",\"Image height\",536870913]],[\"^84\",[107,\"^3;\",1775980635493,536870913]],[\"^84\",[107,\"^2T\",\"~u00000002-3306-1650-4000-000000000000\",536870913]],[\"^84\",[107,\"^<\",\"^=\",536870913]],[\"^84\",[107,\"^;\",\"^3M\",536870913]],[\"^84\",[107,\"^>\",true,536870913]],[\"^84\",[107,\"^57\",true,536870913]],[\"^84\",[107,\"^5J\",true,536870913]],[\"^84\",[107,\"^X\",false,536870913]],[\"^84\",[107,\"^1K\",\"^8C\",536870913]],[\"^84\",[108,\"^48\",1775980635488,536870913]],[\"^84\",[108,\"^7M\",\"code mode\",536870913]],[\"^84\",[108,\"^7@\",\"d3BFU\",536870913]],[\"^84\",[108,\"^69\",124,536870913]],[\"^84\",[108,\"^6:\",\"Code Mode\",536870913]],[\"^84\",[108,\"^3;\",1775980635488,536870913]],[\"^84\",[108,\"^2T\",\"~u00000002-8578-9716-5000-000000000000\",536870913]],[\"^84\",[108,\"^<\",\"^=\",536870913]],[\"^84\",[108,\"^;\",\"^40\",536870913]],[\"^84\",[108,\"^>\",true,536870913]],[\"^84\",[108,\"^57\",true,536870913]],[\"^84\",[108,\"^5J\",true,536870913]],[\"^84\",[108,\"^X\",false,536870913]],[\"^84\",[108,\"^1K\",\"^87\",536870913]],[\"^84\",[108,\"^44\",\"^8E\",536870913]],[\"^84\",[109,\"^48\",1775980635495,536870913]],[\"^84\",[109,\"^7M\",\"node created by\",536870913]],[\"^84\",[109,\"^7@\",\"d3BHF\",536870913]],[\"^84\",[109,\"^69\",124,536870913]],[\"^84\",[109,\"^6:\",\"Node created by\",536870913]],[\"^84\",[109,\"^3;\",1775980635495,536870913]],[\"^84\",[109,\"^2T\",\"~u00000002-8544-3390-8000-000000000000\",536870913]],[\"^84\",[109,\"^<\",\"^=\",536870913]],[\"^84\",[109,\"^;\",\"^4<\",536870913]],[\"^84\",[109,\"^>\",true,536870913]],[\"^84\",[109,\"^C\",\"^D\",536870913]],[\"^84\",[109,\"^57\",true,536870913]],[\"^84\",[109,\"^5J\",true,536870913]],[\"^84\",[109,\"^1K\",\"^8=\",536870913]],[\"^84\",[110,\"^48\",1775980635491,536870913]],[\"^84\",[110,\"^7M\",\"choice exclusions\",536870913]],[\"^84\",[110,\"^7@\",\"d3BG5\",536870913]],[\"^84\",[110,\"^69\",124,536870913]],[\"^84\",[110,\"^6:\",\"Choice exclusions\",536870913]],[\"^84\",[110,\"^3;\",1775980635491,536870913]],[\"^84\",[110,\"^2T\",\"~u00000002-1233-5229-4600-000000000000\",536870913]],[\"^84\",[110,\"^<\",\"^14\",536870913]],[\"^84\",[110,\"^;\",\"^13\",536870913]],[\"^84\",[110,\"^>\",true,536870913]],[\"^84\",[110,\"^C\",\"^D\",536870913]],[\"^84\",[110,\"^57\",true,536870913]],[\"^84\",[110,\"^5J\",true,536870913]],[\"^84\",[110,\"^X\",false,536870913]],[\"^84\",[110,\"^1K\",\"^85\",536870913]],[\"^84\",[110,\"^44\",\"^8J\",536870913]],[\"^84\",[111,\"^;\",\"^R\",536870913]],[\"^84\",[111,\"^28\",[\"^ \",\"^8F\",65,\"^8G\",24],536870913]],[\"^84\",[112,\"^48\",1775980635490,536870913]],[\"^84\",[112,\"^7M\",\"title format\",536870913]],[\"^84\",[112,\"^7@\",\"d3BG2\",536870913]],[\"^84\",[112,\"^69\",124,536870913]],[\"^84\",[112,\"^6:\",\"Title Format\",536870913]],[\"^84\",[112,\"^3;\",1775980635490,536870913]],[\"^84\",[112,\"^2T\",\"~u00000002-1536-4979-5400-000000000000\",536870913]],[\"^84\",[112,\"^<\",\"^=\",536870913]],[\"^84\",[112,\"^;\",\"^55\",536870913]],[\"^84\",[112,\"^>\",true,536870913]],[\"^84\",[112,\"^57\",true,536870913]],[\"^84\",[112,\"^X\",false,536870913]],[\"^84\",[112,\"^1K\",\"^87\",536870913]],[\"^84\",[113,\"^48\",1775980635490,536870913]],[\"^84\",[113,\"^7M\",\"included references\",536870913]],[\"^84\",[113,\"^7@\",\"d3BFy\",536870913]],[\"^84\",[113,\"^69\",124,536870913]],[\"^84\",[113,\"^6:\",\"Included references\",536870913]],[\"^84\",[113,\"^3;\",1775980635490,536870913]],[\"^84\",[113,\"^2T\",\"~u00000002-1680-5777-0300-000000000000\",536870913]],[\"^84\",[113,\"^<\",\"^14\",536870913]],[\"^84\",[113,\"^;\",\"^2F\",536870913]],[\"^84\",[113,\"^>\",true,536870913]],[\"^84\",[113,\"^C\",\"^D\",536870913]],[\"^84\",[113,\"^57\",true,536870913]],[\"^84\",[113,\"^5J\",true,536870913]],[\"^84\",[113,\"^1K\",\"^85\",536870913]],[\"^84\",[114,\"^L\",26,536870913]],[\"^84\",[114,\"^48\",1775980635491,536870913]],[\"^84\",[114,\"^7@\",\"d3BG9\",536870913]],[\"^84\",[114,\"^4S\",26,536870913]],[\"^84\",[114,\"^3X\",26,536870913]],[\"^84\",[114,\"^6:\",\"Todo\",536870913]],[\"^84\",[114,\"^3;\",1775980635491,536870913]],[\"^84\",[114,\"^2T\",\"~u00000002-1615-5853-7700-000000000000\",536870913]],[\"^84\",[114,\"^;\",\"^5H\",536870913]],[\"^84\",[114,\"^57\",true,536870913]],[\"^84\",[114,\"^4W\",false,536870913]],[\"^84\",[114,\"^6?\",26,536870913]],[\"^84\",[114,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Todo\"],536870913]],[\"^84\",[115,\"^48\",1775980635494,536870913]],[\"^84\",[115,\"^7M\",\"history property\",536870913]],[\"^84\",[115,\"^7@\",\"d3BHC\",536870913]],[\"^84\",[115,\"^69\",124,536870913]],[\"^84\",[115,\"^6:\",\"History property\",536870913]],[\"^84\",[115,\"^3;\",1775980635494,536870913]],[\"^84\",[115,\"^2T\",\"~u00000002-1600-4090-8200-000000000000\",536870913]],[\"^84\",[115,\"^<\",\"^=\",536870913]],[\"^84\",[115,\"^;\",\"^5V\",536870913]],[\"^84\",[115,\"^>\",true,536870913]],[\"^84\",[115,\"^C\",\"^D\",536870913]],[\"^84\",[115,\"^57\",true,536870913]],[\"^84\",[115,\"^5J\",true,536870913]],[\"^84\",[115,\"^1K\",\"^86\",536870913]],[\"^84\",[116,\"^48\",1775980635495,536870913]],[\"^84\",[116,\"^7M\",\"recycle original parent\",536870913]],[\"^84\",[116,\"^7@\",\"d3BHI\",536870913]],[\"^84\",[116,\"^69\",124,536870913]],[\"^84\",[116,\"^6:\",\"Recycle original parent\",536870913]],[\"^84\",[116,\"^3;\",1775980635495,536870913]],[\"^84\",[116,\"^2T\",\"~u00000002-4970-0399-4000-000000000000\",536870913]],[\"^84\",[116,\"^<\",\"^=\",536870913]],[\"^84\",[116,\"^;\",\"^63\",536870913]],[\"^84\",[116,\"^>\",true,536870913]],[\"^84\",[116,\"^C\",\"^D\",536870913]],[\"^84\",[116,\"^57\",true,536870913]],[\"^84\",[116,\"^5J\",true,536870913]],[\"^84\",[116,\"^X\",false,536870913]],[\"^84\",[116,\"^1K\",\"^85\",536870913]],[\"^84\",[117,\"^48\",1775980635490,536870913]],[\"^84\",[117,\"^7M\",\"annotation data\",536870913]],[\"^84\",[117,\"^7@\",\"d3BFw\",536870913]],[\"^84\",[117,\"^69\",124,536870913]],[\"^84\",[117,\"^6:\",\"Annotation data\",536870913]],[\"^84\",[117,\"^3;\",1775980635490,536870913]],[\"^84\",[117,\"^2T\",\"~u00000002-5458-2940-2000-000000000000\",536870913]],[\"^84\",[117,\"^<\",\"^=\",536870913]],[\"^84\",[117,\"^;\",\"^6C\",536870913]],[\"^84\",[117,\"^>\",true,536870913]],[\"^84\",[117,\"^57\",true,536870913]],[\"^84\",[117,\"^5J\",true,536870913]],[\"^84\",[117,\"^1K\",\"^88\",536870913]],[\"^84\",[118,\"^48\",1775980635492,536870913]],[\"^84\",[118,\"^7M\",\"view group by property\",536870913]],[\"^84\",[118,\"^7@\",\"d3BGi\",536870913]],[\"^84\",[118,\"^69\",124,536870913]],[\"^84\",[118,\"^6:\",\"View group by property\",536870913]],[\"^84\",[118,\"^3;\",1775980635492,536870913]],[\"^84\",[118,\"^2T\",\"~u00000002-8092-1623-6000-000000000000\",536870913]],[\"^84\",[118,\"^<\",\"^=\",536870913]],[\"^84\",[118,\"^;\",\"^43\",536870913]],[\"^84\",[118,\"^>\",true,536870913]],[\"^84\",[118,\"^C\",\"^D\",536870913]],[\"^84\",[118,\"^57\",true,536870913]],[\"^84\",[118,\"^5J\",true,536870913]],[\"^84\",[118,\"^X\",false,536870913]],[\"^84\",[118,\"^1K\",\"^86\",536870913]],[\"^84\",[119,\"^48\",1775980635487,536870913]],[\"^84\",[119,\"^7M\",\"hide this property or page\",536870913]],[\"^84\",[119,\"^7@\",\"d3BF8\",536870913]],[\"^84\",[119,\"^69\",124,536870913]],[\"^84\",[119,\"^6:\",\"Hide this property or page\",536870913]],[\"^84\",[119,\"^3;\",1775980635487,536870913]],[\"^84\",[119,\"^2T\",\"~u00000002-6836-5746-0000-000000000000\",536870913]],[\"^84\",[119,\"^<\",\"^=\",536870913]],[\"^84\",[119,\"^;\",\"^5J\",536870913]],[\"^84\",[119,\"^>\",true,536870913]],[\"^84\",[119,\"^57\",true,536870913]],[\"^84\",[119,\"^5J\",true,536870913]],[\"^84\",[119,\"^1K\",\"^8?\",536870913]],[\"^84\",[120,\"^48\",1775980635488,536870913]],[\"^84\",[120,\"^7M\",\"node links to\",536870913]],[\"^84\",[120,\"^7@\",\"d3BFM\",536870913]],[\"^84\",[120,\"^69\",124,536870913]],[\"^84\",[120,\"^6:\",\"Node links to\",536870913]],[\"^84\",[120,\"^3;\",1775980635488,536870913]],[\"^84\",[120,\"^2T\",\"~u00000002-1872-3999-9300-000000000000\",536870913]],[\"^84\",[120,\"^<\",\"^=\",536870913]],[\"^84\",[120,\"^;\",\"^2I\",536870913]],[\"^84\",[120,\"^>\",true,536870913]],[\"^84\",[120,\"^C\",\"^D\",536870913]],[\"^84\",[120,\"^57\",true,536870913]],[\"^84\",[120,\"^5J\",true,536870913]],[\"^84\",[120,\"^X\",false,536870913]],[\"^84\",[120,\"^1K\",\"^8=\",536870913]],[\"^84\",[121,\"^48\",1775980635489,536870913]],[\"^84\",[121,\"^7M\",\"tag properties\",536870913]],[\"^84\",[121,\"^7@\",\"d3BFZ\",536870913]],[\"^84\",[121,\"^69\",124,536870913]],[\"^84\",[121,\"^6:\",\"Tag Properties\",536870913]],[\"^84\",[121,\"^3;\",1775980635489,536870913]],[\"^84\",[121,\"^2T\",\"~u00000002-2123-7120-5000-000000000000\",536870913]],[\"^84\",[121,\"^<\",\"^14\",536870913]],[\"^84\",[121,\"^;\",\"^4K\",536870913]],[\"^84\",[121,\"^>\",true,536870913]],[\"^84\",[121,\"^C\",\"^D\",536870913]],[\"^84\",[121,\"^57\",true,536870913]],[\"^84\",[121,\"^X\",true,536870913]],[\"^84\",[121,\"^1K\",\"^86\",536870913]],[\"^84\",[121,\"^44\",\"^8J\",536870913]],[\"^84\",[122,\"^48\",1775980635488,536870913]],[\"^84\",[122,\"^7M\",\"node order\",536870913]],[\"^84\",[122,\"^7@\",\"d3BFI\",536870913]],[\"^84\",[122,\"^69\",124,536870913]],[\"^84\",[122,\"^6:\",\"Node order\",536870913]],[\"^84\",[122,\"^3;\",1775980635488,536870913]],[\"^84\",[122,\"^2T\",\"~u00000002-1429-2824-3700-000000000000\",536870913]],[\"^84\",[122,\"^<\",\"^=\",536870913]],[\"^84\",[122,\"^;\",\"^7@\",536870913]],[\"^84\",[122,\"^>\",true,536870913]],[\"^84\",[122,\"^57\",true,536870913]],[\"^84\",[122,\"^5J\",true,536870913]],[\"^84\",[122,\"^X\",false,536870913]],[\"^84\",[122,\"^1K\",\"^87\",536870913]],[\"^84\",[123,\"^48\",1775980635494,536870913]],[\"^84\",[123,\"^7M\",\"enable property history\",536870913]],[\"^84\",[123,\"^7@\",\"d3BH9\",536870913]],[\"^84\",[123,\"^69\",124,536870913]],[\"^84\",[123,\"^6:\",\"Enable property history\",536870913]],[\"^84\",[123,\"^3;\",1775980635494,536870913]],[\"^84\",[123,\"^2T\",\"~u00000002-8058-5960-2000-000000000000\",536870913]],[\"^84\",[123,\"^<\",\"^=\",536870913]],[\"^84\",[123,\"^;\",\"^4N\",536870913]],[\"^84\",[123,\"^>\",true,536870913]],[\"^84\",[123,\"^57\",true,536870913]],[\"^84\",[123,\"^2A\",162,536870913]],[\"^84\",[123,\"^X\",true,536870913]],[\"^84\",[123,\"^1K\",\"^8?\",536870913]],[\"^84\",[123,\"^44\",\"^86\",536870913]],[\"^84\",[124,\"^48\",1775980635496,536870913]],[\"^84\",[124,\"^7M\",\"property\",536870913]],[\"^84\",[124,\"^69\",14,536870913]],[\"^84\",[124,\"^6:\",\"Property\",536870913]],[\"^84\",[124,\"^3;\",1775980635496,536870913]],[\"^84\",[124,\"^2T\",\"~u00000002-1038-7670-4800-000000000000\",536870913]],[\"^84\",[124,\"^;\",\"^30\",536870913]],[\"^84\",[124,\"^57\",true,536870913]],[\"^84\",[124,\"^3K\",89,536870913]],[\"^84\",[125,\"^48\",1775980635489,536870913]],[\"^84\",[125,\"^7M\",\"page tags\",536870913]],[\"^84\",[125,\"^7@\",\"d3BFh\",536870913]],[\"^84\",[125,\"^69\",124,536870913]],[\"^84\",[125,\"^6:\",\"Page Tags\",536870913]],[\"^84\",[125,\"^3;\",1775980635489,536870913]],[\"^84\",[125,\"^2T\",\"~u00000002-2133-5311-8500-000000000000\",536870913]],[\"^84\",[125,\"^<\",\"^14\",536870913]],[\"^84\",[125,\"^;\",\"^4X\",536870913]],[\"^84\",[125,\"^>\",true,536870913]],[\"^84\",[125,\"^C\",\"^D\",536870913]],[\"^84\",[125,\"^57\",true,536870913]],[\"^84\",[125,\"^2A\",164,536870913]],[\"^84\",[125,\"^X\",true,536870913]],[\"^84\",[125,\"^1K\",\"^89\",536870913]],[\"^84\",[125,\"^44\",\"^89\",536870913]],[\"^84\",[126,\"^L\",26,536870913]],[\"^84\",[126,\"^48\",1775980635491,536870913]],[\"^84\",[126,\"^7@\",\"d3BG8\",536870913]],[\"^84\",[126,\"^4S\",26,536870913]],[\"^84\",[126,\"^3X\",26,536870913]],[\"^84\",[126,\"^6:\",\"Backlog\",536870913]],[\"^84\",[126,\"^3;\",1775980635491,536870913]],[\"^84\",[126,\"^2T\",\"~u00000002-7233-3491-0000-000000000000\",536870913]],[\"^84\",[126,\"^;\",\"^4A\",536870913]],[\"^84\",[126,\"^57\",true,536870913]],[\"^84\",[126,\"^6?\",26,536870913]],[\"^84\",[126,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Backlog\"],536870913]],[\"^84\",[127,\"^48\",1775980635492,536870913]],[\"^84\",[127,\"^7M\",\"view type\",536870913]],[\"^84\",[127,\"^7@\",\"d3BGd\",536870913]],[\"^84\",[127,\"^69\",124,536870913]],[\"^84\",[127,\"^6:\",\"View Type\",536870913]],[\"^84\",[127,\"^3;\",1775980635492,536870913]],[\"^84\",[127,\"^2T\",\"~u00000002-2182-3760-7000-000000000000\",536870913]],[\"^84\",[127,\"^<\",\"^=\",536870913]],[\"^84\",[127,\"^;\",\"^2J\",536870913]],[\"^84\",[127,\"^>\",true,536870913]],[\"^84\",[127,\"^C\",\"^D\",536870913]],[\"^84\",[127,\"^57\",true,536870913]],[\"^84\",[127,\"^5L\",70,536870913]],[\"^84\",[127,\"^5J\",true,536870913]],[\"^84\",[127,\"^X\",false,536870913]],[\"^84\",[127,\"^1K\",\"^8>\",536870913]],[\"^84\",[128,\"^48\",1775980635493,536870913]],[\"^84\",[128,\"^7M\",\"this view belongs to\",536870913]],[\"^84\",[128,\"^7@\",\"d3BGr\",536870913]],[\"^84\",[128,\"^69\",124,536870913]],[\"^84\",[128,\"^6:\",\"This view belongs to\",536870913]],[\"^84\",[128,\"^3;\",1775980635493,536870913]],[\"^84\",[128,\"^2T\",\"~u00000002-3627-4319-0000-000000000000\",536870913]],[\"^84\",[128,\"^<\",\"^=\",536870913]],[\"^84\",[128,\"^;\",\"^9\",536870913]],[\"^84\",[128,\"^>\",true,536870913]],[\"^84\",[128,\"^C\",\"^D\",536870913]],[\"^84\",[128,\"^57\",true,536870913]],[\"^84\",[128,\"^5J\",true,536870913]],[\"^84\",[128,\"^X\",false,536870913]],[\"^84\",[128,\"^1K\",\"^85\",536870913]],[\"^84\",[129,\"^48\",1775980635494,536870913]],[\"^84\",[129,\"^7M\",\"user avatar\",536870913]],[\"^84\",[129,\"^7@\",\"d3BH8\",536870913]],[\"^84\",[129,\"^69\",124,536870913]],[\"^84\",[129,\"^6:\",\"User Avatar\",536870913]],[\"^84\",[129,\"^3;\",1775980635494,536870913]],[\"^84\",[129,\"^2T\",\"~u00000002-4165-4885-8000-000000000000\",536870913]],[\"^84\",[129,\"^<\",\"^=\",536870913]],[\"^84\",[129,\"^;\",\"^O\",536870913]],[\"^84\",[129,\"^>\",true,536870913]],[\"^84\",[129,\"^57\",true,536870913]],[\"^84\",[129,\"^5J\",false,536870913]],[\"^84\",[129,\"^X\",true,536870913]],[\"^84\",[129,\"^1K\",\"^87\",536870913]],[\"^84\",[130,\"^48\",1775980635494,536870913]],[\"^84\",[130,\"^7M\",\"user name\",536870913]],[\"^84\",[130,\"^7@\",\"d3BH6\",536870913]],[\"^84\",[130,\"^69\",124,536870913]],[\"^84\",[130,\"^6:\",\"User Name\",536870913]],[\"^84\",[130,\"^3;\",1775980635494,536870913]],[\"^84\",[130,\"^2T\",\"~u00000002-1360-0260-1600-000000000000\",536870913]],[\"^84\",[130,\"^<\",\"^=\",536870913]],[\"^84\",[130,\"^;\",\"^E\",536870913]],[\"^84\",[130,\"^>\",true,536870913]],[\"^84\",[130,\"^57\",true,536870913]],[\"^84\",[130,\"^5J\",false,536870913]],[\"^84\",[130,\"^X\",true,536870913]],[\"^84\",[130,\"^1K\",\"^87\",536870913]],[\"^84\",[131,\"^48\",1775980635493,536870913]],[\"^84\",[131,\"^7M\",\"file size\",536870913]],[\"^84\",[131,\"^7@\",\"d3BGv\",536870913]],[\"^84\",[131,\"^69\",124,536870913]],[\"^84\",[131,\"^6:\",\"File Size\",536870913]],[\"^84\",[131,\"^3;\",1775980635493,536870913]],[\"^84\",[131,\"^2T\",\"~u00000002-1167-8621-9000-000000000000\",536870913]],[\"^84\",[131,\"^<\",\"^=\",536870913]],[\"^84\",[131,\"^;\",\"^1?\",536870913]],[\"^84\",[131,\"^>\",true,536870913]],[\"^84\",[131,\"^57\",true,536870913]],[\"^84\",[131,\"^5J\",true,536870913]],[\"^84\",[131,\"^X\",false,536870913]],[\"^84\",[131,\"^1K\",\"^8C\",536870913]],[\"^84\",[132,\"^48\",1775980635489,536870913]],[\"^84\",[132,\"^7M\",\"bidirectional property title\",536870913]],[\"^84\",[132,\"^7@\",\"d3BFa\",536870913]],[\"^84\",[132,\"^69\",124,536870913]],[\"^84\",[132,\"^6:\",\"Bidirectional property title\",536870913]],[\"^84\",[132,\"^3;\",1775980635489,536870913]],[\"^84\",[132,\"^2T\",\"~u00000002-6050-5418-7000-000000000000\",536870913]],[\"^84\",[132,\"^<\",\"^=\",536870913]],[\"^84\",[132,\"^;\",\"^1I\",536870913]],[\"^84\",[132,\"^>\",true,536870913]],[\"^84\",[132,\"^57\",true,536870913]],[\"^84\",[132,\"^X\",true,536870913]],[\"^84\",[132,\"^1K\",\"^87\",536870913]],[\"^84\",[132,\"^44\",\"^8@\",536870913]],[\"^84\",[133,\"^48\",1775980635489,536870913]],[\"^84\",[133,\"^7M\",\"hide from node\",536870913]],[\"^84\",[133,\"^7@\",\"d3BFf\",536870913]],[\"^84\",[133,\"^69\",124,536870913]],[\"^84\",[133,\"^6:\",\"Hide from Node\",536870913]],[\"^84\",[133,\"^3;\",1775980635489,536870913]],[\"^84\",[133,\"^2T\",\"~u00000002-2610-3727-0000-000000000000\",536870913]],[\"^84\",[133,\"^<\",\"^=\",536870913]],[\"^84\",[133,\"^;\",\"^1X\",536870913]],[\"^84\",[133,\"^>\",true,536870913]],[\"^84\",[133,\"^57\",true,536870913]],[\"^84\",[133,\"^X\",true,536870913]],[\"^84\",[133,\"^1K\",\"^8?\",536870913]],[\"^84\",[133,\"^44\",\"^8@\",536870913]],[\"^84\",[134,\"^48\",1775980635492,536870913]],[\"^84\",[134,\"^7M\",\"repeating temporal property\",536870913]],[\"^84\",[134,\"^7@\",\"d3BGX\",536870913]],[\"^84\",[134,\"^69\",124,536870913]],[\"^84\",[134,\"^6:\",\"Repeating Temporal Property\",536870913]],[\"^84\",[134,\"^3;\",1775980635492,536870913]],[\"^84\",[134,\"^2T\",\"~u00000002-8346-1078-4000-000000000000\",536870913]],[\"^84\",[134,\"^<\",\"^=\",536870913]],[\"^84\",[134,\"^;\",\"^B\",536870913]],[\"^84\",[134,\"^>\",true,536870913]],[\"^84\",[134,\"^C\",\"^D\",536870913]],[\"^84\",[134,\"^57\",true,536870913]],[\"^84\",[134,\"^5J\",true,536870913]],[\"^84\",[134,\"^1K\",\"^86\",536870913]],[\"^84\",[135,\"^L\",13,536870913]],[\"^84\",[135,\"^48\",1775980635492,536870913]],[\"^84\",[135,\"^7@\",\"d3BGQ\",536870913]],[\"^84\",[135,\"^4S\",13,536870913]],[\"^84\",[135,\"^3X\",13,536870913]],[\"^84\",[135,\"^6:\",\"Minute\",536870913]],[\"^84\",[135,\"^3;\",1775980635492,536870913]],[\"^84\",[135,\"^2T\",\"~u00000002-1513-6550-8500-000000000000\",536870913]],[\"^84\",[135,\"^;\",\"^2E\",536870913]],[\"^84\",[135,\"^57\",true,536870913]],[\"^84\",[135,\"^6?\",13,536870913]],[\"^84\",[136,\"^48\",1775980635489,536870913]],[\"^84\",[136,\"^7M\",\"query\",536870913]],[\"^84\",[136,\"^7@\",\"d3BFg\",536870913]],[\"^84\",[136,\"^69\",124,536870913]],[\"^84\",[136,\"^6:\",\"Query\",536870913]],[\"^84\",[136,\"^3;\",1775980635489,536870913]],[\"^84\",[136,\"^2T\",\"~u00000002-9741-4126-0000-000000000000\",536870913]],[\"^84\",[136,\"^<\",\"^=\",536870913]],[\"^84\",[136,\"^;\",\"^2X\",536870913]],[\"^84\",[136,\"^>\",true,536870913]],[\"^84\",[136,\"^C\",\"^D\",536870913]],[\"^84\",[136,\"^57\",true,536870913]],[\"^84\",[136,\"^5J\",true,536870913]],[\"^84\",[136,\"^X\",true,536870913]],[\"^84\",[136,\"^1K\",\"^8>\",536870913]],[\"^84\",[136,\"^44\",\"^8E\",536870913]],[\"^84\",[137,\"^48\",1775980635494,536870913]],[\"^84\",[137,\"^7M\",\"user email\",536870913]],[\"^84\",[137,\"^7@\",\"d3BH7\",536870913]],[\"^84\",[137,\"^69\",124,536870913]],[\"^84\",[137,\"^6:\",\"User Email\",536870913]],[\"^84\",[137,\"^3;\",1775980635494,536870913]],[\"^84\",[137,\"^2T\",\"~u00000002-1655-2060-6300-000000000000\",536870913]],[\"^84\",[137,\"^<\",\"^=\",536870913]],[\"^84\",[137,\"^;\",\"^38\",536870913]],[\"^84\",[137,\"^>\",true,536870913]],[\"^84\",[137,\"^57\",true,536870913]],[\"^84\",[137,\"^5J\",false,536870913]],[\"^84\",[137,\"^X\",true,536870913]],[\"^84\",[137,\"^1K\",\"^87\",536870913]],[\"^84\",[138,\"^48\",1775980635488,536870913]],[\"^84\",[138,\"^7M\",\"node collapsed?\",536870913]],[\"^84\",[138,\"^7@\",\"d3BFJ\",536870913]],[\"^84\",[138,\"^69\",124,536870913]],[\"^84\",[138,\"^6:\",\"Node collapsed?\",536870913]],[\"^84\",[138,\"^3;\",1775980635488,536870913]],[\"^84\",[138,\"^2T\",\"~u00000002-2140-2109-9100-000000000000\",536870913]],[\"^84\",[138,\"^<\",\"^=\",536870913]],[\"^84\",[138,\"^;\",\"^3D\",536870913]],[\"^84\",[138,\"^>\",true,536870913]],[\"^84\",[138,\"^57\",true,536870913]],[\"^84\",[138,\"^5J\",true,536870913]],[\"^84\",[138,\"^X\",false,536870913]],[\"^84\",[138,\"^1K\",\"^8?\",536870913]],[\"^84\",[139,\"^L\",13,536870913]],[\"^84\",[139,\"^48\",1775980635492,536870913]],[\"^84\",[139,\"^7@\",\"d3BGR\",536870913]],[\"^84\",[139,\"^4S\",13,536870913]],[\"^84\",[139,\"^3X\",13,536870913]],[\"^84\",[139,\"^6:\",\"Hour\",536870913]],[\"^84\",[139,\"^3;\",1775980635492,536870913]],[\"^84\",[139,\"^2T\",\"~u00000002-1438-8849-5400-000000000000\",536870913]],[\"^84\",[139,\"^;\",\"^3O\",536870913]],[\"^84\",[139,\"^57\",true,536870913]],[\"^84\",[139,\"^6?\",13,536870913]],[\"^84\",[140,\"^48\",1775980635490,536870913]],[\"^84\",[140,\"^7M\",\"tldraw page\",536870913]],[\"^84\",[140,\"^7@\",\"d3BG0\",536870913]],[\"^84\",[140,\"^69\",124,536870913]],[\"^84\",[140,\"^6:\",\"Tldraw Page\",536870913]],[\"^84\",[140,\"^3;\",1775980635490,536870913]],[\"^84\",[140,\"^2T\",\"~u00000002-3546-2145-7000-000000000000\",536870913]],[\"^84\",[140,\"^<\",\"^=\",536870913]],[\"^84\",[140,\"^;\",\"^42\",536870913]],[\"^84\",[140,\"^>\",true,536870913]],[\"^84\",[140,\"^57\",true,536870913]],[\"^84\",[140,\"^5J\",true,536870913]],[\"^84\",[140,\"^1K\",\"^88\",536870913]],[\"^84\",[141,\"^;\",\"^31\",536870913]],[\"^84\",[141,\"^28\",\"bf04d4cf5-dirty\",536870913]],[\"^84\",[142,\"^48\",1775980635496,536870913]],[\"^84\",[142,\"^7M\",\"query\",536870913]],[\"^84\",[142,\"^69\",14,536870913]],[\"^84\",[142,\"^6:\",\"Query\",536870913]],[\"^84\",[142,\"^3;\",1775980635496,536870913]],[\"^84\",[142,\"^2T\",\"~u00000002-2324-8016-6000-000000000000\",536870913]],[\"^84\",[142,\"^;\",\"^4I\",536870913]],[\"^84\",[142,\"^57\",true,536870913]],[\"^84\",[142,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[142,\"^3K\",89,536870913]],[\"^84\",[142,\"^4K\",136,536870913]],[\"^84\",[143,\"^48\",1775980635491,536870913]],[\"^84\",[143,\"^7M\",\"choice checkbox state\",536870913]],[\"^84\",[143,\"^7@\",\"d3BG3\",536870913]],[\"^84\",[143,\"^69\",124,536870913]],[\"^84\",[143,\"^6:\",\"Choice checkbox state\",536870913]],[\"^84\",[143,\"^3;\",1775980635491,536870913]],[\"^84\",[143,\"^2T\",\"~u00000002-1272-4228-6300-000000000000\",536870913]],[\"^84\",[143,\"^<\",\"^=\",536870913]],[\"^84\",[143,\"^;\",\"^4W\",536870913]],[\"^84\",[143,\"^>\",true,536870913]],[\"^84\",[143,\"^57\",true,536870913]],[\"^84\",[143,\"^5J\",true,536870913]],[\"^84\",[143,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^48\",1775980635489,536870913]],[\"^84\",[144,\"^7M\",\"hide empty value\",536870913]],[\"^84\",[144,\"^7@\",\"d3BFd\",536870913]],[\"^84\",[144,\"^69\",124,536870913]],[\"^84\",[144,\"^6:\",\"Hide empty value\",536870913]],[\"^84\",[144,\"^3;\",1775980635489,536870913]],[\"^84\",[144,\"^2T\",\"~u00000002-2062-3258-9900-000000000000\",536870913]],[\"^84\",[144,\"^<\",\"^=\",536870913]],[\"^84\",[144,\"^;\",\"^3P\",536870913]],[\"^84\",[144,\"^>\",true,536870913]],[\"^84\",[144,\"^57\",true,536870913]],[\"^84\",[144,\"^2A\",160,536870913]],[\"^84\",[144,\"^X\",true,536870913]],[\"^84\",[144,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^44\",\"^86\",536870913]],[\"^84\",[145,\"^48\",1775980635488,536870913]],[\"^84\",[145,\"^7M\",\"node updated at\",536870913]],[\"^84\",[145,\"^7@\",\"d3BFR\",536870913]],[\"^84\",[145,\"^69\",124,536870913]],[\"^84\",[145,\"^6:\",\"Node updated at\",536870913]],[\"^84\",[145,\"^3;\",1775980635488,536870913]],[\"^84\",[145,\"^2T\",\"~u00000002-1516-5505-5100-000000000000\",536870913]],[\"^84\",[145,\"^<\",\"^=\",536870913]],[\"^84\",[145,\"^;\",\"^3;\",536870913]],[\"^84\",[145,\"^>\",true,536870913]],[\"^84\",[145,\"^57\",true,536870913]],[\"^84\",[145,\"^5J\",true,536870913]],[\"^84\",[145,\"^X\",false,536870913]],[\"^84\",[145,\"^1K\",\"^8A\",536870913]],[\"^84\",[146,\"^48\",1775980635493,536870913]],[\"^84\",[146,\"^7M\",\"external url\",536870913]],[\"^84\",[146,\"^7@\",\"d3BGt\",536870913]],[\"^84\",[146,\"^69\",124,536870913]],[\"^84\",[146,\"^6:\",\"External URL\",536870913]],[\"^84\",[146,\"^3;\",1775980635493,536870913]],[\"^84\",[146,\"^2T\",\"~u00000002-1364-7751-6300-000000000000\",536870913]],[\"^84\",[146,\"^<\",\"^=\",536870913]],[\"^84\",[146,\"^;\",\"^20\",536870913]],[\"^84\",[146,\"^>\",true,536870913]],[\"^84\",[146,\"^57\",true,536870913]],[\"^84\",[146,\"^5J\",false,536870913]],[\"^84\",[146,\"^X\",true,536870913]],[\"^84\",[146,\"^1K\",\"^87\",536870913]],[\"^84\",[147,\"^48\",1775980635495,536870913]],[\"^84\",[147,\"^7M\",\"apply template to tags\",536870913]],[\"^84\",[147,\"^7@\",\"d3BHO\",536870913]],[\"^84\",[147,\"^69\",124,536870913]],[\"^84\",[147,\"^6:\",\"Apply template to tags\",536870913]],[\"^84\",[147,\"^3;\",1775980635495,536870913]],[\"^84\",[147,\"^2T\",\"~u00000002-4291-2432-2000-000000000000\",536870913]],[\"^84\",[147,\"^<\",\"^14\",536870913]],[\"^84\",[147,\"^;\",\"^5X\",536870913]],[\"^84\",[147,\"^>\",true,536870913]],[\"^84\",[147,\"^C\",\"^D\",536870913]],[\"^84\",[147,\"^57\",true,536870913]],[\"^84\",[147,\"^X\",true,536870913]],[\"^84\",[147,\"^1K\",\"^8@\",536870913]],[\"^84\",[148,\"^L\",26,536870913]],[\"^84\",[148,\"^48\",1775980635491,536870913]],[\"^84\",[148,\"^7@\",\"d3BGB\",536870913]],[\"^84\",[148,\"^4S\",26,536870913]],[\"^84\",[148,\"^3X\",26,536870913]],[\"^84\",[148,\"^6:\",\"In Review\",536870913]],[\"^84\",[148,\"^3;\",1775980635491,536870913]],[\"^84\",[148,\"^2T\",\"~u00000002-1870-0014-4300-000000000000\",536870913]],[\"^84\",[148,\"^;\",\"^66\",536870913]],[\"^84\",[148,\"^57\",true,536870913]],[\"^84\",[148,\"^6?\",26,536870913]],[\"^84\",[148,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"InReview\"],536870913]],[\"^84\",[149,\"^48\",1775980635492,536870913]],[\"^84\",[149,\"^7M\",\"published url\",536870913]],[\"^84\",[149,\"^7@\",\"d3BGb\",536870913]],[\"^84\",[149,\"^69\",124,536870913]],[\"^84\",[149,\"^6:\",\"Published URL\",536870913]],[\"^84\",[149,\"^3;\",1775980635492,536870913]],[\"^84\",[149,\"^2T\",\"~u00000002-2045-1825-0100-000000000000\",536870913]],[\"^84\",[149,\"^<\",\"^=\",536870913]],[\"^84\",[149,\"^;\",\"^1[\",536870913]],[\"^84\",[149,\"^>\",true,536870913]],[\"^84\",[149,\"^C\",\"^D\",536870913]],[\"^84\",[149,\"^57\",true,536870913]],[\"^84\",[149,\"^X\",true,536870913]],[\"^84\",[149,\"^1K\",\"~:url\",536870913]],[\"^84\",[149,\"^44\",\"^89\",536870913]],[\"^84\",[150,\"^L\",127,536870913]],[\"^84\",[150,\"^48\",1775980635492,536870913]],[\"^84\",[150,\"^7@\",\"d3BGg\",536870913]],[\"^84\",[150,\"^4S\",127,536870913]],[\"^84\",[150,\"^3X\",127,536870913]],[\"^84\",[150,\"^6:\",\"Gallery View\",536870913]],[\"^84\",[150,\"^3;\",1775980635492,536870913]],[\"^84\",[150,\"^2T\",\"~u00000002-1506-0511-2000-000000000000\",536870913]],[\"^84\",[150,\"^;\",\"^6L\",536870913]],[\"^84\",[150,\"^57\",true,536870913]],[\"^84\",[150,\"^6?\",127,536870913]],[\"^84\",[150,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"layout-grid\"],536870913]],[\"^84\",[151,\"^2T\",\"~u00000004-1595-0218-3700-000000000000\",536870913]],[\"^84\",[151,\"^;\",\"^5Y\",536870913]],[\"^84\",[152,\"^48\",1775980635496,536870913]],[\"^84\",[152,\"^7M\",\"task\",536870913]],[\"^84\",[152,\"^69\",14,536870913]],[\"^84\",[152,\"^6:\",\"Task\",536870913]],[\"^84\",[152,\"^3;\",1775980635496,536870913]],[\"^84\",[152,\"^2T\",\"~u00000002-1282-1814-5700-000000000000\",536870913]],[\"^84\",[152,\"^;\",\"^4Z\",536870913]],[\"^84\",[152,\"^57\",true,536870913]],[\"^84\",[152,\"^3K\",89,536870913]],[\"^84\",[152,\"^4K\",26,536870913]],[\"^84\",[152,\"^4K\",32,536870913]],[\"^84\",[152,\"^4K\",75,536870913]],[\"^84\",[152,\"^4K\",155,536870913]],[\"^84\",[153,\"^L\",32,536870913]],[\"^84\",[153,\"^48\",1775980635491,536870913]],[\"^84\",[153,\"^7@\",\"d3BGG\",536870913]],[\"^84\",[153,\"^4S\",32,536870913]],[\"^84\",[153,\"^3X\",32,536870913]],[\"^84\",[153,\"^6:\",\"Medium\",536870913]],[\"^84\",[153,\"^3;\",1775980635491,536870913]],[\"^84\",[153,\"^2T\",\"~u00000002-1829-3222-7800-000000000000\",536870913]],[\"^84\",[153,\"^;\",\"^7:\",536870913]],[\"^84\",[153,\"^57\",true,536870913]],[\"^84\",[153,\"^6?\",32,536870913]],[\"^84\",[153,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlMedium\"],536870913]],[\"^84\",[154,\"^48\",1775980635486,536870913]],[\"^84\",[154,\"^7M\",\"property type\",536870913]],[\"^84\",[154,\"^7@\",\"d3BF6\",536870913]],[\"^84\",[154,\"^69\",124,536870913]],[\"^84\",[154,\"^6:\",\"Property type\",536870913]],[\"^84\",[154,\"^3;\",1775980635486,536870913]],[\"^84\",[154,\"^2T\",\"~u00000002-8384-2404-0000-000000000000\",536870913]],[\"^84\",[154,\"^<\",\"^=\",536870913]],[\"^84\",[154,\"^;\",\"^1K\",536870913]],[\"^84\",[154,\"^>\",true,536870913]],[\"^84\",[154,\"^57\",true,536870913]],[\"^84\",[154,\"^5J\",true,536870913]],[\"^84\",[154,\"^1K\",\"^8D\",536870913]],[\"^84\",[155,\"^48\",1775980635491,536870913]],[\"^84\",[155,\"^7M\",\"scheduled\",536870913]],[\"^84\",[155,\"^7@\",\"d3BGL\",536870913]],[\"^84\",[155,\"^69\",124,536870913]],[\"^84\",[155,\"^6:\",\"Scheduled\",536870913]],[\"^84\",[155,\"^3;\",1775980635491,536870913]],[\"^84\",[155,\"^2T\",\"~u00000002-1644-5209-4300-000000000000\",536870913]],[\"^84\",[155,\"^<\",\"^=\",536870913]],[\"^84\",[155,\"^;\",\"^4Y\",536870913]],[\"^84\",[155,\"^>\",true,536870913]],[\"^84\",[155,\"^57\",true,536870913]],[\"^84\",[155,\"^2A\",177,536870913]],[\"^84\",[155,\"^3P\",true,536870913]],[\"^84\",[155,\"^X\",true,536870913]],[\"^84\",[155,\"^1K\",\"^8A\",536870913]],[\"^84\",[155,\"^@\",\"^8I\",536870913]],[\"^84\",[156,\"^48\",1775980635489,536870913]],[\"^84\",[156,\"^7M\",\"default value\",536870913]],[\"^84\",[156,\"^7@\",\"d3BFV\",536870913]],[\"^84\",[156,\"^69\",124,536870913]],[\"^84\",[156,\"^6:\",\"Default value\",536870913]],[\"^84\",[156,\"^3;\",1775980635489,536870913]],[\"^84\",[156,\"^2T\",\"~u00000002-8920-7966-2000-000000000000\",536870913]],[\"^84\",[156,\"^<\",\"^=\",536870913]],[\"^84\",[156,\"^;\",\"^5L\",536870913]],[\"^84\",[156,\"^>\",true,536870913]],[\"^84\",[156,\"^C\",\"^D\",536870913]],[\"^84\",[156,\"^57\",true,536870913]],[\"^84\",[156,\"^5J\",true,536870913]],[\"^84\",[156,\"^X\",false,536870913]],[\"^84\",[156,\"^1K\",\"^8=\",536870913]],[\"^84\",[156,\"^44\",\"^86\",536870913]],[\"^84\",[157,\"^48\",1775980635495,536870913]],[\"^84\",[157,\"^7M\",\"deleted by\",536870913]],[\"^84\",[157,\"^7@\",\"d3BHH\",536870913]],[\"^84\",[157,\"^69\",124,536870913]],[\"^84\",[157,\"^6:\",\"Deleted by\",536870913]],[\"^84\",[157,\"^3;\",1775980635495,536870913]],[\"^84\",[157,\"^2T\",\"~u00000002-1998-0431-1200-000000000000\",536870913]],[\"^84\",[157,\"^<\",\"^=\",536870913]],[\"^84\",[157,\"^;\",\"^6Y\",536870913]],[\"^84\",[157,\"^>\",true,536870913]],[\"^84\",[157,\"^C\",\"^D\",536870913]],[\"^84\",[157,\"^57\",true,536870913]],[\"^84\",[157,\"^5J\",true,536870913]],[\"^84\",[157,\"^X\",false,536870913]],[\"^84\",[157,\"^1K\",\"^8=\",536870913]],[\"^84\",[158,\"^48\",1775980635489,536870913]],[\"^84\",[158,\"^7M\",\"extends\",536870913]],[\"^84\",[158,\"^7@\",\"d3BFX\",536870913]],[\"^84\",[158,\"^69\",124,536870913]],[\"^84\",[158,\"^6:\",\"Extends\",536870913]],[\"^84\",[158,\"^3;\",1775980635489,536870913]],[\"^84\",[158,\"^2T\",\"~u00000002-7475-9380-3000-000000000000\",536870913]],[\"^84\",[158,\"^<\",\"^14\",536870913]],[\"^84\",[158,\"^;\",\"^3K\",536870913]],[\"^84\",[158,\"^>\",true,536870913]],[\"^84\",[158,\"^C\",\"^D\",536870913]],[\"^84\",[158,\"^57\",true,536870913]],[\"^84\",[158,\"^2A\",163,536870913]],[\"^84\",[158,\"^X\",true,536870913]],[\"^84\",[158,\"^1K\",\"^8@\",536870913]],[\"^84\",[158,\"^44\",\"^8@\",536870913]],[\"^84\",[159,\"^48\",1775980635493,536870913]],[\"^84\",[159,\"^7M\",\"view sorting\",536870913]],[\"^84\",[159,\"^7@\",\"d3BGl\",536870913]],[\"^84\",[159,\"^69\",124,536870913]],[\"^84\",[159,\"^6:\",\"View sorting\",536870913]],[\"^84\",[159,\"^3;\",1775980635493,536870913]],[\"^84\",[159,\"^2T\",\"~u00000002-2081-0259-4000-000000000000\",536870913]],[\"^84\",[159,\"^<\",\"^=\",536870913]],[\"^84\",[159,\"^;\",\"^12\",536870913]],[\"^84\",[159,\"^>\",true,536870913]],[\"^84\",[159,\"^57\",true,536870913]],[\"^84\",[159,\"^5J\",true,536870913]],[\"^84\",[159,\"^X\",false,536870913]],[\"^84\",[159,\"^1K\",\"^8L\",536870913]],[\"^84\",[160,\"^48\",1775980635489,536870913]],[\"^84\",[160,\"^7@\",\"d3BFe\",536870913]],[\"^84\",[160,\"^4S\",144,536870913]],[\"^84\",[160,\"^3X\",144,536870913]],[\"^84\",[160,\"^6:\",\"Hides a property's value on any node when empty e.g. when a property appears on a node through a tag.\",536870913]],[\"^84\",[160,\"^3;\",1775980635489,536870913]],[\"^84\",[160,\"^2T\",\"~u00000004-1118-8585-8000-000000000000\",536870913]],[\"^84\",[160,\"^57\",true,536870913]],[\"^84\",[160,\"^6?\",71,536870913]],[\"^84\",[161,\"^2T\",\"~u00000004-2116-2824-5200-000000000000\",536870913]],[\"^84\",[161,\"^1M\",\"\",536870913]],[\"^84\",[161,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^7N\",\"logseq/publish.js\",536870913]],[\"^84\",[162,\"^48\",1775980635494,536870913]],[\"^84\",[162,\"^7@\",\"d3BHA\",536870913]],[\"^84\",[162,\"^4S\",123,536870913]],[\"^84\",[162,\"^3X\",123,536870913]],[\"^84\",[162,\"^6:\",\"Records history anytime a property's value changes on a node.\",536870913]],[\"^84\",[162,\"^3;\",1775980635494,536870913]],[\"^84\",[162,\"^2T\",\"~u00000004-1438-1481-0000-000000000000\",536870913]],[\"^84\",[162,\"^57\",true,536870913]],[\"^84\",[162,\"^6?\",71,536870913]],[\"^84\",[163,\"^48\",1775980635489,536870913]],[\"^84\",[163,\"^7@\",\"d3BFY\",536870913]],[\"^84\",[163,\"^4S\",158,536870913]],[\"^84\",[163,\"^3X\",158,536870913]],[\"^84\",[163,\"^6:\",\"This enables tags to inherit properties from other tags\",536870913]],[\"^84\",[163,\"^3;\",1775980635489,536870913]],[\"^84\",[163,\"^2T\",\"~u00000004-4485-6574-6000-000000000000\",536870913]],[\"^84\",[163,\"^57\",true,536870913]],[\"^84\",[163,\"^6?\",71,536870913]],[\"^84\",[164,\"^48\",1775980635489,536870913]],[\"^84\",[164,\"^7@\",\"d3BFi\",536870913]],[\"^84\",[164,\"^4S\",125,536870913]],[\"^84\",[164,\"^3X\",125,536870913]],[\"^84\",[164,\"^6:\",\"Provides a way for a page to associate to another page i.e. backward compatible tagging.\",536870913]],[\"^84\",[164,\"^3;\",1775980635489,536870913]],[\"^84\",[164,\"^2T\",\"~u00000004-1223-8671-8000-000000000000\",536870913]],[\"^84\",[164,\"^57\",true,536870913]],[\"^84\",[164,\"^6?\",71,536870913]],[\"^84\",[165,\"^48\",1775980635496,536870913]],[\"^84\",[165,\"^7M\",\"library\",536870913]],[\"^84\",[165,\"^69\",104,536870913]],[\"^84\",[165,\"^6:\",\"Library\",536870913]],[\"^84\",[165,\"^3;\",1775980635496,536870913]],[\"^84\",[165,\"^2T\",\"~u00000004-1294-7765-6000-000000000000\",536870913]],[\"^84\",[165,\"^57\",true,536870913]],[\"^84\",[166,\"^48\",1775980635491,536870913]],[\"^84\",[166,\"^7@\",\"d3BGK\",536870913]],[\"^84\",[166,\"^4S\",75,536870913]],[\"^84\",[166,\"^3X\",75,536870913]],[\"^84\",[166,\"^6:\",\"Use it to finish something at a specific date(time).\",536870913]],[\"^84\",[166,\"^3;\",1775980635491,536870913]],[\"^84\",[166,\"^2T\",\"~u00000004-1356-3664-2200-000000000000\",536870913]],[\"^84\",[166,\"^57\",true,536870913]],[\"^84\",[166,\"^6?\",71,536870913]],[\"^84\",[167,\"^48\",1775980635494,536870913]],[\"^84\",[167,\"^7@\",\"d3BH1\",536870913]],[\"^84\",[167,\"^4S\",67,536870913]],[\"^84\",[167,\"^3X\",67,536870913]],[\"^84\",[167,\"^6:\",\"Metadata of asset in remote storage\",536870913]],[\"^84\",[167,\"^3;\",1775980635494,536870913]],[\"^84\",[167,\"^2T\",\"~u00000004-5450-2102-9000-000000000000\",536870913]],[\"^84\",[167,\"^57\",true,536870913]],[\"^84\",[167,\"^6?\",71,536870913]],[\"^84\",[168,\"^48\",1775980635496,536870913]],[\"^84\",[168,\"^7M\",\"quick add\",536870913]],[\"^84\",[168,\"^69\",104,536870913]],[\"^84\",[168,\"^6:\",\"Quick add\",536870913]],[\"^84\",[168,\"^3;\",1775980635496,536870913]],[\"^84\",[168,\"^2T\",\"~u00000004-7336-8251-3000-000000000000\",536870913]],[\"^84\",[168,\"^57\",true,536870913]],[\"^84\",[168,\"^5J\",true,536870913]],[\"^84\",[169,\"^48\",1775980635489,536870913]],[\"^84\",[169,\"^7@\",\"d3BFc\",536870913]],[\"^84\",[169,\"^4S\",20,536870913]],[\"^84\",[169,\"^3X\",20,536870913]],[\"^84\",[169,\"^6:\",\"When enabled, this tag will show reverse nodes that link to the current node via properties.\",536870913]],[\"^84\",[169,\"^3;\",1775980635489,536870913]],[\"^84\",[169,\"^2T\",\"~u00000004-1830-0481-2500-000000000000\",536870913]],[\"^84\",[169,\"^57\",true,536870913]],[\"^84\",[169,\"^6?\",71,536870913]],[\"^84\",[170,\"^48\",1775980635496,536870913]],[\"^84\",[170,\"^7M\",\"$$$views\",536870913]],[\"^84\",[170,\"^69\",104,536870913]],[\"^84\",[170,\"^6:\",\"$$$views\",536870913]],[\"^84\",[170,\"^3;\",1775980635496,536870913]],[\"^84\",[170,\"^2T\",\"~u00000004-1906-3437-5800-000000000000\",536870913]],[\"^84\",[170,\"^57\",true,536870913]],[\"^84\",[170,\"^5J\",true,536870913]],[\"^84\",[171,\"^48\",1775980635496,536870913]],[\"^84\",[171,\"^7M\",\"contents\",536870913]],[\"^84\",[171,\"^69\",104,536870913]],[\"^84\",[171,\"^6:\",\"Contents\",536870913]],[\"^84\",[171,\"^3;\",1775980635496,536870913]],[\"^84\",[171,\"^2T\",\"~u00000004-1690-2597-3200-000000000000\",536870913]],[\"^84\",[171,\"^57\",true,536870913]],[\"^84\",[172,\"^2T\",\"~u00000004-1713-4660-3800-000000000000\",536870913]],[\"^84\",[172,\"^1M\",\"\",536870913]],[\"^84\",[172,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^7N\",\"logseq/custom.css\",536870913]],[\"^84\",[173,\"^48\",1775980635496,536870913]],[\"^84\",[173,\"^7M\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^69\",104,536870913]],[\"^84\",[173,\"^6:\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^3;\",1775980635496,536870913]],[\"^84\",[173,\"^2T\",\"~u00000004-1018-5888-4100-000000000000\",536870913]],[\"^84\",[173,\"^57\",true,536870913]],[\"^84\",[173,\"^5J\",true,536870913]],[\"^84\",[174,\"^2T\",\"~u00000004-1335-6485-2300-000000000000\",536870913]],[\"^84\",[174,\"^1M\",\"\",536870913]],[\"^84\",[174,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^7N\",\"logseq/custom.js\",536870913]],[\"^84\",[175,\"^48\",1775980635491,536870913]],[\"^84\",[175,\"^7@\",\"d3BGO\",536870913]],[\"^84\",[175,\"^4S\",95,536870913]],[\"^84\",[175,\"^3X\",95,536870913]],[\"^84\",[175,\"^3;\",1775980635491,536870913]],[\"^84\",[175,\"^2T\",\"~u00000004-1654-1034-2600-000000000000\",536870913]],[\"^84\",[175,\"^57\",true,536870913]],[\"^84\",[175,\"^6?\",95,536870913]],[\"^84\",[175,\"^T\",1,536870913]],[\"^84\",[176,\"^2T\",\"~u00000004-3919-3813-3000-000000000000\",536870913]],[\"^84\",[176,\"^1M\",\"{:meta/version 1\\n\\n ;; Hide empty block properties\\n ;; Default value: false\\n ;; :ui/hide-empty-properties? false\\n\\n ;; Enable tooltip preview on hover.\\n ;; Default value: true\\n :ui/enable-tooltip? true\\n\\n ;; Display brackets [[]] around page references.\\n ;; Default value: true\\n ;; :ui/show-brackets? true\\n\\n ;; Display all lines of a block when referencing ((block)).\\n ;; Default value: false\\n :ui/show-full-blocks? false\\n\\n ;; Automatically expand block references when zooming in.\\n ;; Default value: true\\n :ui/auto-expand-block-refs? true\\n\\n ;; Disable accent marks when searching.\\n ;; After changing this setting, rebuild the search index by pressing (^C ^S).\\n ;; Default value: true\\n :feature/enable-search-remove-accents? true\\n\\n ;; Enable journals.\\n ;; Default value: true\\n ;; :feature/enable-journals? true\\n\\n ;; Enable flashcards.\\n ;; Default value: true\\n ;; :feature/enable-flashcards? true\\n\\n ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query.\\n ;; Default value: false\\n ;; :feature/disable-scheduled-and-deadline-query? false\\n\\n ;; Specify the number of days displayed in the future for\\n ;; the 'scheduled tasks and deadlines' query.\\n ;; Example usage:\\n ;; Display all scheduled and deadline blocks for the next 14 days:\\n ;; :scheduled/future-days 14\\n ;; Default value: 7\\n ;; :scheduled/future-days 7\\n\\n ;; Specify the first day of the week.\\n ;; Available options:\\n ;; - integer from 0 to 6 (Monday to Sunday)\\n ;; Default value: 6 (Sunday)\\n :start-of-week 6\\n\\n ;; Specify a custom CSS import.\\n ;; This option takes precedence over the local `logseq/custom.css` file.\\n ;; Example usage:\\n ;; :custom-css-url \\\"@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');\\\"\\n\\n ;; Specify a custom JS import.\\n ;; This option takes precedence over the local `logseq/custom.js` file.\\n ;; Example usage:\\n ;; :custom-js-url \\\"https://cdn.logseq.com/custom.js\\\"\\n\\n ;; Set bullet indentation when exporting\\n ;; Available options:\\n ;; - `:eight-spaces` as eight spaces\\n ;; - `:four-spaces` as four spaces\\n ;; - `:two-spaces` as two spaces\\n ;; - `:tab` as a tab character (default)\\n ;; :export/bullet-indentation :tab\\n\\n ;; Publish all pages within the Graph\\n ;; Regardless of whether individual pages have been marked as public.\\n ;; Default value: false\\n ;; :publishing/all-pages-public? false\\n\\n ;; Define the default home page and sidebar status.\\n ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden.\\n ;; The `:page` value represents the name of the page displayed at startup.\\n ;; Available options for `:sidebar` are:\\n ;; - \\\"Contents\\\" to display the Contents page in the right sidebar.\\n ;; - A specific page name to display in the right sidebar.\\n ;; - An array of multiple pages, e.g., [\\\"Contents\\\" \\\"Page A\\\" \\\"Page B\\\"].\\n ;; If `:sidebar` remains unset, the right sidebar will stay hidden.\\n ;; Examples:\\n ;; 1. Set \\\"Changelog\\\" as the home page and display \\\"Contents\\\" in the right sidebar:\\n ;; :default-home {:page \\\"Changelog\\\", :sidebar \\\"Contents\\\"}\\n ;; 2. Set \\\"Jun 3rd, 2021\\\" as the home page without the right sidebar:\\n ;; :default-home {:page \\\"Jun 3rd, 2021\\\"}\\n ;; 3. Set \\\"home\\\" as the home page and display multiple pages in the right sidebar:\\n ;; :default-home {:page \\\"home\\\", :sidebar [\\\"Page A\\\" \\\"Page B\\\"]}\\n\\n ;; Configure custom shortcuts.\\n ;; Syntax:\\n ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`.\\n ;; 2. A space between keys represents key chords, e.g., `t s` means\\n ;; pressing `t` followed by `s`.\\n ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac.\\n ;; 4. Use false to disable a specific shortcut.\\n ;; 5. You can define multiple bindings for a single action, e.g., [\\\"ctrl+j\\\" \\\"down\\\"].\\n ;; The full list of configurable shortcuts is available at:\\n ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs\\n ;; Example:\\n ;; :shortcuts\\n ;; {:editor/new-block \\\"enter\\\"\\n ;; :editor/new-line \\\"shift+enter\\\"\\n ;; :editor/insert-link \\\"mod+shift+k\\\"\\n ;; :editor/highlight false\\n ;; :ui/toggle-settings \\\"t s\\\"\\n ;; :editor/up [\\\"ctrl+k\\\" \\\"up\\\"]\\n ;; :editor/down [\\\"ctrl+j\\\" \\\"down\\\"]\\n ;; :editor/left [\\\"ctrl+h\\\" \\\"left\\\"]\\n ;; :editor/right [\\\"ctrl+l\\\" \\\"right\\\"]}\\n :shortcuts {}\\n\\n ;; Configure the behavior of pressing Enter in document mode.\\n ;; if set to true, pressing Enter will create a new block.\\n ;; Default value: false\\n :shortcut/doc-mode-enter-for-new-block? false\\n\\n ;; Block content larger than `block/title-max-length` will not be searchable\\n ;; or editable for performance.\\n ;; Default value: 10000\\n :block/title-max-length 10000\\n\\n ;; Display command documentation on hover.\\n ;; Default value: true\\n :ui/show-command-doc? true\\n\\n ;; Display empty bullet points.\\n ;; Default value: false\\n :ui/show-empty-bullets? false\\n\\n ;; Pre-defined :view function to use with advanced queries.\\n :query/views\\n {:pprint\\n (fn [r] [:pre.code (pprint r)])}\\n\\n ;; Advanced queries `:result-transform` function.\\n ;; Transform the query result before displaying it.\\n ;; Example usage for DB graphs:\\n;; :query/result-transforms\\n;; {:sort-by-priority\\n;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority \\\"Z\\\")) result))}\\n\\n;; Queries will be displayed at the bottom of today's journal page.\\n;; Example usage:\\n;; :default-queries\\n;; {:journals []}\\n\\n ;; Add custom commands to the command palette\\n ;; Example usage:\\n ;; :commands\\n ;; [\\n ;; [\\\"js\\\" \\\"Javascript\\\"]\\n ;; [\\\"md\\\" \\\"Markdown\\\"]\\n ;; ]\\n :commands []\\n\\n ;; Enable collapsing blocks with titles but no children.\\n ;; By default, only blocks with children can be collapsed.\\n ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing\\n ;; blocks with titles (multiple lines) and content. For example:\\n ;; - block title\\n ;; block content\\n ;; Default value: false\\n :outliner/block-title-collapse-enabled? false\\n\\n ;; Macros replace texts and will make you more productive.\\n ;; Example usage:\\n ;; Change the :macros value below to:\\n ;; {\\\"poem\\\" \\\"Rose is $1, violet's $2. Life's ordered: Org assists you.\\\"}\\n ;; input \\\"{{poem red,blue}}\\\"\\n ;; becomes\\n ;; Rose is red, violet's blue. Life's ordered: Org assists you.\\n :macros {}\\n\\n ;; Configure the default expansion level for linked references.\\n ;; For example, consider the following block hierarchy:\\n ;; - a [[page]] (level 1)\\n ;; - b (level 2)\\n ;; - c (level 3)\\n ;; - d (level 4)\\n ;;\\n ;; With the default value of level 2, block b will be collapsed.\\n ;; If the level's value is set to 3, block c will be collapsed.\\n ;; Default value: 2\\n :ref/default-open-blocks-level 2\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/settings\\n ;; {:orphan-pages? true ; Default value: true\\n ;; :builtin-pages? false ; Default value: false\\n ;; :excluded-pages? false ; Default value: false\\n ;; :journal? false} ; Default value: false\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/forcesettings\\n ;; {:link-dist 180 ; Default value: 180\\n ;; :charge-strength -600 ; Default value: -600\\n ;; :charge-range 600} ; Default value: 600\\n\\n ;; Mobile photo upload configuration.\\n ;; :mobile/photo\\n ;; {:allow-editing? true\\n ;; :quality 80}\\n\\n ;; Mobile features options\\n ;; Gestures\\n ;; Example usage:\\n ;; :mobile\\n ;; {:gestures/disabled-in-block-with-tags [\\\"kanban\\\"]}\\n\\n ;; Extra CodeMirror options\\n ;; See https://codemirror.net/5/doc/manual.html#config for possible options\\n ;; Example usage:\\n ;; :editor/extra-codemirror-options\\n ;; {:lineWrapping false ; Default value: false\\n ;; :lineNumbers true ; Default value: true\\n ;; :readOnly false} ; Default value: false\\n\\n ;; Enable logical outdenting\\n ;; Default value: false\\n ;; :editor/logical-outdenting? false\\n\\n ;; Prefer pasting the file when text and a file are in the clipboard.\\n ;; Default value: false\\n ;; :editor/preferred-pasting-file? false\\n\\n ;; Quick capture templates for receiving content from other apps.\\n ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded\\n ;; by receiving content from other apps. Note: the {} cannot be omitted.\\n ;; - {time}: capture time\\n ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference\\n ;; - {text}: text that users selected before sharing.\\n ;; - {url}: URL or assets path for media files stored in Logseq.\\n ;; You can also reorder them or use only one or two of them in the template.\\n ;; You can also insert or format any text in the template, as shown in the following examples.\\n ;; :quick-capture-templates\\n ;; {:text \\\"[[quick capture]] **{time}**: {text} from {url}\\\"\\n ;; :media \\\"[[quick capture]] **{time}**: {url}\\\"}\\n\\n ;; Quick capture options.\\n ;; - insert-today? Insert the capture at the end of today's journal page (boolean).\\n ;; - redirect-page? Redirect to the quick capture page after capturing (boolean).\\n ;; - default-page The default page to capture to if insert-today? is false (string).\\n ;; :quick-capture-options\\n ;; {:insert-today? false ;; Default value: true\\n ;; :redirect-page? false ;; Default value: false\\n ;; :default-page \\\"quick capture\\\"} ;; Default page: \\\"quick capture\\\"\\n\\n }\\n\",536870913]],[\"^84\",[176,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^7N\",\"logseq/config.edn\",536870913]],[\"^84\",[177,\"^48\",1775980635491,536870913]],[\"^84\",[177,\"^7@\",\"d3BGM\",536870913]],[\"^84\",[177,\"^4S\",155,536870913]],[\"^84\",[177,\"^3X\",155,536870913]],[\"^84\",[177,\"^6:\",\"Use it to plan something to start at a specific date(time).\",536870913]],[\"^84\",[177,\"^3;\",1775980635491,536870913]],[\"^84\",[177,\"^2T\",\"~u00000004-9817-6380-0000-000000000000\",536870913]],[\"^84\",[177,\"^57\",true,536870913]],[\"^84\",[177,\"^6?\",71,536870913]],[\"^84\",[178,\"^2T\",\"~u00000004-4049-4381-0000-000000000000\",536870913]],[\"^84\",[178,\"^1M\",\"\",536870913]],[\"^84\",[178,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^7N\",\"logseq/publish.css\",536870913]],[\"^84\",[179,\"^48\",1775980635496,536870913]],[\"^84\",[179,\"^7M\",\"recycle\",536870913]],[\"^84\",[179,\"^69\",104,536870913]],[\"^84\",[179,\"^6:\",\"Recycle\",536870913]],[\"^84\",[179,\"^3;\",1775980635496,536870913]],[\"^84\",[179,\"^2T\",\"~u00000004-7238-1304-3000-000000000000\",536870913]],[\"^84\",[179,\"^57\",true,536870913]],[\"^84\",[179,\"^5J\",true,536870913]],[\"^84\",[180,\"^48\",1775980636002,536870913]],[\"^84\",[180,\"^1C\",20260412,536870913]],[\"^84\",[180,\"^7M\",\"apr 12th, 2026\",536870913]],[\"^84\",[180,\"^1S\",58,536870913]],[\"^84\",[180,\"^1S\",85,536870913]],[\"^84\",[180,\"^69\",85,536870913]],[\"^84\",[180,\"^6:\",\"Apr 12th, 2026\",536870913]],[\"^84\",[180,\"^S\",536873442,536873444]],[\"^84\",[180,\"^3;\",1775984647522,536873442]],[\"^84\",[180,\"^2T\",\"~u00000001-2026-0412-0000-000000000000\",536870913]],[\"^84\",[180,\"^4<\",181,536870913]],[\"^84\",[181,\"^48\",1775980636008,536870913]],[\"^84\",[181,\"^7M\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^1S\",58,536870913]],[\"^84\",[181,\"^1S\",104,536870913]],[\"^84\",[181,\"^1S\",130,536870913]],[\"^84\",[181,\"^1S\",137,536870913]],[\"^84\",[181,\"^69\",104,536870913]],[\"^84\",[181,\"^6:\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^3;\",1775980636008,536870913]],[\"^84\",[181,\"^2T\",\"~ue7cd12e2-f9f7-46ed-a17e-85d95a780003\",536870913]],[\"^84\",[181,\"^38\",\"tienson@logseq.com\",536870913]],[\"^84\",[181,\"^E\",\"tiensonqin\",536870913]],[\"^84\",[182,\"^;\",\"^64\",536870913]],[\"^84\",[182,\"^28\",\"~u952b4187-ac01-41d3-b5fb-24250ac70d56\",536870913]],[\"^84\",[183,\"^;\",\"^3:\",536870913]],[\"^84\",[183,\"^28\",true,536870913]],[\"^84\",[565,\"^48\",1775980817925,536870913]],[\"^84\",[565,\"^7M\",\"p1\",536870913]],[\"^84\",[565,\"^7@\",\"d3BHX\",536870913]],[\"^84\",[565,\"^1S\",58,536870913]],[\"^84\",[565,\"^1S\",124,536870913]],[\"^84\",[565,\"^69\",124,536870913]],[\"^84\",[565,\"^6:\",\"p1\",536870913]],[\"^84\",[565,\"^3;\",1775980817925,536870913]],[\"^84\",[565,\"^2T\",\"~u00000002-1157-0363-3400-000000000000\",536870913]],[\"^84\",[565,\"^<\",\"^=\",536870913]],[\"^84\",[565,\"^;\",\"^5N\",536870913]],[\"^84\",[565,\"^>\",true,536870913]],[\"^84\",[565,\"^C\",\"^D\",536870913]],[\"^84\",[565,\"^4<\",181,536870913]],[\"^84\",[565,\"^1K\",\"^8>\",536870913]],[\"^84\",[568,\"^48\",1775980818025,536870913]],[\"^84\",[568,\"^7M\",\"p2\",536870913]],[\"^84\",[568,\"^7@\",\"d3BHa\",536870913]],[\"^84\",[568,\"^1S\",58,536870913]],[\"^84\",[568,\"^1S\",124,536870913]],[\"^84\",[568,\"^69\",124,536870913]],[\"^84\",[568,\"^6:\",\"p2\",536870913]],[\"^84\",[568,\"^3;\",1775980818025,536870913]],[\"^84\",[568,\"^2T\",\"~u00000002-6891-0936-5000-000000000000\",536870913]],[\"^84\",[568,\"^<\",\"^=\",536870913]],[\"^84\",[568,\"^;\",\"^3V\",536870913]],[\"^84\",[568,\"^>\",true,536870913]],[\"^84\",[568,\"^C\",\"^D\",536870913]],[\"^84\",[568,\"^4<\",181,536870913]],[\"^84\",[568,\"^1K\",\"^8K\",536870913]],[\"^84\",[569,\"^48\",1775980818039,536870913]],[\"^84\",[569,\"^7M\",\"p3\",536870913]],[\"^84\",[569,\"^7@\",\"d3BHb\",536870913]],[\"^84\",[569,\"^1S\",58,536870913]],[\"^84\",[569,\"^1S\",104,536870913]],[\"^84\",[569,\"^1S\",124,536870913]],[\"^84\",[569,\"^69\",124,536870913]],[\"^84\",[569,\"^6:\",\"p3\",536870913]],[\"^84\",[569,\"^3;\",1775980818056,536870913]],[\"^84\",[569,\"^2T\",\"~u00000002-2093-0438-6800-000000000000\",536870913]],[\"^84\",[569,\"^<\",\"^14\",536870913]],[\"^84\",[569,\"^;\",\"^1N\",536870913]],[\"^84\",[569,\"^>\",true,536870913]],[\"^84\",[569,\"^C\",\"^D\",536870913]],[\"^84\",[569,\"^6G\",104,536870913]],[\"^84\",[569,\"^4<\",181,536870913]],[\"^84\",[569,\"^1K\",\"^85\",536870913]],[\"^84\",[573,\"^48\",1775980818095,536870913]],[\"^84\",[573,\"^7M\",\"page 1\",536870913]],[\"^84\",[573,\"^1S\",58,536870913]],[\"^84\",[573,\"^1S\",104,536870913]],[\"^84\",[573,\"^69\",104,536870913]],[\"^84\",[573,\"^6:\",\"page 1\",536870913]],[\"^84\",[573,\"^3;\",1775980818095,536870913]],[\"^84\",[573,\"^2T\",\"~u69db5102-b394-49cc-af0d-6e3ee0752cdf\",536870913]],[\"^84\",[573,\"^4<\",181,536870913]],[\"^84\",[574,\"^48\",1775980818106,536870913]],[\"^84\",[574,\"^7M\",\"page 2\",536870913]],[\"^84\",[574,\"^1S\",58,536870913]],[\"^84\",[574,\"^1S\",104,536870913]],[\"^84\",[574,\"^69\",104,536870913]],[\"^84\",[574,\"^6:\",\"page 2\",536870913]],[\"^84\",[574,\"^3;\",1775980818106,536870913]],[\"^84\",[574,\"^2T\",\"~u69db5103-ac5c-4d04-b098-88b435041434\",536870913]],[\"^84\",[574,\"^4<\",181,536870913]],[\"^84\",[577,\"^48\",1775980800102,536870913]],[\"^84\",[577,\"^7@\",\"d3BHW\",536870913]],[\"^84\",[577,\"^4S\",568,536870913]],[\"^84\",[577,\"^3X\",568,536870913]],[\"^84\",[577,\"^1S\",568,536870913]],[\"^84\",[577,\"^3;\",1775980800102,536870913]],[\"^84\",[577,\"^2T\",\"~u69db5100-05c8-4909-b79e-c56130f4d1e9\",536870913]],[\"^84\",[577,\"^4<\",181,536870913]],[\"^84\",[577,\"^6?\",568,536870913]],[\"^84\",[577,\"^T\",10,536870913]],[\"^84\",[593,\"^48\",1775980982459,536870913]],[\"^84\",[593,\"^7@\",\"d3BHf\",536870913]],[\"^84\",[593,\"^4S\",568,536870913]],[\"^84\",[593,\"^3X\",568,536870913]],[\"^84\",[593,\"^1S\",568,536870913]],[\"^84\",[593,\"^3;\",1775980982459,536870913]],[\"^84\",[593,\"^2T\",\"~u69db51b6-97ff-4bf6-9547-69dc12a2972e\",536870913]],[\"^84\",[593,\"^4<\",181,536870913]],[\"^84\",[593,\"^6?\",568,536870913]],[\"^84\",[593,\"^T\",10,536870913]],[\"^84\",[597,\"^48\",1775980958586,536870913]],[\"^84\",[597,\"^7@\",\"d3BHe\",536870913]],[\"^84\",[597,\"^4S\",568,536870913]],[\"^84\",[597,\"^3X\",568,536870913]],[\"^84\",[597,\"^1S\",568,536870913]],[\"^84\",[597,\"^3;\",1775980958586,536870913]],[\"^84\",[597,\"^2T\",\"~u69db519e-9f15-4a6a-a142-fe9d6e954212\",536870913]],[\"^84\",[597,\"^4<\",181,536870913]],[\"^84\",[597,\"^6?\",568,536870913]],[\"^84\",[597,\"^T\",10,536870913]],[\"^84\",[599,\"^48\",1775981040693,536870913]],[\"^84\",[599,\"^7M\",\"page 3\",536870913]],[\"^84\",[599,\"^1S\",58,536870913]],[\"^84\",[599,\"^1S\",104,536870913]],[\"^84\",[599,\"^69\",104,536870913]],[\"^84\",[599,\"^6:\",\"page 3\",536870913]],[\"^84\",[599,\"^3;\",1775981040693,536870913]],[\"^84\",[599,\"^2T\",\"~u69db51a5-4a8e-4ebf-992e-d31c157eaf5b\",536870913]],[\"^84\",[599,\"^4<\",181,536870913]],[\"^84\",[600,\"^48\",1775981040720,536870913]],[\"^84\",[600,\"^7M\",\"p4\",536870913]],[\"^84\",[600,\"^7@\",\"d3BHh\",536870913]],[\"^84\",[600,\"^1S\",58,536870913]],[\"^84\",[600,\"^1S\",124,536870913]],[\"^84\",[600,\"^69\",124,536870913]],[\"^84\",[600,\"^6:\",\"p4\",536870913]],[\"^84\",[600,\"^3;\",1775981028998,536870913]],[\"^84\",[600,\"^2T\",\"~u00000002-5835-7822-8000-000000000000\",536870913]],[\"^84\",[600,\"^<\",\"^=\",536870913]],[\"^84\",[600,\"^;\",\"^6;\",536870913]],[\"^84\",[600,\"^>\",true,536870913]],[\"^84\",[600,\"^C\",\"^D\",536870913]],[\"^84\",[600,\"^4<\",181,536870913]],[\"^84\",[600,\"^1K\",\"^8>\",536870913]],[\"^84\",[605,\"^48\",1775981057423,536870913]],[\"^84\",[605,\"^7@\",\"Zz\",536870913]],[\"^84\",[605,\"^4S\",170,536870913]],[\"^84\",[605,\"^3X\",170,536870913]],[\"^84\",[605,\"^1S\",170,536870913]],[\"^84\",[605,\"^6:\",\"All\",536870913]],[\"^84\",[605,\"^3;\",1775981057423,536870913]],[\"^84\",[605,\"^2T\",\"~u00000006-1867-5878-9000-000000000000\",536870913]],[\"^84\",[605,\"^4<\",181,536870913]],[\"^84\",[605,\"^9\",170,536870913]],[\"^84\",[605,\"^5>\",\"~:all-pages\",536870913]],[\"^84\",[619,\"^3D\",true,536870913]],[\"^84\",[619,\"^48\",1775981500541,536870913]],[\"^84\",[619,\"^7M\",\"tag1\",536870913]],[\"^84\",[619,\"^1S\",14,536870913]],[\"^84\",[619,\"^1S\",58,536870913]],[\"^84\",[619,\"^1S\",89,536870913]],[\"^84\",[619,\"^1S\",158,536870913]],[\"^84\",[619,\"^69\",14,536870913]],[\"^84\",[619,\"^6:\",\"tag1\",536870913]],[\"^84\",[619,\"^3;\",1775981500541,536870913]],[\"^84\",[619,\"^2T\",\"~u69db50d9-c23a-4aa4-bb79-33d5dbe54593\",536870913]],[\"^84\",[619,\"^;\",\"^3T\",536870913]],[\"^84\",[619,\"^4<\",181,536870913]],[\"^84\",[619,\"^3K\",89,536870913]],[\"^84\",[620,\"^48\",1775981500540,536870913]],[\"^84\",[620,\"^7M\",\"page 4\",536870913]],[\"^84\",[620,\"^1S\",58,536870913]],[\"^84\",[620,\"^1S\",104,536870913]],[\"^84\",[620,\"^1S\",619,536870913]],[\"^84\",[620,\"^69\",104,536870913]],[\"^84\",[620,\"^69\",619,536870913]],[\"^84\",[620,\"^6:\",\"page 4\",536870913]],[\"^84\",[620,\"^3;\",1775981500561,536870913]],[\"^84\",[620,\"^2T\",\"~u69db53ab-5e4a-4039-b2ba-4220ca0b20cd\",536870913]],[\"^84\",[620,\"^4<\",181,536870913]],[\"^84\",[621,\"^48\",1775981500553,536870913]],[\"^84\",[621,\"^7@\",\"a0\",536870913]],[\"^84\",[621,\"^4S\",620,536870913]],[\"^84\",[621,\"^3X\",620,536870913]],[\"^84\",[621,\"^6:\",\"test\",536870913]],[\"^84\",[621,\"^3;\",1775981500560,536870913]],[\"^84\",[621,\"^2T\",\"~u69db53ac-7264-4191-bac4-eee78a2ff031\",536870913]],[\"^84\",[621,\"^4<\",181,536870913]],[\"^84\",[622,\"^48\",1775981513870,536870913]],[\"^84\",[622,\"^7@\",\"a0\",536870913]],[\"^84\",[622,\"^4S\",170,536870913]],[\"^84\",[622,\"^3X\",170,536870913]],[\"^84\",[622,\"^1S\",619,536870913]],[\"^84\",[622,\"^6:\",\"All\",536870913]],[\"^84\",[622,\"^3;\",1775981513870,536870913]],[\"^84\",[622,\"^2T\",\"~u00000006-1742-3452-4000-000000000000\",536870913]],[\"^84\",[622,\"^4<\",181,536870913]],[\"^84\",[622,\"^9\",619,536870913]],[\"^84\",[622,\"^5>\",\"~:class-objects\",536870913]],[\"^84\",[1530,\"^48\",1775982125944,536870913]],[\"^84\",[1530,\"^7M\",\"page 5\",536870913]],[\"^84\",[1530,\"^1S\",58,536870913]],[\"^84\",[1530,\"^1S\",104,536870913]],[\"^84\",[1530,\"^69\",104,536870913]],[\"^84\",[1530,\"^6:\",\"page 5\",536870913]],[\"^84\",[1530,\"^3;\",1775982125944,536870913]],[\"^84\",[1530,\"^2T\",\"~u69db557e-1cd8-48b3-87aa-1b8a684ca17f\",536870913]],[\"^84\",[1530,\"^4<\",181,536870913]],[\"^84\",[1732,\"^48\",1775982384424,536870913]],[\"^84\",[1732,\"^7M\",\"page 6\",536870913]],[\"^84\",[1732,\"^1S\",58,536870913]],[\"^84\",[1732,\"^1S\",104,536870913]],[\"^84\",[1732,\"^69\",104,536870913]],[\"^84\",[1732,\"^6:\",\"page 6\",536870913]],[\"^84\",[1732,\"^3;\",1775982413000,536870913]],[\"^84\",[1732,\"^2T\",\"~u69db5730-ad32-46c0-bb99-f87817d4443a\",536870913]],[\"^84\",[1732,\"^4<\",181,536870913]],[\"^84\",[1733,\"^48\",1775982384655,536870913]],[\"^84\",[1733,\"^7@\",\"a0\",536870913]],[\"^84\",[1733,\"^4S\",1732,536870913]],[\"^84\",[1733,\"^3X\",1732,536870913]],[\"^84\",[1733,\"^6:\",\"hello\",536870913]],[\"^84\",[1733,\"^3;\",1775982389175,536870913]],[\"^84\",[1733,\"^2T\",\"~u69db5730-c5a5-4116-89f5-26341ef44073\",536870913]],[\"^84\",[1733,\"^4<\",181,536870913]],[\"^84\",[1742,\"^48\",1775982389180,536870913]],[\"^84\",[1742,\"^7@\",\"a1\",536870913]],[\"^84\",[1742,\"^4S\",1732,536870913]],[\"^84\",[1742,\"^3X\",1732,536870913]],[\"^84\",[1742,\"^6:\",\"fjdlafda\",536870913]],[\"^84\",[1742,\"^3;\",1775982389685,536870913]],[\"^84\",[1742,\"^2T\",\"~u69db5731-9fe9-4c6b-a183-ffb2e6471f45\",536870913]],[\"^84\",[1743,\"^48\",1775982389690,536870913]],[\"^84\",[1743,\"^7@\",\"a2\",536870913]],[\"^84\",[1743,\"^4S\",1732,536870913]],[\"^84\",[1743,\"^3X\",1732,536870913]],[\"^84\",[1743,\"^6:\",\"f\",536870913]],[\"^84\",[1743,\"^3;\",1775982389754,536870913]],[\"^84\",[1743,\"^2T\",\"~u69db5732-3643-4e28-906b-581fbbb2234a\",536870913]],[\"^84\",[1744,\"^48\",1775982389755,536870913]],[\"^84\",[1744,\"^7@\",\"a3\",536870913]],[\"^84\",[1744,\"^4S\",1732,536870913]],[\"^84\",[1744,\"^3X\",1732,536870913]],[\"^84\",[1744,\"^6:\",\"adf\",536870913]],[\"^84\",[1744,\"^3;\",1775982389841,536870913]],[\"^84\",[1744,\"^2T\",\"~u69db5732-9fbc-49f2-8473-db80eee4e879\",536870913]],[\"^84\",[1745,\"^48\",1775982389843,536870913]],[\"^84\",[1745,\"^7@\",\"a4\",536870913]],[\"^84\",[1745,\"^4S\",1732,536870913]],[\"^84\",[1745,\"^3X\",1732,536870913]],[\"^84\",[1745,\"^6:\",\"f\",536870913]],[\"^84\",[1745,\"^3;\",1775982389926,536870913]],[\"^84\",[1745,\"^2T\",\"~u69db5732-a4fa-48bc-b850-1ea2dd125db8\",536870913]],[\"^84\",[1746,\"^48\",1775982389927,536870913]],[\"^84\",[1746,\"^7@\",\"a5\",536870913]],[\"^84\",[1746,\"^4S\",1732,536870913]],[\"^84\",[1746,\"^3X\",1732,536870913]],[\"^84\",[1746,\"^6:\",\"af\",536870913]],[\"^84\",[1746,\"^3;\",1775982390009,536870913]],[\"^84\",[1746,\"^2T\",\"~u69db5732-c89c-439e-91db-385e7cf0884a\",536870913]],[\"^84\",[1747,\"^48\",1775982390010,536870913]],[\"^84\",[1747,\"^7@\",\"a6\",536870913]],[\"^84\",[1747,\"^4S\",1732,536870913]],[\"^84\",[1747,\"^3X\",1732,536870913]],[\"^84\",[1747,\"^6:\",\"af\",536870913]],[\"^84\",[1747,\"^3;\",1775982390090,536870913]],[\"^84\",[1747,\"^2T\",\"~u69db5732-389f-48f7-a3c9-9ab01e4d06b3\",536870913]],[\"^84\",[1748,\"^48\",1775982390091,536870913]],[\"^84\",[1748,\"^7@\",\"a7\",536870913]],[\"^84\",[1748,\"^4S\",1732,536870913]],[\"^84\",[1748,\"^3X\",1732,536870913]],[\"^84\",[1748,\"^6:\",\"a\",536870913]],[\"^84\",[1748,\"^3;\",1775982390176,536870913]],[\"^84\",[1748,\"^2T\",\"~u69db5733-aeac-4823-bd0e-ba84d7fcc3aa\",536870913]],[\"^84\",[1749,\"^48\",1775982390178,536870913]],[\"^84\",[1749,\"^7@\",\"a8\",536870913]],[\"^84\",[1749,\"^4S\",1732,536870913]],[\"^84\",[1749,\"^3X\",1732,536870913]],[\"^84\",[1749,\"^6:\",\"so cool\",536870913]],[\"^84\",[1749,\"^3;\",1775982411832,536870913]],[\"^84\",[1749,\"^2T\",\"~u69db5733-9bfe-49db-96cb-03fe97bce35f\",536870913]],[\"^84\",[1749,\"^4<\",181,536870913]],[\"^84\",[1754,\"^48\",1775982411836,536870913]],[\"^84\",[1754,\"^7@\",\"a0\",536870913]],[\"^84\",[1754,\"^4S\",1732,536870913]],[\"^84\",[1754,\"^3X\",1749,536870913]],[\"^84\",[1754,\"^6:\",\"awesome work\",536870913]],[\"^84\",[1754,\"^3;\",1775982412416,536870913]],[\"^84\",[1754,\"^2T\",\"~u69db5739-bca6-4992-96ec-45334b44f4c4\",536870913]],[\"^84\",[1755,\"^48\",1775982412417,536870913]],[\"^84\",[1755,\"^7@\",\"a1\",536870913]],[\"^84\",[1755,\"^4S\",1732,536870913]],[\"^84\",[1755,\"^3X\",1749,536870913]],[\"^84\",[1755,\"^6:\",\"great\",536870913]],[\"^84\",[1755,\"^3;\",1775982412496,536870913]],[\"^84\",[1755,\"^2T\",\"~u69db573a-8ec0-46e1-bc26-d7632e93d96f\",536870913]],[\"^84\",[1756,\"^48\",1775982412499,536870913]],[\"^84\",[1756,\"^7@\",\"a2\",536870913]],[\"^84\",[1756,\"^4S\",1732,536870913]],[\"^84\",[1756,\"^3X\",1749,536870913]],[\"^84\",[1756,\"^6:\",\"sounds amazing\",536870913]],[\"^84\",[1756,\"^3;\",1775982412747,536870913]],[\"^84\",[1756,\"^2T\",\"~u69db573b-ec3d-4f66-8e8d-26c9ff157a6b\",536870913]],[\"^84\",[1757,\"^48\",1775982412833,536870913]],[\"^84\",[1757,\"^7@\",\"a3\",536870913]],[\"^84\",[1757,\"^4S\",1732,536870913]],[\"^84\",[1757,\"^3X\",1749,536870913]],[\"^84\",[1757,\"^6:\",\"stuff really just work\",536870913]],[\"^84\",[1757,\"^3;\",1775982413000,536870913]],[\"^84\",[1757,\"^2T\",\"~u69db573f-8a13-43cc-82af-060e89cfd1fa\",536870913]],[\"^84\",[1836,\"^48\",1775983070467,536870913]],[\"^84\",[1836,\"^7M\",\"page 7\",536870913]],[\"^84\",[1836,\"^1S\",58,536870913]],[\"^84\",[1836,\"^1S\",104,536870913]],[\"^84\",[1836,\"^69\",104,536870913]],[\"^84\",[1836,\"^6:\",\"page 7\",536870913]],[\"^84\",[1836,\"^3;\",1775983683136,536884972]],[\"^84\",[1836,\"^2T\",\"~u69db59de-5992-4e9c-aba5-12bbae69d657\",536870913]],[\"^84\",[1836,\"^4<\",181,536870913]],[\"^84\",[1852,\"^48\",1775983078379,536870913]],[\"^84\",[1852,\"^7@\",\"a0\",536870913]],[\"^84\",[1852,\"^4S\",1836,536870913]],[\"^84\",[1852,\"^3X\",1836,536870913]],[\"^84\",[1852,\"^6:\",\"fjdlafjldaf\",536870913]],[\"^84\",[1852,\"^3;\",1775983078467,536870913]],[\"^84\",[1852,\"^2T\",\"~u69db59de-1710-4353-9b7f-a6843d27c9f7\",536870913]],[\"^84\",[1853,\"^48\",1775983078469,536870913]],[\"^84\",[1853,\"^7@\",\"a1\",536870913]],[\"^84\",[1853,\"^4S\",1836,536870913]],[\"^84\",[1853,\"^3X\",1836,536870913]],[\"^84\",[1853,\"^6:\",\"da\",536870913]],[\"^84\",[1853,\"^3;\",1775983078547,536870913]],[\"^84\",[1853,\"^2T\",\"~u69db59df-bbd0-4c0b-b89b-40d255373829\",536870913]],[\"^84\",[1854,\"^48\",1775983078548,536870913]],[\"^84\",[1854,\"^7@\",\"a2\",536870913]],[\"^84\",[1854,\"^4S\",1836,536870913]],[\"^84\",[1854,\"^3X\",1836,536870913]],[\"^84\",[1854,\"^6:\",\"fd\",536870913]],[\"^84\",[1854,\"^3;\",1775983078649,536870913]],[\"^84\",[1854,\"^2T\",\"~u69db59df-ecc8-48f2-ab1d-b885294463c8\",536870913]],[\"^84\",[1855,\"^48\",1775983078652,536870913]],[\"^84\",[1855,\"^7@\",\"a0\",536870913]],[\"^84\",[1855,\"^4S\",1836,536870913]],[\"^84\",[1855,\"^3X\",1854,536870913]],[\"^84\",[1855,\"^6:\",\"aff\",536870913]],[\"^84\",[1855,\"^3;\",1775983078804,536870913]],[\"^84\",[1855,\"^2T\",\"~u69db59df-fbd3-4baa-b307-e439f1f660bc\",536870913]],[\"^84\",[1856,\"^48\",1775983078806,536870913]],[\"^84\",[1856,\"^7@\",\"a1\",536870913]],[\"^84\",[1856,\"^4S\",1836,536870913]],[\"^84\",[1856,\"^3X\",1854,536870913]],[\"^84\",[1856,\"^6:\",\"daqf\",536870913]],[\"^84\",[1856,\"^3;\",1775983078892,536870913]],[\"^84\",[1856,\"^2T\",\"~u69db59e0-6768-4059-b647-6b522367a85d\",536870913]],[\"^84\",[1857,\"^48\",1775983078897,536870913]],[\"^84\",[1857,\"^7@\",\"a2\",536870913]],[\"^84\",[1857,\"^4S\",1836,536870913]],[\"^84\",[1857,\"^3X\",1854,536870913]],[\"^84\",[1857,\"^6:\",\"f\",536870913]],[\"^84\",[1857,\"^3;\",1775983079138,536870913]],[\"^84\",[1857,\"^2T\",\"~u69db59e0-16b6-48d5-ae4d-c6832cec5580\",536870913]],[\"^84\",[1858,\"^48\",1775983079140,536870913]],[\"^84\",[1858,\"^7@\",\"a0\",536870913]],[\"^84\",[1858,\"^4S\",1836,536870913]],[\"^84\",[1858,\"^3X\",1857,536870913]],[\"^84\",[1858,\"^6:\",\"f\",536870913]],[\"^84\",[1858,\"^3;\",1775983079488,536870913]],[\"^84\",[1858,\"^2T\",\"~u69db59e0-1a63-4e46-97b2-9696843f7c47\",536870913]],[\"^84\",[1859,\"^48\",1775983079489,536870913]],[\"^84\",[1859,\"^7@\",\"a1\",536870913]],[\"^84\",[1859,\"^4S\",1836,536870913]],[\"^84\",[1859,\"^3X\",1857,536870913]],[\"^84\",[1859,\"^6:\",\"f\",536870913]],[\"^84\",[1859,\"^3;\",1775983079572,536870913]],[\"^84\",[1859,\"^2T\",\"~u69db59e1-da15-45cb-9f42-a87f80023c55\",536870913]],[\"^84\",[1860,\"^48\",1775983079643,536870913]],[\"^84\",[1860,\"^7@\",\"a0\",536870913]],[\"^84\",[1860,\"^4S\",1836,536870913]],[\"^84\",[1860,\"^3X\",1859,536870913]],[\"^84\",[1860,\"^6:\",\"f\",536870913]],[\"^84\",[1860,\"^3;\",1775983079815,536870913]],[\"^84\",[1860,\"^2T\",\"~u69db59e1-f52e-4e72-b110-c313fbd76c44\",536870913]],[\"^84\",[1861,\"^48\",1775983079817,536870913]],[\"^84\",[1861,\"^7@\",\"a1\",536870913]],[\"^84\",[1861,\"^4S\",1836,536870913]],[\"^84\",[1861,\"^3X\",1859,536870913]],[\"^84\",[1861,\"^6:\",\"f\",536884964]],[\"^84\",[1861,\"^3;\",1775983682382,536884964]],[\"^84\",[1861,\"^2T\",\"~u69db59e1-bc2d-49e6-ad28-ade10a3e506c\",536870913]],[\"^84\",[2384,\"^48\",1775983682383,536871818]],[\"^84\",[2384,\"^7@\",\"a0V\",536871818]],[\"^84\",[2384,\"^4S\",1836,536871818]],[\"^84\",[2384,\"^3X\",1859,536871818]],[\"^84\",[2384,\"^6:\",\"fd\",536884966]],[\"^84\",[2384,\"^3;\",1775983682880,536884966]],[\"^84\",[2384,\"^2T\",\"~u69db59e1-5216-491c-b6ff-36ba8fb23f4d\",536871818]],[\"^84\",[2385,\"^48\",1775983682881,536871819]],[\"^84\",[2385,\"^7@\",\"a0l\",536871819]],[\"^84\",[2385,\"^4S\",1836,536871819]],[\"^84\",[2385,\"^3X\",1859,536871819]],[\"^84\",[2385,\"^6:\",\"af\",536884968]],[\"^84\",[2385,\"^3;\",1775983682974,536884968]],[\"^84\",[2385,\"^2T\",\"~u69db59e1-b11f-4372-9155-50825d6fd4ed\",536871819]],[\"^84\",[2386,\"^48\",1775983682975,536871820]],[\"^84\",[2386,\"^7@\",\"a0t\",536871820]],[\"^84\",[2386,\"^4S\",1836,536871820]],[\"^84\",[2386,\"^3X\",1859,536871820]],[\"^84\",[2386,\"^6:\",\"asf\",536884970]],[\"^84\",[2386,\"^3;\",1775983683045,536884970]],[\"^84\",[2386,\"^2T\",\"~u69db59e2-58f7-4f9e-aa44-34b587b0a687\",536871820]],[\"^84\",[2387,\"^48\",1775983683046,536871821]],[\"^84\",[2387,\"^7@\",\"a0x\",536871821]],[\"^84\",[2387,\"^4S\",1836,536871821]],[\"^84\",[2387,\"^3X\",1859,536871821]],[\"^84\",[2387,\"^6:\",\"sf\",536884972]],[\"^84\",[2387,\"^3;\",1775983683135,536884972]],[\"^84\",[2387,\"^2T\",\"~u69db59e2-7991-45be-ba25-1cdb2e3519a9\",536871821]],[\"^84\",[2388,\"^48\",1775983683137,536871822]],[\"^84\",[2388,\"^7@\",\"a0z\",536871822]],[\"^84\",[2388,\"^4S\",1836,536871822]],[\"^84\",[2388,\"^3X\",1859,536871822]],[\"^84\",[2388,\"^6:\",\"\",536871822]],[\"^84\",[2388,\"^3;\",1775983683137,536871822]],[\"^84\",[2388,\"^2T\",\"~u69db59e2-1a51-43ba-8a3b-ceb25f6aa499\",536871822]],[\"^84\",[2460,\"^48\",1775983689295,536871863]],[\"^84\",[2460,\"^7M\",\"page 9\",536871863]],[\"^84\",[2460,\"^1S\",58,536871863]],[\"^84\",[2460,\"^1S\",104,536871863]],[\"^84\",[2460,\"^69\",104,536871863]],[\"^84\",[2460,\"^6:\",\"page 9\",536871863]],[\"^84\",[2460,\"^3;\",1775983693372,536885142]],[\"^84\",[2460,\"^2T\",\"~u69db5c49-fb5d-4f7a-90a9-cd294f0aef44\",536871863]],[\"^84\",[2460,\"^4<\",181,536871863]],[\"^84\",[2461,\"^48\",1775983689528,536871864]],[\"^84\",[2461,\"^7@\",\"a0\",536871864]],[\"^84\",[2461,\"^4S\",2460,536871864]],[\"^84\",[2461,\"^3X\",2460,536871864]],[\"^84\",[2461,\"^6:\",\"fjldafjlas\",536885075]],[\"^84\",[2461,\"^3;\",1775983690319,536885075]],[\"^84\",[2461,\"^2T\",\"~u69db5c49-9f9e-49e8-9ead-2c36ec84031b\",536871864]],[\"^84\",[2461,\"^4<\",181,536871864]],[\"^84\",[2462,\"^48\",1775983690320,536871865]],[\"^84\",[2462,\"^7@\",\"a0\",536885079]],[\"^84\",[2462,\"^4S\",2460,536871865]],[\"^84\",[2462,\"^3X\",2461,536871866]],[\"^84\",[2462,\"^6:\",\"f\",536885078]],[\"^84\",[2462,\"^3;\",1775983690437,536885078]],[\"^84\",[2462,\"^2T\",\"~u69db5c4a-c58e-4a90-9cd6-4f692890f42a\",536871865]],[\"^84\",[2462,\"^4<\",181,536871865]],[\"^84\",[2463,\"^48\",1775983690514,536871867]],[\"^84\",[2463,\"^7@\",\"a0\",536885089]],[\"^84\",[2463,\"^4S\",2460,536871867]],[\"^84\",[2463,\"^3X\",2462,536871870]],[\"^84\",[2463,\"^6:\",\"f\",536885088]],[\"^84\",[2463,\"^3;\",1775983690873,536885088]],[\"^84\",[2463,\"^2T\",\"~u69db5c4a-0212-4c6f-bee0-24b88a12deee\",536871867]],[\"^84\",[2463,\"^4<\",181,536871867]],[\"^84\",[2464,\"^48\",1775983690916,536871871]],[\"^84\",[2464,\"^7@\",\"Zz\",536871871]],[\"^84\",[2464,\"^4S\",2460,536871871]],[\"^84\",[2464,\"^3X\",2462,536871871]],[\"^84\",[2464,\"^6:\",\"\",536871871]],[\"^84\",[2464,\"^3;\",1775983690916,536871871]],[\"^84\",[2464,\"^2T\",\"~u69db5c4a-2364-4fb7-bdd1-223745f4c69a\",536871871]],[\"^84\",[2464,\"^4<\",181,536871871]],[\"^84\",[2465,\"^48\",1775983691100,536871872]],[\"^84\",[2465,\"^7@\",\"a0\",536885101]],[\"^84\",[2465,\"^4S\",2460,536871872]],[\"^84\",[2465,\"^3X\",2464,536871874]],[\"^84\",[2465,\"^6:\",\"f\",536885097]],[\"^84\",[2465,\"^3;\",1775983691353,536885100]],[\"^84\",[2465,\"^2T\",\"~u69db5c4b-932f-49b8-9522-2f5d6a962f25\",536871872]],[\"^84\",[2465,\"^4<\",181,536871872]],[\"^84\",[2466,\"^48\",1775983691319,536871873]],[\"^84\",[2466,\"^7@\",\"a1\",536885105]],[\"^84\",[2466,\"^4S\",2460,536871873]],[\"^84\",[2466,\"^3X\",2464,536871876]],[\"^84\",[2466,\"^6:\",\"\",536871873]],[\"^84\",[2466,\"^3;\",1775983691319,536871873]],[\"^84\",[2466,\"^2T\",\"~u69db5c4b-9d2c-4516-b00c-25b6e2a7d6de\",536871873]],[\"^84\",[2466,\"^4<\",181,536871873]],[\"^84\",[2467,\"^48\",1775983691450,536871875]],[\"^84\",[2467,\"^7@\",\"a2\",536885111]],[\"^84\",[2467,\"^4S\",2460,536871875]],[\"^84\",[2467,\"^3X\",2464,536871878]],[\"^84\",[2467,\"^6:\",\"f\",536885107]],[\"^84\",[2467,\"^3;\",1775983691677,536885110]],[\"^84\",[2467,\"^2T\",\"~u69db5c4b-7c2d-4bb4-9aaa-5a9f1a445f52\",536871875]],[\"^84\",[2467,\"^4<\",181,536871875]],[\"^84\",[2468,\"^48\",1775983691628,536871877]],[\"^84\",[2468,\"^7@\",\"Zzx\",536871877]],[\"^84\",[2468,\"^4S\",2460,536871877]],[\"^84\",[2468,\"^3X\",2462,536871877]],[\"^84\",[2468,\"^6:\",\"f\",536885112]],[\"^84\",[2468,\"^3;\",1775983691774,536885112]],[\"^84\",[2468,\"^2T\",\"~u69db5c4b-56f8-422b-b193-b482957b3b31\",536871877]],[\"^84\",[2468,\"^4<\",181,536871877]],[\"^84\",[2469,\"^48\",1775983691775,536871879]],[\"^84\",[2469,\"^7@\",\"a0\",536885115]],[\"^84\",[2469,\"^4S\",2460,536871879]],[\"^84\",[2469,\"^3X\",2468,536871880]],[\"^84\",[2469,\"^6:\",\"f\",536885117]],[\"^84\",[2469,\"^3;\",1775983691973,536885117]],[\"^84\",[2469,\"^2T\",\"~u69db5c4b-0f65-4034-9d3e-a53826620eb4\",536871879]],[\"^84\",[2469,\"^4<\",181,536871879]],[\"^84\",[2470,\"^48\",1775983691974,536871881]],[\"^84\",[2470,\"^7@\",\"Zz\",536871881]],[\"^84\",[2470,\"^4S\",2460,536871881]],[\"^84\",[2470,\"^3X\",2468,536871881]],[\"^84\",[2470,\"^6:\",\"\",536871881]],[\"^84\",[2470,\"^3;\",1775983691974,536871881]],[\"^84\",[2470,\"^2T\",\"~u69db5c4b-8a1c-4a55-9c85-2c33cc41abf3\",536871881]],[\"^84\",[2470,\"^4<\",181,536871881]],[\"^84\",[2471,\"^48\",1775983692146,536871882]],[\"^84\",[2471,\"^7@\",\"a0\",536885131]],[\"^84\",[2471,\"^4S\",2460,536871882]],[\"^84\",[2471,\"^3X\",2470,536871886]],[\"^84\",[2471,\"^6:\",\"f\",536885127]],[\"^84\",[2471,\"^3;\",1775983692532,536885130]],[\"^84\",[2471,\"^2T\",\"~u69db5c4c-2d33-4497-a4ca-9fa095153cef\",536871882]],[\"^84\",[2471,\"^4<\",181,536871882]],[\"^84\",[2472,\"^48\",1775983692485,536871885]],[\"^84\",[2472,\"^7@\",\"a1\",536885136]],[\"^84\",[2472,\"^4S\",2460,536871885]],[\"^84\",[2472,\"^3X\",2470,536871888]],[\"^84\",[2472,\"^6:\",\"f\",536885132]],[\"^84\",[2472,\"^3;\",1775983692712,536885135]],[\"^84\",[2472,\"^2T\",\"~u69db5c4c-65af-484b-80c2-221d316cd911\",536871885]],[\"^84\",[2472,\"^4<\",181,536871885]],[\"^84\",[2473,\"^48\",1775983692668,536871887]],[\"^84\",[2473,\"^7@\",\"Zzt\",536871887]],[\"^84\",[2473,\"^4S\",2460,536871887]],[\"^84\",[2473,\"^3X\",2468,536871887]],[\"^84\",[2473,\"^6:\",\"f\",536885137]],[\"^84\",[2473,\"^3;\",1775983692810,536885137]],[\"^84\",[2473,\"^2T\",\"~u69db5c4c-8ca4-40fa-b76f-65bdfb599fa4\",536871887]],[\"^84\",[2473,\"^4<\",181,536871887]],[\"^84\",[2474,\"^48\",1775983692811,536871889]],[\"^84\",[2474,\"^7@\",\"a0\",536885140]],[\"^84\",[2474,\"^4S\",2460,536871889]],[\"^84\",[2474,\"^3X\",2473,536871890]],[\"^84\",[2474,\"^1S\",58,536871893]],[\"^84\",[2474,\"^1S\",2475,536871893]],[\"^84\",[2474,\"^69\",2475,536871893]],[\"^84\",[2474,\"^6:\",\"f\",536885142]],[\"^84\",[2474,\"^3;\",1775983693372,536885142]],[\"^84\",[2474,\"^2T\",\"~u69db5c4c-ddf7-4a3a-a05f-8a31dc7acded\",536871889]],[\"^84\",[2474,\"^4<\",181,536871889]],[\"^84\",[2475,\"^3D\",false,536885153]],[\"^84\",[2475,\"^48\",1775983696483,536871892]],[\"^84\",[2475,\"^7M\",\"tag2\",536871892]],[\"^84\",[2475,\"^1S\",14,536871892]],[\"^84\",[2475,\"^1S\",58,536871892]],[\"^84\",[2475,\"^1S\",89,536871892]],[\"^84\",[2475,\"^1S\",121,536871896]],[\"^84\",[2475,\"^1S\",158,536871892]],[\"^84\",[2475,\"^1S\",565,536871896]],[\"^84\",[2475,\"^1S\",568,536871897]],[\"^84\",[2475,\"^1S\",2477,536871899]],[\"^84\",[2475,\"^69\",14,536871892]],[\"^84\",[2475,\"^6:\",\"tag2\",536871892]],[\"^84\",[2475,\"^3;\",1775983696483,536871892]],[\"^84\",[2475,\"^2T\",\"~u69db5c50-3b82-4cb1-a425-271e7c8e6a03\",536871892]],[\"^84\",[2475,\"^;\",\"^3R\",536871892]],[\"^84\",[2475,\"^4<\",181,536871892]],[\"^84\",[2475,\"^3K\",89,536871892]],[\"^84\",[2475,\"^4K\",565,536871896]],[\"^84\",[2475,\"^4K\",568,536871897]],[\"^84\",[2475,\"^4K\",2477,536871899]],[\"^84\",[2476,\"^48\",1775983698445,536871894]],[\"^84\",[2476,\"^7@\",\"Zy\",536871894]],[\"^84\",[2476,\"^4S\",170,536871894]],[\"^84\",[2476,\"^3X\",170,536871894]],[\"^84\",[2476,\"^1S\",2475,536871894]],[\"^84\",[2476,\"^6:\",\"All\",536871894]],[\"^84\",[2476,\"^3;\",1775983698445,536871894]],[\"^84\",[2476,\"^2T\",\"~u00000006-2710-8879-5000-000000000000\",536871894]],[\"^84\",[2476,\"^4<\",181,536871894]],[\"^84\",[2476,\"^9\",2475,536871894]],[\"^84\",[2476,\"^5>\",\"^8O\",536871894]],[\"^84\",[2477,\"^48\",1775983711630,536871898]],[\"^84\",[2477,\"^7M\",\"p5\",536871898]],[\"^84\",[2477,\"^7@\",\"d3BK2\",536871898]],[\"^84\",[2477,\"^1S\",58,536871898]],[\"^84\",[2477,\"^1S\",104,536871900]],[\"^84\",[2477,\"^1S\",124,536871898]],[\"^84\",[2477,\"^69\",124,536871898]],[\"^84\",[2477,\"^6:\",\"p5\",536871898]],[\"^84\",[2477,\"^3;\",1775983717913,536885166]],[\"^84\",[2477,\"^2T\",\"~u00000002-1345-4970-5000-000000000000\",536871898]],[\"^84\",[2477,\"^<\",\"^14\",536885166]],[\"^84\",[2477,\"^;\",\"^23\",536871898]],[\"^84\",[2477,\"^>\",true,536871898]],[\"^84\",[2477,\"^C\",\"^D\",536871898]],[\"^84\",[2477,\"^6G\",104,536871900]],[\"^84\",[2477,\"^4<\",181,536871898]],[\"^84\",[2477,\"^1K\",\"^85\",536871898]],[\"^84\",[2577,\"^48\",1775983728413,536872235]],[\"^84\",[2577,\"^7@\",\"a0\",536872235]],[\"^84\",[2577,\"^4S\",180,536872235]],[\"^84\",[2577,\"^3X\",180,536872235]],[\"^84\",[2577,\"^6:\",\"1\",536872238]],[\"^84\",[2577,\"^S\",536872238,536872240]],[\"^84\",[2577,\"^3;\",1775983728422,536872238]],[\"^84\",[2577,\"^2T\",\"~u69db5bf3-a739-4564-9347-27fae2cc394d\",536872235]],[\"^84\",[2577,\"^4<\",181,536872236]],[\"^84\",[2578,\"^48\",1775983728424,536872239]],[\"^84\",[2578,\"^7@\",\"a0\",536872241]],[\"^84\",[2578,\"^4S\",180,536872239]],[\"^84\",[2578,\"^3X\",2577,536872241]],[\"^84\",[2578,\"^6:\",\"2\",536872243]],[\"^84\",[2578,\"^S\",536872243,536872245]],[\"^84\",[2578,\"^3;\",1775983728449,536872243]],[\"^84\",[2578,\"^2T\",\"~u69db5bf5-0ac5-4b52-82f3-b4d6d8314401\",536872239]],[\"^84\",[2578,\"^4<\",181,536872239]],[\"^84\",[2692,\"^48\",1775983729631,536872436]],[\"^84\",[2692,\"^7M\",\"page 8\",536872436]],[\"^84\",[2692,\"^1S\",58,536872438]],[\"^84\",[2692,\"^1S\",104,536872438]],[\"^84\",[2692,\"^69\",104,536872436]],[\"^84\",[2692,\"^6:\",\"page 8\",536872436]],[\"^84\",[2692,\"^S\",536872479,536872481]],[\"^84\",[2692,\"^3;\",1775983729797,536872479]],[\"^84\",[2692,\"^2T\",\"~u69db5c2a-601b-45ea-ab8f-13e01ea7787b\",536872436]],[\"^84\",[2692,\"^4<\",181,536872437]],[\"^84\",[2693,\"^48\",1775983729638,536872439]],[\"^84\",[2693,\"^7@\",\"a0\",536872439]],[\"^84\",[2693,\"^4S\",2692,536872439]],[\"^84\",[2693,\"^3X\",2692,536872439]],[\"^84\",[2693,\"^6:\",\"hello\",536872442]],[\"^84\",[2693,\"^S\",536872442,536872444]],[\"^84\",[2693,\"^3;\",1775983729647,536872442]],[\"^84\",[2693,\"^2T\",\"~u69db5c2b-3d88-4bad-ae1a-e068d1950520\",536872439]],[\"^84\",[2693,\"^4<\",181,536872440]],[\"^84\",[2694,\"^48\",1775983729648,536872443]],[\"^84\",[2694,\"^7@\",\"a1\",536872443]],[\"^84\",[2694,\"^4S\",2692,536872443]],[\"^84\",[2694,\"^3X\",2692,536872443]],[\"^84\",[2694,\"^6:\",\"worjfdaf\",536872445]],[\"^84\",[2694,\"^S\",536872445,536872447]],[\"^84\",[2694,\"^3;\",1775983729659,536872445]],[\"^84\",[2694,\"^2T\",\"~u69db5c2b-6a3d-4726-a69e-41d53fd5a0bf\",536872443]],[\"^84\",[2694,\"^4<\",181,536872443]],[\"^84\",[2695,\"^48\",1775983729661,536872446]],[\"^84\",[2695,\"^7@\",\"a2\",536872446]],[\"^84\",[2695,\"^4S\",2692,536872446]],[\"^84\",[2695,\"^3X\",2692,536872446]],[\"^84\",[2695,\"^6:\",\"\",536872446]],[\"^84\",[2695,\"^S\",536872445,536872447]],[\"^84\",[2695,\"^3;\",1775983729661,536872446]],[\"^84\",[2695,\"^2T\",\"~u69db5c2c-b373-49b6-a1f1-029b2f8ca8c8\",536872446]],[\"^84\",[2695,\"^4<\",181,536872446]],[\"^84\",[2696,\"^48\",1775983729672,536872448]],[\"^84\",[2696,\"^7@\",\"a0\",536872451]],[\"^84\",[2696,\"^4S\",2692,536872448]],[\"^84\",[2696,\"^3X\",2695,536872451]],[\"^84\",[2696,\"^6:\",\"f\",536872453]],[\"^84\",[2696,\"^S\",536872453,536872455]],[\"^84\",[2696,\"^3;\",1775983729688,536872453]],[\"^84\",[2696,\"^2T\",\"~u69db5c2c-ba52-4f79-85f8-a7655369ba6c\",536872448]],[\"^84\",[2696,\"^4<\",181,536872449]],[\"^84\",[2697,\"^48\",1775983729689,536872454]],[\"^84\",[2697,\"^7@\",\"a1\",536872454]],[\"^84\",[2697,\"^4S\",2692,536872454]],[\"^84\",[2697,\"^3X\",2695,536872454]],[\"^84\",[2697,\"^6:\",\"af\",536872456]],[\"^84\",[2697,\"^S\",536872456,536872458]],[\"^84\",[2697,\"^3;\",1775983729700,536872456]],[\"^84\",[2697,\"^2T\",\"~u69db5c2c-f588-4d67-89cf-637ed39de614\",536872454]],[\"^84\",[2697,\"^4<\",181,536872454]],[\"^84\",[2698,\"^48\",1775983729701,536872457]],[\"^84\",[2698,\"^7@\",\"a2\",536872457]],[\"^84\",[2698,\"^4S\",2692,536872457]],[\"^84\",[2698,\"^3X\",2695,536872457]],[\"^84\",[2698,\"^6:\",\"as\",536872459]],[\"^84\",[2698,\"^S\",536872459,536872461]],[\"^84\",[2698,\"^3;\",1775983729712,536872459]],[\"^84\",[2698,\"^2T\",\"~u69db5c2d-eb41-41f0-97cb-c9dace0102c2\",536872457]],[\"^84\",[2698,\"^4<\",181,536872457]],[\"^84\",[2699,\"^48\",1775983729714,536872460]],[\"^84\",[2699,\"^7@\",\"a3\",536872460]],[\"^84\",[2699,\"^4S\",2692,536872460]],[\"^84\",[2699,\"^3X\",2695,536872460]],[\"^84\",[2699,\"^6:\",\"fa\",536872462]],[\"^84\",[2699,\"^S\",536872462,536872464]],[\"^84\",[2699,\"^3;\",1775983729725,536872462]],[\"^84\",[2699,\"^2T\",\"~u69db5c2d-02cd-4b60-b743-09feff4e4825\",536872460]],[\"^84\",[2699,\"^4<\",181,536872460]],[\"^84\",[2700,\"^48\",1775983729726,536872463]],[\"^84\",[2700,\"^7@\",\"a4\",536872463]],[\"^84\",[2700,\"^4S\",2692,536872463]],[\"^84\",[2700,\"^3X\",2695,536872463]],[\"^84\",[2700,\"^6:\",\"f\",536872465]],[\"^84\",[2700,\"^S\",536872465,536872467]],[\"^84\",[2700,\"^3;\",1775983729738,536872465]],[\"^84\",[2700,\"^2T\",\"~u69db5c2d-a17f-4516-b106-304a1befd700\",536872463]],[\"^84\",[2700,\"^4<\",181,536872463]],[\"^84\",[2701,\"^48\",1775983729739,536872466]],[\"^84\",[2701,\"^7@\",\"a5\",536872466]],[\"^84\",[2701,\"^4S\",2692,536872466]],[\"^84\",[2701,\"^3X\",2695,536872466]],[\"^84\",[2701,\"^1S\",2702,536872474]],[\"^84\",[2701,\"^6:\",\"af\",536872468]],[\"^84\",[2701,\"^S\",536872474,536872475]],[\"^84\",[2701,\"^3;\",1775983665047,536872474]],[\"^84\",[2701,\"^2T\",\"~u69db5c2d-167b-455d-9de8-a098caf456e3\",536872466]],[\"^84\",[2701,\"^4<\",181,536872466]],[\"^84\",[2701,\"^4?\",2703,536872474]],[\"^84\",[2702,\"^48\",1775983729759,536872471]],[\"^84\",[2702,\"^7M\",\"p10\",536872471]],[\"^84\",[2702,\"^7@\",\"d3BHk\",536872471]],[\"^84\",[2702,\"^1S\",58,536872473]],[\"^84\",[2702,\"^1S\",124,536872473]],[\"^84\",[2702,\"^69\",124,536872471]],[\"^84\",[2702,\"^6:\",\"p10\",536872471]],[\"^84\",[2702,\"^S\",536872471,536872473]],[\"^84\",[2702,\"^3;\",1775983729759,536872471]],[\"^84\",[2702,\"^2T\",\"~u00000002-1449-8430-2100-000000000000\",536872471]],[\"^84\",[2702,\"^<\",\"^=\",536872471]],[\"^84\",[2702,\"^;\",\"^4?\",536872471]],[\"^84\",[2702,\"^>\",true,536872471]],[\"^84\",[2702,\"^C\",\"^D\",536872471]],[\"^84\",[2702,\"^4<\",181,536872472]],[\"^84\",[2702,\"^1K\",\"^8>\",536872471]],[\"^84\",[2703,\"^48\",1775983665044,536872474]],[\"^84\",[2703,\"^7@\",\"d3BHj\",536872474]],[\"^84\",[2703,\"^4S\",2692,536872474]],[\"^84\",[2703,\"^3X\",2701,536872474]],[\"^84\",[2703,\"^1S\",2702,536872474]],[\"^84\",[2703,\"^6:\",\"hello\",536872476]],[\"^84\",[2703,\"^S\",536872476,536872478]],[\"^84\",[2703,\"^3;\",1775983729784,536872476]],[\"^84\",[2703,\"^2T\",\"~u69db5c31-45d6-443b-8f8f-1b1808c86b4b\",536872474]],[\"^84\",[2703,\"^4<\",181,536872474]],[\"^84\",[2703,\"^6?\",2702,536872474]],[\"^84\",[2704,\"^48\",1775983729785,536872477]],[\"^84\",[2704,\"^7@\",\"a0\",536872477]],[\"^84\",[2704,\"^4S\",2692,536872477]],[\"^84\",[2704,\"^3X\",2703,536872477]],[\"^84\",[2704,\"^6:\",\"fjdalfjklaf\",536872479]],[\"^84\",[2704,\"^S\",536872479,536872481]],[\"^84\",[2704,\"^3;\",1775983729797,536872479]],[\"^84\",[2704,\"^2T\",\"~u69db5c32-c449-4614-89c5-afdad827a965\",536872477]],[\"^84\",[2704,\"^4<\",181,536872477]],[\"^84\",[2705,\"^;\",\"^4O\",536872484]],[\"^84\",[2705,\"^28\",1775984161304,536872484]],[\"^84\",[2706,\"^;\",\"^4J\",536872485]],[\"^84\",[2706,\"^28\",1775984161307,536872485]],[\"^84\",[2778,\"^48\",1775984527252,536872747]],[\"^84\",[2778,\"^7M\",\"page 10\",536872747]],[\"^84\",[2778,\"^1S\",58,536872749]],[\"^84\",[2778,\"^1S\",104,536872749]],[\"^84\",[2778,\"^69\",104,536872747]],[\"^84\",[2778,\"^6:\",\"page 10\",536872747]],[\"^84\",[2778,\"^S\",536872879,536872881]],[\"^84\",[2778,\"^3;\",1775984527765,536872879]],[\"^84\",[2778,\"^2T\",\"~u69db5f78-689c-4382-9a3d-d2caf761adb6\",536872747]],[\"^84\",[2778,\"^4<\",181,536872748]],[\"^84\",[2793,\"^48\",1775984527610,536872833]],[\"^84\",[2793,\"^7@\",\"a0\",536872833]],[\"^84\",[2793,\"^4S\",2778,536872833]],[\"^84\",[2793,\"^3X\",2778,536872833]],[\"^84\",[2793,\"^6:\",\"hellofda\",536872836]],[\"^84\",[2793,\"^S\",536872836,536872838]],[\"^84\",[2793,\"^3;\",1775984527618,536872836]],[\"^84\",[2793,\"^2T\",\"~u69db5f78-dcde-4e98-9f1a-1bca6c316e8d\",536872833]],[\"^84\",[2793,\"^4<\",181,536872834]],[\"^84\",[2794,\"^48\",1775984527619,536872837]],[\"^84\",[2794,\"^7@\",\"a1\",536872837]],[\"^84\",[2794,\"^4S\",2778,536872837]],[\"^84\",[2794,\"^3X\",2778,536872837]],[\"^84\",[2794,\"^6:\",\"fa\",536872839]],[\"^84\",[2794,\"^S\",536872839,536872841]],[\"^84\",[2794,\"^3;\",1775984527629,536872839]],[\"^84\",[2794,\"^2T\",\"~u69db5f79-a66e-4a39-aecc-339726c89430\",536872837]],[\"^84\",[2794,\"^4<\",181,536872837]],[\"^84\",[2795,\"^48\",1775984527630,536872840]],[\"^84\",[2795,\"^7@\",\"a0\",536872842]],[\"^84\",[2795,\"^4S\",2778,536872840]],[\"^84\",[2795,\"^3X\",2794,536872842]],[\"^84\",[2795,\"^6:\",\"fdasf\",536872844]],[\"^84\",[2795,\"^S\",536872844,536872846]],[\"^84\",[2795,\"^3;\",1775984527646,536872844]],[\"^84\",[2795,\"^2T\",\"~u69db5f79-fd04-4c4f-a0d5-726877b307a3\",536872840]],[\"^84\",[2795,\"^4<\",181,536872840]],[\"^84\",[2796,\"^48\",1775984527647,536872845]],[\"^84\",[2796,\"^7@\",\"a1\",536872845]],[\"^84\",[2796,\"^4S\",2778,536872845]],[\"^84\",[2796,\"^3X\",2794,536872845]],[\"^84\",[2796,\"^6:\",\"af\",536872847]],[\"^84\",[2796,\"^S\",536872847,536872849]],[\"^84\",[2796,\"^3;\",1775984527656,536872847]],[\"^84\",[2796,\"^2T\",\"~u69db5f7a-d6b1-4bf8-90c6-a70ba0bec381\",536872845]],[\"^84\",[2796,\"^4<\",181,536872845]],[\"^84\",[2797,\"^48\",1775984527658,536872848]],[\"^84\",[2797,\"^7@\",\"a2\",536872848]],[\"^84\",[2797,\"^4S\",2778,536872848]],[\"^84\",[2797,\"^3X\",2794,536872848]],[\"^84\",[2797,\"^6:\",\"as\",536872850]],[\"^84\",[2797,\"^S\",536872850,536872852]],[\"^84\",[2797,\"^3;\",1775984527666,536872850]],[\"^84\",[2797,\"^2T\",\"~u69db5f7a-e8a7-4800-bd8c-b2c14b6648a8\",536872848]],[\"^84\",[2797,\"^4<\",181,536872848]],[\"^84\",[2798,\"^48\",1775984527668,536872851]],[\"^84\",[2798,\"^7@\",\"a3\",536872851]],[\"^84\",[2798,\"^4S\",2778,536872851]],[\"^84\",[2798,\"^3X\",2794,536872851]],[\"^84\",[2798,\"^6:\",\"fdas\",536872853]],[\"^84\",[2798,\"^S\",536872853,536872855]],[\"^84\",[2798,\"^3;\",1775984527677,536872853]],[\"^84\",[2798,\"^2T\",\"~u69db5f7a-f814-4036-b2d5-06b1688f319f\",536872851]],[\"^84\",[2798,\"^4<\",181,536872851]],[\"^84\",[2799,\"^48\",1775984527678,536872854]],[\"^84\",[2799,\"^7@\",\"a2\",536872856]],[\"^84\",[2799,\"^4S\",2778,536872854]],[\"^84\",[2799,\"^3X\",2778,536872856]],[\"^84\",[2799,\"^6:\",\"fs\",536872858]],[\"^84\",[2799,\"^S\",536872858,536872860]],[\"^84\",[2799,\"^3;\",1775984527694,536872858]],[\"^84\",[2799,\"^2T\",\"~u69db5f7b-2941-4704-88d5-3d10f2b585f6\",536872854]],[\"^84\",[2799,\"^4<\",181,536872854]],[\"^84\",[2800,\"^48\",1775984527696,536872859]],[\"^84\",[2800,\"^7@\",\"a3\",536872859]],[\"^84\",[2800,\"^4S\",2778,536872859]],[\"^84\",[2800,\"^3X\",2778,536872859]],[\"^84\",[2800,\"^6:\",\"af\",536872861]],[\"^84\",[2800,\"^S\",536872861,536872863]],[\"^84\",[2800,\"^3;\",1775984527705,536872861]],[\"^84\",[2800,\"^2T\",\"~u69db5f7b-11a3-4387-b2d8-e4360d0574b0\",536872859]],[\"^84\",[2800,\"^4<\",181,536872859]],[\"^84\",[2801,\"^48\",1775984527706,536872862]],[\"^84\",[2801,\"^7@\",\"a4\",536872862]],[\"^84\",[2801,\"^4S\",2778,536872862]],[\"^84\",[2801,\"^3X\",2778,536872862]],[\"^84\",[2801,\"^6:\",\"as\",536872862]],[\"^84\",[2801,\"^S\",536872861,536872863]],[\"^84\",[2801,\"^3;\",1775984527706,536872862]],[\"^84\",[2801,\"^2T\",\"~u69db5f7b-24f0-4258-b5b5-7c86c9fb264e\",536872862]],[\"^84\",[2801,\"^4<\",181,536872862]],[\"^84\",[2802,\"^48\",1775984527715,536872864]],[\"^84\",[2802,\"^7@\",\"a3V\",536872864]],[\"^84\",[2802,\"^4S\",2778,536872864]],[\"^84\",[2802,\"^3X\",2778,536872864]],[\"^84\",[2802,\"^6:\",\"fd\",536872867]],[\"^84\",[2802,\"^S\",536872867,536872869]],[\"^84\",[2802,\"^3;\",1775984527722,536872867]],[\"^84\",[2802,\"^2T\",\"~u69db5f7b-02a5-40e9-baf8-befba0d845c5\",536872864]],[\"^84\",[2802,\"^4<\",181,536872865]],[\"^84\",[2803,\"^48\",1775984527723,536872868]],[\"^84\",[2803,\"^7@\",\"a3l\",536872868]],[\"^84\",[2803,\"^4S\",2778,536872868]],[\"^84\",[2803,\"^3X\",2778,536872868]],[\"^84\",[2803,\"^6:\",\"af\",536872870]],[\"^84\",[2803,\"^S\",536872870,536872872]],[\"^84\",[2803,\"^3;\",1775984527732,536872870]],[\"^84\",[2803,\"^2T\",\"~u69db5f7c-4a9c-4a5f-ad77-4d171ab63937\",536872868]],[\"^84\",[2803,\"^4<\",181,536872868]],[\"^84\",[2804,\"^48\",1775984527734,536872871]],[\"^84\",[2804,\"^7@\",\"a3t\",536872871]],[\"^84\",[2804,\"^4S\",2778,536872871]],[\"^84\",[2804,\"^3X\",2778,536872871]],[\"^84\",[2804,\"^6:\",\"af\",536872873]],[\"^84\",[2804,\"^S\",536872873,536872875]],[\"^84\",[2804,\"^3;\",1775984527743,536872873]],[\"^84\",[2804,\"^2T\",\"~u69db5f7c-e498-4db4-9791-53e4560eb1cc\",536872871]],[\"^84\",[2804,\"^4<\",181,536872871]],[\"^84\",[2805,\"^48\",1775984527744,536872874]],[\"^84\",[2805,\"^7@\",\"a3x\",536872874]],[\"^84\",[2805,\"^4S\",2778,536872874]],[\"^84\",[2805,\"^3X\",2778,536872874]],[\"^84\",[2805,\"^6:\",\"af\",536872876]],[\"^84\",[2805,\"^S\",536872876,536872878]],[\"^84\",[2805,\"^3;\",1775984527754,536872876]],[\"^84\",[2805,\"^2T\",\"~u69db5f7c-7746-45d5-8989-646ee3b9289b\",536872874]],[\"^84\",[2805,\"^4<\",181,536872874]],[\"^84\",[2806,\"^48\",1775984527755,536872877]],[\"^84\",[2806,\"^7@\",\"a3z\",536872877]],[\"^84\",[2806,\"^4S\",2778,536872877]],[\"^84\",[2806,\"^3X\",2778,536872877]],[\"^84\",[2806,\"^6:\",\"a\",536872879]],[\"^84\",[2806,\"^S\",536872879,536872881]],[\"^84\",[2806,\"^3;\",1775984527764,536872879]],[\"^84\",[2806,\"^2T\",\"~u69db5f7c-8423-4ed9-bb36-bf5156b4d757\",536872877]],[\"^84\",[2806,\"^4<\",181,536872877]],[\"^84\",[2931,\"^48\",1775984610679,536873146]],[\"^84\",[2931,\"^7M\",\"page 11\",536873146]],[\"^84\",[2931,\"^1S\",58,536873146]],[\"^84\",[2931,\"^1S\",104,536873146]],[\"^84\",[2931,\"^69\",104,536873146]],[\"^84\",[2931,\"^6:\",\"page 11\",536873146]],[\"^84\",[2931,\"^3;\",1775984651669,536885723]],[\"^84\",[2931,\"^2T\",\"~u69db5fe2-20d8-4c72-8f3a-96b778acec6f\",536873146]],[\"^84\",[2931,\"^4<\",181,536873146]],[\"^84\",[2932,\"^48\",1775984610938,536873147]],[\"^84\",[2932,\"^7@\",\"a0\",536873147]],[\"^84\",[2932,\"^4S\",2931,536873147]],[\"^84\",[2932,\"^3X\",2931,536873147]],[\"^84\",[2932,\"^6:\",\"fjdlafjl\",536885538]],[\"^84\",[2932,\"^3;\",1775984611435,536885538]],[\"^84\",[2932,\"^2T\",\"~u69db5fe2-56e1-428d-bf11-06cb38229be2\",536873147]],[\"^84\",[2932,\"^4<\",181,536873147]],[\"^84\",[2933,\"^48\",1775984611507,536873149]],[\"^84\",[2933,\"^7@\",\"a0\",536885545]],[\"^84\",[2933,\"^4S\",2931,536873149]],[\"^84\",[2933,\"^3X\",2932,536873150]],[\"^84\",[2933,\"^6:\",\"ff\",536885547]],[\"^84\",[2933,\"^3;\",1775984611935,536885547]],[\"^84\",[2933,\"^2T\",\"~u69db5fe3-6de1-4070-96b6-6fb5e16cfd5d\",536873149]],[\"^84\",[2933,\"^4<\",181,536873149]],[\"^84\",[2934,\"^48\",1775984611937,536873151]],[\"^84\",[2934,\"^7@\",\"a0\",536885555]],[\"^84\",[2934,\"^4S\",2931,536873151]],[\"^84\",[2934,\"^3X\",2933,536873154]],[\"^84\",[2934,\"^6:\",\"f\",536885554]],[\"^84\",[2934,\"^3;\",1775984612220,536885554]],[\"^84\",[2934,\"^2T\",\"~u69db5fe3-0419-47c1-9d77-36ba70846eae\",536873151]],[\"^84\",[2934,\"^4<\",181,536873151]],[\"^84\",[2935,\"^48\",1775984612293,536873155]],[\"^84\",[2935,\"^7@\",\"a0\",536885568]],[\"^84\",[2935,\"^4S\",2931,536873155]],[\"^84\",[2935,\"^3X\",2934,536873159]],[\"^84\",[2935,\"^6:\",\"f\",536885564]],[\"^84\",[2935,\"^3;\",1775984612724,536885567]],[\"^84\",[2935,\"^2T\",\"~u69db5fe4-4a1d-4a85-8ffc-13fa73cf7772\",536873155]],[\"^84\",[2935,\"^4<\",181,536873155]],[\"^84\",[2936,\"^48\",1775984612632,536873158]],[\"^84\",[2936,\"^7@\",\"a1\",536885573]],[\"^84\",[2936,\"^4S\",2931,536873158]],[\"^84\",[2936,\"^3X\",2934,536873161]],[\"^84\",[2936,\"^6:\",\"f\",536885569]],[\"^84\",[2936,\"^3;\",1775984612903,536885572]],[\"^84\",[2936,\"^2T\",\"~u69db5fe4-bafe-449d-acb5-c363728d2c3a\",536873158]],[\"^84\",[2936,\"^4<\",181,536873158]],[\"^84\",[2937,\"^48\",1775984612812,536873160]],[\"^84\",[2937,\"^7@\",\"a3\",536873160]],[\"^84\",[2937,\"^4S\",2931,536873160]],[\"^84\",[2937,\"^3X\",2933,536873160]],[\"^84\",[2937,\"^6:\",\"f\",536885574]],[\"^84\",[2937,\"^3;\",1775984613001,536885574]],[\"^84\",[2937,\"^2T\",\"~u69db5fe4-a665-4b11-82ef-9fa78a1d025c\",536873160]],[\"^84\",[2937,\"^4<\",181,536873160]],[\"^84\",[2938,\"^48\",1775984613002,536873162]],[\"^84\",[2938,\"^7@\",\"a0\",536885577]],[\"^84\",[2938,\"^4S\",2931,536873162]],[\"^84\",[2938,\"^3X\",2937,536873163]],[\"^84\",[2938,\"^6:\",\"f\",536885579]],[\"^84\",[2938,\"^3;\",1775984613203,536885579]],[\"^84\",[2938,\"^2T\",\"~u69db5fe4-e871-4f41-a9a6-d2783de5b724\",536873162]],[\"^84\",[2938,\"^4<\",181,536873162]],[\"^84\",[2939,\"^48\",1775984613203,536873164]],[\"^84\",[2939,\"^7@\",\"a2\",536885639]],[\"^84\",[2939,\"^4S\",2931,536873164]],[\"^84\",[2939,\"^3X\",2950,536873186]],[\"^84\",[2939,\"^6:\",\"q\",536885584]],[\"^84\",[2939,\"^3;\",1775984613413,536885584]],[\"^84\",[2939,\"^2T\",\"~u69db5fe5-c93e-4715-a9e2-b311246fe3f1\",536873164]],[\"^84\",[2939,\"^4<\",181,536873164]],[\"^84\",[2940,\"^48\",1775984613413,536873166]],[\"^84\",[2940,\"^7@\",\"a1\",536873166]],[\"^84\",[2940,\"^4S\",2931,536873166]],[\"^84\",[2940,\"^3X\",2938,536873166]],[\"^84\",[2940,\"^6:\",\"\",536873166]],[\"^84\",[2940,\"^3;\",1775984613413,536873166]],[\"^84\",[2940,\"^2T\",\"~u69db5fe5-b337-41af-9146-6e6e01e72130\",536873166]],[\"^84\",[2940,\"^4<\",181,536873166]],[\"^84\",[2941,\"^48\",1775984613561,536873168]],[\"^84\",[2941,\"^7@\",\"a2\",536885721]],[\"^84\",[2941,\"^4S\",2931,536873168]],[\"^84\",[2941,\"^3X\",2931,536873469]],[\"^84\",[2941,\"^6:\",\"fdafdasf\",536885723]],[\"^84\",[2941,\"^3;\",1775984651668,536885723]],[\"^84\",[2941,\"^2T\",\"~u69db5fe5-ce1b-4fb0-802c-ca3c7deba63a\",536873168]],[\"^84\",[2941,\"^4<\",181,536873168]],[\"^84\",[2942,\"^48\",1775984613782,536873170]],[\"^84\",[2942,\"^7@\",\"Zz\",536873170]],[\"^84\",[2942,\"^4S\",2931,536873170]],[\"^84\",[2942,\"^3X\",2940,536873170]],[\"^84\",[2942,\"^6:\",\"ff\",536885603]],[\"^84\",[2942,\"^3;\",1775984614190,536885606]],[\"^84\",[2942,\"^2T\",\"~u69db5fe5-5fad-4cca-bed6-f9e7c1e2a37d\",536873170]],[\"^84\",[2942,\"^4<\",181,536873170]],[\"^84\",[2943,\"^48\",1775984613943,536873172]],[\"^84\",[2943,\"^7@\",\"a1\",536885638]],[\"^84\",[2943,\"^4S\",2931,536873172]],[\"^84\",[2943,\"^3X\",2950,536873186]],[\"^84\",[2943,\"^6:\",\"\",536873172]],[\"^84\",[2943,\"^3;\",1775984613943,536873172]],[\"^84\",[2943,\"^2T\",\"~u69db5fe5-deee-4706-a78d-aaf4007518c1\",536873172]],[\"^84\",[2943,\"^4<\",181,536873172]],[\"^84\",[2944,\"^48\",1775984614111,536873173]],[\"^84\",[2944,\"^7@\",\"a0\",536885612]],[\"^84\",[2944,\"^4S\",2931,536873173]],[\"^84\",[2944,\"^3X\",2942,536873176]],[\"^84\",[2944,\"^6:\",\"f\",536885608]],[\"^84\",[2944,\"^3;\",1775984614359,536885611]],[\"^84\",[2944,\"^2T\",\"~u69db5fe6-fd79-4fa8-bfab-30986aa83bcc\",536873173]],[\"^84\",[2944,\"^4<\",181,536873173]],[\"^84\",[2945,\"^48\",1775984614283,536873175]],[\"^84\",[2945,\"^7@\",\"a1\",536885617]],[\"^84\",[2945,\"^4S\",2931,536873175]],[\"^84\",[2945,\"^3X\",2942,536873178]],[\"^84\",[2945,\"^6:\",\"f\",536885613]],[\"^84\",[2945,\"^3;\",1775984614536,536885616]],[\"^84\",[2945,\"^2T\",\"~u69db5fe6-437d-47c3-91e6-7eed23097b73\",536873175]],[\"^84\",[2945,\"^4<\",181,536873175]],[\"^84\",[2946,\"^48\",1775984614454,536873177]],[\"^84\",[2946,\"^7@\",\"ZzS\",536873177]],[\"^84\",[2946,\"^4S\",2931,536873177]],[\"^84\",[2946,\"^3X\",2940,536873177]],[\"^84\",[2946,\"^6:\",\"\",536873177]],[\"^84\",[2946,\"^3;\",1775984614454,536873177]],[\"^84\",[2946,\"^2T\",\"~u69db5fe6-a079-4c88-8d9c-bc0609008fe6\",536873177]],[\"^84\",[2946,\"^4<\",181,536873177]],[\"^84\",[2947,\"^48\",1775984614634,536873179]],[\"^84\",[2947,\"^7@\",\"a2\",536885627]],[\"^84\",[2947,\"^4S\",2931,536873179]],[\"^84\",[2947,\"^3X\",2946,536873182]],[\"^84\",[2947,\"^6:\",\"ff\",536885632]],[\"^84\",[2947,\"^3;\",1775984615139,536885632]],[\"^84\",[2947,\"^2T\",\"~u69db5fe6-9f47-45fd-bd1d-78cdad7e5894\",536873179]],[\"^84\",[2947,\"^4<\",181,536873179]],[\"^84\",[2948,\"^48\",1775984614835,536873181]],[\"^84\",[2948,\"^7@\",\"a1\",536873181]],[\"^84\",[2948,\"^4S\",2931,536873181]],[\"^84\",[2948,\"^3X\",2946,536873181]],[\"^84\",[2948,\"^6:\",\"\",536873181]],[\"^84\",[2948,\"^3;\",1775984614835,536873181]],[\"^84\",[2948,\"^2T\",\"~u69db5fe6-6b73-4f8c-a89d-3cca819ee63c\",536873181]],[\"^84\",[2948,\"^4<\",181,536873181]],[\"^84\",[2949,\"^48\",1775984614992,536873183]],[\"^84\",[2949,\"^7@\",\"a0\",536885636]],[\"^84\",[2949,\"^4S\",2931,536873183]],[\"^84\",[2949,\"^3X\",2950,536873185]],[\"^84\",[2949,\"^6:\",\"\",536873183]],[\"^84\",[2949,\"^3;\",1775984614992,536873183]],[\"^84\",[2949,\"^2T\",\"~u69db5fe6-d898-483f-bc9b-0122ac0cffe1\",536873183]],[\"^84\",[2949,\"^4<\",181,536873183]],[\"^84\",[2950,\"^48\",1775984615139,536873184]],[\"^84\",[2950,\"^7@\",\"a1\",536885643]],[\"^84\",[2950,\"^4S\",2931,536873184]],[\"^84\",[2950,\"^3X\",2931,536873190]],[\"^84\",[2950,\"^6:\",\"\",536873184]],[\"^84\",[2950,\"^3;\",1775984615139,536873184]],[\"^84\",[2950,\"^2T\",\"~u69db5fe7-2d4d-43a8-b50b-aa6625c817ae\",536873184]],[\"^84\",[2950,\"^4<\",181,536873184]],[\"^84\",[2988,\"^48\",1775984622069,536873306]],[\"^84\",[2988,\"^7@\",\"a0\",536873309]],[\"^84\",[2988,\"^4S\",180,536873306]],[\"^84\",[2988,\"^3X\",2578,536873309]],[\"^84\",[2988,\"^6:\",\"3\",536873466]],[\"^84\",[2988,\"^S\",536873311,536873466]],[\"^84\",[2988,\"^3;\",1775984622083,536873466]],[\"^84\",[2988,\"^2T\",\"~u69db5fbe-a17a-4787-8615-7455e1eba752\",536873306]],[\"^84\",[2988,\"^4<\",181,536873307]],[\"^84\",[2988,\"^2T\",\"~u69db5fbe-a17a-4787-8615-7455e1eba752\",536873306]],[\"^84\",[2988,\"^4<\",181,536873307]],[\"^84\",[3032,\"^48\",1775984643975,536873385]],[\"^84\",[3032,\"^7@\",\"a1\",536873385]],[\"^84\",[3032,\"^4S\",180,536873385]],[\"^84\",[3032,\"^3X\",2578,536873385]],[\"^84\",[3032,\"^6:\",\"da\",536873388]],[\"^84\",[3032,\"^S\",536873388,536873390]],[\"^84\",[3032,\"^3;\",1775984644155,536873388]],[\"^84\",[3032,\"^2T\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873385]],[\"^84\",[3032,\"^4<\",181,536873386]],[\"^84\",[3033,\"^48\",1775984644156,536873388]],[\"^84\",[3033,\"^7@\",\"a2\",536873388]],[\"^84\",[3033,\"^4S\",180,536873388]],[\"^84\",[3033,\"^3X\",2578,536873388]],[\"^84\",[3033,\"^6:\",\"fda\",536873391]],[\"^84\",[3033,\"^S\",536873391,536873393]],[\"^84\",[3033,\"^3;\",1775984644335,536873391]],[\"^84\",[3033,\"^2T\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873388]],[\"^84\",[3033,\"^4<\",181,536873389]],[\"^84\",[3034,\"^48\",1775984644336,536873391]],[\"^84\",[3034,\"^7@\",\"a3\",536873391]],[\"^84\",[3034,\"^4S\",180,536873391]],[\"^84\",[3034,\"^3X\",2578,536873391]],[\"^84\",[3034,\"^6:\",\"f\",536873394]],[\"^84\",[3034,\"^S\",536873394,536873396]],[\"^84\",[3034,\"^3;\",1775984644505,536873394]],[\"^84\",[3034,\"^2T\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873391]],[\"^84\",[3034,\"^4<\",181,536873392]],[\"^84\",[3035,\"^48\",1775984644506,536873394]],[\"^84\",[3035,\"^7@\",\"a1\",536873397]],[\"^84\",[3035,\"^4S\",180,536873394]],[\"^84\",[3035,\"^3X\",180,536873399]],[\"^84\",[3035,\"^6:\",\"\",536873394]],[\"^84\",[3035,\"^S\",536873399,536873400]],[\"^84\",[3035,\"^3;\",1775984644506,536873394]],[\"^84\",[3035,\"^2T\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873394]],[\"^84\",[3035,\"^4<\",181,536873395]],[\"^84\",[3036,\"^48\",1775984645026,536873401]],[\"^84\",[3036,\"^7@\",\"a2\",536873401]],[\"^84\",[3036,\"^4S\",180,536873401]],[\"^84\",[3036,\"^3X\",180,536873401]],[\"^84\",[3036,\"^6:\",\"\",536873401]],[\"^84\",[3036,\"^S\",536873401,536873403]],[\"^84\",[3036,\"^3;\",1775984645026,536873401]],[\"^84\",[3036,\"^2T\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873401]],[\"^84\",[3036,\"^4<\",181,536873402]],[\"^84\",[3037,\"^48\",1775984645181,536873404]],[\"^84\",[3037,\"^7@\",\"a3\",536873404]],[\"^84\",[3037,\"^4S\",180,536873404]],[\"^84\",[3037,\"^3X\",180,536873404]],[\"^84\",[3037,\"^6:\",\"fdasf\",536873407]],[\"^84\",[3037,\"^S\",536873407,536873409]],[\"^84\",[3037,\"^3;\",1775984645524,536873407]],[\"^84\",[3037,\"^2T\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873404]],[\"^84\",[3037,\"^4<\",181,536873405]],[\"^84\",[3038,\"^48\",1775984645526,536873407]],[\"^84\",[3038,\"^7@\",\"a4\",536873407]],[\"^84\",[3038,\"^4S\",180,536873407]],[\"^84\",[3038,\"^3X\",180,536873407]],[\"^84\",[3038,\"^6:\",\"a\",536873410]],[\"^84\",[3038,\"^S\",536873410,536873412]],[\"^84\",[3038,\"^3;\",1775984645690,536873410]],[\"^84\",[3038,\"^2T\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873407]],[\"^84\",[3038,\"^4<\",181,536873408]],[\"^84\",[3039,\"^48\",1775984645692,536873410]],[\"^84\",[3039,\"^7@\",\"a0\",536873414]],[\"^84\",[3039,\"^4S\",180,536873410]],[\"^84\",[3039,\"^3X\",3038,536873414]],[\"^84\",[3039,\"^6:\",\"ffd\",536873416]],[\"^84\",[3039,\"^S\",536873416,536873418]],[\"^84\",[3039,\"^3;\",1775984646215,536873416]],[\"^84\",[3039,\"^2T\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873410]],[\"^84\",[3039,\"^4<\",181,536873411]],[\"^84\",[3040,\"^48\",1775984646216,536873416]],[\"^84\",[3040,\"^7@\",\"a1\",536873416]],[\"^84\",[3040,\"^4S\",180,536873416]],[\"^84\",[3040,\"^3X\",3038,536873416]],[\"^84\",[3040,\"^6:\",\"asf\",536873419]],[\"^84\",[3040,\"^S\",536873419,536873421]],[\"^84\",[3040,\"^3;\",1775984646415,536873419]],[\"^84\",[3040,\"^2T\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873416]],[\"^84\",[3040,\"^4<\",181,536873417]],[\"^84\",[3041,\"^48\",1775984646416,536873419]],[\"^84\",[3041,\"^7@\",\"a2\",536873424]],[\"^84\",[3041,\"^4S\",180,536873419]],[\"^84\",[3041,\"^3X\",3038,536873424]],[\"^84\",[3041,\"^6:\",\"f\",536873426]],[\"^84\",[3041,\"^S\",536873426,536873428]],[\"^84\",[3041,\"^3;\",1775984646786,536873426]],[\"^84\",[3041,\"^2T\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873419]],[\"^84\",[3041,\"^4<\",181,536873420]],[\"^84\",[3042,\"^48\",1775984646787,536873426]],[\"^84\",[3042,\"^7@\",\"a0\",536873429]],[\"^84\",[3042,\"^4S\",180,536873426]],[\"^84\",[3042,\"^3X\",3040,536873429]],[\"^84\",[3042,\"^6:\",\"\",536873426]],[\"^84\",[3042,\"^S\",536873429,536873430]],[\"^84\",[3042,\"^3;\",1775984646787,536873426]],[\"^84\",[3042,\"^2T\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873426]],[\"^84\",[3042,\"^4<\",181,536873427]],[\"^84\",[3043,\"^48\",1775984646989,536873431]],[\"^84\",[3043,\"^7@\",\"a1\",536873431]],[\"^84\",[3043,\"^4S\",180,536873431]],[\"^84\",[3043,\"^3X\",3040,536873431]],[\"^84\",[3043,\"^6:\",\"\",536873431]],[\"^84\",[3043,\"^S\",536873431,536873433]],[\"^84\",[3043,\"^3;\",1775984646989,536873431]],[\"^84\",[3043,\"^2T\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873431]],[\"^84\",[3043,\"^4<\",181,536873432]],[\"^84\",[3044,\"^48\",1775984647121,536873434]],[\"^84\",[3044,\"^7@\",\"a0\",536873440]],[\"^84\",[3044,\"^4S\",180,536873434]],[\"^84\",[3044,\"^3X\",3042,536873440]],[\"^84\",[3044,\"^6:\",\"\",536873434]],[\"^84\",[3044,\"^S\",536873440,536873441]],[\"^84\",[3044,\"^3;\",1775984647121,536873434]],[\"^84\",[3044,\"^2T\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873434]],[\"^84\",[3044,\"^4<\",181,536873435]],[\"^84\",[3045,\"^48\",1775984647315,536873437]],[\"^84\",[3045,\"^7@\",\"a0l\",536873437]],[\"^84\",[3045,\"^4S\",180,536873437]],[\"^84\",[3045,\"^3X\",3040,536873437]],[\"^84\",[3045,\"^6:\",\"f\",536873442]],[\"^84\",[3045,\"^S\",536873442,536873444]],[\"^84\",[3045,\"^3;\",1775984647522,536873442]],[\"^84\",[3045,\"^2T\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873437]],[\"^84\",[3045,\"^4<\",181,536873438]],[\"^84\",[3046,\"^48\",1775984647523,536873442]],[\"^84\",[3046,\"^7@\",\"a0t\",536873442]],[\"^84\",[3046,\"^4S\",180,536873442]],[\"^84\",[3046,\"^3X\",3040,536873442]],[\"^84\",[3046,\"^6:\",\"\",536873442]],[\"^84\",[3046,\"^S\",536873442,536873444]],[\"^84\",[3046,\"^3;\",1775984647523,536873442]],[\"^84\",[3046,\"^2T\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873442]],[\"^84\",[3046,\"^4<\",181,536873443]]]]]]", :db-after "[\"~#datascript/DB\",[\"^ \",\"~:schema\",[\"^ \",\"~i32\",\"~:logseq.property/priority\",\"~i64\",\"~:logseq.property/ls-type\",\"~i96\",\"~:logseq.property.repeat/repeated?\",\"~i128\",\"~:logseq.property/view-for\",\"~:logseq.property.asset/align\",[\"^ \",\"~:db/ident\",\"^:\",\"~:db/cardinality\",\"~:db.cardinality/one\",\"~:db/index\",true],\"~:logseq.property.view/type.table\",[\"^ \",\"^;\",\"^?\"],\"~:logseq.property/ui-position\",[\"^ \",\"^;\",\"^@\",\"^<\",\"^=\",\"^>\",true],\"~:file/created-at\",[\"^ \"],\"~:logseq.property.repeat/temporal-property\",[\"^ \",\"^;\",\"^B\",\"^<\",\"^=\",\"^>\",true,\"~:db/valueType\",\"~:db.type/ref\"],\"~:logseq.property.user/name\",[\"^ \",\"^;\",\"^E\",\"^<\",\"^=\",\"^>\",true],\"~i1\",\"~:logseq.property.reaction/target\",\"~i33\",\"~:logseq.property/priority.urgent\",\"~i65\",\"~:logseq.property/color.yellow\",\"~i97\",\"~:block/closed-value-property\",\"~:logseq.property.pdf/hl-image\",[\"^ \",\"^;\",\"^M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i129\",\"~:logseq.property.user/avatar\",\"~:logseq.property.fsrs/state\",[\"^ \",\"^;\",\"^P\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.pdf/hl-color\",[\"^ \",\"^;\",\"^Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-initial-schema-version\",[\"^ \",\"^;\",\"^R\"],\"~:block/tx-id\",[\"^ \"],\"~:logseq.property/value\",[\"^ \",\"^;\",\"^T\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.week\",[\"^ \",\"^;\",\"^U\"],\"~i2\",\"~:logseq.kv/db-type\",\"~i34\",\"~:logseq.property/public?\",\"~i66\",\"~:logseq.property.asset/width\",\"~i98\",\"~:logseq.property.reaction/emoji-id\",\"~i130\",\"^E\",\"~:logseq.property.table/sorting\",[\"^ \",\"^;\",\"^12\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/choice-exclusions\",[\"^ \",\"^;\",\"^13\",\"^<\",\"~:db.cardinality/many\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.table/pinned-columns\",[\"^ \",\"^;\",\"^15\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit.day\",[\"^ \",\"^;\",\"^16\"],\"~i3\",\"~:logseq.property.repeat/checked-property\",\"~i35\",\"~:logseq.property.recycle/original-order\",\"~i67\",\"~:logseq.property.asset/remote-metadata\",\"~i99\",\"~:logseq.property/priority.high\",\"~i131\",\"~:logseq.property.asset/size\",\"~:logseq.property.sync/large-title-object\",[\"^ \",\"^;\",\"^1@\",\"^<\",\"^=\",\"^>\",true],\"~i4\",\"~:logseq.class/Pdf-annotation\",\"~i36\",\"~:block/journal-day\",\"~i68\",\"~:logseq.property.view/type.list\",\"~i100\",\"~:logseq.property/color.blue\",\"~i132\",\"~:logseq.property.class/bidirectional-property-title\",\"~:logseq.property/deadline\",[\"^ \",\"^;\",\"^1J\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/type\",[\"^ \",\"^;\",\"^1K\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.year\",[\"^ \",\"^;\",\"^1L\"],\"~:file/content\",[\"^ \"],\"~:user.property/p3-Bhx1XTp3\",[\"^ \",\"^;\",\"^1N\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i5\",\"~:logseq.property.asset/checksum\",\"~i37\",\"~:logseq.property.node/display-type\",\"~i69\",\"~:block/refs\",\"~i101\",\"~:logseq.property.table/ordered-columns\",\"~:logseq.property.view/sort-groups-desc?\",[\"^ \",\"^;\",\"^1V\",\"^<\",\"^=\",\"^>\",true],\"~i133\",\"~:logseq.property.class/hide-from-node\",\"~:logseq.property.table/sized-columns\",[\"^ \",\"^;\",\"^1Y\",\"^<\",\"^=\",\"^>\",true],\"~:block/alias\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^>\",true,\"^;\",\"^1Z\"],\"~:logseq.property.publish/published-url\",[\"^ \",\"^;\",\"^1[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/external-url\",[\"^ \",\"^;\",\"^20\",\"^<\",\"^=\",\"^>\",true],\"~i6\",\"^1Y\",\"^O\",[\"^ \",\"^;\",\"^O\",\"^<\",\"^=\",\"^>\",true],\"~i38\",\"~:logseq.class/Template\",\"~:user.property/p5-q5guozE4\",[\"^ \",\"^;\",\"^23\",\"^C\",\"^D\",\"^>\",true,\"^<\",\"^14\"],\"~i70\",\"^?\",\"~i102\",\"~:logseq.property/background-color\",\"~i134\",\"^B\",\"~:kv/value\",[\"^ \"],\"~:logseq.property/scalar-default-value\",[\"^ \",\"^;\",\"^29\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/type\",[\"^ \",\"^;\",\"^2:\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Asset\",[\"^ \",\"^;\",\"^2;\"],\"^19\",[\"^ \",\"^;\",\"^19\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.class/enable-bidirectional?\",[\"^ \",\"^;\",\"^2<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/color.green\",[\"^ \",\"^;\",\"^2=\"],\"~i7\",\"^1Z\",\"~i39\",\"~:logseq.kv/local-graph-uuid\",\"~i71\",\"~:logseq.property/description\",\"~i103\",\"~:logseq.class/Cards\",\"~i135\",\"~:logseq.property.repeat/recur-unit.minute\",\"~:logseq.property.linked-references/includes\",[\"^ \",\"^;\",\"^2F\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit\",[\"^ \",\"^;\",\"^2G\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/checkbox-display-properties\",[\"^ \",\"^;\",\"^2H\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:block/link\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^2I\",\"^<\",\"^=\"],\"~:logseq.property.view/type\",[\"^ \",\"^;\",\"^2J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.class/Quote-block\",[\"^ \",\"^;\",\"^2K\"],\"~:logseq.property/heading\",[\"^ \",\"^;\",\"^2L\",\"^<\",\"^=\",\"^>\",true],\"~i8\",\"~:logseq.property.view/sort-groups-by-property\",\"~i40\",\"~:logseq.property.pdf/hl-page\",\"~i72\",\"~:logseq.property.recycle/original-page\",\"~i104\",\"~:logseq.class/Page\",\"~:block/uuid\",[\"^ \",\"~:db/unique\",\"~:db.unique/identity\"],\"~i136\",\"~:logseq.property/query\",\"~:logseq.property/color.purple\",[\"^ \",\"^;\",\"^2Y\"],\"~:logseq.property.asset/external-file-name\",[\"^ \",\"^;\",\"^2Z\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.table/hidden-columns\",[\"^ \",\"^;\",\"^2[\",\"^<\",\"^14\",\"^>\",true],\"~:logseq.class/Property\",[\"^ \",\"^;\",\"^30\"],\"~:logseq.kv/graph-git-sha\",[\"^ \",\"^;\",\"^31\"],\"~i9\",\"~:logseq.property/status.doing\",\"~i41\",\"^2H\",\"~i73\",\"~:logseq.property.linked-references/excludes\",\"~i105\",\"^U\",\"^2S\",[\"^ \",\"^;\",\"^2S\"],\"~i137\",\"~:logseq.property.user/email\",\"~:logseq.kv/graph-remote?\",[\"^ \",\"^;\",\"^39\"],\"^2?\",[\"^ \",\"^;\",\"^2?\"],\"~:logseq.kv/graph-rtc-e2ee?\",[\"^ \",\"^;\",\"^3:\"],\"~:block/updated-at\",[\"^ \",\"^>\",true,\"^;\",\"^3;\",\"^<\",\"^=\"],\"^1G\",[\"^ \",\"^;\",\"^1G\"],\"~i10\",\"~:logseq.class/Math-block\",\"~i42\",\"^2Y\",\"~i74\",\"~:logseq.property.history/scalar-value\",\"~i106\",\"~:logseq.property.tldraw/shape\",\"~i138\",\"~:block/collapsed?\",\"^1E\",[\"^ \",\"^;\",\"^1E\"],\"^F\",[\"^ \",\"^;\",\"^F\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i11\",\"~:logseq.property/status.canceled\",\"~:file/size\",[\"^ \"],\"~i43\",\"^P\",\"~:logseq.property/status\",[\"^ \",\"^;\",\"^3I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i75\",\"^1J\",\"~:logseq.property.class/extends\",[\"^ \",\"^;\",\"^3K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i107\",\"~:logseq.property.asset/height\",\"~i139\",\"~:logseq.property.repeat/recur-unit.hour\",\"~:logseq.property/hide-empty-value\",[\"^ \",\"^;\",\"^3P\",\"^<\",\"^=\",\"^>\",true],\"^2M\",[\"^ \",\"^;\",\"^2M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1S\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^1S\",\"^>\",true],\"~i2475\",\"~:user.class/tag2-GCc7Ko89\",\"~i619\",\"~:user.class/tag1-bGi52uW6\",\"~:logseq.class/Card\",[\"^ \",\"^;\",\"^3U\"],\"~:user.property/p2-JBrZedg-\",[\"^ \",\"^;\",\"^3V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^10\",[\"^ \",\"^;\",\"^10\",\"^<\",\"^=\",\"^>\",true],\"~i12\",\"~:block/parent\",\"~i44\",\"^1V\",\"~i76\",\"^2=\",\"~i108\",\"~:logseq.property.code/lang\",\"~i140\",\"~:logseq.property.tldraw/page\",\"~:logseq.property.view/group-by-property\",[\"^ \",\"^;\",\"^43\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/view-context\",[\"^ \",\"^;\",\"^44\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/schema-version\",[\"^ \",\"^;\",\"^45\"],\"^2O\",[\"^ \",\"^;\",\"^2O\",\"^<\",\"^=\",\"^>\",true],\"~i13\",\"^2G\",\"~i45\",\"~:block/created-at\",\"^L\",[\"^ \",\"^C\",\"^D\",\"^;\",\"^L\",\"^>\",true,\"^<\",\"^14\"],\"~i77\",\"~:logseq.property/choice-classes\",\"~i109\",\"~:logseq.property/created-by-ref\",\"^1O\",[\"^ \",\"^;\",\"^1O\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/publishing-public?\",[\"^ \",\"^;\",\"^4=\",\"^<\",\"^=\",\"^>\",true],\"~i141\",\"^31\",\"~:user.property/p10-QpfPhdWj\",[\"^ \",\"^>\",true,\"^C\",\"^D\",\"^<\",\"^=\",\"^;\",\"^4?\"],\"^1Q\",[\"^ \",\"^;\",\"^1Q\",\"^<\",\"^=\",\"^>\",true],\"~i2477\",\"^23\",\"~:logseq.property/status.backlog\",[\"^ \",\"^;\",\"^4A\"],\"~i14\",\"~:logseq.class/Tag\",\"~:file/last-modified-at\",[\"^ \"],\"~i46\",\"^2;\",\"~i78\",\"^2K\",\"~i110\",\"^13\",\"~i142\",\"~:logseq.class/Query\",\"~:logseq.kv/recycle-last-gc-at\",[\"^ \",\"^;\",\"^4J\"],\"~:logseq.property.class/properties\",[\"^ \",\"^;\",\"^4K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/used-template\",[\"^ \",\"^;\",\"^4L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2702\",\"^4?\",\"~:logseq.property/enable-history?\",[\"^ \",\"^;\",\"^4N\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/graph-last-gc-at\",[\"^ \",\"^;\",\"^4O\"],\"~i15\",\"~:logseq.kv/graph-created-at\",\"~i47\",\"~:block/page\",\"~i79\",\"^29\",\"^3=\",[\"^ \",\"^;\",\"^3=\"],\"~i111\",\"^R\",\"~i143\",\"~:logseq.property/choice-checkbox-state\",\"^48\",[\"^ \",\"^>\",true,\"^;\",\"^48\",\"^<\",\"^=\"],\"^3@\",[\"^ \",\"^;\",\"^3@\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/page-tags\",[\"^ \",\"^;\",\"^4X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/scheduled\",[\"^ \",\"^;\",\"^4Y\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Task\",[\"^ \",\"^;\",\"^4Z\"],\"^3D\",[\"^ \",\"^;\",\"^3D\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/last-visit-page\",[\"^ \",\"^;\",\"^4[\",\"^<\",\"^=\",\"^>\",true],\"^42\",[\"^ \",\"^;\",\"^42\",\"^<\",\"^=\",\"^>\",true],\"~i16\",\"~:logseq.property/icon\",\"~i48\",\"^1L\",\"~i80\",\"^4[\",\"~i112\",\"~:logseq.property.journal/title-format\",\"~i144\",\"^3P\",\"~:logseq.property/built-in?\",[\"^ \",\"^;\",\"^57\",\"^<\",\"^=\",\"^>\",true],\"~i17\",\"^2Z\",\"~i49\",\"~:logseq.property/exclude-from-graph-view\",\"~i81\",\"^4L\",\"^1X\",[\"^ \",\"^;\",\"^1X\",\"^<\",\"^=\",\"^>\",true],\"~i113\",\"^2F\",\"~i145\",\"^3;\",\"^35\",[\"^ \",\"^;\",\"^35\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.view/feature-type\",[\"^ \",\"^;\",\"^5>\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Code-block\",[\"^ \",\"^;\",\"^5?\"],\"^4W\",[\"^ \",\"^;\",\"^4W\",\"^<\",\"^=\",\"^>\",true],\"^17\",[\"^ \",\"^;\",\"^17\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2705\",\"^4O\",\"~:logseq.property.asset/resize-metadata\",[\"^ \",\"^;\",\"^5A\",\"^<\",\"^=\",\"^>\",true],\"^5\",[\"^ \",\"^;\",\"^5\",\"^<\",\"^=\",\"^>\",true],\"^2C\",[\"^ \",\"^;\",\"^2C\"],\"^38\",[\"^ \",\"^;\",\"^38\",\"^<\",\"^=\",\"^>\",true],\"^9\",[\"^ \",\"^;\",\"^9\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^Z\",[\"^ \",\"^;\",\"^Z\",\"^<\",\"^=\",\"^>\",true],\"~i18\",\"~:logseq.property.history/block\",\"^2X\",[\"^ \",\"^;\",\"^2X\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i50\",\"^15\",\"~i82\",\"~:logseq.property.repeat/recur-unit.month\",\"~i114\",\"~:logseq.property/status.todo\",\"~i146\",\"^20\",\"~:logseq.property/hide?\",[\"^ \",\"^;\",\"^5J\",\"^<\",\"^=\",\"^>\",true],\"^26\",[\"^ \",\"^;\",\"^26\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-frequency\",[\"^ \",\"^;\",\"^5K\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/default-value\",[\"^ \",\"^;\",\"^5L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1C\",[\"^ \",\"^>\",true,\"^;\",\"^1C\",\"^<\",\"^=\"],\"~i2706\",\"^4J\",\"~:user.property/p1-Ka7c_Z9D\",[\"^ \",\"^;\",\"^5N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/color.red\",[\"^ \",\"^;\",\"^5O\"],\"~i19\",\"^Q\",\"~:logseq.class/Root\",[\"^ \",\"^;\",\"^5Q\"],\"~i51\",\"^1@\",\"~i83\",\"~:logseq.property.history/ref-value\",\"~i115\",\"~:logseq.property.history/property\",\"~i147\",\"~:logseq.property/template-applied-to\",\"^2E\",[\"^ \",\"^;\",\"^2E\"],\"~:logseq.property/empty-placeholder\",[\"^ \",\"^;\",\"^5Y\"],\"^40\",[\"^ \",\"^;\",\"^40\",\"^<\",\"^=\",\"^>\",true],\"~i20\",\"^2<\",\"~i52\",\"^45\",\"~i84\",\"~:logseq.property.table/filters\",\"~i116\",\"~:logseq.property.recycle/original-parent\",\"^4<\",[\"^ \",\"^;\",\"^4<\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-uuid\",[\"^ \",\"^;\",\"^64\"],\"~i148\",\"~:logseq.property/status.in-review\",\"~i2196\",[\"^ \"],\"^1=\",[\"^ \",\"^;\",\"^1=\"],\"~:logseq.property/priority.low\",[\"^ \",\"^;\",\"^68\"],\"~:block/tags\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^69\",\"^>\",true],\"~:block/title\",[\"^ \",\"^>\",true,\"^;\",\"^6:\",\"^<\",\"^=\"],\"~:user.property/p4-Et0d5LxL\",[\"^ \",\"^;\",\"^6;\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^3B\",[\"^ \",\"^;\",\"^3B\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Whiteboard\",[\"^ \",\"^;\",\"^6<\"],\"~i21\",\"^6:\",\"~i53\",\"~:logseq.property/created-from-property\",\"~i85\",\"~:logseq.class/Journal\",\"~i117\",\"~:logseq.property.pdf/hl-value\",\"~i149\",\"^1[\",\"^6?\",[\"^ \",\"^;\",\"^6?\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i565\",\"^5N\",\"^51\",[\"^ \",\"^;\",\"^51\",\"^<\",\"^=\",\"^>\",true],\"^1I\",[\"^ \",\"^;\",\"^1I\",\"^<\",\"^=\",\"^>\",true],\"^H\",[\"^ \",\"^;\",\"^H\"],\"^4:\",[\"^ \",\"^;\",\"^4:\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^1?\",[\"^ \",\"^;\",\"^1?\",\"^<\",\"^=\",\"^>\",true],\"~i22\",\"~:logseq.property/classes\",\"~i54\",\"^4=\",\"~i86\",\"^57\",\"~i118\",\"^43\",\"~i150\",\"~:logseq.property.view/type.gallery\",\"~i182\",\"^64\",\"^6G\",[\"^ \",\"^;\",\"^6G\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/asset\",[\"^ \",\"^;\",\"^6N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^4Q\",[\"^ \",\"^;\",\"^4Q\"],\"~i23\",\"^39\",\"~i55\",\"~:logseq.property/status.done\",\"~:logseq.property/deleted-at\",[\"^ \",\"^;\",\"^6R\",\"^<\",\"^=\",\"^>\",true],\"~i87\",\"^6<\",\"~i119\",\"^5J\",\"~i151\",\"^5Y\",\"~i183\",\"^3:\",\"~:logseq.property/order-list-type\",[\"^ \",\"^;\",\"^6W\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5:\",[\"^ \",\"^;\",\"^5:\",\"^<\",\"^=\",\"^>\",true],\"~i24\",\"^5A\",\"~:logseq.property/deleted-by-ref\",[\"^ \",\"^;\",\"^6Y\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i56\",\"^T\",\"~:logseq.property.pdf/hl-type\",[\"^ \",\"^;\",\"^6[\",\"^<\",\"^=\",\"^>\",true],\"~i88\",\"^:\",\"~i120\",\"^2I\",\"~i152\",\"^4Z\",\"^;\",[\"^ \",\"^2U\",\"^2V\"],\"^6L\",[\"^ \",\"^;\",\"^6L\"],\"^2A\",[\"^ \",\"^;\",\"^2A\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i568\",\"^3V\",\"^5C\",[\"^ \",\"^;\",\"^5C\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i600\",\"^6;\",\"^3M\",[\"^ \",\"^;\",\"^3M\",\"^<\",\"^=\",\"^>\",true],\"^3X\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^3X\",\"^<\",\"^=\"],\"~i25\",\"^6R\",\"~i57\",\"^2:\",\"~i89\",\"^5Q\",\"^2Q\",[\"^ \",\"^;\",\"^2Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i121\",\"^4K\",\"~i153\",\"~:logseq.property/priority.medium\",\"~i569\",\"^1N\",\"^6A\",[\"^ \",\"^;\",\"^6A\"],\"~i26\",\"^3I\",\"~i58\",\"^69\",\"~i90\",\"^2L\",\"^3O\",[\"^ \",\"^;\",\"^3O\"],\"^J\",[\"^ \",\"^;\",\"^J\"],\"~i122\",\"~:block/order\",\"~i154\",\"^1K\",\"^63\",[\"^ \",\"^;\",\"^63\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^7:\",[\"^ \",\"^;\",\"^7:\"],\"^5V\",[\"^ \",\"^;\",\"^5V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^6C\",[\"^ \",\"^;\",\"^6C\",\"^<\",\"^=\",\"^>\",true],\"^4I\",[\"^ \",\"^;\",\"^4I\"],\"~i27\",\"^3U\",\"~i59\",\"^6W\",\"~i91\",\"^M\",\"~i123\",\"^4N\",\"~i155\",\"^4Y\",\"^1;\",[\"^ \",\"^;\",\"^1;\",\"^<\",\"^=\",\"^>\",true],\"^3R\",[\"^ \",\"^;\",\"^3R\"],\"^5F\",[\"^ \",\"^;\",\"^5F\"],\"^7@\",[\"^ \",\"^>\",true,\"^;\",\"^7@\",\"^<\",\"^=\"],\"^3\",[\"^ \",\"^;\",\"^3\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5T\",[\"^ \",\"^;\",\"^5T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.fsrs/due\",[\"^ \",\"^;\",\"^7G\",\"^<\",\"^=\",\"^>\",true],\"~i28\",\"^5?\",\"^32\",[\"^ \",\"^;\",\"^32\"],\"~i60\",\"^@\",\"~i92\",\"^44\",\"~i124\",\"^30\",\"^4S\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^4S\",\"^<\",\"^=\"],\"~i156\",\"^5L\",\"~:block/name\",[\"^ \",\"^>\",true],\"^1A\",[\"^ \",\"^;\",\"^1A\"],\"^1U\",[\"^ \",\"^;\",\"^1U\",\"^<\",\"^=\",\"^>\",true],\"~:file/path\",[\"^ \",\"^2U\",\"^2V\"],\"~i29\",\"^7G\",\"~i61\",\"^16\",\"~i93\",\"^5>\",\"~i125\",\"^4X\",\"~i157\",\"^6Y\",\"^7\",[\"^ \",\"^;\",\"^7\",\"^<\",\"^=\",\"^>\",true],\"^4C\",[\"^ \",\"^;\",\"^4C\"],\"^66\",[\"^ \",\"^;\",\"^66\"],\"^X\",[\"^ \",\"^;\",\"^X\",\"^<\",\"^=\",\"^>\",true],\"~i30\",\"^6N\",\"~i62\",\"^6[\",\"~i94\",\"^68\",\"~i126\",\"^4A\",\"~i158\",\"^3K\",\"^3F\",[\"^ \",\"^;\",\"^3F\"],\"^5X\",[\"^ \",\"^;\",\"^5X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^22\",[\"^ \",\"^;\",\"^22\"],\"^55\",[\"^ \",\"^;\",\"^55\",\"^<\",\"^=\",\"^>\",true],\"^6Q\",[\"^ \",\"^;\",\"^6Q\"],\"~i31\",\"^5O\",\"~i63\",\"^2[\",\"^V\",[\"^ \",\"^;\",\"^V\"],\"~i95\",\"^5K\",\"~i127\",\"^2J\",\"~i159\",\"^12\",\"^61\",[\"^ \",\"^;\",\"^61\",\"^<\",\"^=\",\"^>\",true],\"^3T\",[\"^ \",\"^;\",\"^3T\"],\"^5H\",[\"^ \",\"^;\",\"^5H\"]],\"~:datoms\",[\"~#list\",[[\"~#datascript/Datom\",[1,\"^48\",1775980635495,536870913]],[\"^84\",[1,\"^7M\",\"reaction target\",536870913]],[\"^84\",[1,\"^7@\",\"d3BHM\",536870913]],[\"^84\",[1,\"^69\",124,536870913]],[\"^84\",[1,\"^6:\",\"Reaction target\",536870913]],[\"^84\",[1,\"^3;\",1775980635495,536870913]],[\"^84\",[1,\"^2T\",\"~u00000002-1127-5718-5000-000000000000\",536870913]],[\"^84\",[1,\"^<\",\"^=\",536870913]],[\"^84\",[1,\"^;\",\"^F\",536870913]],[\"^84\",[1,\"^>\",true,536870913]],[\"^84\",[1,\"^C\",\"^D\",536870913]],[\"^84\",[1,\"^57\",true,536870913]],[\"^84\",[1,\"^5J\",true,536870913]],[\"^84\",[1,\"^X\",false,536870913]],[\"^84\",[1,\"^1K\",\"~:node\",536870913]],[\"^84\",[2,\"^;\",\"^V\",536870913]],[\"^84\",[2,\"^28\",\"db\",536870913]],[\"^84\",[3,\"^48\",1775980635492,536870913]],[\"^84\",[3,\"^7M\",\"repeating checked property\",536870913]],[\"^84\",[3,\"^7@\",\"d3BGY\",536870913]],[\"^84\",[3,\"^69\",124,536870913]],[\"^84\",[3,\"^6:\",\"Repeating Checked Property\",536870913]],[\"^84\",[3,\"^3;\",1775980635492,536870913]],[\"^84\",[3,\"^2T\",\"~u00000002-1866-3655-5300-000000000000\",536870913]],[\"^84\",[3,\"^<\",\"^=\",536870913]],[\"^84\",[3,\"^;\",\"^17\",536870913]],[\"^84\",[3,\"^>\",true,536870913]],[\"^84\",[3,\"^C\",\"^D\",536870913]],[\"^84\",[3,\"^57\",true,536870913]],[\"^84\",[3,\"^5J\",true,536870913]],[\"^84\",[3,\"^1K\",\"~:property\",536870913]],[\"^84\",[4,\"^48\",1775980635496,536870913]],[\"^84\",[4,\"^7M\",\"pdf annotation\",536870913]],[\"^84\",[4,\"^69\",14,536870913]],[\"^84\",[4,\"^6:\",\"PDF Annotation\",536870913]],[\"^84\",[4,\"^3;\",1775980635496,536870913]],[\"^84\",[4,\"^2T\",\"~u00000002-5049-5962-0000-000000000000\",536870913]],[\"^84\",[4,\"^;\",\"^1A\",536870913]],[\"^84\",[4,\"^57\",true,536870913]],[\"^84\",[4,\"^3K\",89,536870913]],[\"^84\",[4,\"^1X\",true,536870913]],[\"^84\",[4,\"^4K\",19,536870913]],[\"^84\",[4,\"^4K\",30,536870913]],[\"^84\",[4,\"^4K\",40,536870913]],[\"^84\",[4,\"^4K\",62,536870913]],[\"^84\",[4,\"^4K\",64,536870913]],[\"^84\",[4,\"^4K\",91,536870913]],[\"^84\",[4,\"^4K\",117,536870913]],[\"^84\",[5,\"^48\",1775980635493,536870913]],[\"^84\",[5,\"^7M\",\"file checksum\",536870913]],[\"^84\",[5,\"^7@\",\"d3BGy\",536870913]],[\"^84\",[5,\"^69\",124,536870913]],[\"^84\",[5,\"^6:\",\"File checksum\",536870913]],[\"^84\",[5,\"^3;\",1775980635493,536870913]],[\"^84\",[5,\"^2T\",\"~u00000002-1011-4169-7900-000000000000\",536870913]],[\"^84\",[5,\"^<\",\"^=\",536870913]],[\"^84\",[5,\"^;\",\"^1O\",536870913]],[\"^84\",[5,\"^>\",true,536870913]],[\"^84\",[5,\"^57\",true,536870913]],[\"^84\",[5,\"^5J\",true,536870913]],[\"^84\",[5,\"^X\",false,536870913]],[\"^84\",[5,\"^1K\",\"~:string\",536870913]],[\"^84\",[6,\"^48\",1775980635493,536870913]],[\"^84\",[6,\"^7M\",\"view columns settings\",536870913]],[\"^84\",[6,\"^7@\",\"d3BGp\",536870913]],[\"^84\",[6,\"^69\",124,536870913]],[\"^84\",[6,\"^6:\",\"View columns settings\",536870913]],[\"^84\",[6,\"^3;\",1775980635493,536870913]],[\"^84\",[6,\"^2T\",\"~u00000002-1675-5105-5500-000000000000\",536870913]],[\"^84\",[6,\"^<\",\"^=\",536870913]],[\"^84\",[6,\"^;\",\"^1Y\",536870913]],[\"^84\",[6,\"^>\",true,536870913]],[\"^84\",[6,\"^57\",true,536870913]],[\"^84\",[6,\"^5J\",true,536870913]],[\"^84\",[6,\"^X\",false,536870913]],[\"^84\",[6,\"^1K\",\"~:map\",536870913]],[\"^84\",[7,\"^48\",1775980635487,536870913]],[\"^84\",[7,\"^7M\",\"alias\",536870913]],[\"^84\",[7,\"^7@\",\"d3BFF\",536870913]],[\"^84\",[7,\"^69\",124,536870913]],[\"^84\",[7,\"^6:\",\"Alias\",536870913]],[\"^84\",[7,\"^3;\",1775980635487,536870913]],[\"^84\",[7,\"^2T\",\"~u00000002-2112-6446-9900-000000000000\",536870913]],[\"^84\",[7,\"^<\",\"^14\",536870913]],[\"^84\",[7,\"^;\",\"^1Z\",536870913]],[\"^84\",[7,\"^>\",true,536870913]],[\"^84\",[7,\"^C\",\"^D\",536870913]],[\"^84\",[7,\"^57\",true,536870913]],[\"^84\",[7,\"^X\",true,536870913]],[\"^84\",[7,\"^1K\",\"~:page\",536870913]],[\"^84\",[7,\"^44\",\"^89\",536870913]],[\"^84\",[8,\"^48\",1775980635492,536870913]],[\"^84\",[8,\"^7M\",\"view sort groups by\",536870913]],[\"^84\",[8,\"^7@\",\"d3BGj\",536870913]],[\"^84\",[8,\"^69\",124,536870913]],[\"^84\",[8,\"^6:\",\"View sort groups by\",536870913]],[\"^84\",[8,\"^3;\",1775980635492,536870913]],[\"^84\",[8,\"^2T\",\"~u00000002-6253-4002-1000-000000000000\",536870913]],[\"^84\",[8,\"^<\",\"^=\",536870913]],[\"^84\",[8,\"^;\",\"^2M\",536870913]],[\"^84\",[8,\"^>\",true,536870913]],[\"^84\",[8,\"^C\",\"^D\",536870913]],[\"^84\",[8,\"^57\",true,536870913]],[\"^84\",[8,\"^5J\",true,536870913]],[\"^84\",[8,\"^X\",false,536870913]],[\"^84\",[8,\"^1K\",\"^86\",536870913]],[\"^84\",[9,\"^L\",26,536870913]],[\"^84\",[9,\"^48\",1775980635491,536870913]],[\"^84\",[9,\"^7@\",\"d3BGA\",536870913]],[\"^84\",[9,\"^4S\",26,536870913]],[\"^84\",[9,\"^3X\",26,536870913]],[\"^84\",[9,\"^6:\",\"Doing\",536870913]],[\"^84\",[9,\"^3;\",1775980635491,536870913]],[\"^84\",[9,\"^2T\",\"~u00000002-1840-1229-0800-000000000000\",536870913]],[\"^84\",[9,\"^;\",\"^32\",536870913]],[\"^84\",[9,\"^57\",true,536870913]],[\"^84\",[9,\"^6?\",26,536870913]],[\"^84\",[9,\"^51\",[\"^ \",\"~:type\",\"~:tabler-icon\",\"~:id\",\"InProgress50\"],536870913]],[\"^84\",[10,\"^48\",1775980635496,536870913]],[\"^84\",[10,\"^7M\",\"math\",536870913]],[\"^84\",[10,\"^69\",14,536870913]],[\"^84\",[10,\"^6:\",\"Math\",536870913]],[\"^84\",[10,\"^3;\",1775980635496,536870913]],[\"^84\",[10,\"^2T\",\"~u00000002-2038-9631-2100-000000000000\",536870913]],[\"^84\",[10,\"^;\",\"^3=\",536870913]],[\"^84\",[10,\"^57\",true,536870913]],[\"^84\",[10,\"^3K\",89,536870913]],[\"^84\",[10,\"^1X\",true,536870913]],[\"^84\",[10,\"^4K\",37,536870913]],[\"^84\",[11,\"^L\",26,536870913]],[\"^84\",[11,\"^48\",1775980635491,536870913]],[\"^84\",[11,\"^7@\",\"d3BGD\",536870913]],[\"^84\",[11,\"^4S\",26,536870913]],[\"^84\",[11,\"^3X\",26,536870913]],[\"^84\",[11,\"^6:\",\"Canceled\",536870913]],[\"^84\",[11,\"^3;\",1775980635491,536870913]],[\"^84\",[11,\"^2T\",\"~u00000002-7526-4326-2000-000000000000\",536870913]],[\"^84\",[11,\"^;\",\"^3F\",536870913]],[\"^84\",[11,\"^57\",true,536870913]],[\"^84\",[11,\"^6?\",26,536870913]],[\"^84\",[11,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Cancelled\"],536870913]],[\"^84\",[12,\"^48\",1775980635488,536870913]],[\"^84\",[12,\"^7M\",\"node parent\",536870913]],[\"^84\",[12,\"^7@\",\"d3BFH\",536870913]],[\"^84\",[12,\"^69\",124,536870913]],[\"^84\",[12,\"^6:\",\"Node parent\",536870913]],[\"^84\",[12,\"^3;\",1775980635488,536870913]],[\"^84\",[12,\"^2T\",\"~u00000002-9183-0906-4000-000000000000\",536870913]],[\"^84\",[12,\"^<\",\"^=\",536870913]],[\"^84\",[12,\"^;\",\"^3X\",536870913]],[\"^84\",[12,\"^>\",true,536870913]],[\"^84\",[12,\"^C\",\"^D\",536870913]],[\"^84\",[12,\"^57\",true,536870913]],[\"^84\",[12,\"^5J\",true,536870913]],[\"^84\",[12,\"^X\",false,536870913]],[\"^84\",[12,\"^1K\",\"~:entity\",536870913]],[\"^84\",[13,\"^48\",1775980635491,536870913]],[\"^84\",[13,\"^7M\",\"repeating recur unit\",536870913]],[\"^84\",[13,\"^7@\",\"d3BGP\",536870913]],[\"^84\",[13,\"^69\",124,536870913]],[\"^84\",[13,\"^6:\",\"Repeating recur unit\",536870913]],[\"^84\",[13,\"^3;\",1775980635491,536870913]],[\"^84\",[13,\"^2T\",\"~u00000002-6903-0624-7000-000000000000\",536870913]],[\"^84\",[13,\"^<\",\"^=\",536870913]],[\"^84\",[13,\"^;\",\"^2G\",536870913]],[\"^84\",[13,\"^>\",true,536870913]],[\"^84\",[13,\"^C\",\"^D\",536870913]],[\"^84\",[13,\"^57\",true,536870913]],[\"^84\",[13,\"^5L\",61,536870913]],[\"^84\",[13,\"^3P\",true,536870913]],[\"^84\",[13,\"^X\",false,536870913]],[\"^84\",[13,\"^1K\",\"~:default\",536870913]],[\"^84\",[14,\"^48\",1775980635496,536870913]],[\"^84\",[14,\"^7M\",\"tag\",536870913]],[\"^84\",[14,\"^69\",14,536870913]],[\"^84\",[14,\"^6:\",\"Tag\",536870913]],[\"^84\",[14,\"^3;\",1775980635496,536870913]],[\"^84\",[14,\"^2T\",\"~u00000002-5389-0208-3000-000000000000\",536870913]],[\"^84\",[14,\"^;\",\"^4C\",536870913]],[\"^84\",[14,\"^57\",true,536870913]],[\"^84\",[14,\"^3K\",89,536870913]],[\"^84\",[15,\"^;\",\"^4Q\",536870913]],[\"^84\",[15,\"^28\",1775980635485,536870913]],[\"^84\",[16,\"^48\",1775980635492,536870913]],[\"^84\",[16,\"^7M\",\"icon\",536870913]],[\"^84\",[16,\"^7@\",\"d3BGZ\",536870913]],[\"^84\",[16,\"^69\",124,536870913]],[\"^84\",[16,\"^6:\",\"Icon\",536870913]],[\"^84\",[16,\"^3;\",1775980635492,536870913]],[\"^84\",[16,\"^2T\",\"~u00000002-5891-2328-5000-000000000000\",536870913]],[\"^84\",[16,\"^<\",\"^=\",536870913]],[\"^84\",[16,\"^;\",\"^51\",536870913]],[\"^84\",[16,\"^>\",true,536870913]],[\"^84\",[16,\"^57\",true,536870913]],[\"^84\",[16,\"^1K\",\"^88\",536870913]],[\"^84\",[17,\"^48\",1775980635493,536870913]],[\"^84\",[17,\"^7M\",\"external file name\",536870913]],[\"^84\",[17,\"^7@\",\"d3BGu\",536870913]],[\"^84\",[17,\"^69\",124,536870913]],[\"^84\",[17,\"^6:\",\"External file name\",536870913]],[\"^84\",[17,\"^3;\",1775980635493,536870913]],[\"^84\",[17,\"^2T\",\"~u00000002-3995-2972-0000-000000000000\",536870913]],[\"^84\",[17,\"^<\",\"^=\",536870913]],[\"^84\",[17,\"^;\",\"^2Z\",536870913]],[\"^84\",[17,\"^>\",true,536870913]],[\"^84\",[17,\"^57\",true,536870913]],[\"^84\",[17,\"^5J\",true,536870913]],[\"^84\",[17,\"^X\",false,536870913]],[\"^84\",[17,\"^1K\",\"^87\",536870913]],[\"^84\",[18,\"^48\",1775980635494,536870913]],[\"^84\",[18,\"^7M\",\"history block\",536870913]],[\"^84\",[18,\"^7@\",\"d3BHB\",536870913]],[\"^84\",[18,\"^69\",124,536870913]],[\"^84\",[18,\"^6:\",\"History block\",536870913]],[\"^84\",[18,\"^3;\",1775980635494,536870913]],[\"^84\",[18,\"^2T\",\"~u00000002-1142-5541-6000-000000000000\",536870913]],[\"^84\",[18,\"^<\",\"^=\",536870913]],[\"^84\",[18,\"^;\",\"^5C\",536870913]],[\"^84\",[18,\"^>\",true,536870913]],[\"^84\",[18,\"^C\",\"^D\",536870913]],[\"^84\",[18,\"^57\",true,536870913]],[\"^84\",[18,\"^5J\",true,536870913]],[\"^84\",[18,\"^1K\",\"^8=\",536870913]],[\"^84\",[19,\"^48\",1775980635490,536870913]],[\"^84\",[19,\"^7M\",\"annotation color\",536870913]],[\"^84\",[19,\"^7@\",\"d3BFo\",536870913]],[\"^84\",[19,\"^69\",124,536870913]],[\"^84\",[19,\"^6:\",\"Annotation color\",536870913]],[\"^84\",[19,\"^3;\",1775980635490,536870913]],[\"^84\",[19,\"^2T\",\"~u00000002-6747-9388-7000-000000000000\",536870913]],[\"^84\",[19,\"^<\",\"^=\",536870913]],[\"^84\",[19,\"^;\",\"^Q\",536870913]],[\"^84\",[19,\"^>\",true,536870913]],[\"^84\",[19,\"^C\",\"^D\",536870913]],[\"^84\",[19,\"^57\",true,536870913]],[\"^84\",[19,\"^5J\",true,536870913]],[\"^84\",[19,\"^1K\",\"^8>\",536870913]],[\"^84\",[20,\"^48\",1775980635489,536870913]],[\"^84\",[20,\"^7M\",\"enable bidirectional properties\",536870913]],[\"^84\",[20,\"^7@\",\"d3BFb\",536870913]],[\"^84\",[20,\"^69\",124,536870913]],[\"^84\",[20,\"^6:\",\"Enable bidirectional properties\",536870913]],[\"^84\",[20,\"^3;\",1775980635489,536870913]],[\"^84\",[20,\"^2T\",\"~u00000002-2917-8613-8000-000000000000\",536870913]],[\"^84\",[20,\"^<\",\"^=\",536870913]],[\"^84\",[20,\"^;\",\"^2<\",536870913]],[\"^84\",[20,\"^>\",true,536870913]],[\"^84\",[20,\"^57\",true,536870913]],[\"^84\",[20,\"^2A\",169,536870913]],[\"^84\",[20,\"^X\",true,536870913]],[\"^84\",[20,\"^1K\",\"~:checkbox\",536870913]],[\"^84\",[20,\"^44\",\"~:class\",536870913]],[\"^84\",[21,\"^48\",1775980635488,536870913]],[\"^84\",[21,\"^7M\",\"node title\",536870913]],[\"^84\",[21,\"^7@\",\"d3BFN\",536870913]],[\"^84\",[21,\"^69\",124,536870913]],[\"^84\",[21,\"^6:\",\"Node title\",536870913]],[\"^84\",[21,\"^3;\",1775980635488,536870913]],[\"^84\",[21,\"^2T\",\"~u00000002-7104-4568-4000-000000000000\",536870913]],[\"^84\",[21,\"^<\",\"^=\",536870913]],[\"^84\",[21,\"^;\",\"^6:\",536870913]],[\"^84\",[21,\"^>\",true,536870913]],[\"^84\",[21,\"^57\",true,536870913]],[\"^84\",[21,\"^5J\",true,536870913]],[\"^84\",[21,\"^X\",false,536870913]],[\"^84\",[21,\"^1K\",\"^87\",536870913]],[\"^84\",[22,\"^48\",1775980635487,536870913]],[\"^84\",[22,\"^7M\",\"property classes\",536870913]],[\"^84\",[22,\"^7@\",\"d3BFD\",536870913]],[\"^84\",[22,\"^69\",124,536870913]],[\"^84\",[22,\"^6:\",\"Property classes\",536870913]],[\"^84\",[22,\"^3;\",1775980635487,536870913]],[\"^84\",[22,\"^2T\",\"~u00000002-9137-5048-6000-000000000000\",536870913]],[\"^84\",[22,\"^<\",\"^14\",536870913]],[\"^84\",[22,\"^;\",\"^6G\",536870913]],[\"^84\",[22,\"^>\",true,536870913]],[\"^84\",[22,\"^C\",\"^D\",536870913]],[\"^84\",[22,\"^57\",true,536870913]],[\"^84\",[22,\"^5J\",true,536870913]],[\"^84\",[22,\"^X\",false,536870913]],[\"^84\",[22,\"^1K\",\"^8=\",536870913]],[\"^84\",[23,\"^;\",\"^39\",536870913]],[\"^84\",[23,\"^28\",true,536870913]],[\"^84\",[24,\"^48\",1775980635494,536870913]],[\"^84\",[24,\"^7M\",\"asset resize metadata\",536870913]],[\"^84\",[24,\"^7@\",\"d3BH2\",536870913]],[\"^84\",[24,\"^69\",124,536870913]],[\"^84\",[24,\"^6:\",\"Asset resize metadata\",536870913]],[\"^84\",[24,\"^3;\",1775980635494,536870913]],[\"^84\",[24,\"^2T\",\"~u00000002-1297-5230-5500-000000000000\",536870913]],[\"^84\",[24,\"^<\",\"^=\",536870913]],[\"^84\",[24,\"^;\",\"^5A\",536870913]],[\"^84\",[24,\"^>\",true,536870913]],[\"^84\",[24,\"^57\",true,536870913]],[\"^84\",[24,\"^5J\",true,536870913]],[\"^84\",[24,\"^X\",false,536870913]],[\"^84\",[24,\"^1K\",\"^88\",536870913]],[\"^84\",[25,\"^48\",1775980635495,536870913]],[\"^84\",[25,\"^7M\",\"deleted at\",536870913]],[\"^84\",[25,\"^7@\",\"d3BHG\",536870913]],[\"^84\",[25,\"^69\",124,536870913]],[\"^84\",[25,\"^6:\",\"Deleted at\",536870913]],[\"^84\",[25,\"^3;\",1775980635495,536870913]],[\"^84\",[25,\"^2T\",\"~u00000002-6852-1375-1000-000000000000\",536870913]],[\"^84\",[25,\"^<\",\"^=\",536870913]],[\"^84\",[25,\"^;\",\"^6R\",536870913]],[\"^84\",[25,\"^>\",true,536870913]],[\"^84\",[25,\"^57\",true,536870913]],[\"^84\",[25,\"^5J\",true,536870913]],[\"^84\",[25,\"^X\",false,536870913]],[\"^84\",[25,\"^1K\",\"~:datetime\",536870913]],[\"^84\",[26,\"^48\",1775980635491,536870913]],[\"^84\",[26,\"^7M\",\"status\",536870913]],[\"^84\",[26,\"^7@\",\"d3BG7\",536870913]],[\"^84\",[26,\"^69\",124,536870913]],[\"^84\",[26,\"^6:\",\"Status\",536870913]],[\"^84\",[26,\"^3;\",1775980635491,536870913]],[\"^84\",[26,\"^2T\",\"~u00000002-9072-1685-3000-000000000000\",536870913]],[\"^84\",[26,\"^<\",\"^=\",536870913]],[\"^84\",[26,\"^;\",\"^3I\",536870913]],[\"^84\",[26,\"^>\",true,536870913]],[\"^84\",[26,\"^C\",\"^D\",536870913]],[\"^84\",[26,\"^57\",true,536870913]],[\"^84\",[26,\"^5L\",114,536870913]],[\"^84\",[26,\"^4N\",true,536870913]],[\"^84\",[26,\"^3P\",true,536870913]],[\"^84\",[26,\"^X\",true,536870913]],[\"^84\",[26,\"^1K\",\"^8>\",536870913]],[\"^84\",[26,\"^@\",\"~:block-left\",536870913]],[\"^84\",[27,\"^48\",1775980635496,536870913]],[\"^84\",[27,\"^7M\",\"card\",536870913]],[\"^84\",[27,\"^69\",14,536870913]],[\"^84\",[27,\"^6:\",\"Card\",536870913]],[\"^84\",[27,\"^3;\",1775980635496,536870913]],[\"^84\",[27,\"^2T\",\"~u00000002-1358-2811-0900-000000000000\",536870913]],[\"^84\",[27,\"^;\",\"^3U\",536870913]],[\"^84\",[27,\"^57\",true,536870913]],[\"^84\",[27,\"^3K\",89,536870913]],[\"^84\",[27,\"^4K\",29,536870913]],[\"^84\",[27,\"^4K\",43,536870913]],[\"^84\",[28,\"^48\",1775980635496,536870913]],[\"^84\",[28,\"^7M\",\"code\",536870913]],[\"^84\",[28,\"^69\",14,536870913]],[\"^84\",[28,\"^6:\",\"Code\",536870913]],[\"^84\",[28,\"^3;\",1775980635496,536870913]],[\"^84\",[28,\"^2T\",\"~u00000002-1454-9866-4100-000000000000\",536870913]],[\"^84\",[28,\"^;\",\"^5?\",536870913]],[\"^84\",[28,\"^57\",true,536870913]],[\"^84\",[28,\"^3K\",89,536870913]],[\"^84\",[28,\"^1X\",true,536870913]],[\"^84\",[28,\"^4K\",37,536870913]],[\"^84\",[28,\"^4K\",108,536870913]],[\"^84\",[29,\"^48\",1775980635494,536870913]],[\"^84\",[29,\"^7M\",\"due\",536870913]],[\"^84\",[29,\"^7@\",\"d3BH4\",536870913]],[\"^84\",[29,\"^69\",124,536870913]],[\"^84\",[29,\"^6:\",\"Due\",536870913]],[\"^84\",[29,\"^3;\",1775980635494,536870913]],[\"^84\",[29,\"^2T\",\"~u00000002-1089-0805-4900-000000000000\",536870913]],[\"^84\",[29,\"^<\",\"^=\",536870913]],[\"^84\",[29,\"^;\",\"^7G\",536870913]],[\"^84\",[29,\"^>\",true,536870913]],[\"^84\",[29,\"^57\",true,536870913]],[\"^84\",[29,\"^5J\",false,536870913]],[\"^84\",[29,\"^X\",false,536870913]],[\"^84\",[29,\"^1K\",\"^8A\",536870913]],[\"^84\",[30,\"^48\",1775980635490,536870913]],[\"^84\",[30,\"^7M\",\"asset\",536870913]],[\"^84\",[30,\"^7@\",\"d3BFl\",536870913]],[\"^84\",[30,\"^69\",124,536870913]],[\"^84\",[30,\"^6:\",\"Asset\",536870913]],[\"^84\",[30,\"^3;\",1775980635490,536870913]],[\"^84\",[30,\"^2T\",\"~u00000002-8768-5679-0000-000000000000\",536870913]],[\"^84\",[30,\"^<\",\"^=\",536870913]],[\"^84\",[30,\"^;\",\"^6N\",536870913]],[\"^84\",[30,\"^>\",true,536870913]],[\"^84\",[30,\"^C\",\"^D\",536870913]],[\"^84\",[30,\"^57\",true,536870913]],[\"^84\",[30,\"^5J\",true,536870913]],[\"^84\",[30,\"^1K\",\"^8=\",536870913]],[\"^84\",[31,\"^L\",19,536870913]],[\"^84\",[31,\"^48\",1775980635490,536870913]],[\"^84\",[31,\"^7@\",\"d3BFq\",536870913]],[\"^84\",[31,\"^4S\",19,536870913]],[\"^84\",[31,\"^3X\",19,536870913]],[\"^84\",[31,\"^6:\",\"red\",536870913]],[\"^84\",[31,\"^3;\",1775980635490,536870913]],[\"^84\",[31,\"^2T\",\"~u00000002-4136-2216-2000-000000000000\",536870913]],[\"^84\",[31,\"^;\",\"^5O\",536870913]],[\"^84\",[31,\"^57\",true,536870913]],[\"^84\",[31,\"^6?\",19,536870913]],[\"^84\",[32,\"^48\",1775980635491,536870913]],[\"^84\",[32,\"^7M\",\"priority\",536870913]],[\"^84\",[32,\"^7@\",\"d3BGE\",536870913]],[\"^84\",[32,\"^69\",124,536870913]],[\"^84\",[32,\"^6:\",\"Priority\",536870913]],[\"^84\",[32,\"^3;\",1775980635491,536870913]],[\"^84\",[32,\"^2T\",\"~u00000002-2392-2841-1000-000000000000\",536870913]],[\"^84\",[32,\"^<\",\"^=\",536870913]],[\"^84\",[32,\"^;\",\"^3\",536870913]],[\"^84\",[32,\"^>\",true,536870913]],[\"^84\",[32,\"^C\",\"^D\",536870913]],[\"^84\",[32,\"^57\",true,536870913]],[\"^84\",[32,\"^4N\",true,536870913]],[\"^84\",[32,\"^3P\",true,536870913]],[\"^84\",[32,\"^X\",true,536870913]],[\"^84\",[32,\"^1K\",\"^8>\",536870913]],[\"^84\",[32,\"^@\",\"^8B\",536870913]],[\"^84\",[33,\"^L\",32,536870913]],[\"^84\",[33,\"^48\",1775980635491,536870913]],[\"^84\",[33,\"^7@\",\"d3BGI\",536870913]],[\"^84\",[33,\"^4S\",32,536870913]],[\"^84\",[33,\"^3X\",32,536870913]],[\"^84\",[33,\"^6:\",\"Urgent\",536870913]],[\"^84\",[33,\"^3;\",1775980635491,536870913]],[\"^84\",[33,\"^2T\",\"~u00000002-1996-4346-6700-000000000000\",536870913]],[\"^84\",[33,\"^;\",\"^H\",536870913]],[\"^84\",[33,\"^57\",true,536870913]],[\"^84\",[33,\"^6?\",32,536870913]],[\"^84\",[33,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlUrgent\"],536870913]],[\"^84\",[34,\"^48\",1775980635487,536870913]],[\"^84\",[34,\"^7M\",\"property public?\",536870913]],[\"^84\",[34,\"^7@\",\"d3BFA\",536870913]],[\"^84\",[34,\"^69\",124,536870913]],[\"^84\",[34,\"^6:\",\"Property public?\",536870913]],[\"^84\",[34,\"^3;\",1775980635487,536870913]],[\"^84\",[34,\"^2T\",\"~u00000002-1843-0851-4900-000000000000\",536870913]],[\"^84\",[34,\"^<\",\"^=\",536870913]],[\"^84\",[34,\"^;\",\"^X\",536870913]],[\"^84\",[34,\"^>\",true,536870913]],[\"^84\",[34,\"^57\",true,536870913]],[\"^84\",[34,\"^5J\",true,536870913]],[\"^84\",[34,\"^1K\",\"^8?\",536870913]],[\"^84\",[35,\"^48\",1775980635495,536870913]],[\"^84\",[35,\"^7M\",\"recycle original order\",536870913]],[\"^84\",[35,\"^7@\",\"d3BHK\",536870913]],[\"^84\",[35,\"^69\",124,536870913]],[\"^84\",[35,\"^6:\",\"Recycle original order\",536870913]],[\"^84\",[35,\"^3;\",1775980635495,536870913]],[\"^84\",[35,\"^2T\",\"~u00000002-8699-2537-0000-000000000000\",536870913]],[\"^84\",[35,\"^<\",\"^=\",536870913]],[\"^84\",[35,\"^;\",\"^19\",536870913]],[\"^84\",[35,\"^>\",true,536870913]],[\"^84\",[35,\"^57\",true,536870913]],[\"^84\",[35,\"^5J\",true,536870913]],[\"^84\",[35,\"^X\",false,536870913]],[\"^84\",[35,\"^1K\",\"^87\",536870913]],[\"^84\",[36,\"^48\",1775980635488,536870913]],[\"^84\",[36,\"^7M\",\"journal date\",536870913]],[\"^84\",[36,\"^7@\",\"d3BFP\",536870913]],[\"^84\",[36,\"^69\",124,536870913]],[\"^84\",[36,\"^6:\",\"Journal date\",536870913]],[\"^84\",[36,\"^3;\",1775980635488,536870913]],[\"^84\",[36,\"^2T\",\"~u00000002-1457-4836-6000-000000000000\",536870913]],[\"^84\",[36,\"^<\",\"^=\",536870913]],[\"^84\",[36,\"^;\",\"^1C\",536870913]],[\"^84\",[36,\"^>\",true,536870913]],[\"^84\",[36,\"^57\",true,536870913]],[\"^84\",[36,\"^5J\",true,536870913]],[\"^84\",[36,\"^X\",false,536870913]],[\"^84\",[36,\"^1K\",\"~:raw-number\",536870913]],[\"^84\",[37,\"^48\",1775980635488,536870913]],[\"^84\",[37,\"^7M\",\"node display type\",536870913]],[\"^84\",[37,\"^7@\",\"d3BFS\",536870913]],[\"^84\",[37,\"^69\",124,536870913]],[\"^84\",[37,\"^6:\",\"Node Display Type\",536870913]],[\"^84\",[37,\"^3;\",1775980635488,536870913]],[\"^84\",[37,\"^2T\",\"~u00000002-4424-4618-9000-000000000000\",536870913]],[\"^84\",[37,\"^<\",\"^=\",536870913]],[\"^84\",[37,\"^;\",\"^1Q\",536870913]],[\"^84\",[37,\"^>\",true,536870913]],[\"^84\",[37,\"^57\",true,536870913]],[\"^84\",[37,\"^5J\",true,536870913]],[\"^84\",[37,\"^X\",false,536870913]],[\"^84\",[37,\"^1K\",\"~:keyword\",536870913]],[\"^84\",[37,\"^44\",\"~:block\",536870913]],[\"^84\",[38,\"^48\",1775980635496,536870913]],[\"^84\",[38,\"^7M\",\"template\",536870913]],[\"^84\",[38,\"^69\",14,536870913]],[\"^84\",[38,\"^6:\",\"Template\",536870913]],[\"^84\",[38,\"^3;\",1775980635496,536870913]],[\"^84\",[38,\"^2T\",\"~u00000002-1720-8548-4600-000000000000\",536870913]],[\"^84\",[38,\"^;\",\"^22\",536870913]],[\"^84\",[38,\"^57\",true,536870913]],[\"^84\",[38,\"^3K\",89,536870913]],[\"^84\",[38,\"^4K\",147,536870913]],[\"^84\",[39,\"^;\",\"^2?\",536870913]],[\"^84\",[39,\"^28\",\"~u00000000-4735-4d82-94bb-c9bfb8039105\",536870913]],[\"^84\",[40,\"^48\",1775980635490,536870913]],[\"^84\",[40,\"^7M\",\"annotation page\",536870913]],[\"^84\",[40,\"^7@\",\"d3BFu\",536870913]],[\"^84\",[40,\"^69\",124,536870913]],[\"^84\",[40,\"^6:\",\"Annotation page\",536870913]],[\"^84\",[40,\"^3;\",1775980635490,536870913]],[\"^84\",[40,\"^2T\",\"~u00000002-7532-8459-6000-000000000000\",536870913]],[\"^84\",[40,\"^<\",\"^=\",536870913]],[\"^84\",[40,\"^;\",\"^2O\",536870913]],[\"^84\",[40,\"^>\",true,536870913]],[\"^84\",[40,\"^57\",true,536870913]],[\"^84\",[40,\"^5J\",true,536870913]],[\"^84\",[40,\"^1K\",\"^8C\",536870913]],[\"^84\",[41,\"^48\",1775980635491,536870913]],[\"^84\",[41,\"^7M\",\"properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^7@\",\"d3BG6\",536870913]],[\"^84\",[41,\"^69\",124,536870913]],[\"^84\",[41,\"^6:\",\"Properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^3;\",1775980635491,536870913]],[\"^84\",[41,\"^2T\",\"~u00000002-3215-3256-9000-000000000000\",536870913]],[\"^84\",[41,\"^<\",\"^14\",536870913]],[\"^84\",[41,\"^;\",\"^2H\",536870913]],[\"^84\",[41,\"^>\",true,536870913]],[\"^84\",[41,\"^C\",\"^D\",536870913]],[\"^84\",[41,\"^57\",true,536870913]],[\"^84\",[41,\"^5J\",true,536870913]],[\"^84\",[41,\"^1K\",\"^86\",536870913]],[\"^84\",[42,\"^L\",19,536870913]],[\"^84\",[42,\"^48\",1775980635490,536870913]],[\"^84\",[42,\"^7@\",\"d3BFt\",536870913]],[\"^84\",[42,\"^4S\",19,536870913]],[\"^84\",[42,\"^3X\",19,536870913]],[\"^84\",[42,\"^6:\",\"purple\",536870913]],[\"^84\",[42,\"^3;\",1775980635490,536870913]],[\"^84\",[42,\"^2T\",\"~u00000002-1104-3848-5600-000000000000\",536870913]],[\"^84\",[42,\"^;\",\"^2Y\",536870913]],[\"^84\",[42,\"^57\",true,536870913]],[\"^84\",[42,\"^6?\",19,536870913]],[\"^84\",[43,\"^48\",1775980635494,536870913]],[\"^84\",[43,\"^7M\",\"state\",536870913]],[\"^84\",[43,\"^7@\",\"d3BH5\",536870913]],[\"^84\",[43,\"^69\",124,536870913]],[\"^84\",[43,\"^6:\",\"State\",536870913]],[\"^84\",[43,\"^3;\",1775980635494,536870913]],[\"^84\",[43,\"^2T\",\"~u00000002-1165-1650-8700-000000000000\",536870913]],[\"^84\",[43,\"^<\",\"^=\",536870913]],[\"^84\",[43,\"^;\",\"^P\",536870913]],[\"^84\",[43,\"^>\",true,536870913]],[\"^84\",[43,\"^57\",true,536870913]],[\"^84\",[43,\"^5J\",false,536870913]],[\"^84\",[43,\"^X\",false,536870913]],[\"^84\",[43,\"^1K\",\"^88\",536870913]],[\"^84\",[44,\"^48\",1775980635492,536870913]],[\"^84\",[44,\"^7M\",\"view sort groups desc\",536870913]],[\"^84\",[44,\"^7@\",\"d3BGk\",536870913]],[\"^84\",[44,\"^69\",124,536870913]],[\"^84\",[44,\"^6:\",\"View sort groups DESC\",536870913]],[\"^84\",[44,\"^3;\",1775980635492,536870913]],[\"^84\",[44,\"^2T\",\"~u00000002-1081-4073-8700-000000000000\",536870913]],[\"^84\",[44,\"^<\",\"^=\",536870913]],[\"^84\",[44,\"^;\",\"^1V\",536870913]],[\"^84\",[44,\"^>\",true,536870913]],[\"^84\",[44,\"^57\",true,536870913]],[\"^84\",[44,\"^5J\",true,536870913]],[\"^84\",[44,\"^X\",false,536870913]],[\"^84\",[44,\"^29\",true,536870913]],[\"^84\",[44,\"^1K\",\"^8?\",536870913]],[\"^84\",[45,\"^48\",1775980635488,536870913]],[\"^84\",[45,\"^7M\",\"node created at\",536870913]],[\"^84\",[45,\"^7@\",\"d3BFQ\",536870913]],[\"^84\",[45,\"^69\",124,536870913]],[\"^84\",[45,\"^6:\",\"Node created at\",536870913]],[\"^84\",[45,\"^3;\",1775980635488,536870913]],[\"^84\",[45,\"^2T\",\"~u00000002-1440-0150-0000-000000000000\",536870913]],[\"^84\",[45,\"^<\",\"^=\",536870913]],[\"^84\",[45,\"^;\",\"^48\",536870913]],[\"^84\",[45,\"^>\",true,536870913]],[\"^84\",[45,\"^57\",true,536870913]],[\"^84\",[45,\"^5J\",true,536870913]],[\"^84\",[45,\"^X\",false,536870913]],[\"^84\",[45,\"^1K\",\"^8A\",536870913]],[\"^84\",[46,\"^48\",1775980635496,536870913]],[\"^84\",[46,\"^7M\",\"asset\",536870913]],[\"^84\",[46,\"^69\",14,536870913]],[\"^84\",[46,\"^6:\",\"Asset\",536870913]],[\"^84\",[46,\"^3;\",1775980635496,536870913]],[\"^84\",[46,\"^2T\",\"~u00000002-7975-0297-0000-000000000000\",536870913]],[\"^84\",[46,\"^;\",\"^2;\",536870913]],[\"^84\",[46,\"^57\",true,536870913]],[\"^84\",[46,\"^3K\",89,536870913]],[\"^84\",[46,\"^1X\",true,536870913]],[\"^84\",[46,\"^4K\",5,536870913]],[\"^84\",[46,\"^4K\",57,536870913]],[\"^84\",[46,\"^4K\",131,536870913]],[\"^84\",[46,\"^2J\",150,536870913]],[\"^84\",[47,\"^48\",1775980635488,536870913]],[\"^84\",[47,\"^7M\",\"node page\",536870913]],[\"^84\",[47,\"^7@\",\"d3BFK\",536870913]],[\"^84\",[47,\"^69\",124,536870913]],[\"^84\",[47,\"^6:\",\"Node page\",536870913]],[\"^84\",[47,\"^3;\",1775980635488,536870913]],[\"^84\",[47,\"^2T\",\"~u00000002-8223-1410-8000-000000000000\",536870913]],[\"^84\",[47,\"^<\",\"^=\",536870913]],[\"^84\",[47,\"^;\",\"^4S\",536870913]],[\"^84\",[47,\"^>\",true,536870913]],[\"^84\",[47,\"^C\",\"^D\",536870913]],[\"^84\",[47,\"^57\",true,536870913]],[\"^84\",[47,\"^5J\",true,536870913]],[\"^84\",[47,\"^X\",false,536870913]],[\"^84\",[47,\"^1K\",\"^8=\",536870913]],[\"^84\",[48,\"^L\",13,536870913]],[\"^84\",[48,\"^48\",1775980635492,536870913]],[\"^84\",[48,\"^7@\",\"d3BGV\",536870913]],[\"^84\",[48,\"^4S\",13,536870913]],[\"^84\",[48,\"^3X\",13,536870913]],[\"^84\",[48,\"^6:\",\"Year\",536870913]],[\"^84\",[48,\"^3;\",1775980635492,536870913]],[\"^84\",[48,\"^2T\",\"~u00000002-1520-4385-2400-000000000000\",536870913]],[\"^84\",[48,\"^;\",\"^1L\",536870913]],[\"^84\",[48,\"^57\",true,536870913]],[\"^84\",[48,\"^6?\",13,536870913]],[\"^84\",[49,\"^48\",1775980635492,536870913]],[\"^84\",[49,\"^7M\",\"excluded from graph view?\",536870913]],[\"^84\",[49,\"^7@\",\"d3BGc\",536870913]],[\"^84\",[49,\"^69\",124,536870913]],[\"^84\",[49,\"^6:\",\"Excluded from Graph view?\",536870913]],[\"^84\",[49,\"^3;\",1775980635492,536870913]],[\"^84\",[49,\"^2T\",\"~u00000002-4524-3306-5000-000000000000\",536870913]],[\"^84\",[49,\"^<\",\"^=\",536870913]],[\"^84\",[49,\"^;\",\"^5:\",536870913]],[\"^84\",[49,\"^>\",true,536870913]],[\"^84\",[49,\"^57\",true,536870913]],[\"^84\",[49,\"^5J\",true,536870913]],[\"^84\",[49,\"^X\",true,536870913]],[\"^84\",[49,\"^1K\",\"^8?\",536870913]],[\"^84\",[49,\"^44\",\"^89\",536870913]],[\"^84\",[50,\"^48\",1775980635493,536870913]],[\"^84\",[50,\"^7M\",\"table view pinned columns\",536870913]],[\"^84\",[50,\"^7@\",\"d3BGq\",536870913]],[\"^84\",[50,\"^69\",124,536870913]],[\"^84\",[50,\"^6:\",\"Table view pinned columns\",536870913]],[\"^84\",[50,\"^3;\",1775980635493,536870913]],[\"^84\",[50,\"^2T\",\"~u00000002-2673-7513-8000-000000000000\",536870913]],[\"^84\",[50,\"^<\",\"^14\",536870913]],[\"^84\",[50,\"^;\",\"^15\",536870913]],[\"^84\",[50,\"^>\",true,536870913]],[\"^84\",[50,\"^C\",\"^D\",536870913]],[\"^84\",[50,\"^57\",true,536870913]],[\"^84\",[50,\"^5J\",true,536870913]],[\"^84\",[50,\"^X\",false,536870913]],[\"^84\",[50,\"^1K\",\"^86\",536870913]],[\"^84\",[51,\"^48\",1775980635495,536870913]],[\"^84\",[51,\"^7M\",\"reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^7@\",\"d3BHP\",536870913]],[\"^84\",[51,\"^69\",124,536870913]],[\"^84\",[51,\"^6:\",\"Reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^3;\",1775980635495,536870913]],[\"^84\",[51,\"^2T\",\"~u00000002-1044-8271-6500-000000000000\",536870913]],[\"^84\",[51,\"^<\",\"^=\",536870913]],[\"^84\",[51,\"^;\",\"^1@\",536870913]],[\"^84\",[51,\"^>\",true,536870913]],[\"^84\",[51,\"^57\",true,536870913]],[\"^84\",[51,\"^5J\",true,536870913]],[\"^84\",[51,\"^X\",false,536870913]],[\"^84\",[51,\"^1K\",\"^88\",536870913]],[\"^84\",[52,\"^;\",\"^45\",536870913]],[\"^84\",[52,\"^28\",[\"^ \",\"~:major\",65,\"~:minor\",24],536870913]],[\"^84\",[53,\"^48\",1775980635487,536870913]],[\"^84\",[53,\"^7M\",\"created from property\",536870913]],[\"^84\",[53,\"^7@\",\"d3BF9\",536870913]],[\"^84\",[53,\"^69\",124,536870913]],[\"^84\",[53,\"^6:\",\"Created from property\",536870913]],[\"^84\",[53,\"^3;\",1775980635487,536870913]],[\"^84\",[53,\"^2T\",\"~u00000002-8618-9226-7000-000000000000\",536870913]],[\"^84\",[53,\"^<\",\"^=\",536870913]],[\"^84\",[53,\"^;\",\"^6?\",536870913]],[\"^84\",[53,\"^>\",true,536870913]],[\"^84\",[53,\"^C\",\"^D\",536870913]],[\"^84\",[53,\"^57\",true,536870913]],[\"^84\",[53,\"^5J\",true,536870913]],[\"^84\",[53,\"^1K\",\"^8=\",536870913]],[\"^84\",[54,\"^48\",1775980635492,536870913]],[\"^84\",[54,\"^7M\",\"publishing public?\",536870913]],[\"^84\",[54,\"^7@\",\"d3BGa\",536870913]],[\"^84\",[54,\"^69\",124,536870913]],[\"^84\",[54,\"^6:\",\"Publishing Public?\",536870913]],[\"^84\",[54,\"^3;\",1775980635492,536870913]],[\"^84\",[54,\"^2T\",\"~u00000002-1094-6579-3900-000000000000\",536870913]],[\"^84\",[54,\"^<\",\"^=\",536870913]],[\"^84\",[54,\"^;\",\"^4=\",536870913]],[\"^84\",[54,\"^>\",true,536870913]],[\"^84\",[54,\"^57\",true,536870913]],[\"^84\",[54,\"^5J\",true,536870913]],[\"^84\",[54,\"^X\",true,536870913]],[\"^84\",[54,\"^1K\",\"^8?\",536870913]],[\"^84\",[54,\"^44\",\"^89\",536870913]],[\"^84\",[55,\"^L\",26,536870913]],[\"^84\",[55,\"^48\",1775980635491,536870913]],[\"^84\",[55,\"^7@\",\"d3BGC\",536870913]],[\"^84\",[55,\"^4S\",26,536870913]],[\"^84\",[55,\"^3X\",26,536870913]],[\"^84\",[55,\"^6:\",\"Done\",536870913]],[\"^84\",[55,\"^3;\",1775980635491,536870913]],[\"^84\",[55,\"^2T\",\"~u00000002-1827-5820-8200-000000000000\",536870913]],[\"^84\",[55,\"^;\",\"^6Q\",536870913]],[\"^84\",[55,\"^57\",true,536870913]],[\"^84\",[55,\"^4W\",true,536870913]],[\"^84\",[55,\"^6?\",26,536870913]],[\"^84\",[55,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Done\"],536870913]],[\"^84\",[56,\"^48\",1775980635487,536870913]],[\"^84\",[56,\"^7M\",\"property value\",536870913]],[\"^84\",[56,\"^7@\",\"d3BFE\",536870913]],[\"^84\",[56,\"^69\",124,536870913]],[\"^84\",[56,\"^6:\",\"Property value\",536870913]],[\"^84\",[56,\"^3;\",1775980635487,536870913]],[\"^84\",[56,\"^2T\",\"~u00000002-1396-5248-6500-000000000000\",536870913]],[\"^84\",[56,\"^<\",\"^=\",536870913]],[\"^84\",[56,\"^;\",\"^T\",536870913]],[\"^84\",[56,\"^>\",true,536870913]],[\"^84\",[56,\"^57\",true,536870913]],[\"^84\",[56,\"^5J\",true,536870913]],[\"^84\",[56,\"^X\",false,536870913]],[\"^84\",[56,\"^1K\",\"~:any\",536870913]],[\"^84\",[57,\"^48\",1775980635493,536870913]],[\"^84\",[57,\"^7M\",\"file type\",536870913]],[\"^84\",[57,\"^7@\",\"d3BGs\",536870913]],[\"^84\",[57,\"^69\",124,536870913]],[\"^84\",[57,\"^6:\",\"File Type\",536870913]],[\"^84\",[57,\"^3;\",1775980635493,536870913]],[\"^84\",[57,\"^2T\",\"~u00000002-1142-0830-9800-000000000000\",536870913]],[\"^84\",[57,\"^<\",\"^=\",536870913]],[\"^84\",[57,\"^;\",\"^2:\",536870913]],[\"^84\",[57,\"^>\",true,536870913]],[\"^84\",[57,\"^57\",true,536870913]],[\"^84\",[57,\"^5J\",true,536870913]],[\"^84\",[57,\"^X\",false,536870913]],[\"^84\",[57,\"^1K\",\"^87\",536870913]],[\"^84\",[58,\"^48\",1775980635487,536870913]],[\"^84\",[58,\"^7M\",\"tags\",536870913]],[\"^84\",[58,\"^7@\",\"d3BFG\",536870913]],[\"^84\",[58,\"^69\",124,536870913]],[\"^84\",[58,\"^6:\",\"Tags\",536870913]],[\"^84\",[58,\"^3;\",1775980635487,536870913]],[\"^84\",[58,\"^2T\",\"~u00000002-1814-9483-4000-000000000000\",536870913]],[\"^84\",[58,\"^<\",\"^14\",536870913]],[\"^84\",[58,\"^;\",\"^69\",536870913]],[\"^84\",[58,\"^>\",true,536870913]],[\"^84\",[58,\"^C\",\"^D\",536870913]],[\"^84\",[58,\"^57\",true,536870913]],[\"^84\",[58,\"^X\",true,536870913]],[\"^84\",[58,\"^1K\",\"^8@\",536870913]],[\"^84\",[59,\"^48\",1775980635490,536870913]],[\"^84\",[59,\"^7M\",\"list type\",536870913]],[\"^84\",[59,\"^7@\",\"d3BFx\",536870913]],[\"^84\",[59,\"^69\",124,536870913]],[\"^84\",[59,\"^6:\",\"List type\",536870913]],[\"^84\",[59,\"^3;\",1775980635490,536870913]],[\"^84\",[59,\"^2T\",\"~u00000002-6078-1711-1000-000000000000\",536870913]],[\"^84\",[59,\"^<\",\"^=\",536870913]],[\"^84\",[59,\"^;\",\"^6W\",536870913]],[\"^84\",[59,\"^>\",true,536870913]],[\"^84\",[59,\"^C\",\"^D\",536870913]],[\"^84\",[59,\"^57\",true,536870913]],[\"^84\",[59,\"^5J\",true,536870913]],[\"^84\",[59,\"^1K\",\"^8>\",536870913]],[\"^84\",[60,\"^48\",1775980635487,536870913]],[\"^84\",[60,\"^7M\",\"property position\",536870913]],[\"^84\",[60,\"^7@\",\"d3BFC\",536870913]],[\"^84\",[60,\"^69\",124,536870913]],[\"^84\",[60,\"^6:\",\"Property position\",536870913]],[\"^84\",[60,\"^3;\",1775980635487,536870913]],[\"^84\",[60,\"^2T\",\"~u00000002-1869-2008-6400-000000000000\",536870913]],[\"^84\",[60,\"^<\",\"^=\",536870913]],[\"^84\",[60,\"^;\",\"^@\",536870913]],[\"^84\",[60,\"^>\",true,536870913]],[\"^84\",[60,\"^57\",true,536870913]],[\"^84\",[60,\"^5J\",true,536870913]],[\"^84\",[60,\"^1K\",\"^8D\",536870913]],[\"^84\",[61,\"^L\",13,536870913]],[\"^84\",[61,\"^48\",1775980635492,536870913]],[\"^84\",[61,\"^7@\",\"d3BGS\",536870913]],[\"^84\",[61,\"^4S\",13,536870913]],[\"^84\",[61,\"^3X\",13,536870913]],[\"^84\",[61,\"^6:\",\"Day\",536870913]],[\"^84\",[61,\"^3;\",1775980635492,536870913]],[\"^84\",[61,\"^2T\",\"~u00000002-3924-1785-8000-000000000000\",536870913]],[\"^84\",[61,\"^;\",\"^16\",536870913]],[\"^84\",[61,\"^57\",true,536870913]],[\"^84\",[61,\"^6?\",13,536870913]],[\"^84\",[62,\"^48\",1775980635490,536870913]],[\"^84\",[62,\"^7M\",\"annotation type\",536870913]],[\"^84\",[62,\"^7@\",\"d3BFn\",536870913]],[\"^84\",[62,\"^69\",124,536870913]],[\"^84\",[62,\"^6:\",\"Annotation type\",536870913]],[\"^84\",[62,\"^3;\",1775980635490,536870913]],[\"^84\",[62,\"^2T\",\"~u00000002-9984-3783-2000-000000000000\",536870913]],[\"^84\",[62,\"^<\",\"^=\",536870913]],[\"^84\",[62,\"^;\",\"^6[\",536870913]],[\"^84\",[62,\"^>\",true,536870913]],[\"^84\",[62,\"^57\",true,536870913]],[\"^84\",[62,\"^5J\",true,536870913]],[\"^84\",[62,\"^1K\",\"^8D\",536870913]],[\"^84\",[63,\"^48\",1775980635493,536870913]],[\"^84\",[63,\"^7M\",\"view hidden columns\",536870913]],[\"^84\",[63,\"^7@\",\"d3BGn\",536870913]],[\"^84\",[63,\"^69\",124,536870913]],[\"^84\",[63,\"^6:\",\"View hidden columns\",536870913]],[\"^84\",[63,\"^3;\",1775980635493,536870913]],[\"^84\",[63,\"^2T\",\"~u00000002-9750-5719-2000-000000000000\",536870913]],[\"^84\",[63,\"^<\",\"^14\",536870913]],[\"^84\",[63,\"^;\",\"^2[\",536870913]],[\"^84\",[63,\"^>\",true,536870913]],[\"^84\",[63,\"^57\",true,536870913]],[\"^84\",[63,\"^5J\",true,536870913]],[\"^84\",[63,\"^X\",false,536870913]],[\"^84\",[63,\"^1K\",\"^8D\",536870913]],[\"^84\",[64,\"^48\",1775980635490,536870913]],[\"^84\",[64,\"^7M\",\"ls-type\",536870913]],[\"^84\",[64,\"^7@\",\"d3BFm\",536870913]],[\"^84\",[64,\"^69\",124,536870913]],[\"^84\",[64,\"^6:\",\"ls-type\",536870913]],[\"^84\",[64,\"^3;\",1775980635490,536870913]],[\"^84\",[64,\"^2T\",\"~u00000002-3269-7934-5000-000000000000\",536870913]],[\"^84\",[64,\"^<\",\"^=\",536870913]],[\"^84\",[64,\"^;\",\"^5\",536870913]],[\"^84\",[64,\"^>\",true,536870913]],[\"^84\",[64,\"^57\",true,536870913]],[\"^84\",[64,\"^5J\",true,536870913]],[\"^84\",[64,\"^1K\",\"^8D\",536870913]],[\"^84\",[65,\"^L\",19,536870913]],[\"^84\",[65,\"^48\",1775980635490,536870913]],[\"^84\",[65,\"^7@\",\"d3BFp\",536870913]],[\"^84\",[65,\"^4S\",19,536870913]],[\"^84\",[65,\"^3X\",19,536870913]],[\"^84\",[65,\"^6:\",\"yellow\",536870913]],[\"^84\",[65,\"^3;\",1775980635490,536870913]],[\"^84\",[65,\"^2T\",\"~u00000002-1752-1030-0200-000000000000\",536870913]],[\"^84\",[65,\"^;\",\"^J\",536870913]],[\"^84\",[65,\"^57\",true,536870913]],[\"^84\",[65,\"^6?\",19,536870913]],[\"^84\",[66,\"^48\",1775980635493,536870913]],[\"^84\",[66,\"^7M\",\"image width\",536870913]],[\"^84\",[66,\"^7@\",\"d3BGw\",536870913]],[\"^84\",[66,\"^69\",124,536870913]],[\"^84\",[66,\"^6:\",\"Image width\",536870913]],[\"^84\",[66,\"^3;\",1775980635493,536870913]],[\"^84\",[66,\"^2T\",\"~u00000002-1857-8658-3900-000000000000\",536870913]],[\"^84\",[66,\"^<\",\"^=\",536870913]],[\"^84\",[66,\"^;\",\"^Z\",536870913]],[\"^84\",[66,\"^>\",true,536870913]],[\"^84\",[66,\"^57\",true,536870913]],[\"^84\",[66,\"^5J\",true,536870913]],[\"^84\",[66,\"^X\",false,536870913]],[\"^84\",[66,\"^1K\",\"^8C\",536870913]],[\"^84\",[67,\"^48\",1775980635494,536870913]],[\"^84\",[67,\"^7M\",\"file remote metadata\",536870913]],[\"^84\",[67,\"^7@\",\"d3BH0\",536870913]],[\"^84\",[67,\"^69\",124,536870913]],[\"^84\",[67,\"^6:\",\"File remote metadata\",536870913]],[\"^84\",[67,\"^3;\",1775980635494,536870913]],[\"^84\",[67,\"^2T\",\"~u00000002-9907-5046-9000-000000000000\",536870913]],[\"^84\",[67,\"^<\",\"^=\",536870913]],[\"^84\",[67,\"^;\",\"^1;\",536870913]],[\"^84\",[67,\"^>\",true,536870913]],[\"^84\",[67,\"^57\",true,536870913]],[\"^84\",[67,\"^2A\",167,536870913]],[\"^84\",[67,\"^5J\",true,536870913]],[\"^84\",[67,\"^X\",false,536870913]],[\"^84\",[67,\"^1K\",\"^88\",536870913]],[\"^84\",[68,\"^L\",127,536870913]],[\"^84\",[68,\"^48\",1775980635492,536870913]],[\"^84\",[68,\"^7@\",\"d3BGf\",536870913]],[\"^84\",[68,\"^4S\",127,536870913]],[\"^84\",[68,\"^3X\",127,536870913]],[\"^84\",[68,\"^6:\",\"List View\",536870913]],[\"^84\",[68,\"^3;\",1775980635492,536870913]],[\"^84\",[68,\"^2T\",\"~u00000002-1164-8285-0200-000000000000\",536870913]],[\"^84\",[68,\"^;\",\"^1E\",536870913]],[\"^84\",[68,\"^57\",true,536870913]],[\"^84\",[68,\"^6?\",127,536870913]],[\"^84\",[68,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"list\"],536870913]],[\"^84\",[69,\"^48\",1775980635488,536870913]],[\"^84\",[69,\"^7M\",\"node references\",536870913]],[\"^84\",[69,\"^7@\",\"d3BFL\",536870913]],[\"^84\",[69,\"^69\",124,536870913]],[\"^84\",[69,\"^6:\",\"Node references\",536870913]],[\"^84\",[69,\"^3;\",1775980635488,536870913]],[\"^84\",[69,\"^2T\",\"~u00000002-1214-4953-4900-000000000000\",536870913]],[\"^84\",[69,\"^<\",\"^14\",536870913]],[\"^84\",[69,\"^;\",\"^1S\",536870913]],[\"^84\",[69,\"^>\",true,536870913]],[\"^84\",[69,\"^C\",\"^D\",536870913]],[\"^84\",[69,\"^57\",true,536870913]],[\"^84\",[69,\"^5J\",true,536870913]],[\"^84\",[69,\"^X\",false,536870913]],[\"^84\",[69,\"^1K\",\"^8=\",536870913]],[\"^84\",[70,\"^L\",127,536870913]],[\"^84\",[70,\"^48\",1775980635492,536870913]],[\"^84\",[70,\"^7@\",\"d3BGe\",536870913]],[\"^84\",[70,\"^4S\",127,536870913]],[\"^84\",[70,\"^3X\",127,536870913]],[\"^84\",[70,\"^6:\",\"Table View\",536870913]],[\"^84\",[70,\"^3;\",1775980635492,536870913]],[\"^84\",[70,\"^2T\",\"~u00000002-1942-5424-0000-000000000000\",536870913]],[\"^84\",[70,\"^;\",\"^?\",536870913]],[\"^84\",[70,\"^57\",true,536870913]],[\"^84\",[70,\"^6?\",127,536870913]],[\"^84\",[70,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"table\"],536870913]],[\"^84\",[71,\"^48\",1775980635488,536870913]],[\"^84\",[71,\"^7M\",\"description\",536870913]],[\"^84\",[71,\"^7@\",\"d3BFT\",536870913]],[\"^84\",[71,\"^69\",124,536870913]],[\"^84\",[71,\"^6:\",\"Description\",536870913]],[\"^84\",[71,\"^3;\",1775980635488,536870913]],[\"^84\",[71,\"^2T\",\"~u00000002-3362-3620-0000-000000000000\",536870913]],[\"^84\",[71,\"^<\",\"^=\",536870913]],[\"^84\",[71,\"^;\",\"^2A\",536870913]],[\"^84\",[71,\"^>\",true,536870913]],[\"^84\",[71,\"^C\",\"^D\",536870913]],[\"^84\",[71,\"^57\",true,536870913]],[\"^84\",[71,\"^X\",true,536870913]],[\"^84\",[71,\"^1K\",\"^8>\",536870913]],[\"^84\",[72,\"^48\",1775980635495,536870913]],[\"^84\",[72,\"^7M\",\"recycle original page\",536870913]],[\"^84\",[72,\"^7@\",\"d3BHJ\",536870913]],[\"^84\",[72,\"^69\",124,536870913]],[\"^84\",[72,\"^6:\",\"Recycle original page\",536870913]],[\"^84\",[72,\"^3;\",1775980635495,536870913]],[\"^84\",[72,\"^2T\",\"~u00000002-1658-7897-9900-000000000000\",536870913]],[\"^84\",[72,\"^<\",\"^=\",536870913]],[\"^84\",[72,\"^;\",\"^2Q\",536870913]],[\"^84\",[72,\"^>\",true,536870913]],[\"^84\",[72,\"^C\",\"^D\",536870913]],[\"^84\",[72,\"^57\",true,536870913]],[\"^84\",[72,\"^5J\",true,536870913]],[\"^84\",[72,\"^X\",false,536870913]],[\"^84\",[72,\"^1K\",\"^85\",536870913]],[\"^84\",[73,\"^48\",1775980635490,536870913]],[\"^84\",[73,\"^7M\",\"excluded references\",536870913]],[\"^84\",[73,\"^7@\",\"d3BFz\",536870913]],[\"^84\",[73,\"^69\",124,536870913]],[\"^84\",[73,\"^6:\",\"Excluded references\",536870913]],[\"^84\",[73,\"^3;\",1775980635490,536870913]],[\"^84\",[73,\"^2T\",\"~u00000002-2426-7588-9000-000000000000\",536870913]],[\"^84\",[73,\"^<\",\"^14\",536870913]],[\"^84\",[73,\"^;\",\"^35\",536870913]],[\"^84\",[73,\"^>\",true,536870913]],[\"^84\",[73,\"^C\",\"^D\",536870913]],[\"^84\",[73,\"^57\",true,536870913]],[\"^84\",[73,\"^5J\",true,536870913]],[\"^84\",[73,\"^1K\",\"^85\",536870913]],[\"^84\",[74,\"^48\",1775980635494,536870913]],[\"^84\",[74,\"^7M\",\"history scalar value\",536870913]],[\"^84\",[74,\"^7@\",\"d3BHE\",536870913]],[\"^84\",[74,\"^69\",124,536870913]],[\"^84\",[74,\"^6:\",\"History scalar value\",536870913]],[\"^84\",[74,\"^3;\",1775980635494,536870913]],[\"^84\",[74,\"^2T\",\"~u00000002-2393-3777-5000-000000000000\",536870913]],[\"^84\",[74,\"^<\",\"^=\",536870913]],[\"^84\",[74,\"^;\",\"^3@\",536870913]],[\"^84\",[74,\"^>\",true,536870913]],[\"^84\",[74,\"^57\",true,536870913]],[\"^84\",[74,\"^5J\",true,536870913]],[\"^84\",[74,\"^1K\",\"^8H\",536870913]],[\"^84\",[75,\"^48\",1775980635491,536870913]],[\"^84\",[75,\"^7M\",\"deadline\",536870913]],[\"^84\",[75,\"^7@\",\"d3BGJ\",536870913]],[\"^84\",[75,\"^69\",124,536870913]],[\"^84\",[75,\"^6:\",\"Deadline\",536870913]],[\"^84\",[75,\"^3;\",1775980635491,536870913]],[\"^84\",[75,\"^2T\",\"~u00000002-1685-9016-0400-000000000000\",536870913]],[\"^84\",[75,\"^<\",\"^=\",536870913]],[\"^84\",[75,\"^;\",\"^1J\",536870913]],[\"^84\",[75,\"^>\",true,536870913]],[\"^84\",[75,\"^57\",true,536870913]],[\"^84\",[75,\"^2A\",166,536870913]],[\"^84\",[75,\"^3P\",true,536870913]],[\"^84\",[75,\"^X\",true,536870913]],[\"^84\",[75,\"^1K\",\"^8A\",536870913]],[\"^84\",[75,\"^@\",\"~:block-below\",536870913]],[\"^84\",[76,\"^L\",19,536870913]],[\"^84\",[76,\"^48\",1775980635490,536870913]],[\"^84\",[76,\"^7@\",\"d3BFr\",536870913]],[\"^84\",[76,\"^4S\",19,536870913]],[\"^84\",[76,\"^3X\",19,536870913]],[\"^84\",[76,\"^6:\",\"green\",536870913]],[\"^84\",[76,\"^3;\",1775980635490,536870913]],[\"^84\",[76,\"^2T\",\"~u00000002-1992-2016-2600-000000000000\",536870913]],[\"^84\",[76,\"^;\",\"^2=\",536870913]],[\"^84\",[76,\"^57\",true,536870913]],[\"^84\",[76,\"^6?\",19,536870913]],[\"^84\",[77,\"^48\",1775980635491,536870913]],[\"^84\",[77,\"^7M\",\"choice classes\",536870913]],[\"^84\",[77,\"^7@\",\"d3BG4\",536870913]],[\"^84\",[77,\"^69\",124,536870913]],[\"^84\",[77,\"^6:\",\"Choice classes\",536870913]],[\"^84\",[77,\"^3;\",1775980635491,536870913]],[\"^84\",[77,\"^2T\",\"~u00000002-7629-6621-9000-000000000000\",536870913]],[\"^84\",[77,\"^<\",\"^14\",536870913]],[\"^84\",[77,\"^;\",\"^4:\",536870913]],[\"^84\",[77,\"^>\",true,536870913]],[\"^84\",[77,\"^C\",\"^D\",536870913]],[\"^84\",[77,\"^57\",true,536870913]],[\"^84\",[77,\"^5J\",true,536870913]],[\"^84\",[77,\"^X\",false,536870913]],[\"^84\",[77,\"^1K\",\"^8@\",536870913]],[\"^84\",[77,\"^44\",\"~:never\",536870913]],[\"^84\",[78,\"^48\",1775980635496,536870913]],[\"^84\",[78,\"^7M\",\"quote\",536870913]],[\"^84\",[78,\"^69\",14,536870913]],[\"^84\",[78,\"^6:\",\"Quote\",536870913]],[\"^84\",[78,\"^3;\",1775980635496,536870913]],[\"^84\",[78,\"^2T\",\"~u00000002-1176-1666-1700-000000000000\",536870913]],[\"^84\",[78,\"^;\",\"^2K\",536870913]],[\"^84\",[78,\"^57\",true,536870913]],[\"^84\",[78,\"^3K\",89,536870913]],[\"^84\",[78,\"^1X\",true,536870913]],[\"^84\",[78,\"^4K\",37,536870913]],[\"^84\",[79,\"^48\",1775980635489,536870913]],[\"^84\",[79,\"^7M\",\"non ref type default value\",536870913]],[\"^84\",[79,\"^7@\",\"d3BFW\",536870913]],[\"^84\",[79,\"^69\",124,536870913]],[\"^84\",[79,\"^6:\",\"Non ref type default value\",536870913]],[\"^84\",[79,\"^3;\",1775980635489,536870913]],[\"^84\",[79,\"^2T\",\"~u00000002-1595-7230-1400-000000000000\",536870913]],[\"^84\",[79,\"^<\",\"^=\",536870913]],[\"^84\",[79,\"^;\",\"^29\",536870913]],[\"^84\",[79,\"^>\",true,536870913]],[\"^84\",[79,\"^57\",true,536870913]],[\"^84\",[79,\"^5J\",true,536870913]],[\"^84\",[79,\"^X\",false,536870913]],[\"^84\",[79,\"^1K\",\"^8H\",536870913]],[\"^84\",[79,\"^44\",\"^86\",536870913]],[\"^84\",[80,\"^48\",1775980635494,536870913]],[\"^84\",[80,\"^7M\",\"last visit page\",536870913]],[\"^84\",[80,\"^7@\",\"d3BGz\",536870913]],[\"^84\",[80,\"^69\",124,536870913]],[\"^84\",[80,\"^6:\",\"Last visit page\",536870913]],[\"^84\",[80,\"^3;\",1775980635494,536870913]],[\"^84\",[80,\"^2T\",\"~u00000002-2107-8035-3500-000000000000\",536870913]],[\"^84\",[80,\"^<\",\"^=\",536870913]],[\"^84\",[80,\"^;\",\"^4[\",536870913]],[\"^84\",[80,\"^>\",true,536870913]],[\"^84\",[80,\"^57\",true,536870913]],[\"^84\",[80,\"^5J\",true,536870913]],[\"^84\",[80,\"^X\",false,536870913]],[\"^84\",[80,\"^1K\",\"^8C\",536870913]],[\"^84\",[81,\"^48\",1775980635495,536870913]],[\"^84\",[81,\"^7M\",\"used template\",536870913]],[\"^84\",[81,\"^7@\",\"d3BHN\",536870913]],[\"^84\",[81,\"^69\",124,536870913]],[\"^84\",[81,\"^6:\",\"Used template\",536870913]],[\"^84\",[81,\"^3;\",1775980635495,536870913]],[\"^84\",[81,\"^2T\",\"~u00000002-9803-6990-6000-000000000000\",536870913]],[\"^84\",[81,\"^<\",\"^=\",536870913]],[\"^84\",[81,\"^;\",\"^4L\",536870913]],[\"^84\",[81,\"^>\",true,536870913]],[\"^84\",[81,\"^C\",\"^D\",536870913]],[\"^84\",[81,\"^57\",true,536870913]],[\"^84\",[81,\"^6G\",38,536870913]],[\"^84\",[81,\"^5J\",true,536870913]],[\"^84\",[81,\"^X\",false,536870913]],[\"^84\",[81,\"^1K\",\"^85\",536870913]],[\"^84\",[82,\"^L\",13,536870913]],[\"^84\",[82,\"^48\",1775980635492,536870913]],[\"^84\",[82,\"^7@\",\"d3BGU\",536870913]],[\"^84\",[82,\"^4S\",13,536870913]],[\"^84\",[82,\"^3X\",13,536870913]],[\"^84\",[82,\"^6:\",\"Month\",536870913]],[\"^84\",[82,\"^3;\",1775980635492,536870913]],[\"^84\",[82,\"^2T\",\"~u00000002-2073-3937-9700-000000000000\",536870913]],[\"^84\",[82,\"^;\",\"^5F\",536870913]],[\"^84\",[82,\"^57\",true,536870913]],[\"^84\",[82,\"^6?\",13,536870913]],[\"^84\",[83,\"^48\",1775980635494,536870913]],[\"^84\",[83,\"^7M\",\"history value\",536870913]],[\"^84\",[83,\"^7@\",\"d3BHD\",536870913]],[\"^84\",[83,\"^69\",124,536870913]],[\"^84\",[83,\"^6:\",\"History value\",536870913]],[\"^84\",[83,\"^3;\",1775980635494,536870913]],[\"^84\",[83,\"^2T\",\"~u00000002-5131-3603-7000-000000000000\",536870913]],[\"^84\",[83,\"^<\",\"^=\",536870913]],[\"^84\",[83,\"^;\",\"^5T\",536870913]],[\"^84\",[83,\"^>\",true,536870913]],[\"^84\",[83,\"^C\",\"^D\",536870913]],[\"^84\",[83,\"^57\",true,536870913]],[\"^84\",[83,\"^5J\",true,536870913]],[\"^84\",[83,\"^1K\",\"^8=\",536870913]],[\"^84\",[84,\"^48\",1775980635493,536870913]],[\"^84\",[84,\"^7M\",\"view filters\",536870913]],[\"^84\",[84,\"^7@\",\"d3BGm\",536870913]],[\"^84\",[84,\"^69\",124,536870913]],[\"^84\",[84,\"^6:\",\"View filters\",536870913]],[\"^84\",[84,\"^3;\",1775980635493,536870913]],[\"^84\",[84,\"^2T\",\"~u00000002-1702-3936-3300-000000000000\",536870913]],[\"^84\",[84,\"^<\",\"^=\",536870913]],[\"^84\",[84,\"^;\",\"^61\",536870913]],[\"^84\",[84,\"^>\",true,536870913]],[\"^84\",[84,\"^57\",true,536870913]],[\"^84\",[84,\"^5J\",true,536870913]],[\"^84\",[84,\"^X\",false,536870913]],[\"^84\",[84,\"^1K\",\"^88\",536870913]],[\"^84\",[85,\"^48\",1775980635496,536870913]],[\"^84\",[85,\"^7M\",\"journal\",536870913]],[\"^84\",[85,\"^69\",14,536870913]],[\"^84\",[85,\"^6:\",\"Journal\",536870913]],[\"^84\",[85,\"^3;\",1775980635496,536870913]],[\"^84\",[85,\"^2T\",\"~u00000002-1979-7410-8100-000000000000\",536870913]],[\"^84\",[85,\"^;\",\"^6A\",536870913]],[\"^84\",[85,\"^57\",true,536870913]],[\"^84\",[85,\"^3K\",104,536870913]],[\"^84\",[85,\"^55\",\"MMM do, yyyy\",536870913]],[\"^84\",[86,\"^48\",1775980635486,536870913]],[\"^84\",[86,\"^7M\",\"built in?\",536870913]],[\"^84\",[86,\"^7@\",\"d3BF7\",536870913]],[\"^84\",[86,\"^69\",124,536870913]],[\"^84\",[86,\"^6:\",\"Built in?\",536870913]],[\"^84\",[86,\"^3;\",1775980635486,536870913]],[\"^84\",[86,\"^2T\",\"~u00000002-1125-9581-6000-000000000000\",536870913]],[\"^84\",[86,\"^<\",\"^=\",536870913]],[\"^84\",[86,\"^;\",\"^57\",536870913]],[\"^84\",[86,\"^>\",true,536870913]],[\"^84\",[86,\"^57\",true,536870913]],[\"^84\",[86,\"^5J\",true,536870913]],[\"^84\",[86,\"^1K\",\"^8?\",536870913]],[\"^84\",[87,\"^48\",1775980635496,536870913]],[\"^84\",[87,\"^7M\",\"whiteboard\",536870913]],[\"^84\",[87,\"^69\",14,536870913]],[\"^84\",[87,\"^6:\",\"Whiteboard\",536870913]],[\"^84\",[87,\"^3;\",1775980635496,536870913]],[\"^84\",[87,\"^2T\",\"~u00000002-1013-6984-5200-000000000000\",536870913]],[\"^84\",[87,\"^;\",\"^6<\",536870913]],[\"^84\",[87,\"^57\",true,536870913]],[\"^84\",[87,\"^3K\",104,536870913]],[\"^84\",[88,\"^48\",1775980635494,536870913]],[\"^84\",[88,\"^7M\",\"asset alignment\",536870913]],[\"^84\",[88,\"^7@\",\"d3BH3\",536870913]],[\"^84\",[88,\"^69\",124,536870913]],[\"^84\",[88,\"^6:\",\"Asset alignment\",536870913]],[\"^84\",[88,\"^3;\",1775980635494,536870913]],[\"^84\",[88,\"^2T\",\"~u00000002-7135-0412-8000-000000000000\",536870913]],[\"^84\",[88,\"^<\",\"^=\",536870913]],[\"^84\",[88,\"^;\",\"^:\",536870913]],[\"^84\",[88,\"^>\",true,536870913]],[\"^84\",[88,\"^57\",true,536870913]],[\"^84\",[88,\"^5J\",true,536870913]],[\"^84\",[88,\"^X\",false,536870913]],[\"^84\",[88,\"^1K\",\"^8D\",536870913]],[\"^84\",[89,\"^48\",1775980635496,536870913]],[\"^84\",[89,\"^7M\",\"root tag\",536870913]],[\"^84\",[89,\"^69\",14,536870913]],[\"^84\",[89,\"^6:\",\"Root Tag\",536870913]],[\"^84\",[89,\"^3;\",1775980635496,536870913]],[\"^84\",[89,\"^2T\",\"~u00000002-2737-8382-7000-000000000000\",536870913]],[\"^84\",[89,\"^;\",\"^5Q\",536870913]],[\"^84\",[89,\"^57\",true,536870913]],[\"^84\",[90,\"^48\",1775980635489,536870913]],[\"^84\",[90,\"^7M\",\"heading\",536870913]],[\"^84\",[90,\"^7@\",\"d3BFk\",536870913]],[\"^84\",[90,\"^69\",124,536870913]],[\"^84\",[90,\"^6:\",\"Heading\",536870913]],[\"^84\",[90,\"^3;\",1775980635489,536870913]],[\"^84\",[90,\"^2T\",\"~u00000002-1858-7494-1500-000000000000\",536870913]],[\"^84\",[90,\"^<\",\"^=\",536870913]],[\"^84\",[90,\"^;\",\"^2L\",536870913]],[\"^84\",[90,\"^>\",true,536870913]],[\"^84\",[90,\"^57\",true,536870913]],[\"^84\",[90,\"^5J\",true,536870913]],[\"^84\",[90,\"^1K\",\"^8H\",536870913]],[\"^84\",[91,\"^48\",1775980635490,536870913]],[\"^84\",[91,\"^7M\",\"annotation image\",536870913]],[\"^84\",[91,\"^7@\",\"d3BFv\",536870913]],[\"^84\",[91,\"^69\",124,536870913]],[\"^84\",[91,\"^6:\",\"Annotation image\",536870913]],[\"^84\",[91,\"^3;\",1775980635490,536870913]],[\"^84\",[91,\"^2T\",\"~u00000002-1377-6700-9000-000000000000\",536870913]],[\"^84\",[91,\"^<\",\"^=\",536870913]],[\"^84\",[91,\"^;\",\"^M\",536870913]],[\"^84\",[91,\"^>\",true,536870913]],[\"^84\",[91,\"^C\",\"^D\",536870913]],[\"^84\",[91,\"^57\",true,536870913]],[\"^84\",[91,\"^5J\",true,536870913]],[\"^84\",[91,\"^1K\",\"^8=\",536870913]],[\"^84\",[92,\"^48\",1775980635487,536870913]],[\"^84\",[92,\"^7M\",\"property view context\",536870913]],[\"^84\",[92,\"^7@\",\"d3BFB\",536870913]],[\"^84\",[92,\"^69\",124,536870913]],[\"^84\",[92,\"^6:\",\"Property view context\",536870913]],[\"^84\",[92,\"^3;\",1775980635487,536870913]],[\"^84\",[92,\"^2T\",\"~u00000002-1547-3958-2800-000000000000\",536870913]],[\"^84\",[92,\"^<\",\"^=\",536870913]],[\"^84\",[92,\"^;\",\"^44\",536870913]],[\"^84\",[92,\"^>\",true,536870913]],[\"^84\",[92,\"^57\",true,536870913]],[\"^84\",[92,\"^5J\",true,536870913]],[\"^84\",[92,\"^1K\",\"^8D\",536870913]],[\"^84\",[93,\"^48\",1775980635492,536870913]],[\"^84\",[93,\"^7M\",\"view feature type\",536870913]],[\"^84\",[93,\"^7@\",\"d3BGh\",536870913]],[\"^84\",[93,\"^69\",124,536870913]],[\"^84\",[93,\"^6:\",\"View Feature Type\",536870913]],[\"^84\",[93,\"^3;\",1775980635492,536870913]],[\"^84\",[93,\"^2T\",\"~u00000002-9391-4187-1000-000000000000\",536870913]],[\"^84\",[93,\"^<\",\"^=\",536870913]],[\"^84\",[93,\"^;\",\"^5>\",536870913]],[\"^84\",[93,\"^>\",true,536870913]],[\"^84\",[93,\"^57\",true,536870913]],[\"^84\",[93,\"^5J\",true,536870913]],[\"^84\",[93,\"^X\",false,536870913]],[\"^84\",[93,\"^1K\",\"^8D\",536870913]],[\"^84\",[94,\"^L\",32,536870913]],[\"^84\",[94,\"^48\",1775980635491,536870913]],[\"^84\",[94,\"^7@\",\"d3BGF\",536870913]],[\"^84\",[94,\"^4S\",32,536870913]],[\"^84\",[94,\"^3X\",32,536870913]],[\"^84\",[94,\"^6:\",\"Low\",536870913]],[\"^84\",[94,\"^3;\",1775980635491,536870913]],[\"^84\",[94,\"^2T\",\"~u00000002-2107-4537-4800-000000000000\",536870913]],[\"^84\",[94,\"^;\",\"^68\",536870913]],[\"^84\",[94,\"^57\",true,536870913]],[\"^84\",[94,\"^6?\",32,536870913]],[\"^84\",[94,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlLow\"],536870913]],[\"^84\",[95,\"^48\",1775980635491,536870913]],[\"^84\",[95,\"^7M\",\"repeating recur frequency\",536870913]],[\"^84\",[95,\"^7@\",\"d3BGN\",536870913]],[\"^84\",[95,\"^69\",124,536870913]],[\"^84\",[95,\"^6:\",\"Repeating recur frequency\",536870913]],[\"^84\",[95,\"^3;\",1775980635491,536870913]],[\"^84\",[95,\"^2T\",\"~u00000002-8716-1592-2000-000000000000\",536870913]],[\"^84\",[95,\"^<\",\"^=\",536870913]],[\"^84\",[95,\"^;\",\"^5K\",536870913]],[\"^84\",[95,\"^>\",true,536870913]],[\"^84\",[95,\"^C\",\"^D\",536870913]],[\"^84\",[95,\"^57\",true,536870913]],[\"^84\",[95,\"^5L\",175,536870913]],[\"^84\",[95,\"^3P\",true,536870913]],[\"^84\",[95,\"^X\",false,536870913]],[\"^84\",[95,\"^1K\",\"~:number\",536870913]],[\"^84\",[96,\"^48\",1775980635492,536870913]],[\"^84\",[96,\"^7M\",\"node repeats?\",536870913]],[\"^84\",[96,\"^7@\",\"d3BGW\",536870913]],[\"^84\",[96,\"^69\",124,536870913]],[\"^84\",[96,\"^6:\",\"Node Repeats?\",536870913]],[\"^84\",[96,\"^3;\",1775980635492,536870913]],[\"^84\",[96,\"^2T\",\"~u00000002-1908-1217-8900-000000000000\",536870913]],[\"^84\",[96,\"^<\",\"^=\",536870913]],[\"^84\",[96,\"^;\",\"^7\",536870913]],[\"^84\",[96,\"^>\",true,536870913]],[\"^84\",[96,\"^57\",true,536870913]],[\"^84\",[96,\"^5J\",true,536870913]],[\"^84\",[96,\"^1K\",\"^8?\",536870913]],[\"^84\",[97,\"^48\",1775980635488,536870913]],[\"^84\",[97,\"^7M\",\"closed value property\",536870913]],[\"^84\",[97,\"^7@\",\"d3BFO\",536870913]],[\"^84\",[97,\"^69\",124,536870913]],[\"^84\",[97,\"^6:\",\"Closed value property\",536870913]],[\"^84\",[97,\"^3;\",1775980635488,536870913]],[\"^84\",[97,\"^2T\",\"~u00000002-1157-7928-1300-000000000000\",536870913]],[\"^84\",[97,\"^<\",\"^14\",536872483]],[\"^84\",[97,\"^;\",\"^L\",536870913]],[\"^84\",[97,\"^>\",true,536870913]],[\"^84\",[97,\"^C\",\"^D\",536870913]],[\"^84\",[97,\"^57\",true,536870913]],[\"^84\",[97,\"^5J\",true,536870913]],[\"^84\",[97,\"^X\",false,536870913]],[\"^84\",[97,\"^1K\",\"^8=\",536870913]],[\"^84\",[98,\"^48\",1775980635495,536870913]],[\"^84\",[98,\"^7M\",\"reaction emoji\",536870913]],[\"^84\",[98,\"^7@\",\"d3BHL\",536870913]],[\"^84\",[98,\"^69\",124,536870913]],[\"^84\",[98,\"^6:\",\"Reaction emoji\",536870913]],[\"^84\",[98,\"^3;\",1775980635495,536870913]],[\"^84\",[98,\"^2T\",\"~u00000002-9877-5864-5000-000000000000\",536870913]],[\"^84\",[98,\"^<\",\"^=\",536870913]],[\"^84\",[98,\"^;\",\"^10\",536870913]],[\"^84\",[98,\"^>\",true,536870913]],[\"^84\",[98,\"^57\",true,536870913]],[\"^84\",[98,\"^5J\",true,536870913]],[\"^84\",[98,\"^X\",false,536870913]],[\"^84\",[98,\"^1K\",\"^87\",536870913]],[\"^84\",[99,\"^L\",32,536870913]],[\"^84\",[99,\"^48\",1775980635491,536870913]],[\"^84\",[99,\"^7@\",\"d3BGH\",536870913]],[\"^84\",[99,\"^4S\",32,536870913]],[\"^84\",[99,\"^3X\",32,536870913]],[\"^84\",[99,\"^6:\",\"High\",536870913]],[\"^84\",[99,\"^3;\",1775980635491,536870913]],[\"^84\",[99,\"^2T\",\"~u00000002-5672-2766-8000-000000000000\",536870913]],[\"^84\",[99,\"^;\",\"^1=\",536870913]],[\"^84\",[99,\"^57\",true,536870913]],[\"^84\",[99,\"^6?\",32,536870913]],[\"^84\",[99,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlHigh\"],536870913]],[\"^84\",[100,\"^L\",19,536870913]],[\"^84\",[100,\"^48\",1775980635490,536870913]],[\"^84\",[100,\"^7@\",\"d3BFs\",536870913]],[\"^84\",[100,\"^4S\",19,536870913]],[\"^84\",[100,\"^3X\",19,536870913]],[\"^84\",[100,\"^6:\",\"blue\",536870913]],[\"^84\",[100,\"^3;\",1775980635490,536870913]],[\"^84\",[100,\"^2T\",\"~u00000002-1836-0512-4100-000000000000\",536870913]],[\"^84\",[100,\"^;\",\"^1G\",536870913]],[\"^84\",[100,\"^57\",true,536870913]],[\"^84\",[100,\"^6?\",19,536870913]],[\"^84\",[101,\"^48\",1775980635493,536870913]],[\"^84\",[101,\"^7M\",\"view ordered columns\",536870913]],[\"^84\",[101,\"^7@\",\"d3BGo\",536870913]],[\"^84\",[101,\"^69\",124,536870913]],[\"^84\",[101,\"^6:\",\"View ordered columns\",536870913]],[\"^84\",[101,\"^3;\",1775980635493,536870913]],[\"^84\",[101,\"^2T\",\"~u00000002-1485-5871-0000-000000000000\",536870913]],[\"^84\",[101,\"^<\",\"^=\",536870913]],[\"^84\",[101,\"^;\",\"^1U\",536870913]],[\"^84\",[101,\"^>\",true,536870913]],[\"^84\",[101,\"^57\",true,536870913]],[\"^84\",[101,\"^5J\",true,536870913]],[\"^84\",[101,\"^X\",false,536870913]],[\"^84\",[101,\"^1K\",\"~:coll\",536870913]],[\"^84\",[102,\"^48\",1775980635489,536870913]],[\"^84\",[102,\"^7M\",\"background color\",536870913]],[\"^84\",[102,\"^7@\",\"d3BFj\",536870913]],[\"^84\",[102,\"^69\",124,536870913]],[\"^84\",[102,\"^6:\",\"Background color\",536870913]],[\"^84\",[102,\"^3;\",1775980635489,536870913]],[\"^84\",[102,\"^2T\",\"~u00000002-5191-2660-6000-000000000000\",536870913]],[\"^84\",[102,\"^<\",\"^=\",536870913]],[\"^84\",[102,\"^;\",\"^26\",536870913]],[\"^84\",[102,\"^>\",true,536870913]],[\"^84\",[102,\"^C\",\"^D\",536870913]],[\"^84\",[102,\"^57\",true,536870913]],[\"^84\",[102,\"^5J\",true,536870913]],[\"^84\",[102,\"^1K\",\"^8>\",536870913]],[\"^84\",[103,\"^48\",1775980635496,536870913]],[\"^84\",[103,\"^7M\",\"cards\",536870913]],[\"^84\",[103,\"^69\",14,536870913]],[\"^84\",[103,\"^6:\",\"Cards\",536870913]],[\"^84\",[103,\"^3;\",1775980635496,536870913]],[\"^84\",[103,\"^2T\",\"~u00000002-1284-2651-6700-000000000000\",536870913]],[\"^84\",[103,\"^;\",\"^2C\",536870913]],[\"^84\",[103,\"^57\",true,536870913]],[\"^84\",[103,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[103,\"^3K\",142,536870913]],[\"^84\",[104,\"^48\",1775980635496,536870913]],[\"^84\",[104,\"^7M\",\"page\",536870913]],[\"^84\",[104,\"^69\",14,536870913]],[\"^84\",[104,\"^6:\",\"Page\",536870913]],[\"^84\",[104,\"^3;\",1775980635496,536870913]],[\"^84\",[104,\"^2T\",\"~u00000002-1484-3403-2900-000000000000\",536870913]],[\"^84\",[104,\"^;\",\"^2S\",536870913]],[\"^84\",[104,\"^57\",true,536870913]],[\"^84\",[104,\"^3K\",89,536870913]],[\"^84\",[105,\"^L\",13,536870913]],[\"^84\",[105,\"^48\",1775980635492,536870913]],[\"^84\",[105,\"^7@\",\"d3BGT\",536870913]],[\"^84\",[105,\"^4S\",13,536870913]],[\"^84\",[105,\"^3X\",13,536870913]],[\"^84\",[105,\"^6:\",\"Week\",536870913]],[\"^84\",[105,\"^3;\",1775980635492,536870913]],[\"^84\",[105,\"^2T\",\"~u00000002-2130-9244-4900-000000000000\",536870913]],[\"^84\",[105,\"^;\",\"^U\",536870913]],[\"^84\",[105,\"^57\",true,536870913]],[\"^84\",[105,\"^6?\",13,536870913]],[\"^84\",[106,\"^48\",1775980635490,536870913]],[\"^84\",[106,\"^7M\",\"tldraw shape\",536870913]],[\"^84\",[106,\"^7@\",\"d3BG1\",536870913]],[\"^84\",[106,\"^69\",124,536870913]],[\"^84\",[106,\"^6:\",\"Tldraw Shape\",536870913]],[\"^84\",[106,\"^3;\",1775980635490,536870913]],[\"^84\",[106,\"^2T\",\"~u00000002-1313-2454-2000-000000000000\",536870913]],[\"^84\",[106,\"^<\",\"^=\",536870913]],[\"^84\",[106,\"^;\",\"^3B\",536870913]],[\"^84\",[106,\"^>\",true,536870913]],[\"^84\",[106,\"^57\",true,536870913]],[\"^84\",[106,\"^5J\",true,536870913]],[\"^84\",[106,\"^1K\",\"^88\",536870913]],[\"^84\",[107,\"^48\",1775980635493,536870913]],[\"^84\",[107,\"^7M\",\"image height\",536870913]],[\"^84\",[107,\"^7@\",\"d3BGx\",536870913]],[\"^84\",[107,\"^69\",124,536870913]],[\"^84\",[107,\"^6:\",\"Image height\",536870913]],[\"^84\",[107,\"^3;\",1775980635493,536870913]],[\"^84\",[107,\"^2T\",\"~u00000002-3306-1650-4000-000000000000\",536870913]],[\"^84\",[107,\"^<\",\"^=\",536870913]],[\"^84\",[107,\"^;\",\"^3M\",536870913]],[\"^84\",[107,\"^>\",true,536870913]],[\"^84\",[107,\"^57\",true,536870913]],[\"^84\",[107,\"^5J\",true,536870913]],[\"^84\",[107,\"^X\",false,536870913]],[\"^84\",[107,\"^1K\",\"^8C\",536870913]],[\"^84\",[108,\"^48\",1775980635488,536870913]],[\"^84\",[108,\"^7M\",\"code mode\",536870913]],[\"^84\",[108,\"^7@\",\"d3BFU\",536870913]],[\"^84\",[108,\"^69\",124,536870913]],[\"^84\",[108,\"^6:\",\"Code Mode\",536870913]],[\"^84\",[108,\"^3;\",1775980635488,536870913]],[\"^84\",[108,\"^2T\",\"~u00000002-8578-9716-5000-000000000000\",536870913]],[\"^84\",[108,\"^<\",\"^=\",536870913]],[\"^84\",[108,\"^;\",\"^40\",536870913]],[\"^84\",[108,\"^>\",true,536870913]],[\"^84\",[108,\"^57\",true,536870913]],[\"^84\",[108,\"^5J\",true,536870913]],[\"^84\",[108,\"^X\",false,536870913]],[\"^84\",[108,\"^1K\",\"^87\",536870913]],[\"^84\",[108,\"^44\",\"^8E\",536870913]],[\"^84\",[109,\"^48\",1775980635495,536870913]],[\"^84\",[109,\"^7M\",\"node created by\",536870913]],[\"^84\",[109,\"^7@\",\"d3BHF\",536870913]],[\"^84\",[109,\"^69\",124,536870913]],[\"^84\",[109,\"^6:\",\"Node created by\",536870913]],[\"^84\",[109,\"^3;\",1775980635495,536870913]],[\"^84\",[109,\"^2T\",\"~u00000002-8544-3390-8000-000000000000\",536870913]],[\"^84\",[109,\"^<\",\"^=\",536870913]],[\"^84\",[109,\"^;\",\"^4<\",536870913]],[\"^84\",[109,\"^>\",true,536870913]],[\"^84\",[109,\"^C\",\"^D\",536870913]],[\"^84\",[109,\"^57\",true,536870913]],[\"^84\",[109,\"^5J\",true,536870913]],[\"^84\",[109,\"^1K\",\"^8=\",536870913]],[\"^84\",[110,\"^48\",1775980635491,536870913]],[\"^84\",[110,\"^7M\",\"choice exclusions\",536870913]],[\"^84\",[110,\"^7@\",\"d3BG5\",536870913]],[\"^84\",[110,\"^69\",124,536870913]],[\"^84\",[110,\"^6:\",\"Choice exclusions\",536870913]],[\"^84\",[110,\"^3;\",1775980635491,536870913]],[\"^84\",[110,\"^2T\",\"~u00000002-1233-5229-4600-000000000000\",536870913]],[\"^84\",[110,\"^<\",\"^14\",536870913]],[\"^84\",[110,\"^;\",\"^13\",536870913]],[\"^84\",[110,\"^>\",true,536870913]],[\"^84\",[110,\"^C\",\"^D\",536870913]],[\"^84\",[110,\"^57\",true,536870913]],[\"^84\",[110,\"^5J\",true,536870913]],[\"^84\",[110,\"^X\",false,536870913]],[\"^84\",[110,\"^1K\",\"^85\",536870913]],[\"^84\",[110,\"^44\",\"^8J\",536870913]],[\"^84\",[111,\"^;\",\"^R\",536870913]],[\"^84\",[111,\"^28\",[\"^ \",\"^8F\",65,\"^8G\",24],536870913]],[\"^84\",[112,\"^48\",1775980635490,536870913]],[\"^84\",[112,\"^7M\",\"title format\",536870913]],[\"^84\",[112,\"^7@\",\"d3BG2\",536870913]],[\"^84\",[112,\"^69\",124,536870913]],[\"^84\",[112,\"^6:\",\"Title Format\",536870913]],[\"^84\",[112,\"^3;\",1775980635490,536870913]],[\"^84\",[112,\"^2T\",\"~u00000002-1536-4979-5400-000000000000\",536870913]],[\"^84\",[112,\"^<\",\"^=\",536870913]],[\"^84\",[112,\"^;\",\"^55\",536870913]],[\"^84\",[112,\"^>\",true,536870913]],[\"^84\",[112,\"^57\",true,536870913]],[\"^84\",[112,\"^X\",false,536870913]],[\"^84\",[112,\"^1K\",\"^87\",536870913]],[\"^84\",[113,\"^48\",1775980635490,536870913]],[\"^84\",[113,\"^7M\",\"included references\",536870913]],[\"^84\",[113,\"^7@\",\"d3BFy\",536870913]],[\"^84\",[113,\"^69\",124,536870913]],[\"^84\",[113,\"^6:\",\"Included references\",536870913]],[\"^84\",[113,\"^3;\",1775980635490,536870913]],[\"^84\",[113,\"^2T\",\"~u00000002-1680-5777-0300-000000000000\",536870913]],[\"^84\",[113,\"^<\",\"^14\",536870913]],[\"^84\",[113,\"^;\",\"^2F\",536870913]],[\"^84\",[113,\"^>\",true,536870913]],[\"^84\",[113,\"^C\",\"^D\",536870913]],[\"^84\",[113,\"^57\",true,536870913]],[\"^84\",[113,\"^5J\",true,536870913]],[\"^84\",[113,\"^1K\",\"^85\",536870913]],[\"^84\",[114,\"^L\",26,536870913]],[\"^84\",[114,\"^48\",1775980635491,536870913]],[\"^84\",[114,\"^7@\",\"d3BG9\",536870913]],[\"^84\",[114,\"^4S\",26,536870913]],[\"^84\",[114,\"^3X\",26,536870913]],[\"^84\",[114,\"^6:\",\"Todo\",536870913]],[\"^84\",[114,\"^3;\",1775980635491,536870913]],[\"^84\",[114,\"^2T\",\"~u00000002-1615-5853-7700-000000000000\",536870913]],[\"^84\",[114,\"^;\",\"^5H\",536870913]],[\"^84\",[114,\"^57\",true,536870913]],[\"^84\",[114,\"^4W\",false,536870913]],[\"^84\",[114,\"^6?\",26,536870913]],[\"^84\",[114,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Todo\"],536870913]],[\"^84\",[115,\"^48\",1775980635494,536870913]],[\"^84\",[115,\"^7M\",\"history property\",536870913]],[\"^84\",[115,\"^7@\",\"d3BHC\",536870913]],[\"^84\",[115,\"^69\",124,536870913]],[\"^84\",[115,\"^6:\",\"History property\",536870913]],[\"^84\",[115,\"^3;\",1775980635494,536870913]],[\"^84\",[115,\"^2T\",\"~u00000002-1600-4090-8200-000000000000\",536870913]],[\"^84\",[115,\"^<\",\"^=\",536870913]],[\"^84\",[115,\"^;\",\"^5V\",536870913]],[\"^84\",[115,\"^>\",true,536870913]],[\"^84\",[115,\"^C\",\"^D\",536870913]],[\"^84\",[115,\"^57\",true,536870913]],[\"^84\",[115,\"^5J\",true,536870913]],[\"^84\",[115,\"^1K\",\"^86\",536870913]],[\"^84\",[116,\"^48\",1775980635495,536870913]],[\"^84\",[116,\"^7M\",\"recycle original parent\",536870913]],[\"^84\",[116,\"^7@\",\"d3BHI\",536870913]],[\"^84\",[116,\"^69\",124,536870913]],[\"^84\",[116,\"^6:\",\"Recycle original parent\",536870913]],[\"^84\",[116,\"^3;\",1775980635495,536870913]],[\"^84\",[116,\"^2T\",\"~u00000002-4970-0399-4000-000000000000\",536870913]],[\"^84\",[116,\"^<\",\"^=\",536870913]],[\"^84\",[116,\"^;\",\"^63\",536870913]],[\"^84\",[116,\"^>\",true,536870913]],[\"^84\",[116,\"^C\",\"^D\",536870913]],[\"^84\",[116,\"^57\",true,536870913]],[\"^84\",[116,\"^5J\",true,536870913]],[\"^84\",[116,\"^X\",false,536870913]],[\"^84\",[116,\"^1K\",\"^85\",536870913]],[\"^84\",[117,\"^48\",1775980635490,536870913]],[\"^84\",[117,\"^7M\",\"annotation data\",536870913]],[\"^84\",[117,\"^7@\",\"d3BFw\",536870913]],[\"^84\",[117,\"^69\",124,536870913]],[\"^84\",[117,\"^6:\",\"Annotation data\",536870913]],[\"^84\",[117,\"^3;\",1775980635490,536870913]],[\"^84\",[117,\"^2T\",\"~u00000002-5458-2940-2000-000000000000\",536870913]],[\"^84\",[117,\"^<\",\"^=\",536870913]],[\"^84\",[117,\"^;\",\"^6C\",536870913]],[\"^84\",[117,\"^>\",true,536870913]],[\"^84\",[117,\"^57\",true,536870913]],[\"^84\",[117,\"^5J\",true,536870913]],[\"^84\",[117,\"^1K\",\"^88\",536870913]],[\"^84\",[118,\"^48\",1775980635492,536870913]],[\"^84\",[118,\"^7M\",\"view group by property\",536870913]],[\"^84\",[118,\"^7@\",\"d3BGi\",536870913]],[\"^84\",[118,\"^69\",124,536870913]],[\"^84\",[118,\"^6:\",\"View group by property\",536870913]],[\"^84\",[118,\"^3;\",1775980635492,536870913]],[\"^84\",[118,\"^2T\",\"~u00000002-8092-1623-6000-000000000000\",536870913]],[\"^84\",[118,\"^<\",\"^=\",536870913]],[\"^84\",[118,\"^;\",\"^43\",536870913]],[\"^84\",[118,\"^>\",true,536870913]],[\"^84\",[118,\"^C\",\"^D\",536870913]],[\"^84\",[118,\"^57\",true,536870913]],[\"^84\",[118,\"^5J\",true,536870913]],[\"^84\",[118,\"^X\",false,536870913]],[\"^84\",[118,\"^1K\",\"^86\",536870913]],[\"^84\",[119,\"^48\",1775980635487,536870913]],[\"^84\",[119,\"^7M\",\"hide this property or page\",536870913]],[\"^84\",[119,\"^7@\",\"d3BF8\",536870913]],[\"^84\",[119,\"^69\",124,536870913]],[\"^84\",[119,\"^6:\",\"Hide this property or page\",536870913]],[\"^84\",[119,\"^3;\",1775980635487,536870913]],[\"^84\",[119,\"^2T\",\"~u00000002-6836-5746-0000-000000000000\",536870913]],[\"^84\",[119,\"^<\",\"^=\",536870913]],[\"^84\",[119,\"^;\",\"^5J\",536870913]],[\"^84\",[119,\"^>\",true,536870913]],[\"^84\",[119,\"^57\",true,536870913]],[\"^84\",[119,\"^5J\",true,536870913]],[\"^84\",[119,\"^1K\",\"^8?\",536870913]],[\"^84\",[120,\"^48\",1775980635488,536870913]],[\"^84\",[120,\"^7M\",\"node links to\",536870913]],[\"^84\",[120,\"^7@\",\"d3BFM\",536870913]],[\"^84\",[120,\"^69\",124,536870913]],[\"^84\",[120,\"^6:\",\"Node links to\",536870913]],[\"^84\",[120,\"^3;\",1775980635488,536870913]],[\"^84\",[120,\"^2T\",\"~u00000002-1872-3999-9300-000000000000\",536870913]],[\"^84\",[120,\"^<\",\"^=\",536870913]],[\"^84\",[120,\"^;\",\"^2I\",536870913]],[\"^84\",[120,\"^>\",true,536870913]],[\"^84\",[120,\"^C\",\"^D\",536870913]],[\"^84\",[120,\"^57\",true,536870913]],[\"^84\",[120,\"^5J\",true,536870913]],[\"^84\",[120,\"^X\",false,536870913]],[\"^84\",[120,\"^1K\",\"^8=\",536870913]],[\"^84\",[121,\"^48\",1775980635489,536870913]],[\"^84\",[121,\"^7M\",\"tag properties\",536870913]],[\"^84\",[121,\"^7@\",\"d3BFZ\",536870913]],[\"^84\",[121,\"^69\",124,536870913]],[\"^84\",[121,\"^6:\",\"Tag Properties\",536870913]],[\"^84\",[121,\"^3;\",1775980635489,536870913]],[\"^84\",[121,\"^2T\",\"~u00000002-2123-7120-5000-000000000000\",536870913]],[\"^84\",[121,\"^<\",\"^14\",536870913]],[\"^84\",[121,\"^;\",\"^4K\",536870913]],[\"^84\",[121,\"^>\",true,536870913]],[\"^84\",[121,\"^C\",\"^D\",536870913]],[\"^84\",[121,\"^57\",true,536870913]],[\"^84\",[121,\"^X\",true,536870913]],[\"^84\",[121,\"^1K\",\"^86\",536870913]],[\"^84\",[121,\"^44\",\"^8J\",536870913]],[\"^84\",[122,\"^48\",1775980635488,536870913]],[\"^84\",[122,\"^7M\",\"node order\",536870913]],[\"^84\",[122,\"^7@\",\"d3BFI\",536870913]],[\"^84\",[122,\"^69\",124,536870913]],[\"^84\",[122,\"^6:\",\"Node order\",536870913]],[\"^84\",[122,\"^3;\",1775980635488,536870913]],[\"^84\",[122,\"^2T\",\"~u00000002-1429-2824-3700-000000000000\",536870913]],[\"^84\",[122,\"^<\",\"^=\",536870913]],[\"^84\",[122,\"^;\",\"^7@\",536870913]],[\"^84\",[122,\"^>\",true,536870913]],[\"^84\",[122,\"^57\",true,536870913]],[\"^84\",[122,\"^5J\",true,536870913]],[\"^84\",[122,\"^X\",false,536870913]],[\"^84\",[122,\"^1K\",\"^87\",536870913]],[\"^84\",[123,\"^48\",1775980635494,536870913]],[\"^84\",[123,\"^7M\",\"enable property history\",536870913]],[\"^84\",[123,\"^7@\",\"d3BH9\",536870913]],[\"^84\",[123,\"^69\",124,536870913]],[\"^84\",[123,\"^6:\",\"Enable property history\",536870913]],[\"^84\",[123,\"^3;\",1775980635494,536870913]],[\"^84\",[123,\"^2T\",\"~u00000002-8058-5960-2000-000000000000\",536870913]],[\"^84\",[123,\"^<\",\"^=\",536870913]],[\"^84\",[123,\"^;\",\"^4N\",536870913]],[\"^84\",[123,\"^>\",true,536870913]],[\"^84\",[123,\"^57\",true,536870913]],[\"^84\",[123,\"^2A\",162,536870913]],[\"^84\",[123,\"^X\",true,536870913]],[\"^84\",[123,\"^1K\",\"^8?\",536870913]],[\"^84\",[123,\"^44\",\"^86\",536870913]],[\"^84\",[124,\"^48\",1775980635496,536870913]],[\"^84\",[124,\"^7M\",\"property\",536870913]],[\"^84\",[124,\"^69\",14,536870913]],[\"^84\",[124,\"^6:\",\"Property\",536870913]],[\"^84\",[124,\"^3;\",1775980635496,536870913]],[\"^84\",[124,\"^2T\",\"~u00000002-1038-7670-4800-000000000000\",536870913]],[\"^84\",[124,\"^;\",\"^30\",536870913]],[\"^84\",[124,\"^57\",true,536870913]],[\"^84\",[124,\"^3K\",89,536870913]],[\"^84\",[125,\"^48\",1775980635489,536870913]],[\"^84\",[125,\"^7M\",\"page tags\",536870913]],[\"^84\",[125,\"^7@\",\"d3BFh\",536870913]],[\"^84\",[125,\"^69\",124,536870913]],[\"^84\",[125,\"^6:\",\"Page Tags\",536870913]],[\"^84\",[125,\"^3;\",1775980635489,536870913]],[\"^84\",[125,\"^2T\",\"~u00000002-2133-5311-8500-000000000000\",536870913]],[\"^84\",[125,\"^<\",\"^14\",536870913]],[\"^84\",[125,\"^;\",\"^4X\",536870913]],[\"^84\",[125,\"^>\",true,536870913]],[\"^84\",[125,\"^C\",\"^D\",536870913]],[\"^84\",[125,\"^57\",true,536870913]],[\"^84\",[125,\"^2A\",164,536870913]],[\"^84\",[125,\"^X\",true,536870913]],[\"^84\",[125,\"^1K\",\"^89\",536870913]],[\"^84\",[125,\"^44\",\"^89\",536870913]],[\"^84\",[126,\"^L\",26,536870913]],[\"^84\",[126,\"^48\",1775980635491,536870913]],[\"^84\",[126,\"^7@\",\"d3BG8\",536870913]],[\"^84\",[126,\"^4S\",26,536870913]],[\"^84\",[126,\"^3X\",26,536870913]],[\"^84\",[126,\"^6:\",\"Backlog\",536870913]],[\"^84\",[126,\"^3;\",1775980635491,536870913]],[\"^84\",[126,\"^2T\",\"~u00000002-7233-3491-0000-000000000000\",536870913]],[\"^84\",[126,\"^;\",\"^4A\",536870913]],[\"^84\",[126,\"^57\",true,536870913]],[\"^84\",[126,\"^6?\",26,536870913]],[\"^84\",[126,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Backlog\"],536870913]],[\"^84\",[127,\"^48\",1775980635492,536870913]],[\"^84\",[127,\"^7M\",\"view type\",536870913]],[\"^84\",[127,\"^7@\",\"d3BGd\",536870913]],[\"^84\",[127,\"^69\",124,536870913]],[\"^84\",[127,\"^6:\",\"View Type\",536870913]],[\"^84\",[127,\"^3;\",1775980635492,536870913]],[\"^84\",[127,\"^2T\",\"~u00000002-2182-3760-7000-000000000000\",536870913]],[\"^84\",[127,\"^<\",\"^=\",536870913]],[\"^84\",[127,\"^;\",\"^2J\",536870913]],[\"^84\",[127,\"^>\",true,536870913]],[\"^84\",[127,\"^C\",\"^D\",536870913]],[\"^84\",[127,\"^57\",true,536870913]],[\"^84\",[127,\"^5L\",70,536870913]],[\"^84\",[127,\"^5J\",true,536870913]],[\"^84\",[127,\"^X\",false,536870913]],[\"^84\",[127,\"^1K\",\"^8>\",536870913]],[\"^84\",[128,\"^48\",1775980635493,536870913]],[\"^84\",[128,\"^7M\",\"this view belongs to\",536870913]],[\"^84\",[128,\"^7@\",\"d3BGr\",536870913]],[\"^84\",[128,\"^69\",124,536870913]],[\"^84\",[128,\"^6:\",\"This view belongs to\",536870913]],[\"^84\",[128,\"^3;\",1775980635493,536870913]],[\"^84\",[128,\"^2T\",\"~u00000002-3627-4319-0000-000000000000\",536870913]],[\"^84\",[128,\"^<\",\"^=\",536870913]],[\"^84\",[128,\"^;\",\"^9\",536870913]],[\"^84\",[128,\"^>\",true,536870913]],[\"^84\",[128,\"^C\",\"^D\",536870913]],[\"^84\",[128,\"^57\",true,536870913]],[\"^84\",[128,\"^5J\",true,536870913]],[\"^84\",[128,\"^X\",false,536870913]],[\"^84\",[128,\"^1K\",\"^85\",536870913]],[\"^84\",[129,\"^48\",1775980635494,536870913]],[\"^84\",[129,\"^7M\",\"user avatar\",536870913]],[\"^84\",[129,\"^7@\",\"d3BH8\",536870913]],[\"^84\",[129,\"^69\",124,536870913]],[\"^84\",[129,\"^6:\",\"User Avatar\",536870913]],[\"^84\",[129,\"^3;\",1775980635494,536870913]],[\"^84\",[129,\"^2T\",\"~u00000002-4165-4885-8000-000000000000\",536870913]],[\"^84\",[129,\"^<\",\"^=\",536870913]],[\"^84\",[129,\"^;\",\"^O\",536870913]],[\"^84\",[129,\"^>\",true,536870913]],[\"^84\",[129,\"^57\",true,536870913]],[\"^84\",[129,\"^5J\",false,536870913]],[\"^84\",[129,\"^X\",true,536870913]],[\"^84\",[129,\"^1K\",\"^87\",536870913]],[\"^84\",[130,\"^48\",1775980635494,536870913]],[\"^84\",[130,\"^7M\",\"user name\",536870913]],[\"^84\",[130,\"^7@\",\"d3BH6\",536870913]],[\"^84\",[130,\"^69\",124,536870913]],[\"^84\",[130,\"^6:\",\"User Name\",536870913]],[\"^84\",[130,\"^3;\",1775980635494,536870913]],[\"^84\",[130,\"^2T\",\"~u00000002-1360-0260-1600-000000000000\",536870913]],[\"^84\",[130,\"^<\",\"^=\",536870913]],[\"^84\",[130,\"^;\",\"^E\",536870913]],[\"^84\",[130,\"^>\",true,536870913]],[\"^84\",[130,\"^57\",true,536870913]],[\"^84\",[130,\"^5J\",false,536870913]],[\"^84\",[130,\"^X\",true,536870913]],[\"^84\",[130,\"^1K\",\"^87\",536870913]],[\"^84\",[131,\"^48\",1775980635493,536870913]],[\"^84\",[131,\"^7M\",\"file size\",536870913]],[\"^84\",[131,\"^7@\",\"d3BGv\",536870913]],[\"^84\",[131,\"^69\",124,536870913]],[\"^84\",[131,\"^6:\",\"File Size\",536870913]],[\"^84\",[131,\"^3;\",1775980635493,536870913]],[\"^84\",[131,\"^2T\",\"~u00000002-1167-8621-9000-000000000000\",536870913]],[\"^84\",[131,\"^<\",\"^=\",536870913]],[\"^84\",[131,\"^;\",\"^1?\",536870913]],[\"^84\",[131,\"^>\",true,536870913]],[\"^84\",[131,\"^57\",true,536870913]],[\"^84\",[131,\"^5J\",true,536870913]],[\"^84\",[131,\"^X\",false,536870913]],[\"^84\",[131,\"^1K\",\"^8C\",536870913]],[\"^84\",[132,\"^48\",1775980635489,536870913]],[\"^84\",[132,\"^7M\",\"bidirectional property title\",536870913]],[\"^84\",[132,\"^7@\",\"d3BFa\",536870913]],[\"^84\",[132,\"^69\",124,536870913]],[\"^84\",[132,\"^6:\",\"Bidirectional property title\",536870913]],[\"^84\",[132,\"^3;\",1775980635489,536870913]],[\"^84\",[132,\"^2T\",\"~u00000002-6050-5418-7000-000000000000\",536870913]],[\"^84\",[132,\"^<\",\"^=\",536870913]],[\"^84\",[132,\"^;\",\"^1I\",536870913]],[\"^84\",[132,\"^>\",true,536870913]],[\"^84\",[132,\"^57\",true,536870913]],[\"^84\",[132,\"^X\",true,536870913]],[\"^84\",[132,\"^1K\",\"^87\",536870913]],[\"^84\",[132,\"^44\",\"^8@\",536870913]],[\"^84\",[133,\"^48\",1775980635489,536870913]],[\"^84\",[133,\"^7M\",\"hide from node\",536870913]],[\"^84\",[133,\"^7@\",\"d3BFf\",536870913]],[\"^84\",[133,\"^69\",124,536870913]],[\"^84\",[133,\"^6:\",\"Hide from Node\",536870913]],[\"^84\",[133,\"^3;\",1775980635489,536870913]],[\"^84\",[133,\"^2T\",\"~u00000002-2610-3727-0000-000000000000\",536870913]],[\"^84\",[133,\"^<\",\"^=\",536870913]],[\"^84\",[133,\"^;\",\"^1X\",536870913]],[\"^84\",[133,\"^>\",true,536870913]],[\"^84\",[133,\"^57\",true,536870913]],[\"^84\",[133,\"^X\",true,536870913]],[\"^84\",[133,\"^1K\",\"^8?\",536870913]],[\"^84\",[133,\"^44\",\"^8@\",536870913]],[\"^84\",[134,\"^48\",1775980635492,536870913]],[\"^84\",[134,\"^7M\",\"repeating temporal property\",536870913]],[\"^84\",[134,\"^7@\",\"d3BGX\",536870913]],[\"^84\",[134,\"^69\",124,536870913]],[\"^84\",[134,\"^6:\",\"Repeating Temporal Property\",536870913]],[\"^84\",[134,\"^3;\",1775980635492,536870913]],[\"^84\",[134,\"^2T\",\"~u00000002-8346-1078-4000-000000000000\",536870913]],[\"^84\",[134,\"^<\",\"^=\",536870913]],[\"^84\",[134,\"^;\",\"^B\",536870913]],[\"^84\",[134,\"^>\",true,536870913]],[\"^84\",[134,\"^C\",\"^D\",536870913]],[\"^84\",[134,\"^57\",true,536870913]],[\"^84\",[134,\"^5J\",true,536870913]],[\"^84\",[134,\"^1K\",\"^86\",536870913]],[\"^84\",[135,\"^L\",13,536870913]],[\"^84\",[135,\"^48\",1775980635492,536870913]],[\"^84\",[135,\"^7@\",\"d3BGQ\",536870913]],[\"^84\",[135,\"^4S\",13,536870913]],[\"^84\",[135,\"^3X\",13,536870913]],[\"^84\",[135,\"^6:\",\"Minute\",536870913]],[\"^84\",[135,\"^3;\",1775980635492,536870913]],[\"^84\",[135,\"^2T\",\"~u00000002-1513-6550-8500-000000000000\",536870913]],[\"^84\",[135,\"^;\",\"^2E\",536870913]],[\"^84\",[135,\"^57\",true,536870913]],[\"^84\",[135,\"^6?\",13,536870913]],[\"^84\",[136,\"^48\",1775980635489,536870913]],[\"^84\",[136,\"^7M\",\"query\",536870913]],[\"^84\",[136,\"^7@\",\"d3BFg\",536870913]],[\"^84\",[136,\"^69\",124,536870913]],[\"^84\",[136,\"^6:\",\"Query\",536870913]],[\"^84\",[136,\"^3;\",1775980635489,536870913]],[\"^84\",[136,\"^2T\",\"~u00000002-9741-4126-0000-000000000000\",536870913]],[\"^84\",[136,\"^<\",\"^=\",536870913]],[\"^84\",[136,\"^;\",\"^2X\",536870913]],[\"^84\",[136,\"^>\",true,536870913]],[\"^84\",[136,\"^C\",\"^D\",536870913]],[\"^84\",[136,\"^57\",true,536870913]],[\"^84\",[136,\"^5J\",true,536870913]],[\"^84\",[136,\"^X\",true,536870913]],[\"^84\",[136,\"^1K\",\"^8>\",536870913]],[\"^84\",[136,\"^44\",\"^8E\",536870913]],[\"^84\",[137,\"^48\",1775980635494,536870913]],[\"^84\",[137,\"^7M\",\"user email\",536870913]],[\"^84\",[137,\"^7@\",\"d3BH7\",536870913]],[\"^84\",[137,\"^69\",124,536870913]],[\"^84\",[137,\"^6:\",\"User Email\",536870913]],[\"^84\",[137,\"^3;\",1775980635494,536870913]],[\"^84\",[137,\"^2T\",\"~u00000002-1655-2060-6300-000000000000\",536870913]],[\"^84\",[137,\"^<\",\"^=\",536870913]],[\"^84\",[137,\"^;\",\"^38\",536870913]],[\"^84\",[137,\"^>\",true,536870913]],[\"^84\",[137,\"^57\",true,536870913]],[\"^84\",[137,\"^5J\",false,536870913]],[\"^84\",[137,\"^X\",true,536870913]],[\"^84\",[137,\"^1K\",\"^87\",536870913]],[\"^84\",[138,\"^48\",1775980635488,536870913]],[\"^84\",[138,\"^7M\",\"node collapsed?\",536870913]],[\"^84\",[138,\"^7@\",\"d3BFJ\",536870913]],[\"^84\",[138,\"^69\",124,536870913]],[\"^84\",[138,\"^6:\",\"Node collapsed?\",536870913]],[\"^84\",[138,\"^3;\",1775980635488,536870913]],[\"^84\",[138,\"^2T\",\"~u00000002-2140-2109-9100-000000000000\",536870913]],[\"^84\",[138,\"^<\",\"^=\",536870913]],[\"^84\",[138,\"^;\",\"^3D\",536870913]],[\"^84\",[138,\"^>\",true,536870913]],[\"^84\",[138,\"^57\",true,536870913]],[\"^84\",[138,\"^5J\",true,536870913]],[\"^84\",[138,\"^X\",false,536870913]],[\"^84\",[138,\"^1K\",\"^8?\",536870913]],[\"^84\",[139,\"^L\",13,536870913]],[\"^84\",[139,\"^48\",1775980635492,536870913]],[\"^84\",[139,\"^7@\",\"d3BGR\",536870913]],[\"^84\",[139,\"^4S\",13,536870913]],[\"^84\",[139,\"^3X\",13,536870913]],[\"^84\",[139,\"^6:\",\"Hour\",536870913]],[\"^84\",[139,\"^3;\",1775980635492,536870913]],[\"^84\",[139,\"^2T\",\"~u00000002-1438-8849-5400-000000000000\",536870913]],[\"^84\",[139,\"^;\",\"^3O\",536870913]],[\"^84\",[139,\"^57\",true,536870913]],[\"^84\",[139,\"^6?\",13,536870913]],[\"^84\",[140,\"^48\",1775980635490,536870913]],[\"^84\",[140,\"^7M\",\"tldraw page\",536870913]],[\"^84\",[140,\"^7@\",\"d3BG0\",536870913]],[\"^84\",[140,\"^69\",124,536870913]],[\"^84\",[140,\"^6:\",\"Tldraw Page\",536870913]],[\"^84\",[140,\"^3;\",1775980635490,536870913]],[\"^84\",[140,\"^2T\",\"~u00000002-3546-2145-7000-000000000000\",536870913]],[\"^84\",[140,\"^<\",\"^=\",536870913]],[\"^84\",[140,\"^;\",\"^42\",536870913]],[\"^84\",[140,\"^>\",true,536870913]],[\"^84\",[140,\"^57\",true,536870913]],[\"^84\",[140,\"^5J\",true,536870913]],[\"^84\",[140,\"^1K\",\"^88\",536870913]],[\"^84\",[141,\"^;\",\"^31\",536870913]],[\"^84\",[141,\"^28\",\"bf04d4cf5-dirty\",536870913]],[\"^84\",[142,\"^48\",1775980635496,536870913]],[\"^84\",[142,\"^7M\",\"query\",536870913]],[\"^84\",[142,\"^69\",14,536870913]],[\"^84\",[142,\"^6:\",\"Query\",536870913]],[\"^84\",[142,\"^3;\",1775980635496,536870913]],[\"^84\",[142,\"^2T\",\"~u00000002-2324-8016-6000-000000000000\",536870913]],[\"^84\",[142,\"^;\",\"^4I\",536870913]],[\"^84\",[142,\"^57\",true,536870913]],[\"^84\",[142,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[142,\"^3K\",89,536870913]],[\"^84\",[142,\"^4K\",136,536870913]],[\"^84\",[143,\"^48\",1775980635491,536870913]],[\"^84\",[143,\"^7M\",\"choice checkbox state\",536870913]],[\"^84\",[143,\"^7@\",\"d3BG3\",536870913]],[\"^84\",[143,\"^69\",124,536870913]],[\"^84\",[143,\"^6:\",\"Choice checkbox state\",536870913]],[\"^84\",[143,\"^3;\",1775980635491,536870913]],[\"^84\",[143,\"^2T\",\"~u00000002-1272-4228-6300-000000000000\",536870913]],[\"^84\",[143,\"^<\",\"^=\",536870913]],[\"^84\",[143,\"^;\",\"^4W\",536870913]],[\"^84\",[143,\"^>\",true,536870913]],[\"^84\",[143,\"^57\",true,536870913]],[\"^84\",[143,\"^5J\",true,536870913]],[\"^84\",[143,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^48\",1775980635489,536870913]],[\"^84\",[144,\"^7M\",\"hide empty value\",536870913]],[\"^84\",[144,\"^7@\",\"d3BFd\",536870913]],[\"^84\",[144,\"^69\",124,536870913]],[\"^84\",[144,\"^6:\",\"Hide empty value\",536870913]],[\"^84\",[144,\"^3;\",1775980635489,536870913]],[\"^84\",[144,\"^2T\",\"~u00000002-2062-3258-9900-000000000000\",536870913]],[\"^84\",[144,\"^<\",\"^=\",536870913]],[\"^84\",[144,\"^;\",\"^3P\",536870913]],[\"^84\",[144,\"^>\",true,536870913]],[\"^84\",[144,\"^57\",true,536870913]],[\"^84\",[144,\"^2A\",160,536870913]],[\"^84\",[144,\"^X\",true,536870913]],[\"^84\",[144,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^44\",\"^86\",536870913]],[\"^84\",[145,\"^48\",1775980635488,536870913]],[\"^84\",[145,\"^7M\",\"node updated at\",536870913]],[\"^84\",[145,\"^7@\",\"d3BFR\",536870913]],[\"^84\",[145,\"^69\",124,536870913]],[\"^84\",[145,\"^6:\",\"Node updated at\",536870913]],[\"^84\",[145,\"^3;\",1775980635488,536870913]],[\"^84\",[145,\"^2T\",\"~u00000002-1516-5505-5100-000000000000\",536870913]],[\"^84\",[145,\"^<\",\"^=\",536870913]],[\"^84\",[145,\"^;\",\"^3;\",536870913]],[\"^84\",[145,\"^>\",true,536870913]],[\"^84\",[145,\"^57\",true,536870913]],[\"^84\",[145,\"^5J\",true,536870913]],[\"^84\",[145,\"^X\",false,536870913]],[\"^84\",[145,\"^1K\",\"^8A\",536870913]],[\"^84\",[146,\"^48\",1775980635493,536870913]],[\"^84\",[146,\"^7M\",\"external url\",536870913]],[\"^84\",[146,\"^7@\",\"d3BGt\",536870913]],[\"^84\",[146,\"^69\",124,536870913]],[\"^84\",[146,\"^6:\",\"External URL\",536870913]],[\"^84\",[146,\"^3;\",1775980635493,536870913]],[\"^84\",[146,\"^2T\",\"~u00000002-1364-7751-6300-000000000000\",536870913]],[\"^84\",[146,\"^<\",\"^=\",536870913]],[\"^84\",[146,\"^;\",\"^20\",536870913]],[\"^84\",[146,\"^>\",true,536870913]],[\"^84\",[146,\"^57\",true,536870913]],[\"^84\",[146,\"^5J\",false,536870913]],[\"^84\",[146,\"^X\",true,536870913]],[\"^84\",[146,\"^1K\",\"^87\",536870913]],[\"^84\",[147,\"^48\",1775980635495,536870913]],[\"^84\",[147,\"^7M\",\"apply template to tags\",536870913]],[\"^84\",[147,\"^7@\",\"d3BHO\",536870913]],[\"^84\",[147,\"^69\",124,536870913]],[\"^84\",[147,\"^6:\",\"Apply template to tags\",536870913]],[\"^84\",[147,\"^3;\",1775980635495,536870913]],[\"^84\",[147,\"^2T\",\"~u00000002-4291-2432-2000-000000000000\",536870913]],[\"^84\",[147,\"^<\",\"^14\",536870913]],[\"^84\",[147,\"^;\",\"^5X\",536870913]],[\"^84\",[147,\"^>\",true,536870913]],[\"^84\",[147,\"^C\",\"^D\",536870913]],[\"^84\",[147,\"^57\",true,536870913]],[\"^84\",[147,\"^X\",true,536870913]],[\"^84\",[147,\"^1K\",\"^8@\",536870913]],[\"^84\",[148,\"^L\",26,536870913]],[\"^84\",[148,\"^48\",1775980635491,536870913]],[\"^84\",[148,\"^7@\",\"d3BGB\",536870913]],[\"^84\",[148,\"^4S\",26,536870913]],[\"^84\",[148,\"^3X\",26,536870913]],[\"^84\",[148,\"^6:\",\"In Review\",536870913]],[\"^84\",[148,\"^3;\",1775980635491,536870913]],[\"^84\",[148,\"^2T\",\"~u00000002-1870-0014-4300-000000000000\",536870913]],[\"^84\",[148,\"^;\",\"^66\",536870913]],[\"^84\",[148,\"^57\",true,536870913]],[\"^84\",[148,\"^6?\",26,536870913]],[\"^84\",[148,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"InReview\"],536870913]],[\"^84\",[149,\"^48\",1775980635492,536870913]],[\"^84\",[149,\"^7M\",\"published url\",536870913]],[\"^84\",[149,\"^7@\",\"d3BGb\",536870913]],[\"^84\",[149,\"^69\",124,536870913]],[\"^84\",[149,\"^6:\",\"Published URL\",536870913]],[\"^84\",[149,\"^3;\",1775980635492,536870913]],[\"^84\",[149,\"^2T\",\"~u00000002-2045-1825-0100-000000000000\",536870913]],[\"^84\",[149,\"^<\",\"^=\",536870913]],[\"^84\",[149,\"^;\",\"^1[\",536870913]],[\"^84\",[149,\"^>\",true,536870913]],[\"^84\",[149,\"^C\",\"^D\",536870913]],[\"^84\",[149,\"^57\",true,536870913]],[\"^84\",[149,\"^X\",true,536870913]],[\"^84\",[149,\"^1K\",\"~:url\",536870913]],[\"^84\",[149,\"^44\",\"^89\",536870913]],[\"^84\",[150,\"^L\",127,536870913]],[\"^84\",[150,\"^48\",1775980635492,536870913]],[\"^84\",[150,\"^7@\",\"d3BGg\",536870913]],[\"^84\",[150,\"^4S\",127,536870913]],[\"^84\",[150,\"^3X\",127,536870913]],[\"^84\",[150,\"^6:\",\"Gallery View\",536870913]],[\"^84\",[150,\"^3;\",1775980635492,536870913]],[\"^84\",[150,\"^2T\",\"~u00000002-1506-0511-2000-000000000000\",536870913]],[\"^84\",[150,\"^;\",\"^6L\",536870913]],[\"^84\",[150,\"^57\",true,536870913]],[\"^84\",[150,\"^6?\",127,536870913]],[\"^84\",[150,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"layout-grid\"],536870913]],[\"^84\",[151,\"^2T\",\"~u00000004-1595-0218-3700-000000000000\",536870913]],[\"^84\",[151,\"^;\",\"^5Y\",536870913]],[\"^84\",[152,\"^48\",1775980635496,536870913]],[\"^84\",[152,\"^7M\",\"task\",536870913]],[\"^84\",[152,\"^69\",14,536870913]],[\"^84\",[152,\"^6:\",\"Task\",536870913]],[\"^84\",[152,\"^3;\",1775980635496,536870913]],[\"^84\",[152,\"^2T\",\"~u00000002-1282-1814-5700-000000000000\",536870913]],[\"^84\",[152,\"^;\",\"^4Z\",536870913]],[\"^84\",[152,\"^57\",true,536870913]],[\"^84\",[152,\"^3K\",89,536870913]],[\"^84\",[152,\"^4K\",26,536870913]],[\"^84\",[152,\"^4K\",32,536870913]],[\"^84\",[152,\"^4K\",75,536870913]],[\"^84\",[152,\"^4K\",155,536870913]],[\"^84\",[153,\"^L\",32,536870913]],[\"^84\",[153,\"^48\",1775980635491,536870913]],[\"^84\",[153,\"^7@\",\"d3BGG\",536870913]],[\"^84\",[153,\"^4S\",32,536870913]],[\"^84\",[153,\"^3X\",32,536870913]],[\"^84\",[153,\"^6:\",\"Medium\",536870913]],[\"^84\",[153,\"^3;\",1775980635491,536870913]],[\"^84\",[153,\"^2T\",\"~u00000002-1829-3222-7800-000000000000\",536870913]],[\"^84\",[153,\"^;\",\"^7:\",536870913]],[\"^84\",[153,\"^57\",true,536870913]],[\"^84\",[153,\"^6?\",32,536870913]],[\"^84\",[153,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlMedium\"],536870913]],[\"^84\",[154,\"^48\",1775980635486,536870913]],[\"^84\",[154,\"^7M\",\"property type\",536870913]],[\"^84\",[154,\"^7@\",\"d3BF6\",536870913]],[\"^84\",[154,\"^69\",124,536870913]],[\"^84\",[154,\"^6:\",\"Property type\",536870913]],[\"^84\",[154,\"^3;\",1775980635486,536870913]],[\"^84\",[154,\"^2T\",\"~u00000002-8384-2404-0000-000000000000\",536870913]],[\"^84\",[154,\"^<\",\"^=\",536870913]],[\"^84\",[154,\"^;\",\"^1K\",536870913]],[\"^84\",[154,\"^>\",true,536870913]],[\"^84\",[154,\"^57\",true,536870913]],[\"^84\",[154,\"^5J\",true,536870913]],[\"^84\",[154,\"^1K\",\"^8D\",536870913]],[\"^84\",[155,\"^48\",1775980635491,536870913]],[\"^84\",[155,\"^7M\",\"scheduled\",536870913]],[\"^84\",[155,\"^7@\",\"d3BGL\",536870913]],[\"^84\",[155,\"^69\",124,536870913]],[\"^84\",[155,\"^6:\",\"Scheduled\",536870913]],[\"^84\",[155,\"^3;\",1775980635491,536870913]],[\"^84\",[155,\"^2T\",\"~u00000002-1644-5209-4300-000000000000\",536870913]],[\"^84\",[155,\"^<\",\"^=\",536870913]],[\"^84\",[155,\"^;\",\"^4Y\",536870913]],[\"^84\",[155,\"^>\",true,536870913]],[\"^84\",[155,\"^57\",true,536870913]],[\"^84\",[155,\"^2A\",177,536870913]],[\"^84\",[155,\"^3P\",true,536870913]],[\"^84\",[155,\"^X\",true,536870913]],[\"^84\",[155,\"^1K\",\"^8A\",536870913]],[\"^84\",[155,\"^@\",\"^8I\",536870913]],[\"^84\",[156,\"^48\",1775980635489,536870913]],[\"^84\",[156,\"^7M\",\"default value\",536870913]],[\"^84\",[156,\"^7@\",\"d3BFV\",536870913]],[\"^84\",[156,\"^69\",124,536870913]],[\"^84\",[156,\"^6:\",\"Default value\",536870913]],[\"^84\",[156,\"^3;\",1775980635489,536870913]],[\"^84\",[156,\"^2T\",\"~u00000002-8920-7966-2000-000000000000\",536870913]],[\"^84\",[156,\"^<\",\"^=\",536870913]],[\"^84\",[156,\"^;\",\"^5L\",536870913]],[\"^84\",[156,\"^>\",true,536870913]],[\"^84\",[156,\"^C\",\"^D\",536870913]],[\"^84\",[156,\"^57\",true,536870913]],[\"^84\",[156,\"^5J\",true,536870913]],[\"^84\",[156,\"^X\",false,536870913]],[\"^84\",[156,\"^1K\",\"^8=\",536870913]],[\"^84\",[156,\"^44\",\"^86\",536870913]],[\"^84\",[157,\"^48\",1775980635495,536870913]],[\"^84\",[157,\"^7M\",\"deleted by\",536870913]],[\"^84\",[157,\"^7@\",\"d3BHH\",536870913]],[\"^84\",[157,\"^69\",124,536870913]],[\"^84\",[157,\"^6:\",\"Deleted by\",536870913]],[\"^84\",[157,\"^3;\",1775980635495,536870913]],[\"^84\",[157,\"^2T\",\"~u00000002-1998-0431-1200-000000000000\",536870913]],[\"^84\",[157,\"^<\",\"^=\",536870913]],[\"^84\",[157,\"^;\",\"^6Y\",536870913]],[\"^84\",[157,\"^>\",true,536870913]],[\"^84\",[157,\"^C\",\"^D\",536870913]],[\"^84\",[157,\"^57\",true,536870913]],[\"^84\",[157,\"^5J\",true,536870913]],[\"^84\",[157,\"^X\",false,536870913]],[\"^84\",[157,\"^1K\",\"^8=\",536870913]],[\"^84\",[158,\"^48\",1775980635489,536870913]],[\"^84\",[158,\"^7M\",\"extends\",536870913]],[\"^84\",[158,\"^7@\",\"d3BFX\",536870913]],[\"^84\",[158,\"^69\",124,536870913]],[\"^84\",[158,\"^6:\",\"Extends\",536870913]],[\"^84\",[158,\"^3;\",1775980635489,536870913]],[\"^84\",[158,\"^2T\",\"~u00000002-7475-9380-3000-000000000000\",536870913]],[\"^84\",[158,\"^<\",\"^14\",536870913]],[\"^84\",[158,\"^;\",\"^3K\",536870913]],[\"^84\",[158,\"^>\",true,536870913]],[\"^84\",[158,\"^C\",\"^D\",536870913]],[\"^84\",[158,\"^57\",true,536870913]],[\"^84\",[158,\"^2A\",163,536870913]],[\"^84\",[158,\"^X\",true,536870913]],[\"^84\",[158,\"^1K\",\"^8@\",536870913]],[\"^84\",[158,\"^44\",\"^8@\",536870913]],[\"^84\",[159,\"^48\",1775980635493,536870913]],[\"^84\",[159,\"^7M\",\"view sorting\",536870913]],[\"^84\",[159,\"^7@\",\"d3BGl\",536870913]],[\"^84\",[159,\"^69\",124,536870913]],[\"^84\",[159,\"^6:\",\"View sorting\",536870913]],[\"^84\",[159,\"^3;\",1775980635493,536870913]],[\"^84\",[159,\"^2T\",\"~u00000002-2081-0259-4000-000000000000\",536870913]],[\"^84\",[159,\"^<\",\"^=\",536870913]],[\"^84\",[159,\"^;\",\"^12\",536870913]],[\"^84\",[159,\"^>\",true,536870913]],[\"^84\",[159,\"^57\",true,536870913]],[\"^84\",[159,\"^5J\",true,536870913]],[\"^84\",[159,\"^X\",false,536870913]],[\"^84\",[159,\"^1K\",\"^8L\",536870913]],[\"^84\",[160,\"^48\",1775980635489,536870913]],[\"^84\",[160,\"^7@\",\"d3BFe\",536870913]],[\"^84\",[160,\"^4S\",144,536870913]],[\"^84\",[160,\"^3X\",144,536870913]],[\"^84\",[160,\"^6:\",\"Hides a property's value on any node when empty e.g. when a property appears on a node through a tag.\",536870913]],[\"^84\",[160,\"^3;\",1775980635489,536870913]],[\"^84\",[160,\"^2T\",\"~u00000004-1118-8585-8000-000000000000\",536870913]],[\"^84\",[160,\"^57\",true,536870913]],[\"^84\",[160,\"^6?\",71,536870913]],[\"^84\",[161,\"^2T\",\"~u00000004-2116-2824-5200-000000000000\",536870913]],[\"^84\",[161,\"^1M\",\"\",536870913]],[\"^84\",[161,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^7N\",\"logseq/publish.js\",536870913]],[\"^84\",[162,\"^48\",1775980635494,536870913]],[\"^84\",[162,\"^7@\",\"d3BHA\",536870913]],[\"^84\",[162,\"^4S\",123,536870913]],[\"^84\",[162,\"^3X\",123,536870913]],[\"^84\",[162,\"^6:\",\"Records history anytime a property's value changes on a node.\",536870913]],[\"^84\",[162,\"^3;\",1775980635494,536870913]],[\"^84\",[162,\"^2T\",\"~u00000004-1438-1481-0000-000000000000\",536870913]],[\"^84\",[162,\"^57\",true,536870913]],[\"^84\",[162,\"^6?\",71,536870913]],[\"^84\",[163,\"^48\",1775980635489,536870913]],[\"^84\",[163,\"^7@\",\"d3BFY\",536870913]],[\"^84\",[163,\"^4S\",158,536870913]],[\"^84\",[163,\"^3X\",158,536870913]],[\"^84\",[163,\"^6:\",\"This enables tags to inherit properties from other tags\",536870913]],[\"^84\",[163,\"^3;\",1775980635489,536870913]],[\"^84\",[163,\"^2T\",\"~u00000004-4485-6574-6000-000000000000\",536870913]],[\"^84\",[163,\"^57\",true,536870913]],[\"^84\",[163,\"^6?\",71,536870913]],[\"^84\",[164,\"^48\",1775980635489,536870913]],[\"^84\",[164,\"^7@\",\"d3BFi\",536870913]],[\"^84\",[164,\"^4S\",125,536870913]],[\"^84\",[164,\"^3X\",125,536870913]],[\"^84\",[164,\"^6:\",\"Provides a way for a page to associate to another page i.e. backward compatible tagging.\",536870913]],[\"^84\",[164,\"^3;\",1775980635489,536870913]],[\"^84\",[164,\"^2T\",\"~u00000004-1223-8671-8000-000000000000\",536870913]],[\"^84\",[164,\"^57\",true,536870913]],[\"^84\",[164,\"^6?\",71,536870913]],[\"^84\",[165,\"^48\",1775980635496,536870913]],[\"^84\",[165,\"^7M\",\"library\",536870913]],[\"^84\",[165,\"^69\",104,536870913]],[\"^84\",[165,\"^6:\",\"Library\",536870913]],[\"^84\",[165,\"^3;\",1775980635496,536870913]],[\"^84\",[165,\"^2T\",\"~u00000004-1294-7765-6000-000000000000\",536870913]],[\"^84\",[165,\"^57\",true,536870913]],[\"^84\",[166,\"^48\",1775980635491,536870913]],[\"^84\",[166,\"^7@\",\"d3BGK\",536870913]],[\"^84\",[166,\"^4S\",75,536870913]],[\"^84\",[166,\"^3X\",75,536870913]],[\"^84\",[166,\"^6:\",\"Use it to finish something at a specific date(time).\",536870913]],[\"^84\",[166,\"^3;\",1775980635491,536870913]],[\"^84\",[166,\"^2T\",\"~u00000004-1356-3664-2200-000000000000\",536870913]],[\"^84\",[166,\"^57\",true,536870913]],[\"^84\",[166,\"^6?\",71,536870913]],[\"^84\",[167,\"^48\",1775980635494,536870913]],[\"^84\",[167,\"^7@\",\"d3BH1\",536870913]],[\"^84\",[167,\"^4S\",67,536870913]],[\"^84\",[167,\"^3X\",67,536870913]],[\"^84\",[167,\"^6:\",\"Metadata of asset in remote storage\",536870913]],[\"^84\",[167,\"^3;\",1775980635494,536870913]],[\"^84\",[167,\"^2T\",\"~u00000004-5450-2102-9000-000000000000\",536870913]],[\"^84\",[167,\"^57\",true,536870913]],[\"^84\",[167,\"^6?\",71,536870913]],[\"^84\",[168,\"^48\",1775980635496,536870913]],[\"^84\",[168,\"^7M\",\"quick add\",536870913]],[\"^84\",[168,\"^69\",104,536870913]],[\"^84\",[168,\"^6:\",\"Quick add\",536870913]],[\"^84\",[168,\"^3;\",1775980635496,536870913]],[\"^84\",[168,\"^2T\",\"~u00000004-7336-8251-3000-000000000000\",536870913]],[\"^84\",[168,\"^57\",true,536870913]],[\"^84\",[168,\"^5J\",true,536870913]],[\"^84\",[169,\"^48\",1775980635489,536870913]],[\"^84\",[169,\"^7@\",\"d3BFc\",536870913]],[\"^84\",[169,\"^4S\",20,536870913]],[\"^84\",[169,\"^3X\",20,536870913]],[\"^84\",[169,\"^6:\",\"When enabled, this tag will show reverse nodes that link to the current node via properties.\",536870913]],[\"^84\",[169,\"^3;\",1775980635489,536870913]],[\"^84\",[169,\"^2T\",\"~u00000004-1830-0481-2500-000000000000\",536870913]],[\"^84\",[169,\"^57\",true,536870913]],[\"^84\",[169,\"^6?\",71,536870913]],[\"^84\",[170,\"^48\",1775980635496,536870913]],[\"^84\",[170,\"^7M\",\"$$$views\",536870913]],[\"^84\",[170,\"^69\",104,536870913]],[\"^84\",[170,\"^6:\",\"$$$views\",536870913]],[\"^84\",[170,\"^3;\",1775980635496,536870913]],[\"^84\",[170,\"^2T\",\"~u00000004-1906-3437-5800-000000000000\",536870913]],[\"^84\",[170,\"^57\",true,536870913]],[\"^84\",[170,\"^5J\",true,536870913]],[\"^84\",[171,\"^48\",1775980635496,536870913]],[\"^84\",[171,\"^7M\",\"contents\",536870913]],[\"^84\",[171,\"^69\",104,536870913]],[\"^84\",[171,\"^6:\",\"Contents\",536870913]],[\"^84\",[171,\"^3;\",1775980635496,536870913]],[\"^84\",[171,\"^2T\",\"~u00000004-1690-2597-3200-000000000000\",536870913]],[\"^84\",[171,\"^57\",true,536870913]],[\"^84\",[172,\"^2T\",\"~u00000004-1713-4660-3800-000000000000\",536870913]],[\"^84\",[172,\"^1M\",\"\",536870913]],[\"^84\",[172,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^7N\",\"logseq/custom.css\",536870913]],[\"^84\",[173,\"^48\",1775980635496,536870913]],[\"^84\",[173,\"^7M\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^69\",104,536870913]],[\"^84\",[173,\"^6:\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^3;\",1775980635496,536870913]],[\"^84\",[173,\"^2T\",\"~u00000004-1018-5888-4100-000000000000\",536870913]],[\"^84\",[173,\"^57\",true,536870913]],[\"^84\",[173,\"^5J\",true,536870913]],[\"^84\",[174,\"^2T\",\"~u00000004-1335-6485-2300-000000000000\",536870913]],[\"^84\",[174,\"^1M\",\"\",536870913]],[\"^84\",[174,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^7N\",\"logseq/custom.js\",536870913]],[\"^84\",[175,\"^48\",1775980635491,536870913]],[\"^84\",[175,\"^7@\",\"d3BGO\",536870913]],[\"^84\",[175,\"^4S\",95,536870913]],[\"^84\",[175,\"^3X\",95,536870913]],[\"^84\",[175,\"^3;\",1775980635491,536870913]],[\"^84\",[175,\"^2T\",\"~u00000004-1654-1034-2600-000000000000\",536870913]],[\"^84\",[175,\"^57\",true,536870913]],[\"^84\",[175,\"^6?\",95,536870913]],[\"^84\",[175,\"^T\",1,536870913]],[\"^84\",[176,\"^2T\",\"~u00000004-3919-3813-3000-000000000000\",536870913]],[\"^84\",[176,\"^1M\",\"{:meta/version 1\\n\\n ;; Hide empty block properties\\n ;; Default value: false\\n ;; :ui/hide-empty-properties? false\\n\\n ;; Enable tooltip preview on hover.\\n ;; Default value: true\\n :ui/enable-tooltip? true\\n\\n ;; Display brackets [[]] around page references.\\n ;; Default value: true\\n ;; :ui/show-brackets? true\\n\\n ;; Display all lines of a block when referencing ((block)).\\n ;; Default value: false\\n :ui/show-full-blocks? false\\n\\n ;; Automatically expand block references when zooming in.\\n ;; Default value: true\\n :ui/auto-expand-block-refs? true\\n\\n ;; Disable accent marks when searching.\\n ;; After changing this setting, rebuild the search index by pressing (^C ^S).\\n ;; Default value: true\\n :feature/enable-search-remove-accents? true\\n\\n ;; Enable journals.\\n ;; Default value: true\\n ;; :feature/enable-journals? true\\n\\n ;; Enable flashcards.\\n ;; Default value: true\\n ;; :feature/enable-flashcards? true\\n\\n ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query.\\n ;; Default value: false\\n ;; :feature/disable-scheduled-and-deadline-query? false\\n\\n ;; Specify the number of days displayed in the future for\\n ;; the 'scheduled tasks and deadlines' query.\\n ;; Example usage:\\n ;; Display all scheduled and deadline blocks for the next 14 days:\\n ;; :scheduled/future-days 14\\n ;; Default value: 7\\n ;; :scheduled/future-days 7\\n\\n ;; Specify the first day of the week.\\n ;; Available options:\\n ;; - integer from 0 to 6 (Monday to Sunday)\\n ;; Default value: 6 (Sunday)\\n :start-of-week 6\\n\\n ;; Specify a custom CSS import.\\n ;; This option takes precedence over the local `logseq/custom.css` file.\\n ;; Example usage:\\n ;; :custom-css-url \\\"@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');\\\"\\n\\n ;; Specify a custom JS import.\\n ;; This option takes precedence over the local `logseq/custom.js` file.\\n ;; Example usage:\\n ;; :custom-js-url \\\"https://cdn.logseq.com/custom.js\\\"\\n\\n ;; Set bullet indentation when exporting\\n ;; Available options:\\n ;; - `:eight-spaces` as eight spaces\\n ;; - `:four-spaces` as four spaces\\n ;; - `:two-spaces` as two spaces\\n ;; - `:tab` as a tab character (default)\\n ;; :export/bullet-indentation :tab\\n\\n ;; Publish all pages within the Graph\\n ;; Regardless of whether individual pages have been marked as public.\\n ;; Default value: false\\n ;; :publishing/all-pages-public? false\\n\\n ;; Define the default home page and sidebar status.\\n ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden.\\n ;; The `:page` value represents the name of the page displayed at startup.\\n ;; Available options for `:sidebar` are:\\n ;; - \\\"Contents\\\" to display the Contents page in the right sidebar.\\n ;; - A specific page name to display in the right sidebar.\\n ;; - An array of multiple pages, e.g., [\\\"Contents\\\" \\\"Page A\\\" \\\"Page B\\\"].\\n ;; If `:sidebar` remains unset, the right sidebar will stay hidden.\\n ;; Examples:\\n ;; 1. Set \\\"Changelog\\\" as the home page and display \\\"Contents\\\" in the right sidebar:\\n ;; :default-home {:page \\\"Changelog\\\", :sidebar \\\"Contents\\\"}\\n ;; 2. Set \\\"Jun 3rd, 2021\\\" as the home page without the right sidebar:\\n ;; :default-home {:page \\\"Jun 3rd, 2021\\\"}\\n ;; 3. Set \\\"home\\\" as the home page and display multiple pages in the right sidebar:\\n ;; :default-home {:page \\\"home\\\", :sidebar [\\\"Page A\\\" \\\"Page B\\\"]}\\n\\n ;; Configure custom shortcuts.\\n ;; Syntax:\\n ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`.\\n ;; 2. A space between keys represents key chords, e.g., `t s` means\\n ;; pressing `t` followed by `s`.\\n ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac.\\n ;; 4. Use false to disable a specific shortcut.\\n ;; 5. You can define multiple bindings for a single action, e.g., [\\\"ctrl+j\\\" \\\"down\\\"].\\n ;; The full list of configurable shortcuts is available at:\\n ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs\\n ;; Example:\\n ;; :shortcuts\\n ;; {:editor/new-block \\\"enter\\\"\\n ;; :editor/new-line \\\"shift+enter\\\"\\n ;; :editor/insert-link \\\"mod+shift+k\\\"\\n ;; :editor/highlight false\\n ;; :ui/toggle-settings \\\"t s\\\"\\n ;; :editor/up [\\\"ctrl+k\\\" \\\"up\\\"]\\n ;; :editor/down [\\\"ctrl+j\\\" \\\"down\\\"]\\n ;; :editor/left [\\\"ctrl+h\\\" \\\"left\\\"]\\n ;; :editor/right [\\\"ctrl+l\\\" \\\"right\\\"]}\\n :shortcuts {}\\n\\n ;; Configure the behavior of pressing Enter in document mode.\\n ;; if set to true, pressing Enter will create a new block.\\n ;; Default value: false\\n :shortcut/doc-mode-enter-for-new-block? false\\n\\n ;; Block content larger than `block/title-max-length` will not be searchable\\n ;; or editable for performance.\\n ;; Default value: 10000\\n :block/title-max-length 10000\\n\\n ;; Display command documentation on hover.\\n ;; Default value: true\\n :ui/show-command-doc? true\\n\\n ;; Display empty bullet points.\\n ;; Default value: false\\n :ui/show-empty-bullets? false\\n\\n ;; Pre-defined :view function to use with advanced queries.\\n :query/views\\n {:pprint\\n (fn [r] [:pre.code (pprint r)])}\\n\\n ;; Advanced queries `:result-transform` function.\\n ;; Transform the query result before displaying it.\\n ;; Example usage for DB graphs:\\n;; :query/result-transforms\\n;; {:sort-by-priority\\n;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority \\\"Z\\\")) result))}\\n\\n;; Queries will be displayed at the bottom of today's journal page.\\n;; Example usage:\\n;; :default-queries\\n;; {:journals []}\\n\\n ;; Add custom commands to the command palette\\n ;; Example usage:\\n ;; :commands\\n ;; [\\n ;; [\\\"js\\\" \\\"Javascript\\\"]\\n ;; [\\\"md\\\" \\\"Markdown\\\"]\\n ;; ]\\n :commands []\\n\\n ;; Enable collapsing blocks with titles but no children.\\n ;; By default, only blocks with children can be collapsed.\\n ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing\\n ;; blocks with titles (multiple lines) and content. For example:\\n ;; - block title\\n ;; block content\\n ;; Default value: false\\n :outliner/block-title-collapse-enabled? false\\n\\n ;; Macros replace texts and will make you more productive.\\n ;; Example usage:\\n ;; Change the :macros value below to:\\n ;; {\\\"poem\\\" \\\"Rose is $1, violet's $2. Life's ordered: Org assists you.\\\"}\\n ;; input \\\"{{poem red,blue}}\\\"\\n ;; becomes\\n ;; Rose is red, violet's blue. Life's ordered: Org assists you.\\n :macros {}\\n\\n ;; Configure the default expansion level for linked references.\\n ;; For example, consider the following block hierarchy:\\n ;; - a [[page]] (level 1)\\n ;; - b (level 2)\\n ;; - c (level 3)\\n ;; - d (level 4)\\n ;;\\n ;; With the default value of level 2, block b will be collapsed.\\n ;; If the level's value is set to 3, block c will be collapsed.\\n ;; Default value: 2\\n :ref/default-open-blocks-level 2\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/settings\\n ;; {:orphan-pages? true ; Default value: true\\n ;; :builtin-pages? false ; Default value: false\\n ;; :excluded-pages? false ; Default value: false\\n ;; :journal? false} ; Default value: false\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/forcesettings\\n ;; {:link-dist 180 ; Default value: 180\\n ;; :charge-strength -600 ; Default value: -600\\n ;; :charge-range 600} ; Default value: 600\\n\\n ;; Mobile photo upload configuration.\\n ;; :mobile/photo\\n ;; {:allow-editing? true\\n ;; :quality 80}\\n\\n ;; Mobile features options\\n ;; Gestures\\n ;; Example usage:\\n ;; :mobile\\n ;; {:gestures/disabled-in-block-with-tags [\\\"kanban\\\"]}\\n\\n ;; Extra CodeMirror options\\n ;; See https://codemirror.net/5/doc/manual.html#config for possible options\\n ;; Example usage:\\n ;; :editor/extra-codemirror-options\\n ;; {:lineWrapping false ; Default value: false\\n ;; :lineNumbers true ; Default value: true\\n ;; :readOnly false} ; Default value: false\\n\\n ;; Enable logical outdenting\\n ;; Default value: false\\n ;; :editor/logical-outdenting? false\\n\\n ;; Prefer pasting the file when text and a file are in the clipboard.\\n ;; Default value: false\\n ;; :editor/preferred-pasting-file? false\\n\\n ;; Quick capture templates for receiving content from other apps.\\n ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded\\n ;; by receiving content from other apps. Note: the {} cannot be omitted.\\n ;; - {time}: capture time\\n ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference\\n ;; - {text}: text that users selected before sharing.\\n ;; - {url}: URL or assets path for media files stored in Logseq.\\n ;; You can also reorder them or use only one or two of them in the template.\\n ;; You can also insert or format any text in the template, as shown in the following examples.\\n ;; :quick-capture-templates\\n ;; {:text \\\"[[quick capture]] **{time}**: {text} from {url}\\\"\\n ;; :media \\\"[[quick capture]] **{time}**: {url}\\\"}\\n\\n ;; Quick capture options.\\n ;; - insert-today? Insert the capture at the end of today's journal page (boolean).\\n ;; - redirect-page? Redirect to the quick capture page after capturing (boolean).\\n ;; - default-page The default page to capture to if insert-today? is false (string).\\n ;; :quick-capture-options\\n ;; {:insert-today? false ;; Default value: true\\n ;; :redirect-page? false ;; Default value: false\\n ;; :default-page \\\"quick capture\\\"} ;; Default page: \\\"quick capture\\\"\\n\\n }\\n\",536870913]],[\"^84\",[176,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^7N\",\"logseq/config.edn\",536870913]],[\"^84\",[177,\"^48\",1775980635491,536870913]],[\"^84\",[177,\"^7@\",\"d3BGM\",536870913]],[\"^84\",[177,\"^4S\",155,536870913]],[\"^84\",[177,\"^3X\",155,536870913]],[\"^84\",[177,\"^6:\",\"Use it to plan something to start at a specific date(time).\",536870913]],[\"^84\",[177,\"^3;\",1775980635491,536870913]],[\"^84\",[177,\"^2T\",\"~u00000004-9817-6380-0000-000000000000\",536870913]],[\"^84\",[177,\"^57\",true,536870913]],[\"^84\",[177,\"^6?\",71,536870913]],[\"^84\",[178,\"^2T\",\"~u00000004-4049-4381-0000-000000000000\",536870913]],[\"^84\",[178,\"^1M\",\"\",536870913]],[\"^84\",[178,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^7N\",\"logseq/publish.css\",536870913]],[\"^84\",[179,\"^48\",1775980635496,536870913]],[\"^84\",[179,\"^7M\",\"recycle\",536870913]],[\"^84\",[179,\"^69\",104,536870913]],[\"^84\",[179,\"^6:\",\"Recycle\",536870913]],[\"^84\",[179,\"^3;\",1775980635496,536870913]],[\"^84\",[179,\"^2T\",\"~u00000004-7238-1304-3000-000000000000\",536870913]],[\"^84\",[179,\"^57\",true,536870913]],[\"^84\",[179,\"^5J\",true,536870913]],[\"^84\",[180,\"^48\",1775980636002,536870913]],[\"^84\",[180,\"^1C\",20260412,536870913]],[\"^84\",[180,\"^7M\",\"apr 12th, 2026\",536870913]],[\"^84\",[180,\"^1S\",58,536870913]],[\"^84\",[180,\"^1S\",85,536870913]],[\"^84\",[180,\"^69\",85,536870913]],[\"^84\",[180,\"^6:\",\"Apr 12th, 2026\",536870913]],[\"^84\",[180,\"^S\",536873532,536873534]],[\"^84\",[180,\"^3;\",1775984657858,536873532]],[\"^84\",[180,\"^2T\",\"~u00000001-2026-0412-0000-000000000000\",536870913]],[\"^84\",[180,\"^4<\",181,536870913]],[\"^84\",[181,\"^48\",1775980636008,536870913]],[\"^84\",[181,\"^7M\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^1S\",58,536870913]],[\"^84\",[181,\"^1S\",104,536870913]],[\"^84\",[181,\"^1S\",130,536870913]],[\"^84\",[181,\"^1S\",137,536870913]],[\"^84\",[181,\"^69\",104,536870913]],[\"^84\",[181,\"^6:\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^3;\",1775980636008,536870913]],[\"^84\",[181,\"^2T\",\"~ue7cd12e2-f9f7-46ed-a17e-85d95a780003\",536870913]],[\"^84\",[181,\"^38\",\"tienson@logseq.com\",536870913]],[\"^84\",[181,\"^E\",\"tiensonqin\",536870913]],[\"^84\",[182,\"^;\",\"^64\",536870913]],[\"^84\",[182,\"^28\",\"~u952b4187-ac01-41d3-b5fb-24250ac70d56\",536870913]],[\"^84\",[183,\"^;\",\"^3:\",536870913]],[\"^84\",[183,\"^28\",true,536870913]],[\"^84\",[565,\"^48\",1775980817925,536870913]],[\"^84\",[565,\"^7M\",\"p1\",536870913]],[\"^84\",[565,\"^7@\",\"d3BHX\",536870913]],[\"^84\",[565,\"^1S\",58,536870913]],[\"^84\",[565,\"^1S\",124,536870913]],[\"^84\",[565,\"^69\",124,536870913]],[\"^84\",[565,\"^6:\",\"p1\",536870913]],[\"^84\",[565,\"^3;\",1775980817925,536870913]],[\"^84\",[565,\"^2T\",\"~u00000002-1157-0363-3400-000000000000\",536870913]],[\"^84\",[565,\"^<\",\"^=\",536870913]],[\"^84\",[565,\"^;\",\"^5N\",536870913]],[\"^84\",[565,\"^>\",true,536870913]],[\"^84\",[565,\"^C\",\"^D\",536870913]],[\"^84\",[565,\"^4<\",181,536870913]],[\"^84\",[565,\"^1K\",\"^8>\",536870913]],[\"^84\",[568,\"^48\",1775980818025,536870913]],[\"^84\",[568,\"^7M\",\"p2\",536870913]],[\"^84\",[568,\"^7@\",\"d3BHa\",536870913]],[\"^84\",[568,\"^1S\",58,536870913]],[\"^84\",[568,\"^1S\",124,536870913]],[\"^84\",[568,\"^69\",124,536870913]],[\"^84\",[568,\"^6:\",\"p2\",536870913]],[\"^84\",[568,\"^3;\",1775980818025,536870913]],[\"^84\",[568,\"^2T\",\"~u00000002-6891-0936-5000-000000000000\",536870913]],[\"^84\",[568,\"^<\",\"^=\",536870913]],[\"^84\",[568,\"^;\",\"^3V\",536870913]],[\"^84\",[568,\"^>\",true,536870913]],[\"^84\",[568,\"^C\",\"^D\",536870913]],[\"^84\",[568,\"^4<\",181,536870913]],[\"^84\",[568,\"^1K\",\"^8K\",536870913]],[\"^84\",[569,\"^48\",1775980818039,536870913]],[\"^84\",[569,\"^7M\",\"p3\",536870913]],[\"^84\",[569,\"^7@\",\"d3BHb\",536870913]],[\"^84\",[569,\"^1S\",58,536870913]],[\"^84\",[569,\"^1S\",104,536870913]],[\"^84\",[569,\"^1S\",124,536870913]],[\"^84\",[569,\"^69\",124,536870913]],[\"^84\",[569,\"^6:\",\"p3\",536870913]],[\"^84\",[569,\"^3;\",1775980818056,536870913]],[\"^84\",[569,\"^2T\",\"~u00000002-2093-0438-6800-000000000000\",536870913]],[\"^84\",[569,\"^<\",\"^14\",536870913]],[\"^84\",[569,\"^;\",\"^1N\",536870913]],[\"^84\",[569,\"^>\",true,536870913]],[\"^84\",[569,\"^C\",\"^D\",536870913]],[\"^84\",[569,\"^6G\",104,536870913]],[\"^84\",[569,\"^4<\",181,536870913]],[\"^84\",[569,\"^1K\",\"^85\",536870913]],[\"^84\",[573,\"^48\",1775980818095,536870913]],[\"^84\",[573,\"^7M\",\"page 1\",536870913]],[\"^84\",[573,\"^1S\",58,536870913]],[\"^84\",[573,\"^1S\",104,536870913]],[\"^84\",[573,\"^69\",104,536870913]],[\"^84\",[573,\"^6:\",\"page 1\",536870913]],[\"^84\",[573,\"^3;\",1775980818095,536870913]],[\"^84\",[573,\"^2T\",\"~u69db5102-b394-49cc-af0d-6e3ee0752cdf\",536870913]],[\"^84\",[573,\"^4<\",181,536870913]],[\"^84\",[574,\"^48\",1775980818106,536870913]],[\"^84\",[574,\"^7M\",\"page 2\",536870913]],[\"^84\",[574,\"^1S\",58,536870913]],[\"^84\",[574,\"^1S\",104,536870913]],[\"^84\",[574,\"^69\",104,536870913]],[\"^84\",[574,\"^6:\",\"page 2\",536870913]],[\"^84\",[574,\"^3;\",1775980818106,536870913]],[\"^84\",[574,\"^2T\",\"~u69db5103-ac5c-4d04-b098-88b435041434\",536870913]],[\"^84\",[574,\"^4<\",181,536870913]],[\"^84\",[577,\"^48\",1775980800102,536870913]],[\"^84\",[577,\"^7@\",\"d3BHW\",536870913]],[\"^84\",[577,\"^4S\",568,536870913]],[\"^84\",[577,\"^3X\",568,536870913]],[\"^84\",[577,\"^1S\",568,536870913]],[\"^84\",[577,\"^3;\",1775980800102,536870913]],[\"^84\",[577,\"^2T\",\"~u69db5100-05c8-4909-b79e-c56130f4d1e9\",536870913]],[\"^84\",[577,\"^4<\",181,536870913]],[\"^84\",[577,\"^6?\",568,536870913]],[\"^84\",[577,\"^T\",10,536870913]],[\"^84\",[593,\"^48\",1775980982459,536870913]],[\"^84\",[593,\"^7@\",\"d3BHf\",536870913]],[\"^84\",[593,\"^4S\",568,536870913]],[\"^84\",[593,\"^3X\",568,536870913]],[\"^84\",[593,\"^1S\",568,536870913]],[\"^84\",[593,\"^3;\",1775980982459,536870913]],[\"^84\",[593,\"^2T\",\"~u69db51b6-97ff-4bf6-9547-69dc12a2972e\",536870913]],[\"^84\",[593,\"^4<\",181,536870913]],[\"^84\",[593,\"^6?\",568,536870913]],[\"^84\",[593,\"^T\",10,536870913]],[\"^84\",[597,\"^48\",1775980958586,536870913]],[\"^84\",[597,\"^7@\",\"d3BHe\",536870913]],[\"^84\",[597,\"^4S\",568,536870913]],[\"^84\",[597,\"^3X\",568,536870913]],[\"^84\",[597,\"^1S\",568,536870913]],[\"^84\",[597,\"^3;\",1775980958586,536870913]],[\"^84\",[597,\"^2T\",\"~u69db519e-9f15-4a6a-a142-fe9d6e954212\",536870913]],[\"^84\",[597,\"^4<\",181,536870913]],[\"^84\",[597,\"^6?\",568,536870913]],[\"^84\",[597,\"^T\",10,536870913]],[\"^84\",[599,\"^48\",1775981040693,536870913]],[\"^84\",[599,\"^7M\",\"page 3\",536870913]],[\"^84\",[599,\"^1S\",58,536870913]],[\"^84\",[599,\"^1S\",104,536870913]],[\"^84\",[599,\"^69\",104,536870913]],[\"^84\",[599,\"^6:\",\"page 3\",536870913]],[\"^84\",[599,\"^3;\",1775981040693,536870913]],[\"^84\",[599,\"^2T\",\"~u69db51a5-4a8e-4ebf-992e-d31c157eaf5b\",536870913]],[\"^84\",[599,\"^4<\",181,536870913]],[\"^84\",[600,\"^48\",1775981040720,536870913]],[\"^84\",[600,\"^7M\",\"p4\",536870913]],[\"^84\",[600,\"^7@\",\"d3BHh\",536870913]],[\"^84\",[600,\"^1S\",58,536870913]],[\"^84\",[600,\"^1S\",124,536870913]],[\"^84\",[600,\"^69\",124,536870913]],[\"^84\",[600,\"^6:\",\"p4\",536870913]],[\"^84\",[600,\"^3;\",1775981028998,536870913]],[\"^84\",[600,\"^2T\",\"~u00000002-5835-7822-8000-000000000000\",536870913]],[\"^84\",[600,\"^<\",\"^=\",536870913]],[\"^84\",[600,\"^;\",\"^6;\",536870913]],[\"^84\",[600,\"^>\",true,536870913]],[\"^84\",[600,\"^C\",\"^D\",536870913]],[\"^84\",[600,\"^4<\",181,536870913]],[\"^84\",[600,\"^1K\",\"^8>\",536870913]],[\"^84\",[605,\"^48\",1775981057423,536870913]],[\"^84\",[605,\"^7@\",\"Zz\",536870913]],[\"^84\",[605,\"^4S\",170,536870913]],[\"^84\",[605,\"^3X\",170,536870913]],[\"^84\",[605,\"^1S\",170,536870913]],[\"^84\",[605,\"^6:\",\"All\",536870913]],[\"^84\",[605,\"^3;\",1775981057423,536870913]],[\"^84\",[605,\"^2T\",\"~u00000006-1867-5878-9000-000000000000\",536870913]],[\"^84\",[605,\"^4<\",181,536870913]],[\"^84\",[605,\"^9\",170,536870913]],[\"^84\",[605,\"^5>\",\"~:all-pages\",536870913]],[\"^84\",[619,\"^3D\",true,536870913]],[\"^84\",[619,\"^48\",1775981500541,536870913]],[\"^84\",[619,\"^7M\",\"tag1\",536870913]],[\"^84\",[619,\"^1S\",14,536870913]],[\"^84\",[619,\"^1S\",58,536870913]],[\"^84\",[619,\"^1S\",89,536870913]],[\"^84\",[619,\"^1S\",158,536870913]],[\"^84\",[619,\"^69\",14,536870913]],[\"^84\",[619,\"^6:\",\"tag1\",536870913]],[\"^84\",[619,\"^3;\",1775981500541,536870913]],[\"^84\",[619,\"^2T\",\"~u69db50d9-c23a-4aa4-bb79-33d5dbe54593\",536870913]],[\"^84\",[619,\"^;\",\"^3T\",536870913]],[\"^84\",[619,\"^4<\",181,536870913]],[\"^84\",[619,\"^3K\",89,536870913]],[\"^84\",[620,\"^48\",1775981500540,536870913]],[\"^84\",[620,\"^7M\",\"page 4\",536870913]],[\"^84\",[620,\"^1S\",58,536870913]],[\"^84\",[620,\"^1S\",104,536870913]],[\"^84\",[620,\"^1S\",619,536870913]],[\"^84\",[620,\"^69\",104,536870913]],[\"^84\",[620,\"^69\",619,536870913]],[\"^84\",[620,\"^6:\",\"page 4\",536870913]],[\"^84\",[620,\"^3;\",1775981500561,536870913]],[\"^84\",[620,\"^2T\",\"~u69db53ab-5e4a-4039-b2ba-4220ca0b20cd\",536870913]],[\"^84\",[620,\"^4<\",181,536870913]],[\"^84\",[621,\"^48\",1775981500553,536870913]],[\"^84\",[621,\"^7@\",\"a0\",536870913]],[\"^84\",[621,\"^4S\",620,536870913]],[\"^84\",[621,\"^3X\",620,536870913]],[\"^84\",[621,\"^6:\",\"test\",536870913]],[\"^84\",[621,\"^3;\",1775981500560,536870913]],[\"^84\",[621,\"^2T\",\"~u69db53ac-7264-4191-bac4-eee78a2ff031\",536870913]],[\"^84\",[621,\"^4<\",181,536870913]],[\"^84\",[622,\"^48\",1775981513870,536870913]],[\"^84\",[622,\"^7@\",\"a0\",536870913]],[\"^84\",[622,\"^4S\",170,536870913]],[\"^84\",[622,\"^3X\",170,536870913]],[\"^84\",[622,\"^1S\",619,536870913]],[\"^84\",[622,\"^6:\",\"All\",536870913]],[\"^84\",[622,\"^3;\",1775981513870,536870913]],[\"^84\",[622,\"^2T\",\"~u00000006-1742-3452-4000-000000000000\",536870913]],[\"^84\",[622,\"^4<\",181,536870913]],[\"^84\",[622,\"^9\",619,536870913]],[\"^84\",[622,\"^5>\",\"~:class-objects\",536870913]],[\"^84\",[1530,\"^48\",1775982125944,536870913]],[\"^84\",[1530,\"^7M\",\"page 5\",536870913]],[\"^84\",[1530,\"^1S\",58,536870913]],[\"^84\",[1530,\"^1S\",104,536870913]],[\"^84\",[1530,\"^69\",104,536870913]],[\"^84\",[1530,\"^6:\",\"page 5\",536870913]],[\"^84\",[1530,\"^3;\",1775982125944,536870913]],[\"^84\",[1530,\"^2T\",\"~u69db557e-1cd8-48b3-87aa-1b8a684ca17f\",536870913]],[\"^84\",[1530,\"^4<\",181,536870913]],[\"^84\",[1732,\"^48\",1775982384424,536870913]],[\"^84\",[1732,\"^7M\",\"page 6\",536870913]],[\"^84\",[1732,\"^1S\",58,536870913]],[\"^84\",[1732,\"^1S\",104,536870913]],[\"^84\",[1732,\"^69\",104,536870913]],[\"^84\",[1732,\"^6:\",\"page 6\",536870913]],[\"^84\",[1732,\"^3;\",1775982413000,536870913]],[\"^84\",[1732,\"^2T\",\"~u69db5730-ad32-46c0-bb99-f87817d4443a\",536870913]],[\"^84\",[1732,\"^4<\",181,536870913]],[\"^84\",[1733,\"^48\",1775982384655,536870913]],[\"^84\",[1733,\"^7@\",\"a0\",536870913]],[\"^84\",[1733,\"^4S\",1732,536870913]],[\"^84\",[1733,\"^3X\",1732,536870913]],[\"^84\",[1733,\"^6:\",\"hello\",536870913]],[\"^84\",[1733,\"^3;\",1775982389175,536870913]],[\"^84\",[1733,\"^2T\",\"~u69db5730-c5a5-4116-89f5-26341ef44073\",536870913]],[\"^84\",[1733,\"^4<\",181,536870913]],[\"^84\",[1742,\"^48\",1775982389180,536870913]],[\"^84\",[1742,\"^7@\",\"a1\",536870913]],[\"^84\",[1742,\"^4S\",1732,536870913]],[\"^84\",[1742,\"^3X\",1732,536870913]],[\"^84\",[1742,\"^6:\",\"fjdlafda\",536870913]],[\"^84\",[1742,\"^3;\",1775982389685,536870913]],[\"^84\",[1742,\"^2T\",\"~u69db5731-9fe9-4c6b-a183-ffb2e6471f45\",536870913]],[\"^84\",[1743,\"^48\",1775982389690,536870913]],[\"^84\",[1743,\"^7@\",\"a2\",536870913]],[\"^84\",[1743,\"^4S\",1732,536870913]],[\"^84\",[1743,\"^3X\",1732,536870913]],[\"^84\",[1743,\"^6:\",\"f\",536870913]],[\"^84\",[1743,\"^3;\",1775982389754,536870913]],[\"^84\",[1743,\"^2T\",\"~u69db5732-3643-4e28-906b-581fbbb2234a\",536870913]],[\"^84\",[1744,\"^48\",1775982389755,536870913]],[\"^84\",[1744,\"^7@\",\"a3\",536870913]],[\"^84\",[1744,\"^4S\",1732,536870913]],[\"^84\",[1744,\"^3X\",1732,536870913]],[\"^84\",[1744,\"^6:\",\"adf\",536870913]],[\"^84\",[1744,\"^3;\",1775982389841,536870913]],[\"^84\",[1744,\"^2T\",\"~u69db5732-9fbc-49f2-8473-db80eee4e879\",536870913]],[\"^84\",[1745,\"^48\",1775982389843,536870913]],[\"^84\",[1745,\"^7@\",\"a4\",536870913]],[\"^84\",[1745,\"^4S\",1732,536870913]],[\"^84\",[1745,\"^3X\",1732,536870913]],[\"^84\",[1745,\"^6:\",\"f\",536870913]],[\"^84\",[1745,\"^3;\",1775982389926,536870913]],[\"^84\",[1745,\"^2T\",\"~u69db5732-a4fa-48bc-b850-1ea2dd125db8\",536870913]],[\"^84\",[1746,\"^48\",1775982389927,536870913]],[\"^84\",[1746,\"^7@\",\"a5\",536870913]],[\"^84\",[1746,\"^4S\",1732,536870913]],[\"^84\",[1746,\"^3X\",1732,536870913]],[\"^84\",[1746,\"^6:\",\"af\",536870913]],[\"^84\",[1746,\"^3;\",1775982390009,536870913]],[\"^84\",[1746,\"^2T\",\"~u69db5732-c89c-439e-91db-385e7cf0884a\",536870913]],[\"^84\",[1747,\"^48\",1775982390010,536870913]],[\"^84\",[1747,\"^7@\",\"a6\",536870913]],[\"^84\",[1747,\"^4S\",1732,536870913]],[\"^84\",[1747,\"^3X\",1732,536870913]],[\"^84\",[1747,\"^6:\",\"af\",536870913]],[\"^84\",[1747,\"^3;\",1775982390090,536870913]],[\"^84\",[1747,\"^2T\",\"~u69db5732-389f-48f7-a3c9-9ab01e4d06b3\",536870913]],[\"^84\",[1748,\"^48\",1775982390091,536870913]],[\"^84\",[1748,\"^7@\",\"a7\",536870913]],[\"^84\",[1748,\"^4S\",1732,536870913]],[\"^84\",[1748,\"^3X\",1732,536870913]],[\"^84\",[1748,\"^6:\",\"a\",536870913]],[\"^84\",[1748,\"^3;\",1775982390176,536870913]],[\"^84\",[1748,\"^2T\",\"~u69db5733-aeac-4823-bd0e-ba84d7fcc3aa\",536870913]],[\"^84\",[1749,\"^48\",1775982390178,536870913]],[\"^84\",[1749,\"^7@\",\"a8\",536870913]],[\"^84\",[1749,\"^4S\",1732,536870913]],[\"^84\",[1749,\"^3X\",1732,536870913]],[\"^84\",[1749,\"^6:\",\"so cool\",536870913]],[\"^84\",[1749,\"^3;\",1775982411832,536870913]],[\"^84\",[1749,\"^2T\",\"~u69db5733-9bfe-49db-96cb-03fe97bce35f\",536870913]],[\"^84\",[1749,\"^4<\",181,536870913]],[\"^84\",[1754,\"^48\",1775982411836,536870913]],[\"^84\",[1754,\"^7@\",\"a0\",536870913]],[\"^84\",[1754,\"^4S\",1732,536870913]],[\"^84\",[1754,\"^3X\",1749,536870913]],[\"^84\",[1754,\"^6:\",\"awesome work\",536870913]],[\"^84\",[1754,\"^3;\",1775982412416,536870913]],[\"^84\",[1754,\"^2T\",\"~u69db5739-bca6-4992-96ec-45334b44f4c4\",536870913]],[\"^84\",[1755,\"^48\",1775982412417,536870913]],[\"^84\",[1755,\"^7@\",\"a1\",536870913]],[\"^84\",[1755,\"^4S\",1732,536870913]],[\"^84\",[1755,\"^3X\",1749,536870913]],[\"^84\",[1755,\"^6:\",\"great\",536870913]],[\"^84\",[1755,\"^3;\",1775982412496,536870913]],[\"^84\",[1755,\"^2T\",\"~u69db573a-8ec0-46e1-bc26-d7632e93d96f\",536870913]],[\"^84\",[1756,\"^48\",1775982412499,536870913]],[\"^84\",[1756,\"^7@\",\"a2\",536870913]],[\"^84\",[1756,\"^4S\",1732,536870913]],[\"^84\",[1756,\"^3X\",1749,536870913]],[\"^84\",[1756,\"^6:\",\"sounds amazing\",536870913]],[\"^84\",[1756,\"^3;\",1775982412747,536870913]],[\"^84\",[1756,\"^2T\",\"~u69db573b-ec3d-4f66-8e8d-26c9ff157a6b\",536870913]],[\"^84\",[1757,\"^48\",1775982412833,536870913]],[\"^84\",[1757,\"^7@\",\"a3\",536870913]],[\"^84\",[1757,\"^4S\",1732,536870913]],[\"^84\",[1757,\"^3X\",1749,536870913]],[\"^84\",[1757,\"^6:\",\"stuff really just work\",536870913]],[\"^84\",[1757,\"^3;\",1775982413000,536870913]],[\"^84\",[1757,\"^2T\",\"~u69db573f-8a13-43cc-82af-060e89cfd1fa\",536870913]],[\"^84\",[1836,\"^48\",1775983070467,536870913]],[\"^84\",[1836,\"^7M\",\"page 7\",536870913]],[\"^84\",[1836,\"^1S\",58,536870913]],[\"^84\",[1836,\"^1S\",104,536870913]],[\"^84\",[1836,\"^69\",104,536870913]],[\"^84\",[1836,\"^6:\",\"page 7\",536870913]],[\"^84\",[1836,\"^3;\",1775983683136,536884972]],[\"^84\",[1836,\"^2T\",\"~u69db59de-5992-4e9c-aba5-12bbae69d657\",536870913]],[\"^84\",[1836,\"^4<\",181,536870913]],[\"^84\",[1852,\"^48\",1775983078379,536870913]],[\"^84\",[1852,\"^7@\",\"a0\",536870913]],[\"^84\",[1852,\"^4S\",1836,536870913]],[\"^84\",[1852,\"^3X\",1836,536870913]],[\"^84\",[1852,\"^6:\",\"fjdlafjldaf\",536870913]],[\"^84\",[1852,\"^3;\",1775983078467,536870913]],[\"^84\",[1852,\"^2T\",\"~u69db59de-1710-4353-9b7f-a6843d27c9f7\",536870913]],[\"^84\",[1853,\"^48\",1775983078469,536870913]],[\"^84\",[1853,\"^7@\",\"a1\",536870913]],[\"^84\",[1853,\"^4S\",1836,536870913]],[\"^84\",[1853,\"^3X\",1836,536870913]],[\"^84\",[1853,\"^6:\",\"da\",536870913]],[\"^84\",[1853,\"^3;\",1775983078547,536870913]],[\"^84\",[1853,\"^2T\",\"~u69db59df-bbd0-4c0b-b89b-40d255373829\",536870913]],[\"^84\",[1854,\"^48\",1775983078548,536870913]],[\"^84\",[1854,\"^7@\",\"a2\",536870913]],[\"^84\",[1854,\"^4S\",1836,536870913]],[\"^84\",[1854,\"^3X\",1836,536870913]],[\"^84\",[1854,\"^6:\",\"fd\",536870913]],[\"^84\",[1854,\"^3;\",1775983078649,536870913]],[\"^84\",[1854,\"^2T\",\"~u69db59df-ecc8-48f2-ab1d-b885294463c8\",536870913]],[\"^84\",[1855,\"^48\",1775983078652,536870913]],[\"^84\",[1855,\"^7@\",\"a0\",536870913]],[\"^84\",[1855,\"^4S\",1836,536870913]],[\"^84\",[1855,\"^3X\",1854,536870913]],[\"^84\",[1855,\"^6:\",\"aff\",536870913]],[\"^84\",[1855,\"^3;\",1775983078804,536870913]],[\"^84\",[1855,\"^2T\",\"~u69db59df-fbd3-4baa-b307-e439f1f660bc\",536870913]],[\"^84\",[1856,\"^48\",1775983078806,536870913]],[\"^84\",[1856,\"^7@\",\"a1\",536870913]],[\"^84\",[1856,\"^4S\",1836,536870913]],[\"^84\",[1856,\"^3X\",1854,536870913]],[\"^84\",[1856,\"^6:\",\"daqf\",536870913]],[\"^84\",[1856,\"^3;\",1775983078892,536870913]],[\"^84\",[1856,\"^2T\",\"~u69db59e0-6768-4059-b647-6b522367a85d\",536870913]],[\"^84\",[1857,\"^48\",1775983078897,536870913]],[\"^84\",[1857,\"^7@\",\"a2\",536870913]],[\"^84\",[1857,\"^4S\",1836,536870913]],[\"^84\",[1857,\"^3X\",1854,536870913]],[\"^84\",[1857,\"^6:\",\"f\",536870913]],[\"^84\",[1857,\"^3;\",1775983079138,536870913]],[\"^84\",[1857,\"^2T\",\"~u69db59e0-16b6-48d5-ae4d-c6832cec5580\",536870913]],[\"^84\",[1858,\"^48\",1775983079140,536870913]],[\"^84\",[1858,\"^7@\",\"a0\",536870913]],[\"^84\",[1858,\"^4S\",1836,536870913]],[\"^84\",[1858,\"^3X\",1857,536870913]],[\"^84\",[1858,\"^6:\",\"f\",536870913]],[\"^84\",[1858,\"^3;\",1775983079488,536870913]],[\"^84\",[1858,\"^2T\",\"~u69db59e0-1a63-4e46-97b2-9696843f7c47\",536870913]],[\"^84\",[1859,\"^48\",1775983079489,536870913]],[\"^84\",[1859,\"^7@\",\"a1\",536870913]],[\"^84\",[1859,\"^4S\",1836,536870913]],[\"^84\",[1859,\"^3X\",1857,536870913]],[\"^84\",[1859,\"^6:\",\"f\",536870913]],[\"^84\",[1859,\"^3;\",1775983079572,536870913]],[\"^84\",[1859,\"^2T\",\"~u69db59e1-da15-45cb-9f42-a87f80023c55\",536870913]],[\"^84\",[1860,\"^48\",1775983079643,536870913]],[\"^84\",[1860,\"^7@\",\"a0\",536870913]],[\"^84\",[1860,\"^4S\",1836,536870913]],[\"^84\",[1860,\"^3X\",1859,536870913]],[\"^84\",[1860,\"^6:\",\"f\",536870913]],[\"^84\",[1860,\"^3;\",1775983079815,536870913]],[\"^84\",[1860,\"^2T\",\"~u69db59e1-f52e-4e72-b110-c313fbd76c44\",536870913]],[\"^84\",[1861,\"^48\",1775983079817,536870913]],[\"^84\",[1861,\"^7@\",\"a1\",536870913]],[\"^84\",[1861,\"^4S\",1836,536870913]],[\"^84\",[1861,\"^3X\",1859,536870913]],[\"^84\",[1861,\"^6:\",\"f\",536884964]],[\"^84\",[1861,\"^3;\",1775983682382,536884964]],[\"^84\",[1861,\"^2T\",\"~u69db59e1-bc2d-49e6-ad28-ade10a3e506c\",536870913]],[\"^84\",[2384,\"^48\",1775983682383,536871818]],[\"^84\",[2384,\"^7@\",\"a0V\",536871818]],[\"^84\",[2384,\"^4S\",1836,536871818]],[\"^84\",[2384,\"^3X\",1859,536871818]],[\"^84\",[2384,\"^6:\",\"fd\",536884966]],[\"^84\",[2384,\"^3;\",1775983682880,536884966]],[\"^84\",[2384,\"^2T\",\"~u69db59e1-5216-491c-b6ff-36ba8fb23f4d\",536871818]],[\"^84\",[2385,\"^48\",1775983682881,536871819]],[\"^84\",[2385,\"^7@\",\"a0l\",536871819]],[\"^84\",[2385,\"^4S\",1836,536871819]],[\"^84\",[2385,\"^3X\",1859,536871819]],[\"^84\",[2385,\"^6:\",\"af\",536884968]],[\"^84\",[2385,\"^3;\",1775983682974,536884968]],[\"^84\",[2385,\"^2T\",\"~u69db59e1-b11f-4372-9155-50825d6fd4ed\",536871819]],[\"^84\",[2386,\"^48\",1775983682975,536871820]],[\"^84\",[2386,\"^7@\",\"a0t\",536871820]],[\"^84\",[2386,\"^4S\",1836,536871820]],[\"^84\",[2386,\"^3X\",1859,536871820]],[\"^84\",[2386,\"^6:\",\"asf\",536884970]],[\"^84\",[2386,\"^3;\",1775983683045,536884970]],[\"^84\",[2386,\"^2T\",\"~u69db59e2-58f7-4f9e-aa44-34b587b0a687\",536871820]],[\"^84\",[2387,\"^48\",1775983683046,536871821]],[\"^84\",[2387,\"^7@\",\"a0x\",536871821]],[\"^84\",[2387,\"^4S\",1836,536871821]],[\"^84\",[2387,\"^3X\",1859,536871821]],[\"^84\",[2387,\"^6:\",\"sf\",536884972]],[\"^84\",[2387,\"^3;\",1775983683135,536884972]],[\"^84\",[2387,\"^2T\",\"~u69db59e2-7991-45be-ba25-1cdb2e3519a9\",536871821]],[\"^84\",[2388,\"^48\",1775983683137,536871822]],[\"^84\",[2388,\"^7@\",\"a0z\",536871822]],[\"^84\",[2388,\"^4S\",1836,536871822]],[\"^84\",[2388,\"^3X\",1859,536871822]],[\"^84\",[2388,\"^6:\",\"\",536871822]],[\"^84\",[2388,\"^3;\",1775983683137,536871822]],[\"^84\",[2388,\"^2T\",\"~u69db59e2-1a51-43ba-8a3b-ceb25f6aa499\",536871822]],[\"^84\",[2460,\"^48\",1775983689295,536871863]],[\"^84\",[2460,\"^7M\",\"page 9\",536871863]],[\"^84\",[2460,\"^1S\",58,536871863]],[\"^84\",[2460,\"^1S\",104,536871863]],[\"^84\",[2460,\"^69\",104,536871863]],[\"^84\",[2460,\"^6:\",\"page 9\",536871863]],[\"^84\",[2460,\"^3;\",1775983693372,536885142]],[\"^84\",[2460,\"^2T\",\"~u69db5c49-fb5d-4f7a-90a9-cd294f0aef44\",536871863]],[\"^84\",[2460,\"^4<\",181,536871863]],[\"^84\",[2461,\"^48\",1775983689528,536871864]],[\"^84\",[2461,\"^7@\",\"a0\",536871864]],[\"^84\",[2461,\"^4S\",2460,536871864]],[\"^84\",[2461,\"^3X\",2460,536871864]],[\"^84\",[2461,\"^6:\",\"fjldafjlas\",536885075]],[\"^84\",[2461,\"^3;\",1775983690319,536885075]],[\"^84\",[2461,\"^2T\",\"~u69db5c49-9f9e-49e8-9ead-2c36ec84031b\",536871864]],[\"^84\",[2461,\"^4<\",181,536871864]],[\"^84\",[2462,\"^48\",1775983690320,536871865]],[\"^84\",[2462,\"^7@\",\"a0\",536885079]],[\"^84\",[2462,\"^4S\",2460,536871865]],[\"^84\",[2462,\"^3X\",2461,536871866]],[\"^84\",[2462,\"^6:\",\"f\",536885078]],[\"^84\",[2462,\"^3;\",1775983690437,536885078]],[\"^84\",[2462,\"^2T\",\"~u69db5c4a-c58e-4a90-9cd6-4f692890f42a\",536871865]],[\"^84\",[2462,\"^4<\",181,536871865]],[\"^84\",[2463,\"^48\",1775983690514,536871867]],[\"^84\",[2463,\"^7@\",\"a0\",536885089]],[\"^84\",[2463,\"^4S\",2460,536871867]],[\"^84\",[2463,\"^3X\",2462,536871870]],[\"^84\",[2463,\"^6:\",\"f\",536885088]],[\"^84\",[2463,\"^3;\",1775983690873,536885088]],[\"^84\",[2463,\"^2T\",\"~u69db5c4a-0212-4c6f-bee0-24b88a12deee\",536871867]],[\"^84\",[2463,\"^4<\",181,536871867]],[\"^84\",[2464,\"^48\",1775983690916,536871871]],[\"^84\",[2464,\"^7@\",\"Zz\",536871871]],[\"^84\",[2464,\"^4S\",2460,536871871]],[\"^84\",[2464,\"^3X\",2462,536871871]],[\"^84\",[2464,\"^6:\",\"\",536871871]],[\"^84\",[2464,\"^3;\",1775983690916,536871871]],[\"^84\",[2464,\"^2T\",\"~u69db5c4a-2364-4fb7-bdd1-223745f4c69a\",536871871]],[\"^84\",[2464,\"^4<\",181,536871871]],[\"^84\",[2465,\"^48\",1775983691100,536871872]],[\"^84\",[2465,\"^7@\",\"a0\",536885101]],[\"^84\",[2465,\"^4S\",2460,536871872]],[\"^84\",[2465,\"^3X\",2464,536871874]],[\"^84\",[2465,\"^6:\",\"f\",536885097]],[\"^84\",[2465,\"^3;\",1775983691353,536885100]],[\"^84\",[2465,\"^2T\",\"~u69db5c4b-932f-49b8-9522-2f5d6a962f25\",536871872]],[\"^84\",[2465,\"^4<\",181,536871872]],[\"^84\",[2466,\"^48\",1775983691319,536871873]],[\"^84\",[2466,\"^7@\",\"a1\",536885105]],[\"^84\",[2466,\"^4S\",2460,536871873]],[\"^84\",[2466,\"^3X\",2464,536871876]],[\"^84\",[2466,\"^6:\",\"\",536871873]],[\"^84\",[2466,\"^3;\",1775983691319,536871873]],[\"^84\",[2466,\"^2T\",\"~u69db5c4b-9d2c-4516-b00c-25b6e2a7d6de\",536871873]],[\"^84\",[2466,\"^4<\",181,536871873]],[\"^84\",[2467,\"^48\",1775983691450,536871875]],[\"^84\",[2467,\"^7@\",\"a2\",536885111]],[\"^84\",[2467,\"^4S\",2460,536871875]],[\"^84\",[2467,\"^3X\",2464,536871878]],[\"^84\",[2467,\"^6:\",\"f\",536885107]],[\"^84\",[2467,\"^3;\",1775983691677,536885110]],[\"^84\",[2467,\"^2T\",\"~u69db5c4b-7c2d-4bb4-9aaa-5a9f1a445f52\",536871875]],[\"^84\",[2467,\"^4<\",181,536871875]],[\"^84\",[2468,\"^48\",1775983691628,536871877]],[\"^84\",[2468,\"^7@\",\"Zzx\",536871877]],[\"^84\",[2468,\"^4S\",2460,536871877]],[\"^84\",[2468,\"^3X\",2462,536871877]],[\"^84\",[2468,\"^6:\",\"f\",536885112]],[\"^84\",[2468,\"^3;\",1775983691774,536885112]],[\"^84\",[2468,\"^2T\",\"~u69db5c4b-56f8-422b-b193-b482957b3b31\",536871877]],[\"^84\",[2468,\"^4<\",181,536871877]],[\"^84\",[2469,\"^48\",1775983691775,536871879]],[\"^84\",[2469,\"^7@\",\"a0\",536885115]],[\"^84\",[2469,\"^4S\",2460,536871879]],[\"^84\",[2469,\"^3X\",2468,536871880]],[\"^84\",[2469,\"^6:\",\"f\",536885117]],[\"^84\",[2469,\"^3;\",1775983691973,536885117]],[\"^84\",[2469,\"^2T\",\"~u69db5c4b-0f65-4034-9d3e-a53826620eb4\",536871879]],[\"^84\",[2469,\"^4<\",181,536871879]],[\"^84\",[2470,\"^48\",1775983691974,536871881]],[\"^84\",[2470,\"^7@\",\"Zz\",536871881]],[\"^84\",[2470,\"^4S\",2460,536871881]],[\"^84\",[2470,\"^3X\",2468,536871881]],[\"^84\",[2470,\"^6:\",\"\",536871881]],[\"^84\",[2470,\"^3;\",1775983691974,536871881]],[\"^84\",[2470,\"^2T\",\"~u69db5c4b-8a1c-4a55-9c85-2c33cc41abf3\",536871881]],[\"^84\",[2470,\"^4<\",181,536871881]],[\"^84\",[2471,\"^48\",1775983692146,536871882]],[\"^84\",[2471,\"^7@\",\"a0\",536885131]],[\"^84\",[2471,\"^4S\",2460,536871882]],[\"^84\",[2471,\"^3X\",2470,536871886]],[\"^84\",[2471,\"^6:\",\"f\",536885127]],[\"^84\",[2471,\"^3;\",1775983692532,536885130]],[\"^84\",[2471,\"^2T\",\"~u69db5c4c-2d33-4497-a4ca-9fa095153cef\",536871882]],[\"^84\",[2471,\"^4<\",181,536871882]],[\"^84\",[2472,\"^48\",1775983692485,536871885]],[\"^84\",[2472,\"^7@\",\"a1\",536885136]],[\"^84\",[2472,\"^4S\",2460,536871885]],[\"^84\",[2472,\"^3X\",2470,536871888]],[\"^84\",[2472,\"^6:\",\"f\",536885132]],[\"^84\",[2472,\"^3;\",1775983692712,536885135]],[\"^84\",[2472,\"^2T\",\"~u69db5c4c-65af-484b-80c2-221d316cd911\",536871885]],[\"^84\",[2472,\"^4<\",181,536871885]],[\"^84\",[2473,\"^48\",1775983692668,536871887]],[\"^84\",[2473,\"^7@\",\"Zzt\",536871887]],[\"^84\",[2473,\"^4S\",2460,536871887]],[\"^84\",[2473,\"^3X\",2468,536871887]],[\"^84\",[2473,\"^6:\",\"f\",536885137]],[\"^84\",[2473,\"^3;\",1775983692810,536885137]],[\"^84\",[2473,\"^2T\",\"~u69db5c4c-8ca4-40fa-b76f-65bdfb599fa4\",536871887]],[\"^84\",[2473,\"^4<\",181,536871887]],[\"^84\",[2474,\"^48\",1775983692811,536871889]],[\"^84\",[2474,\"^7@\",\"a0\",536885140]],[\"^84\",[2474,\"^4S\",2460,536871889]],[\"^84\",[2474,\"^3X\",2473,536871890]],[\"^84\",[2474,\"^1S\",58,536871893]],[\"^84\",[2474,\"^1S\",2475,536871893]],[\"^84\",[2474,\"^69\",2475,536871893]],[\"^84\",[2474,\"^6:\",\"f\",536885142]],[\"^84\",[2474,\"^3;\",1775983693372,536885142]],[\"^84\",[2474,\"^2T\",\"~u69db5c4c-ddf7-4a3a-a05f-8a31dc7acded\",536871889]],[\"^84\",[2474,\"^4<\",181,536871889]],[\"^84\",[2475,\"^3D\",false,536885153]],[\"^84\",[2475,\"^48\",1775983696483,536871892]],[\"^84\",[2475,\"^7M\",\"tag2\",536871892]],[\"^84\",[2475,\"^1S\",14,536871892]],[\"^84\",[2475,\"^1S\",58,536871892]],[\"^84\",[2475,\"^1S\",89,536871892]],[\"^84\",[2475,\"^1S\",121,536871896]],[\"^84\",[2475,\"^1S\",158,536871892]],[\"^84\",[2475,\"^1S\",565,536871896]],[\"^84\",[2475,\"^1S\",568,536871897]],[\"^84\",[2475,\"^1S\",2477,536871899]],[\"^84\",[2475,\"^69\",14,536871892]],[\"^84\",[2475,\"^6:\",\"tag2\",536871892]],[\"^84\",[2475,\"^3;\",1775983696483,536871892]],[\"^84\",[2475,\"^2T\",\"~u69db5c50-3b82-4cb1-a425-271e7c8e6a03\",536871892]],[\"^84\",[2475,\"^;\",\"^3R\",536871892]],[\"^84\",[2475,\"^4<\",181,536871892]],[\"^84\",[2475,\"^3K\",89,536871892]],[\"^84\",[2475,\"^4K\",565,536871896]],[\"^84\",[2475,\"^4K\",568,536871897]],[\"^84\",[2475,\"^4K\",2477,536871899]],[\"^84\",[2476,\"^48\",1775983698445,536871894]],[\"^84\",[2476,\"^7@\",\"Zy\",536871894]],[\"^84\",[2476,\"^4S\",170,536871894]],[\"^84\",[2476,\"^3X\",170,536871894]],[\"^84\",[2476,\"^1S\",2475,536871894]],[\"^84\",[2476,\"^6:\",\"All\",536871894]],[\"^84\",[2476,\"^3;\",1775983698445,536871894]],[\"^84\",[2476,\"^2T\",\"~u00000006-2710-8879-5000-000000000000\",536871894]],[\"^84\",[2476,\"^4<\",181,536871894]],[\"^84\",[2476,\"^9\",2475,536871894]],[\"^84\",[2476,\"^5>\",\"^8O\",536871894]],[\"^84\",[2477,\"^48\",1775983711630,536871898]],[\"^84\",[2477,\"^7M\",\"p5\",536871898]],[\"^84\",[2477,\"^7@\",\"d3BK2\",536871898]],[\"^84\",[2477,\"^1S\",58,536871898]],[\"^84\",[2477,\"^1S\",104,536871900]],[\"^84\",[2477,\"^1S\",124,536871898]],[\"^84\",[2477,\"^69\",124,536871898]],[\"^84\",[2477,\"^6:\",\"p5\",536871898]],[\"^84\",[2477,\"^3;\",1775983717913,536885166]],[\"^84\",[2477,\"^2T\",\"~u00000002-1345-4970-5000-000000000000\",536871898]],[\"^84\",[2477,\"^<\",\"^14\",536885166]],[\"^84\",[2477,\"^;\",\"^23\",536871898]],[\"^84\",[2477,\"^>\",true,536871898]],[\"^84\",[2477,\"^C\",\"^D\",536871898]],[\"^84\",[2477,\"^6G\",104,536871900]],[\"^84\",[2477,\"^4<\",181,536871898]],[\"^84\",[2477,\"^1K\",\"^85\",536871898]],[\"^84\",[2577,\"^48\",1775983728413,536872235]],[\"^84\",[2577,\"^7@\",\"a0\",536872235]],[\"^84\",[2577,\"^4S\",180,536872235]],[\"^84\",[2577,\"^3X\",180,536872235]],[\"^84\",[2577,\"^6:\",\"1\",536872238]],[\"^84\",[2577,\"^S\",536872238,536872240]],[\"^84\",[2577,\"^3;\",1775983728422,536872238]],[\"^84\",[2577,\"^2T\",\"~u69db5bf3-a739-4564-9347-27fae2cc394d\",536872235]],[\"^84\",[2577,\"^4<\",181,536872236]],[\"^84\",[2578,\"^48\",1775983728424,536872239]],[\"^84\",[2578,\"^7@\",\"a0\",536872241]],[\"^84\",[2578,\"^4S\",180,536872239]],[\"^84\",[2578,\"^3X\",2577,536872241]],[\"^84\",[2578,\"^6:\",\"2\",536872243]],[\"^84\",[2578,\"^S\",536872243,536872245]],[\"^84\",[2578,\"^3;\",1775983728449,536872243]],[\"^84\",[2578,\"^2T\",\"~u69db5bf5-0ac5-4b52-82f3-b4d6d8314401\",536872239]],[\"^84\",[2578,\"^4<\",181,536872239]],[\"^84\",[2692,\"^48\",1775983729631,536872436]],[\"^84\",[2692,\"^7M\",\"page 8\",536872436]],[\"^84\",[2692,\"^1S\",58,536872438]],[\"^84\",[2692,\"^1S\",104,536872438]],[\"^84\",[2692,\"^69\",104,536872436]],[\"^84\",[2692,\"^6:\",\"page 8\",536872436]],[\"^84\",[2692,\"^S\",536872479,536872481]],[\"^84\",[2692,\"^3;\",1775983729797,536872479]],[\"^84\",[2692,\"^2T\",\"~u69db5c2a-601b-45ea-ab8f-13e01ea7787b\",536872436]],[\"^84\",[2692,\"^4<\",181,536872437]],[\"^84\",[2693,\"^48\",1775983729638,536872439]],[\"^84\",[2693,\"^7@\",\"a0\",536872439]],[\"^84\",[2693,\"^4S\",2692,536872439]],[\"^84\",[2693,\"^3X\",2692,536872439]],[\"^84\",[2693,\"^6:\",\"hello\",536872442]],[\"^84\",[2693,\"^S\",536872442,536872444]],[\"^84\",[2693,\"^3;\",1775983729647,536872442]],[\"^84\",[2693,\"^2T\",\"~u69db5c2b-3d88-4bad-ae1a-e068d1950520\",536872439]],[\"^84\",[2693,\"^4<\",181,536872440]],[\"^84\",[2694,\"^48\",1775983729648,536872443]],[\"^84\",[2694,\"^7@\",\"a1\",536872443]],[\"^84\",[2694,\"^4S\",2692,536872443]],[\"^84\",[2694,\"^3X\",2692,536872443]],[\"^84\",[2694,\"^6:\",\"worjfdaf\",536872445]],[\"^84\",[2694,\"^S\",536872445,536872447]],[\"^84\",[2694,\"^3;\",1775983729659,536872445]],[\"^84\",[2694,\"^2T\",\"~u69db5c2b-6a3d-4726-a69e-41d53fd5a0bf\",536872443]],[\"^84\",[2694,\"^4<\",181,536872443]],[\"^84\",[2695,\"^48\",1775983729661,536872446]],[\"^84\",[2695,\"^7@\",\"a2\",536872446]],[\"^84\",[2695,\"^4S\",2692,536872446]],[\"^84\",[2695,\"^3X\",2692,536872446]],[\"^84\",[2695,\"^6:\",\"\",536872446]],[\"^84\",[2695,\"^S\",536872445,536872447]],[\"^84\",[2695,\"^3;\",1775983729661,536872446]],[\"^84\",[2695,\"^2T\",\"~u69db5c2c-b373-49b6-a1f1-029b2f8ca8c8\",536872446]],[\"^84\",[2695,\"^4<\",181,536872446]],[\"^84\",[2696,\"^48\",1775983729672,536872448]],[\"^84\",[2696,\"^7@\",\"a0\",536872451]],[\"^84\",[2696,\"^4S\",2692,536872448]],[\"^84\",[2696,\"^3X\",2695,536872451]],[\"^84\",[2696,\"^6:\",\"f\",536872453]],[\"^84\",[2696,\"^S\",536872453,536872455]],[\"^84\",[2696,\"^3;\",1775983729688,536872453]],[\"^84\",[2696,\"^2T\",\"~u69db5c2c-ba52-4f79-85f8-a7655369ba6c\",536872448]],[\"^84\",[2696,\"^4<\",181,536872449]],[\"^84\",[2697,\"^48\",1775983729689,536872454]],[\"^84\",[2697,\"^7@\",\"a1\",536872454]],[\"^84\",[2697,\"^4S\",2692,536872454]],[\"^84\",[2697,\"^3X\",2695,536872454]],[\"^84\",[2697,\"^6:\",\"af\",536872456]],[\"^84\",[2697,\"^S\",536872456,536872458]],[\"^84\",[2697,\"^3;\",1775983729700,536872456]],[\"^84\",[2697,\"^2T\",\"~u69db5c2c-f588-4d67-89cf-637ed39de614\",536872454]],[\"^84\",[2697,\"^4<\",181,536872454]],[\"^84\",[2698,\"^48\",1775983729701,536872457]],[\"^84\",[2698,\"^7@\",\"a2\",536872457]],[\"^84\",[2698,\"^4S\",2692,536872457]],[\"^84\",[2698,\"^3X\",2695,536872457]],[\"^84\",[2698,\"^6:\",\"as\",536872459]],[\"^84\",[2698,\"^S\",536872459,536872461]],[\"^84\",[2698,\"^3;\",1775983729712,536872459]],[\"^84\",[2698,\"^2T\",\"~u69db5c2d-eb41-41f0-97cb-c9dace0102c2\",536872457]],[\"^84\",[2698,\"^4<\",181,536872457]],[\"^84\",[2699,\"^48\",1775983729714,536872460]],[\"^84\",[2699,\"^7@\",\"a3\",536872460]],[\"^84\",[2699,\"^4S\",2692,536872460]],[\"^84\",[2699,\"^3X\",2695,536872460]],[\"^84\",[2699,\"^6:\",\"fa\",536872462]],[\"^84\",[2699,\"^S\",536872462,536872464]],[\"^84\",[2699,\"^3;\",1775983729725,536872462]],[\"^84\",[2699,\"^2T\",\"~u69db5c2d-02cd-4b60-b743-09feff4e4825\",536872460]],[\"^84\",[2699,\"^4<\",181,536872460]],[\"^84\",[2700,\"^48\",1775983729726,536872463]],[\"^84\",[2700,\"^7@\",\"a4\",536872463]],[\"^84\",[2700,\"^4S\",2692,536872463]],[\"^84\",[2700,\"^3X\",2695,536872463]],[\"^84\",[2700,\"^6:\",\"f\",536872465]],[\"^84\",[2700,\"^S\",536872465,536872467]],[\"^84\",[2700,\"^3;\",1775983729738,536872465]],[\"^84\",[2700,\"^2T\",\"~u69db5c2d-a17f-4516-b106-304a1befd700\",536872463]],[\"^84\",[2700,\"^4<\",181,536872463]],[\"^84\",[2701,\"^48\",1775983729739,536872466]],[\"^84\",[2701,\"^7@\",\"a5\",536872466]],[\"^84\",[2701,\"^4S\",2692,536872466]],[\"^84\",[2701,\"^3X\",2695,536872466]],[\"^84\",[2701,\"^1S\",2702,536872474]],[\"^84\",[2701,\"^6:\",\"af\",536872468]],[\"^84\",[2701,\"^S\",536872474,536872475]],[\"^84\",[2701,\"^3;\",1775983665047,536872474]],[\"^84\",[2701,\"^2T\",\"~u69db5c2d-167b-455d-9de8-a098caf456e3\",536872466]],[\"^84\",[2701,\"^4<\",181,536872466]],[\"^84\",[2701,\"^4?\",2703,536872474]],[\"^84\",[2702,\"^48\",1775983729759,536872471]],[\"^84\",[2702,\"^7M\",\"p10\",536872471]],[\"^84\",[2702,\"^7@\",\"d3BHk\",536872471]],[\"^84\",[2702,\"^1S\",58,536872473]],[\"^84\",[2702,\"^1S\",124,536872473]],[\"^84\",[2702,\"^69\",124,536872471]],[\"^84\",[2702,\"^6:\",\"p10\",536872471]],[\"^84\",[2702,\"^S\",536872471,536872473]],[\"^84\",[2702,\"^3;\",1775983729759,536872471]],[\"^84\",[2702,\"^2T\",\"~u00000002-1449-8430-2100-000000000000\",536872471]],[\"^84\",[2702,\"^<\",\"^=\",536872471]],[\"^84\",[2702,\"^;\",\"^4?\",536872471]],[\"^84\",[2702,\"^>\",true,536872471]],[\"^84\",[2702,\"^C\",\"^D\",536872471]],[\"^84\",[2702,\"^4<\",181,536872472]],[\"^84\",[2702,\"^1K\",\"^8>\",536872471]],[\"^84\",[2703,\"^48\",1775983665044,536872474]],[\"^84\",[2703,\"^7@\",\"d3BHj\",536872474]],[\"^84\",[2703,\"^4S\",2692,536872474]],[\"^84\",[2703,\"^3X\",2701,536872474]],[\"^84\",[2703,\"^1S\",2702,536872474]],[\"^84\",[2703,\"^6:\",\"hello\",536872476]],[\"^84\",[2703,\"^S\",536872476,536872478]],[\"^84\",[2703,\"^3;\",1775983729784,536872476]],[\"^84\",[2703,\"^2T\",\"~u69db5c31-45d6-443b-8f8f-1b1808c86b4b\",536872474]],[\"^84\",[2703,\"^4<\",181,536872474]],[\"^84\",[2703,\"^6?\",2702,536872474]],[\"^84\",[2704,\"^48\",1775983729785,536872477]],[\"^84\",[2704,\"^7@\",\"a0\",536872477]],[\"^84\",[2704,\"^4S\",2692,536872477]],[\"^84\",[2704,\"^3X\",2703,536872477]],[\"^84\",[2704,\"^6:\",\"fjdalfjklaf\",536872479]],[\"^84\",[2704,\"^S\",536872479,536872481]],[\"^84\",[2704,\"^3;\",1775983729797,536872479]],[\"^84\",[2704,\"^2T\",\"~u69db5c32-c449-4614-89c5-afdad827a965\",536872477]],[\"^84\",[2704,\"^4<\",181,536872477]],[\"^84\",[2705,\"^;\",\"^4O\",536872484]],[\"^84\",[2705,\"^28\",1775984161304,536872484]],[\"^84\",[2706,\"^;\",\"^4J\",536872485]],[\"^84\",[2706,\"^28\",1775984161307,536872485]],[\"^84\",[2778,\"^48\",1775984527252,536872747]],[\"^84\",[2778,\"^7M\",\"page 10\",536872747]],[\"^84\",[2778,\"^1S\",58,536872749]],[\"^84\",[2778,\"^1S\",104,536872749]],[\"^84\",[2778,\"^69\",104,536872747]],[\"^84\",[2778,\"^6:\",\"page 10\",536872747]],[\"^84\",[2778,\"^S\",536872879,536872881]],[\"^84\",[2778,\"^3;\",1775984527765,536872879]],[\"^84\",[2778,\"^2T\",\"~u69db5f78-689c-4382-9a3d-d2caf761adb6\",536872747]],[\"^84\",[2778,\"^4<\",181,536872748]],[\"^84\",[2793,\"^48\",1775984527610,536872833]],[\"^84\",[2793,\"^7@\",\"a0\",536872833]],[\"^84\",[2793,\"^4S\",2778,536872833]],[\"^84\",[2793,\"^3X\",2778,536872833]],[\"^84\",[2793,\"^6:\",\"hellofda\",536872836]],[\"^84\",[2793,\"^S\",536872836,536872838]],[\"^84\",[2793,\"^3;\",1775984527618,536872836]],[\"^84\",[2793,\"^2T\",\"~u69db5f78-dcde-4e98-9f1a-1bca6c316e8d\",536872833]],[\"^84\",[2793,\"^4<\",181,536872834]],[\"^84\",[2794,\"^48\",1775984527619,536872837]],[\"^84\",[2794,\"^7@\",\"a1\",536872837]],[\"^84\",[2794,\"^4S\",2778,536872837]],[\"^84\",[2794,\"^3X\",2778,536872837]],[\"^84\",[2794,\"^6:\",\"fa\",536872839]],[\"^84\",[2794,\"^S\",536872839,536872841]],[\"^84\",[2794,\"^3;\",1775984527629,536872839]],[\"^84\",[2794,\"^2T\",\"~u69db5f79-a66e-4a39-aecc-339726c89430\",536872837]],[\"^84\",[2794,\"^4<\",181,536872837]],[\"^84\",[2795,\"^48\",1775984527630,536872840]],[\"^84\",[2795,\"^7@\",\"a0\",536872842]],[\"^84\",[2795,\"^4S\",2778,536872840]],[\"^84\",[2795,\"^3X\",2794,536872842]],[\"^84\",[2795,\"^6:\",\"fdasf\",536872844]],[\"^84\",[2795,\"^S\",536872844,536872846]],[\"^84\",[2795,\"^3;\",1775984527646,536872844]],[\"^84\",[2795,\"^2T\",\"~u69db5f79-fd04-4c4f-a0d5-726877b307a3\",536872840]],[\"^84\",[2795,\"^4<\",181,536872840]],[\"^84\",[2796,\"^48\",1775984527647,536872845]],[\"^84\",[2796,\"^7@\",\"a1\",536872845]],[\"^84\",[2796,\"^4S\",2778,536872845]],[\"^84\",[2796,\"^3X\",2794,536872845]],[\"^84\",[2796,\"^6:\",\"af\",536872847]],[\"^84\",[2796,\"^S\",536872847,536872849]],[\"^84\",[2796,\"^3;\",1775984527656,536872847]],[\"^84\",[2796,\"^2T\",\"~u69db5f7a-d6b1-4bf8-90c6-a70ba0bec381\",536872845]],[\"^84\",[2796,\"^4<\",181,536872845]],[\"^84\",[2797,\"^48\",1775984527658,536872848]],[\"^84\",[2797,\"^7@\",\"a2\",536872848]],[\"^84\",[2797,\"^4S\",2778,536872848]],[\"^84\",[2797,\"^3X\",2794,536872848]],[\"^84\",[2797,\"^6:\",\"as\",536872850]],[\"^84\",[2797,\"^S\",536872850,536872852]],[\"^84\",[2797,\"^3;\",1775984527666,536872850]],[\"^84\",[2797,\"^2T\",\"~u69db5f7a-e8a7-4800-bd8c-b2c14b6648a8\",536872848]],[\"^84\",[2797,\"^4<\",181,536872848]],[\"^84\",[2798,\"^48\",1775984527668,536872851]],[\"^84\",[2798,\"^7@\",\"a3\",536872851]],[\"^84\",[2798,\"^4S\",2778,536872851]],[\"^84\",[2798,\"^3X\",2794,536872851]],[\"^84\",[2798,\"^6:\",\"fdas\",536872853]],[\"^84\",[2798,\"^S\",536872853,536872855]],[\"^84\",[2798,\"^3;\",1775984527677,536872853]],[\"^84\",[2798,\"^2T\",\"~u69db5f7a-f814-4036-b2d5-06b1688f319f\",536872851]],[\"^84\",[2798,\"^4<\",181,536872851]],[\"^84\",[2799,\"^48\",1775984527678,536872854]],[\"^84\",[2799,\"^7@\",\"a2\",536872856]],[\"^84\",[2799,\"^4S\",2778,536872854]],[\"^84\",[2799,\"^3X\",2778,536872856]],[\"^84\",[2799,\"^6:\",\"fs\",536872858]],[\"^84\",[2799,\"^S\",536872858,536872860]],[\"^84\",[2799,\"^3;\",1775984527694,536872858]],[\"^84\",[2799,\"^2T\",\"~u69db5f7b-2941-4704-88d5-3d10f2b585f6\",536872854]],[\"^84\",[2799,\"^4<\",181,536872854]],[\"^84\",[2800,\"^48\",1775984527696,536872859]],[\"^84\",[2800,\"^7@\",\"a3\",536872859]],[\"^84\",[2800,\"^4S\",2778,536872859]],[\"^84\",[2800,\"^3X\",2778,536872859]],[\"^84\",[2800,\"^6:\",\"af\",536872861]],[\"^84\",[2800,\"^S\",536872861,536872863]],[\"^84\",[2800,\"^3;\",1775984527705,536872861]],[\"^84\",[2800,\"^2T\",\"~u69db5f7b-11a3-4387-b2d8-e4360d0574b0\",536872859]],[\"^84\",[2800,\"^4<\",181,536872859]],[\"^84\",[2801,\"^48\",1775984527706,536872862]],[\"^84\",[2801,\"^7@\",\"a4\",536872862]],[\"^84\",[2801,\"^4S\",2778,536872862]],[\"^84\",[2801,\"^3X\",2778,536872862]],[\"^84\",[2801,\"^6:\",\"as\",536872862]],[\"^84\",[2801,\"^S\",536872861,536872863]],[\"^84\",[2801,\"^3;\",1775984527706,536872862]],[\"^84\",[2801,\"^2T\",\"~u69db5f7b-24f0-4258-b5b5-7c86c9fb264e\",536872862]],[\"^84\",[2801,\"^4<\",181,536872862]],[\"^84\",[2802,\"^48\",1775984527715,536872864]],[\"^84\",[2802,\"^7@\",\"a3V\",536872864]],[\"^84\",[2802,\"^4S\",2778,536872864]],[\"^84\",[2802,\"^3X\",2778,536872864]],[\"^84\",[2802,\"^6:\",\"fd\",536872867]],[\"^84\",[2802,\"^S\",536872867,536872869]],[\"^84\",[2802,\"^3;\",1775984527722,536872867]],[\"^84\",[2802,\"^2T\",\"~u69db5f7b-02a5-40e9-baf8-befba0d845c5\",536872864]],[\"^84\",[2802,\"^4<\",181,536872865]],[\"^84\",[2803,\"^48\",1775984527723,536872868]],[\"^84\",[2803,\"^7@\",\"a3l\",536872868]],[\"^84\",[2803,\"^4S\",2778,536872868]],[\"^84\",[2803,\"^3X\",2778,536872868]],[\"^84\",[2803,\"^6:\",\"af\",536872870]],[\"^84\",[2803,\"^S\",536872870,536872872]],[\"^84\",[2803,\"^3;\",1775984527732,536872870]],[\"^84\",[2803,\"^2T\",\"~u69db5f7c-4a9c-4a5f-ad77-4d171ab63937\",536872868]],[\"^84\",[2803,\"^4<\",181,536872868]],[\"^84\",[2804,\"^48\",1775984527734,536872871]],[\"^84\",[2804,\"^7@\",\"a3t\",536872871]],[\"^84\",[2804,\"^4S\",2778,536872871]],[\"^84\",[2804,\"^3X\",2778,536872871]],[\"^84\",[2804,\"^6:\",\"af\",536872873]],[\"^84\",[2804,\"^S\",536872873,536872875]],[\"^84\",[2804,\"^3;\",1775984527743,536872873]],[\"^84\",[2804,\"^2T\",\"~u69db5f7c-e498-4db4-9791-53e4560eb1cc\",536872871]],[\"^84\",[2804,\"^4<\",181,536872871]],[\"^84\",[2805,\"^48\",1775984527744,536872874]],[\"^84\",[2805,\"^7@\",\"a3x\",536872874]],[\"^84\",[2805,\"^4S\",2778,536872874]],[\"^84\",[2805,\"^3X\",2778,536872874]],[\"^84\",[2805,\"^6:\",\"af\",536872876]],[\"^84\",[2805,\"^S\",536872876,536872878]],[\"^84\",[2805,\"^3;\",1775984527754,536872876]],[\"^84\",[2805,\"^2T\",\"~u69db5f7c-7746-45d5-8989-646ee3b9289b\",536872874]],[\"^84\",[2805,\"^4<\",181,536872874]],[\"^84\",[2806,\"^48\",1775984527755,536872877]],[\"^84\",[2806,\"^7@\",\"a3z\",536872877]],[\"^84\",[2806,\"^4S\",2778,536872877]],[\"^84\",[2806,\"^3X\",2778,536872877]],[\"^84\",[2806,\"^6:\",\"a\",536872879]],[\"^84\",[2806,\"^S\",536872879,536872881]],[\"^84\",[2806,\"^3;\",1775984527764,536872879]],[\"^84\",[2806,\"^2T\",\"~u69db5f7c-8423-4ed9-bb36-bf5156b4d757\",536872877]],[\"^84\",[2806,\"^4<\",181,536872877]],[\"^84\",[2931,\"^48\",1775984610679,536873146]],[\"^84\",[2931,\"^7M\",\"page 11\",536873146]],[\"^84\",[2931,\"^1S\",58,536873146]],[\"^84\",[2931,\"^1S\",104,536873146]],[\"^84\",[2931,\"^69\",104,536873146]],[\"^84\",[2931,\"^6:\",\"page 11\",536873146]],[\"^84\",[2931,\"^3;\",1775984651669,536885723]],[\"^84\",[2931,\"^2T\",\"~u69db5fe2-20d8-4c72-8f3a-96b778acec6f\",536873146]],[\"^84\",[2931,\"^4<\",181,536873146]],[\"^84\",[2932,\"^48\",1775984610938,536873147]],[\"^84\",[2932,\"^7@\",\"a0\",536873147]],[\"^84\",[2932,\"^4S\",2931,536873147]],[\"^84\",[2932,\"^3X\",2931,536873147]],[\"^84\",[2932,\"^6:\",\"fjdlafjl\",536885538]],[\"^84\",[2932,\"^3;\",1775984611435,536885538]],[\"^84\",[2932,\"^2T\",\"~u69db5fe2-56e1-428d-bf11-06cb38229be2\",536873147]],[\"^84\",[2932,\"^4<\",181,536873147]],[\"^84\",[2933,\"^48\",1775984611507,536873149]],[\"^84\",[2933,\"^7@\",\"a0\",536885545]],[\"^84\",[2933,\"^4S\",2931,536873149]],[\"^84\",[2933,\"^3X\",2932,536873150]],[\"^84\",[2933,\"^6:\",\"ff\",536885547]],[\"^84\",[2933,\"^3;\",1775984611935,536885547]],[\"^84\",[2933,\"^2T\",\"~u69db5fe3-6de1-4070-96b6-6fb5e16cfd5d\",536873149]],[\"^84\",[2933,\"^4<\",181,536873149]],[\"^84\",[2934,\"^48\",1775984611937,536873151]],[\"^84\",[2934,\"^7@\",\"a0\",536885555]],[\"^84\",[2934,\"^4S\",2931,536873151]],[\"^84\",[2934,\"^3X\",2933,536873154]],[\"^84\",[2934,\"^6:\",\"f\",536885554]],[\"^84\",[2934,\"^3;\",1775984612220,536885554]],[\"^84\",[2934,\"^2T\",\"~u69db5fe3-0419-47c1-9d77-36ba70846eae\",536873151]],[\"^84\",[2934,\"^4<\",181,536873151]],[\"^84\",[2935,\"^48\",1775984612293,536873155]],[\"^84\",[2935,\"^7@\",\"a0\",536885568]],[\"^84\",[2935,\"^4S\",2931,536873155]],[\"^84\",[2935,\"^3X\",2934,536873159]],[\"^84\",[2935,\"^6:\",\"f\",536885564]],[\"^84\",[2935,\"^3;\",1775984612724,536885567]],[\"^84\",[2935,\"^2T\",\"~u69db5fe4-4a1d-4a85-8ffc-13fa73cf7772\",536873155]],[\"^84\",[2935,\"^4<\",181,536873155]],[\"^84\",[2936,\"^48\",1775984612632,536873158]],[\"^84\",[2936,\"^7@\",\"a1\",536885573]],[\"^84\",[2936,\"^4S\",2931,536873158]],[\"^84\",[2936,\"^3X\",2934,536873161]],[\"^84\",[2936,\"^6:\",\"f\",536885569]],[\"^84\",[2936,\"^3;\",1775984612903,536885572]],[\"^84\",[2936,\"^2T\",\"~u69db5fe4-bafe-449d-acb5-c363728d2c3a\",536873158]],[\"^84\",[2936,\"^4<\",181,536873158]],[\"^84\",[2937,\"^48\",1775984612812,536873160]],[\"^84\",[2937,\"^7@\",\"a3\",536873160]],[\"^84\",[2937,\"^4S\",2931,536873160]],[\"^84\",[2937,\"^3X\",2933,536873160]],[\"^84\",[2937,\"^6:\",\"f\",536885574]],[\"^84\",[2937,\"^3;\",1775984613001,536885574]],[\"^84\",[2937,\"^2T\",\"~u69db5fe4-a665-4b11-82ef-9fa78a1d025c\",536873160]],[\"^84\",[2937,\"^4<\",181,536873160]],[\"^84\",[2938,\"^48\",1775984613002,536873162]],[\"^84\",[2938,\"^7@\",\"a0\",536885577]],[\"^84\",[2938,\"^4S\",2931,536873162]],[\"^84\",[2938,\"^3X\",2937,536873163]],[\"^84\",[2938,\"^6:\",\"f\",536885579]],[\"^84\",[2938,\"^3;\",1775984613203,536885579]],[\"^84\",[2938,\"^2T\",\"~u69db5fe4-e871-4f41-a9a6-d2783de5b724\",536873162]],[\"^84\",[2938,\"^4<\",181,536873162]],[\"^84\",[2939,\"^48\",1775984613203,536873164]],[\"^84\",[2939,\"^7@\",\"a2\",536885639]],[\"^84\",[2939,\"^4S\",2931,536873164]],[\"^84\",[2939,\"^3X\",2950,536873186]],[\"^84\",[2939,\"^6:\",\"q\",536885584]],[\"^84\",[2939,\"^3;\",1775984613413,536885584]],[\"^84\",[2939,\"^2T\",\"~u69db5fe5-c93e-4715-a9e2-b311246fe3f1\",536873164]],[\"^84\",[2939,\"^4<\",181,536873164]],[\"^84\",[2940,\"^48\",1775984613413,536873166]],[\"^84\",[2940,\"^7@\",\"a1\",536873166]],[\"^84\",[2940,\"^4S\",2931,536873166]],[\"^84\",[2940,\"^3X\",2938,536873166]],[\"^84\",[2940,\"^6:\",\"\",536873166]],[\"^84\",[2940,\"^3;\",1775984613413,536873166]],[\"^84\",[2940,\"^2T\",\"~u69db5fe5-b337-41af-9146-6e6e01e72130\",536873166]],[\"^84\",[2940,\"^4<\",181,536873166]],[\"^84\",[2941,\"^48\",1775984613561,536873168]],[\"^84\",[2941,\"^7@\",\"a2\",536885721]],[\"^84\",[2941,\"^4S\",2931,536873168]],[\"^84\",[2941,\"^3X\",2931,536873469]],[\"^84\",[2941,\"^6:\",\"fdafdasf\",536885723]],[\"^84\",[2941,\"^3;\",1775984651668,536885723]],[\"^84\",[2941,\"^2T\",\"~u69db5fe5-ce1b-4fb0-802c-ca3c7deba63a\",536873168]],[\"^84\",[2941,\"^4<\",181,536873168]],[\"^84\",[2942,\"^48\",1775984613782,536873170]],[\"^84\",[2942,\"^7@\",\"Zz\",536873170]],[\"^84\",[2942,\"^4S\",2931,536873170]],[\"^84\",[2942,\"^3X\",2940,536873170]],[\"^84\",[2942,\"^6:\",\"ff\",536885603]],[\"^84\",[2942,\"^3;\",1775984614190,536885606]],[\"^84\",[2942,\"^2T\",\"~u69db5fe5-5fad-4cca-bed6-f9e7c1e2a37d\",536873170]],[\"^84\",[2942,\"^4<\",181,536873170]],[\"^84\",[2943,\"^48\",1775984613943,536873172]],[\"^84\",[2943,\"^7@\",\"a1\",536885638]],[\"^84\",[2943,\"^4S\",2931,536873172]],[\"^84\",[2943,\"^3X\",2950,536873186]],[\"^84\",[2943,\"^6:\",\"\",536873172]],[\"^84\",[2943,\"^3;\",1775984613943,536873172]],[\"^84\",[2943,\"^2T\",\"~u69db5fe5-deee-4706-a78d-aaf4007518c1\",536873172]],[\"^84\",[2943,\"^4<\",181,536873172]],[\"^84\",[2944,\"^48\",1775984614111,536873173]],[\"^84\",[2944,\"^7@\",\"a0\",536885612]],[\"^84\",[2944,\"^4S\",2931,536873173]],[\"^84\",[2944,\"^3X\",2942,536873176]],[\"^84\",[2944,\"^6:\",\"f\",536885608]],[\"^84\",[2944,\"^3;\",1775984614359,536885611]],[\"^84\",[2944,\"^2T\",\"~u69db5fe6-fd79-4fa8-bfab-30986aa83bcc\",536873173]],[\"^84\",[2944,\"^4<\",181,536873173]],[\"^84\",[2945,\"^48\",1775984614283,536873175]],[\"^84\",[2945,\"^7@\",\"a1\",536885617]],[\"^84\",[2945,\"^4S\",2931,536873175]],[\"^84\",[2945,\"^3X\",2942,536873178]],[\"^84\",[2945,\"^6:\",\"f\",536885613]],[\"^84\",[2945,\"^3;\",1775984614536,536885616]],[\"^84\",[2945,\"^2T\",\"~u69db5fe6-437d-47c3-91e6-7eed23097b73\",536873175]],[\"^84\",[2945,\"^4<\",181,536873175]],[\"^84\",[2946,\"^48\",1775984614454,536873177]],[\"^84\",[2946,\"^7@\",\"ZzS\",536873177]],[\"^84\",[2946,\"^4S\",2931,536873177]],[\"^84\",[2946,\"^3X\",2940,536873177]],[\"^84\",[2946,\"^6:\",\"\",536873177]],[\"^84\",[2946,\"^3;\",1775984614454,536873177]],[\"^84\",[2946,\"^2T\",\"~u69db5fe6-a079-4c88-8d9c-bc0609008fe6\",536873177]],[\"^84\",[2946,\"^4<\",181,536873177]],[\"^84\",[2947,\"^48\",1775984614634,536873179]],[\"^84\",[2947,\"^7@\",\"a2\",536885627]],[\"^84\",[2947,\"^4S\",2931,536873179]],[\"^84\",[2947,\"^3X\",2946,536873182]],[\"^84\",[2947,\"^6:\",\"ff\",536885632]],[\"^84\",[2947,\"^3;\",1775984615139,536885632]],[\"^84\",[2947,\"^2T\",\"~u69db5fe6-9f47-45fd-bd1d-78cdad7e5894\",536873179]],[\"^84\",[2947,\"^4<\",181,536873179]],[\"^84\",[2948,\"^48\",1775984614835,536873181]],[\"^84\",[2948,\"^7@\",\"a1\",536873181]],[\"^84\",[2948,\"^4S\",2931,536873181]],[\"^84\",[2948,\"^3X\",2946,536873181]],[\"^84\",[2948,\"^6:\",\"\",536873181]],[\"^84\",[2948,\"^3;\",1775984614835,536873181]],[\"^84\",[2948,\"^2T\",\"~u69db5fe6-6b73-4f8c-a89d-3cca819ee63c\",536873181]],[\"^84\",[2948,\"^4<\",181,536873181]],[\"^84\",[2949,\"^48\",1775984614992,536873183]],[\"^84\",[2949,\"^7@\",\"a0\",536885636]],[\"^84\",[2949,\"^4S\",2931,536873183]],[\"^84\",[2949,\"^3X\",2950,536873185]],[\"^84\",[2949,\"^6:\",\"\",536873183]],[\"^84\",[2949,\"^3;\",1775984614992,536873183]],[\"^84\",[2949,\"^2T\",\"~u69db5fe6-d898-483f-bc9b-0122ac0cffe1\",536873183]],[\"^84\",[2949,\"^4<\",181,536873183]],[\"^84\",[2950,\"^48\",1775984615139,536873184]],[\"^84\",[2950,\"^7@\",\"a1\",536885643]],[\"^84\",[2950,\"^4S\",2931,536873184]],[\"^84\",[2950,\"^3X\",2931,536873190]],[\"^84\",[2950,\"^6:\",\"\",536873184]],[\"^84\",[2950,\"^3;\",1775984615139,536873184]],[\"^84\",[2950,\"^2T\",\"~u69db5fe7-2d4d-43a8-b50b-aa6625c817ae\",536873184]],[\"^84\",[2950,\"^4<\",181,536873184]],[\"^84\",[2988,\"^48\",1775984622069,536873306]],[\"^84\",[2988,\"^7@\",\"a0\",536873309]],[\"^84\",[2988,\"^4S\",180,536873306]],[\"^84\",[2988,\"^3X\",2578,536873309]],[\"^84\",[2988,\"^6:\",\"3f\",536873475]],[\"^84\",[2988,\"^S\",536873475,536873477]],[\"^84\",[2988,\"^3;\",1775984657451,536873475]],[\"^84\",[2988,\"^2T\",\"~u69db5fbe-a17a-4787-8615-7455e1eba752\",536873306]],[\"^84\",[2988,\"^4<\",181,536873307]],[\"^84\",[3104,\"^48\",1775984657454,536873476]],[\"^84\",[3104,\"^7@\",\"a1\",536873476]],[\"^84\",[3104,\"^4S\",180,536873476]],[\"^84\",[3104,\"^3X\",2578,536873476]],[\"^84\",[3104,\"^6:\",\"da\",536873478]],[\"^84\",[3104,\"^S\",536873478,536873480]],[\"^84\",[3104,\"^3;\",1775984657472,536873478]],[\"^84\",[3104,\"^2T\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873476]],[\"^84\",[3104,\"^4<\",181,536873476]],[\"^84\",[3105,\"^48\",1775984657475,536873479]],[\"^84\",[3105,\"^7@\",\"a2\",536873479]],[\"^84\",[3105,\"^4S\",180,536873479]],[\"^84\",[3105,\"^3X\",2578,536873479]],[\"^84\",[3105,\"^6:\",\"fda\",536873481]],[\"^84\",[3105,\"^S\",536873481,536873483]],[\"^84\",[3105,\"^3;\",1775984657494,536873481]],[\"^84\",[3105,\"^2T\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873479]],[\"^84\",[3105,\"^4<\",181,536873479]],[\"^84\",[3106,\"^48\",1775984657497,536873482]],[\"^84\",[3106,\"^7@\",\"a3\",536873482]],[\"^84\",[3106,\"^4S\",180,536873482]],[\"^84\",[3106,\"^3X\",2578,536873482]],[\"^84\",[3106,\"^6:\",\"f\",536873484]],[\"^84\",[3106,\"^S\",536873484,536873486]],[\"^84\",[3106,\"^3;\",1775984657516,536873484]],[\"^84\",[3106,\"^2T\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873482]],[\"^84\",[3106,\"^4<\",181,536873482]],[\"^84\",[3107,\"^48\",1775984657519,536873485]],[\"^84\",[3107,\"^7@\",\"a1\",536873487]],[\"^84\",[3107,\"^4S\",180,536873485]],[\"^84\",[3107,\"^3X\",180,536873489]],[\"^84\",[3107,\"^6:\",\"\",536873485]],[\"^84\",[3107,\"^S\",536873489,536873490]],[\"^84\",[3107,\"^3;\",1775984657519,536873485]],[\"^84\",[3107,\"^2T\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873485]],[\"^84\",[3107,\"^4<\",181,536873485]],[\"^84\",[3108,\"^48\",1775984657567,536873491]],[\"^84\",[3108,\"^7@\",\"a2\",536873491]],[\"^84\",[3108,\"^4S\",180,536873491]],[\"^84\",[3108,\"^3X\",180,536873491]],[\"^84\",[3108,\"^6:\",\"\",536873491]],[\"^84\",[3108,\"^S\",536873491,536873493]],[\"^84\",[3108,\"^3;\",1775984657567,536873491]],[\"^84\",[3108,\"^2T\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873491]],[\"^84\",[3108,\"^4<\",181,536873492]],[\"^84\",[3109,\"^48\",1775984657583,536873494]],[\"^84\",[3109,\"^7@\",\"a3\",536873494]],[\"^84\",[3109,\"^4S\",180,536873494]],[\"^84\",[3109,\"^3X\",180,536873494]],[\"^84\",[3109,\"^6:\",\"fdasf\",536873497]],[\"^84\",[3109,\"^S\",536873497,536873499]],[\"^84\",[3109,\"^3;\",1775984657602,536873497]],[\"^84\",[3109,\"^2T\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873494]],[\"^84\",[3109,\"^4<\",181,536873495]],[\"^84\",[3110,\"^48\",1775984657605,536873498]],[\"^84\",[3110,\"^7@\",\"a4\",536873498]],[\"^84\",[3110,\"^4S\",180,536873498]],[\"^84\",[3110,\"^3X\",180,536873498]],[\"^84\",[3110,\"^6:\",\"a\",536873500]],[\"^84\",[3110,\"^S\",536873500,536873502]],[\"^84\",[3110,\"^3;\",1775984657625,536873500]],[\"^84\",[3110,\"^2T\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873498]],[\"^84\",[3110,\"^4<\",181,536873498]],[\"^84\",[3111,\"^48\",1775984657627,536873501]],[\"^84\",[3111,\"^7@\",\"a0\",536873504]],[\"^84\",[3111,\"^4S\",180,536873501]],[\"^84\",[3111,\"^3X\",3110,536873504]],[\"^84\",[3111,\"^6:\",\"ffd\",536873506]],[\"^84\",[3111,\"^S\",536873506,536873508]],[\"^84\",[3111,\"^3;\",1775984657668,536873506]],[\"^84\",[3111,\"^2T\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873501]],[\"^84\",[3111,\"^4<\",181,536873501]],[\"^84\",[3112,\"^48\",1775984657671,536873507]],[\"^84\",[3112,\"^7@\",\"a1\",536873507]],[\"^84\",[3112,\"^4S\",180,536873507]],[\"^84\",[3112,\"^3X\",3110,536873507]],[\"^84\",[3112,\"^6:\",\"asf\",536873509]],[\"^84\",[3112,\"^S\",536873509,536873511]],[\"^84\",[3112,\"^3;\",1775984657690,536873509]],[\"^84\",[3112,\"^2T\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873507]],[\"^84\",[3112,\"^4<\",181,536873507]],[\"^84\",[3113,\"^48\",1775984657693,536873510]],[\"^84\",[3113,\"^7@\",\"a2\",536873514]],[\"^84\",[3113,\"^4S\",180,536873510]],[\"^84\",[3113,\"^3X\",3110,536873514]],[\"^84\",[3113,\"^6:\",\"f\",536873516]],[\"^84\",[3113,\"^S\",536873516,536873518]],[\"^84\",[3113,\"^3;\",1775984657739,536873516]],[\"^84\",[3113,\"^2T\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873510]],[\"^84\",[3113,\"^4<\",181,536873510]],[\"^84\",[3114,\"^48\",1775984657742,536873517]],[\"^84\",[3114,\"^7@\",\"a0\",536873519]],[\"^84\",[3114,\"^4S\",180,536873517]],[\"^84\",[3114,\"^3X\",3112,536873519]],[\"^84\",[3114,\"^6:\",\"\",536873517]],[\"^84\",[3114,\"^S\",536873519,536873520]],[\"^84\",[3114,\"^3;\",1775984657742,536873517]],[\"^84\",[3114,\"^2T\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873517]],[\"^84\",[3114,\"^4<\",181,536873517]],[\"^84\",[3115,\"^48\",1775984657775,536873521]],[\"^84\",[3115,\"^7@\",\"a1\",536873521]],[\"^84\",[3115,\"^4S\",180,536873521]],[\"^84\",[3115,\"^3X\",3112,536873521]],[\"^84\",[3115,\"^6:\",\"\",536873521]],[\"^84\",[3115,\"^S\",536873521,536873523]],[\"^84\",[3115,\"^3;\",1775984657775,536873521]],[\"^84\",[3115,\"^2T\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873521]],[\"^84\",[3115,\"^4<\",181,536873522]],[\"^84\",[3116,\"^48\",1775984657790,536873524]],[\"^84\",[3116,\"^7@\",\"a0\",536873530]],[\"^84\",[3116,\"^4S\",180,536873524]],[\"^84\",[3116,\"^3X\",3114,536873530]],[\"^84\",[3116,\"^6:\",\"\",536873524]],[\"^84\",[3116,\"^S\",536873530,536873531]],[\"^84\",[3116,\"^3;\",1775984657790,536873524]],[\"^84\",[3116,\"^2T\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873524]],[\"^84\",[3116,\"^4<\",181,536873525]],[\"^84\",[3117,\"^48\",1775984657810,536873527]],[\"^84\",[3117,\"^7@\",\"a0l\",536873527]],[\"^84\",[3117,\"^4S\",180,536873527]],[\"^84\",[3117,\"^3X\",3112,536873527]],[\"^84\",[3117,\"^6:\",\"f\",536873532]],[\"^84\",[3117,\"^S\",536873532,536873534]],[\"^84\",[3117,\"^3;\",1775984657857,536873532]],[\"^84\",[3117,\"^2T\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873527]],[\"^84\",[3117,\"^4<\",181,536873528]],[\"^84\",[3118,\"^48\",1775984657861,536873533]],[\"^84\",[3118,\"^7@\",\"a0t\",536873533]],[\"^84\",[3118,\"^4S\",180,536873533]],[\"^84\",[3118,\"^3X\",3112,536873533]],[\"^84\",[3118,\"^6:\",\"\",536873533]],[\"^84\",[3118,\"^S\",536873532,536873534]],[\"^84\",[3118,\"^3;\",1775984657861,536873533]],[\"^84\",[3118,\"^2T\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873533]],[\"^84\",[3118,\"^4<\",181,536873533]]]]]]", :tx-data "[[\"~#datascript/Datom\",[3046,\"~:block/tx-id\",536873442,536873445,false]],[\"^0\",[3046,\"~:logseq.property/created-by-ref\",181,536873445,false]],[\"^0\",[3046,\"~:block/page\",180,536873445,false]],[\"^0\",[3046,\"~:block/order\",\"a0t\",536873445,false]],[\"^0\",[3046,\"~:block/parent\",3040,536873445,false]],[\"^0\",[3046,\"~:block/title\",\"\",536873445,false]],[\"^0\",[3046,\"~:block/created-at\",1775984647523,536873445,false]],[\"^0\",[3046,\"~:block/updated-at\",1775984647523,536873445,false]],[\"^0\",[3045,\"^1\",536873442,536873445,false]],[\"^0\",[3045,\"^1\",536873437,536873445]],[\"^0\",[180,\"^1\",536873442,536873445,false]],[\"^0\",[180,\"^1\",536873440,536873445]],[\"^0\",[3045,\"^6\",\"f\",536873445,false]],[\"^0\",[3045,\"^6\",\"\",536873445]],[\"^0\",[3045,\"^8\",1775984647522,536873445,false]],[\"^0\",[3045,\"^8\",1775984647315,536873445]],[\"^0\",[180,\"^8\",1775984647522,536873445,false]],[\"^0\",[180,\"^8\",1775984646786,536873445]],[\"^0\",[3046,\"~:block/uuid\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873445,false]],[\"^0\",[3044,\"^1\",536873440,536873446,false]],[\"^0\",[3044,\"^1\",536873434,536873446]],[\"^0\",[180,\"^1\",536873440,536873446,false]],[\"^0\",[180,\"^1\",536873437,536873446]],[\"^0\",[3044,\"^4\",\"a0\",536873446,false]],[\"^0\",[3044,\"^4\",\"a0V\",536873446]],[\"^0\",[3044,\"^5\",3042,536873446,false]],[\"^0\",[3044,\"^5\",3040,536873446]],[\"^0\",[3045,\"^1\",536873437,536873447,false]],[\"^0\",[3045,\"^2\",181,536873447,false]],[\"^0\",[3045,\"^3\",180,536873447,false]],[\"^0\",[3045,\"^4\",\"a0l\",536873447,false]],[\"^0\",[3045,\"^5\",3040,536873447,false]],[\"^0\",[3045,\"^6\",\"\",536873447,false]],[\"^0\",[3045,\"^7\",1775984647315,536873447,false]],[\"^0\",[3045,\"^8\",1775984647315,536873447,false]],[\"^0\",[180,\"^1\",536873437,536873447,false]],[\"^0\",[180,\"^1\",536873434,536873447]],[\"^0\",[3045,\"^9\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873447,false]],[\"^0\",[3044,\"^1\",536873434,536873448,false]],[\"^0\",[3044,\"^2\",181,536873448,false]],[\"^0\",[3044,\"^3\",180,536873448,false]],[\"^0\",[3044,\"^4\",\"a0V\",536873448,false]],[\"^0\",[3044,\"^5\",3040,536873448,false]],[\"^0\",[3044,\"^6\",\"\",536873448,false]],[\"^0\",[3044,\"^7\",1775984647121,536873448,false]],[\"^0\",[3044,\"^8\",1775984647121,536873448,false]],[\"^0\",[180,\"^1\",536873434,536873448,false]],[\"^0\",[180,\"^1\",536873431,536873448]],[\"^0\",[3044,\"^9\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873448,false]],[\"^0\",[3043,\"^1\",536873431,536873449,false]],[\"^0\",[3043,\"^2\",181,536873449,false]],[\"^0\",[3043,\"^3\",180,536873449,false]],[\"^0\",[3043,\"^4\",\"a1\",536873449,false]],[\"^0\",[3043,\"^5\",3040,536873449,false]],[\"^0\",[3043,\"^6\",\"\",536873449,false]],[\"^0\",[3043,\"^7\",1775984646989,536873449,false]],[\"^0\",[3043,\"^8\",1775984646989,536873449,false]],[\"^0\",[180,\"^1\",536873431,536873449,false]],[\"^0\",[180,\"^1\",536873429,536873449]],[\"^0\",[3043,\"^9\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873449,false]],[\"^0\",[3042,\"^1\",536873429,536873450,false]],[\"^0\",[3042,\"^1\",536873426,536873450]],[\"^0\",[180,\"^1\",536873429,536873450,false]],[\"^0\",[180,\"^1\",536873426,536873450]],[\"^0\",[3042,\"^4\",\"a0\",536873450,false]],[\"^0\",[3042,\"^4\",\"a1V\",536873450]],[\"^0\",[3042,\"^5\",3040,536873450,false]],[\"^0\",[3042,\"^5\",3038,536873450]],[\"^0\",[3042,\"^1\",536873426,536873451,false]],[\"^0\",[3042,\"^2\",181,536873451,false]],[\"^0\",[3042,\"^3\",180,536873451,false]],[\"^0\",[3042,\"^4\",\"a1V\",536873451,false]],[\"^0\",[3042,\"^5\",3038,536873451,false]],[\"^0\",[3042,\"^6\",\"\",536873451,false]],[\"^0\",[3042,\"^7\",1775984646787,536873451,false]],[\"^0\",[3042,\"^8\",1775984646787,536873451,false]],[\"^0\",[3041,\"^1\",536873426,536873451,false]],[\"^0\",[3041,\"^1\",536873424,536873451]],[\"^0\",[180,\"^1\",536873426,536873451,false]],[\"^0\",[180,\"^1\",536873424,536873451]],[\"^0\",[3041,\"^6\",\"f\",536873451,false]],[\"^0\",[3041,\"^6\",\"\",536873451]],[\"^0\",[3041,\"^8\",1775984646786,536873451,false]],[\"^0\",[3041,\"^8\",1775984646416,536873451]],[\"^0\",[180,\"^8\",1775984646786,536873451,false]],[\"^0\",[180,\"^8\",1775984646415,536873451]],[\"^0\",[3042,\"^9\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873451,false]],[\"^0\",[3041,\"^1\",536873424,536873452,false]],[\"^0\",[3041,\"^1\",536873422,536873452]],[\"^0\",[180,\"^1\",536873424,536873452,false]],[\"^0\",[180,\"^1\",536873422,536873452]],[\"^0\",[3041,\"^4\",\"a2\",536873452,false]],[\"^0\",[3041,\"^4\",\"a0\",536873452]],[\"^0\",[3041,\"^5\",3038,536873452,false]],[\"^0\",[3041,\"^5\",3040,536873452]],[\"^0\",[3041,\"^1\",536873422,536873453,false]],[\"^0\",[3041,\"^1\",536873419,536873453]],[\"^0\",[180,\"^1\",536873422,536873453,false]],[\"^0\",[180,\"^1\",536873419,536873453]],[\"^0\",[3041,\"^4\",\"a0\",536873453,false]],[\"^0\",[3041,\"^4\",\"a2\",536873453]],[\"^0\",[3041,\"^5\",3040,536873453,false]],[\"^0\",[3041,\"^5\",3038,536873453]],[\"^0\",[3041,\"^1\",536873419,536873454,false]],[\"^0\",[3041,\"^2\",181,536873454,false]],[\"^0\",[3041,\"^3\",180,536873454,false]],[\"^0\",[3041,\"^4\",\"a2\",536873454,false]],[\"^0\",[3041,\"^5\",3038,536873454,false]],[\"^0\",[3041,\"^6\",\"\",536873454,false]],[\"^0\",[3041,\"^7\",1775984646416,536873454,false]],[\"^0\",[3041,\"^8\",1775984646416,536873454,false]],[\"^0\",[3040,\"^1\",536873419,536873454,false]],[\"^0\",[3040,\"^1\",536873416,536873454]],[\"^0\",[180,\"^1\",536873419,536873454,false]],[\"^0\",[180,\"^1\",536873416,536873454]],[\"^0\",[3040,\"^6\",\"asf\",536873454,false]],[\"^0\",[3040,\"^6\",\"\",536873454]],[\"^0\",[3040,\"^8\",1775984646415,536873454,false]],[\"^0\",[3040,\"^8\",1775984646216,536873454]],[\"^0\",[180,\"^8\",1775984646415,536873454,false]],[\"^0\",[180,\"^8\",1775984646215,536873454]],[\"^0\",[3041,\"^9\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873454,false]],[\"^0\",[3040,\"^1\",536873416,536873455,false]],[\"^0\",[3040,\"^2\",181,536873455,false]],[\"^0\",[3040,\"^3\",180,536873455,false]],[\"^0\",[3040,\"^4\",\"a1\",536873455,false]],[\"^0\",[3040,\"^5\",3038,536873455,false]],[\"^0\",[3040,\"^6\",\"\",536873455,false]],[\"^0\",[3040,\"^7\",1775984646216,536873455,false]],[\"^0\",[3040,\"^8\",1775984646216,536873455,false]],[\"^0\",[3039,\"^1\",536873416,536873455,false]],[\"^0\",[3039,\"^1\",536873413,536873455]],[\"^0\",[180,\"^1\",536873416,536873455,false]],[\"^0\",[180,\"^1\",536873413,536873455]],[\"^0\",[3039,\"^6\",\"ffd\",536873455,false]],[\"^0\",[3039,\"^6\",\"f\",536873455]],[\"^0\",[3039,\"^8\",1775984646215,536873455,false]],[\"^0\",[3039,\"^8\",1775984645864,536873455]],[\"^0\",[180,\"^8\",1775984646215,536873455,false]],[\"^0\",[180,\"^8\",1775984645864,536873455]],[\"^0\",[3040,\"^9\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873455,false]],[\"^0\",[3039,\"^1\",536873413,536873456,false]],[\"^0\",[3039,\"^1\",536873410,536873456]],[\"^0\",[180,\"^1\",536873413,536873456,false]],[\"^0\",[180,\"^1\",536873410,536873456]],[\"^0\",[3039,\"^4\",\"a0\",536873456,false]],[\"^0\",[3039,\"^4\",\"a5\",536873456]],[\"^0\",[3039,\"^5\",3038,536873456,false]],[\"^0\",[3039,\"^5\",180,536873456]],[\"^0\",[3039,\"^6\",\"f\",536873456,false]],[\"^0\",[3039,\"^6\",\"\",536873456]],[\"^0\",[3039,\"^8\",1775984645864,536873456,false]],[\"^0\",[3039,\"^8\",1775984645692,536873456]],[\"^0\",[180,\"^8\",1775984645864,536873456,false]],[\"^0\",[180,\"^8\",1775984645691,536873456]],[\"^0\",[3039,\"^1\",536873410,536873457,false]],[\"^0\",[3039,\"^2\",181,536873457,false]],[\"^0\",[3039,\"^3\",180,536873457,false]],[\"^0\",[3039,\"^4\",\"a5\",536873457,false]],[\"^0\",[3039,\"^5\",180,536873457,false]],[\"^0\",[3039,\"^6\",\"\",536873457,false]],[\"^0\",[3039,\"^7\",1775984645692,536873457,false]],[\"^0\",[3039,\"^8\",1775984645692,536873457,false]],[\"^0\",[3038,\"^1\",536873410,536873457,false]],[\"^0\",[3038,\"^1\",536873407,536873457]],[\"^0\",[180,\"^1\",536873410,536873457,false]],[\"^0\",[180,\"^1\",536873407,536873457]],[\"^0\",[3038,\"^6\",\"a\",536873457,false]],[\"^0\",[3038,\"^6\",\"\",536873457]],[\"^0\",[3038,\"^8\",1775984645690,536873457,false]],[\"^0\",[3038,\"^8\",1775984645526,536873457]],[\"^0\",[180,\"^8\",1775984645691,536873457,false]],[\"^0\",[180,\"^8\",1775984645525,536873457]],[\"^0\",[3039,\"^9\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873457,false]],[\"^0\",[3038,\"^1\",536873407,536873458,false]],[\"^0\",[3038,\"^2\",181,536873458,false]],[\"^0\",[3038,\"^3\",180,536873458,false]],[\"^0\",[3038,\"^4\",\"a4\",536873458,false]],[\"^0\",[3038,\"^5\",180,536873458,false]],[\"^0\",[3038,\"^6\",\"\",536873458,false]],[\"^0\",[3038,\"^7\",1775984645526,536873458,false]],[\"^0\",[3038,\"^8\",1775984645526,536873458,false]],[\"^0\",[3037,\"^1\",536873407,536873458,false]],[\"^0\",[3037,\"^1\",536873404,536873458]],[\"^0\",[180,\"^1\",536873407,536873458,false]],[\"^0\",[180,\"^1\",536873404,536873458]],[\"^0\",[3037,\"^6\",\"fdasf\",536873458,false]],[\"^0\",[3037,\"^6\",\"\",536873458]],[\"^0\",[3037,\"^8\",1775984645524,536873458,false]],[\"^0\",[3037,\"^8\",1775984645181,536873458]],[\"^0\",[180,\"^8\",1775984645525,536873458,false]],[\"^0\",[180,\"^8\",1775984644505,536873458]],[\"^0\",[3038,\"^9\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873458,false]],[\"^0\",[3037,\"^1\",536873404,536873459,false]],[\"^0\",[3037,\"^2\",181,536873459,false]],[\"^0\",[3037,\"^3\",180,536873459,false]],[\"^0\",[3037,\"^4\",\"a3\",536873459,false]],[\"^0\",[3037,\"^5\",180,536873459,false]],[\"^0\",[3037,\"^6\",\"\",536873459,false]],[\"^0\",[3037,\"^7\",1775984645181,536873459,false]],[\"^0\",[3037,\"^8\",1775984645181,536873459,false]],[\"^0\",[180,\"^1\",536873404,536873459,false]],[\"^0\",[180,\"^1\",536873401,536873459]],[\"^0\",[3037,\"^9\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873459,false]],[\"^0\",[3036,\"^1\",536873401,536873460,false]],[\"^0\",[3036,\"^2\",181,536873460,false]],[\"^0\",[3036,\"^3\",180,536873460,false]],[\"^0\",[3036,\"^4\",\"a2\",536873460,false]],[\"^0\",[3036,\"^5\",180,536873460,false]],[\"^0\",[3036,\"^6\",\"\",536873460,false]],[\"^0\",[3036,\"^7\",1775984645026,536873460,false]],[\"^0\",[3036,\"^8\",1775984645026,536873460,false]],[\"^0\",[180,\"^1\",536873401,536873460,false]],[\"^0\",[180,\"^1\",536873399,536873460]],[\"^0\",[3036,\"^9\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873460,false]],[\"^0\",[3035,\"^1\",536873399,536873461,false]],[\"^0\",[3035,\"^1\",536873397,536873461]],[\"^0\",[180,\"^1\",536873399,536873461,false]],[\"^0\",[180,\"^1\",536873397,536873461]],[\"^0\",[3035,\"^5\",180,536873461,false]],[\"^0\",[3035,\"^5\",2577,536873461]],[\"^0\",[3035,\"^1\",536873397,536873462,false]],[\"^0\",[3035,\"^1\",536873394,536873462]],[\"^0\",[180,\"^1\",536873397,536873462,false]],[\"^0\",[180,\"^1\",536873394,536873462]],[\"^0\",[3035,\"^4\",\"a1\",536873462,false]],[\"^0\",[3035,\"^4\",\"a4\",536873462]],[\"^0\",[3035,\"^5\",2577,536873462,false]],[\"^0\",[3035,\"^5\",2578,536873462]],[\"^0\",[3035,\"^1\",536873394,536873463,false]],[\"^0\",[3035,\"^2\",181,536873463,false]],[\"^0\",[3035,\"^3\",180,536873463,false]],[\"^0\",[3035,\"^4\",\"a4\",536873463,false]],[\"^0\",[3035,\"^5\",2578,536873463,false]],[\"^0\",[3035,\"^6\",\"\",536873463,false]],[\"^0\",[3035,\"^7\",1775984644506,536873463,false]],[\"^0\",[3035,\"^8\",1775984644506,536873463,false]],[\"^0\",[3034,\"^1\",536873394,536873463,false]],[\"^0\",[3034,\"^1\",536873391,536873463]],[\"^0\",[180,\"^1\",536873394,536873463,false]],[\"^0\",[180,\"^1\",536873391,536873463]],[\"^0\",[3034,\"^6\",\"f\",536873463,false]],[\"^0\",[3034,\"^6\",\"\",536873463]],[\"^0\",[3034,\"^8\",1775984644505,536873463,false]],[\"^0\",[3034,\"^8\",1775984644336,536873463]],[\"^0\",[180,\"^8\",1775984644505,536873463,false]],[\"^0\",[180,\"^8\",1775984644335,536873463]],[\"^0\",[3035,\"^9\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873463,false]],[\"^0\",[3034,\"^1\",536873391,536873464,false]],[\"^0\",[3034,\"^2\",181,536873464,false]],[\"^0\",[3034,\"^3\",180,536873464,false]],[\"^0\",[3034,\"^4\",\"a3\",536873464,false]],[\"^0\",[3034,\"^5\",2578,536873464,false]],[\"^0\",[3034,\"^6\",\"\",536873464,false]],[\"^0\",[3034,\"^7\",1775984644336,536873464,false]],[\"^0\",[3034,\"^8\",1775984644336,536873464,false]],[\"^0\",[3033,\"^1\",536873391,536873464,false]],[\"^0\",[3033,\"^1\",536873388,536873464]],[\"^0\",[180,\"^1\",536873391,536873464,false]],[\"^0\",[180,\"^1\",536873388,536873464]],[\"^0\",[3033,\"^6\",\"fda\",536873464,false]],[\"^0\",[3033,\"^6\",\"\",536873464]],[\"^0\",[3033,\"^8\",1775984644335,536873464,false]],[\"^0\",[3033,\"^8\",1775984644156,536873464]],[\"^0\",[180,\"^8\",1775984644335,536873464,false]],[\"^0\",[180,\"^8\",1775984644155,536873464]],[\"^0\",[3034,\"^9\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873464,false]],[\"^0\",[3033,\"^1\",536873388,536873465,false]],[\"^0\",[3033,\"^2\",181,536873465,false]],[\"^0\",[3033,\"^3\",180,536873465,false]],[\"^0\",[3033,\"^4\",\"a2\",536873465,false]],[\"^0\",[3033,\"^5\",2578,536873465,false]],[\"^0\",[3033,\"^6\",\"\",536873465,false]],[\"^0\",[3033,\"^7\",1775984644156,536873465,false]],[\"^0\",[3033,\"^8\",1775984644156,536873465,false]],[\"^0\",[3032,\"^1\",536873388,536873465,false]],[\"^0\",[3032,\"^1\",536873385,536873465]],[\"^0\",[180,\"^1\",536873388,536873465,false]],[\"^0\",[180,\"^1\",536873385,536873465]],[\"^0\",[3032,\"^6\",\"da\",536873465,false]],[\"^0\",[3032,\"^6\",\"\",536873465]],[\"^0\",[3032,\"^8\",1775984644155,536873465,false]],[\"^0\",[3032,\"^8\",1775984643975,536873465]],[\"^0\",[180,\"^8\",1775984644155,536873465,false]],[\"^0\",[180,\"^8\",1775984643974,536873465]],[\"^0\",[3033,\"^9\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873465,false]],[\"^0\",[3032,\"^1\",536873385,536873466,false]],[\"^0\",[3032,\"^2\",181,536873466,false]],[\"^0\",[3032,\"^3\",180,536873466,false]],[\"^0\",[3032,\"^4\",\"a1\",536873466,false]],[\"^0\",[3032,\"^5\",2578,536873466,false]],[\"^0\",[3032,\"^6\",\"\",536873466,false]],[\"^0\",[3032,\"^7\",1775984643975,536873466,false]],[\"^0\",[3032,\"^8\",1775984643975,536873466,false]],[\"^0\",[2988,\"^1\",536873385,536873466,false]],[\"^0\",[2988,\"^1\",536873311,536873466]],[\"^0\",[180,\"^1\",536873385,536873466,false]],[\"^0\",[180,\"^1\",536873383,536873466]],[\"^0\",[2988,\"^6\",\"3f\",536873466,false]],[\"^0\",[2988,\"^6\",\"3\",536873466]],[\"^0\",[2988,\"^8\",1775984643973,536873466,false]],[\"^0\",[2988,\"^8\",1775984622083,536873466]],[\"^0\",[180,\"^8\",1775984643974,536873466,false]],[\"^0\",[180,\"^8\",1775984622122,536873466]],[\"^0\",[3032,\"^9\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873466,false]],[\"^0\",[180,\"^1\",536873383,536873467,false]],[\"^0\",[180,\"^1\",536873381,536873467]],[\"^0\",[3047,\"^2\",181,536873467]],[\"^0\",[3047,\"^9\",\"~u69db5f8b-d66e-43df-ab5f-7d57a066f4e0\",536873467]],[\"^0\",[3047,\"^8\",1775984523541,536873467]],[\"^0\",[3047,\"^6\",\"f\",536873467]],[\"^0\",[3047,\"^3\",180,536873467]],[\"^0\",[3047,\"^4\",\"a4\",536873467]],[\"^0\",[3047,\"^7\",1775984523179,536873467]],[\"^0\",[3048,\"^2\",181,536873467]],[\"^0\",[3048,\"^9\",\"~u69db5f8a-c743-4d46-8a33-844916e45c4b\",536873467]],[\"^0\",[3048,\"^8\",1775984523266,536873467]],[\"^0\",[3048,\"^6\",\"f\",536873467]],[\"^0\",[3048,\"^3\",180,536873467]],[\"^0\",[3048,\"^4\",\"a3\",536873467]],[\"^0\",[3048,\"^7\",1775984523002,536873467]],[\"^0\",[3049,\"^2\",181,536873467]],[\"^0\",[3049,\"^9\",\"~u69db5f8a-9e79-49ce-9a5c-dad08cd22189\",536873467]],[\"^0\",[3049,\"^8\",1775984522893,536873467]],[\"^0\",[3049,\"^6\",\"\",536873467]],[\"^0\",[3049,\"^3\",180,536873467]],[\"^0\",[3049,\"^4\",\"a1\",536873467]],[\"^0\",[3049,\"^7\",1775984522893,536873467]],[\"^0\",[3050,\"^2\",181,536873467]],[\"^0\",[3050,\"^9\",\"~u69db5f8a-3da9-40e5-a22f-c8f9b13a78af\",536873467]],[\"^0\",[3050,\"^8\",1775984522893,536873467]],[\"^0\",[3050,\"^6\",\"f\",536873467]],[\"^0\",[3050,\"^3\",180,536873467]],[\"^0\",[3050,\"^4\",\"a2\",536873467]],[\"^0\",[3050,\"^7\",1775984522698,536873467]],[\"^0\",[3051,\"^2\",181,536873467]],[\"^0\",[3051,\"^9\",\"~u69db5f8b-0b14-4a38-a6a9-e7447e0dcac9\",536873467]],[\"^0\",[3051,\"^8\",1775984523459,536873467]],[\"^0\",[3051,\"^6\",\"\",536873467]],[\"^0\",[3051,\"^3\",180,536873467]],[\"^0\",[3051,\"^4\",\"a4\",536873467]],[\"^0\",[3051,\"^7\",1775984523459,536873467]],[\"^0\",[3047,\"^5\",3052,536873467]],[\"^0\",[3048,\"^5\",3052,536873467]],[\"^0\",[3049,\"^5\",3052,536873467]],[\"^0\",[3050,\"^5\",3052,536873467]],[\"^0\",[3052,\"^2\",181,536873467]],[\"^0\",[3052,\"^9\",\"~u69db5f8a-bf4b-4013-806f-f2a6e1c0cc73\",536873467]],[\"^0\",[3052,\"^8\",1775984522620,536873467]],[\"^0\",[3052,\"^6\",\"e\",536873467]],[\"^0\",[3052,\"^3\",180,536873467]],[\"^0\",[3052,\"^4\",\"a0\",536873467]],[\"^0\",[3052,\"^7\",1775984522530,536873467]],[\"^0\",[3051,\"^5\",3053,536873467]],[\"^0\",[3052,\"^5\",3053,536873467]],[\"^0\",[3053,\"^2\",181,536873467]],[\"^0\",[3053,\"^9\",\"~u69db5f8a-dea4-4434-a3ae-5b13b90882ce\",536873467]],[\"^0\",[3053,\"^8\",1775984522529,536873467]],[\"^0\",[3053,\"^6\",\"f\",536873467]],[\"^0\",[3053,\"^5\",180,536873467]],[\"^0\",[3053,\"^3\",180,536873467]],[\"^0\",[3053,\"^4\",\"a6\",536873467]],[\"^0\",[3053,\"^7\",1775984522301,536873467]],[\"^0\",[3054,\"^2\",181,536873467]],[\"^0\",[3054,\"^9\",\"~u69db5f8a-20b6-4e5e-b82d-9351b6709e3c\",536873467]],[\"^0\",[3054,\"^8\",1775984522366,536873467]],[\"^0\",[3054,\"^6\",\"a\",536873467]],[\"^0\",[3054,\"^3\",180,536873467]],[\"^0\",[3054,\"^4\",\"a0\",536873467]],[\"^0\",[3054,\"^7\",1775984522126,536873467]],[\"^0\",[3054,\"^5\",3055,536873467]],[\"^0\",[3055,\"^2\",181,536873467]],[\"^0\",[3055,\"^9\",\"~u69db5f89-2322-4e14-98a7-edc3728c12e4\",536873467]],[\"^0\",[3055,\"^8\",1775984522125,536873467]],[\"^0\",[3055,\"^6\",\"f\",536873467]],[\"^0\",[3055,\"^5\",180,536873467]],[\"^0\",[3055,\"^3\",180,536873467]],[\"^0\",[3055,\"^4\",\"a4\",536873467]],[\"^0\",[3055,\"^7\",1775984521960,536873467]],[\"^0\",[3056,\"^2\",181,536873467]],[\"^0\",[3056,\"^9\",\"~u69db5e9c-1424-4f8a-a0be-2d8c42355472\",536873467]],[\"^0\",[3056,\"^8\",1775984521958,536873467]],[\"^0\",[3056,\"^6\",\"10fa\",536873467]],[\"^0\",[3056,\"^5\",180,536873467]],[\"^0\",[3056,\"^3\",180,536873467]],[\"^0\",[3056,\"^4\",\"a3\",536873467]],[\"^0\",[3056,\"^7\",1775984284987,536873467]],[\"^0\",[3057,\"^2\",181,536873467]],[\"^0\",[3057,\"^9\",\"~u69db5e9c-e1fb-4d8a-8ce9-1e05986eea79\",536873467]],[\"^0\",[3057,\"^8\",1775984284986,536873467]],[\"^0\",[3057,\"^6\",\"9\",536873467]],[\"^0\",[3057,\"^5\",180,536873467]],[\"^0\",[3057,\"^3\",180,536873467]],[\"^0\",[3057,\"^4\",\"a2\",536873467]],[\"^0\",[3057,\"^7\",1775984284238,536873467]],[\"^0\",[3058,\"^2\",181,536873467]],[\"^0\",[3058,\"^9\",\"~u69db5e99-341b-4196-85af-9f5a75828be9\",536873467]],[\"^0\",[3058,\"^8\",1775984293031,536873467]],[\"^0\",[3058,\"^1\",536872554,536873467]],[\"^0\",[3058,\"^6\",\"8\",536873467]],[\"^0\",[3058,\"^5\",180,536873467]],[\"^0\",[3058,\"^3\",180,536873467]],[\"^0\",[3058,\"^4\",\"a1l\",536873467]],[\"^0\",[3058,\"^7\",1775984293021,536873467]],[\"^0\",[3059,\"^2\",181,536873467]],[\"^0\",[3059,\"^9\",\"~u69db5e98-e930-44d2-acb0-76bb2b2cfa85\",536873467]],[\"^0\",[3059,\"^8\",1775984293020,536873467]],[\"^0\",[3059,\"^1\",536872551,536873467]],[\"^0\",[3059,\"^6\",\"7\",536873467]],[\"^0\",[3059,\"^5\",180,536873467]],[\"^0\",[3059,\"^3\",180,536873467]],[\"^0\",[3059,\"^4\",\"a1V\",536873467]],[\"^0\",[3059,\"^7\",1775984293011,536873467]],[\"^0\",[3060,\"^2\",181,536873467]],[\"^0\",[3060,\"^9\",\"~u69db5e43-06ef-40dc-9f74-cffd52768f40\",536873467]],[\"^0\",[3060,\"^8\",1775984196746,536873467]],[\"^0\",[3060,\"^6\",\"6\",536873467]],[\"^0\",[3060,\"^5\",180,536873467]],[\"^0\",[3060,\"^3\",180,536873467]],[\"^0\",[3060,\"^4\",\"a1\",536873467]],[\"^0\",[3060,\"^7\",1775984195215,536873467]],[\"^0\",[180,\"^1\",536873381,536873468,false]],[\"^0\",[180,\"^1\",536873378,536873468]],[\"^0\",[3061,\"~:logseq.property.history/ref-value\",114,536873468]],[\"^0\",[3061,\"~:logseq.property.history/property\",26,536873468]],[\"^0\",[3061,\"^2\",181,536873468]],[\"^0\",[3061,\"^9\",\"~u69db5fda-6885-4fac-b5b0-134fa03e071d\",536873468]],[\"^0\",[3061,\"^8\",1775984602776,536873468]],[\"^0\",[3061,\"^1\",536873345,536873468]],[\"^0\",[3061,\"^7\",1775984602776,536873468]],[\"^0\",[3062,\"^:\",114,536873468]],[\"^0\",[3062,\"^;\",26,536873468]],[\"^0\",[3062,\"^2\",181,536873468]],[\"^0\",[3062,\"^9\",\"~u69db5fd1-d725-489f-9c7d-bb0fa8fd5421\",536873468]],[\"^0\",[3062,\"^8\",1775984593967,536873468]],[\"^0\",[3062,\"^1\",536873345,536873468]],[\"^0\",[3062,\"^7\",1775984593967,536873468]],[\"^0\",[3063,\"^:\",114,536873468]],[\"^0\",[3063,\"^;\",26,536873468]],[\"^0\",[3063,\"^2\",181,536873468]],[\"^0\",[3063,\"^9\",\"~u69db5fdb-d3de-4f3b-b69e-ebd4279e7d9c\",536873468]],[\"^0\",[3063,\"^8\",1775984603032,536873468]],[\"^0\",[3063,\"^1\",536873353,536873468]],[\"^0\",[3063,\"^7\",1775984603032,536873468]],[\"^0\",[3064,\"^:\",114,536873468]],[\"^0\",[3064,\"^;\",26,536873468]],[\"^0\",[3064,\"^2\",181,536873468]],[\"^0\",[3064,\"^9\",\"~u69db5fd3-254e-4d8a-9093-0e30e2ea565d\",536873468]],[\"^0\",[3064,\"^8\",1775984595023,536873468]],[\"^0\",[3064,\"^1\",536873353,536873468]],[\"^0\",[3064,\"^7\",1775984595023,536873468]],[\"^0\",[3065,\"^:\",55,536873468]],[\"^0\",[3065,\"^;\",26,536873468]],[\"^0\",[3065,\"^2\",181,536873468]],[\"^0\",[3065,\"^9\",\"~u69db5fda-fd9c-4f43-95ea-1f1f138f42fc\",536873468]],[\"^0\",[3065,\"^8\",1775984602508,536873468]],[\"^0\",[3065,\"^1\",536873336,536873468]],[\"^0\",[3065,\"^7\",1775984602508,536873468]],[\"^0\",[3066,\"^:\",55,536873468]],[\"^0\",[3066,\"^;\",26,536873468]],[\"^0\",[3066,\"^2\",181,536873468]],[\"^0\",[3066,\"^9\",\"~u69db5fd0-dd43-44a3-a49e-9c9eb3ee21de\",536873468]],[\"^0\",[3066,\"^8\",1775984592739,536873468]],[\"^0\",[3066,\"^1\",536873336,536873468]],[\"^0\",[3066,\"^7\",1775984592739,536873468]],[\"^0\",[3067,\"^:\",9,536873468]],[\"^0\",[3067,\"^;\",26,536873468]],[\"^0\",[3067,\"^2\",181,536873468]],[\"^0\",[3067,\"^9\",\"~u69db5fda-5536-432b-8527-beb3ef42e823\",536873468]],[\"^0\",[3067,\"^8\",1775984602438,536873468]],[\"^0\",[3067,\"^1\",536873333,536873468]],[\"^0\",[3067,\"^7\",1775984602438,536873468]],[\"^0\",[3068,\"^:\",9,536873468]],[\"^0\",[3068,\"^;\",26,536873468]],[\"^0\",[3068,\"^2\",181,536873468]],[\"^0\",[3068,\"^9\",\"~u69db5fd0-467e-4233-848d-d5a8a17132c1\",536873468]],[\"^0\",[3068,\"^8\",1775984592497,536873468]],[\"^0\",[3068,\"^1\",536873333,536873468]],[\"^0\",[3068,\"^7\",1775984592497,536873468]],[\"^0\",[3069,\"^:\",114,536873468]],[\"^0\",[3069,\"^;\",26,536873468]],[\"^0\",[3069,\"^2\",181,536873468]],[\"^0\",[3069,\"^9\",\"~u69db5fda-8f2b-461f-8fcf-eb2048e612db\",536873468]],[\"^0\",[3069,\"^8\",1775984602366,536873468]],[\"^0\",[3069,\"^1\",536873330,536873468]],[\"^0\",[3069,\"^7\",1775984602366,536873468]],[\"^0\",[3070,\"^:\",114,536873468]],[\"^0\",[3070,\"^;\",26,536873468]],[\"^0\",[3070,\"^2\",181,536873468]],[\"^0\",[3070,\"^9\",\"~u69db5fd0-7219-4848-add2-93885c48cdf0\",536873468]],[\"^0\",[3070,\"^8\",1775984592269,536873468]],[\"^0\",[3070,\"^1\",536873330,536873468]],[\"^0\",[3070,\"^7\",1775984592269,536873468]],[\"^0\",[3071,\"^:\",9,536873468]],[\"^0\",[3071,\"^;\",26,536873468]],[\"^0\",[3071,\"^2\",181,536873468]],[\"^0\",[3071,\"^9\",\"~u69db5fdb-7cd3-4fb8-b24a-6658a716ec9e\",536873468]],[\"^0\",[3071,\"^8\",1775984603456,536873468]],[\"^0\",[3071,\"^1\",536873367,536873468]],[\"^0\",[3071,\"^7\",1775984603456,536873468]],[\"^0\",[3072,\"^:\",9,536873468]],[\"^0\",[3072,\"^;\",26,536873468]],[\"^0\",[3072,\"^2\",181,536873468]],[\"^0\",[3072,\"^9\",\"~u69db5fd4-cb2f-4364-a9b5-3c4321628245\",536873468]],[\"^0\",[3072,\"^8\",1775984596073,536873468]],[\"^0\",[3072,\"^1\",536873367,536873468]],[\"^0\",[3072,\"^7\",1775984596073,536873468]],[\"^0\",[3073,\"^:\",114,536873468]],[\"^0\",[3073,\"^;\",26,536873468]],[\"^0\",[3073,\"^2\",181,536873468]],[\"^0\",[3073,\"^9\",\"~u69db5fdb-0542-4ec3-a6fd-4e9b3b805963\",536873468]],[\"^0\",[3073,\"^8\",1775984603383,536873468]],[\"^0\",[3073,\"^1\",536873364,536873468]],[\"^0\",[3073,\"^7\",1775984603383,536873468]],[\"^0\",[3074,\"^:\",114,536873468]],[\"^0\",[3074,\"^;\",26,536873468]],[\"^0\",[3074,\"^2\",181,536873468]],[\"^0\",[3074,\"^9\",\"~u69db5fd3-6534-42d8-a5c2-4fe02aa6e94c\",536873468]],[\"^0\",[3074,\"^8\",1775984595867,536873468]],[\"^0\",[3074,\"^1\",536873364,536873468]],[\"^0\",[3074,\"^7\",1775984595867,536873468]],[\"^0\",[3075,\"^:\",114,536873468]],[\"^0\",[3075,\"^;\",26,536873468]],[\"^0\",[3075,\"^2\",181,536873468]],[\"^0\",[3075,\"^9\",\"~u69db5fdb-aa3e-4767-825c-00af0198e1e7\",536873468]],[\"^0\",[3075,\"^8\",1775984603802,536873468]],[\"^0\",[3075,\"^1\",536873378,536873468]],[\"^0\",[3075,\"^7\",1775984603802,536873468]],[\"^0\",[3076,\"^:\",114,536873468]],[\"^0\",[3076,\"^;\",26,536873468]],[\"^0\",[3076,\"^2\",181,536873468]],[\"^0\",[3076,\"^9\",\"~u69db5fd5-10c1-4bec-b4ad-10be544167c4\",536873468]],[\"^0\",[3076,\"^8\",1775984597060,536873468]],[\"^0\",[3076,\"^1\",536873378,536873468]],[\"^0\",[3076,\"^7\",1775984597060,536873468]],[\"^0\",[3077,\"^2\",181,536873468]],[\"^0\",[3077,\"^9\",\"~u69db5fd3-0f51-4773-8902-d00d889dbcde\",536873468]],[\"^0\",[3077,\"^8\",1775984622278,536873468]],[\"^0\",[3077,\"^1\",536873361,536873468]],[\"^0\",[3077,\"^6\",\"4\",536873468]],[\"^0\",[3077,\"^3\",180,536873468]],[\"^0\",[3077,\"^4\",\"aK\",536873468]],[\"^0\",[3077,\"^7\",1775984622278,536873468]],[\"^0\",[3078,\"^2\",181,536873468]],[\"^0\",[3078,\"^9\",\"~u69db5fd4-2a7a-4d88-be50-4a536a4f29dd\",536873468]],[\"^0\",[3078,\"^8\",1775984622334,536873468]],[\"^0\",[3078,\"^1\",536873375,536873468]],[\"^0\",[3078,\"^6\",\"3\",536873468]],[\"^0\",[3078,\"^3\",180,536873468]],[\"^0\",[3078,\"^4\",\"aO\",536873468]],[\"^0\",[3078,\"^7\",1775984622334,536873468]],[\"^0\",[3079,\"^2\",181,536873468]],[\"^0\",[3079,\"^9\",\"~u69db5fd0-426f-4608-9177-97a62a20a0a6\",536873468]],[\"^0\",[3079,\"^8\",1775984622136,536873468]],[\"^0\",[3079,\"^1\",536873327,536873468]],[\"^0\",[3079,\"^6\",\"4\",536873468]],[\"^0\",[3079,\"^3\",180,536873468]],[\"^0\",[3079,\"^4\",\"a5\",536873468]],[\"^0\",[3079,\"^7\",1775984622136,536873468]],[\"^0\",[3080,\"^2\",181,536873468]],[\"^0\",[3080,\"^9\",\"~u69db5fd4-4b47-4531-8f7c-dd8aa76170e7\",536873468]],[\"^0\",[3080,\"^8\",1775984622334,536873468]],[\"^0\",[3080,\"^1\",536873375,536873468]],[\"^0\",[3080,\"^6\",\"4\",536873468]],[\"^0\",[3080,\"^3\",180,536873468]],[\"^0\",[3080,\"^4\",\"aP\",536873468]],[\"^0\",[3080,\"^7\",1775984622334,536873468]],[\"^0\",[3081,\"^2\",181,536873468]],[\"^0\",[3081,\"^9\",\"~u69db5fd1-440e-44a7-8a00-607bbf2af4a5\",536873468]],[\"^0\",[3081,\"^8\",1775984622189,536873468]],[\"^0\",[3081,\"^1\",536873339,536873468]],[\"^0\",[3081,\"^6\",\"4\",536873468]],[\"^0\",[3081,\"^3\",180,536873468]],[\"^0\",[3081,\"^4\",\"aA\",536873468]],[\"^0\",[3081,\"^7\",1775984622189,536873468]],[\"^0\",[3082,\"^2\",181,536873468]],[\"^0\",[3082,\"^9\",\"~u69db5fd3-5271-4dc5-8af7-8c69d0477130\",536873468]],[\"^0\",[3082,\"^8\",1775984622278,536873468]],[\"^0\",[3082,\"^1\",536873361,536873468]],[\"^0\",[3082,\"^6\",\"3\",536873468]],[\"^0\",[3082,\"^3\",180,536873468]],[\"^0\",[3082,\"^4\",\"aJ\",536873468]],[\"^0\",[3082,\"^7\",1775984622278,536873468]],[\"^0\",[3082,\"^5\",3083,536873468]],[\"^0\",[3083,\"^2\",181,536873468]],[\"^0\",[3083,\"^9\",\"~u69db5fd3-edce-49f7-b2b0-449ee103901a\",536873468]],[\"^0\",[3083,\"^8\",1775984622278,536873468]],[\"^0\",[3083,\"^1\",536873361,536873468]],[\"^0\",[3083,\"^6\",\"2\",536873468]],[\"^0\",[3083,\"^3\",180,536873468]],[\"^0\",[3083,\"^4\",\"aI\",536873468]],[\"^0\",[3083,\"^7\",1775984622278,536873468]],[\"^0\",[3084,\"^2\",181,536873468]],[\"^0\",[3084,\"^9\",\"~u69db5fc0-a560-43fb-a22b-f2d6961ffb31\",536873468]],[\"^0\",[3084,\"^8\",1775984622121,536873468]],[\"^0\",[3084,\"^1\",536873321,536873468]],[\"^0\",[3084,\"^6\",\"5\",536873468]],[\"^0\",[3084,\"^3\",180,536873468]],[\"^0\",[3084,\"^4\",\"a0\",536873468]],[\"^0\",[3084,\"^7\",1775984622101,536873468]],[\"^0\",[3080,\"^5\",3085,536873468]],[\"^0\",[3085,\"^2\",181,536873468]],[\"^0\",[3085,\"^9\",\"~u69db5fd4-6a39-4cf4-b88f-a038a98f1d81\",536873468]],[\"^0\",[3085,\"^8\",1775984622334,536873468]],[\"^0\",[3085,\"^1\",536873375,536873468]],[\"^0\",[3085,\"^6\",\"1\",536873468]],[\"^0\",[3085,\"^3\",180,536873468]],[\"^0\",[3085,\"^4\",\"aL\",536873468]],[\"^0\",[3085,\"^7\",1775984622334,536873468]],[\"^0\",[3079,\"^5\",3086,536873468]],[\"^0\",[3086,\"^2\",181,536873468]],[\"^0\",[3086,\"^9\",\"~u69db5fcf-5698-4512-ae34-c401492c972f\",536873468]],[\"^0\",[3086,\"^8\",1775984622136,536873468]],[\"^0\",[3086,\"^1\",536873327,536873468]],[\"^0\",[3086,\"^6\",\"1\",536873468]],[\"^0\",[3086,\"^3\",180,536873468]],[\"^0\",[3086,\"^4\",\"a1\",536873468]],[\"^0\",[3086,\"^7\",1775984622136,536873468]],[\"^0\",[3087,\"^2\",181,536873468]],[\"^0\",[3087,\"^9\",\"~u69db5fd1-e0be-4836-9bbf-8da40fce6b26\",536873468]],[\"^0\",[3087,\"^8\",1775984622189,536873468]],[\"^0\",[3087,\"^1\",536873339,536873468]],[\"^0\",[3087,\"^6\",\"3\",536873468]],[\"^0\",[3087,\"^3\",180,536873468]],[\"^0\",[3087,\"^4\",\"a9\",536873468]],[\"^0\",[3087,\"^7\",1775984622189,536873468]],[\"^0\",[3061,\"~:logseq.property.history/block\",3088,536873468]],[\"^0\",[3062,\"^<\",3088,536873468]],[\"^0\",[3088,\"~:logseq.property/status\",114,536873468]],[\"^0\",[3088,\"^2\",181,536873468]],[\"^0\",[3088,\"^9\",\"~u69db5fd1-68fc-4084-8518-ea383e66995c\",536873468]],[\"^0\",[3088,\"^8\",1775984622233,536873468]],[\"^0\",[3088,\"^1\",536873350,536873468]],[\"^0\",[3088,\"^6\",\"1\",536873468]],[\"^0\",[3088,\"~:block/tags\",152,536873468]],[\"^0\",[3088,\"~:block/refs\",152,536873468]],[\"^0\",[3088,\"^?\",58,536873468]],[\"^0\",[3088,\"^?\",26,536873468]],[\"^0\",[3088,\"^3\",180,536873468]],[\"^0\",[3088,\"^4\",\"aB\",536873468]],[\"^0\",[3088,\"^7\",1775984622233,536873468]],[\"^0\",[3088,\"^5\",3089,536873468]],[\"^0\",[3081,\"^5\",3089,536873468]],[\"^0\",[3089,\"^2\",181,536873468]],[\"^0\",[3089,\"^9\",\"~u69db5fd1-c79f-451c-a03a-3661fda1b5ad\",536873468]],[\"^0\",[3089,\"^8\",1775984622189,536873468]],[\"^0\",[3089,\"^1\",536873339,536873468]],[\"^0\",[3089,\"^6\",\"1\",536873468]],[\"^0\",[3089,\"^5\",3079,536873468]],[\"^0\",[3089,\"^3\",180,536873468]],[\"^0\",[3089,\"^4\",\"a7\",536873468]],[\"^0\",[3089,\"^7\",1775984622189,536873468]],[\"^0\",[3090,\"^2\",181,536873468]],[\"^0\",[3090,\"^9\",\"~u69db5fd2-b71f-405a-9d3d-b36dfd103fb4\",536873468]],[\"^0\",[3090,\"^8\",1775984622233,536873468]],[\"^0\",[3090,\"^1\",536873350,536873468]],[\"^0\",[3090,\"^6\",\"4\",536873468]],[\"^0\",[3090,\"^5\",3088,536873468]],[\"^0\",[3090,\"^3\",180,536873468]],[\"^0\",[3090,\"^4\",\"aF\",536873468]],[\"^0\",[3090,\"^7\",1775984622233,536873468]],[\"^0\",[3087,\"^5\",3091,536873468]],[\"^0\",[3091,\"^2\",181,536873468]],[\"^0\",[3091,\"^9\",\"~u69db5fd1-ff5c-4d87-b07b-7f2eb8f22130\",536873468]],[\"^0\",[3091,\"^8\",1775984622189,536873468]],[\"^0\",[3091,\"^1\",536873339,536873468]],[\"^0\",[3091,\"^6\",\"2\",536873468]],[\"^0\",[3091,\"^5\",3089,536873468]],[\"^0\",[3091,\"^3\",180,536873468]],[\"^0\",[3091,\"^4\",\"a8\",536873468]],[\"^0\",[3091,\"^7\",1775984622189,536873468]],[\"^0\",[3063,\"^<\",3092,536873468]],[\"^0\",[3064,\"^<\",3092,536873468]],[\"^0\",[3092,\"^=\",114,536873468]],[\"^0\",[3092,\"^2\",181,536873468]],[\"^0\",[3092,\"^9\",\"~u69db5fd2-9363-4c82-b248-588df3b44428\",536873468]],[\"^0\",[3092,\"^8\",1775984595016,536873468]],[\"^0\",[3092,\"^1\",536873353,536873468]],[\"^0\",[3092,\"^6\",\"5\",536873468]],[\"^0\",[3092,\"^>\",152,536873468]],[\"^0\",[3092,\"^?\",152,536873468]],[\"^0\",[3092,\"^?\",58,536873468]],[\"^0\",[3092,\"^?\",26,536873468]],[\"^0\",[3092,\"^5\",3090,536873468]],[\"^0\",[3092,\"^3\",180,536873468]],[\"^0\",[3092,\"^4\",\"aG\",536873468]],[\"^0\",[3092,\"^7\",1775984622233,536873468]],[\"^0\",[3065,\"^<\",3093,536873468]],[\"^0\",[3066,\"^<\",3093,536873468]],[\"^0\",[3067,\"^<\",3093,536873468]],[\"^0\",[3068,\"^<\",3093,536873468]],[\"^0\",[3069,\"^<\",3093,536873468]],[\"^0\",[3070,\"^<\",3093,536873468]],[\"^0\",[3093,\"^=\",55,536873468]],[\"^0\",[3093,\"^2\",181,536873468]],[\"^0\",[3093,\"^9\",\"~u69db5fd0-4aa1-42da-9278-a70c3fa778e9\",536873468]],[\"^0\",[3093,\"^8\",1775984592737,536873468]],[\"^0\",[3093,\"^1\",536873336,536873468]],[\"^0\",[3093,\"^6\",\"5\",536873468]],[\"^0\",[3093,\"^>\",152,536873468]],[\"^0\",[3093,\"^?\",152,536873468]],[\"^0\",[3093,\"^?\",58,536873468]],[\"^0\",[3093,\"^?\",26,536873468]],[\"^0\",[3093,\"^5\",3079,536873468]],[\"^0\",[3093,\"^3\",180,536873468]],[\"^0\",[3093,\"^4\",\"a6\",536873468]],[\"^0\",[3093,\"^7\",1775984622136,536873468]],[\"^0\",[3071,\"^<\",3094,536873468]],[\"^0\",[3072,\"^<\",3094,536873468]],[\"^0\",[3073,\"^<\",3094,536873468]],[\"^0\",[3074,\"^<\",3094,536873468]],[\"^0\",[3094,\"^=\",9,536873468]],[\"^0\",[3094,\"^2\",181,536873468]],[\"^0\",[3094,\"^9\",\"~u69db5fd3-06ca-4550-a5b8-8dd78ef72d4a\",536873468]],[\"^0\",[3094,\"^8\",1775984596072,536873468]],[\"^0\",[3094,\"^1\",536873367,536873468]],[\"^0\",[3094,\"^6\",\"5\",536873468]],[\"^0\",[3094,\"^>\",152,536873468]],[\"^0\",[3094,\"^?\",152,536873468]],[\"^0\",[3094,\"^?\",58,536873468]],[\"^0\",[3094,\"^?\",26,536873468]],[\"^0\",[3094,\"^5\",3077,536873468]],[\"^0\",[3094,\"^3\",180,536873468]],[\"^0\",[3094,\"^4\",\"aL\",536873468]],[\"^0\",[3094,\"^7\",1775984622278,536873468]],[\"^0\",[3095,\"^2\",181,536873468]],[\"^0\",[3095,\"^9\",\"~u69db5fd1-7a39-4314-b8cb-b1855c78e084\",536873468]],[\"^0\",[3095,\"^8\",1775984622189,536873468]],[\"^0\",[3095,\"^1\",536873339,536873468]],[\"^0\",[3095,\"^6\",\"5\",536873468]],[\"^0\",[3095,\"^5\",3081,536873468]],[\"^0\",[3095,\"^3\",180,536873468]],[\"^0\",[3095,\"^4\",\"aB\",536873468]],[\"^0\",[3095,\"^7\",1775984622189,536873468]],[\"^0\",[3096,\"^2\",181,536873468]],[\"^0\",[3096,\"^9\",\"~u69db5fd0-ebdb-4f66-bebb-9398b8c37e1a\",536873468]],[\"^0\",[3096,\"^8\",1775984622136,536873468]],[\"^0\",[3096,\"^1\",536873327,536873468]],[\"^0\",[3096,\"^6\",\"2\",536873468]],[\"^0\",[3096,\"^5\",3086,536873468]],[\"^0\",[3096,\"^3\",180,536873468]],[\"^0\",[3096,\"^4\",\"a3\",536873468]],[\"^0\",[3096,\"^7\",1775984622136,536873468]],[\"^0\",[3078,\"^5\",3097,536873468]],[\"^0\",[3097,\"^2\",181,536873468]],[\"^0\",[3097,\"^9\",\"~u69db5fd4-600b-476b-8c23-c1027a259cef\",536873468]],[\"^0\",[3097,\"^8\",1775984622334,536873468]],[\"^0\",[3097,\"^1\",536873375,536873468]],[\"^0\",[3097,\"^6\",\"2\",536873468]],[\"^0\",[3097,\"^5\",3085,536873468]],[\"^0\",[3097,\"^3\",180,536873468]],[\"^0\",[3097,\"^4\",\"aN\",536873468]],[\"^0\",[3097,\"^7\",1775984622334,536873468]],[\"^0\",[3098,\"^2\",181,536873468]],[\"^0\",[3098,\"^9\",\"~u69db5fd0-d884-4bc4-9fe0-a44b108bc919\",536873468]],[\"^0\",[3098,\"^8\",1775984622136,536873468]],[\"^0\",[3098,\"^1\",536873327,536873468]],[\"^0\",[3098,\"^6\",\"3\",536873468]],[\"^0\",[3098,\"^5\",3096,536873468]],[\"^0\",[3098,\"^3\",180,536873468]],[\"^0\",[3098,\"^4\",\"a4\",536873468]],[\"^0\",[3098,\"^7\",1775984622136,536873468]],[\"^0\",[3085,\"^5\",3099,536873468]],[\"^0\",[3077,\"^5\",3099,536873468]],[\"^0\",[3083,\"^5\",3099,536873468]],[\"^0\",[3099,\"^2\",181,536873468]],[\"^0\",[3099,\"^9\",\"~u69db5fd3-b9a5-41e2-8011-05f45de17fa7\",536873468]],[\"^0\",[3099,\"^8\",1775984622278,536873468]],[\"^0\",[3099,\"^1\",536873361,536873468]],[\"^0\",[3099,\"^6\",\"1\",536873468]],[\"^0\",[3099,\"^5\",3088,536873468]],[\"^0\",[3099,\"^3\",180,536873468]],[\"^0\",[3099,\"^4\",\"aG\",536873468]],[\"^0\",[3099,\"^7\",1775984622278,536873468]],[\"^0\",[3075,\"^<\",3100,536873468]],[\"^0\",[3076,\"^<\",3100,536873468]],[\"^0\",[3100,\"^=\",114,536873468]],[\"^0\",[3100,\"^2\",181,536873468]],[\"^0\",[3100,\"^9\",\"~u69db5fd4-83fc-4a35-8408-f4e61cd23a56\",536873468]],[\"^0\",[3100,\"^8\",1775984597058,536873468]],[\"^0\",[3100,\"^1\",536873378,536873468]],[\"^0\",[3100,\"^6\",\"5\",536873468]],[\"^0\",[3100,\"^>\",152,536873468]],[\"^0\",[3100,\"^?\",152,536873468]],[\"^0\",[3100,\"^?\",58,536873468]],[\"^0\",[3100,\"^?\",26,536873468]],[\"^0\",[3100,\"^5\",3080,536873468]],[\"^0\",[3100,\"^3\",180,536873468]],[\"^0\",[3100,\"^4\",\"aQ\",536873468]],[\"^0\",[3100,\"^7\",1775984622334,536873468]],[\"^0\",[3101,\"^2\",181,536873468]],[\"^0\",[3101,\"^9\",\"~u69db5fd2-825c-45d8-9da0-cd3e1f488648\",536873468]],[\"^0\",[3101,\"^8\",1775984622233,536873468]],[\"^0\",[3101,\"^1\",536873350,536873468]],[\"^0\",[3101,\"^6\",\"2\",536873468]],[\"^0\",[3101,\"^5\",3088,536873468]],[\"^0\",[3101,\"^3\",180,536873468]],[\"^0\",[3101,\"^4\",\"aD\",536873468]],[\"^0\",[3101,\"^7\",1775984622233,536873468]],[\"^0\",[3102,\"^2\",181,536873468]],[\"^0\",[3102,\"^9\",\"~u69db5fd2-f30e-4bc8-a67e-08ac5322c1cc\",536873468]],[\"^0\",[3102,\"^8\",1775984622233,536873468]],[\"^0\",[3102,\"^1\",536873350,536873468]],[\"^0\",[3102,\"^6\",\"3\",536873468]],[\"^0\",[3102,\"^5\",3101,536873468]],[\"^0\",[3102,\"^3\",180,536873468]],[\"^0\",[3102,\"^4\",\"aE\",536873468]],[\"^0\",[3102,\"^7\",1775984622233,536873468]],[\"^0\",[3086,\"^5\",3103,536873468]],[\"^0\",[3084,\"^5\",3103,536873468]],[\"^0\",[3103,\"^2\",181,536873468]],[\"^0\",[3103,\"^9\",\"~u69db5fbf-6687-487d-b0a1-668adb961cab\",536873468]],[\"^0\",[3103,\"^8\",1775984622100,536873468]],[\"^0\",[3103,\"^1\",536873316,536873468]],[\"^0\",[3103,\"^6\",\"4\",536873468]],[\"^0\",[3103,\"^5\",2577,536873468]],[\"^0\",[3103,\"^3\",180,536873468]],[\"^0\",[3103,\"^4\",\"a1\",536873468]],[\"^0\",[3103,\"^7\",1775984622084,536873468]],[\"^0\",[2941,\"^5\",2950,536873469,false]],[\"^0\",[2941,\"^5\",2931,536873469]],[\"^0\",[2941,\"^4\",\"a3\",536873469,false]],[\"^0\",[2941,\"^4\",\"a2\",536885721]],[\"^0\",[2931,\"^8\",1775984615139,536873470,false]],[\"^0\",[2941,\"^8\",1775984613561,536873470,false]],[\"^0\",[2931,\"^8\",1775984651669,536885723]],[\"^0\",[2941,\"^8\",1775984651668,536885723]],[\"^0\",[2941,\"^6\",\"\",536873470,false]],[\"^0\",[2941,\"^6\",\"fdafdasf\",536885723]],[\"^0\",[3103,\"^7\",1775984622084,536873471,false]],[\"^0\",[3103,\"^4\",\"a1\",536873471,false]],[\"^0\",[3103,\"^3\",180,536873471,false]],[\"^0\",[3103,\"^5\",2577,536873471,false]],[\"^0\",[3103,\"^6\",\"4\",536873471,false]],[\"^0\",[3103,\"^1\",536873316,536873471,false]],[\"^0\",[3103,\"^8\",1775984622100,536873471,false]],[\"^0\",[3103,\"^9\",\"~u69db5fbf-6687-487d-b0a1-668adb961cab\",536873471,false]],[\"^0\",[3103,\"^2\",181,536873471,false]],[\"^0\",[3084,\"^5\",3103,536873471,false]],[\"^0\",[3086,\"^5\",3103,536873471,false]],[\"^0\",[3090,\"^7\",1775984622233,536873471,false]],[\"^0\",[3090,\"^4\",\"aF\",536873471,false]],[\"^0\",[3090,\"^3\",180,536873471,false]],[\"^0\",[3090,\"^5\",3088,536873471,false]],[\"^0\",[3090,\"^6\",\"4\",536873471,false]],[\"^0\",[3090,\"^1\",536873350,536873471,false]],[\"^0\",[3090,\"^8\",1775984622233,536873471,false]],[\"^0\",[3090,\"^9\",\"~u69db5fd2-b71f-405a-9d3d-b36dfd103fb4\",536873471,false]],[\"^0\",[3090,\"^2\",181,536873471,false]],[\"^0\",[3092,\"^5\",3090,536873471,false]],[\"^0\",[3089,\"^7\",1775984622189,536873471,false]],[\"^0\",[3089,\"^4\",\"a7\",536873471,false]],[\"^0\",[3089,\"^3\",180,536873471,false]],[\"^0\",[3089,\"^5\",3079,536873471,false]],[\"^0\",[3089,\"^6\",\"1\",536873471,false]],[\"^0\",[3089,\"^1\",536873339,536873471,false]],[\"^0\",[3089,\"^8\",1775984622189,536873471,false]],[\"^0\",[3089,\"^9\",\"~u69db5fd1-c79f-451c-a03a-3661fda1b5ad\",536873471,false]],[\"^0\",[3089,\"^2\",181,536873471,false]],[\"^0\",[3081,\"^5\",3089,536873471,false]],[\"^0\",[3088,\"^5\",3089,536873471,false]],[\"^0\",[3091,\"^5\",3089,536873471,false]],[\"^0\",[3080,\"^7\",1775984622334,536873471,false]],[\"^0\",[3080,\"^4\",\"aP\",536873471,false]],[\"^0\",[3080,\"^3\",180,536873471,false]],[\"^0\",[3080,\"^5\",3085,536873471,false]],[\"^0\",[3080,\"^6\",\"4\",536873471,false]],[\"^0\",[3080,\"^1\",536873375,536873471,false]],[\"^0\",[3080,\"^8\",1775984622334,536873471,false]],[\"^0\",[3080,\"^9\",\"~u69db5fd4-4b47-4531-8f7c-dd8aa76170e7\",536873471,false]],[\"^0\",[3080,\"^2\",181,536873471,false]],[\"^0\",[3100,\"^5\",3080,536873471,false]],[\"^0\",[3093,\"^7\",1775984622136,536873471,false]],[\"^0\",[3093,\"^4\",\"a6\",536873471,false]],[\"^0\",[3093,\"^3\",180,536873471,false]],[\"^0\",[3093,\"^5\",3079,536873471,false]],[\"^0\",[3093,\"^?\",26,536873471,false]],[\"^0\",[3093,\"^?\",58,536873471,false]],[\"^0\",[3093,\"^?\",152,536873471,false]],[\"^0\",[3093,\"^>\",152,536873471,false]],[\"^0\",[3093,\"^6\",\"5\",536873471,false]],[\"^0\",[3093,\"^1\",536873336,536873471,false]],[\"^0\",[3093,\"^8\",1775984592737,536873471,false]],[\"^0\",[3093,\"^9\",\"~u69db5fd0-4aa1-42da-9278-a70c3fa778e9\",536873471,false]],[\"^0\",[3093,\"^2\",181,536873471,false]],[\"^0\",[3093,\"^=\",55,536873471,false]],[\"^0\",[3065,\"^<\",3093,536873471,false]],[\"^0\",[3066,\"^<\",3093,536873471,false]],[\"^0\",[3067,\"^<\",3093,536873471,false]],[\"^0\",[3068,\"^<\",3093,536873471,false]],[\"^0\",[3069,\"^<\",3093,536873471,false]],[\"^0\",[3070,\"^<\",3093,536873471,false]],[\"^0\",[3077,\"^7\",1775984622278,536873471,false]],[\"^0\",[3077,\"^4\",\"aK\",536873471,false]],[\"^0\",[3077,\"^3\",180,536873471,false]],[\"^0\",[3077,\"^5\",3099,536873471,false]],[\"^0\",[3077,\"^6\",\"4\",536873471,false]],[\"^0\",[3077,\"^1\",536873361,536873471,false]],[\"^0\",[3077,\"^8\",1775984622278,536873471,false]],[\"^0\",[3077,\"^9\",\"~u69db5fd3-0f51-4773-8902-d00d889dbcde\",536873471,false]],[\"^0\",[3077,\"^2\",181,536873471,false]],[\"^0\",[3094,\"^5\",3077,536873471,false]],[\"^0\",[3096,\"^7\",1775984622136,536873471,false]],[\"^0\",[3096,\"^4\",\"a3\",536873471,false]],[\"^0\",[3096,\"^3\",180,536873471,false]],[\"^0\",[3096,\"^5\",3086,536873471,false]],[\"^0\",[3096,\"^6\",\"2\",536873471,false]],[\"^0\",[3096,\"^1\",536873327,536873471,false]],[\"^0\",[3096,\"^8\",1775984622136,536873471,false]],[\"^0\",[3096,\"^9\",\"~u69db5fd0-ebdb-4f66-bebb-9398b8c37e1a\",536873471,false]],[\"^0\",[3096,\"^2\",181,536873471,false]],[\"^0\",[3098,\"^5\",3096,536873471,false]],[\"^0\",[3086,\"^7\",1775984622136,536873471,false]],[\"^0\",[3086,\"^4\",\"a1\",536873471,false]],[\"^0\",[3086,\"^3\",180,536873471,false]],[\"^0\",[3086,\"^6\",\"1\",536873471,false]],[\"^0\",[3086,\"^1\",536873327,536873471,false]],[\"^0\",[3086,\"^8\",1775984622136,536873471,false]],[\"^0\",[3086,\"^9\",\"~u69db5fcf-5698-4512-ae34-c401492c972f\",536873471,false]],[\"^0\",[3086,\"^2\",181,536873471,false]],[\"^0\",[3079,\"^5\",3086,536873471,false]],[\"^0\",[3099,\"^7\",1775984622278,536873471,false]],[\"^0\",[3099,\"^4\",\"aG\",536873471,false]],[\"^0\",[3099,\"^3\",180,536873471,false]],[\"^0\",[3099,\"^5\",3088,536873471,false]],[\"^0\",[3099,\"^6\",\"1\",536873471,false]],[\"^0\",[3099,\"^1\",536873361,536873471,false]],[\"^0\",[3099,\"^8\",1775984622278,536873471,false]],[\"^0\",[3099,\"^9\",\"~u69db5fd3-b9a5-41e2-8011-05f45de17fa7\",536873471,false]],[\"^0\",[3099,\"^2\",181,536873471,false]],[\"^0\",[3083,\"^5\",3099,536873471,false]],[\"^0\",[3085,\"^5\",3099,536873471,false]],[\"^0\",[3085,\"^7\",1775984622334,536873471,false]],[\"^0\",[3085,\"^4\",\"aL\",536873471,false]],[\"^0\",[3085,\"^3\",180,536873471,false]],[\"^0\",[3085,\"^6\",\"1\",536873471,false]],[\"^0\",[3085,\"^1\",536873375,536873471,false]],[\"^0\",[3085,\"^8\",1775984622334,536873471,false]],[\"^0\",[3085,\"^9\",\"~u69db5fd4-6a39-4cf4-b88f-a038a98f1d81\",536873471,false]],[\"^0\",[3085,\"^2\",181,536873471,false]],[\"^0\",[3097,\"^5\",3085,536873471,false]],[\"^0\",[3094,\"^7\",1775984622278,536873471,false]],[\"^0\",[3094,\"^4\",\"aL\",536873471,false]],[\"^0\",[3094,\"^3\",180,536873471,false]],[\"^0\",[3094,\"^?\",26,536873471,false]],[\"^0\",[3094,\"^?\",58,536873471,false]],[\"^0\",[3094,\"^?\",152,536873471,false]],[\"^0\",[3094,\"^>\",152,536873471,false]],[\"^0\",[3094,\"^6\",\"5\",536873471,false]],[\"^0\",[3094,\"^1\",536873367,536873471,false]],[\"^0\",[3094,\"^8\",1775984596072,536873471,false]],[\"^0\",[3094,\"^9\",\"~u69db5fd3-06ca-4550-a5b8-8dd78ef72d4a\",536873471,false]],[\"^0\",[3094,\"^2\",181,536873471,false]],[\"^0\",[3094,\"^=\",9,536873471,false]],[\"^0\",[3071,\"^<\",3094,536873471,false]],[\"^0\",[3072,\"^<\",3094,536873471,false]],[\"^0\",[3073,\"^<\",3094,536873471,false]],[\"^0\",[3074,\"^<\",3094,536873471,false]],[\"^0\",[3084,\"^7\",1775984622101,536873471,false]],[\"^0\",[3084,\"^4\",\"a0\",536873471,false]],[\"^0\",[3084,\"^3\",180,536873471,false]],[\"^0\",[3084,\"^6\",\"5\",536873471,false]],[\"^0\",[3084,\"^1\",536873321,536873471,false]],[\"^0\",[3084,\"^8\",1775984622121,536873471,false]],[\"^0\",[3084,\"^9\",\"~u69db5fc0-a560-43fb-a22b-f2d6961ffb31\",536873471,false]],[\"^0\",[3084,\"^2\",181,536873471,false]],[\"^0\",[3088,\"^7\",1775984622233,536873471,false]],[\"^0\",[3088,\"^4\",\"aB\",536873471,false]],[\"^0\",[3088,\"^3\",180,536873471,false]],[\"^0\",[3088,\"^?\",26,536873471,false]],[\"^0\",[3088,\"^?\",58,536873471,false]],[\"^0\",[3088,\"^?\",152,536873471,false]],[\"^0\",[3088,\"^>\",152,536873471,false]],[\"^0\",[3088,\"^6\",\"1\",536873471,false]],[\"^0\",[3088,\"^1\",536873350,536873471,false]],[\"^0\",[3088,\"^8\",1775984622233,536873471,false]],[\"^0\",[3088,\"^9\",\"~u69db5fd1-68fc-4084-8518-ea383e66995c\",536873471,false]],[\"^0\",[3088,\"^2\",181,536873471,false]],[\"^0\",[3088,\"^=\",114,536873471,false]],[\"^0\",[3061,\"^<\",3088,536873471,false]],[\"^0\",[3062,\"^<\",3088,536873471,false]],[\"^0\",[3101,\"^5\",3088,536873471,false]],[\"^0\",[3082,\"^7\",1775984622278,536873471,false]],[\"^0\",[3082,\"^4\",\"aJ\",536873471,false]],[\"^0\",[3082,\"^3\",180,536873471,false]],[\"^0\",[3082,\"^5\",3083,536873471,false]],[\"^0\",[3082,\"^6\",\"3\",536873471,false]],[\"^0\",[3082,\"^1\",536873361,536873471,false]],[\"^0\",[3082,\"^8\",1775984622278,536873471,false]],[\"^0\",[3082,\"^9\",\"~u69db5fd3-5271-4dc5-8af7-8c69d0477130\",536873471,false]],[\"^0\",[3082,\"^2\",181,536873471,false]],[\"^0\",[3101,\"^7\",1775984622233,536873471,false]],[\"^0\",[3101,\"^4\",\"aD\",536873471,false]],[\"^0\",[3101,\"^3\",180,536873471,false]],[\"^0\",[3101,\"^6\",\"2\",536873471,false]],[\"^0\",[3101,\"^1\",536873350,536873471,false]],[\"^0\",[3101,\"^8\",1775984622233,536873471,false]],[\"^0\",[3101,\"^9\",\"~u69db5fd2-825c-45d8-9da0-cd3e1f488648\",536873471,false]],[\"^0\",[3101,\"^2\",181,536873471,false]],[\"^0\",[3102,\"^5\",3101,536873471,false]],[\"^0\",[3092,\"^7\",1775984622233,536873471,false]],[\"^0\",[3092,\"^4\",\"aG\",536873471,false]],[\"^0\",[3092,\"^3\",180,536873471,false]],[\"^0\",[3092,\"^?\",26,536873471,false]],[\"^0\",[3092,\"^?\",58,536873471,false]],[\"^0\",[3092,\"^?\",152,536873471,false]],[\"^0\",[3092,\"^>\",152,536873471,false]],[\"^0\",[3092,\"^6\",\"5\",536873471,false]],[\"^0\",[3092,\"^1\",536873353,536873471,false]],[\"^0\",[3092,\"^8\",1775984595016,536873471,false]],[\"^0\",[3092,\"^9\",\"~u69db5fd2-9363-4c82-b248-588df3b44428\",536873471,false]],[\"^0\",[3092,\"^2\",181,536873471,false]],[\"^0\",[3092,\"^=\",114,536873471,false]],[\"^0\",[3063,\"^<\",3092,536873471,false]],[\"^0\",[3064,\"^<\",3092,536873471,false]],[\"^0\",[3079,\"^7\",1775984622136,536873471,false]],[\"^0\",[3079,\"^4\",\"a5\",536873471,false]],[\"^0\",[3079,\"^3\",180,536873471,false]],[\"^0\",[3079,\"^6\",\"4\",536873471,false]],[\"^0\",[3079,\"^1\",536873327,536873471,false]],[\"^0\",[3079,\"^8\",1775984622136,536873471,false]],[\"^0\",[3079,\"^9\",\"~u69db5fd0-426f-4608-9177-97a62a20a0a6\",536873471,false]],[\"^0\",[3079,\"^2\",181,536873471,false]],[\"^0\",[3100,\"^7\",1775984622334,536873471,false]],[\"^0\",[3100,\"^4\",\"aQ\",536873471,false]],[\"^0\",[3100,\"^3\",180,536873471,false]],[\"^0\",[3100,\"^?\",26,536873471,false]],[\"^0\",[3100,\"^?\",58,536873471,false]],[\"^0\",[3100,\"^?\",152,536873471,false]],[\"^0\",[3100,\"^>\",152,536873471,false]],[\"^0\",[3100,\"^6\",\"5\",536873471,false]],[\"^0\",[3100,\"^1\",536873378,536873471,false]],[\"^0\",[3100,\"^8\",1775984597058,536873471,false]],[\"^0\",[3100,\"^9\",\"~u69db5fd4-83fc-4a35-8408-f4e61cd23a56\",536873471,false]],[\"^0\",[3100,\"^2\",181,536873471,false]],[\"^0\",[3100,\"^=\",114,536873471,false]],[\"^0\",[3075,\"^<\",3100,536873471,false]],[\"^0\",[3076,\"^<\",3100,536873471,false]],[\"^0\",[3087,\"^7\",1775984622189,536873471,false]],[\"^0\",[3087,\"^4\",\"a9\",536873471,false]],[\"^0\",[3087,\"^3\",180,536873471,false]],[\"^0\",[3087,\"^5\",3091,536873471,false]],[\"^0\",[3087,\"^6\",\"3\",536873471,false]],[\"^0\",[3087,\"^1\",536873339,536873471,false]],[\"^0\",[3087,\"^8\",1775984622189,536873471,false]],[\"^0\",[3087,\"^9\",\"~u69db5fd1-e0be-4836-9bbf-8da40fce6b26\",536873471,false]],[\"^0\",[3087,\"^2\",181,536873471,false]],[\"^0\",[3081,\"^7\",1775984622189,536873471,false]],[\"^0\",[3081,\"^4\",\"aA\",536873471,false]],[\"^0\",[3081,\"^3\",180,536873471,false]],[\"^0\",[3081,\"^6\",\"4\",536873471,false]],[\"^0\",[3081,\"^1\",536873339,536873471,false]],[\"^0\",[3081,\"^8\",1775984622189,536873471,false]],[\"^0\",[3081,\"^9\",\"~u69db5fd1-440e-44a7-8a00-607bbf2af4a5\",536873471,false]],[\"^0\",[3081,\"^2\",181,536873471,false]],[\"^0\",[3095,\"^5\",3081,536873471,false]],[\"^0\",[3095,\"^7\",1775984622189,536873471,false]],[\"^0\",[3095,\"^4\",\"aB\",536873471,false]],[\"^0\",[3095,\"^3\",180,536873471,false]],[\"^0\",[3095,\"^6\",\"5\",536873471,false]],[\"^0\",[3095,\"^1\",536873339,536873471,false]],[\"^0\",[3095,\"^8\",1775984622189,536873471,false]],[\"^0\",[3095,\"^9\",\"~u69db5fd1-7a39-4314-b8cb-b1855c78e084\",536873471,false]],[\"^0\",[3095,\"^2\",181,536873471,false]],[\"^0\",[3083,\"^7\",1775984622278,536873471,false]],[\"^0\",[3083,\"^4\",\"aI\",536873471,false]],[\"^0\",[3083,\"^3\",180,536873471,false]],[\"^0\",[3083,\"^6\",\"2\",536873471,false]],[\"^0\",[3083,\"^1\",536873361,536873471,false]],[\"^0\",[3083,\"^8\",1775984622278,536873471,false]],[\"^0\",[3083,\"^9\",\"~u69db5fd3-edce-49f7-b2b0-449ee103901a\",536873471,false]],[\"^0\",[3083,\"^2\",181,536873471,false]],[\"^0\",[3102,\"^7\",1775984622233,536873471,false]],[\"^0\",[3102,\"^4\",\"aE\",536873471,false]],[\"^0\",[3102,\"^3\",180,536873471,false]],[\"^0\",[3102,\"^6\",\"3\",536873471,false]],[\"^0\",[3102,\"^1\",536873350,536873471,false]],[\"^0\",[3102,\"^8\",1775984622233,536873471,false]],[\"^0\",[3102,\"^9\",\"~u69db5fd2-f30e-4bc8-a67e-08ac5322c1cc\",536873471,false]],[\"^0\",[3102,\"^2\",181,536873471,false]],[\"^0\",[3097,\"^7\",1775984622334,536873471,false]],[\"^0\",[3097,\"^4\",\"aN\",536873471,false]],[\"^0\",[3097,\"^3\",180,536873471,false]],[\"^0\",[3097,\"^6\",\"2\",536873471,false]],[\"^0\",[3097,\"^1\",536873375,536873471,false]],[\"^0\",[3097,\"^8\",1775984622334,536873471,false]],[\"^0\",[3097,\"^9\",\"~u69db5fd4-600b-476b-8c23-c1027a259cef\",536873471,false]],[\"^0\",[3097,\"^2\",181,536873471,false]],[\"^0\",[3078,\"^5\",3097,536873471,false]],[\"^0\",[3078,\"^7\",1775984622334,536873471,false]],[\"^0\",[3078,\"^4\",\"aO\",536873471,false]],[\"^0\",[3078,\"^3\",180,536873471,false]],[\"^0\",[3078,\"^6\",\"3\",536873471,false]],[\"^0\",[3078,\"^1\",536873375,536873471,false]],[\"^0\",[3078,\"^8\",1775984622334,536873471,false]],[\"^0\",[3078,\"^9\",\"~u69db5fd4-2a7a-4d88-be50-4a536a4f29dd\",536873471,false]],[\"^0\",[3078,\"^2\",181,536873471,false]],[\"^0\",[3091,\"^7\",1775984622189,536873471,false]],[\"^0\",[3091,\"^4\",\"a8\",536873471,false]],[\"^0\",[3091,\"^3\",180,536873471,false]],[\"^0\",[3091,\"^6\",\"2\",536873471,false]],[\"^0\",[3091,\"^1\",536873339,536873471,false]],[\"^0\",[3091,\"^8\",1775984622189,536873471,false]],[\"^0\",[3091,\"^9\",\"~u69db5fd1-ff5c-4d87-b07b-7f2eb8f22130\",536873471,false]],[\"^0\",[3091,\"^2\",181,536873471,false]],[\"^0\",[3098,\"^7\",1775984622136,536873471,false]],[\"^0\",[3098,\"^4\",\"a4\",536873471,false]],[\"^0\",[3098,\"^3\",180,536873471,false]],[\"^0\",[3098,\"^6\",\"3\",536873471,false]],[\"^0\",[3098,\"^1\",536873327,536873471,false]],[\"^0\",[3098,\"^8\",1775984622136,536873471,false]],[\"^0\",[3098,\"^9\",\"~u69db5fd0-d884-4bc4-9fe0-a44b108bc919\",536873471,false]],[\"^0\",[3098,\"^2\",181,536873471,false]],[\"^0\",[3076,\"^7\",1775984597060,536873471,false]],[\"^0\",[3076,\"^1\",536873378,536873471,false]],[\"^0\",[3076,\"^8\",1775984597060,536873471,false]],[\"^0\",[3076,\"^9\",\"~u69db5fd5-10c1-4bec-b4ad-10be544167c4\",536873471,false]],[\"^0\",[3076,\"^2\",181,536873471,false]],[\"^0\",[3076,\"^;\",26,536873471,false]],[\"^0\",[3076,\"^:\",114,536873471,false]],[\"^0\",[3075,\"^7\",1775984603802,536873471,false]],[\"^0\",[3075,\"^1\",536873378,536873471,false]],[\"^0\",[3075,\"^8\",1775984603802,536873471,false]],[\"^0\",[3075,\"^9\",\"~u69db5fdb-aa3e-4767-825c-00af0198e1e7\",536873471,false]],[\"^0\",[3075,\"^2\",181,536873471,false]],[\"^0\",[3075,\"^;\",26,536873471,false]],[\"^0\",[3075,\"^:\",114,536873471,false]],[\"^0\",[3074,\"^7\",1775984595867,536873471,false]],[\"^0\",[3074,\"^1\",536873364,536873471,false]],[\"^0\",[3074,\"^8\",1775984595867,536873471,false]],[\"^0\",[3074,\"^9\",\"~u69db5fd3-6534-42d8-a5c2-4fe02aa6e94c\",536873471,false]],[\"^0\",[3074,\"^2\",181,536873471,false]],[\"^0\",[3074,\"^;\",26,536873471,false]],[\"^0\",[3074,\"^:\",114,536873471,false]],[\"^0\",[3073,\"^7\",1775984603383,536873471,false]],[\"^0\",[3073,\"^1\",536873364,536873471,false]],[\"^0\",[3073,\"^8\",1775984603383,536873471,false]],[\"^0\",[3073,\"^9\",\"~u69db5fdb-0542-4ec3-a6fd-4e9b3b805963\",536873471,false]],[\"^0\",[3073,\"^2\",181,536873471,false]],[\"^0\",[3073,\"^;\",26,536873471,false]],[\"^0\",[3073,\"^:\",114,536873471,false]],[\"^0\",[3072,\"^7\",1775984596073,536873471,false]],[\"^0\",[3072,\"^1\",536873367,536873471,false]],[\"^0\",[3072,\"^8\",1775984596073,536873471,false]],[\"^0\",[3072,\"^9\",\"~u69db5fd4-cb2f-4364-a9b5-3c4321628245\",536873471,false]],[\"^0\",[3072,\"^2\",181,536873471,false]],[\"^0\",[3072,\"^;\",26,536873471,false]],[\"^0\",[3072,\"^:\",9,536873471,false]],[\"^0\",[3071,\"^7\",1775984603456,536873471,false]],[\"^0\",[3071,\"^1\",536873367,536873471,false]],[\"^0\",[3071,\"^8\",1775984603456,536873471,false]],[\"^0\",[3071,\"^9\",\"~u69db5fdb-7cd3-4fb8-b24a-6658a716ec9e\",536873471,false]],[\"^0\",[3071,\"^2\",181,536873471,false]],[\"^0\",[3071,\"^;\",26,536873471,false]],[\"^0\",[3071,\"^:\",9,536873471,false]],[\"^0\",[3070,\"^7\",1775984592269,536873471,false]],[\"^0\",[3070,\"^1\",536873330,536873471,false]],[\"^0\",[3070,\"^8\",1775984592269,536873471,false]],[\"^0\",[3070,\"^9\",\"~u69db5fd0-7219-4848-add2-93885c48cdf0\",536873471,false]],[\"^0\",[3070,\"^2\",181,536873471,false]],[\"^0\",[3070,\"^;\",26,536873471,false]],[\"^0\",[3070,\"^:\",114,536873471,false]],[\"^0\",[3069,\"^7\",1775984602366,536873471,false]],[\"^0\",[3069,\"^1\",536873330,536873471,false]],[\"^0\",[3069,\"^8\",1775984602366,536873471,false]],[\"^0\",[3069,\"^9\",\"~u69db5fda-8f2b-461f-8fcf-eb2048e612db\",536873471,false]],[\"^0\",[3069,\"^2\",181,536873471,false]],[\"^0\",[3069,\"^;\",26,536873471,false]],[\"^0\",[3069,\"^:\",114,536873471,false]],[\"^0\",[3068,\"^7\",1775984592497,536873471,false]],[\"^0\",[3068,\"^1\",536873333,536873471,false]],[\"^0\",[3068,\"^8\",1775984592497,536873471,false]],[\"^0\",[3068,\"^9\",\"~u69db5fd0-467e-4233-848d-d5a8a17132c1\",536873471,false]],[\"^0\",[3068,\"^2\",181,536873471,false]],[\"^0\",[3068,\"^;\",26,536873471,false]],[\"^0\",[3068,\"^:\",9,536873471,false]],[\"^0\",[3067,\"^7\",1775984602438,536873471,false]],[\"^0\",[3067,\"^1\",536873333,536873471,false]],[\"^0\",[3067,\"^8\",1775984602438,536873471,false]],[\"^0\",[3067,\"^9\",\"~u69db5fda-5536-432b-8527-beb3ef42e823\",536873471,false]],[\"^0\",[3067,\"^2\",181,536873471,false]],[\"^0\",[3067,\"^;\",26,536873471,false]],[\"^0\",[3067,\"^:\",9,536873471,false]],[\"^0\",[3066,\"^7\",1775984592739,536873471,false]],[\"^0\",[3066,\"^1\",536873336,536873471,false]],[\"^0\",[3066,\"^8\",1775984592739,536873471,false]],[\"^0\",[3066,\"^9\",\"~u69db5fd0-dd43-44a3-a49e-9c9eb3ee21de\",536873471,false]],[\"^0\",[3066,\"^2\",181,536873471,false]],[\"^0\",[3066,\"^;\",26,536873471,false]],[\"^0\",[3066,\"^:\",55,536873471,false]],[\"^0\",[3065,\"^7\",1775984602508,536873471,false]],[\"^0\",[3065,\"^1\",536873336,536873471,false]],[\"^0\",[3065,\"^8\",1775984602508,536873471,false]],[\"^0\",[3065,\"^9\",\"~u69db5fda-fd9c-4f43-95ea-1f1f138f42fc\",536873471,false]],[\"^0\",[3065,\"^2\",181,536873471,false]],[\"^0\",[3065,\"^;\",26,536873471,false]],[\"^0\",[3065,\"^:\",55,536873471,false]],[\"^0\",[3064,\"^7\",1775984595023,536873471,false]],[\"^0\",[3064,\"^1\",536873353,536873471,false]],[\"^0\",[3064,\"^8\",1775984595023,536873471,false]],[\"^0\",[3064,\"^9\",\"~u69db5fd3-254e-4d8a-9093-0e30e2ea565d\",536873471,false]],[\"^0\",[3064,\"^2\",181,536873471,false]],[\"^0\",[3064,\"^;\",26,536873471,false]],[\"^0\",[3064,\"^:\",114,536873471,false]],[\"^0\",[3063,\"^7\",1775984603032,536873471,false]],[\"^0\",[3063,\"^1\",536873353,536873471,false]],[\"^0\",[3063,\"^8\",1775984603032,536873471,false]],[\"^0\",[3063,\"^9\",\"~u69db5fdb-d3de-4f3b-b69e-ebd4279e7d9c\",536873471,false]],[\"^0\",[3063,\"^2\",181,536873471,false]],[\"^0\",[3063,\"^;\",26,536873471,false]],[\"^0\",[3063,\"^:\",114,536873471,false]],[\"^0\",[3062,\"^7\",1775984593967,536873471,false]],[\"^0\",[3062,\"^1\",536873345,536873471,false]],[\"^0\",[3062,\"^8\",1775984593967,536873471,false]],[\"^0\",[3062,\"^9\",\"~u69db5fd1-d725-489f-9c7d-bb0fa8fd5421\",536873471,false]],[\"^0\",[3062,\"^2\",181,536873471,false]],[\"^0\",[3062,\"^;\",26,536873471,false]],[\"^0\",[3062,\"^:\",114,536873471,false]],[\"^0\",[3061,\"^7\",1775984602776,536873471,false]],[\"^0\",[3061,\"^1\",536873345,536873471,false]],[\"^0\",[3061,\"^8\",1775984602776,536873471,false]],[\"^0\",[3061,\"^9\",\"~u69db5fda-6885-4fac-b5b0-134fa03e071d\",536873471,false]],[\"^0\",[3061,\"^2\",181,536873471,false]],[\"^0\",[3061,\"^;\",26,536873471,false]],[\"^0\",[3061,\"^:\",114,536873471,false]],[\"^0\",[180,\"^1\",536873378,536873472,false]],[\"^0\",[180,\"^1\",536873471,536873472]],[\"^0\",[3060,\"^7\",1775984195215,536873473,false]],[\"^0\",[3060,\"^4\",\"a1\",536873473,false]],[\"^0\",[3060,\"^3\",180,536873473,false]],[\"^0\",[3060,\"^5\",180,536873473,false]],[\"^0\",[3060,\"^6\",\"6\",536873473,false]],[\"^0\",[3060,\"^8\",1775984196746,536873473,false]],[\"^0\",[3060,\"^9\",\"~u69db5e43-06ef-40dc-9f74-cffd52768f40\",536873473,false]],[\"^0\",[3060,\"^2\",181,536873473,false]],[\"^0\",[3059,\"^7\",1775984293011,536873473,false]],[\"^0\",[3059,\"^4\",\"a1V\",536873473,false]],[\"^0\",[3059,\"^3\",180,536873473,false]],[\"^0\",[3059,\"^5\",180,536873473,false]],[\"^0\",[3059,\"^6\",\"7\",536873473,false]],[\"^0\",[3059,\"^1\",536872551,536873473,false]],[\"^0\",[3059,\"^8\",1775984293020,536873473,false]],[\"^0\",[3059,\"^9\",\"~u69db5e98-e930-44d2-acb0-76bb2b2cfa85\",536873473,false]],[\"^0\",[3059,\"^2\",181,536873473,false]],[\"^0\",[3058,\"^7\",1775984293021,536873473,false]],[\"^0\",[3058,\"^4\",\"a1l\",536873473,false]],[\"^0\",[3058,\"^3\",180,536873473,false]],[\"^0\",[3058,\"^5\",180,536873473,false]],[\"^0\",[3058,\"^6\",\"8\",536873473,false]],[\"^0\",[3058,\"^1\",536872554,536873473,false]],[\"^0\",[3058,\"^8\",1775984293031,536873473,false]],[\"^0\",[3058,\"^9\",\"~u69db5e99-341b-4196-85af-9f5a75828be9\",536873473,false]],[\"^0\",[3058,\"^2\",181,536873473,false]],[\"^0\",[3057,\"^7\",1775984284238,536873473,false]],[\"^0\",[3057,\"^4\",\"a2\",536873473,false]],[\"^0\",[3057,\"^3\",180,536873473,false]],[\"^0\",[3057,\"^5\",180,536873473,false]],[\"^0\",[3057,\"^6\",\"9\",536873473,false]],[\"^0\",[3057,\"^8\",1775984284986,536873473,false]],[\"^0\",[3057,\"^9\",\"~u69db5e9c-e1fb-4d8a-8ce9-1e05986eea79\",536873473,false]],[\"^0\",[3057,\"^2\",181,536873473,false]],[\"^0\",[3056,\"^7\",1775984284987,536873473,false]],[\"^0\",[3056,\"^4\",\"a3\",536873473,false]],[\"^0\",[3056,\"^3\",180,536873473,false]],[\"^0\",[3056,\"^5\",180,536873473,false]],[\"^0\",[3056,\"^6\",\"10fa\",536873473,false]],[\"^0\",[3056,\"^8\",1775984521958,536873473,false]],[\"^0\",[3056,\"^9\",\"~u69db5e9c-1424-4f8a-a0be-2d8c42355472\",536873473,false]],[\"^0\",[3056,\"^2\",181,536873473,false]],[\"^0\",[3055,\"^7\",1775984521960,536873473,false]],[\"^0\",[3055,\"^4\",\"a4\",536873473,false]],[\"^0\",[3055,\"^3\",180,536873473,false]],[\"^0\",[3055,\"^5\",180,536873473,false]],[\"^0\",[3055,\"^6\",\"f\",536873473,false]],[\"^0\",[3055,\"^8\",1775984522125,536873473,false]],[\"^0\",[3055,\"^9\",\"~u69db5f89-2322-4e14-98a7-edc3728c12e4\",536873473,false]],[\"^0\",[3055,\"^2\",181,536873473,false]],[\"^0\",[3054,\"^5\",3055,536873473,false]],[\"^0\",[3054,\"^7\",1775984522126,536873473,false]],[\"^0\",[3054,\"^4\",\"a0\",536873473,false]],[\"^0\",[3054,\"^3\",180,536873473,false]],[\"^0\",[3054,\"^6\",\"a\",536873473,false]],[\"^0\",[3054,\"^8\",1775984522366,536873473,false]],[\"^0\",[3054,\"^9\",\"~u69db5f8a-20b6-4e5e-b82d-9351b6709e3c\",536873473,false]],[\"^0\",[3054,\"^2\",181,536873473,false]],[\"^0\",[3053,\"^7\",1775984522301,536873473,false]],[\"^0\",[3053,\"^4\",\"a6\",536873473,false]],[\"^0\",[3053,\"^3\",180,536873473,false]],[\"^0\",[3053,\"^5\",180,536873473,false]],[\"^0\",[3053,\"^6\",\"f\",536873473,false]],[\"^0\",[3053,\"^8\",1775984522529,536873473,false]],[\"^0\",[3053,\"^9\",\"~u69db5f8a-dea4-4434-a3ae-5b13b90882ce\",536873473,false]],[\"^0\",[3053,\"^2\",181,536873473,false]],[\"^0\",[3051,\"^5\",3053,536873473,false]],[\"^0\",[3052,\"^5\",3053,536873473,false]],[\"^0\",[3051,\"^7\",1775984523459,536873473,false]],[\"^0\",[3051,\"^4\",\"a4\",536873473,false]],[\"^0\",[3051,\"^3\",180,536873473,false]],[\"^0\",[3051,\"^6\",\"\",536873473,false]],[\"^0\",[3051,\"^8\",1775984523459,536873473,false]],[\"^0\",[3051,\"^9\",\"~u69db5f8b-0b14-4a38-a6a9-e7447e0dcac9\",536873473,false]],[\"^0\",[3051,\"^2\",181,536873473,false]],[\"^0\",[3052,\"^7\",1775984522530,536873473,false]],[\"^0\",[3052,\"^4\",\"a0\",536873473,false]],[\"^0\",[3052,\"^3\",180,536873473,false]],[\"^0\",[3052,\"^6\",\"e\",536873473,false]],[\"^0\",[3052,\"^8\",1775984522620,536873473,false]],[\"^0\",[3052,\"^9\",\"~u69db5f8a-bf4b-4013-806f-f2a6e1c0cc73\",536873473,false]],[\"^0\",[3052,\"^2\",181,536873473,false]],[\"^0\",[3047,\"^5\",3052,536873473,false]],[\"^0\",[3048,\"^5\",3052,536873473,false]],[\"^0\",[3049,\"^5\",3052,536873473,false]],[\"^0\",[3050,\"^5\",3052,536873473,false]],[\"^0\",[3047,\"^7\",1775984523179,536873473,false]],[\"^0\",[3047,\"^4\",\"a4\",536873473,false]],[\"^0\",[3047,\"^3\",180,536873473,false]],[\"^0\",[3047,\"^6\",\"f\",536873473,false]],[\"^0\",[3047,\"^8\",1775984523541,536873473,false]],[\"^0\",[3047,\"^9\",\"~u69db5f8b-d66e-43df-ab5f-7d57a066f4e0\",536873473,false]],[\"^0\",[3047,\"^2\",181,536873473,false]],[\"^0\",[3048,\"^7\",1775984523002,536873473,false]],[\"^0\",[3048,\"^4\",\"a3\",536873473,false]],[\"^0\",[3048,\"^3\",180,536873473,false]],[\"^0\",[3048,\"^6\",\"f\",536873473,false]],[\"^0\",[3048,\"^8\",1775984523266,536873473,false]],[\"^0\",[3048,\"^9\",\"~u69db5f8a-c743-4d46-8a33-844916e45c4b\",536873473,false]],[\"^0\",[3048,\"^2\",181,536873473,false]],[\"^0\",[3049,\"^7\",1775984522893,536873473,false]],[\"^0\",[3049,\"^4\",\"a1\",536873473,false]],[\"^0\",[3049,\"^3\",180,536873473,false]],[\"^0\",[3049,\"^6\",\"\",536873473,false]],[\"^0\",[3049,\"^8\",1775984522893,536873473,false]],[\"^0\",[3049,\"^9\",\"~u69db5f8a-9e79-49ce-9a5c-dad08cd22189\",536873473,false]],[\"^0\",[3049,\"^2\",181,536873473,false]],[\"^0\",[3050,\"^7\",1775984522698,536873473,false]],[\"^0\",[3050,\"^4\",\"a2\",536873473,false]],[\"^0\",[3050,\"^3\",180,536873473,false]],[\"^0\",[3050,\"^6\",\"f\",536873473,false]],[\"^0\",[3050,\"^8\",1775984522893,536873473,false]],[\"^0\",[3050,\"^9\",\"~u69db5f8a-3da9-40e5-a22f-c8f9b13a78af\",536873473,false]],[\"^0\",[3050,\"^2\",181,536873473,false]],[\"^0\",[180,\"^1\",536873471,536873474,false]],[\"^0\",[180,\"^1\",536873473,536873474]],[\"^0\",[180,\"^8\",1775984622122,536873475,false]],[\"^0\",[180,\"^8\",1775984657451,536873475]],[\"^0\",[2988,\"^6\",\"3\",536873475,false]],[\"^0\",[2988,\"^6\",\"3f\",536873475]],[\"^0\",[2988,\"^8\",1775984622083,536873475,false]],[\"^0\",[2988,\"^8\",1775984657451,536873475]],[\"^0\",[3104,\"^9\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873476]],[\"^0\",[3104,\"^8\",1775984657454,536873476]],[\"^0\",[3104,\"^7\",1775984657454,536873476]],[\"^0\",[3104,\"^6\",\"\",536873476]],[\"^0\",[3104,\"^5\",2578,536873476]],[\"^0\",[3104,\"^4\",\"a1\",536873476]],[\"^0\",[3104,\"^3\",180,536873476]],[\"^0\",[3104,\"^2\",181,536873476]],[\"^0\",[180,\"^1\",536873473,536873477,false]],[\"^0\",[180,\"^1\",536873475,536873477]],[\"^0\",[2988,\"^1\",536873311,536873477,false]],[\"^0\",[2988,\"^1\",536873475,536873477]],[\"^0\",[3104,\"^1\",536873475,536873477]],[\"^0\",[180,\"^8\",1775984657451,536873478,false]],[\"^0\",[180,\"^8\",1775984657472,536873478]],[\"^0\",[3104,\"^6\",\"\",536873478,false]],[\"^0\",[3104,\"^6\",\"da\",536873478]],[\"^0\",[3104,\"^8\",1775984657454,536873478,false]],[\"^0\",[3104,\"^8\",1775984657472,536873478]],[\"^0\",[3105,\"^9\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873479]],[\"^0\",[3105,\"^8\",1775984657475,536873479]],[\"^0\",[3105,\"^7\",1775984657475,536873479]],[\"^0\",[3105,\"^6\",\"\",536873479]],[\"^0\",[3105,\"^5\",2578,536873479]],[\"^0\",[3105,\"^4\",\"a2\",536873479]],[\"^0\",[3105,\"^3\",180,536873479]],[\"^0\",[3105,\"^2\",181,536873479]],[\"^0\",[180,\"^1\",536873475,536873480,false]],[\"^0\",[180,\"^1\",536873478,536873480]],[\"^0\",[3104,\"^1\",536873475,536873480,false]],[\"^0\",[3104,\"^1\",536873478,536873480]],[\"^0\",[3105,\"^1\",536873478,536873480]],[\"^0\",[180,\"^8\",1775984657472,536873481,false]],[\"^0\",[180,\"^8\",1775984657495,536873481]],[\"^0\",[3105,\"^6\",\"\",536873481,false]],[\"^0\",[3105,\"^6\",\"fda\",536873481]],[\"^0\",[3105,\"^8\",1775984657475,536873481,false]],[\"^0\",[3105,\"^8\",1775984657494,536873481]],[\"^0\",[3106,\"^9\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873482]],[\"^0\",[3106,\"^8\",1775984657497,536873482]],[\"^0\",[3106,\"^7\",1775984657497,536873482]],[\"^0\",[3106,\"^6\",\"\",536873482]],[\"^0\",[3106,\"^5\",2578,536873482]],[\"^0\",[3106,\"^4\",\"a3\",536873482]],[\"^0\",[3106,\"^3\",180,536873482]],[\"^0\",[3106,\"^2\",181,536873482]],[\"^0\",[180,\"^1\",536873478,536873483,false]],[\"^0\",[180,\"^1\",536873481,536873483]],[\"^0\",[3105,\"^1\",536873478,536873483,false]],[\"^0\",[3105,\"^1\",536873481,536873483]],[\"^0\",[3106,\"^1\",536873481,536873483]],[\"^0\",[180,\"^8\",1775984657495,536873484,false]],[\"^0\",[180,\"^8\",1775984657517,536873484]],[\"^0\",[3106,\"^6\",\"\",536873484,false]],[\"^0\",[3106,\"^6\",\"f\",536873484]],[\"^0\",[3106,\"^8\",1775984657497,536873484,false]],[\"^0\",[3106,\"^8\",1775984657516,536873484]],[\"^0\",[3107,\"^9\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873485]],[\"^0\",[3107,\"^8\",1775984657519,536873485]],[\"^0\",[3107,\"^7\",1775984657519,536873485]],[\"^0\",[3107,\"^6\",\"\",536873485]],[\"^0\",[3107,\"^5\",2578,536873485]],[\"^0\",[3107,\"^4\",\"a4\",536873485]],[\"^0\",[3107,\"^3\",180,536873485]],[\"^0\",[3107,\"^2\",181,536873485]],[\"^0\",[180,\"^1\",536873481,536873486,false]],[\"^0\",[180,\"^1\",536873484,536873486]],[\"^0\",[3106,\"^1\",536873481,536873486,false]],[\"^0\",[3106,\"^1\",536873484,536873486]],[\"^0\",[3107,\"^1\",536873484,536873486]],[\"^0\",[3107,\"^5\",2578,536873487,false]],[\"^0\",[3107,\"^5\",2577,536873487]],[\"^0\",[3107,\"^4\",\"a4\",536873487,false]],[\"^0\",[3107,\"^4\",\"a1\",536873487]],[\"^0\",[180,\"^1\",536873484,536873488,false]],[\"^0\",[180,\"^1\",536873487,536873488]],[\"^0\",[3107,\"^1\",536873484,536873488,false]],[\"^0\",[3107,\"^1\",536873487,536873488]],[\"^0\",[3107,\"^5\",2577,536873489,false]],[\"^0\",[3107,\"^5\",180,536873489]],[\"^0\",[180,\"^1\",536873487,536873490,false]],[\"^0\",[180,\"^1\",536873489,536873490]],[\"^0\",[3107,\"^1\",536873487,536873490,false]],[\"^0\",[3107,\"^1\",536873489,536873490]],[\"^0\",[3108,\"^9\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873491]],[\"^0\",[3108,\"^8\",1775984657567,536873491]],[\"^0\",[3108,\"^7\",1775984657567,536873491]],[\"^0\",[3108,\"^6\",\"\",536873491]],[\"^0\",[3108,\"^5\",180,536873491]],[\"^0\",[3108,\"^4\",\"a2\",536873491]],[\"^0\",[3108,\"^3\",180,536873491]],[\"^0\",[3108,\"^2\",181,536873492]],[\"^0\",[180,\"^1\",536873489,536873493,false]],[\"^0\",[180,\"^1\",536873491,536873493]],[\"^0\",[3108,\"^1\",536873491,536873493]],[\"^0\",[3109,\"^9\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873494]],[\"^0\",[3109,\"^8\",1775984657583,536873494]],[\"^0\",[3109,\"^7\",1775984657583,536873494]],[\"^0\",[3109,\"^6\",\"\",536873494]],[\"^0\",[3109,\"^5\",180,536873494]],[\"^0\",[3109,\"^4\",\"a3\",536873494]],[\"^0\",[3109,\"^3\",180,536873494]],[\"^0\",[3109,\"^2\",181,536873495]],[\"^0\",[180,\"^1\",536873491,536873496,false]],[\"^0\",[180,\"^1\",536873494,536873496]],[\"^0\",[3109,\"^1\",536873494,536873496]],[\"^0\",[180,\"^8\",1775984657517,536873497,false]],[\"^0\",[180,\"^8\",1775984657602,536873497]],[\"^0\",[3109,\"^6\",\"\",536873497,false]],[\"^0\",[3109,\"^6\",\"fdasf\",536873497]],[\"^0\",[3109,\"^8\",1775984657583,536873497,false]],[\"^0\",[3109,\"^8\",1775984657602,536873497]],[\"^0\",[3110,\"^9\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873498]],[\"^0\",[3110,\"^8\",1775984657605,536873498]],[\"^0\",[3110,\"^7\",1775984657605,536873498]],[\"^0\",[3110,\"^6\",\"\",536873498]],[\"^0\",[3110,\"^5\",180,536873498]],[\"^0\",[3110,\"^4\",\"a4\",536873498]],[\"^0\",[3110,\"^3\",180,536873498]],[\"^0\",[3110,\"^2\",181,536873498]],[\"^0\",[180,\"^1\",536873494,536873499,false]],[\"^0\",[180,\"^1\",536873497,536873499]],[\"^0\",[3109,\"^1\",536873494,536873499,false]],[\"^0\",[3109,\"^1\",536873497,536873499]],[\"^0\",[3110,\"^1\",536873497,536873499]],[\"^0\",[180,\"^8\",1775984657602,536873500,false]],[\"^0\",[180,\"^8\",1775984657625,536873500]],[\"^0\",[3110,\"^6\",\"\",536873500,false]],[\"^0\",[3110,\"^6\",\"a\",536873500]],[\"^0\",[3110,\"^8\",1775984657605,536873500,false]],[\"^0\",[3110,\"^8\",1775984657625,536873500]],[\"^0\",[3111,\"^9\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873501]],[\"^0\",[3111,\"^8\",1775984657627,536873501]],[\"^0\",[3111,\"^7\",1775984657627,536873501]],[\"^0\",[3111,\"^6\",\"\",536873501]],[\"^0\",[3111,\"^5\",180,536873501]],[\"^0\",[3111,\"^4\",\"a5\",536873501]],[\"^0\",[3111,\"^3\",180,536873501]],[\"^0\",[3111,\"^2\",181,536873501]],[\"^0\",[180,\"^1\",536873497,536873502,false]],[\"^0\",[180,\"^1\",536873500,536873502]],[\"^0\",[3110,\"^1\",536873497,536873502,false]],[\"^0\",[3110,\"^1\",536873500,536873502]],[\"^0\",[3111,\"^1\",536873500,536873502]],[\"^0\",[180,\"^8\",1775984657625,536873503,false]],[\"^0\",[180,\"^8\",1775984657645,536873503]],[\"^0\",[3111,\"^6\",\"\",536873503,false]],[\"^0\",[3111,\"^6\",\"f\",536873503]],[\"^0\",[3111,\"^8\",1775984657627,536873503,false]],[\"^0\",[3111,\"^8\",1775984657645,536873503]],[\"^0\",[3111,\"^5\",180,536873503,false]],[\"^0\",[3111,\"^5\",3110,536873504]],[\"^0\",[3111,\"^4\",\"a5\",536873503,false]],[\"^0\",[3111,\"^4\",\"a0\",536873504]],[\"^0\",[180,\"^1\",536873500,536873505,false]],[\"^0\",[180,\"^1\",536873503,536873505]],[\"^0\",[3111,\"^1\",536873500,536873505,false]],[\"^0\",[3111,\"^1\",536873503,536873505]],[\"^0\",[180,\"^8\",1775984657645,536873506,false]],[\"^0\",[180,\"^8\",1775984657668,536873506]],[\"^0\",[3111,\"^6\",\"f\",536873506,false]],[\"^0\",[3111,\"^6\",\"ffd\",536873506]],[\"^0\",[3111,\"^8\",1775984657645,536873506,false]],[\"^0\",[3111,\"^8\",1775984657668,536873506]],[\"^0\",[3112,\"^9\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873507]],[\"^0\",[3112,\"^8\",1775984657671,536873507]],[\"^0\",[3112,\"^7\",1775984657671,536873507]],[\"^0\",[3112,\"^6\",\"\",536873507]],[\"^0\",[3112,\"^5\",3110,536873507]],[\"^0\",[3112,\"^4\",\"a1\",536873507]],[\"^0\",[3112,\"^3\",180,536873507]],[\"^0\",[3112,\"^2\",181,536873507]],[\"^0\",[180,\"^1\",536873503,536873508,false]],[\"^0\",[180,\"^1\",536873506,536873508]],[\"^0\",[3111,\"^1\",536873503,536873508,false]],[\"^0\",[3111,\"^1\",536873506,536873508]],[\"^0\",[3112,\"^1\",536873506,536873508]],[\"^0\",[180,\"^8\",1775984657668,536873509,false]],[\"^0\",[180,\"^8\",1775984657691,536873509]],[\"^0\",[3112,\"^6\",\"\",536873509,false]],[\"^0\",[3112,\"^6\",\"asf\",536873509]],[\"^0\",[3112,\"^8\",1775984657671,536873509,false]],[\"^0\",[3112,\"^8\",1775984657690,536873509]],[\"^0\",[3113,\"^9\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873510]],[\"^0\",[3113,\"^8\",1775984657693,536873510]],[\"^0\",[3113,\"^7\",1775984657693,536873510]],[\"^0\",[3113,\"^6\",\"\",536873510]],[\"^0\",[3113,\"^5\",3110,536873510]],[\"^0\",[3113,\"^4\",\"a2\",536873510]],[\"^0\",[3113,\"^3\",180,536873510]],[\"^0\",[3113,\"^2\",181,536873510]],[\"^0\",[180,\"^1\",536873506,536873511,false]],[\"^0\",[180,\"^1\",536873509,536873511]],[\"^0\",[3112,\"^1\",536873506,536873511,false]],[\"^0\",[3112,\"^1\",536873509,536873511]],[\"^0\",[3113,\"^1\",536873509,536873511]],[\"^0\",[3113,\"^5\",3110,536873512,false]],[\"^0\",[3113,\"^5\",3112,536873512]],[\"^0\",[3113,\"^4\",\"a2\",536873512,false]],[\"^0\",[3113,\"^4\",\"a0\",536873512]],[\"^0\",[180,\"^1\",536873509,536873513,false]],[\"^0\",[180,\"^1\",536873512,536873513]],[\"^0\",[3113,\"^1\",536873509,536873513,false]],[\"^0\",[3113,\"^1\",536873512,536873513]],[\"^0\",[3113,\"^5\",3112,536873514,false]],[\"^0\",[3113,\"^5\",3110,536873514]],[\"^0\",[3113,\"^4\",\"a0\",536873514,false]],[\"^0\",[3113,\"^4\",\"a2\",536873514]],[\"^0\",[180,\"^1\",536873512,536873515,false]],[\"^0\",[180,\"^1\",536873514,536873515]],[\"^0\",[3113,\"^1\",536873512,536873515,false]],[\"^0\",[3113,\"^1\",536873514,536873515]],[\"^0\",[180,\"^8\",1775984657691,536873516,false]],[\"^0\",[180,\"^8\",1775984657739,536873516]],[\"^0\",[3113,\"^6\",\"\",536873516,false]],[\"^0\",[3113,\"^6\",\"f\",536873516]],[\"^0\",[3113,\"^8\",1775984657693,536873516,false]],[\"^0\",[3113,\"^8\",1775984657739,536873516]],[\"^0\",[3114,\"^9\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873517]],[\"^0\",[3114,\"^8\",1775984657742,536873517]],[\"^0\",[3114,\"^7\",1775984657742,536873517]],[\"^0\",[3114,\"^6\",\"\",536873517]],[\"^0\",[3114,\"^5\",3110,536873517]],[\"^0\",[3114,\"^4\",\"a1V\",536873517]],[\"^0\",[3114,\"^3\",180,536873517]],[\"^0\",[3114,\"^2\",181,536873517]],[\"^0\",[180,\"^1\",536873514,536873518,false]],[\"^0\",[180,\"^1\",536873516,536873518]],[\"^0\",[3113,\"^1\",536873514,536873518,false]],[\"^0\",[3113,\"^1\",536873516,536873518]],[\"^0\",[3114,\"^1\",536873516,536873518]],[\"^0\",[3114,\"^5\",3110,536873519,false]],[\"^0\",[3114,\"^5\",3112,536873519]],[\"^0\",[3114,\"^4\",\"a1V\",536873519,false]],[\"^0\",[3114,\"^4\",\"a0\",536873519]],[\"^0\",[180,\"^1\",536873516,536873520,false]],[\"^0\",[180,\"^1\",536873519,536873520]],[\"^0\",[3114,\"^1\",536873516,536873520,false]],[\"^0\",[3114,\"^1\",536873519,536873520]],[\"^0\",[3115,\"^9\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873521]],[\"^0\",[3115,\"^8\",1775984657775,536873521]],[\"^0\",[3115,\"^7\",1775984657775,536873521]],[\"^0\",[3115,\"^6\",\"\",536873521]],[\"^0\",[3115,\"^5\",3112,536873521]],[\"^0\",[3115,\"^4\",\"a1\",536873521]],[\"^0\",[3115,\"^3\",180,536873521]],[\"^0\",[3115,\"^2\",181,536873522]],[\"^0\",[180,\"^1\",536873519,536873523,false]],[\"^0\",[180,\"^1\",536873521,536873523]],[\"^0\",[3115,\"^1\",536873521,536873523]],[\"^0\",[3116,\"^9\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873524]],[\"^0\",[3116,\"^8\",1775984657790,536873524]],[\"^0\",[3116,\"^7\",1775984657790,536873524]],[\"^0\",[3116,\"^6\",\"\",536873524]],[\"^0\",[3116,\"^5\",3112,536873524]],[\"^0\",[3116,\"^4\",\"a0V\",536873524]],[\"^0\",[3116,\"^3\",180,536873524]],[\"^0\",[3116,\"^2\",181,536873525]],[\"^0\",[180,\"^1\",536873521,536873526,false]],[\"^0\",[180,\"^1\",536873524,536873526]],[\"^0\",[3116,\"^1\",536873524,536873526]],[\"^0\",[3117,\"^9\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873527]],[\"^0\",[3117,\"^8\",1775984657810,536873527]],[\"^0\",[3117,\"^7\",1775984657810,536873527]],[\"^0\",[3117,\"^6\",\"\",536873527]],[\"^0\",[3117,\"^5\",3112,536873527]],[\"^0\",[3117,\"^4\",\"a0l\",536873527]],[\"^0\",[3117,\"^3\",180,536873527]],[\"^0\",[3117,\"^2\",181,536873528]],[\"^0\",[180,\"^1\",536873524,536873529,false]],[\"^0\",[180,\"^1\",536873527,536873529]],[\"^0\",[3117,\"^1\",536873527,536873529]],[\"^0\",[3116,\"^5\",3112,536873530,false]],[\"^0\",[3116,\"^5\",3114,536873530]],[\"^0\",[3116,\"^4\",\"a0V\",536873530,false]],[\"^0\",[3116,\"^4\",\"a0\",536873530]],[\"^0\",[180,\"^1\",536873527,536873531,false]],[\"^0\",[180,\"^1\",536873530,536873531]],[\"^0\",[3116,\"^1\",536873524,536873531,false]],[\"^0\",[3116,\"^1\",536873530,536873531]],[\"^0\",[180,\"^8\",1775984657739,536873532,false]],[\"^0\",[180,\"^8\",1775984657858,536873532]],[\"^0\",[3117,\"^6\",\"\",536873532,false]],[\"^0\",[3117,\"^6\",\"f\",536873532]],[\"^0\",[3117,\"^8\",1775984657810,536873532,false]],[\"^0\",[3117,\"^8\",1775984657857,536873532]],[\"^0\",[3118,\"^9\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873533]],[\"^0\",[3118,\"^8\",1775984657861,536873533]],[\"^0\",[3118,\"^7\",1775984657861,536873533]],[\"^0\",[3118,\"^6\",\"\",536873533]],[\"^0\",[3118,\"^5\",3112,536873533]],[\"^0\",[3118,\"^4\",\"a0t\",536873533]],[\"^0\",[3118,\"^3\",180,536873533]],[\"^0\",[3118,\"^2\",181,536873533]],[\"^0\",[180,\"^1\",536873530,536873534,false]],[\"^0\",[180,\"^1\",536873532,536873534]],[\"^0\",[3117,\"^1\",536873527,536873534,false]],[\"^0\",[3117,\"^1\",536873532,536873534]],[\"^0\",[3118,\"^1\",536873532,536873534]]]"} diff --git a/deps/db-sync/test/logseq/db_sync/fixtures/rebased_retract_checksum_payload.edn b/deps/db-sync/test/logseq/db_sync/fixtures/rebased_retract_checksum_payload.edn new file mode 100644 index 0000000..5d00483 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/fixtures/rebased_retract_checksum_payload.edn @@ -0,0 +1 @@ +{:db-before "[\"~#datascript/DB\",[\"^ \",\"~:schema\",[\"^ \",\"~i32\",\"~:logseq.property/priority\",\"~i64\",\"~:logseq.property/ls-type\",\"~i96\",\"~:logseq.property.repeat/repeated?\",\"~i128\",\"~:logseq.property/view-for\",\"~:logseq.property.asset/align\",[\"^ \",\"~:db/ident\",\"^:\",\"~:db/cardinality\",\"~:db.cardinality/one\",\"~:db/index\",true],\"~:logseq.property.view/type.table\",[\"^ \",\"^;\",\"^?\"],\"~:logseq.property/ui-position\",[\"^ \",\"^;\",\"^@\",\"^<\",\"^=\",\"^>\",true],\"~:file/created-at\",[\"^ \"],\"~:logseq.property.repeat/temporal-property\",[\"^ \",\"^;\",\"^B\",\"^<\",\"^=\",\"^>\",true,\"~:db/valueType\",\"~:db.type/ref\"],\"~:logseq.property.user/name\",[\"^ \",\"^;\",\"^E\",\"^<\",\"^=\",\"^>\",true],\"~i1\",\"~:logseq.property.reaction/target\",\"~i33\",\"~:logseq.property/priority.urgent\",\"~i65\",\"~:logseq.property/color.yellow\",\"~i97\",\"~:block/closed-value-property\",\"~:logseq.property.pdf/hl-image\",[\"^ \",\"^;\",\"^M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i129\",\"~:logseq.property.user/avatar\",\"~:logseq.property.fsrs/state\",[\"^ \",\"^;\",\"^P\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.pdf/hl-color\",[\"^ \",\"^;\",\"^Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-initial-schema-version\",[\"^ \",\"^;\",\"^R\"],\"~:block/tx-id\",[\"^ \"],\"~:logseq.property/value\",[\"^ \",\"^;\",\"^T\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.week\",[\"^ \",\"^;\",\"^U\"],\"~i2\",\"~:logseq.kv/db-type\",\"~i34\",\"~:logseq.property/public?\",\"~i66\",\"~:logseq.property.asset/width\",\"~i98\",\"~:logseq.property.reaction/emoji-id\",\"~i130\",\"^E\",\"~:logseq.property.table/sorting\",[\"^ \",\"^;\",\"^12\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/choice-exclusions\",[\"^ \",\"^;\",\"^13\",\"^<\",\"~:db.cardinality/many\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.table/pinned-columns\",[\"^ \",\"^;\",\"^15\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit.day\",[\"^ \",\"^;\",\"^16\"],\"~i3\",\"~:logseq.property.repeat/checked-property\",\"~i35\",\"~:logseq.property.recycle/original-order\",\"~i67\",\"~:logseq.property.asset/remote-metadata\",\"~i99\",\"~:logseq.property/priority.high\",\"~i131\",\"~:logseq.property.asset/size\",\"~:logseq.property.sync/large-title-object\",[\"^ \",\"^;\",\"^1@\",\"^<\",\"^=\",\"^>\",true],\"~i4\",\"~:logseq.class/Pdf-annotation\",\"~i36\",\"~:block/journal-day\",\"~i68\",\"~:logseq.property.view/type.list\",\"~i100\",\"~:logseq.property/color.blue\",\"~i132\",\"~:logseq.property.class/bidirectional-property-title\",\"~:logseq.property/deadline\",[\"^ \",\"^;\",\"^1J\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/type\",[\"^ \",\"^;\",\"^1K\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.year\",[\"^ \",\"^;\",\"^1L\"],\"~:file/content\",[\"^ \"],\"~:user.property/p3-Bhx1XTp3\",[\"^ \",\"^;\",\"^1N\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i5\",\"~:logseq.property.asset/checksum\",\"~i37\",\"~:logseq.property.node/display-type\",\"~i69\",\"~:block/refs\",\"~i101\",\"~:logseq.property.table/ordered-columns\",\"~:logseq.property.view/sort-groups-desc?\",[\"^ \",\"^;\",\"^1V\",\"^<\",\"^=\",\"^>\",true],\"~i133\",\"~:logseq.property.class/hide-from-node\",\"~:logseq.property.table/sized-columns\",[\"^ \",\"^;\",\"^1Y\",\"^<\",\"^=\",\"^>\",true],\"~:block/alias\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^>\",true,\"^;\",\"^1Z\"],\"~:logseq.property.publish/published-url\",[\"^ \",\"^;\",\"^1[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/external-url\",[\"^ \",\"^;\",\"^20\",\"^<\",\"^=\",\"^>\",true],\"~i6\",\"^1Y\",\"^O\",[\"^ \",\"^;\",\"^O\",\"^<\",\"^=\",\"^>\",true],\"~i38\",\"~:logseq.class/Template\",\"~:user.property/p5-q5guozE4\",[\"^ \",\"^;\",\"^23\",\"^C\",\"^D\",\"^>\",true,\"^<\",\"^14\"],\"~i70\",\"^?\",\"~i102\",\"~:logseq.property/background-color\",\"~i134\",\"^B\",\"~:kv/value\",[\"^ \"],\"~:logseq.property/scalar-default-value\",[\"^ \",\"^;\",\"^29\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/type\",[\"^ \",\"^;\",\"^2:\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Asset\",[\"^ \",\"^;\",\"^2;\"],\"^19\",[\"^ \",\"^;\",\"^19\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.class/enable-bidirectional?\",[\"^ \",\"^;\",\"^2<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/color.green\",[\"^ \",\"^;\",\"^2=\"],\"~i7\",\"^1Z\",\"~i39\",\"~:logseq.kv/local-graph-uuid\",\"~i71\",\"~:logseq.property/description\",\"~i103\",\"~:logseq.class/Cards\",\"~i135\",\"~:logseq.property.repeat/recur-unit.minute\",\"~:logseq.property.linked-references/includes\",[\"^ \",\"^;\",\"^2F\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit\",[\"^ \",\"^;\",\"^2G\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/checkbox-display-properties\",[\"^ \",\"^;\",\"^2H\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:block/link\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^2I\",\"^<\",\"^=\"],\"~:logseq.property.view/type\",[\"^ \",\"^;\",\"^2J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.class/Quote-block\",[\"^ \",\"^;\",\"^2K\"],\"~:logseq.property/heading\",[\"^ \",\"^;\",\"^2L\",\"^<\",\"^=\",\"^>\",true],\"~i8\",\"~:logseq.property.view/sort-groups-by-property\",\"~i40\",\"~:logseq.property.pdf/hl-page\",\"~i72\",\"~:logseq.property.recycle/original-page\",\"~i104\",\"~:logseq.class/Page\",\"~:block/uuid\",[\"^ \",\"~:db/unique\",\"~:db.unique/identity\"],\"~i136\",\"~:logseq.property/query\",\"~:logseq.property/color.purple\",[\"^ \",\"^;\",\"^2Y\"],\"~:logseq.property.asset/external-file-name\",[\"^ \",\"^;\",\"^2Z\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.table/hidden-columns\",[\"^ \",\"^;\",\"^2[\",\"^<\",\"^14\",\"^>\",true],\"~:logseq.class/Property\",[\"^ \",\"^;\",\"^30\"],\"~:logseq.kv/graph-git-sha\",[\"^ \",\"^;\",\"^31\"],\"~i9\",\"~:logseq.property/status.doing\",\"~i41\",\"^2H\",\"~i73\",\"~:logseq.property.linked-references/excludes\",\"~i105\",\"^U\",\"^2S\",[\"^ \",\"^;\",\"^2S\"],\"~i137\",\"~:logseq.property.user/email\",\"~:logseq.kv/graph-remote?\",[\"^ \",\"^;\",\"^39\"],\"^2?\",[\"^ \",\"^;\",\"^2?\"],\"~:logseq.kv/graph-rtc-e2ee?\",[\"^ \",\"^;\",\"^3:\"],\"~:block/updated-at\",[\"^ \",\"^>\",true,\"^;\",\"^3;\",\"^<\",\"^=\"],\"^1G\",[\"^ \",\"^;\",\"^1G\"],\"~i10\",\"~:logseq.class/Math-block\",\"~i42\",\"^2Y\",\"~i74\",\"~:logseq.property.history/scalar-value\",\"~i106\",\"~:logseq.property.tldraw/shape\",\"~i138\",\"~:block/collapsed?\",\"^1E\",[\"^ \",\"^;\",\"^1E\"],\"^F\",[\"^ \",\"^;\",\"^F\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i11\",\"~:logseq.property/status.canceled\",\"~:file/size\",[\"^ \"],\"~i43\",\"^P\",\"~:logseq.property/status\",[\"^ \",\"^;\",\"^3I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i75\",\"^1J\",\"~:logseq.property.class/extends\",[\"^ \",\"^;\",\"^3K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i107\",\"~:logseq.property.asset/height\",\"~i139\",\"~:logseq.property.repeat/recur-unit.hour\",\"~:logseq.property/hide-empty-value\",[\"^ \",\"^;\",\"^3P\",\"^<\",\"^=\",\"^>\",true],\"^2M\",[\"^ \",\"^;\",\"^2M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1S\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^1S\",\"^>\",true],\"~i2475\",\"~:user.class/tag2-GCc7Ko89\",\"~i619\",\"~:user.class/tag1-bGi52uW6\",\"~:logseq.class/Card\",[\"^ \",\"^;\",\"^3U\"],\"~:user.property/p2-JBrZedg-\",[\"^ \",\"^;\",\"^3V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^10\",[\"^ \",\"^;\",\"^10\",\"^<\",\"^=\",\"^>\",true],\"~i12\",\"~:block/parent\",\"~i44\",\"^1V\",\"~i76\",\"^2=\",\"~i108\",\"~:logseq.property.code/lang\",\"~i140\",\"~:logseq.property.tldraw/page\",\"~:logseq.property.view/group-by-property\",[\"^ \",\"^;\",\"^43\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/view-context\",[\"^ \",\"^;\",\"^44\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/schema-version\",[\"^ \",\"^;\",\"^45\"],\"^2O\",[\"^ \",\"^;\",\"^2O\",\"^<\",\"^=\",\"^>\",true],\"~i13\",\"^2G\",\"~i45\",\"~:block/created-at\",\"^L\",[\"^ \",\"^C\",\"^D\",\"^;\",\"^L\",\"^>\",true,\"^<\",\"^14\"],\"~i77\",\"~:logseq.property/choice-classes\",\"~i109\",\"~:logseq.property/created-by-ref\",\"^1O\",[\"^ \",\"^;\",\"^1O\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/publishing-public?\",[\"^ \",\"^;\",\"^4=\",\"^<\",\"^=\",\"^>\",true],\"~i141\",\"^31\",\"~:user.property/p10-QpfPhdWj\",[\"^ \",\"^>\",true,\"^C\",\"^D\",\"^<\",\"^=\",\"^;\",\"^4?\"],\"^1Q\",[\"^ \",\"^;\",\"^1Q\",\"^<\",\"^=\",\"^>\",true],\"~i2477\",\"^23\",\"~:logseq.property/status.backlog\",[\"^ \",\"^;\",\"^4A\"],\"~i14\",\"~:logseq.class/Tag\",\"~:file/last-modified-at\",[\"^ \"],\"~i46\",\"^2;\",\"~i78\",\"^2K\",\"~i110\",\"^13\",\"~i142\",\"~:logseq.class/Query\",\"~:logseq.kv/recycle-last-gc-at\",[\"^ \",\"^;\",\"^4J\"],\"~:logseq.property.class/properties\",[\"^ \",\"^;\",\"^4K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/used-template\",[\"^ \",\"^;\",\"^4L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2702\",\"^4?\",\"~:logseq.property/enable-history?\",[\"^ \",\"^;\",\"^4N\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/graph-last-gc-at\",[\"^ \",\"^;\",\"^4O\"],\"~i15\",\"~:logseq.kv/graph-created-at\",\"~i47\",\"~:block/page\",\"~i79\",\"^29\",\"^3=\",[\"^ \",\"^;\",\"^3=\"],\"~i111\",\"^R\",\"~i143\",\"~:logseq.property/choice-checkbox-state\",\"^48\",[\"^ \",\"^>\",true,\"^;\",\"^48\",\"^<\",\"^=\"],\"^3@\",[\"^ \",\"^;\",\"^3@\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/page-tags\",[\"^ \",\"^;\",\"^4X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/scheduled\",[\"^ \",\"^;\",\"^4Y\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Task\",[\"^ \",\"^;\",\"^4Z\"],\"^3D\",[\"^ \",\"^;\",\"^3D\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/last-visit-page\",[\"^ \",\"^;\",\"^4[\",\"^<\",\"^=\",\"^>\",true],\"^42\",[\"^ \",\"^;\",\"^42\",\"^<\",\"^=\",\"^>\",true],\"~i16\",\"~:logseq.property/icon\",\"~i48\",\"^1L\",\"~i80\",\"^4[\",\"~i112\",\"~:logseq.property.journal/title-format\",\"~i144\",\"^3P\",\"~:logseq.property/built-in?\",[\"^ \",\"^;\",\"^57\",\"^<\",\"^=\",\"^>\",true],\"~i17\",\"^2Z\",\"~i49\",\"~:logseq.property/exclude-from-graph-view\",\"~i81\",\"^4L\",\"^1X\",[\"^ \",\"^;\",\"^1X\",\"^<\",\"^=\",\"^>\",true],\"~i113\",\"^2F\",\"~i145\",\"^3;\",\"^35\",[\"^ \",\"^;\",\"^35\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.view/feature-type\",[\"^ \",\"^;\",\"^5>\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Code-block\",[\"^ \",\"^;\",\"^5?\"],\"^4W\",[\"^ \",\"^;\",\"^4W\",\"^<\",\"^=\",\"^>\",true],\"^17\",[\"^ \",\"^;\",\"^17\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2705\",\"^4O\",\"~:logseq.property.asset/resize-metadata\",[\"^ \",\"^;\",\"^5A\",\"^<\",\"^=\",\"^>\",true],\"^5\",[\"^ \",\"^;\",\"^5\",\"^<\",\"^=\",\"^>\",true],\"^2C\",[\"^ \",\"^;\",\"^2C\"],\"^38\",[\"^ \",\"^;\",\"^38\",\"^<\",\"^=\",\"^>\",true],\"^9\",[\"^ \",\"^;\",\"^9\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^Z\",[\"^ \",\"^;\",\"^Z\",\"^<\",\"^=\",\"^>\",true],\"~i18\",\"~:logseq.property.history/block\",\"^2X\",[\"^ \",\"^;\",\"^2X\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i50\",\"^15\",\"~i82\",\"~:logseq.property.repeat/recur-unit.month\",\"~i114\",\"~:logseq.property/status.todo\",\"~i146\",\"^20\",\"~:logseq.property/hide?\",[\"^ \",\"^;\",\"^5J\",\"^<\",\"^=\",\"^>\",true],\"^26\",[\"^ \",\"^;\",\"^26\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-frequency\",[\"^ \",\"^;\",\"^5K\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/default-value\",[\"^ \",\"^;\",\"^5L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1C\",[\"^ \",\"^>\",true,\"^;\",\"^1C\",\"^<\",\"^=\"],\"~i2706\",\"^4J\",\"~:user.property/p1-Ka7c_Z9D\",[\"^ \",\"^;\",\"^5N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/color.red\",[\"^ \",\"^;\",\"^5O\"],\"~i19\",\"^Q\",\"~:logseq.class/Root\",[\"^ \",\"^;\",\"^5Q\"],\"~i51\",\"^1@\",\"~i83\",\"~:logseq.property.history/ref-value\",\"~i115\",\"~:logseq.property.history/property\",\"~i147\",\"~:logseq.property/template-applied-to\",\"^2E\",[\"^ \",\"^;\",\"^2E\"],\"~:logseq.property/empty-placeholder\",[\"^ \",\"^;\",\"^5Y\"],\"^40\",[\"^ \",\"^;\",\"^40\",\"^<\",\"^=\",\"^>\",true],\"~i20\",\"^2<\",\"~i52\",\"^45\",\"~i84\",\"~:logseq.property.table/filters\",\"~i116\",\"~:logseq.property.recycle/original-parent\",\"^4<\",[\"^ \",\"^;\",\"^4<\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-uuid\",[\"^ \",\"^;\",\"^64\"],\"~i148\",\"~:logseq.property/status.in-review\",\"~i2196\",[\"^ \"],\"^1=\",[\"^ \",\"^;\",\"^1=\"],\"~:logseq.property/priority.low\",[\"^ \",\"^;\",\"^68\"],\"~:block/tags\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^69\",\"^>\",true],\"~:block/title\",[\"^ \",\"^>\",true,\"^;\",\"^6:\",\"^<\",\"^=\"],\"~:user.property/p4-Et0d5LxL\",[\"^ \",\"^;\",\"^6;\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^3B\",[\"^ \",\"^;\",\"^3B\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Whiteboard\",[\"^ \",\"^;\",\"^6<\"],\"~i21\",\"^6:\",\"~i53\",\"~:logseq.property/created-from-property\",\"~i85\",\"~:logseq.class/Journal\",\"~i117\",\"~:logseq.property.pdf/hl-value\",\"~i149\",\"^1[\",\"^6?\",[\"^ \",\"^;\",\"^6?\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i565\",\"^5N\",\"^51\",[\"^ \",\"^;\",\"^51\",\"^<\",\"^=\",\"^>\",true],\"^1I\",[\"^ \",\"^;\",\"^1I\",\"^<\",\"^=\",\"^>\",true],\"^H\",[\"^ \",\"^;\",\"^H\"],\"^4:\",[\"^ \",\"^;\",\"^4:\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^1?\",[\"^ \",\"^;\",\"^1?\",\"^<\",\"^=\",\"^>\",true],\"~i22\",\"~:logseq.property/classes\",\"~i54\",\"^4=\",\"~i86\",\"^57\",\"~i118\",\"^43\",\"~i150\",\"~:logseq.property.view/type.gallery\",\"~i182\",\"^64\",\"^6G\",[\"^ \",\"^;\",\"^6G\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/asset\",[\"^ \",\"^;\",\"^6N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^4Q\",[\"^ \",\"^;\",\"^4Q\"],\"~i23\",\"^39\",\"~i55\",\"~:logseq.property/status.done\",\"~:logseq.property/deleted-at\",[\"^ \",\"^;\",\"^6R\",\"^<\",\"^=\",\"^>\",true],\"~i87\",\"^6<\",\"~i119\",\"^5J\",\"~i151\",\"^5Y\",\"~i183\",\"^3:\",\"~:logseq.property/order-list-type\",[\"^ \",\"^;\",\"^6W\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5:\",[\"^ \",\"^;\",\"^5:\",\"^<\",\"^=\",\"^>\",true],\"~i24\",\"^5A\",\"~:logseq.property/deleted-by-ref\",[\"^ \",\"^;\",\"^6Y\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i56\",\"^T\",\"~:logseq.property.pdf/hl-type\",[\"^ \",\"^;\",\"^6[\",\"^<\",\"^=\",\"^>\",true],\"~i88\",\"^:\",\"~i120\",\"^2I\",\"~i152\",\"^4Z\",\"^;\",[\"^ \",\"^2U\",\"^2V\"],\"^6L\",[\"^ \",\"^;\",\"^6L\"],\"^2A\",[\"^ \",\"^;\",\"^2A\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i568\",\"^3V\",\"^5C\",[\"^ \",\"^;\",\"^5C\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i600\",\"^6;\",\"^3M\",[\"^ \",\"^;\",\"^3M\",\"^<\",\"^=\",\"^>\",true],\"^3X\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^3X\",\"^<\",\"^=\"],\"~i25\",\"^6R\",\"~i57\",\"^2:\",\"~i89\",\"^5Q\",\"^2Q\",[\"^ \",\"^;\",\"^2Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i121\",\"^4K\",\"~i153\",\"~:logseq.property/priority.medium\",\"~i569\",\"^1N\",\"^6A\",[\"^ \",\"^;\",\"^6A\"],\"~i26\",\"^3I\",\"~i58\",\"^69\",\"~i90\",\"^2L\",\"^3O\",[\"^ \",\"^;\",\"^3O\"],\"^J\",[\"^ \",\"^;\",\"^J\"],\"~i122\",\"~:block/order\",\"~i154\",\"^1K\",\"^63\",[\"^ \",\"^;\",\"^63\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^7:\",[\"^ \",\"^;\",\"^7:\"],\"^5V\",[\"^ \",\"^;\",\"^5V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^6C\",[\"^ \",\"^;\",\"^6C\",\"^<\",\"^=\",\"^>\",true],\"^4I\",[\"^ \",\"^;\",\"^4I\"],\"~i27\",\"^3U\",\"~i59\",\"^6W\",\"~i91\",\"^M\",\"~i123\",\"^4N\",\"~i155\",\"^4Y\",\"^1;\",[\"^ \",\"^;\",\"^1;\",\"^<\",\"^=\",\"^>\",true],\"^3R\",[\"^ \",\"^;\",\"^3R\"],\"^5F\",[\"^ \",\"^;\",\"^5F\"],\"^7@\",[\"^ \",\"^>\",true,\"^;\",\"^7@\",\"^<\",\"^=\"],\"^3\",[\"^ \",\"^;\",\"^3\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5T\",[\"^ \",\"^;\",\"^5T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.fsrs/due\",[\"^ \",\"^;\",\"^7G\",\"^<\",\"^=\",\"^>\",true],\"~i28\",\"^5?\",\"^32\",[\"^ \",\"^;\",\"^32\"],\"~i60\",\"^@\",\"~i92\",\"^44\",\"~i124\",\"^30\",\"^4S\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^4S\",\"^<\",\"^=\"],\"~i156\",\"^5L\",\"~:block/name\",[\"^ \",\"^>\",true],\"^1A\",[\"^ \",\"^;\",\"^1A\"],\"^1U\",[\"^ \",\"^;\",\"^1U\",\"^<\",\"^=\",\"^>\",true],\"~:file/path\",[\"^ \",\"^2U\",\"^2V\"],\"~i29\",\"^7G\",\"~i61\",\"^16\",\"~i93\",\"^5>\",\"~i125\",\"^4X\",\"~i157\",\"^6Y\",\"^7\",[\"^ \",\"^;\",\"^7\",\"^<\",\"^=\",\"^>\",true],\"^4C\",[\"^ \",\"^;\",\"^4C\"],\"^66\",[\"^ \",\"^;\",\"^66\"],\"^X\",[\"^ \",\"^;\",\"^X\",\"^<\",\"^=\",\"^>\",true],\"~i30\",\"^6N\",\"~i62\",\"^6[\",\"~i94\",\"^68\",\"~i126\",\"^4A\",\"~i158\",\"^3K\",\"^3F\",[\"^ \",\"^;\",\"^3F\"],\"^5X\",[\"^ \",\"^;\",\"^5X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^22\",[\"^ \",\"^;\",\"^22\"],\"^55\",[\"^ \",\"^;\",\"^55\",\"^<\",\"^=\",\"^>\",true],\"^6Q\",[\"^ \",\"^;\",\"^6Q\"],\"~i31\",\"^5O\",\"~i63\",\"^2[\",\"^V\",[\"^ \",\"^;\",\"^V\"],\"~i95\",\"^5K\",\"~i127\",\"^2J\",\"~i159\",\"^12\",\"^61\",[\"^ \",\"^;\",\"^61\",\"^<\",\"^=\",\"^>\",true],\"^3T\",[\"^ \",\"^;\",\"^3T\"],\"^5H\",[\"^ \",\"^;\",\"^5H\"]],\"~:datoms\",[\"~#list\",[[\"~#datascript/Datom\",[1,\"^48\",1775980635495,536870913]],[\"^84\",[1,\"^7M\",\"reaction target\",536870913]],[\"^84\",[1,\"^7@\",\"d3BHM\",536870913]],[\"^84\",[1,\"^69\",124,536870913]],[\"^84\",[1,\"^6:\",\"Reaction target\",536870913]],[\"^84\",[1,\"^3;\",1775980635495,536870913]],[\"^84\",[1,\"^2T\",\"~u00000002-1127-5718-5000-000000000000\",536870913]],[\"^84\",[1,\"^<\",\"^=\",536870913]],[\"^84\",[1,\"^;\",\"^F\",536870913]],[\"^84\",[1,\"^>\",true,536870913]],[\"^84\",[1,\"^C\",\"^D\",536870913]],[\"^84\",[1,\"^57\",true,536870913]],[\"^84\",[1,\"^5J\",true,536870913]],[\"^84\",[1,\"^X\",false,536870913]],[\"^84\",[1,\"^1K\",\"~:node\",536870913]],[\"^84\",[2,\"^;\",\"^V\",536870913]],[\"^84\",[2,\"^28\",\"db\",536870913]],[\"^84\",[3,\"^48\",1775980635492,536870913]],[\"^84\",[3,\"^7M\",\"repeating checked property\",536870913]],[\"^84\",[3,\"^7@\",\"d3BGY\",536870913]],[\"^84\",[3,\"^69\",124,536870913]],[\"^84\",[3,\"^6:\",\"Repeating Checked Property\",536870913]],[\"^84\",[3,\"^3;\",1775980635492,536870913]],[\"^84\",[3,\"^2T\",\"~u00000002-1866-3655-5300-000000000000\",536870913]],[\"^84\",[3,\"^<\",\"^=\",536870913]],[\"^84\",[3,\"^;\",\"^17\",536870913]],[\"^84\",[3,\"^>\",true,536870913]],[\"^84\",[3,\"^C\",\"^D\",536870913]],[\"^84\",[3,\"^57\",true,536870913]],[\"^84\",[3,\"^5J\",true,536870913]],[\"^84\",[3,\"^1K\",\"~:property\",536870913]],[\"^84\",[4,\"^48\",1775980635496,536870913]],[\"^84\",[4,\"^7M\",\"pdf annotation\",536870913]],[\"^84\",[4,\"^69\",14,536870913]],[\"^84\",[4,\"^6:\",\"PDF Annotation\",536870913]],[\"^84\",[4,\"^3;\",1775980635496,536870913]],[\"^84\",[4,\"^2T\",\"~u00000002-5049-5962-0000-000000000000\",536870913]],[\"^84\",[4,\"^;\",\"^1A\",536870913]],[\"^84\",[4,\"^57\",true,536870913]],[\"^84\",[4,\"^3K\",89,536870913]],[\"^84\",[4,\"^1X\",true,536870913]],[\"^84\",[4,\"^4K\",19,536870913]],[\"^84\",[4,\"^4K\",30,536870913]],[\"^84\",[4,\"^4K\",40,536870913]],[\"^84\",[4,\"^4K\",62,536870913]],[\"^84\",[4,\"^4K\",64,536870913]],[\"^84\",[4,\"^4K\",91,536870913]],[\"^84\",[4,\"^4K\",117,536870913]],[\"^84\",[5,\"^48\",1775980635493,536870913]],[\"^84\",[5,\"^7M\",\"file checksum\",536870913]],[\"^84\",[5,\"^7@\",\"d3BGy\",536870913]],[\"^84\",[5,\"^69\",124,536870913]],[\"^84\",[5,\"^6:\",\"File checksum\",536870913]],[\"^84\",[5,\"^3;\",1775980635493,536870913]],[\"^84\",[5,\"^2T\",\"~u00000002-1011-4169-7900-000000000000\",536870913]],[\"^84\",[5,\"^<\",\"^=\",536870913]],[\"^84\",[5,\"^;\",\"^1O\",536870913]],[\"^84\",[5,\"^>\",true,536870913]],[\"^84\",[5,\"^57\",true,536870913]],[\"^84\",[5,\"^5J\",true,536870913]],[\"^84\",[5,\"^X\",false,536870913]],[\"^84\",[5,\"^1K\",\"~:string\",536870913]],[\"^84\",[6,\"^48\",1775980635493,536870913]],[\"^84\",[6,\"^7M\",\"view columns settings\",536870913]],[\"^84\",[6,\"^7@\",\"d3BGp\",536870913]],[\"^84\",[6,\"^69\",124,536870913]],[\"^84\",[6,\"^6:\",\"View columns settings\",536870913]],[\"^84\",[6,\"^3;\",1775980635493,536870913]],[\"^84\",[6,\"^2T\",\"~u00000002-1675-5105-5500-000000000000\",536870913]],[\"^84\",[6,\"^<\",\"^=\",536870913]],[\"^84\",[6,\"^;\",\"^1Y\",536870913]],[\"^84\",[6,\"^>\",true,536870913]],[\"^84\",[6,\"^57\",true,536870913]],[\"^84\",[6,\"^5J\",true,536870913]],[\"^84\",[6,\"^X\",false,536870913]],[\"^84\",[6,\"^1K\",\"~:map\",536870913]],[\"^84\",[7,\"^48\",1775980635487,536870913]],[\"^84\",[7,\"^7M\",\"alias\",536870913]],[\"^84\",[7,\"^7@\",\"d3BFF\",536870913]],[\"^84\",[7,\"^69\",124,536870913]],[\"^84\",[7,\"^6:\",\"Alias\",536870913]],[\"^84\",[7,\"^3;\",1775980635487,536870913]],[\"^84\",[7,\"^2T\",\"~u00000002-2112-6446-9900-000000000000\",536870913]],[\"^84\",[7,\"^<\",\"^14\",536870913]],[\"^84\",[7,\"^;\",\"^1Z\",536870913]],[\"^84\",[7,\"^>\",true,536870913]],[\"^84\",[7,\"^C\",\"^D\",536870913]],[\"^84\",[7,\"^57\",true,536870913]],[\"^84\",[7,\"^X\",true,536870913]],[\"^84\",[7,\"^1K\",\"~:page\",536870913]],[\"^84\",[7,\"^44\",\"^89\",536870913]],[\"^84\",[8,\"^48\",1775980635492,536870913]],[\"^84\",[8,\"^7M\",\"view sort groups by\",536870913]],[\"^84\",[8,\"^7@\",\"d3BGj\",536870913]],[\"^84\",[8,\"^69\",124,536870913]],[\"^84\",[8,\"^6:\",\"View sort groups by\",536870913]],[\"^84\",[8,\"^3;\",1775980635492,536870913]],[\"^84\",[8,\"^2T\",\"~u00000002-6253-4002-1000-000000000000\",536870913]],[\"^84\",[8,\"^<\",\"^=\",536870913]],[\"^84\",[8,\"^;\",\"^2M\",536870913]],[\"^84\",[8,\"^>\",true,536870913]],[\"^84\",[8,\"^C\",\"^D\",536870913]],[\"^84\",[8,\"^57\",true,536870913]],[\"^84\",[8,\"^5J\",true,536870913]],[\"^84\",[8,\"^X\",false,536870913]],[\"^84\",[8,\"^1K\",\"^86\",536870913]],[\"^84\",[9,\"^L\",26,536870913]],[\"^84\",[9,\"^48\",1775980635491,536870913]],[\"^84\",[9,\"^7@\",\"d3BGA\",536870913]],[\"^84\",[9,\"^4S\",26,536870913]],[\"^84\",[9,\"^3X\",26,536870913]],[\"^84\",[9,\"^6:\",\"Doing\",536870913]],[\"^84\",[9,\"^3;\",1775980635491,536870913]],[\"^84\",[9,\"^2T\",\"~u00000002-1840-1229-0800-000000000000\",536870913]],[\"^84\",[9,\"^;\",\"^32\",536870913]],[\"^84\",[9,\"^57\",true,536870913]],[\"^84\",[9,\"^6?\",26,536870913]],[\"^84\",[9,\"^51\",[\"^ \",\"~:type\",\"~:tabler-icon\",\"~:id\",\"InProgress50\"],536870913]],[\"^84\",[10,\"^48\",1775980635496,536870913]],[\"^84\",[10,\"^7M\",\"math\",536870913]],[\"^84\",[10,\"^69\",14,536870913]],[\"^84\",[10,\"^6:\",\"Math\",536870913]],[\"^84\",[10,\"^3;\",1775980635496,536870913]],[\"^84\",[10,\"^2T\",\"~u00000002-2038-9631-2100-000000000000\",536870913]],[\"^84\",[10,\"^;\",\"^3=\",536870913]],[\"^84\",[10,\"^57\",true,536870913]],[\"^84\",[10,\"^3K\",89,536870913]],[\"^84\",[10,\"^1X\",true,536870913]],[\"^84\",[10,\"^4K\",37,536870913]],[\"^84\",[11,\"^L\",26,536870913]],[\"^84\",[11,\"^48\",1775980635491,536870913]],[\"^84\",[11,\"^7@\",\"d3BGD\",536870913]],[\"^84\",[11,\"^4S\",26,536870913]],[\"^84\",[11,\"^3X\",26,536870913]],[\"^84\",[11,\"^6:\",\"Canceled\",536870913]],[\"^84\",[11,\"^3;\",1775980635491,536870913]],[\"^84\",[11,\"^2T\",\"~u00000002-7526-4326-2000-000000000000\",536870913]],[\"^84\",[11,\"^;\",\"^3F\",536870913]],[\"^84\",[11,\"^57\",true,536870913]],[\"^84\",[11,\"^6?\",26,536870913]],[\"^84\",[11,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Cancelled\"],536870913]],[\"^84\",[12,\"^48\",1775980635488,536870913]],[\"^84\",[12,\"^7M\",\"node parent\",536870913]],[\"^84\",[12,\"^7@\",\"d3BFH\",536870913]],[\"^84\",[12,\"^69\",124,536870913]],[\"^84\",[12,\"^6:\",\"Node parent\",536870913]],[\"^84\",[12,\"^3;\",1775980635488,536870913]],[\"^84\",[12,\"^2T\",\"~u00000002-9183-0906-4000-000000000000\",536870913]],[\"^84\",[12,\"^<\",\"^=\",536870913]],[\"^84\",[12,\"^;\",\"^3X\",536870913]],[\"^84\",[12,\"^>\",true,536870913]],[\"^84\",[12,\"^C\",\"^D\",536870913]],[\"^84\",[12,\"^57\",true,536870913]],[\"^84\",[12,\"^5J\",true,536870913]],[\"^84\",[12,\"^X\",false,536870913]],[\"^84\",[12,\"^1K\",\"~:entity\",536870913]],[\"^84\",[13,\"^48\",1775980635491,536870913]],[\"^84\",[13,\"^7M\",\"repeating recur unit\",536870913]],[\"^84\",[13,\"^7@\",\"d3BGP\",536870913]],[\"^84\",[13,\"^69\",124,536870913]],[\"^84\",[13,\"^6:\",\"Repeating recur unit\",536870913]],[\"^84\",[13,\"^3;\",1775980635491,536870913]],[\"^84\",[13,\"^2T\",\"~u00000002-6903-0624-7000-000000000000\",536870913]],[\"^84\",[13,\"^<\",\"^=\",536870913]],[\"^84\",[13,\"^;\",\"^2G\",536870913]],[\"^84\",[13,\"^>\",true,536870913]],[\"^84\",[13,\"^C\",\"^D\",536870913]],[\"^84\",[13,\"^57\",true,536870913]],[\"^84\",[13,\"^5L\",61,536870913]],[\"^84\",[13,\"^3P\",true,536870913]],[\"^84\",[13,\"^X\",false,536870913]],[\"^84\",[13,\"^1K\",\"~:default\",536870913]],[\"^84\",[14,\"^48\",1775980635496,536870913]],[\"^84\",[14,\"^7M\",\"tag\",536870913]],[\"^84\",[14,\"^69\",14,536870913]],[\"^84\",[14,\"^6:\",\"Tag\",536870913]],[\"^84\",[14,\"^3;\",1775980635496,536870913]],[\"^84\",[14,\"^2T\",\"~u00000002-5389-0208-3000-000000000000\",536870913]],[\"^84\",[14,\"^;\",\"^4C\",536870913]],[\"^84\",[14,\"^57\",true,536870913]],[\"^84\",[14,\"^3K\",89,536870913]],[\"^84\",[15,\"^;\",\"^4Q\",536870913]],[\"^84\",[15,\"^28\",1775980635485,536870913]],[\"^84\",[16,\"^48\",1775980635492,536870913]],[\"^84\",[16,\"^7M\",\"icon\",536870913]],[\"^84\",[16,\"^7@\",\"d3BGZ\",536870913]],[\"^84\",[16,\"^69\",124,536870913]],[\"^84\",[16,\"^6:\",\"Icon\",536870913]],[\"^84\",[16,\"^3;\",1775980635492,536870913]],[\"^84\",[16,\"^2T\",\"~u00000002-5891-2328-5000-000000000000\",536870913]],[\"^84\",[16,\"^<\",\"^=\",536870913]],[\"^84\",[16,\"^;\",\"^51\",536870913]],[\"^84\",[16,\"^>\",true,536870913]],[\"^84\",[16,\"^57\",true,536870913]],[\"^84\",[16,\"^1K\",\"^88\",536870913]],[\"^84\",[17,\"^48\",1775980635493,536870913]],[\"^84\",[17,\"^7M\",\"external file name\",536870913]],[\"^84\",[17,\"^7@\",\"d3BGu\",536870913]],[\"^84\",[17,\"^69\",124,536870913]],[\"^84\",[17,\"^6:\",\"External file name\",536870913]],[\"^84\",[17,\"^3;\",1775980635493,536870913]],[\"^84\",[17,\"^2T\",\"~u00000002-3995-2972-0000-000000000000\",536870913]],[\"^84\",[17,\"^<\",\"^=\",536870913]],[\"^84\",[17,\"^;\",\"^2Z\",536870913]],[\"^84\",[17,\"^>\",true,536870913]],[\"^84\",[17,\"^57\",true,536870913]],[\"^84\",[17,\"^5J\",true,536870913]],[\"^84\",[17,\"^X\",false,536870913]],[\"^84\",[17,\"^1K\",\"^87\",536870913]],[\"^84\",[18,\"^48\",1775980635494,536870913]],[\"^84\",[18,\"^7M\",\"history block\",536870913]],[\"^84\",[18,\"^7@\",\"d3BHB\",536870913]],[\"^84\",[18,\"^69\",124,536870913]],[\"^84\",[18,\"^6:\",\"History block\",536870913]],[\"^84\",[18,\"^3;\",1775980635494,536870913]],[\"^84\",[18,\"^2T\",\"~u00000002-1142-5541-6000-000000000000\",536870913]],[\"^84\",[18,\"^<\",\"^=\",536870913]],[\"^84\",[18,\"^;\",\"^5C\",536870913]],[\"^84\",[18,\"^>\",true,536870913]],[\"^84\",[18,\"^C\",\"^D\",536870913]],[\"^84\",[18,\"^57\",true,536870913]],[\"^84\",[18,\"^5J\",true,536870913]],[\"^84\",[18,\"^1K\",\"^8=\",536870913]],[\"^84\",[19,\"^48\",1775980635490,536870913]],[\"^84\",[19,\"^7M\",\"annotation color\",536870913]],[\"^84\",[19,\"^7@\",\"d3BFo\",536870913]],[\"^84\",[19,\"^69\",124,536870913]],[\"^84\",[19,\"^6:\",\"Annotation color\",536870913]],[\"^84\",[19,\"^3;\",1775980635490,536870913]],[\"^84\",[19,\"^2T\",\"~u00000002-6747-9388-7000-000000000000\",536870913]],[\"^84\",[19,\"^<\",\"^=\",536870913]],[\"^84\",[19,\"^;\",\"^Q\",536870913]],[\"^84\",[19,\"^>\",true,536870913]],[\"^84\",[19,\"^C\",\"^D\",536870913]],[\"^84\",[19,\"^57\",true,536870913]],[\"^84\",[19,\"^5J\",true,536870913]],[\"^84\",[19,\"^1K\",\"^8>\",536870913]],[\"^84\",[20,\"^48\",1775980635489,536870913]],[\"^84\",[20,\"^7M\",\"enable bidirectional properties\",536870913]],[\"^84\",[20,\"^7@\",\"d3BFb\",536870913]],[\"^84\",[20,\"^69\",124,536870913]],[\"^84\",[20,\"^6:\",\"Enable bidirectional properties\",536870913]],[\"^84\",[20,\"^3;\",1775980635489,536870913]],[\"^84\",[20,\"^2T\",\"~u00000002-2917-8613-8000-000000000000\",536870913]],[\"^84\",[20,\"^<\",\"^=\",536870913]],[\"^84\",[20,\"^;\",\"^2<\",536870913]],[\"^84\",[20,\"^>\",true,536870913]],[\"^84\",[20,\"^57\",true,536870913]],[\"^84\",[20,\"^2A\",169,536870913]],[\"^84\",[20,\"^X\",true,536870913]],[\"^84\",[20,\"^1K\",\"~:checkbox\",536870913]],[\"^84\",[20,\"^44\",\"~:class\",536870913]],[\"^84\",[21,\"^48\",1775980635488,536870913]],[\"^84\",[21,\"^7M\",\"node title\",536870913]],[\"^84\",[21,\"^7@\",\"d3BFN\",536870913]],[\"^84\",[21,\"^69\",124,536870913]],[\"^84\",[21,\"^6:\",\"Node title\",536870913]],[\"^84\",[21,\"^3;\",1775980635488,536870913]],[\"^84\",[21,\"^2T\",\"~u00000002-7104-4568-4000-000000000000\",536870913]],[\"^84\",[21,\"^<\",\"^=\",536870913]],[\"^84\",[21,\"^;\",\"^6:\",536870913]],[\"^84\",[21,\"^>\",true,536870913]],[\"^84\",[21,\"^57\",true,536870913]],[\"^84\",[21,\"^5J\",true,536870913]],[\"^84\",[21,\"^X\",false,536870913]],[\"^84\",[21,\"^1K\",\"^87\",536870913]],[\"^84\",[22,\"^48\",1775980635487,536870913]],[\"^84\",[22,\"^7M\",\"property classes\",536870913]],[\"^84\",[22,\"^7@\",\"d3BFD\",536870913]],[\"^84\",[22,\"^69\",124,536870913]],[\"^84\",[22,\"^6:\",\"Property classes\",536870913]],[\"^84\",[22,\"^3;\",1775980635487,536870913]],[\"^84\",[22,\"^2T\",\"~u00000002-9137-5048-6000-000000000000\",536870913]],[\"^84\",[22,\"^<\",\"^14\",536870913]],[\"^84\",[22,\"^;\",\"^6G\",536870913]],[\"^84\",[22,\"^>\",true,536870913]],[\"^84\",[22,\"^C\",\"^D\",536870913]],[\"^84\",[22,\"^57\",true,536870913]],[\"^84\",[22,\"^5J\",true,536870913]],[\"^84\",[22,\"^X\",false,536870913]],[\"^84\",[22,\"^1K\",\"^8=\",536870913]],[\"^84\",[23,\"^;\",\"^39\",536870913]],[\"^84\",[23,\"^28\",true,536870913]],[\"^84\",[24,\"^48\",1775980635494,536870913]],[\"^84\",[24,\"^7M\",\"asset resize metadata\",536870913]],[\"^84\",[24,\"^7@\",\"d3BH2\",536870913]],[\"^84\",[24,\"^69\",124,536870913]],[\"^84\",[24,\"^6:\",\"Asset resize metadata\",536870913]],[\"^84\",[24,\"^3;\",1775980635494,536870913]],[\"^84\",[24,\"^2T\",\"~u00000002-1297-5230-5500-000000000000\",536870913]],[\"^84\",[24,\"^<\",\"^=\",536870913]],[\"^84\",[24,\"^;\",\"^5A\",536870913]],[\"^84\",[24,\"^>\",true,536870913]],[\"^84\",[24,\"^57\",true,536870913]],[\"^84\",[24,\"^5J\",true,536870913]],[\"^84\",[24,\"^X\",false,536870913]],[\"^84\",[24,\"^1K\",\"^88\",536870913]],[\"^84\",[25,\"^48\",1775980635495,536870913]],[\"^84\",[25,\"^7M\",\"deleted at\",536870913]],[\"^84\",[25,\"^7@\",\"d3BHG\",536870913]],[\"^84\",[25,\"^69\",124,536870913]],[\"^84\",[25,\"^6:\",\"Deleted at\",536870913]],[\"^84\",[25,\"^3;\",1775980635495,536870913]],[\"^84\",[25,\"^2T\",\"~u00000002-6852-1375-1000-000000000000\",536870913]],[\"^84\",[25,\"^<\",\"^=\",536870913]],[\"^84\",[25,\"^;\",\"^6R\",536870913]],[\"^84\",[25,\"^>\",true,536870913]],[\"^84\",[25,\"^57\",true,536870913]],[\"^84\",[25,\"^5J\",true,536870913]],[\"^84\",[25,\"^X\",false,536870913]],[\"^84\",[25,\"^1K\",\"~:datetime\",536870913]],[\"^84\",[26,\"^48\",1775980635491,536870913]],[\"^84\",[26,\"^7M\",\"status\",536870913]],[\"^84\",[26,\"^7@\",\"d3BG7\",536870913]],[\"^84\",[26,\"^69\",124,536870913]],[\"^84\",[26,\"^6:\",\"Status\",536870913]],[\"^84\",[26,\"^3;\",1775980635491,536870913]],[\"^84\",[26,\"^2T\",\"~u00000002-9072-1685-3000-000000000000\",536870913]],[\"^84\",[26,\"^<\",\"^=\",536870913]],[\"^84\",[26,\"^;\",\"^3I\",536870913]],[\"^84\",[26,\"^>\",true,536870913]],[\"^84\",[26,\"^C\",\"^D\",536870913]],[\"^84\",[26,\"^57\",true,536870913]],[\"^84\",[26,\"^5L\",114,536870913]],[\"^84\",[26,\"^4N\",true,536870913]],[\"^84\",[26,\"^3P\",true,536870913]],[\"^84\",[26,\"^X\",true,536870913]],[\"^84\",[26,\"^1K\",\"^8>\",536870913]],[\"^84\",[26,\"^@\",\"~:block-left\",536870913]],[\"^84\",[27,\"^48\",1775980635496,536870913]],[\"^84\",[27,\"^7M\",\"card\",536870913]],[\"^84\",[27,\"^69\",14,536870913]],[\"^84\",[27,\"^6:\",\"Card\",536870913]],[\"^84\",[27,\"^3;\",1775980635496,536870913]],[\"^84\",[27,\"^2T\",\"~u00000002-1358-2811-0900-000000000000\",536870913]],[\"^84\",[27,\"^;\",\"^3U\",536870913]],[\"^84\",[27,\"^57\",true,536870913]],[\"^84\",[27,\"^3K\",89,536870913]],[\"^84\",[27,\"^4K\",29,536870913]],[\"^84\",[27,\"^4K\",43,536870913]],[\"^84\",[28,\"^48\",1775980635496,536870913]],[\"^84\",[28,\"^7M\",\"code\",536870913]],[\"^84\",[28,\"^69\",14,536870913]],[\"^84\",[28,\"^6:\",\"Code\",536870913]],[\"^84\",[28,\"^3;\",1775980635496,536870913]],[\"^84\",[28,\"^2T\",\"~u00000002-1454-9866-4100-000000000000\",536870913]],[\"^84\",[28,\"^;\",\"^5?\",536870913]],[\"^84\",[28,\"^57\",true,536870913]],[\"^84\",[28,\"^3K\",89,536870913]],[\"^84\",[28,\"^1X\",true,536870913]],[\"^84\",[28,\"^4K\",37,536870913]],[\"^84\",[28,\"^4K\",108,536870913]],[\"^84\",[29,\"^48\",1775980635494,536870913]],[\"^84\",[29,\"^7M\",\"due\",536870913]],[\"^84\",[29,\"^7@\",\"d3BH4\",536870913]],[\"^84\",[29,\"^69\",124,536870913]],[\"^84\",[29,\"^6:\",\"Due\",536870913]],[\"^84\",[29,\"^3;\",1775980635494,536870913]],[\"^84\",[29,\"^2T\",\"~u00000002-1089-0805-4900-000000000000\",536870913]],[\"^84\",[29,\"^<\",\"^=\",536870913]],[\"^84\",[29,\"^;\",\"^7G\",536870913]],[\"^84\",[29,\"^>\",true,536870913]],[\"^84\",[29,\"^57\",true,536870913]],[\"^84\",[29,\"^5J\",false,536870913]],[\"^84\",[29,\"^X\",false,536870913]],[\"^84\",[29,\"^1K\",\"^8A\",536870913]],[\"^84\",[30,\"^48\",1775980635490,536870913]],[\"^84\",[30,\"^7M\",\"asset\",536870913]],[\"^84\",[30,\"^7@\",\"d3BFl\",536870913]],[\"^84\",[30,\"^69\",124,536870913]],[\"^84\",[30,\"^6:\",\"Asset\",536870913]],[\"^84\",[30,\"^3;\",1775980635490,536870913]],[\"^84\",[30,\"^2T\",\"~u00000002-8768-5679-0000-000000000000\",536870913]],[\"^84\",[30,\"^<\",\"^=\",536870913]],[\"^84\",[30,\"^;\",\"^6N\",536870913]],[\"^84\",[30,\"^>\",true,536870913]],[\"^84\",[30,\"^C\",\"^D\",536870913]],[\"^84\",[30,\"^57\",true,536870913]],[\"^84\",[30,\"^5J\",true,536870913]],[\"^84\",[30,\"^1K\",\"^8=\",536870913]],[\"^84\",[31,\"^L\",19,536870913]],[\"^84\",[31,\"^48\",1775980635490,536870913]],[\"^84\",[31,\"^7@\",\"d3BFq\",536870913]],[\"^84\",[31,\"^4S\",19,536870913]],[\"^84\",[31,\"^3X\",19,536870913]],[\"^84\",[31,\"^6:\",\"red\",536870913]],[\"^84\",[31,\"^3;\",1775980635490,536870913]],[\"^84\",[31,\"^2T\",\"~u00000002-4136-2216-2000-000000000000\",536870913]],[\"^84\",[31,\"^;\",\"^5O\",536870913]],[\"^84\",[31,\"^57\",true,536870913]],[\"^84\",[31,\"^6?\",19,536870913]],[\"^84\",[32,\"^48\",1775980635491,536870913]],[\"^84\",[32,\"^7M\",\"priority\",536870913]],[\"^84\",[32,\"^7@\",\"d3BGE\",536870913]],[\"^84\",[32,\"^69\",124,536870913]],[\"^84\",[32,\"^6:\",\"Priority\",536870913]],[\"^84\",[32,\"^3;\",1775980635491,536870913]],[\"^84\",[32,\"^2T\",\"~u00000002-2392-2841-1000-000000000000\",536870913]],[\"^84\",[32,\"^<\",\"^=\",536870913]],[\"^84\",[32,\"^;\",\"^3\",536870913]],[\"^84\",[32,\"^>\",true,536870913]],[\"^84\",[32,\"^C\",\"^D\",536870913]],[\"^84\",[32,\"^57\",true,536870913]],[\"^84\",[32,\"^4N\",true,536870913]],[\"^84\",[32,\"^3P\",true,536870913]],[\"^84\",[32,\"^X\",true,536870913]],[\"^84\",[32,\"^1K\",\"^8>\",536870913]],[\"^84\",[32,\"^@\",\"^8B\",536870913]],[\"^84\",[33,\"^L\",32,536870913]],[\"^84\",[33,\"^48\",1775980635491,536870913]],[\"^84\",[33,\"^7@\",\"d3BGI\",536870913]],[\"^84\",[33,\"^4S\",32,536870913]],[\"^84\",[33,\"^3X\",32,536870913]],[\"^84\",[33,\"^6:\",\"Urgent\",536870913]],[\"^84\",[33,\"^3;\",1775980635491,536870913]],[\"^84\",[33,\"^2T\",\"~u00000002-1996-4346-6700-000000000000\",536870913]],[\"^84\",[33,\"^;\",\"^H\",536870913]],[\"^84\",[33,\"^57\",true,536870913]],[\"^84\",[33,\"^6?\",32,536870913]],[\"^84\",[33,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlUrgent\"],536870913]],[\"^84\",[34,\"^48\",1775980635487,536870913]],[\"^84\",[34,\"^7M\",\"property public?\",536870913]],[\"^84\",[34,\"^7@\",\"d3BFA\",536870913]],[\"^84\",[34,\"^69\",124,536870913]],[\"^84\",[34,\"^6:\",\"Property public?\",536870913]],[\"^84\",[34,\"^3;\",1775980635487,536870913]],[\"^84\",[34,\"^2T\",\"~u00000002-1843-0851-4900-000000000000\",536870913]],[\"^84\",[34,\"^<\",\"^=\",536870913]],[\"^84\",[34,\"^;\",\"^X\",536870913]],[\"^84\",[34,\"^>\",true,536870913]],[\"^84\",[34,\"^57\",true,536870913]],[\"^84\",[34,\"^5J\",true,536870913]],[\"^84\",[34,\"^1K\",\"^8?\",536870913]],[\"^84\",[35,\"^48\",1775980635495,536870913]],[\"^84\",[35,\"^7M\",\"recycle original order\",536870913]],[\"^84\",[35,\"^7@\",\"d3BHK\",536870913]],[\"^84\",[35,\"^69\",124,536870913]],[\"^84\",[35,\"^6:\",\"Recycle original order\",536870913]],[\"^84\",[35,\"^3;\",1775980635495,536870913]],[\"^84\",[35,\"^2T\",\"~u00000002-8699-2537-0000-000000000000\",536870913]],[\"^84\",[35,\"^<\",\"^=\",536870913]],[\"^84\",[35,\"^;\",\"^19\",536870913]],[\"^84\",[35,\"^>\",true,536870913]],[\"^84\",[35,\"^57\",true,536870913]],[\"^84\",[35,\"^5J\",true,536870913]],[\"^84\",[35,\"^X\",false,536870913]],[\"^84\",[35,\"^1K\",\"^87\",536870913]],[\"^84\",[36,\"^48\",1775980635488,536870913]],[\"^84\",[36,\"^7M\",\"journal date\",536870913]],[\"^84\",[36,\"^7@\",\"d3BFP\",536870913]],[\"^84\",[36,\"^69\",124,536870913]],[\"^84\",[36,\"^6:\",\"Journal date\",536870913]],[\"^84\",[36,\"^3;\",1775980635488,536870913]],[\"^84\",[36,\"^2T\",\"~u00000002-1457-4836-6000-000000000000\",536870913]],[\"^84\",[36,\"^<\",\"^=\",536870913]],[\"^84\",[36,\"^;\",\"^1C\",536870913]],[\"^84\",[36,\"^>\",true,536870913]],[\"^84\",[36,\"^57\",true,536870913]],[\"^84\",[36,\"^5J\",true,536870913]],[\"^84\",[36,\"^X\",false,536870913]],[\"^84\",[36,\"^1K\",\"~:raw-number\",536870913]],[\"^84\",[37,\"^48\",1775980635488,536870913]],[\"^84\",[37,\"^7M\",\"node display type\",536870913]],[\"^84\",[37,\"^7@\",\"d3BFS\",536870913]],[\"^84\",[37,\"^69\",124,536870913]],[\"^84\",[37,\"^6:\",\"Node Display Type\",536870913]],[\"^84\",[37,\"^3;\",1775980635488,536870913]],[\"^84\",[37,\"^2T\",\"~u00000002-4424-4618-9000-000000000000\",536870913]],[\"^84\",[37,\"^<\",\"^=\",536870913]],[\"^84\",[37,\"^;\",\"^1Q\",536870913]],[\"^84\",[37,\"^>\",true,536870913]],[\"^84\",[37,\"^57\",true,536870913]],[\"^84\",[37,\"^5J\",true,536870913]],[\"^84\",[37,\"^X\",false,536870913]],[\"^84\",[37,\"^1K\",\"~:keyword\",536870913]],[\"^84\",[37,\"^44\",\"~:block\",536870913]],[\"^84\",[38,\"^48\",1775980635496,536870913]],[\"^84\",[38,\"^7M\",\"template\",536870913]],[\"^84\",[38,\"^69\",14,536870913]],[\"^84\",[38,\"^6:\",\"Template\",536870913]],[\"^84\",[38,\"^3;\",1775980635496,536870913]],[\"^84\",[38,\"^2T\",\"~u00000002-1720-8548-4600-000000000000\",536870913]],[\"^84\",[38,\"^;\",\"^22\",536870913]],[\"^84\",[38,\"^57\",true,536870913]],[\"^84\",[38,\"^3K\",89,536870913]],[\"^84\",[38,\"^4K\",147,536870913]],[\"^84\",[39,\"^;\",\"^2?\",536870913]],[\"^84\",[39,\"^28\",\"~u00000000-4735-4d82-94bb-c9bfb8039105\",536870913]],[\"^84\",[40,\"^48\",1775980635490,536870913]],[\"^84\",[40,\"^7M\",\"annotation page\",536870913]],[\"^84\",[40,\"^7@\",\"d3BFu\",536870913]],[\"^84\",[40,\"^69\",124,536870913]],[\"^84\",[40,\"^6:\",\"Annotation page\",536870913]],[\"^84\",[40,\"^3;\",1775980635490,536870913]],[\"^84\",[40,\"^2T\",\"~u00000002-7532-8459-6000-000000000000\",536870913]],[\"^84\",[40,\"^<\",\"^=\",536870913]],[\"^84\",[40,\"^;\",\"^2O\",536870913]],[\"^84\",[40,\"^>\",true,536870913]],[\"^84\",[40,\"^57\",true,536870913]],[\"^84\",[40,\"^5J\",true,536870913]],[\"^84\",[40,\"^1K\",\"^8C\",536870913]],[\"^84\",[41,\"^48\",1775980635491,536870913]],[\"^84\",[41,\"^7M\",\"properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^7@\",\"d3BG6\",536870913]],[\"^84\",[41,\"^69\",124,536870913]],[\"^84\",[41,\"^6:\",\"Properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^3;\",1775980635491,536870913]],[\"^84\",[41,\"^2T\",\"~u00000002-3215-3256-9000-000000000000\",536870913]],[\"^84\",[41,\"^<\",\"^14\",536870913]],[\"^84\",[41,\"^;\",\"^2H\",536870913]],[\"^84\",[41,\"^>\",true,536870913]],[\"^84\",[41,\"^C\",\"^D\",536870913]],[\"^84\",[41,\"^57\",true,536870913]],[\"^84\",[41,\"^5J\",true,536870913]],[\"^84\",[41,\"^1K\",\"^86\",536870913]],[\"^84\",[42,\"^L\",19,536870913]],[\"^84\",[42,\"^48\",1775980635490,536870913]],[\"^84\",[42,\"^7@\",\"d3BFt\",536870913]],[\"^84\",[42,\"^4S\",19,536870913]],[\"^84\",[42,\"^3X\",19,536870913]],[\"^84\",[42,\"^6:\",\"purple\",536870913]],[\"^84\",[42,\"^3;\",1775980635490,536870913]],[\"^84\",[42,\"^2T\",\"~u00000002-1104-3848-5600-000000000000\",536870913]],[\"^84\",[42,\"^;\",\"^2Y\",536870913]],[\"^84\",[42,\"^57\",true,536870913]],[\"^84\",[42,\"^6?\",19,536870913]],[\"^84\",[43,\"^48\",1775980635494,536870913]],[\"^84\",[43,\"^7M\",\"state\",536870913]],[\"^84\",[43,\"^7@\",\"d3BH5\",536870913]],[\"^84\",[43,\"^69\",124,536870913]],[\"^84\",[43,\"^6:\",\"State\",536870913]],[\"^84\",[43,\"^3;\",1775980635494,536870913]],[\"^84\",[43,\"^2T\",\"~u00000002-1165-1650-8700-000000000000\",536870913]],[\"^84\",[43,\"^<\",\"^=\",536870913]],[\"^84\",[43,\"^;\",\"^P\",536870913]],[\"^84\",[43,\"^>\",true,536870913]],[\"^84\",[43,\"^57\",true,536870913]],[\"^84\",[43,\"^5J\",false,536870913]],[\"^84\",[43,\"^X\",false,536870913]],[\"^84\",[43,\"^1K\",\"^88\",536870913]],[\"^84\",[44,\"^48\",1775980635492,536870913]],[\"^84\",[44,\"^7M\",\"view sort groups desc\",536870913]],[\"^84\",[44,\"^7@\",\"d3BGk\",536870913]],[\"^84\",[44,\"^69\",124,536870913]],[\"^84\",[44,\"^6:\",\"View sort groups DESC\",536870913]],[\"^84\",[44,\"^3;\",1775980635492,536870913]],[\"^84\",[44,\"^2T\",\"~u00000002-1081-4073-8700-000000000000\",536870913]],[\"^84\",[44,\"^<\",\"^=\",536870913]],[\"^84\",[44,\"^;\",\"^1V\",536870913]],[\"^84\",[44,\"^>\",true,536870913]],[\"^84\",[44,\"^57\",true,536870913]],[\"^84\",[44,\"^5J\",true,536870913]],[\"^84\",[44,\"^X\",false,536870913]],[\"^84\",[44,\"^29\",true,536870913]],[\"^84\",[44,\"^1K\",\"^8?\",536870913]],[\"^84\",[45,\"^48\",1775980635488,536870913]],[\"^84\",[45,\"^7M\",\"node created at\",536870913]],[\"^84\",[45,\"^7@\",\"d3BFQ\",536870913]],[\"^84\",[45,\"^69\",124,536870913]],[\"^84\",[45,\"^6:\",\"Node created at\",536870913]],[\"^84\",[45,\"^3;\",1775980635488,536870913]],[\"^84\",[45,\"^2T\",\"~u00000002-1440-0150-0000-000000000000\",536870913]],[\"^84\",[45,\"^<\",\"^=\",536870913]],[\"^84\",[45,\"^;\",\"^48\",536870913]],[\"^84\",[45,\"^>\",true,536870913]],[\"^84\",[45,\"^57\",true,536870913]],[\"^84\",[45,\"^5J\",true,536870913]],[\"^84\",[45,\"^X\",false,536870913]],[\"^84\",[45,\"^1K\",\"^8A\",536870913]],[\"^84\",[46,\"^48\",1775980635496,536870913]],[\"^84\",[46,\"^7M\",\"asset\",536870913]],[\"^84\",[46,\"^69\",14,536870913]],[\"^84\",[46,\"^6:\",\"Asset\",536870913]],[\"^84\",[46,\"^3;\",1775980635496,536870913]],[\"^84\",[46,\"^2T\",\"~u00000002-7975-0297-0000-000000000000\",536870913]],[\"^84\",[46,\"^;\",\"^2;\",536870913]],[\"^84\",[46,\"^57\",true,536870913]],[\"^84\",[46,\"^3K\",89,536870913]],[\"^84\",[46,\"^1X\",true,536870913]],[\"^84\",[46,\"^4K\",5,536870913]],[\"^84\",[46,\"^4K\",57,536870913]],[\"^84\",[46,\"^4K\",131,536870913]],[\"^84\",[46,\"^2J\",150,536870913]],[\"^84\",[47,\"^48\",1775980635488,536870913]],[\"^84\",[47,\"^7M\",\"node page\",536870913]],[\"^84\",[47,\"^7@\",\"d3BFK\",536870913]],[\"^84\",[47,\"^69\",124,536870913]],[\"^84\",[47,\"^6:\",\"Node page\",536870913]],[\"^84\",[47,\"^3;\",1775980635488,536870913]],[\"^84\",[47,\"^2T\",\"~u00000002-8223-1410-8000-000000000000\",536870913]],[\"^84\",[47,\"^<\",\"^=\",536870913]],[\"^84\",[47,\"^;\",\"^4S\",536870913]],[\"^84\",[47,\"^>\",true,536870913]],[\"^84\",[47,\"^C\",\"^D\",536870913]],[\"^84\",[47,\"^57\",true,536870913]],[\"^84\",[47,\"^5J\",true,536870913]],[\"^84\",[47,\"^X\",false,536870913]],[\"^84\",[47,\"^1K\",\"^8=\",536870913]],[\"^84\",[48,\"^L\",13,536870913]],[\"^84\",[48,\"^48\",1775980635492,536870913]],[\"^84\",[48,\"^7@\",\"d3BGV\",536870913]],[\"^84\",[48,\"^4S\",13,536870913]],[\"^84\",[48,\"^3X\",13,536870913]],[\"^84\",[48,\"^6:\",\"Year\",536870913]],[\"^84\",[48,\"^3;\",1775980635492,536870913]],[\"^84\",[48,\"^2T\",\"~u00000002-1520-4385-2400-000000000000\",536870913]],[\"^84\",[48,\"^;\",\"^1L\",536870913]],[\"^84\",[48,\"^57\",true,536870913]],[\"^84\",[48,\"^6?\",13,536870913]],[\"^84\",[49,\"^48\",1775980635492,536870913]],[\"^84\",[49,\"^7M\",\"excluded from graph view?\",536870913]],[\"^84\",[49,\"^7@\",\"d3BGc\",536870913]],[\"^84\",[49,\"^69\",124,536870913]],[\"^84\",[49,\"^6:\",\"Excluded from Graph view?\",536870913]],[\"^84\",[49,\"^3;\",1775980635492,536870913]],[\"^84\",[49,\"^2T\",\"~u00000002-4524-3306-5000-000000000000\",536870913]],[\"^84\",[49,\"^<\",\"^=\",536870913]],[\"^84\",[49,\"^;\",\"^5:\",536870913]],[\"^84\",[49,\"^>\",true,536870913]],[\"^84\",[49,\"^57\",true,536870913]],[\"^84\",[49,\"^5J\",true,536870913]],[\"^84\",[49,\"^X\",true,536870913]],[\"^84\",[49,\"^1K\",\"^8?\",536870913]],[\"^84\",[49,\"^44\",\"^89\",536870913]],[\"^84\",[50,\"^48\",1775980635493,536870913]],[\"^84\",[50,\"^7M\",\"table view pinned columns\",536870913]],[\"^84\",[50,\"^7@\",\"d3BGq\",536870913]],[\"^84\",[50,\"^69\",124,536870913]],[\"^84\",[50,\"^6:\",\"Table view pinned columns\",536870913]],[\"^84\",[50,\"^3;\",1775980635493,536870913]],[\"^84\",[50,\"^2T\",\"~u00000002-2673-7513-8000-000000000000\",536870913]],[\"^84\",[50,\"^<\",\"^14\",536870913]],[\"^84\",[50,\"^;\",\"^15\",536870913]],[\"^84\",[50,\"^>\",true,536870913]],[\"^84\",[50,\"^C\",\"^D\",536870913]],[\"^84\",[50,\"^57\",true,536870913]],[\"^84\",[50,\"^5J\",true,536870913]],[\"^84\",[50,\"^X\",false,536870913]],[\"^84\",[50,\"^1K\",\"^86\",536870913]],[\"^84\",[51,\"^48\",1775980635495,536870913]],[\"^84\",[51,\"^7M\",\"reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^7@\",\"d3BHP\",536870913]],[\"^84\",[51,\"^69\",124,536870913]],[\"^84\",[51,\"^6:\",\"Reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^3;\",1775980635495,536870913]],[\"^84\",[51,\"^2T\",\"~u00000002-1044-8271-6500-000000000000\",536870913]],[\"^84\",[51,\"^<\",\"^=\",536870913]],[\"^84\",[51,\"^;\",\"^1@\",536870913]],[\"^84\",[51,\"^>\",true,536870913]],[\"^84\",[51,\"^57\",true,536870913]],[\"^84\",[51,\"^5J\",true,536870913]],[\"^84\",[51,\"^X\",false,536870913]],[\"^84\",[51,\"^1K\",\"^88\",536870913]],[\"^84\",[52,\"^;\",\"^45\",536870913]],[\"^84\",[52,\"^28\",[\"^ \",\"~:major\",65,\"~:minor\",24],536870913]],[\"^84\",[53,\"^48\",1775980635487,536870913]],[\"^84\",[53,\"^7M\",\"created from property\",536870913]],[\"^84\",[53,\"^7@\",\"d3BF9\",536870913]],[\"^84\",[53,\"^69\",124,536870913]],[\"^84\",[53,\"^6:\",\"Created from property\",536870913]],[\"^84\",[53,\"^3;\",1775980635487,536870913]],[\"^84\",[53,\"^2T\",\"~u00000002-8618-9226-7000-000000000000\",536870913]],[\"^84\",[53,\"^<\",\"^=\",536870913]],[\"^84\",[53,\"^;\",\"^6?\",536870913]],[\"^84\",[53,\"^>\",true,536870913]],[\"^84\",[53,\"^C\",\"^D\",536870913]],[\"^84\",[53,\"^57\",true,536870913]],[\"^84\",[53,\"^5J\",true,536870913]],[\"^84\",[53,\"^1K\",\"^8=\",536870913]],[\"^84\",[54,\"^48\",1775980635492,536870913]],[\"^84\",[54,\"^7M\",\"publishing public?\",536870913]],[\"^84\",[54,\"^7@\",\"d3BGa\",536870913]],[\"^84\",[54,\"^69\",124,536870913]],[\"^84\",[54,\"^6:\",\"Publishing Public?\",536870913]],[\"^84\",[54,\"^3;\",1775980635492,536870913]],[\"^84\",[54,\"^2T\",\"~u00000002-1094-6579-3900-000000000000\",536870913]],[\"^84\",[54,\"^<\",\"^=\",536870913]],[\"^84\",[54,\"^;\",\"^4=\",536870913]],[\"^84\",[54,\"^>\",true,536870913]],[\"^84\",[54,\"^57\",true,536870913]],[\"^84\",[54,\"^5J\",true,536870913]],[\"^84\",[54,\"^X\",true,536870913]],[\"^84\",[54,\"^1K\",\"^8?\",536870913]],[\"^84\",[54,\"^44\",\"^89\",536870913]],[\"^84\",[55,\"^L\",26,536870913]],[\"^84\",[55,\"^48\",1775980635491,536870913]],[\"^84\",[55,\"^7@\",\"d3BGC\",536870913]],[\"^84\",[55,\"^4S\",26,536870913]],[\"^84\",[55,\"^3X\",26,536870913]],[\"^84\",[55,\"^6:\",\"Done\",536870913]],[\"^84\",[55,\"^3;\",1775980635491,536870913]],[\"^84\",[55,\"^2T\",\"~u00000002-1827-5820-8200-000000000000\",536870913]],[\"^84\",[55,\"^;\",\"^6Q\",536870913]],[\"^84\",[55,\"^57\",true,536870913]],[\"^84\",[55,\"^4W\",true,536870913]],[\"^84\",[55,\"^6?\",26,536870913]],[\"^84\",[55,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Done\"],536870913]],[\"^84\",[56,\"^48\",1775980635487,536870913]],[\"^84\",[56,\"^7M\",\"property value\",536870913]],[\"^84\",[56,\"^7@\",\"d3BFE\",536870913]],[\"^84\",[56,\"^69\",124,536870913]],[\"^84\",[56,\"^6:\",\"Property value\",536870913]],[\"^84\",[56,\"^3;\",1775980635487,536870913]],[\"^84\",[56,\"^2T\",\"~u00000002-1396-5248-6500-000000000000\",536870913]],[\"^84\",[56,\"^<\",\"^=\",536870913]],[\"^84\",[56,\"^;\",\"^T\",536870913]],[\"^84\",[56,\"^>\",true,536870913]],[\"^84\",[56,\"^57\",true,536870913]],[\"^84\",[56,\"^5J\",true,536870913]],[\"^84\",[56,\"^X\",false,536870913]],[\"^84\",[56,\"^1K\",\"~:any\",536870913]],[\"^84\",[57,\"^48\",1775980635493,536870913]],[\"^84\",[57,\"^7M\",\"file type\",536870913]],[\"^84\",[57,\"^7@\",\"d3BGs\",536870913]],[\"^84\",[57,\"^69\",124,536870913]],[\"^84\",[57,\"^6:\",\"File Type\",536870913]],[\"^84\",[57,\"^3;\",1775980635493,536870913]],[\"^84\",[57,\"^2T\",\"~u00000002-1142-0830-9800-000000000000\",536870913]],[\"^84\",[57,\"^<\",\"^=\",536870913]],[\"^84\",[57,\"^;\",\"^2:\",536870913]],[\"^84\",[57,\"^>\",true,536870913]],[\"^84\",[57,\"^57\",true,536870913]],[\"^84\",[57,\"^5J\",true,536870913]],[\"^84\",[57,\"^X\",false,536870913]],[\"^84\",[57,\"^1K\",\"^87\",536870913]],[\"^84\",[58,\"^48\",1775980635487,536870913]],[\"^84\",[58,\"^7M\",\"tags\",536870913]],[\"^84\",[58,\"^7@\",\"d3BFG\",536870913]],[\"^84\",[58,\"^69\",124,536870913]],[\"^84\",[58,\"^6:\",\"Tags\",536870913]],[\"^84\",[58,\"^3;\",1775980635487,536870913]],[\"^84\",[58,\"^2T\",\"~u00000002-1814-9483-4000-000000000000\",536870913]],[\"^84\",[58,\"^<\",\"^14\",536870913]],[\"^84\",[58,\"^;\",\"^69\",536870913]],[\"^84\",[58,\"^>\",true,536870913]],[\"^84\",[58,\"^C\",\"^D\",536870913]],[\"^84\",[58,\"^57\",true,536870913]],[\"^84\",[58,\"^X\",true,536870913]],[\"^84\",[58,\"^1K\",\"^8@\",536870913]],[\"^84\",[59,\"^48\",1775980635490,536870913]],[\"^84\",[59,\"^7M\",\"list type\",536870913]],[\"^84\",[59,\"^7@\",\"d3BFx\",536870913]],[\"^84\",[59,\"^69\",124,536870913]],[\"^84\",[59,\"^6:\",\"List type\",536870913]],[\"^84\",[59,\"^3;\",1775980635490,536870913]],[\"^84\",[59,\"^2T\",\"~u00000002-6078-1711-1000-000000000000\",536870913]],[\"^84\",[59,\"^<\",\"^=\",536870913]],[\"^84\",[59,\"^;\",\"^6W\",536870913]],[\"^84\",[59,\"^>\",true,536870913]],[\"^84\",[59,\"^C\",\"^D\",536870913]],[\"^84\",[59,\"^57\",true,536870913]],[\"^84\",[59,\"^5J\",true,536870913]],[\"^84\",[59,\"^1K\",\"^8>\",536870913]],[\"^84\",[60,\"^48\",1775980635487,536870913]],[\"^84\",[60,\"^7M\",\"property position\",536870913]],[\"^84\",[60,\"^7@\",\"d3BFC\",536870913]],[\"^84\",[60,\"^69\",124,536870913]],[\"^84\",[60,\"^6:\",\"Property position\",536870913]],[\"^84\",[60,\"^3;\",1775980635487,536870913]],[\"^84\",[60,\"^2T\",\"~u00000002-1869-2008-6400-000000000000\",536870913]],[\"^84\",[60,\"^<\",\"^=\",536870913]],[\"^84\",[60,\"^;\",\"^@\",536870913]],[\"^84\",[60,\"^>\",true,536870913]],[\"^84\",[60,\"^57\",true,536870913]],[\"^84\",[60,\"^5J\",true,536870913]],[\"^84\",[60,\"^1K\",\"^8D\",536870913]],[\"^84\",[61,\"^L\",13,536870913]],[\"^84\",[61,\"^48\",1775980635492,536870913]],[\"^84\",[61,\"^7@\",\"d3BGS\",536870913]],[\"^84\",[61,\"^4S\",13,536870913]],[\"^84\",[61,\"^3X\",13,536870913]],[\"^84\",[61,\"^6:\",\"Day\",536870913]],[\"^84\",[61,\"^3;\",1775980635492,536870913]],[\"^84\",[61,\"^2T\",\"~u00000002-3924-1785-8000-000000000000\",536870913]],[\"^84\",[61,\"^;\",\"^16\",536870913]],[\"^84\",[61,\"^57\",true,536870913]],[\"^84\",[61,\"^6?\",13,536870913]],[\"^84\",[62,\"^48\",1775980635490,536870913]],[\"^84\",[62,\"^7M\",\"annotation type\",536870913]],[\"^84\",[62,\"^7@\",\"d3BFn\",536870913]],[\"^84\",[62,\"^69\",124,536870913]],[\"^84\",[62,\"^6:\",\"Annotation type\",536870913]],[\"^84\",[62,\"^3;\",1775980635490,536870913]],[\"^84\",[62,\"^2T\",\"~u00000002-9984-3783-2000-000000000000\",536870913]],[\"^84\",[62,\"^<\",\"^=\",536870913]],[\"^84\",[62,\"^;\",\"^6[\",536870913]],[\"^84\",[62,\"^>\",true,536870913]],[\"^84\",[62,\"^57\",true,536870913]],[\"^84\",[62,\"^5J\",true,536870913]],[\"^84\",[62,\"^1K\",\"^8D\",536870913]],[\"^84\",[63,\"^48\",1775980635493,536870913]],[\"^84\",[63,\"^7M\",\"view hidden columns\",536870913]],[\"^84\",[63,\"^7@\",\"d3BGn\",536870913]],[\"^84\",[63,\"^69\",124,536870913]],[\"^84\",[63,\"^6:\",\"View hidden columns\",536870913]],[\"^84\",[63,\"^3;\",1775980635493,536870913]],[\"^84\",[63,\"^2T\",\"~u00000002-9750-5719-2000-000000000000\",536870913]],[\"^84\",[63,\"^<\",\"^14\",536870913]],[\"^84\",[63,\"^;\",\"^2[\",536870913]],[\"^84\",[63,\"^>\",true,536870913]],[\"^84\",[63,\"^57\",true,536870913]],[\"^84\",[63,\"^5J\",true,536870913]],[\"^84\",[63,\"^X\",false,536870913]],[\"^84\",[63,\"^1K\",\"^8D\",536870913]],[\"^84\",[64,\"^48\",1775980635490,536870913]],[\"^84\",[64,\"^7M\",\"ls-type\",536870913]],[\"^84\",[64,\"^7@\",\"d3BFm\",536870913]],[\"^84\",[64,\"^69\",124,536870913]],[\"^84\",[64,\"^6:\",\"ls-type\",536870913]],[\"^84\",[64,\"^3;\",1775980635490,536870913]],[\"^84\",[64,\"^2T\",\"~u00000002-3269-7934-5000-000000000000\",536870913]],[\"^84\",[64,\"^<\",\"^=\",536870913]],[\"^84\",[64,\"^;\",\"^5\",536870913]],[\"^84\",[64,\"^>\",true,536870913]],[\"^84\",[64,\"^57\",true,536870913]],[\"^84\",[64,\"^5J\",true,536870913]],[\"^84\",[64,\"^1K\",\"^8D\",536870913]],[\"^84\",[65,\"^L\",19,536870913]],[\"^84\",[65,\"^48\",1775980635490,536870913]],[\"^84\",[65,\"^7@\",\"d3BFp\",536870913]],[\"^84\",[65,\"^4S\",19,536870913]],[\"^84\",[65,\"^3X\",19,536870913]],[\"^84\",[65,\"^6:\",\"yellow\",536870913]],[\"^84\",[65,\"^3;\",1775980635490,536870913]],[\"^84\",[65,\"^2T\",\"~u00000002-1752-1030-0200-000000000000\",536870913]],[\"^84\",[65,\"^;\",\"^J\",536870913]],[\"^84\",[65,\"^57\",true,536870913]],[\"^84\",[65,\"^6?\",19,536870913]],[\"^84\",[66,\"^48\",1775980635493,536870913]],[\"^84\",[66,\"^7M\",\"image width\",536870913]],[\"^84\",[66,\"^7@\",\"d3BGw\",536870913]],[\"^84\",[66,\"^69\",124,536870913]],[\"^84\",[66,\"^6:\",\"Image width\",536870913]],[\"^84\",[66,\"^3;\",1775980635493,536870913]],[\"^84\",[66,\"^2T\",\"~u00000002-1857-8658-3900-000000000000\",536870913]],[\"^84\",[66,\"^<\",\"^=\",536870913]],[\"^84\",[66,\"^;\",\"^Z\",536870913]],[\"^84\",[66,\"^>\",true,536870913]],[\"^84\",[66,\"^57\",true,536870913]],[\"^84\",[66,\"^5J\",true,536870913]],[\"^84\",[66,\"^X\",false,536870913]],[\"^84\",[66,\"^1K\",\"^8C\",536870913]],[\"^84\",[67,\"^48\",1775980635494,536870913]],[\"^84\",[67,\"^7M\",\"file remote metadata\",536870913]],[\"^84\",[67,\"^7@\",\"d3BH0\",536870913]],[\"^84\",[67,\"^69\",124,536870913]],[\"^84\",[67,\"^6:\",\"File remote metadata\",536870913]],[\"^84\",[67,\"^3;\",1775980635494,536870913]],[\"^84\",[67,\"^2T\",\"~u00000002-9907-5046-9000-000000000000\",536870913]],[\"^84\",[67,\"^<\",\"^=\",536870913]],[\"^84\",[67,\"^;\",\"^1;\",536870913]],[\"^84\",[67,\"^>\",true,536870913]],[\"^84\",[67,\"^57\",true,536870913]],[\"^84\",[67,\"^2A\",167,536870913]],[\"^84\",[67,\"^5J\",true,536870913]],[\"^84\",[67,\"^X\",false,536870913]],[\"^84\",[67,\"^1K\",\"^88\",536870913]],[\"^84\",[68,\"^L\",127,536870913]],[\"^84\",[68,\"^48\",1775980635492,536870913]],[\"^84\",[68,\"^7@\",\"d3BGf\",536870913]],[\"^84\",[68,\"^4S\",127,536870913]],[\"^84\",[68,\"^3X\",127,536870913]],[\"^84\",[68,\"^6:\",\"List View\",536870913]],[\"^84\",[68,\"^3;\",1775980635492,536870913]],[\"^84\",[68,\"^2T\",\"~u00000002-1164-8285-0200-000000000000\",536870913]],[\"^84\",[68,\"^;\",\"^1E\",536870913]],[\"^84\",[68,\"^57\",true,536870913]],[\"^84\",[68,\"^6?\",127,536870913]],[\"^84\",[68,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"list\"],536870913]],[\"^84\",[69,\"^48\",1775980635488,536870913]],[\"^84\",[69,\"^7M\",\"node references\",536870913]],[\"^84\",[69,\"^7@\",\"d3BFL\",536870913]],[\"^84\",[69,\"^69\",124,536870913]],[\"^84\",[69,\"^6:\",\"Node references\",536870913]],[\"^84\",[69,\"^3;\",1775980635488,536870913]],[\"^84\",[69,\"^2T\",\"~u00000002-1214-4953-4900-000000000000\",536870913]],[\"^84\",[69,\"^<\",\"^14\",536870913]],[\"^84\",[69,\"^;\",\"^1S\",536870913]],[\"^84\",[69,\"^>\",true,536870913]],[\"^84\",[69,\"^C\",\"^D\",536870913]],[\"^84\",[69,\"^57\",true,536870913]],[\"^84\",[69,\"^5J\",true,536870913]],[\"^84\",[69,\"^X\",false,536870913]],[\"^84\",[69,\"^1K\",\"^8=\",536870913]],[\"^84\",[70,\"^L\",127,536870913]],[\"^84\",[70,\"^48\",1775980635492,536870913]],[\"^84\",[70,\"^7@\",\"d3BGe\",536870913]],[\"^84\",[70,\"^4S\",127,536870913]],[\"^84\",[70,\"^3X\",127,536870913]],[\"^84\",[70,\"^6:\",\"Table View\",536870913]],[\"^84\",[70,\"^3;\",1775980635492,536870913]],[\"^84\",[70,\"^2T\",\"~u00000002-1942-5424-0000-000000000000\",536870913]],[\"^84\",[70,\"^;\",\"^?\",536870913]],[\"^84\",[70,\"^57\",true,536870913]],[\"^84\",[70,\"^6?\",127,536870913]],[\"^84\",[70,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"table\"],536870913]],[\"^84\",[71,\"^48\",1775980635488,536870913]],[\"^84\",[71,\"^7M\",\"description\",536870913]],[\"^84\",[71,\"^7@\",\"d3BFT\",536870913]],[\"^84\",[71,\"^69\",124,536870913]],[\"^84\",[71,\"^6:\",\"Description\",536870913]],[\"^84\",[71,\"^3;\",1775980635488,536870913]],[\"^84\",[71,\"^2T\",\"~u00000002-3362-3620-0000-000000000000\",536870913]],[\"^84\",[71,\"^<\",\"^=\",536870913]],[\"^84\",[71,\"^;\",\"^2A\",536870913]],[\"^84\",[71,\"^>\",true,536870913]],[\"^84\",[71,\"^C\",\"^D\",536870913]],[\"^84\",[71,\"^57\",true,536870913]],[\"^84\",[71,\"^X\",true,536870913]],[\"^84\",[71,\"^1K\",\"^8>\",536870913]],[\"^84\",[72,\"^48\",1775980635495,536870913]],[\"^84\",[72,\"^7M\",\"recycle original page\",536870913]],[\"^84\",[72,\"^7@\",\"d3BHJ\",536870913]],[\"^84\",[72,\"^69\",124,536870913]],[\"^84\",[72,\"^6:\",\"Recycle original page\",536870913]],[\"^84\",[72,\"^3;\",1775980635495,536870913]],[\"^84\",[72,\"^2T\",\"~u00000002-1658-7897-9900-000000000000\",536870913]],[\"^84\",[72,\"^<\",\"^=\",536870913]],[\"^84\",[72,\"^;\",\"^2Q\",536870913]],[\"^84\",[72,\"^>\",true,536870913]],[\"^84\",[72,\"^C\",\"^D\",536870913]],[\"^84\",[72,\"^57\",true,536870913]],[\"^84\",[72,\"^5J\",true,536870913]],[\"^84\",[72,\"^X\",false,536870913]],[\"^84\",[72,\"^1K\",\"^85\",536870913]],[\"^84\",[73,\"^48\",1775980635490,536870913]],[\"^84\",[73,\"^7M\",\"excluded references\",536870913]],[\"^84\",[73,\"^7@\",\"d3BFz\",536870913]],[\"^84\",[73,\"^69\",124,536870913]],[\"^84\",[73,\"^6:\",\"Excluded references\",536870913]],[\"^84\",[73,\"^3;\",1775980635490,536870913]],[\"^84\",[73,\"^2T\",\"~u00000002-2426-7588-9000-000000000000\",536870913]],[\"^84\",[73,\"^<\",\"^14\",536870913]],[\"^84\",[73,\"^;\",\"^35\",536870913]],[\"^84\",[73,\"^>\",true,536870913]],[\"^84\",[73,\"^C\",\"^D\",536870913]],[\"^84\",[73,\"^57\",true,536870913]],[\"^84\",[73,\"^5J\",true,536870913]],[\"^84\",[73,\"^1K\",\"^85\",536870913]],[\"^84\",[74,\"^48\",1775980635494,536870913]],[\"^84\",[74,\"^7M\",\"history scalar value\",536870913]],[\"^84\",[74,\"^7@\",\"d3BHE\",536870913]],[\"^84\",[74,\"^69\",124,536870913]],[\"^84\",[74,\"^6:\",\"History scalar value\",536870913]],[\"^84\",[74,\"^3;\",1775980635494,536870913]],[\"^84\",[74,\"^2T\",\"~u00000002-2393-3777-5000-000000000000\",536870913]],[\"^84\",[74,\"^<\",\"^=\",536870913]],[\"^84\",[74,\"^;\",\"^3@\",536870913]],[\"^84\",[74,\"^>\",true,536870913]],[\"^84\",[74,\"^57\",true,536870913]],[\"^84\",[74,\"^5J\",true,536870913]],[\"^84\",[74,\"^1K\",\"^8H\",536870913]],[\"^84\",[75,\"^48\",1775980635491,536870913]],[\"^84\",[75,\"^7M\",\"deadline\",536870913]],[\"^84\",[75,\"^7@\",\"d3BGJ\",536870913]],[\"^84\",[75,\"^69\",124,536870913]],[\"^84\",[75,\"^6:\",\"Deadline\",536870913]],[\"^84\",[75,\"^3;\",1775980635491,536870913]],[\"^84\",[75,\"^2T\",\"~u00000002-1685-9016-0400-000000000000\",536870913]],[\"^84\",[75,\"^<\",\"^=\",536870913]],[\"^84\",[75,\"^;\",\"^1J\",536870913]],[\"^84\",[75,\"^>\",true,536870913]],[\"^84\",[75,\"^57\",true,536870913]],[\"^84\",[75,\"^2A\",166,536870913]],[\"^84\",[75,\"^3P\",true,536870913]],[\"^84\",[75,\"^X\",true,536870913]],[\"^84\",[75,\"^1K\",\"^8A\",536870913]],[\"^84\",[75,\"^@\",\"~:block-below\",536870913]],[\"^84\",[76,\"^L\",19,536870913]],[\"^84\",[76,\"^48\",1775980635490,536870913]],[\"^84\",[76,\"^7@\",\"d3BFr\",536870913]],[\"^84\",[76,\"^4S\",19,536870913]],[\"^84\",[76,\"^3X\",19,536870913]],[\"^84\",[76,\"^6:\",\"green\",536870913]],[\"^84\",[76,\"^3;\",1775980635490,536870913]],[\"^84\",[76,\"^2T\",\"~u00000002-1992-2016-2600-000000000000\",536870913]],[\"^84\",[76,\"^;\",\"^2=\",536870913]],[\"^84\",[76,\"^57\",true,536870913]],[\"^84\",[76,\"^6?\",19,536870913]],[\"^84\",[77,\"^48\",1775980635491,536870913]],[\"^84\",[77,\"^7M\",\"choice classes\",536870913]],[\"^84\",[77,\"^7@\",\"d3BG4\",536870913]],[\"^84\",[77,\"^69\",124,536870913]],[\"^84\",[77,\"^6:\",\"Choice classes\",536870913]],[\"^84\",[77,\"^3;\",1775980635491,536870913]],[\"^84\",[77,\"^2T\",\"~u00000002-7629-6621-9000-000000000000\",536870913]],[\"^84\",[77,\"^<\",\"^14\",536870913]],[\"^84\",[77,\"^;\",\"^4:\",536870913]],[\"^84\",[77,\"^>\",true,536870913]],[\"^84\",[77,\"^C\",\"^D\",536870913]],[\"^84\",[77,\"^57\",true,536870913]],[\"^84\",[77,\"^5J\",true,536870913]],[\"^84\",[77,\"^X\",false,536870913]],[\"^84\",[77,\"^1K\",\"^8@\",536870913]],[\"^84\",[77,\"^44\",\"~:never\",536870913]],[\"^84\",[78,\"^48\",1775980635496,536870913]],[\"^84\",[78,\"^7M\",\"quote\",536870913]],[\"^84\",[78,\"^69\",14,536870913]],[\"^84\",[78,\"^6:\",\"Quote\",536870913]],[\"^84\",[78,\"^3;\",1775980635496,536870913]],[\"^84\",[78,\"^2T\",\"~u00000002-1176-1666-1700-000000000000\",536870913]],[\"^84\",[78,\"^;\",\"^2K\",536870913]],[\"^84\",[78,\"^57\",true,536870913]],[\"^84\",[78,\"^3K\",89,536870913]],[\"^84\",[78,\"^1X\",true,536870913]],[\"^84\",[78,\"^4K\",37,536870913]],[\"^84\",[79,\"^48\",1775980635489,536870913]],[\"^84\",[79,\"^7M\",\"non ref type default value\",536870913]],[\"^84\",[79,\"^7@\",\"d3BFW\",536870913]],[\"^84\",[79,\"^69\",124,536870913]],[\"^84\",[79,\"^6:\",\"Non ref type default value\",536870913]],[\"^84\",[79,\"^3;\",1775980635489,536870913]],[\"^84\",[79,\"^2T\",\"~u00000002-1595-7230-1400-000000000000\",536870913]],[\"^84\",[79,\"^<\",\"^=\",536870913]],[\"^84\",[79,\"^;\",\"^29\",536870913]],[\"^84\",[79,\"^>\",true,536870913]],[\"^84\",[79,\"^57\",true,536870913]],[\"^84\",[79,\"^5J\",true,536870913]],[\"^84\",[79,\"^X\",false,536870913]],[\"^84\",[79,\"^1K\",\"^8H\",536870913]],[\"^84\",[79,\"^44\",\"^86\",536870913]],[\"^84\",[80,\"^48\",1775980635494,536870913]],[\"^84\",[80,\"^7M\",\"last visit page\",536870913]],[\"^84\",[80,\"^7@\",\"d3BGz\",536870913]],[\"^84\",[80,\"^69\",124,536870913]],[\"^84\",[80,\"^6:\",\"Last visit page\",536870913]],[\"^84\",[80,\"^3;\",1775980635494,536870913]],[\"^84\",[80,\"^2T\",\"~u00000002-2107-8035-3500-000000000000\",536870913]],[\"^84\",[80,\"^<\",\"^=\",536870913]],[\"^84\",[80,\"^;\",\"^4[\",536870913]],[\"^84\",[80,\"^>\",true,536870913]],[\"^84\",[80,\"^57\",true,536870913]],[\"^84\",[80,\"^5J\",true,536870913]],[\"^84\",[80,\"^X\",false,536870913]],[\"^84\",[80,\"^1K\",\"^8C\",536870913]],[\"^84\",[81,\"^48\",1775980635495,536870913]],[\"^84\",[81,\"^7M\",\"used template\",536870913]],[\"^84\",[81,\"^7@\",\"d3BHN\",536870913]],[\"^84\",[81,\"^69\",124,536870913]],[\"^84\",[81,\"^6:\",\"Used template\",536870913]],[\"^84\",[81,\"^3;\",1775980635495,536870913]],[\"^84\",[81,\"^2T\",\"~u00000002-9803-6990-6000-000000000000\",536870913]],[\"^84\",[81,\"^<\",\"^=\",536870913]],[\"^84\",[81,\"^;\",\"^4L\",536870913]],[\"^84\",[81,\"^>\",true,536870913]],[\"^84\",[81,\"^C\",\"^D\",536870913]],[\"^84\",[81,\"^57\",true,536870913]],[\"^84\",[81,\"^6G\",38,536870913]],[\"^84\",[81,\"^5J\",true,536870913]],[\"^84\",[81,\"^X\",false,536870913]],[\"^84\",[81,\"^1K\",\"^85\",536870913]],[\"^84\",[82,\"^L\",13,536870913]],[\"^84\",[82,\"^48\",1775980635492,536870913]],[\"^84\",[82,\"^7@\",\"d3BGU\",536870913]],[\"^84\",[82,\"^4S\",13,536870913]],[\"^84\",[82,\"^3X\",13,536870913]],[\"^84\",[82,\"^6:\",\"Month\",536870913]],[\"^84\",[82,\"^3;\",1775980635492,536870913]],[\"^84\",[82,\"^2T\",\"~u00000002-2073-3937-9700-000000000000\",536870913]],[\"^84\",[82,\"^;\",\"^5F\",536870913]],[\"^84\",[82,\"^57\",true,536870913]],[\"^84\",[82,\"^6?\",13,536870913]],[\"^84\",[83,\"^48\",1775980635494,536870913]],[\"^84\",[83,\"^7M\",\"history value\",536870913]],[\"^84\",[83,\"^7@\",\"d3BHD\",536870913]],[\"^84\",[83,\"^69\",124,536870913]],[\"^84\",[83,\"^6:\",\"History value\",536870913]],[\"^84\",[83,\"^3;\",1775980635494,536870913]],[\"^84\",[83,\"^2T\",\"~u00000002-5131-3603-7000-000000000000\",536870913]],[\"^84\",[83,\"^<\",\"^=\",536870913]],[\"^84\",[83,\"^;\",\"^5T\",536870913]],[\"^84\",[83,\"^>\",true,536870913]],[\"^84\",[83,\"^C\",\"^D\",536870913]],[\"^84\",[83,\"^57\",true,536870913]],[\"^84\",[83,\"^5J\",true,536870913]],[\"^84\",[83,\"^1K\",\"^8=\",536870913]],[\"^84\",[84,\"^48\",1775980635493,536870913]],[\"^84\",[84,\"^7M\",\"view filters\",536870913]],[\"^84\",[84,\"^7@\",\"d3BGm\",536870913]],[\"^84\",[84,\"^69\",124,536870913]],[\"^84\",[84,\"^6:\",\"View filters\",536870913]],[\"^84\",[84,\"^3;\",1775980635493,536870913]],[\"^84\",[84,\"^2T\",\"~u00000002-1702-3936-3300-000000000000\",536870913]],[\"^84\",[84,\"^<\",\"^=\",536870913]],[\"^84\",[84,\"^;\",\"^61\",536870913]],[\"^84\",[84,\"^>\",true,536870913]],[\"^84\",[84,\"^57\",true,536870913]],[\"^84\",[84,\"^5J\",true,536870913]],[\"^84\",[84,\"^X\",false,536870913]],[\"^84\",[84,\"^1K\",\"^88\",536870913]],[\"^84\",[85,\"^48\",1775980635496,536870913]],[\"^84\",[85,\"^7M\",\"journal\",536870913]],[\"^84\",[85,\"^69\",14,536870913]],[\"^84\",[85,\"^6:\",\"Journal\",536870913]],[\"^84\",[85,\"^3;\",1775980635496,536870913]],[\"^84\",[85,\"^2T\",\"~u00000002-1979-7410-8100-000000000000\",536870913]],[\"^84\",[85,\"^;\",\"^6A\",536870913]],[\"^84\",[85,\"^57\",true,536870913]],[\"^84\",[85,\"^3K\",104,536870913]],[\"^84\",[85,\"^55\",\"MMM do, yyyy\",536870913]],[\"^84\",[86,\"^48\",1775980635486,536870913]],[\"^84\",[86,\"^7M\",\"built in?\",536870913]],[\"^84\",[86,\"^7@\",\"d3BF7\",536870913]],[\"^84\",[86,\"^69\",124,536870913]],[\"^84\",[86,\"^6:\",\"Built in?\",536870913]],[\"^84\",[86,\"^3;\",1775980635486,536870913]],[\"^84\",[86,\"^2T\",\"~u00000002-1125-9581-6000-000000000000\",536870913]],[\"^84\",[86,\"^<\",\"^=\",536870913]],[\"^84\",[86,\"^;\",\"^57\",536870913]],[\"^84\",[86,\"^>\",true,536870913]],[\"^84\",[86,\"^57\",true,536870913]],[\"^84\",[86,\"^5J\",true,536870913]],[\"^84\",[86,\"^1K\",\"^8?\",536870913]],[\"^84\",[87,\"^48\",1775980635496,536870913]],[\"^84\",[87,\"^7M\",\"whiteboard\",536870913]],[\"^84\",[87,\"^69\",14,536870913]],[\"^84\",[87,\"^6:\",\"Whiteboard\",536870913]],[\"^84\",[87,\"^3;\",1775980635496,536870913]],[\"^84\",[87,\"^2T\",\"~u00000002-1013-6984-5200-000000000000\",536870913]],[\"^84\",[87,\"^;\",\"^6<\",536870913]],[\"^84\",[87,\"^57\",true,536870913]],[\"^84\",[87,\"^3K\",104,536870913]],[\"^84\",[88,\"^48\",1775980635494,536870913]],[\"^84\",[88,\"^7M\",\"asset alignment\",536870913]],[\"^84\",[88,\"^7@\",\"d3BH3\",536870913]],[\"^84\",[88,\"^69\",124,536870913]],[\"^84\",[88,\"^6:\",\"Asset alignment\",536870913]],[\"^84\",[88,\"^3;\",1775980635494,536870913]],[\"^84\",[88,\"^2T\",\"~u00000002-7135-0412-8000-000000000000\",536870913]],[\"^84\",[88,\"^<\",\"^=\",536870913]],[\"^84\",[88,\"^;\",\"^:\",536870913]],[\"^84\",[88,\"^>\",true,536870913]],[\"^84\",[88,\"^57\",true,536870913]],[\"^84\",[88,\"^5J\",true,536870913]],[\"^84\",[88,\"^X\",false,536870913]],[\"^84\",[88,\"^1K\",\"^8D\",536870913]],[\"^84\",[89,\"^48\",1775980635496,536870913]],[\"^84\",[89,\"^7M\",\"root tag\",536870913]],[\"^84\",[89,\"^69\",14,536870913]],[\"^84\",[89,\"^6:\",\"Root Tag\",536870913]],[\"^84\",[89,\"^3;\",1775980635496,536870913]],[\"^84\",[89,\"^2T\",\"~u00000002-2737-8382-7000-000000000000\",536870913]],[\"^84\",[89,\"^;\",\"^5Q\",536870913]],[\"^84\",[89,\"^57\",true,536870913]],[\"^84\",[90,\"^48\",1775980635489,536870913]],[\"^84\",[90,\"^7M\",\"heading\",536870913]],[\"^84\",[90,\"^7@\",\"d3BFk\",536870913]],[\"^84\",[90,\"^69\",124,536870913]],[\"^84\",[90,\"^6:\",\"Heading\",536870913]],[\"^84\",[90,\"^3;\",1775980635489,536870913]],[\"^84\",[90,\"^2T\",\"~u00000002-1858-7494-1500-000000000000\",536870913]],[\"^84\",[90,\"^<\",\"^=\",536870913]],[\"^84\",[90,\"^;\",\"^2L\",536870913]],[\"^84\",[90,\"^>\",true,536870913]],[\"^84\",[90,\"^57\",true,536870913]],[\"^84\",[90,\"^5J\",true,536870913]],[\"^84\",[90,\"^1K\",\"^8H\",536870913]],[\"^84\",[91,\"^48\",1775980635490,536870913]],[\"^84\",[91,\"^7M\",\"annotation image\",536870913]],[\"^84\",[91,\"^7@\",\"d3BFv\",536870913]],[\"^84\",[91,\"^69\",124,536870913]],[\"^84\",[91,\"^6:\",\"Annotation image\",536870913]],[\"^84\",[91,\"^3;\",1775980635490,536870913]],[\"^84\",[91,\"^2T\",\"~u00000002-1377-6700-9000-000000000000\",536870913]],[\"^84\",[91,\"^<\",\"^=\",536870913]],[\"^84\",[91,\"^;\",\"^M\",536870913]],[\"^84\",[91,\"^>\",true,536870913]],[\"^84\",[91,\"^C\",\"^D\",536870913]],[\"^84\",[91,\"^57\",true,536870913]],[\"^84\",[91,\"^5J\",true,536870913]],[\"^84\",[91,\"^1K\",\"^8=\",536870913]],[\"^84\",[92,\"^48\",1775980635487,536870913]],[\"^84\",[92,\"^7M\",\"property view context\",536870913]],[\"^84\",[92,\"^7@\",\"d3BFB\",536870913]],[\"^84\",[92,\"^69\",124,536870913]],[\"^84\",[92,\"^6:\",\"Property view context\",536870913]],[\"^84\",[92,\"^3;\",1775980635487,536870913]],[\"^84\",[92,\"^2T\",\"~u00000002-1547-3958-2800-000000000000\",536870913]],[\"^84\",[92,\"^<\",\"^=\",536870913]],[\"^84\",[92,\"^;\",\"^44\",536870913]],[\"^84\",[92,\"^>\",true,536870913]],[\"^84\",[92,\"^57\",true,536870913]],[\"^84\",[92,\"^5J\",true,536870913]],[\"^84\",[92,\"^1K\",\"^8D\",536870913]],[\"^84\",[93,\"^48\",1775980635492,536870913]],[\"^84\",[93,\"^7M\",\"view feature type\",536870913]],[\"^84\",[93,\"^7@\",\"d3BGh\",536870913]],[\"^84\",[93,\"^69\",124,536870913]],[\"^84\",[93,\"^6:\",\"View Feature Type\",536870913]],[\"^84\",[93,\"^3;\",1775980635492,536870913]],[\"^84\",[93,\"^2T\",\"~u00000002-9391-4187-1000-000000000000\",536870913]],[\"^84\",[93,\"^<\",\"^=\",536870913]],[\"^84\",[93,\"^;\",\"^5>\",536870913]],[\"^84\",[93,\"^>\",true,536870913]],[\"^84\",[93,\"^57\",true,536870913]],[\"^84\",[93,\"^5J\",true,536870913]],[\"^84\",[93,\"^X\",false,536870913]],[\"^84\",[93,\"^1K\",\"^8D\",536870913]],[\"^84\",[94,\"^L\",32,536870913]],[\"^84\",[94,\"^48\",1775980635491,536870913]],[\"^84\",[94,\"^7@\",\"d3BGF\",536870913]],[\"^84\",[94,\"^4S\",32,536870913]],[\"^84\",[94,\"^3X\",32,536870913]],[\"^84\",[94,\"^6:\",\"Low\",536870913]],[\"^84\",[94,\"^3;\",1775980635491,536870913]],[\"^84\",[94,\"^2T\",\"~u00000002-2107-4537-4800-000000000000\",536870913]],[\"^84\",[94,\"^;\",\"^68\",536870913]],[\"^84\",[94,\"^57\",true,536870913]],[\"^84\",[94,\"^6?\",32,536870913]],[\"^84\",[94,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlLow\"],536870913]],[\"^84\",[95,\"^48\",1775980635491,536870913]],[\"^84\",[95,\"^7M\",\"repeating recur frequency\",536870913]],[\"^84\",[95,\"^7@\",\"d3BGN\",536870913]],[\"^84\",[95,\"^69\",124,536870913]],[\"^84\",[95,\"^6:\",\"Repeating recur frequency\",536870913]],[\"^84\",[95,\"^3;\",1775980635491,536870913]],[\"^84\",[95,\"^2T\",\"~u00000002-8716-1592-2000-000000000000\",536870913]],[\"^84\",[95,\"^<\",\"^=\",536870913]],[\"^84\",[95,\"^;\",\"^5K\",536870913]],[\"^84\",[95,\"^>\",true,536870913]],[\"^84\",[95,\"^C\",\"^D\",536870913]],[\"^84\",[95,\"^57\",true,536870913]],[\"^84\",[95,\"^5L\",175,536870913]],[\"^84\",[95,\"^3P\",true,536870913]],[\"^84\",[95,\"^X\",false,536870913]],[\"^84\",[95,\"^1K\",\"~:number\",536870913]],[\"^84\",[96,\"^48\",1775980635492,536870913]],[\"^84\",[96,\"^7M\",\"node repeats?\",536870913]],[\"^84\",[96,\"^7@\",\"d3BGW\",536870913]],[\"^84\",[96,\"^69\",124,536870913]],[\"^84\",[96,\"^6:\",\"Node Repeats?\",536870913]],[\"^84\",[96,\"^3;\",1775980635492,536870913]],[\"^84\",[96,\"^2T\",\"~u00000002-1908-1217-8900-000000000000\",536870913]],[\"^84\",[96,\"^<\",\"^=\",536870913]],[\"^84\",[96,\"^;\",\"^7\",536870913]],[\"^84\",[96,\"^>\",true,536870913]],[\"^84\",[96,\"^57\",true,536870913]],[\"^84\",[96,\"^5J\",true,536870913]],[\"^84\",[96,\"^1K\",\"^8?\",536870913]],[\"^84\",[97,\"^48\",1775980635488,536870913]],[\"^84\",[97,\"^7M\",\"closed value property\",536870913]],[\"^84\",[97,\"^7@\",\"d3BFO\",536870913]],[\"^84\",[97,\"^69\",124,536870913]],[\"^84\",[97,\"^6:\",\"Closed value property\",536870913]],[\"^84\",[97,\"^3;\",1775980635488,536870913]],[\"^84\",[97,\"^2T\",\"~u00000002-1157-7928-1300-000000000000\",536870913]],[\"^84\",[97,\"^<\",\"^14\",536872483]],[\"^84\",[97,\"^;\",\"^L\",536870913]],[\"^84\",[97,\"^>\",true,536870913]],[\"^84\",[97,\"^C\",\"^D\",536870913]],[\"^84\",[97,\"^57\",true,536870913]],[\"^84\",[97,\"^5J\",true,536870913]],[\"^84\",[97,\"^X\",false,536870913]],[\"^84\",[97,\"^1K\",\"^8=\",536870913]],[\"^84\",[98,\"^48\",1775980635495,536870913]],[\"^84\",[98,\"^7M\",\"reaction emoji\",536870913]],[\"^84\",[98,\"^7@\",\"d3BHL\",536870913]],[\"^84\",[98,\"^69\",124,536870913]],[\"^84\",[98,\"^6:\",\"Reaction emoji\",536870913]],[\"^84\",[98,\"^3;\",1775980635495,536870913]],[\"^84\",[98,\"^2T\",\"~u00000002-9877-5864-5000-000000000000\",536870913]],[\"^84\",[98,\"^<\",\"^=\",536870913]],[\"^84\",[98,\"^;\",\"^10\",536870913]],[\"^84\",[98,\"^>\",true,536870913]],[\"^84\",[98,\"^57\",true,536870913]],[\"^84\",[98,\"^5J\",true,536870913]],[\"^84\",[98,\"^X\",false,536870913]],[\"^84\",[98,\"^1K\",\"^87\",536870913]],[\"^84\",[99,\"^L\",32,536870913]],[\"^84\",[99,\"^48\",1775980635491,536870913]],[\"^84\",[99,\"^7@\",\"d3BGH\",536870913]],[\"^84\",[99,\"^4S\",32,536870913]],[\"^84\",[99,\"^3X\",32,536870913]],[\"^84\",[99,\"^6:\",\"High\",536870913]],[\"^84\",[99,\"^3;\",1775980635491,536870913]],[\"^84\",[99,\"^2T\",\"~u00000002-5672-2766-8000-000000000000\",536870913]],[\"^84\",[99,\"^;\",\"^1=\",536870913]],[\"^84\",[99,\"^57\",true,536870913]],[\"^84\",[99,\"^6?\",32,536870913]],[\"^84\",[99,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlHigh\"],536870913]],[\"^84\",[100,\"^L\",19,536870913]],[\"^84\",[100,\"^48\",1775980635490,536870913]],[\"^84\",[100,\"^7@\",\"d3BFs\",536870913]],[\"^84\",[100,\"^4S\",19,536870913]],[\"^84\",[100,\"^3X\",19,536870913]],[\"^84\",[100,\"^6:\",\"blue\",536870913]],[\"^84\",[100,\"^3;\",1775980635490,536870913]],[\"^84\",[100,\"^2T\",\"~u00000002-1836-0512-4100-000000000000\",536870913]],[\"^84\",[100,\"^;\",\"^1G\",536870913]],[\"^84\",[100,\"^57\",true,536870913]],[\"^84\",[100,\"^6?\",19,536870913]],[\"^84\",[101,\"^48\",1775980635493,536870913]],[\"^84\",[101,\"^7M\",\"view ordered columns\",536870913]],[\"^84\",[101,\"^7@\",\"d3BGo\",536870913]],[\"^84\",[101,\"^69\",124,536870913]],[\"^84\",[101,\"^6:\",\"View ordered columns\",536870913]],[\"^84\",[101,\"^3;\",1775980635493,536870913]],[\"^84\",[101,\"^2T\",\"~u00000002-1485-5871-0000-000000000000\",536870913]],[\"^84\",[101,\"^<\",\"^=\",536870913]],[\"^84\",[101,\"^;\",\"^1U\",536870913]],[\"^84\",[101,\"^>\",true,536870913]],[\"^84\",[101,\"^57\",true,536870913]],[\"^84\",[101,\"^5J\",true,536870913]],[\"^84\",[101,\"^X\",false,536870913]],[\"^84\",[101,\"^1K\",\"~:coll\",536870913]],[\"^84\",[102,\"^48\",1775980635489,536870913]],[\"^84\",[102,\"^7M\",\"background color\",536870913]],[\"^84\",[102,\"^7@\",\"d3BFj\",536870913]],[\"^84\",[102,\"^69\",124,536870913]],[\"^84\",[102,\"^6:\",\"Background color\",536870913]],[\"^84\",[102,\"^3;\",1775980635489,536870913]],[\"^84\",[102,\"^2T\",\"~u00000002-5191-2660-6000-000000000000\",536870913]],[\"^84\",[102,\"^<\",\"^=\",536870913]],[\"^84\",[102,\"^;\",\"^26\",536870913]],[\"^84\",[102,\"^>\",true,536870913]],[\"^84\",[102,\"^C\",\"^D\",536870913]],[\"^84\",[102,\"^57\",true,536870913]],[\"^84\",[102,\"^5J\",true,536870913]],[\"^84\",[102,\"^1K\",\"^8>\",536870913]],[\"^84\",[103,\"^48\",1775980635496,536870913]],[\"^84\",[103,\"^7M\",\"cards\",536870913]],[\"^84\",[103,\"^69\",14,536870913]],[\"^84\",[103,\"^6:\",\"Cards\",536870913]],[\"^84\",[103,\"^3;\",1775980635496,536870913]],[\"^84\",[103,\"^2T\",\"~u00000002-1284-2651-6700-000000000000\",536870913]],[\"^84\",[103,\"^;\",\"^2C\",536870913]],[\"^84\",[103,\"^57\",true,536870913]],[\"^84\",[103,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[103,\"^3K\",142,536870913]],[\"^84\",[104,\"^48\",1775980635496,536870913]],[\"^84\",[104,\"^7M\",\"page\",536870913]],[\"^84\",[104,\"^69\",14,536870913]],[\"^84\",[104,\"^6:\",\"Page\",536870913]],[\"^84\",[104,\"^3;\",1775980635496,536870913]],[\"^84\",[104,\"^2T\",\"~u00000002-1484-3403-2900-000000000000\",536870913]],[\"^84\",[104,\"^;\",\"^2S\",536870913]],[\"^84\",[104,\"^57\",true,536870913]],[\"^84\",[104,\"^3K\",89,536870913]],[\"^84\",[105,\"^L\",13,536870913]],[\"^84\",[105,\"^48\",1775980635492,536870913]],[\"^84\",[105,\"^7@\",\"d3BGT\",536870913]],[\"^84\",[105,\"^4S\",13,536870913]],[\"^84\",[105,\"^3X\",13,536870913]],[\"^84\",[105,\"^6:\",\"Week\",536870913]],[\"^84\",[105,\"^3;\",1775980635492,536870913]],[\"^84\",[105,\"^2T\",\"~u00000002-2130-9244-4900-000000000000\",536870913]],[\"^84\",[105,\"^;\",\"^U\",536870913]],[\"^84\",[105,\"^57\",true,536870913]],[\"^84\",[105,\"^6?\",13,536870913]],[\"^84\",[106,\"^48\",1775980635490,536870913]],[\"^84\",[106,\"^7M\",\"tldraw shape\",536870913]],[\"^84\",[106,\"^7@\",\"d3BG1\",536870913]],[\"^84\",[106,\"^69\",124,536870913]],[\"^84\",[106,\"^6:\",\"Tldraw Shape\",536870913]],[\"^84\",[106,\"^3;\",1775980635490,536870913]],[\"^84\",[106,\"^2T\",\"~u00000002-1313-2454-2000-000000000000\",536870913]],[\"^84\",[106,\"^<\",\"^=\",536870913]],[\"^84\",[106,\"^;\",\"^3B\",536870913]],[\"^84\",[106,\"^>\",true,536870913]],[\"^84\",[106,\"^57\",true,536870913]],[\"^84\",[106,\"^5J\",true,536870913]],[\"^84\",[106,\"^1K\",\"^88\",536870913]],[\"^84\",[107,\"^48\",1775980635493,536870913]],[\"^84\",[107,\"^7M\",\"image height\",536870913]],[\"^84\",[107,\"^7@\",\"d3BGx\",536870913]],[\"^84\",[107,\"^69\",124,536870913]],[\"^84\",[107,\"^6:\",\"Image height\",536870913]],[\"^84\",[107,\"^3;\",1775980635493,536870913]],[\"^84\",[107,\"^2T\",\"~u00000002-3306-1650-4000-000000000000\",536870913]],[\"^84\",[107,\"^<\",\"^=\",536870913]],[\"^84\",[107,\"^;\",\"^3M\",536870913]],[\"^84\",[107,\"^>\",true,536870913]],[\"^84\",[107,\"^57\",true,536870913]],[\"^84\",[107,\"^5J\",true,536870913]],[\"^84\",[107,\"^X\",false,536870913]],[\"^84\",[107,\"^1K\",\"^8C\",536870913]],[\"^84\",[108,\"^48\",1775980635488,536870913]],[\"^84\",[108,\"^7M\",\"code mode\",536870913]],[\"^84\",[108,\"^7@\",\"d3BFU\",536870913]],[\"^84\",[108,\"^69\",124,536870913]],[\"^84\",[108,\"^6:\",\"Code Mode\",536870913]],[\"^84\",[108,\"^3;\",1775980635488,536870913]],[\"^84\",[108,\"^2T\",\"~u00000002-8578-9716-5000-000000000000\",536870913]],[\"^84\",[108,\"^<\",\"^=\",536870913]],[\"^84\",[108,\"^;\",\"^40\",536870913]],[\"^84\",[108,\"^>\",true,536870913]],[\"^84\",[108,\"^57\",true,536870913]],[\"^84\",[108,\"^5J\",true,536870913]],[\"^84\",[108,\"^X\",false,536870913]],[\"^84\",[108,\"^1K\",\"^87\",536870913]],[\"^84\",[108,\"^44\",\"^8E\",536870913]],[\"^84\",[109,\"^48\",1775980635495,536870913]],[\"^84\",[109,\"^7M\",\"node created by\",536870913]],[\"^84\",[109,\"^7@\",\"d3BHF\",536870913]],[\"^84\",[109,\"^69\",124,536870913]],[\"^84\",[109,\"^6:\",\"Node created by\",536870913]],[\"^84\",[109,\"^3;\",1775980635495,536870913]],[\"^84\",[109,\"^2T\",\"~u00000002-8544-3390-8000-000000000000\",536870913]],[\"^84\",[109,\"^<\",\"^=\",536870913]],[\"^84\",[109,\"^;\",\"^4<\",536870913]],[\"^84\",[109,\"^>\",true,536870913]],[\"^84\",[109,\"^C\",\"^D\",536870913]],[\"^84\",[109,\"^57\",true,536870913]],[\"^84\",[109,\"^5J\",true,536870913]],[\"^84\",[109,\"^1K\",\"^8=\",536870913]],[\"^84\",[110,\"^48\",1775980635491,536870913]],[\"^84\",[110,\"^7M\",\"choice exclusions\",536870913]],[\"^84\",[110,\"^7@\",\"d3BG5\",536870913]],[\"^84\",[110,\"^69\",124,536870913]],[\"^84\",[110,\"^6:\",\"Choice exclusions\",536870913]],[\"^84\",[110,\"^3;\",1775980635491,536870913]],[\"^84\",[110,\"^2T\",\"~u00000002-1233-5229-4600-000000000000\",536870913]],[\"^84\",[110,\"^<\",\"^14\",536870913]],[\"^84\",[110,\"^;\",\"^13\",536870913]],[\"^84\",[110,\"^>\",true,536870913]],[\"^84\",[110,\"^C\",\"^D\",536870913]],[\"^84\",[110,\"^57\",true,536870913]],[\"^84\",[110,\"^5J\",true,536870913]],[\"^84\",[110,\"^X\",false,536870913]],[\"^84\",[110,\"^1K\",\"^85\",536870913]],[\"^84\",[110,\"^44\",\"^8J\",536870913]],[\"^84\",[111,\"^;\",\"^R\",536870913]],[\"^84\",[111,\"^28\",[\"^ \",\"^8F\",65,\"^8G\",24],536870913]],[\"^84\",[112,\"^48\",1775980635490,536870913]],[\"^84\",[112,\"^7M\",\"title format\",536870913]],[\"^84\",[112,\"^7@\",\"d3BG2\",536870913]],[\"^84\",[112,\"^69\",124,536870913]],[\"^84\",[112,\"^6:\",\"Title Format\",536870913]],[\"^84\",[112,\"^3;\",1775980635490,536870913]],[\"^84\",[112,\"^2T\",\"~u00000002-1536-4979-5400-000000000000\",536870913]],[\"^84\",[112,\"^<\",\"^=\",536870913]],[\"^84\",[112,\"^;\",\"^55\",536870913]],[\"^84\",[112,\"^>\",true,536870913]],[\"^84\",[112,\"^57\",true,536870913]],[\"^84\",[112,\"^X\",false,536870913]],[\"^84\",[112,\"^1K\",\"^87\",536870913]],[\"^84\",[113,\"^48\",1775980635490,536870913]],[\"^84\",[113,\"^7M\",\"included references\",536870913]],[\"^84\",[113,\"^7@\",\"d3BFy\",536870913]],[\"^84\",[113,\"^69\",124,536870913]],[\"^84\",[113,\"^6:\",\"Included references\",536870913]],[\"^84\",[113,\"^3;\",1775980635490,536870913]],[\"^84\",[113,\"^2T\",\"~u00000002-1680-5777-0300-000000000000\",536870913]],[\"^84\",[113,\"^<\",\"^14\",536870913]],[\"^84\",[113,\"^;\",\"^2F\",536870913]],[\"^84\",[113,\"^>\",true,536870913]],[\"^84\",[113,\"^C\",\"^D\",536870913]],[\"^84\",[113,\"^57\",true,536870913]],[\"^84\",[113,\"^5J\",true,536870913]],[\"^84\",[113,\"^1K\",\"^85\",536870913]],[\"^84\",[114,\"^L\",26,536870913]],[\"^84\",[114,\"^48\",1775980635491,536870913]],[\"^84\",[114,\"^7@\",\"d3BG9\",536870913]],[\"^84\",[114,\"^4S\",26,536870913]],[\"^84\",[114,\"^3X\",26,536870913]],[\"^84\",[114,\"^6:\",\"Todo\",536870913]],[\"^84\",[114,\"^3;\",1775980635491,536870913]],[\"^84\",[114,\"^2T\",\"~u00000002-1615-5853-7700-000000000000\",536870913]],[\"^84\",[114,\"^;\",\"^5H\",536870913]],[\"^84\",[114,\"^57\",true,536870913]],[\"^84\",[114,\"^4W\",false,536870913]],[\"^84\",[114,\"^6?\",26,536870913]],[\"^84\",[114,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Todo\"],536870913]],[\"^84\",[115,\"^48\",1775980635494,536870913]],[\"^84\",[115,\"^7M\",\"history property\",536870913]],[\"^84\",[115,\"^7@\",\"d3BHC\",536870913]],[\"^84\",[115,\"^69\",124,536870913]],[\"^84\",[115,\"^6:\",\"History property\",536870913]],[\"^84\",[115,\"^3;\",1775980635494,536870913]],[\"^84\",[115,\"^2T\",\"~u00000002-1600-4090-8200-000000000000\",536870913]],[\"^84\",[115,\"^<\",\"^=\",536870913]],[\"^84\",[115,\"^;\",\"^5V\",536870913]],[\"^84\",[115,\"^>\",true,536870913]],[\"^84\",[115,\"^C\",\"^D\",536870913]],[\"^84\",[115,\"^57\",true,536870913]],[\"^84\",[115,\"^5J\",true,536870913]],[\"^84\",[115,\"^1K\",\"^86\",536870913]],[\"^84\",[116,\"^48\",1775980635495,536870913]],[\"^84\",[116,\"^7M\",\"recycle original parent\",536870913]],[\"^84\",[116,\"^7@\",\"d3BHI\",536870913]],[\"^84\",[116,\"^69\",124,536870913]],[\"^84\",[116,\"^6:\",\"Recycle original parent\",536870913]],[\"^84\",[116,\"^3;\",1775980635495,536870913]],[\"^84\",[116,\"^2T\",\"~u00000002-4970-0399-4000-000000000000\",536870913]],[\"^84\",[116,\"^<\",\"^=\",536870913]],[\"^84\",[116,\"^;\",\"^63\",536870913]],[\"^84\",[116,\"^>\",true,536870913]],[\"^84\",[116,\"^C\",\"^D\",536870913]],[\"^84\",[116,\"^57\",true,536870913]],[\"^84\",[116,\"^5J\",true,536870913]],[\"^84\",[116,\"^X\",false,536870913]],[\"^84\",[116,\"^1K\",\"^85\",536870913]],[\"^84\",[117,\"^48\",1775980635490,536870913]],[\"^84\",[117,\"^7M\",\"annotation data\",536870913]],[\"^84\",[117,\"^7@\",\"d3BFw\",536870913]],[\"^84\",[117,\"^69\",124,536870913]],[\"^84\",[117,\"^6:\",\"Annotation data\",536870913]],[\"^84\",[117,\"^3;\",1775980635490,536870913]],[\"^84\",[117,\"^2T\",\"~u00000002-5458-2940-2000-000000000000\",536870913]],[\"^84\",[117,\"^<\",\"^=\",536870913]],[\"^84\",[117,\"^;\",\"^6C\",536870913]],[\"^84\",[117,\"^>\",true,536870913]],[\"^84\",[117,\"^57\",true,536870913]],[\"^84\",[117,\"^5J\",true,536870913]],[\"^84\",[117,\"^1K\",\"^88\",536870913]],[\"^84\",[118,\"^48\",1775980635492,536870913]],[\"^84\",[118,\"^7M\",\"view group by property\",536870913]],[\"^84\",[118,\"^7@\",\"d3BGi\",536870913]],[\"^84\",[118,\"^69\",124,536870913]],[\"^84\",[118,\"^6:\",\"View group by property\",536870913]],[\"^84\",[118,\"^3;\",1775980635492,536870913]],[\"^84\",[118,\"^2T\",\"~u00000002-8092-1623-6000-000000000000\",536870913]],[\"^84\",[118,\"^<\",\"^=\",536870913]],[\"^84\",[118,\"^;\",\"^43\",536870913]],[\"^84\",[118,\"^>\",true,536870913]],[\"^84\",[118,\"^C\",\"^D\",536870913]],[\"^84\",[118,\"^57\",true,536870913]],[\"^84\",[118,\"^5J\",true,536870913]],[\"^84\",[118,\"^X\",false,536870913]],[\"^84\",[118,\"^1K\",\"^86\",536870913]],[\"^84\",[119,\"^48\",1775980635487,536870913]],[\"^84\",[119,\"^7M\",\"hide this property or page\",536870913]],[\"^84\",[119,\"^7@\",\"d3BF8\",536870913]],[\"^84\",[119,\"^69\",124,536870913]],[\"^84\",[119,\"^6:\",\"Hide this property or page\",536870913]],[\"^84\",[119,\"^3;\",1775980635487,536870913]],[\"^84\",[119,\"^2T\",\"~u00000002-6836-5746-0000-000000000000\",536870913]],[\"^84\",[119,\"^<\",\"^=\",536870913]],[\"^84\",[119,\"^;\",\"^5J\",536870913]],[\"^84\",[119,\"^>\",true,536870913]],[\"^84\",[119,\"^57\",true,536870913]],[\"^84\",[119,\"^5J\",true,536870913]],[\"^84\",[119,\"^1K\",\"^8?\",536870913]],[\"^84\",[120,\"^48\",1775980635488,536870913]],[\"^84\",[120,\"^7M\",\"node links to\",536870913]],[\"^84\",[120,\"^7@\",\"d3BFM\",536870913]],[\"^84\",[120,\"^69\",124,536870913]],[\"^84\",[120,\"^6:\",\"Node links to\",536870913]],[\"^84\",[120,\"^3;\",1775980635488,536870913]],[\"^84\",[120,\"^2T\",\"~u00000002-1872-3999-9300-000000000000\",536870913]],[\"^84\",[120,\"^<\",\"^=\",536870913]],[\"^84\",[120,\"^;\",\"^2I\",536870913]],[\"^84\",[120,\"^>\",true,536870913]],[\"^84\",[120,\"^C\",\"^D\",536870913]],[\"^84\",[120,\"^57\",true,536870913]],[\"^84\",[120,\"^5J\",true,536870913]],[\"^84\",[120,\"^X\",false,536870913]],[\"^84\",[120,\"^1K\",\"^8=\",536870913]],[\"^84\",[121,\"^48\",1775980635489,536870913]],[\"^84\",[121,\"^7M\",\"tag properties\",536870913]],[\"^84\",[121,\"^7@\",\"d3BFZ\",536870913]],[\"^84\",[121,\"^69\",124,536870913]],[\"^84\",[121,\"^6:\",\"Tag Properties\",536870913]],[\"^84\",[121,\"^3;\",1775980635489,536870913]],[\"^84\",[121,\"^2T\",\"~u00000002-2123-7120-5000-000000000000\",536870913]],[\"^84\",[121,\"^<\",\"^14\",536870913]],[\"^84\",[121,\"^;\",\"^4K\",536870913]],[\"^84\",[121,\"^>\",true,536870913]],[\"^84\",[121,\"^C\",\"^D\",536870913]],[\"^84\",[121,\"^57\",true,536870913]],[\"^84\",[121,\"^X\",true,536870913]],[\"^84\",[121,\"^1K\",\"^86\",536870913]],[\"^84\",[121,\"^44\",\"^8J\",536870913]],[\"^84\",[122,\"^48\",1775980635488,536870913]],[\"^84\",[122,\"^7M\",\"node order\",536870913]],[\"^84\",[122,\"^7@\",\"d3BFI\",536870913]],[\"^84\",[122,\"^69\",124,536870913]],[\"^84\",[122,\"^6:\",\"Node order\",536870913]],[\"^84\",[122,\"^3;\",1775980635488,536870913]],[\"^84\",[122,\"^2T\",\"~u00000002-1429-2824-3700-000000000000\",536870913]],[\"^84\",[122,\"^<\",\"^=\",536870913]],[\"^84\",[122,\"^;\",\"^7@\",536870913]],[\"^84\",[122,\"^>\",true,536870913]],[\"^84\",[122,\"^57\",true,536870913]],[\"^84\",[122,\"^5J\",true,536870913]],[\"^84\",[122,\"^X\",false,536870913]],[\"^84\",[122,\"^1K\",\"^87\",536870913]],[\"^84\",[123,\"^48\",1775980635494,536870913]],[\"^84\",[123,\"^7M\",\"enable property history\",536870913]],[\"^84\",[123,\"^7@\",\"d3BH9\",536870913]],[\"^84\",[123,\"^69\",124,536870913]],[\"^84\",[123,\"^6:\",\"Enable property history\",536870913]],[\"^84\",[123,\"^3;\",1775980635494,536870913]],[\"^84\",[123,\"^2T\",\"~u00000002-8058-5960-2000-000000000000\",536870913]],[\"^84\",[123,\"^<\",\"^=\",536870913]],[\"^84\",[123,\"^;\",\"^4N\",536870913]],[\"^84\",[123,\"^>\",true,536870913]],[\"^84\",[123,\"^57\",true,536870913]],[\"^84\",[123,\"^2A\",162,536870913]],[\"^84\",[123,\"^X\",true,536870913]],[\"^84\",[123,\"^1K\",\"^8?\",536870913]],[\"^84\",[123,\"^44\",\"^86\",536870913]],[\"^84\",[124,\"^48\",1775980635496,536870913]],[\"^84\",[124,\"^7M\",\"property\",536870913]],[\"^84\",[124,\"^69\",14,536870913]],[\"^84\",[124,\"^6:\",\"Property\",536870913]],[\"^84\",[124,\"^3;\",1775980635496,536870913]],[\"^84\",[124,\"^2T\",\"~u00000002-1038-7670-4800-000000000000\",536870913]],[\"^84\",[124,\"^;\",\"^30\",536870913]],[\"^84\",[124,\"^57\",true,536870913]],[\"^84\",[124,\"^3K\",89,536870913]],[\"^84\",[125,\"^48\",1775980635489,536870913]],[\"^84\",[125,\"^7M\",\"page tags\",536870913]],[\"^84\",[125,\"^7@\",\"d3BFh\",536870913]],[\"^84\",[125,\"^69\",124,536870913]],[\"^84\",[125,\"^6:\",\"Page Tags\",536870913]],[\"^84\",[125,\"^3;\",1775980635489,536870913]],[\"^84\",[125,\"^2T\",\"~u00000002-2133-5311-8500-000000000000\",536870913]],[\"^84\",[125,\"^<\",\"^14\",536870913]],[\"^84\",[125,\"^;\",\"^4X\",536870913]],[\"^84\",[125,\"^>\",true,536870913]],[\"^84\",[125,\"^C\",\"^D\",536870913]],[\"^84\",[125,\"^57\",true,536870913]],[\"^84\",[125,\"^2A\",164,536870913]],[\"^84\",[125,\"^X\",true,536870913]],[\"^84\",[125,\"^1K\",\"^89\",536870913]],[\"^84\",[125,\"^44\",\"^89\",536870913]],[\"^84\",[126,\"^L\",26,536870913]],[\"^84\",[126,\"^48\",1775980635491,536870913]],[\"^84\",[126,\"^7@\",\"d3BG8\",536870913]],[\"^84\",[126,\"^4S\",26,536870913]],[\"^84\",[126,\"^3X\",26,536870913]],[\"^84\",[126,\"^6:\",\"Backlog\",536870913]],[\"^84\",[126,\"^3;\",1775980635491,536870913]],[\"^84\",[126,\"^2T\",\"~u00000002-7233-3491-0000-000000000000\",536870913]],[\"^84\",[126,\"^;\",\"^4A\",536870913]],[\"^84\",[126,\"^57\",true,536870913]],[\"^84\",[126,\"^6?\",26,536870913]],[\"^84\",[126,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Backlog\"],536870913]],[\"^84\",[127,\"^48\",1775980635492,536870913]],[\"^84\",[127,\"^7M\",\"view type\",536870913]],[\"^84\",[127,\"^7@\",\"d3BGd\",536870913]],[\"^84\",[127,\"^69\",124,536870913]],[\"^84\",[127,\"^6:\",\"View Type\",536870913]],[\"^84\",[127,\"^3;\",1775980635492,536870913]],[\"^84\",[127,\"^2T\",\"~u00000002-2182-3760-7000-000000000000\",536870913]],[\"^84\",[127,\"^<\",\"^=\",536870913]],[\"^84\",[127,\"^;\",\"^2J\",536870913]],[\"^84\",[127,\"^>\",true,536870913]],[\"^84\",[127,\"^C\",\"^D\",536870913]],[\"^84\",[127,\"^57\",true,536870913]],[\"^84\",[127,\"^5L\",70,536870913]],[\"^84\",[127,\"^5J\",true,536870913]],[\"^84\",[127,\"^X\",false,536870913]],[\"^84\",[127,\"^1K\",\"^8>\",536870913]],[\"^84\",[128,\"^48\",1775980635493,536870913]],[\"^84\",[128,\"^7M\",\"this view belongs to\",536870913]],[\"^84\",[128,\"^7@\",\"d3BGr\",536870913]],[\"^84\",[128,\"^69\",124,536870913]],[\"^84\",[128,\"^6:\",\"This view belongs to\",536870913]],[\"^84\",[128,\"^3;\",1775980635493,536870913]],[\"^84\",[128,\"^2T\",\"~u00000002-3627-4319-0000-000000000000\",536870913]],[\"^84\",[128,\"^<\",\"^=\",536870913]],[\"^84\",[128,\"^;\",\"^9\",536870913]],[\"^84\",[128,\"^>\",true,536870913]],[\"^84\",[128,\"^C\",\"^D\",536870913]],[\"^84\",[128,\"^57\",true,536870913]],[\"^84\",[128,\"^5J\",true,536870913]],[\"^84\",[128,\"^X\",false,536870913]],[\"^84\",[128,\"^1K\",\"^85\",536870913]],[\"^84\",[129,\"^48\",1775980635494,536870913]],[\"^84\",[129,\"^7M\",\"user avatar\",536870913]],[\"^84\",[129,\"^7@\",\"d3BH8\",536870913]],[\"^84\",[129,\"^69\",124,536870913]],[\"^84\",[129,\"^6:\",\"User Avatar\",536870913]],[\"^84\",[129,\"^3;\",1775980635494,536870913]],[\"^84\",[129,\"^2T\",\"~u00000002-4165-4885-8000-000000000000\",536870913]],[\"^84\",[129,\"^<\",\"^=\",536870913]],[\"^84\",[129,\"^;\",\"^O\",536870913]],[\"^84\",[129,\"^>\",true,536870913]],[\"^84\",[129,\"^57\",true,536870913]],[\"^84\",[129,\"^5J\",false,536870913]],[\"^84\",[129,\"^X\",true,536870913]],[\"^84\",[129,\"^1K\",\"^87\",536870913]],[\"^84\",[130,\"^48\",1775980635494,536870913]],[\"^84\",[130,\"^7M\",\"user name\",536870913]],[\"^84\",[130,\"^7@\",\"d3BH6\",536870913]],[\"^84\",[130,\"^69\",124,536870913]],[\"^84\",[130,\"^6:\",\"User Name\",536870913]],[\"^84\",[130,\"^3;\",1775980635494,536870913]],[\"^84\",[130,\"^2T\",\"~u00000002-1360-0260-1600-000000000000\",536870913]],[\"^84\",[130,\"^<\",\"^=\",536870913]],[\"^84\",[130,\"^;\",\"^E\",536870913]],[\"^84\",[130,\"^>\",true,536870913]],[\"^84\",[130,\"^57\",true,536870913]],[\"^84\",[130,\"^5J\",false,536870913]],[\"^84\",[130,\"^X\",true,536870913]],[\"^84\",[130,\"^1K\",\"^87\",536870913]],[\"^84\",[131,\"^48\",1775980635493,536870913]],[\"^84\",[131,\"^7M\",\"file size\",536870913]],[\"^84\",[131,\"^7@\",\"d3BGv\",536870913]],[\"^84\",[131,\"^69\",124,536870913]],[\"^84\",[131,\"^6:\",\"File Size\",536870913]],[\"^84\",[131,\"^3;\",1775980635493,536870913]],[\"^84\",[131,\"^2T\",\"~u00000002-1167-8621-9000-000000000000\",536870913]],[\"^84\",[131,\"^<\",\"^=\",536870913]],[\"^84\",[131,\"^;\",\"^1?\",536870913]],[\"^84\",[131,\"^>\",true,536870913]],[\"^84\",[131,\"^57\",true,536870913]],[\"^84\",[131,\"^5J\",true,536870913]],[\"^84\",[131,\"^X\",false,536870913]],[\"^84\",[131,\"^1K\",\"^8C\",536870913]],[\"^84\",[132,\"^48\",1775980635489,536870913]],[\"^84\",[132,\"^7M\",\"bidirectional property title\",536870913]],[\"^84\",[132,\"^7@\",\"d3BFa\",536870913]],[\"^84\",[132,\"^69\",124,536870913]],[\"^84\",[132,\"^6:\",\"Bidirectional property title\",536870913]],[\"^84\",[132,\"^3;\",1775980635489,536870913]],[\"^84\",[132,\"^2T\",\"~u00000002-6050-5418-7000-000000000000\",536870913]],[\"^84\",[132,\"^<\",\"^=\",536870913]],[\"^84\",[132,\"^;\",\"^1I\",536870913]],[\"^84\",[132,\"^>\",true,536870913]],[\"^84\",[132,\"^57\",true,536870913]],[\"^84\",[132,\"^X\",true,536870913]],[\"^84\",[132,\"^1K\",\"^87\",536870913]],[\"^84\",[132,\"^44\",\"^8@\",536870913]],[\"^84\",[133,\"^48\",1775980635489,536870913]],[\"^84\",[133,\"^7M\",\"hide from node\",536870913]],[\"^84\",[133,\"^7@\",\"d3BFf\",536870913]],[\"^84\",[133,\"^69\",124,536870913]],[\"^84\",[133,\"^6:\",\"Hide from Node\",536870913]],[\"^84\",[133,\"^3;\",1775980635489,536870913]],[\"^84\",[133,\"^2T\",\"~u00000002-2610-3727-0000-000000000000\",536870913]],[\"^84\",[133,\"^<\",\"^=\",536870913]],[\"^84\",[133,\"^;\",\"^1X\",536870913]],[\"^84\",[133,\"^>\",true,536870913]],[\"^84\",[133,\"^57\",true,536870913]],[\"^84\",[133,\"^X\",true,536870913]],[\"^84\",[133,\"^1K\",\"^8?\",536870913]],[\"^84\",[133,\"^44\",\"^8@\",536870913]],[\"^84\",[134,\"^48\",1775980635492,536870913]],[\"^84\",[134,\"^7M\",\"repeating temporal property\",536870913]],[\"^84\",[134,\"^7@\",\"d3BGX\",536870913]],[\"^84\",[134,\"^69\",124,536870913]],[\"^84\",[134,\"^6:\",\"Repeating Temporal Property\",536870913]],[\"^84\",[134,\"^3;\",1775980635492,536870913]],[\"^84\",[134,\"^2T\",\"~u00000002-8346-1078-4000-000000000000\",536870913]],[\"^84\",[134,\"^<\",\"^=\",536870913]],[\"^84\",[134,\"^;\",\"^B\",536870913]],[\"^84\",[134,\"^>\",true,536870913]],[\"^84\",[134,\"^C\",\"^D\",536870913]],[\"^84\",[134,\"^57\",true,536870913]],[\"^84\",[134,\"^5J\",true,536870913]],[\"^84\",[134,\"^1K\",\"^86\",536870913]],[\"^84\",[135,\"^L\",13,536870913]],[\"^84\",[135,\"^48\",1775980635492,536870913]],[\"^84\",[135,\"^7@\",\"d3BGQ\",536870913]],[\"^84\",[135,\"^4S\",13,536870913]],[\"^84\",[135,\"^3X\",13,536870913]],[\"^84\",[135,\"^6:\",\"Minute\",536870913]],[\"^84\",[135,\"^3;\",1775980635492,536870913]],[\"^84\",[135,\"^2T\",\"~u00000002-1513-6550-8500-000000000000\",536870913]],[\"^84\",[135,\"^;\",\"^2E\",536870913]],[\"^84\",[135,\"^57\",true,536870913]],[\"^84\",[135,\"^6?\",13,536870913]],[\"^84\",[136,\"^48\",1775980635489,536870913]],[\"^84\",[136,\"^7M\",\"query\",536870913]],[\"^84\",[136,\"^7@\",\"d3BFg\",536870913]],[\"^84\",[136,\"^69\",124,536870913]],[\"^84\",[136,\"^6:\",\"Query\",536870913]],[\"^84\",[136,\"^3;\",1775980635489,536870913]],[\"^84\",[136,\"^2T\",\"~u00000002-9741-4126-0000-000000000000\",536870913]],[\"^84\",[136,\"^<\",\"^=\",536870913]],[\"^84\",[136,\"^;\",\"^2X\",536870913]],[\"^84\",[136,\"^>\",true,536870913]],[\"^84\",[136,\"^C\",\"^D\",536870913]],[\"^84\",[136,\"^57\",true,536870913]],[\"^84\",[136,\"^5J\",true,536870913]],[\"^84\",[136,\"^X\",true,536870913]],[\"^84\",[136,\"^1K\",\"^8>\",536870913]],[\"^84\",[136,\"^44\",\"^8E\",536870913]],[\"^84\",[137,\"^48\",1775980635494,536870913]],[\"^84\",[137,\"^7M\",\"user email\",536870913]],[\"^84\",[137,\"^7@\",\"d3BH7\",536870913]],[\"^84\",[137,\"^69\",124,536870913]],[\"^84\",[137,\"^6:\",\"User Email\",536870913]],[\"^84\",[137,\"^3;\",1775980635494,536870913]],[\"^84\",[137,\"^2T\",\"~u00000002-1655-2060-6300-000000000000\",536870913]],[\"^84\",[137,\"^<\",\"^=\",536870913]],[\"^84\",[137,\"^;\",\"^38\",536870913]],[\"^84\",[137,\"^>\",true,536870913]],[\"^84\",[137,\"^57\",true,536870913]],[\"^84\",[137,\"^5J\",false,536870913]],[\"^84\",[137,\"^X\",true,536870913]],[\"^84\",[137,\"^1K\",\"^87\",536870913]],[\"^84\",[138,\"^48\",1775980635488,536870913]],[\"^84\",[138,\"^7M\",\"node collapsed?\",536870913]],[\"^84\",[138,\"^7@\",\"d3BFJ\",536870913]],[\"^84\",[138,\"^69\",124,536870913]],[\"^84\",[138,\"^6:\",\"Node collapsed?\",536870913]],[\"^84\",[138,\"^3;\",1775980635488,536870913]],[\"^84\",[138,\"^2T\",\"~u00000002-2140-2109-9100-000000000000\",536870913]],[\"^84\",[138,\"^<\",\"^=\",536870913]],[\"^84\",[138,\"^;\",\"^3D\",536870913]],[\"^84\",[138,\"^>\",true,536870913]],[\"^84\",[138,\"^57\",true,536870913]],[\"^84\",[138,\"^5J\",true,536870913]],[\"^84\",[138,\"^X\",false,536870913]],[\"^84\",[138,\"^1K\",\"^8?\",536870913]],[\"^84\",[139,\"^L\",13,536870913]],[\"^84\",[139,\"^48\",1775980635492,536870913]],[\"^84\",[139,\"^7@\",\"d3BGR\",536870913]],[\"^84\",[139,\"^4S\",13,536870913]],[\"^84\",[139,\"^3X\",13,536870913]],[\"^84\",[139,\"^6:\",\"Hour\",536870913]],[\"^84\",[139,\"^3;\",1775980635492,536870913]],[\"^84\",[139,\"^2T\",\"~u00000002-1438-8849-5400-000000000000\",536870913]],[\"^84\",[139,\"^;\",\"^3O\",536870913]],[\"^84\",[139,\"^57\",true,536870913]],[\"^84\",[139,\"^6?\",13,536870913]],[\"^84\",[140,\"^48\",1775980635490,536870913]],[\"^84\",[140,\"^7M\",\"tldraw page\",536870913]],[\"^84\",[140,\"^7@\",\"d3BG0\",536870913]],[\"^84\",[140,\"^69\",124,536870913]],[\"^84\",[140,\"^6:\",\"Tldraw Page\",536870913]],[\"^84\",[140,\"^3;\",1775980635490,536870913]],[\"^84\",[140,\"^2T\",\"~u00000002-3546-2145-7000-000000000000\",536870913]],[\"^84\",[140,\"^<\",\"^=\",536870913]],[\"^84\",[140,\"^;\",\"^42\",536870913]],[\"^84\",[140,\"^>\",true,536870913]],[\"^84\",[140,\"^57\",true,536870913]],[\"^84\",[140,\"^5J\",true,536870913]],[\"^84\",[140,\"^1K\",\"^88\",536870913]],[\"^84\",[141,\"^;\",\"^31\",536870913]],[\"^84\",[141,\"^28\",\"bf04d4cf5-dirty\",536870913]],[\"^84\",[142,\"^48\",1775980635496,536870913]],[\"^84\",[142,\"^7M\",\"query\",536870913]],[\"^84\",[142,\"^69\",14,536870913]],[\"^84\",[142,\"^6:\",\"Query\",536870913]],[\"^84\",[142,\"^3;\",1775980635496,536870913]],[\"^84\",[142,\"^2T\",\"~u00000002-2324-8016-6000-000000000000\",536870913]],[\"^84\",[142,\"^;\",\"^4I\",536870913]],[\"^84\",[142,\"^57\",true,536870913]],[\"^84\",[142,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[142,\"^3K\",89,536870913]],[\"^84\",[142,\"^4K\",136,536870913]],[\"^84\",[143,\"^48\",1775980635491,536870913]],[\"^84\",[143,\"^7M\",\"choice checkbox state\",536870913]],[\"^84\",[143,\"^7@\",\"d3BG3\",536870913]],[\"^84\",[143,\"^69\",124,536870913]],[\"^84\",[143,\"^6:\",\"Choice checkbox state\",536870913]],[\"^84\",[143,\"^3;\",1775980635491,536870913]],[\"^84\",[143,\"^2T\",\"~u00000002-1272-4228-6300-000000000000\",536870913]],[\"^84\",[143,\"^<\",\"^=\",536870913]],[\"^84\",[143,\"^;\",\"^4W\",536870913]],[\"^84\",[143,\"^>\",true,536870913]],[\"^84\",[143,\"^57\",true,536870913]],[\"^84\",[143,\"^5J\",true,536870913]],[\"^84\",[143,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^48\",1775980635489,536870913]],[\"^84\",[144,\"^7M\",\"hide empty value\",536870913]],[\"^84\",[144,\"^7@\",\"d3BFd\",536870913]],[\"^84\",[144,\"^69\",124,536870913]],[\"^84\",[144,\"^6:\",\"Hide empty value\",536870913]],[\"^84\",[144,\"^3;\",1775980635489,536870913]],[\"^84\",[144,\"^2T\",\"~u00000002-2062-3258-9900-000000000000\",536870913]],[\"^84\",[144,\"^<\",\"^=\",536870913]],[\"^84\",[144,\"^;\",\"^3P\",536870913]],[\"^84\",[144,\"^>\",true,536870913]],[\"^84\",[144,\"^57\",true,536870913]],[\"^84\",[144,\"^2A\",160,536870913]],[\"^84\",[144,\"^X\",true,536870913]],[\"^84\",[144,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^44\",\"^86\",536870913]],[\"^84\",[145,\"^48\",1775980635488,536870913]],[\"^84\",[145,\"^7M\",\"node updated at\",536870913]],[\"^84\",[145,\"^7@\",\"d3BFR\",536870913]],[\"^84\",[145,\"^69\",124,536870913]],[\"^84\",[145,\"^6:\",\"Node updated at\",536870913]],[\"^84\",[145,\"^3;\",1775980635488,536870913]],[\"^84\",[145,\"^2T\",\"~u00000002-1516-5505-5100-000000000000\",536870913]],[\"^84\",[145,\"^<\",\"^=\",536870913]],[\"^84\",[145,\"^;\",\"^3;\",536870913]],[\"^84\",[145,\"^>\",true,536870913]],[\"^84\",[145,\"^57\",true,536870913]],[\"^84\",[145,\"^5J\",true,536870913]],[\"^84\",[145,\"^X\",false,536870913]],[\"^84\",[145,\"^1K\",\"^8A\",536870913]],[\"^84\",[146,\"^48\",1775980635493,536870913]],[\"^84\",[146,\"^7M\",\"external url\",536870913]],[\"^84\",[146,\"^7@\",\"d3BGt\",536870913]],[\"^84\",[146,\"^69\",124,536870913]],[\"^84\",[146,\"^6:\",\"External URL\",536870913]],[\"^84\",[146,\"^3;\",1775980635493,536870913]],[\"^84\",[146,\"^2T\",\"~u00000002-1364-7751-6300-000000000000\",536870913]],[\"^84\",[146,\"^<\",\"^=\",536870913]],[\"^84\",[146,\"^;\",\"^20\",536870913]],[\"^84\",[146,\"^>\",true,536870913]],[\"^84\",[146,\"^57\",true,536870913]],[\"^84\",[146,\"^5J\",false,536870913]],[\"^84\",[146,\"^X\",true,536870913]],[\"^84\",[146,\"^1K\",\"^87\",536870913]],[\"^84\",[147,\"^48\",1775980635495,536870913]],[\"^84\",[147,\"^7M\",\"apply template to tags\",536870913]],[\"^84\",[147,\"^7@\",\"d3BHO\",536870913]],[\"^84\",[147,\"^69\",124,536870913]],[\"^84\",[147,\"^6:\",\"Apply template to tags\",536870913]],[\"^84\",[147,\"^3;\",1775980635495,536870913]],[\"^84\",[147,\"^2T\",\"~u00000002-4291-2432-2000-000000000000\",536870913]],[\"^84\",[147,\"^<\",\"^14\",536870913]],[\"^84\",[147,\"^;\",\"^5X\",536870913]],[\"^84\",[147,\"^>\",true,536870913]],[\"^84\",[147,\"^C\",\"^D\",536870913]],[\"^84\",[147,\"^57\",true,536870913]],[\"^84\",[147,\"^X\",true,536870913]],[\"^84\",[147,\"^1K\",\"^8@\",536870913]],[\"^84\",[148,\"^L\",26,536870913]],[\"^84\",[148,\"^48\",1775980635491,536870913]],[\"^84\",[148,\"^7@\",\"d3BGB\",536870913]],[\"^84\",[148,\"^4S\",26,536870913]],[\"^84\",[148,\"^3X\",26,536870913]],[\"^84\",[148,\"^6:\",\"In Review\",536870913]],[\"^84\",[148,\"^3;\",1775980635491,536870913]],[\"^84\",[148,\"^2T\",\"~u00000002-1870-0014-4300-000000000000\",536870913]],[\"^84\",[148,\"^;\",\"^66\",536870913]],[\"^84\",[148,\"^57\",true,536870913]],[\"^84\",[148,\"^6?\",26,536870913]],[\"^84\",[148,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"InReview\"],536870913]],[\"^84\",[149,\"^48\",1775980635492,536870913]],[\"^84\",[149,\"^7M\",\"published url\",536870913]],[\"^84\",[149,\"^7@\",\"d3BGb\",536870913]],[\"^84\",[149,\"^69\",124,536870913]],[\"^84\",[149,\"^6:\",\"Published URL\",536870913]],[\"^84\",[149,\"^3;\",1775980635492,536870913]],[\"^84\",[149,\"^2T\",\"~u00000002-2045-1825-0100-000000000000\",536870913]],[\"^84\",[149,\"^<\",\"^=\",536870913]],[\"^84\",[149,\"^;\",\"^1[\",536870913]],[\"^84\",[149,\"^>\",true,536870913]],[\"^84\",[149,\"^C\",\"^D\",536870913]],[\"^84\",[149,\"^57\",true,536870913]],[\"^84\",[149,\"^X\",true,536870913]],[\"^84\",[149,\"^1K\",\"~:url\",536870913]],[\"^84\",[149,\"^44\",\"^89\",536870913]],[\"^84\",[150,\"^L\",127,536870913]],[\"^84\",[150,\"^48\",1775980635492,536870913]],[\"^84\",[150,\"^7@\",\"d3BGg\",536870913]],[\"^84\",[150,\"^4S\",127,536870913]],[\"^84\",[150,\"^3X\",127,536870913]],[\"^84\",[150,\"^6:\",\"Gallery View\",536870913]],[\"^84\",[150,\"^3;\",1775980635492,536870913]],[\"^84\",[150,\"^2T\",\"~u00000002-1506-0511-2000-000000000000\",536870913]],[\"^84\",[150,\"^;\",\"^6L\",536870913]],[\"^84\",[150,\"^57\",true,536870913]],[\"^84\",[150,\"^6?\",127,536870913]],[\"^84\",[150,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"layout-grid\"],536870913]],[\"^84\",[151,\"^2T\",\"~u00000004-1595-0218-3700-000000000000\",536870913]],[\"^84\",[151,\"^;\",\"^5Y\",536870913]],[\"^84\",[152,\"^48\",1775980635496,536870913]],[\"^84\",[152,\"^7M\",\"task\",536870913]],[\"^84\",[152,\"^69\",14,536870913]],[\"^84\",[152,\"^6:\",\"Task\",536870913]],[\"^84\",[152,\"^3;\",1775980635496,536870913]],[\"^84\",[152,\"^2T\",\"~u00000002-1282-1814-5700-000000000000\",536870913]],[\"^84\",[152,\"^;\",\"^4Z\",536870913]],[\"^84\",[152,\"^57\",true,536870913]],[\"^84\",[152,\"^3K\",89,536870913]],[\"^84\",[152,\"^4K\",26,536870913]],[\"^84\",[152,\"^4K\",32,536870913]],[\"^84\",[152,\"^4K\",75,536870913]],[\"^84\",[152,\"^4K\",155,536870913]],[\"^84\",[153,\"^L\",32,536870913]],[\"^84\",[153,\"^48\",1775980635491,536870913]],[\"^84\",[153,\"^7@\",\"d3BGG\",536870913]],[\"^84\",[153,\"^4S\",32,536870913]],[\"^84\",[153,\"^3X\",32,536870913]],[\"^84\",[153,\"^6:\",\"Medium\",536870913]],[\"^84\",[153,\"^3;\",1775980635491,536870913]],[\"^84\",[153,\"^2T\",\"~u00000002-1829-3222-7800-000000000000\",536870913]],[\"^84\",[153,\"^;\",\"^7:\",536870913]],[\"^84\",[153,\"^57\",true,536870913]],[\"^84\",[153,\"^6?\",32,536870913]],[\"^84\",[153,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlMedium\"],536870913]],[\"^84\",[154,\"^48\",1775980635486,536870913]],[\"^84\",[154,\"^7M\",\"property type\",536870913]],[\"^84\",[154,\"^7@\",\"d3BF6\",536870913]],[\"^84\",[154,\"^69\",124,536870913]],[\"^84\",[154,\"^6:\",\"Property type\",536870913]],[\"^84\",[154,\"^3;\",1775980635486,536870913]],[\"^84\",[154,\"^2T\",\"~u00000002-8384-2404-0000-000000000000\",536870913]],[\"^84\",[154,\"^<\",\"^=\",536870913]],[\"^84\",[154,\"^;\",\"^1K\",536870913]],[\"^84\",[154,\"^>\",true,536870913]],[\"^84\",[154,\"^57\",true,536870913]],[\"^84\",[154,\"^5J\",true,536870913]],[\"^84\",[154,\"^1K\",\"^8D\",536870913]],[\"^84\",[155,\"^48\",1775980635491,536870913]],[\"^84\",[155,\"^7M\",\"scheduled\",536870913]],[\"^84\",[155,\"^7@\",\"d3BGL\",536870913]],[\"^84\",[155,\"^69\",124,536870913]],[\"^84\",[155,\"^6:\",\"Scheduled\",536870913]],[\"^84\",[155,\"^3;\",1775980635491,536870913]],[\"^84\",[155,\"^2T\",\"~u00000002-1644-5209-4300-000000000000\",536870913]],[\"^84\",[155,\"^<\",\"^=\",536870913]],[\"^84\",[155,\"^;\",\"^4Y\",536870913]],[\"^84\",[155,\"^>\",true,536870913]],[\"^84\",[155,\"^57\",true,536870913]],[\"^84\",[155,\"^2A\",177,536870913]],[\"^84\",[155,\"^3P\",true,536870913]],[\"^84\",[155,\"^X\",true,536870913]],[\"^84\",[155,\"^1K\",\"^8A\",536870913]],[\"^84\",[155,\"^@\",\"^8I\",536870913]],[\"^84\",[156,\"^48\",1775980635489,536870913]],[\"^84\",[156,\"^7M\",\"default value\",536870913]],[\"^84\",[156,\"^7@\",\"d3BFV\",536870913]],[\"^84\",[156,\"^69\",124,536870913]],[\"^84\",[156,\"^6:\",\"Default value\",536870913]],[\"^84\",[156,\"^3;\",1775980635489,536870913]],[\"^84\",[156,\"^2T\",\"~u00000002-8920-7966-2000-000000000000\",536870913]],[\"^84\",[156,\"^<\",\"^=\",536870913]],[\"^84\",[156,\"^;\",\"^5L\",536870913]],[\"^84\",[156,\"^>\",true,536870913]],[\"^84\",[156,\"^C\",\"^D\",536870913]],[\"^84\",[156,\"^57\",true,536870913]],[\"^84\",[156,\"^5J\",true,536870913]],[\"^84\",[156,\"^X\",false,536870913]],[\"^84\",[156,\"^1K\",\"^8=\",536870913]],[\"^84\",[156,\"^44\",\"^86\",536870913]],[\"^84\",[157,\"^48\",1775980635495,536870913]],[\"^84\",[157,\"^7M\",\"deleted by\",536870913]],[\"^84\",[157,\"^7@\",\"d3BHH\",536870913]],[\"^84\",[157,\"^69\",124,536870913]],[\"^84\",[157,\"^6:\",\"Deleted by\",536870913]],[\"^84\",[157,\"^3;\",1775980635495,536870913]],[\"^84\",[157,\"^2T\",\"~u00000002-1998-0431-1200-000000000000\",536870913]],[\"^84\",[157,\"^<\",\"^=\",536870913]],[\"^84\",[157,\"^;\",\"^6Y\",536870913]],[\"^84\",[157,\"^>\",true,536870913]],[\"^84\",[157,\"^C\",\"^D\",536870913]],[\"^84\",[157,\"^57\",true,536870913]],[\"^84\",[157,\"^5J\",true,536870913]],[\"^84\",[157,\"^X\",false,536870913]],[\"^84\",[157,\"^1K\",\"^8=\",536870913]],[\"^84\",[158,\"^48\",1775980635489,536870913]],[\"^84\",[158,\"^7M\",\"extends\",536870913]],[\"^84\",[158,\"^7@\",\"d3BFX\",536870913]],[\"^84\",[158,\"^69\",124,536870913]],[\"^84\",[158,\"^6:\",\"Extends\",536870913]],[\"^84\",[158,\"^3;\",1775980635489,536870913]],[\"^84\",[158,\"^2T\",\"~u00000002-7475-9380-3000-000000000000\",536870913]],[\"^84\",[158,\"^<\",\"^14\",536870913]],[\"^84\",[158,\"^;\",\"^3K\",536870913]],[\"^84\",[158,\"^>\",true,536870913]],[\"^84\",[158,\"^C\",\"^D\",536870913]],[\"^84\",[158,\"^57\",true,536870913]],[\"^84\",[158,\"^2A\",163,536870913]],[\"^84\",[158,\"^X\",true,536870913]],[\"^84\",[158,\"^1K\",\"^8@\",536870913]],[\"^84\",[158,\"^44\",\"^8@\",536870913]],[\"^84\",[159,\"^48\",1775980635493,536870913]],[\"^84\",[159,\"^7M\",\"view sorting\",536870913]],[\"^84\",[159,\"^7@\",\"d3BGl\",536870913]],[\"^84\",[159,\"^69\",124,536870913]],[\"^84\",[159,\"^6:\",\"View sorting\",536870913]],[\"^84\",[159,\"^3;\",1775980635493,536870913]],[\"^84\",[159,\"^2T\",\"~u00000002-2081-0259-4000-000000000000\",536870913]],[\"^84\",[159,\"^<\",\"^=\",536870913]],[\"^84\",[159,\"^;\",\"^12\",536870913]],[\"^84\",[159,\"^>\",true,536870913]],[\"^84\",[159,\"^57\",true,536870913]],[\"^84\",[159,\"^5J\",true,536870913]],[\"^84\",[159,\"^X\",false,536870913]],[\"^84\",[159,\"^1K\",\"^8L\",536870913]],[\"^84\",[160,\"^48\",1775980635489,536870913]],[\"^84\",[160,\"^7@\",\"d3BFe\",536870913]],[\"^84\",[160,\"^4S\",144,536870913]],[\"^84\",[160,\"^3X\",144,536870913]],[\"^84\",[160,\"^6:\",\"Hides a property's value on any node when empty e.g. when a property appears on a node through a tag.\",536870913]],[\"^84\",[160,\"^3;\",1775980635489,536870913]],[\"^84\",[160,\"^2T\",\"~u00000004-1118-8585-8000-000000000000\",536870913]],[\"^84\",[160,\"^57\",true,536870913]],[\"^84\",[160,\"^6?\",71,536870913]],[\"^84\",[161,\"^2T\",\"~u00000004-2116-2824-5200-000000000000\",536870913]],[\"^84\",[161,\"^1M\",\"\",536870913]],[\"^84\",[161,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^7N\",\"logseq/publish.js\",536870913]],[\"^84\",[162,\"^48\",1775980635494,536870913]],[\"^84\",[162,\"^7@\",\"d3BHA\",536870913]],[\"^84\",[162,\"^4S\",123,536870913]],[\"^84\",[162,\"^3X\",123,536870913]],[\"^84\",[162,\"^6:\",\"Records history anytime a property's value changes on a node.\",536870913]],[\"^84\",[162,\"^3;\",1775980635494,536870913]],[\"^84\",[162,\"^2T\",\"~u00000004-1438-1481-0000-000000000000\",536870913]],[\"^84\",[162,\"^57\",true,536870913]],[\"^84\",[162,\"^6?\",71,536870913]],[\"^84\",[163,\"^48\",1775980635489,536870913]],[\"^84\",[163,\"^7@\",\"d3BFY\",536870913]],[\"^84\",[163,\"^4S\",158,536870913]],[\"^84\",[163,\"^3X\",158,536870913]],[\"^84\",[163,\"^6:\",\"This enables tags to inherit properties from other tags\",536870913]],[\"^84\",[163,\"^3;\",1775980635489,536870913]],[\"^84\",[163,\"^2T\",\"~u00000004-4485-6574-6000-000000000000\",536870913]],[\"^84\",[163,\"^57\",true,536870913]],[\"^84\",[163,\"^6?\",71,536870913]],[\"^84\",[164,\"^48\",1775980635489,536870913]],[\"^84\",[164,\"^7@\",\"d3BFi\",536870913]],[\"^84\",[164,\"^4S\",125,536870913]],[\"^84\",[164,\"^3X\",125,536870913]],[\"^84\",[164,\"^6:\",\"Provides a way for a page to associate to another page i.e. backward compatible tagging.\",536870913]],[\"^84\",[164,\"^3;\",1775980635489,536870913]],[\"^84\",[164,\"^2T\",\"~u00000004-1223-8671-8000-000000000000\",536870913]],[\"^84\",[164,\"^57\",true,536870913]],[\"^84\",[164,\"^6?\",71,536870913]],[\"^84\",[165,\"^48\",1775980635496,536870913]],[\"^84\",[165,\"^7M\",\"library\",536870913]],[\"^84\",[165,\"^69\",104,536870913]],[\"^84\",[165,\"^6:\",\"Library\",536870913]],[\"^84\",[165,\"^3;\",1775980635496,536870913]],[\"^84\",[165,\"^2T\",\"~u00000004-1294-7765-6000-000000000000\",536870913]],[\"^84\",[165,\"^57\",true,536870913]],[\"^84\",[166,\"^48\",1775980635491,536870913]],[\"^84\",[166,\"^7@\",\"d3BGK\",536870913]],[\"^84\",[166,\"^4S\",75,536870913]],[\"^84\",[166,\"^3X\",75,536870913]],[\"^84\",[166,\"^6:\",\"Use it to finish something at a specific date(time).\",536870913]],[\"^84\",[166,\"^3;\",1775980635491,536870913]],[\"^84\",[166,\"^2T\",\"~u00000004-1356-3664-2200-000000000000\",536870913]],[\"^84\",[166,\"^57\",true,536870913]],[\"^84\",[166,\"^6?\",71,536870913]],[\"^84\",[167,\"^48\",1775980635494,536870913]],[\"^84\",[167,\"^7@\",\"d3BH1\",536870913]],[\"^84\",[167,\"^4S\",67,536870913]],[\"^84\",[167,\"^3X\",67,536870913]],[\"^84\",[167,\"^6:\",\"Metadata of asset in remote storage\",536870913]],[\"^84\",[167,\"^3;\",1775980635494,536870913]],[\"^84\",[167,\"^2T\",\"~u00000004-5450-2102-9000-000000000000\",536870913]],[\"^84\",[167,\"^57\",true,536870913]],[\"^84\",[167,\"^6?\",71,536870913]],[\"^84\",[168,\"^48\",1775980635496,536870913]],[\"^84\",[168,\"^7M\",\"quick add\",536870913]],[\"^84\",[168,\"^69\",104,536870913]],[\"^84\",[168,\"^6:\",\"Quick add\",536870913]],[\"^84\",[168,\"^3;\",1775980635496,536870913]],[\"^84\",[168,\"^2T\",\"~u00000004-7336-8251-3000-000000000000\",536870913]],[\"^84\",[168,\"^57\",true,536870913]],[\"^84\",[168,\"^5J\",true,536870913]],[\"^84\",[169,\"^48\",1775980635489,536870913]],[\"^84\",[169,\"^7@\",\"d3BFc\",536870913]],[\"^84\",[169,\"^4S\",20,536870913]],[\"^84\",[169,\"^3X\",20,536870913]],[\"^84\",[169,\"^6:\",\"When enabled, this tag will show reverse nodes that link to the current node via properties.\",536870913]],[\"^84\",[169,\"^3;\",1775980635489,536870913]],[\"^84\",[169,\"^2T\",\"~u00000004-1830-0481-2500-000000000000\",536870913]],[\"^84\",[169,\"^57\",true,536870913]],[\"^84\",[169,\"^6?\",71,536870913]],[\"^84\",[170,\"^48\",1775980635496,536870913]],[\"^84\",[170,\"^7M\",\"$$$views\",536870913]],[\"^84\",[170,\"^69\",104,536870913]],[\"^84\",[170,\"^6:\",\"$$$views\",536870913]],[\"^84\",[170,\"^3;\",1775980635496,536870913]],[\"^84\",[170,\"^2T\",\"~u00000004-1906-3437-5800-000000000000\",536870913]],[\"^84\",[170,\"^57\",true,536870913]],[\"^84\",[170,\"^5J\",true,536870913]],[\"^84\",[171,\"^48\",1775980635496,536870913]],[\"^84\",[171,\"^7M\",\"contents\",536870913]],[\"^84\",[171,\"^69\",104,536870913]],[\"^84\",[171,\"^6:\",\"Contents\",536870913]],[\"^84\",[171,\"^3;\",1775980635496,536870913]],[\"^84\",[171,\"^2T\",\"~u00000004-1690-2597-3200-000000000000\",536870913]],[\"^84\",[171,\"^57\",true,536870913]],[\"^84\",[172,\"^2T\",\"~u00000004-1713-4660-3800-000000000000\",536870913]],[\"^84\",[172,\"^1M\",\"\",536870913]],[\"^84\",[172,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^7N\",\"logseq/custom.css\",536870913]],[\"^84\",[173,\"^48\",1775980635496,536870913]],[\"^84\",[173,\"^7M\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^69\",104,536870913]],[\"^84\",[173,\"^6:\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^3;\",1775980635496,536870913]],[\"^84\",[173,\"^2T\",\"~u00000004-1018-5888-4100-000000000000\",536870913]],[\"^84\",[173,\"^57\",true,536870913]],[\"^84\",[173,\"^5J\",true,536870913]],[\"^84\",[174,\"^2T\",\"~u00000004-1335-6485-2300-000000000000\",536870913]],[\"^84\",[174,\"^1M\",\"\",536870913]],[\"^84\",[174,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^7N\",\"logseq/custom.js\",536870913]],[\"^84\",[175,\"^48\",1775980635491,536870913]],[\"^84\",[175,\"^7@\",\"d3BGO\",536870913]],[\"^84\",[175,\"^4S\",95,536870913]],[\"^84\",[175,\"^3X\",95,536870913]],[\"^84\",[175,\"^3;\",1775980635491,536870913]],[\"^84\",[175,\"^2T\",\"~u00000004-1654-1034-2600-000000000000\",536870913]],[\"^84\",[175,\"^57\",true,536870913]],[\"^84\",[175,\"^6?\",95,536870913]],[\"^84\",[175,\"^T\",1,536870913]],[\"^84\",[176,\"^2T\",\"~u00000004-3919-3813-3000-000000000000\",536870913]],[\"^84\",[176,\"^1M\",\"{:meta/version 1\\n\\n ;; Hide empty block properties\\n ;; Default value: false\\n ;; :ui/hide-empty-properties? false\\n\\n ;; Enable tooltip preview on hover.\\n ;; Default value: true\\n :ui/enable-tooltip? true\\n\\n ;; Display brackets [[]] around page references.\\n ;; Default value: true\\n ;; :ui/show-brackets? true\\n\\n ;; Display all lines of a block when referencing ((block)).\\n ;; Default value: false\\n :ui/show-full-blocks? false\\n\\n ;; Automatically expand block references when zooming in.\\n ;; Default value: true\\n :ui/auto-expand-block-refs? true\\n\\n ;; Disable accent marks when searching.\\n ;; After changing this setting, rebuild the search index by pressing (^C ^S).\\n ;; Default value: true\\n :feature/enable-search-remove-accents? true\\n\\n ;; Enable journals.\\n ;; Default value: true\\n ;; :feature/enable-journals? true\\n\\n ;; Enable flashcards.\\n ;; Default value: true\\n ;; :feature/enable-flashcards? true\\n\\n ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query.\\n ;; Default value: false\\n ;; :feature/disable-scheduled-and-deadline-query? false\\n\\n ;; Specify the number of days displayed in the future for\\n ;; the 'scheduled tasks and deadlines' query.\\n ;; Example usage:\\n ;; Display all scheduled and deadline blocks for the next 14 days:\\n ;; :scheduled/future-days 14\\n ;; Default value: 7\\n ;; :scheduled/future-days 7\\n\\n ;; Specify the first day of the week.\\n ;; Available options:\\n ;; - integer from 0 to 6 (Monday to Sunday)\\n ;; Default value: 6 (Sunday)\\n :start-of-week 6\\n\\n ;; Specify a custom CSS import.\\n ;; This option takes precedence over the local `logseq/custom.css` file.\\n ;; Example usage:\\n ;; :custom-css-url \\\"@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');\\\"\\n\\n ;; Specify a custom JS import.\\n ;; This option takes precedence over the local `logseq/custom.js` file.\\n ;; Example usage:\\n ;; :custom-js-url \\\"https://cdn.logseq.com/custom.js\\\"\\n\\n ;; Set bullet indentation when exporting\\n ;; Available options:\\n ;; - `:eight-spaces` as eight spaces\\n ;; - `:four-spaces` as four spaces\\n ;; - `:two-spaces` as two spaces\\n ;; - `:tab` as a tab character (default)\\n ;; :export/bullet-indentation :tab\\n\\n ;; Publish all pages within the Graph\\n ;; Regardless of whether individual pages have been marked as public.\\n ;; Default value: false\\n ;; :publishing/all-pages-public? false\\n\\n ;; Define the default home page and sidebar status.\\n ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden.\\n ;; The `:page` value represents the name of the page displayed at startup.\\n ;; Available options for `:sidebar` are:\\n ;; - \\\"Contents\\\" to display the Contents page in the right sidebar.\\n ;; - A specific page name to display in the right sidebar.\\n ;; - An array of multiple pages, e.g., [\\\"Contents\\\" \\\"Page A\\\" \\\"Page B\\\"].\\n ;; If `:sidebar` remains unset, the right sidebar will stay hidden.\\n ;; Examples:\\n ;; 1. Set \\\"Changelog\\\" as the home page and display \\\"Contents\\\" in the right sidebar:\\n ;; :default-home {:page \\\"Changelog\\\", :sidebar \\\"Contents\\\"}\\n ;; 2. Set \\\"Jun 3rd, 2021\\\" as the home page without the right sidebar:\\n ;; :default-home {:page \\\"Jun 3rd, 2021\\\"}\\n ;; 3. Set \\\"home\\\" as the home page and display multiple pages in the right sidebar:\\n ;; :default-home {:page \\\"home\\\", :sidebar [\\\"Page A\\\" \\\"Page B\\\"]}\\n\\n ;; Configure custom shortcuts.\\n ;; Syntax:\\n ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`.\\n ;; 2. A space between keys represents key chords, e.g., `t s` means\\n ;; pressing `t` followed by `s`.\\n ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac.\\n ;; 4. Use false to disable a specific shortcut.\\n ;; 5. You can define multiple bindings for a single action, e.g., [\\\"ctrl+j\\\" \\\"down\\\"].\\n ;; The full list of configurable shortcuts is available at:\\n ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs\\n ;; Example:\\n ;; :shortcuts\\n ;; {:editor/new-block \\\"enter\\\"\\n ;; :editor/new-line \\\"shift+enter\\\"\\n ;; :editor/insert-link \\\"mod+shift+k\\\"\\n ;; :editor/highlight false\\n ;; :ui/toggle-settings \\\"t s\\\"\\n ;; :editor/up [\\\"ctrl+k\\\" \\\"up\\\"]\\n ;; :editor/down [\\\"ctrl+j\\\" \\\"down\\\"]\\n ;; :editor/left [\\\"ctrl+h\\\" \\\"left\\\"]\\n ;; :editor/right [\\\"ctrl+l\\\" \\\"right\\\"]}\\n :shortcuts {}\\n\\n ;; Configure the behavior of pressing Enter in document mode.\\n ;; if set to true, pressing Enter will create a new block.\\n ;; Default value: false\\n :shortcut/doc-mode-enter-for-new-block? false\\n\\n ;; Block content larger than `block/title-max-length` will not be searchable\\n ;; or editable for performance.\\n ;; Default value: 10000\\n :block/title-max-length 10000\\n\\n ;; Display command documentation on hover.\\n ;; Default value: true\\n :ui/show-command-doc? true\\n\\n ;; Display empty bullet points.\\n ;; Default value: false\\n :ui/show-empty-bullets? false\\n\\n ;; Pre-defined :view function to use with advanced queries.\\n :query/views\\n {:pprint\\n (fn [r] [:pre.code (pprint r)])}\\n\\n ;; Advanced queries `:result-transform` function.\\n ;; Transform the query result before displaying it.\\n ;; Example usage for DB graphs:\\n;; :query/result-transforms\\n;; {:sort-by-priority\\n;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority \\\"Z\\\")) result))}\\n\\n;; Queries will be displayed at the bottom of today's journal page.\\n;; Example usage:\\n;; :default-queries\\n;; {:journals []}\\n\\n ;; Add custom commands to the command palette\\n ;; Example usage:\\n ;; :commands\\n ;; [\\n ;; [\\\"js\\\" \\\"Javascript\\\"]\\n ;; [\\\"md\\\" \\\"Markdown\\\"]\\n ;; ]\\n :commands []\\n\\n ;; Enable collapsing blocks with titles but no children.\\n ;; By default, only blocks with children can be collapsed.\\n ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing\\n ;; blocks with titles (multiple lines) and content. For example:\\n ;; - block title\\n ;; block content\\n ;; Default value: false\\n :outliner/block-title-collapse-enabled? false\\n\\n ;; Macros replace texts and will make you more productive.\\n ;; Example usage:\\n ;; Change the :macros value below to:\\n ;; {\\\"poem\\\" \\\"Rose is $1, violet's $2. Life's ordered: Org assists you.\\\"}\\n ;; input \\\"{{poem red,blue}}\\\"\\n ;; becomes\\n ;; Rose is red, violet's blue. Life's ordered: Org assists you.\\n :macros {}\\n\\n ;; Configure the default expansion level for linked references.\\n ;; For example, consider the following block hierarchy:\\n ;; - a [[page]] (level 1)\\n ;; - b (level 2)\\n ;; - c (level 3)\\n ;; - d (level 4)\\n ;;\\n ;; With the default value of level 2, block b will be collapsed.\\n ;; If the level's value is set to 3, block c will be collapsed.\\n ;; Default value: 2\\n :ref/default-open-blocks-level 2\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/settings\\n ;; {:orphan-pages? true ; Default value: true\\n ;; :builtin-pages? false ; Default value: false\\n ;; :excluded-pages? false ; Default value: false\\n ;; :journal? false} ; Default value: false\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/forcesettings\\n ;; {:link-dist 180 ; Default value: 180\\n ;; :charge-strength -600 ; Default value: -600\\n ;; :charge-range 600} ; Default value: 600\\n\\n ;; Mobile photo upload configuration.\\n ;; :mobile/photo\\n ;; {:allow-editing? true\\n ;; :quality 80}\\n\\n ;; Mobile features options\\n ;; Gestures\\n ;; Example usage:\\n ;; :mobile\\n ;; {:gestures/disabled-in-block-with-tags [\\\"kanban\\\"]}\\n\\n ;; Extra CodeMirror options\\n ;; See https://codemirror.net/5/doc/manual.html#config for possible options\\n ;; Example usage:\\n ;; :editor/extra-codemirror-options\\n ;; {:lineWrapping false ; Default value: false\\n ;; :lineNumbers true ; Default value: true\\n ;; :readOnly false} ; Default value: false\\n\\n ;; Enable logical outdenting\\n ;; Default value: false\\n ;; :editor/logical-outdenting? false\\n\\n ;; Prefer pasting the file when text and a file are in the clipboard.\\n ;; Default value: false\\n ;; :editor/preferred-pasting-file? false\\n\\n ;; Quick capture templates for receiving content from other apps.\\n ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded\\n ;; by receiving content from other apps. Note: the {} cannot be omitted.\\n ;; - {time}: capture time\\n ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference\\n ;; - {text}: text that users selected before sharing.\\n ;; - {url}: URL or assets path for media files stored in Logseq.\\n ;; You can also reorder them or use only one or two of them in the template.\\n ;; You can also insert or format any text in the template, as shown in the following examples.\\n ;; :quick-capture-templates\\n ;; {:text \\\"[[quick capture]] **{time}**: {text} from {url}\\\"\\n ;; :media \\\"[[quick capture]] **{time}**: {url}\\\"}\\n\\n ;; Quick capture options.\\n ;; - insert-today? Insert the capture at the end of today's journal page (boolean).\\n ;; - redirect-page? Redirect to the quick capture page after capturing (boolean).\\n ;; - default-page The default page to capture to if insert-today? is false (string).\\n ;; :quick-capture-options\\n ;; {:insert-today? false ;; Default value: true\\n ;; :redirect-page? false ;; Default value: false\\n ;; :default-page \\\"quick capture\\\"} ;; Default page: \\\"quick capture\\\"\\n\\n }\\n\",536870913]],[\"^84\",[176,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^7N\",\"logseq/config.edn\",536870913]],[\"^84\",[177,\"^48\",1775980635491,536870913]],[\"^84\",[177,\"^7@\",\"d3BGM\",536870913]],[\"^84\",[177,\"^4S\",155,536870913]],[\"^84\",[177,\"^3X\",155,536870913]],[\"^84\",[177,\"^6:\",\"Use it to plan something to start at a specific date(time).\",536870913]],[\"^84\",[177,\"^3;\",1775980635491,536870913]],[\"^84\",[177,\"^2T\",\"~u00000004-9817-6380-0000-000000000000\",536870913]],[\"^84\",[177,\"^57\",true,536870913]],[\"^84\",[177,\"^6?\",71,536870913]],[\"^84\",[178,\"^2T\",\"~u00000004-4049-4381-0000-000000000000\",536870913]],[\"^84\",[178,\"^1M\",\"\",536870913]],[\"^84\",[178,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^7N\",\"logseq/publish.css\",536870913]],[\"^84\",[179,\"^48\",1775980635496,536870913]],[\"^84\",[179,\"^7M\",\"recycle\",536870913]],[\"^84\",[179,\"^69\",104,536870913]],[\"^84\",[179,\"^6:\",\"Recycle\",536870913]],[\"^84\",[179,\"^3;\",1775980635496,536870913]],[\"^84\",[179,\"^2T\",\"~u00000004-7238-1304-3000-000000000000\",536870913]],[\"^84\",[179,\"^57\",true,536870913]],[\"^84\",[179,\"^5J\",true,536870913]],[\"^84\",[180,\"^48\",1775980636002,536870913]],[\"^84\",[180,\"^1C\",20260412,536870913]],[\"^84\",[180,\"^7M\",\"apr 12th, 2026\",536870913]],[\"^84\",[180,\"^1S\",58,536870913]],[\"^84\",[180,\"^1S\",85,536870913]],[\"^84\",[180,\"^69\",85,536870913]],[\"^84\",[180,\"^6:\",\"Apr 12th, 2026\",536870913]],[\"^84\",[180,\"^S\",536873442,536873444]],[\"^84\",[180,\"^3;\",1775984647522,536873442]],[\"^84\",[180,\"^2T\",\"~u00000001-2026-0412-0000-000000000000\",536870913]],[\"^84\",[180,\"^4<\",181,536870913]],[\"^84\",[181,\"^48\",1775980636008,536870913]],[\"^84\",[181,\"^7M\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^1S\",58,536870913]],[\"^84\",[181,\"^1S\",104,536870913]],[\"^84\",[181,\"^1S\",130,536870913]],[\"^84\",[181,\"^1S\",137,536870913]],[\"^84\",[181,\"^69\",104,536870913]],[\"^84\",[181,\"^6:\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^3;\",1775980636008,536870913]],[\"^84\",[181,\"^2T\",\"~ue7cd12e2-f9f7-46ed-a17e-85d95a780003\",536870913]],[\"^84\",[181,\"^38\",\"tienson@logseq.com\",536870913]],[\"^84\",[181,\"^E\",\"tiensonqin\",536870913]],[\"^84\",[182,\"^;\",\"^64\",536870913]],[\"^84\",[182,\"^28\",\"~u952b4187-ac01-41d3-b5fb-24250ac70d56\",536870913]],[\"^84\",[183,\"^;\",\"^3:\",536870913]],[\"^84\",[183,\"^28\",true,536870913]],[\"^84\",[565,\"^48\",1775980817925,536870913]],[\"^84\",[565,\"^7M\",\"p1\",536870913]],[\"^84\",[565,\"^7@\",\"d3BHX\",536870913]],[\"^84\",[565,\"^1S\",58,536870913]],[\"^84\",[565,\"^1S\",124,536870913]],[\"^84\",[565,\"^69\",124,536870913]],[\"^84\",[565,\"^6:\",\"p1\",536870913]],[\"^84\",[565,\"^3;\",1775980817925,536870913]],[\"^84\",[565,\"^2T\",\"~u00000002-1157-0363-3400-000000000000\",536870913]],[\"^84\",[565,\"^<\",\"^=\",536870913]],[\"^84\",[565,\"^;\",\"^5N\",536870913]],[\"^84\",[565,\"^>\",true,536870913]],[\"^84\",[565,\"^C\",\"^D\",536870913]],[\"^84\",[565,\"^4<\",181,536870913]],[\"^84\",[565,\"^1K\",\"^8>\",536870913]],[\"^84\",[568,\"^48\",1775980818025,536870913]],[\"^84\",[568,\"^7M\",\"p2\",536870913]],[\"^84\",[568,\"^7@\",\"d3BHa\",536870913]],[\"^84\",[568,\"^1S\",58,536870913]],[\"^84\",[568,\"^1S\",124,536870913]],[\"^84\",[568,\"^69\",124,536870913]],[\"^84\",[568,\"^6:\",\"p2\",536870913]],[\"^84\",[568,\"^3;\",1775980818025,536870913]],[\"^84\",[568,\"^2T\",\"~u00000002-6891-0936-5000-000000000000\",536870913]],[\"^84\",[568,\"^<\",\"^=\",536870913]],[\"^84\",[568,\"^;\",\"^3V\",536870913]],[\"^84\",[568,\"^>\",true,536870913]],[\"^84\",[568,\"^C\",\"^D\",536870913]],[\"^84\",[568,\"^4<\",181,536870913]],[\"^84\",[568,\"^1K\",\"^8K\",536870913]],[\"^84\",[569,\"^48\",1775980818039,536870913]],[\"^84\",[569,\"^7M\",\"p3\",536870913]],[\"^84\",[569,\"^7@\",\"d3BHb\",536870913]],[\"^84\",[569,\"^1S\",58,536870913]],[\"^84\",[569,\"^1S\",104,536870913]],[\"^84\",[569,\"^1S\",124,536870913]],[\"^84\",[569,\"^69\",124,536870913]],[\"^84\",[569,\"^6:\",\"p3\",536870913]],[\"^84\",[569,\"^3;\",1775980818056,536870913]],[\"^84\",[569,\"^2T\",\"~u00000002-2093-0438-6800-000000000000\",536870913]],[\"^84\",[569,\"^<\",\"^14\",536870913]],[\"^84\",[569,\"^;\",\"^1N\",536870913]],[\"^84\",[569,\"^>\",true,536870913]],[\"^84\",[569,\"^C\",\"^D\",536870913]],[\"^84\",[569,\"^6G\",104,536870913]],[\"^84\",[569,\"^4<\",181,536870913]],[\"^84\",[569,\"^1K\",\"^85\",536870913]],[\"^84\",[573,\"^48\",1775980818095,536870913]],[\"^84\",[573,\"^7M\",\"page 1\",536870913]],[\"^84\",[573,\"^1S\",58,536870913]],[\"^84\",[573,\"^1S\",104,536870913]],[\"^84\",[573,\"^69\",104,536870913]],[\"^84\",[573,\"^6:\",\"page 1\",536870913]],[\"^84\",[573,\"^3;\",1775980818095,536870913]],[\"^84\",[573,\"^2T\",\"~u69db5102-b394-49cc-af0d-6e3ee0752cdf\",536870913]],[\"^84\",[573,\"^4<\",181,536870913]],[\"^84\",[574,\"^48\",1775980818106,536870913]],[\"^84\",[574,\"^7M\",\"page 2\",536870913]],[\"^84\",[574,\"^1S\",58,536870913]],[\"^84\",[574,\"^1S\",104,536870913]],[\"^84\",[574,\"^69\",104,536870913]],[\"^84\",[574,\"^6:\",\"page 2\",536870913]],[\"^84\",[574,\"^3;\",1775980818106,536870913]],[\"^84\",[574,\"^2T\",\"~u69db5103-ac5c-4d04-b098-88b435041434\",536870913]],[\"^84\",[574,\"^4<\",181,536870913]],[\"^84\",[577,\"^48\",1775980800102,536870913]],[\"^84\",[577,\"^7@\",\"d3BHW\",536870913]],[\"^84\",[577,\"^4S\",568,536870913]],[\"^84\",[577,\"^3X\",568,536870913]],[\"^84\",[577,\"^1S\",568,536870913]],[\"^84\",[577,\"^3;\",1775980800102,536870913]],[\"^84\",[577,\"^2T\",\"~u69db5100-05c8-4909-b79e-c56130f4d1e9\",536870913]],[\"^84\",[577,\"^4<\",181,536870913]],[\"^84\",[577,\"^6?\",568,536870913]],[\"^84\",[577,\"^T\",10,536870913]],[\"^84\",[593,\"^48\",1775980982459,536870913]],[\"^84\",[593,\"^7@\",\"d3BHf\",536870913]],[\"^84\",[593,\"^4S\",568,536870913]],[\"^84\",[593,\"^3X\",568,536870913]],[\"^84\",[593,\"^1S\",568,536870913]],[\"^84\",[593,\"^3;\",1775980982459,536870913]],[\"^84\",[593,\"^2T\",\"~u69db51b6-97ff-4bf6-9547-69dc12a2972e\",536870913]],[\"^84\",[593,\"^4<\",181,536870913]],[\"^84\",[593,\"^6?\",568,536870913]],[\"^84\",[593,\"^T\",10,536870913]],[\"^84\",[597,\"^48\",1775980958586,536870913]],[\"^84\",[597,\"^7@\",\"d3BHe\",536870913]],[\"^84\",[597,\"^4S\",568,536870913]],[\"^84\",[597,\"^3X\",568,536870913]],[\"^84\",[597,\"^1S\",568,536870913]],[\"^84\",[597,\"^3;\",1775980958586,536870913]],[\"^84\",[597,\"^2T\",\"~u69db519e-9f15-4a6a-a142-fe9d6e954212\",536870913]],[\"^84\",[597,\"^4<\",181,536870913]],[\"^84\",[597,\"^6?\",568,536870913]],[\"^84\",[597,\"^T\",10,536870913]],[\"^84\",[599,\"^48\",1775981040693,536870913]],[\"^84\",[599,\"^7M\",\"page 3\",536870913]],[\"^84\",[599,\"^1S\",58,536870913]],[\"^84\",[599,\"^1S\",104,536870913]],[\"^84\",[599,\"^69\",104,536870913]],[\"^84\",[599,\"^6:\",\"page 3\",536870913]],[\"^84\",[599,\"^3;\",1775981040693,536870913]],[\"^84\",[599,\"^2T\",\"~u69db51a5-4a8e-4ebf-992e-d31c157eaf5b\",536870913]],[\"^84\",[599,\"^4<\",181,536870913]],[\"^84\",[600,\"^48\",1775981040720,536870913]],[\"^84\",[600,\"^7M\",\"p4\",536870913]],[\"^84\",[600,\"^7@\",\"d3BHh\",536870913]],[\"^84\",[600,\"^1S\",58,536870913]],[\"^84\",[600,\"^1S\",124,536870913]],[\"^84\",[600,\"^69\",124,536870913]],[\"^84\",[600,\"^6:\",\"p4\",536870913]],[\"^84\",[600,\"^3;\",1775981028998,536870913]],[\"^84\",[600,\"^2T\",\"~u00000002-5835-7822-8000-000000000000\",536870913]],[\"^84\",[600,\"^<\",\"^=\",536870913]],[\"^84\",[600,\"^;\",\"^6;\",536870913]],[\"^84\",[600,\"^>\",true,536870913]],[\"^84\",[600,\"^C\",\"^D\",536870913]],[\"^84\",[600,\"^4<\",181,536870913]],[\"^84\",[600,\"^1K\",\"^8>\",536870913]],[\"^84\",[605,\"^48\",1775981057423,536870913]],[\"^84\",[605,\"^7@\",\"Zz\",536870913]],[\"^84\",[605,\"^4S\",170,536870913]],[\"^84\",[605,\"^3X\",170,536870913]],[\"^84\",[605,\"^1S\",170,536870913]],[\"^84\",[605,\"^6:\",\"All\",536870913]],[\"^84\",[605,\"^3;\",1775981057423,536870913]],[\"^84\",[605,\"^2T\",\"~u00000006-1867-5878-9000-000000000000\",536870913]],[\"^84\",[605,\"^4<\",181,536870913]],[\"^84\",[605,\"^9\",170,536870913]],[\"^84\",[605,\"^5>\",\"~:all-pages\",536870913]],[\"^84\",[619,\"^3D\",true,536870913]],[\"^84\",[619,\"^48\",1775981500541,536870913]],[\"^84\",[619,\"^7M\",\"tag1\",536870913]],[\"^84\",[619,\"^1S\",14,536870913]],[\"^84\",[619,\"^1S\",58,536870913]],[\"^84\",[619,\"^1S\",89,536870913]],[\"^84\",[619,\"^1S\",158,536870913]],[\"^84\",[619,\"^69\",14,536870913]],[\"^84\",[619,\"^6:\",\"tag1\",536870913]],[\"^84\",[619,\"^3;\",1775981500541,536870913]],[\"^84\",[619,\"^2T\",\"~u69db50d9-c23a-4aa4-bb79-33d5dbe54593\",536870913]],[\"^84\",[619,\"^;\",\"^3T\",536870913]],[\"^84\",[619,\"^4<\",181,536870913]],[\"^84\",[619,\"^3K\",89,536870913]],[\"^84\",[620,\"^48\",1775981500540,536870913]],[\"^84\",[620,\"^7M\",\"page 4\",536870913]],[\"^84\",[620,\"^1S\",58,536870913]],[\"^84\",[620,\"^1S\",104,536870913]],[\"^84\",[620,\"^1S\",619,536870913]],[\"^84\",[620,\"^69\",104,536870913]],[\"^84\",[620,\"^69\",619,536870913]],[\"^84\",[620,\"^6:\",\"page 4\",536870913]],[\"^84\",[620,\"^3;\",1775981500561,536870913]],[\"^84\",[620,\"^2T\",\"~u69db53ab-5e4a-4039-b2ba-4220ca0b20cd\",536870913]],[\"^84\",[620,\"^4<\",181,536870913]],[\"^84\",[621,\"^48\",1775981500553,536870913]],[\"^84\",[621,\"^7@\",\"a0\",536870913]],[\"^84\",[621,\"^4S\",620,536870913]],[\"^84\",[621,\"^3X\",620,536870913]],[\"^84\",[621,\"^6:\",\"test\",536870913]],[\"^84\",[621,\"^3;\",1775981500560,536870913]],[\"^84\",[621,\"^2T\",\"~u69db53ac-7264-4191-bac4-eee78a2ff031\",536870913]],[\"^84\",[621,\"^4<\",181,536870913]],[\"^84\",[622,\"^48\",1775981513870,536870913]],[\"^84\",[622,\"^7@\",\"a0\",536870913]],[\"^84\",[622,\"^4S\",170,536870913]],[\"^84\",[622,\"^3X\",170,536870913]],[\"^84\",[622,\"^1S\",619,536870913]],[\"^84\",[622,\"^6:\",\"All\",536870913]],[\"^84\",[622,\"^3;\",1775981513870,536870913]],[\"^84\",[622,\"^2T\",\"~u00000006-1742-3452-4000-000000000000\",536870913]],[\"^84\",[622,\"^4<\",181,536870913]],[\"^84\",[622,\"^9\",619,536870913]],[\"^84\",[622,\"^5>\",\"~:class-objects\",536870913]],[\"^84\",[1530,\"^48\",1775982125944,536870913]],[\"^84\",[1530,\"^7M\",\"page 5\",536870913]],[\"^84\",[1530,\"^1S\",58,536870913]],[\"^84\",[1530,\"^1S\",104,536870913]],[\"^84\",[1530,\"^69\",104,536870913]],[\"^84\",[1530,\"^6:\",\"page 5\",536870913]],[\"^84\",[1530,\"^3;\",1775982125944,536870913]],[\"^84\",[1530,\"^2T\",\"~u69db557e-1cd8-48b3-87aa-1b8a684ca17f\",536870913]],[\"^84\",[1530,\"^4<\",181,536870913]],[\"^84\",[1732,\"^48\",1775982384424,536870913]],[\"^84\",[1732,\"^7M\",\"page 6\",536870913]],[\"^84\",[1732,\"^1S\",58,536870913]],[\"^84\",[1732,\"^1S\",104,536870913]],[\"^84\",[1732,\"^69\",104,536870913]],[\"^84\",[1732,\"^6:\",\"page 6\",536870913]],[\"^84\",[1732,\"^3;\",1775982413000,536870913]],[\"^84\",[1732,\"^2T\",\"~u69db5730-ad32-46c0-bb99-f87817d4443a\",536870913]],[\"^84\",[1732,\"^4<\",181,536870913]],[\"^84\",[1733,\"^48\",1775982384655,536870913]],[\"^84\",[1733,\"^7@\",\"a0\",536870913]],[\"^84\",[1733,\"^4S\",1732,536870913]],[\"^84\",[1733,\"^3X\",1732,536870913]],[\"^84\",[1733,\"^6:\",\"hello\",536870913]],[\"^84\",[1733,\"^3;\",1775982389175,536870913]],[\"^84\",[1733,\"^2T\",\"~u69db5730-c5a5-4116-89f5-26341ef44073\",536870913]],[\"^84\",[1733,\"^4<\",181,536870913]],[\"^84\",[1742,\"^48\",1775982389180,536870913]],[\"^84\",[1742,\"^7@\",\"a1\",536870913]],[\"^84\",[1742,\"^4S\",1732,536870913]],[\"^84\",[1742,\"^3X\",1732,536870913]],[\"^84\",[1742,\"^6:\",\"fjdlafda\",536870913]],[\"^84\",[1742,\"^3;\",1775982389685,536870913]],[\"^84\",[1742,\"^2T\",\"~u69db5731-9fe9-4c6b-a183-ffb2e6471f45\",536870913]],[\"^84\",[1743,\"^48\",1775982389690,536870913]],[\"^84\",[1743,\"^7@\",\"a2\",536870913]],[\"^84\",[1743,\"^4S\",1732,536870913]],[\"^84\",[1743,\"^3X\",1732,536870913]],[\"^84\",[1743,\"^6:\",\"f\",536870913]],[\"^84\",[1743,\"^3;\",1775982389754,536870913]],[\"^84\",[1743,\"^2T\",\"~u69db5732-3643-4e28-906b-581fbbb2234a\",536870913]],[\"^84\",[1744,\"^48\",1775982389755,536870913]],[\"^84\",[1744,\"^7@\",\"a3\",536870913]],[\"^84\",[1744,\"^4S\",1732,536870913]],[\"^84\",[1744,\"^3X\",1732,536870913]],[\"^84\",[1744,\"^6:\",\"adf\",536870913]],[\"^84\",[1744,\"^3;\",1775982389841,536870913]],[\"^84\",[1744,\"^2T\",\"~u69db5732-9fbc-49f2-8473-db80eee4e879\",536870913]],[\"^84\",[1745,\"^48\",1775982389843,536870913]],[\"^84\",[1745,\"^7@\",\"a4\",536870913]],[\"^84\",[1745,\"^4S\",1732,536870913]],[\"^84\",[1745,\"^3X\",1732,536870913]],[\"^84\",[1745,\"^6:\",\"f\",536870913]],[\"^84\",[1745,\"^3;\",1775982389926,536870913]],[\"^84\",[1745,\"^2T\",\"~u69db5732-a4fa-48bc-b850-1ea2dd125db8\",536870913]],[\"^84\",[1746,\"^48\",1775982389927,536870913]],[\"^84\",[1746,\"^7@\",\"a5\",536870913]],[\"^84\",[1746,\"^4S\",1732,536870913]],[\"^84\",[1746,\"^3X\",1732,536870913]],[\"^84\",[1746,\"^6:\",\"af\",536870913]],[\"^84\",[1746,\"^3;\",1775982390009,536870913]],[\"^84\",[1746,\"^2T\",\"~u69db5732-c89c-439e-91db-385e7cf0884a\",536870913]],[\"^84\",[1747,\"^48\",1775982390010,536870913]],[\"^84\",[1747,\"^7@\",\"a6\",536870913]],[\"^84\",[1747,\"^4S\",1732,536870913]],[\"^84\",[1747,\"^3X\",1732,536870913]],[\"^84\",[1747,\"^6:\",\"af\",536870913]],[\"^84\",[1747,\"^3;\",1775982390090,536870913]],[\"^84\",[1747,\"^2T\",\"~u69db5732-389f-48f7-a3c9-9ab01e4d06b3\",536870913]],[\"^84\",[1748,\"^48\",1775982390091,536870913]],[\"^84\",[1748,\"^7@\",\"a7\",536870913]],[\"^84\",[1748,\"^4S\",1732,536870913]],[\"^84\",[1748,\"^3X\",1732,536870913]],[\"^84\",[1748,\"^6:\",\"a\",536870913]],[\"^84\",[1748,\"^3;\",1775982390176,536870913]],[\"^84\",[1748,\"^2T\",\"~u69db5733-aeac-4823-bd0e-ba84d7fcc3aa\",536870913]],[\"^84\",[1749,\"^48\",1775982390178,536870913]],[\"^84\",[1749,\"^7@\",\"a8\",536870913]],[\"^84\",[1749,\"^4S\",1732,536870913]],[\"^84\",[1749,\"^3X\",1732,536870913]],[\"^84\",[1749,\"^6:\",\"so cool\",536870913]],[\"^84\",[1749,\"^3;\",1775982411832,536870913]],[\"^84\",[1749,\"^2T\",\"~u69db5733-9bfe-49db-96cb-03fe97bce35f\",536870913]],[\"^84\",[1749,\"^4<\",181,536870913]],[\"^84\",[1754,\"^48\",1775982411836,536870913]],[\"^84\",[1754,\"^7@\",\"a0\",536870913]],[\"^84\",[1754,\"^4S\",1732,536870913]],[\"^84\",[1754,\"^3X\",1749,536870913]],[\"^84\",[1754,\"^6:\",\"awesome work\",536870913]],[\"^84\",[1754,\"^3;\",1775982412416,536870913]],[\"^84\",[1754,\"^2T\",\"~u69db5739-bca6-4992-96ec-45334b44f4c4\",536870913]],[\"^84\",[1755,\"^48\",1775982412417,536870913]],[\"^84\",[1755,\"^7@\",\"a1\",536870913]],[\"^84\",[1755,\"^4S\",1732,536870913]],[\"^84\",[1755,\"^3X\",1749,536870913]],[\"^84\",[1755,\"^6:\",\"great\",536870913]],[\"^84\",[1755,\"^3;\",1775982412496,536870913]],[\"^84\",[1755,\"^2T\",\"~u69db573a-8ec0-46e1-bc26-d7632e93d96f\",536870913]],[\"^84\",[1756,\"^48\",1775982412499,536870913]],[\"^84\",[1756,\"^7@\",\"a2\",536870913]],[\"^84\",[1756,\"^4S\",1732,536870913]],[\"^84\",[1756,\"^3X\",1749,536870913]],[\"^84\",[1756,\"^6:\",\"sounds amazing\",536870913]],[\"^84\",[1756,\"^3;\",1775982412747,536870913]],[\"^84\",[1756,\"^2T\",\"~u69db573b-ec3d-4f66-8e8d-26c9ff157a6b\",536870913]],[\"^84\",[1757,\"^48\",1775982412833,536870913]],[\"^84\",[1757,\"^7@\",\"a3\",536870913]],[\"^84\",[1757,\"^4S\",1732,536870913]],[\"^84\",[1757,\"^3X\",1749,536870913]],[\"^84\",[1757,\"^6:\",\"stuff really just work\",536870913]],[\"^84\",[1757,\"^3;\",1775982413000,536870913]],[\"^84\",[1757,\"^2T\",\"~u69db573f-8a13-43cc-82af-060e89cfd1fa\",536870913]],[\"^84\",[1836,\"^48\",1775983070467,536870913]],[\"^84\",[1836,\"^7M\",\"page 7\",536870913]],[\"^84\",[1836,\"^1S\",58,536870913]],[\"^84\",[1836,\"^1S\",104,536870913]],[\"^84\",[1836,\"^69\",104,536870913]],[\"^84\",[1836,\"^6:\",\"page 7\",536870913]],[\"^84\",[1836,\"^3;\",1775983683136,536884972]],[\"^84\",[1836,\"^2T\",\"~u69db59de-5992-4e9c-aba5-12bbae69d657\",536870913]],[\"^84\",[1836,\"^4<\",181,536870913]],[\"^84\",[1852,\"^48\",1775983078379,536870913]],[\"^84\",[1852,\"^7@\",\"a0\",536870913]],[\"^84\",[1852,\"^4S\",1836,536870913]],[\"^84\",[1852,\"^3X\",1836,536870913]],[\"^84\",[1852,\"^6:\",\"fjdlafjldaf\",536870913]],[\"^84\",[1852,\"^3;\",1775983078467,536870913]],[\"^84\",[1852,\"^2T\",\"~u69db59de-1710-4353-9b7f-a6843d27c9f7\",536870913]],[\"^84\",[1853,\"^48\",1775983078469,536870913]],[\"^84\",[1853,\"^7@\",\"a1\",536870913]],[\"^84\",[1853,\"^4S\",1836,536870913]],[\"^84\",[1853,\"^3X\",1836,536870913]],[\"^84\",[1853,\"^6:\",\"da\",536870913]],[\"^84\",[1853,\"^3;\",1775983078547,536870913]],[\"^84\",[1853,\"^2T\",\"~u69db59df-bbd0-4c0b-b89b-40d255373829\",536870913]],[\"^84\",[1854,\"^48\",1775983078548,536870913]],[\"^84\",[1854,\"^7@\",\"a2\",536870913]],[\"^84\",[1854,\"^4S\",1836,536870913]],[\"^84\",[1854,\"^3X\",1836,536870913]],[\"^84\",[1854,\"^6:\",\"fd\",536870913]],[\"^84\",[1854,\"^3;\",1775983078649,536870913]],[\"^84\",[1854,\"^2T\",\"~u69db59df-ecc8-48f2-ab1d-b885294463c8\",536870913]],[\"^84\",[1855,\"^48\",1775983078652,536870913]],[\"^84\",[1855,\"^7@\",\"a0\",536870913]],[\"^84\",[1855,\"^4S\",1836,536870913]],[\"^84\",[1855,\"^3X\",1854,536870913]],[\"^84\",[1855,\"^6:\",\"aff\",536870913]],[\"^84\",[1855,\"^3;\",1775983078804,536870913]],[\"^84\",[1855,\"^2T\",\"~u69db59df-fbd3-4baa-b307-e439f1f660bc\",536870913]],[\"^84\",[1856,\"^48\",1775983078806,536870913]],[\"^84\",[1856,\"^7@\",\"a1\",536870913]],[\"^84\",[1856,\"^4S\",1836,536870913]],[\"^84\",[1856,\"^3X\",1854,536870913]],[\"^84\",[1856,\"^6:\",\"daqf\",536870913]],[\"^84\",[1856,\"^3;\",1775983078892,536870913]],[\"^84\",[1856,\"^2T\",\"~u69db59e0-6768-4059-b647-6b522367a85d\",536870913]],[\"^84\",[1857,\"^48\",1775983078897,536870913]],[\"^84\",[1857,\"^7@\",\"a2\",536870913]],[\"^84\",[1857,\"^4S\",1836,536870913]],[\"^84\",[1857,\"^3X\",1854,536870913]],[\"^84\",[1857,\"^6:\",\"f\",536870913]],[\"^84\",[1857,\"^3;\",1775983079138,536870913]],[\"^84\",[1857,\"^2T\",\"~u69db59e0-16b6-48d5-ae4d-c6832cec5580\",536870913]],[\"^84\",[1858,\"^48\",1775983079140,536870913]],[\"^84\",[1858,\"^7@\",\"a0\",536870913]],[\"^84\",[1858,\"^4S\",1836,536870913]],[\"^84\",[1858,\"^3X\",1857,536870913]],[\"^84\",[1858,\"^6:\",\"f\",536870913]],[\"^84\",[1858,\"^3;\",1775983079488,536870913]],[\"^84\",[1858,\"^2T\",\"~u69db59e0-1a63-4e46-97b2-9696843f7c47\",536870913]],[\"^84\",[1859,\"^48\",1775983079489,536870913]],[\"^84\",[1859,\"^7@\",\"a1\",536870913]],[\"^84\",[1859,\"^4S\",1836,536870913]],[\"^84\",[1859,\"^3X\",1857,536870913]],[\"^84\",[1859,\"^6:\",\"f\",536870913]],[\"^84\",[1859,\"^3;\",1775983079572,536870913]],[\"^84\",[1859,\"^2T\",\"~u69db59e1-da15-45cb-9f42-a87f80023c55\",536870913]],[\"^84\",[1860,\"^48\",1775983079643,536870913]],[\"^84\",[1860,\"^7@\",\"a0\",536870913]],[\"^84\",[1860,\"^4S\",1836,536870913]],[\"^84\",[1860,\"^3X\",1859,536870913]],[\"^84\",[1860,\"^6:\",\"f\",536870913]],[\"^84\",[1860,\"^3;\",1775983079815,536870913]],[\"^84\",[1860,\"^2T\",\"~u69db59e1-f52e-4e72-b110-c313fbd76c44\",536870913]],[\"^84\",[1861,\"^48\",1775983079817,536870913]],[\"^84\",[1861,\"^7@\",\"a1\",536870913]],[\"^84\",[1861,\"^4S\",1836,536870913]],[\"^84\",[1861,\"^3X\",1859,536870913]],[\"^84\",[1861,\"^6:\",\"f\",536884964]],[\"^84\",[1861,\"^3;\",1775983682382,536884964]],[\"^84\",[1861,\"^2T\",\"~u69db59e1-bc2d-49e6-ad28-ade10a3e506c\",536870913]],[\"^84\",[2384,\"^48\",1775983682383,536871818]],[\"^84\",[2384,\"^7@\",\"a0V\",536871818]],[\"^84\",[2384,\"^4S\",1836,536871818]],[\"^84\",[2384,\"^3X\",1859,536871818]],[\"^84\",[2384,\"^6:\",\"fd\",536884966]],[\"^84\",[2384,\"^3;\",1775983682880,536884966]],[\"^84\",[2384,\"^2T\",\"~u69db59e1-5216-491c-b6ff-36ba8fb23f4d\",536871818]],[\"^84\",[2385,\"^48\",1775983682881,536871819]],[\"^84\",[2385,\"^7@\",\"a0l\",536871819]],[\"^84\",[2385,\"^4S\",1836,536871819]],[\"^84\",[2385,\"^3X\",1859,536871819]],[\"^84\",[2385,\"^6:\",\"af\",536884968]],[\"^84\",[2385,\"^3;\",1775983682974,536884968]],[\"^84\",[2385,\"^2T\",\"~u69db59e1-b11f-4372-9155-50825d6fd4ed\",536871819]],[\"^84\",[2386,\"^48\",1775983682975,536871820]],[\"^84\",[2386,\"^7@\",\"a0t\",536871820]],[\"^84\",[2386,\"^4S\",1836,536871820]],[\"^84\",[2386,\"^3X\",1859,536871820]],[\"^84\",[2386,\"^6:\",\"asf\",536884970]],[\"^84\",[2386,\"^3;\",1775983683045,536884970]],[\"^84\",[2386,\"^2T\",\"~u69db59e2-58f7-4f9e-aa44-34b587b0a687\",536871820]],[\"^84\",[2387,\"^48\",1775983683046,536871821]],[\"^84\",[2387,\"^7@\",\"a0x\",536871821]],[\"^84\",[2387,\"^4S\",1836,536871821]],[\"^84\",[2387,\"^3X\",1859,536871821]],[\"^84\",[2387,\"^6:\",\"sf\",536884972]],[\"^84\",[2387,\"^3;\",1775983683135,536884972]],[\"^84\",[2387,\"^2T\",\"~u69db59e2-7991-45be-ba25-1cdb2e3519a9\",536871821]],[\"^84\",[2388,\"^48\",1775983683137,536871822]],[\"^84\",[2388,\"^7@\",\"a0z\",536871822]],[\"^84\",[2388,\"^4S\",1836,536871822]],[\"^84\",[2388,\"^3X\",1859,536871822]],[\"^84\",[2388,\"^6:\",\"\",536871822]],[\"^84\",[2388,\"^3;\",1775983683137,536871822]],[\"^84\",[2388,\"^2T\",\"~u69db59e2-1a51-43ba-8a3b-ceb25f6aa499\",536871822]],[\"^84\",[2460,\"^48\",1775983689295,536871863]],[\"^84\",[2460,\"^7M\",\"page 9\",536871863]],[\"^84\",[2460,\"^1S\",58,536871863]],[\"^84\",[2460,\"^1S\",104,536871863]],[\"^84\",[2460,\"^69\",104,536871863]],[\"^84\",[2460,\"^6:\",\"page 9\",536871863]],[\"^84\",[2460,\"^3;\",1775983693372,536885142]],[\"^84\",[2460,\"^2T\",\"~u69db5c49-fb5d-4f7a-90a9-cd294f0aef44\",536871863]],[\"^84\",[2460,\"^4<\",181,536871863]],[\"^84\",[2461,\"^48\",1775983689528,536871864]],[\"^84\",[2461,\"^7@\",\"a0\",536871864]],[\"^84\",[2461,\"^4S\",2460,536871864]],[\"^84\",[2461,\"^3X\",2460,536871864]],[\"^84\",[2461,\"^6:\",\"fjldafjlas\",536885075]],[\"^84\",[2461,\"^3;\",1775983690319,536885075]],[\"^84\",[2461,\"^2T\",\"~u69db5c49-9f9e-49e8-9ead-2c36ec84031b\",536871864]],[\"^84\",[2461,\"^4<\",181,536871864]],[\"^84\",[2462,\"^48\",1775983690320,536871865]],[\"^84\",[2462,\"^7@\",\"a0\",536885079]],[\"^84\",[2462,\"^4S\",2460,536871865]],[\"^84\",[2462,\"^3X\",2461,536871866]],[\"^84\",[2462,\"^6:\",\"f\",536885078]],[\"^84\",[2462,\"^3;\",1775983690437,536885078]],[\"^84\",[2462,\"^2T\",\"~u69db5c4a-c58e-4a90-9cd6-4f692890f42a\",536871865]],[\"^84\",[2462,\"^4<\",181,536871865]],[\"^84\",[2463,\"^48\",1775983690514,536871867]],[\"^84\",[2463,\"^7@\",\"a0\",536885089]],[\"^84\",[2463,\"^4S\",2460,536871867]],[\"^84\",[2463,\"^3X\",2462,536871870]],[\"^84\",[2463,\"^6:\",\"f\",536885088]],[\"^84\",[2463,\"^3;\",1775983690873,536885088]],[\"^84\",[2463,\"^2T\",\"~u69db5c4a-0212-4c6f-bee0-24b88a12deee\",536871867]],[\"^84\",[2463,\"^4<\",181,536871867]],[\"^84\",[2464,\"^48\",1775983690916,536871871]],[\"^84\",[2464,\"^7@\",\"Zz\",536871871]],[\"^84\",[2464,\"^4S\",2460,536871871]],[\"^84\",[2464,\"^3X\",2462,536871871]],[\"^84\",[2464,\"^6:\",\"\",536871871]],[\"^84\",[2464,\"^3;\",1775983690916,536871871]],[\"^84\",[2464,\"^2T\",\"~u69db5c4a-2364-4fb7-bdd1-223745f4c69a\",536871871]],[\"^84\",[2464,\"^4<\",181,536871871]],[\"^84\",[2465,\"^48\",1775983691100,536871872]],[\"^84\",[2465,\"^7@\",\"a0\",536885101]],[\"^84\",[2465,\"^4S\",2460,536871872]],[\"^84\",[2465,\"^3X\",2464,536871874]],[\"^84\",[2465,\"^6:\",\"f\",536885097]],[\"^84\",[2465,\"^3;\",1775983691353,536885100]],[\"^84\",[2465,\"^2T\",\"~u69db5c4b-932f-49b8-9522-2f5d6a962f25\",536871872]],[\"^84\",[2465,\"^4<\",181,536871872]],[\"^84\",[2466,\"^48\",1775983691319,536871873]],[\"^84\",[2466,\"^7@\",\"a1\",536885105]],[\"^84\",[2466,\"^4S\",2460,536871873]],[\"^84\",[2466,\"^3X\",2464,536871876]],[\"^84\",[2466,\"^6:\",\"\",536871873]],[\"^84\",[2466,\"^3;\",1775983691319,536871873]],[\"^84\",[2466,\"^2T\",\"~u69db5c4b-9d2c-4516-b00c-25b6e2a7d6de\",536871873]],[\"^84\",[2466,\"^4<\",181,536871873]],[\"^84\",[2467,\"^48\",1775983691450,536871875]],[\"^84\",[2467,\"^7@\",\"a2\",536885111]],[\"^84\",[2467,\"^4S\",2460,536871875]],[\"^84\",[2467,\"^3X\",2464,536871878]],[\"^84\",[2467,\"^6:\",\"f\",536885107]],[\"^84\",[2467,\"^3;\",1775983691677,536885110]],[\"^84\",[2467,\"^2T\",\"~u69db5c4b-7c2d-4bb4-9aaa-5a9f1a445f52\",536871875]],[\"^84\",[2467,\"^4<\",181,536871875]],[\"^84\",[2468,\"^48\",1775983691628,536871877]],[\"^84\",[2468,\"^7@\",\"Zzx\",536871877]],[\"^84\",[2468,\"^4S\",2460,536871877]],[\"^84\",[2468,\"^3X\",2462,536871877]],[\"^84\",[2468,\"^6:\",\"f\",536885112]],[\"^84\",[2468,\"^3;\",1775983691774,536885112]],[\"^84\",[2468,\"^2T\",\"~u69db5c4b-56f8-422b-b193-b482957b3b31\",536871877]],[\"^84\",[2468,\"^4<\",181,536871877]],[\"^84\",[2469,\"^48\",1775983691775,536871879]],[\"^84\",[2469,\"^7@\",\"a0\",536885115]],[\"^84\",[2469,\"^4S\",2460,536871879]],[\"^84\",[2469,\"^3X\",2468,536871880]],[\"^84\",[2469,\"^6:\",\"f\",536885117]],[\"^84\",[2469,\"^3;\",1775983691973,536885117]],[\"^84\",[2469,\"^2T\",\"~u69db5c4b-0f65-4034-9d3e-a53826620eb4\",536871879]],[\"^84\",[2469,\"^4<\",181,536871879]],[\"^84\",[2470,\"^48\",1775983691974,536871881]],[\"^84\",[2470,\"^7@\",\"Zz\",536871881]],[\"^84\",[2470,\"^4S\",2460,536871881]],[\"^84\",[2470,\"^3X\",2468,536871881]],[\"^84\",[2470,\"^6:\",\"\",536871881]],[\"^84\",[2470,\"^3;\",1775983691974,536871881]],[\"^84\",[2470,\"^2T\",\"~u69db5c4b-8a1c-4a55-9c85-2c33cc41abf3\",536871881]],[\"^84\",[2470,\"^4<\",181,536871881]],[\"^84\",[2471,\"^48\",1775983692146,536871882]],[\"^84\",[2471,\"^7@\",\"a0\",536885131]],[\"^84\",[2471,\"^4S\",2460,536871882]],[\"^84\",[2471,\"^3X\",2470,536871886]],[\"^84\",[2471,\"^6:\",\"f\",536885127]],[\"^84\",[2471,\"^3;\",1775983692532,536885130]],[\"^84\",[2471,\"^2T\",\"~u69db5c4c-2d33-4497-a4ca-9fa095153cef\",536871882]],[\"^84\",[2471,\"^4<\",181,536871882]],[\"^84\",[2472,\"^48\",1775983692485,536871885]],[\"^84\",[2472,\"^7@\",\"a1\",536885136]],[\"^84\",[2472,\"^4S\",2460,536871885]],[\"^84\",[2472,\"^3X\",2470,536871888]],[\"^84\",[2472,\"^6:\",\"f\",536885132]],[\"^84\",[2472,\"^3;\",1775983692712,536885135]],[\"^84\",[2472,\"^2T\",\"~u69db5c4c-65af-484b-80c2-221d316cd911\",536871885]],[\"^84\",[2472,\"^4<\",181,536871885]],[\"^84\",[2473,\"^48\",1775983692668,536871887]],[\"^84\",[2473,\"^7@\",\"Zzt\",536871887]],[\"^84\",[2473,\"^4S\",2460,536871887]],[\"^84\",[2473,\"^3X\",2468,536871887]],[\"^84\",[2473,\"^6:\",\"f\",536885137]],[\"^84\",[2473,\"^3;\",1775983692810,536885137]],[\"^84\",[2473,\"^2T\",\"~u69db5c4c-8ca4-40fa-b76f-65bdfb599fa4\",536871887]],[\"^84\",[2473,\"^4<\",181,536871887]],[\"^84\",[2474,\"^48\",1775983692811,536871889]],[\"^84\",[2474,\"^7@\",\"a0\",536885140]],[\"^84\",[2474,\"^4S\",2460,536871889]],[\"^84\",[2474,\"^3X\",2473,536871890]],[\"^84\",[2474,\"^1S\",58,536871893]],[\"^84\",[2474,\"^1S\",2475,536871893]],[\"^84\",[2474,\"^69\",2475,536871893]],[\"^84\",[2474,\"^6:\",\"f\",536885142]],[\"^84\",[2474,\"^3;\",1775983693372,536885142]],[\"^84\",[2474,\"^2T\",\"~u69db5c4c-ddf7-4a3a-a05f-8a31dc7acded\",536871889]],[\"^84\",[2474,\"^4<\",181,536871889]],[\"^84\",[2475,\"^3D\",false,536885153]],[\"^84\",[2475,\"^48\",1775983696483,536871892]],[\"^84\",[2475,\"^7M\",\"tag2\",536871892]],[\"^84\",[2475,\"^1S\",14,536871892]],[\"^84\",[2475,\"^1S\",58,536871892]],[\"^84\",[2475,\"^1S\",89,536871892]],[\"^84\",[2475,\"^1S\",121,536871896]],[\"^84\",[2475,\"^1S\",158,536871892]],[\"^84\",[2475,\"^1S\",565,536871896]],[\"^84\",[2475,\"^1S\",568,536871897]],[\"^84\",[2475,\"^1S\",2477,536871899]],[\"^84\",[2475,\"^69\",14,536871892]],[\"^84\",[2475,\"^6:\",\"tag2\",536871892]],[\"^84\",[2475,\"^3;\",1775983696483,536871892]],[\"^84\",[2475,\"^2T\",\"~u69db5c50-3b82-4cb1-a425-271e7c8e6a03\",536871892]],[\"^84\",[2475,\"^;\",\"^3R\",536871892]],[\"^84\",[2475,\"^4<\",181,536871892]],[\"^84\",[2475,\"^3K\",89,536871892]],[\"^84\",[2475,\"^4K\",565,536871896]],[\"^84\",[2475,\"^4K\",568,536871897]],[\"^84\",[2475,\"^4K\",2477,536871899]],[\"^84\",[2476,\"^48\",1775983698445,536871894]],[\"^84\",[2476,\"^7@\",\"Zy\",536871894]],[\"^84\",[2476,\"^4S\",170,536871894]],[\"^84\",[2476,\"^3X\",170,536871894]],[\"^84\",[2476,\"^1S\",2475,536871894]],[\"^84\",[2476,\"^6:\",\"All\",536871894]],[\"^84\",[2476,\"^3;\",1775983698445,536871894]],[\"^84\",[2476,\"^2T\",\"~u00000006-2710-8879-5000-000000000000\",536871894]],[\"^84\",[2476,\"^4<\",181,536871894]],[\"^84\",[2476,\"^9\",2475,536871894]],[\"^84\",[2476,\"^5>\",\"^8O\",536871894]],[\"^84\",[2477,\"^48\",1775983711630,536871898]],[\"^84\",[2477,\"^7M\",\"p5\",536871898]],[\"^84\",[2477,\"^7@\",\"d3BK2\",536871898]],[\"^84\",[2477,\"^1S\",58,536871898]],[\"^84\",[2477,\"^1S\",104,536871900]],[\"^84\",[2477,\"^1S\",124,536871898]],[\"^84\",[2477,\"^69\",124,536871898]],[\"^84\",[2477,\"^6:\",\"p5\",536871898]],[\"^84\",[2477,\"^3;\",1775983717913,536885166]],[\"^84\",[2477,\"^2T\",\"~u00000002-1345-4970-5000-000000000000\",536871898]],[\"^84\",[2477,\"^<\",\"^14\",536885166]],[\"^84\",[2477,\"^;\",\"^23\",536871898]],[\"^84\",[2477,\"^>\",true,536871898]],[\"^84\",[2477,\"^C\",\"^D\",536871898]],[\"^84\",[2477,\"^6G\",104,536871900]],[\"^84\",[2477,\"^4<\",181,536871898]],[\"^84\",[2477,\"^1K\",\"^85\",536871898]],[\"^84\",[2577,\"^48\",1775983728413,536872235]],[\"^84\",[2577,\"^7@\",\"a0\",536872235]],[\"^84\",[2577,\"^4S\",180,536872235]],[\"^84\",[2577,\"^3X\",180,536872235]],[\"^84\",[2577,\"^6:\",\"1\",536872238]],[\"^84\",[2577,\"^S\",536872238,536872240]],[\"^84\",[2577,\"^3;\",1775983728422,536872238]],[\"^84\",[2577,\"^2T\",\"~u69db5bf3-a739-4564-9347-27fae2cc394d\",536872235]],[\"^84\",[2577,\"^4<\",181,536872236]],[\"^84\",[2578,\"^48\",1775983728424,536872239]],[\"^84\",[2578,\"^7@\",\"a0\",536872241]],[\"^84\",[2578,\"^4S\",180,536872239]],[\"^84\",[2578,\"^3X\",2577,536872241]],[\"^84\",[2578,\"^6:\",\"2\",536872243]],[\"^84\",[2578,\"^S\",536872243,536872245]],[\"^84\",[2578,\"^3;\",1775983728449,536872243]],[\"^84\",[2578,\"^2T\",\"~u69db5bf5-0ac5-4b52-82f3-b4d6d8314401\",536872239]],[\"^84\",[2578,\"^4<\",181,536872239]],[\"^84\",[2692,\"^48\",1775983729631,536872436]],[\"^84\",[2692,\"^7M\",\"page 8\",536872436]],[\"^84\",[2692,\"^1S\",58,536872438]],[\"^84\",[2692,\"^1S\",104,536872438]],[\"^84\",[2692,\"^69\",104,536872436]],[\"^84\",[2692,\"^6:\",\"page 8\",536872436]],[\"^84\",[2692,\"^S\",536872479,536872481]],[\"^84\",[2692,\"^3;\",1775983729797,536872479]],[\"^84\",[2692,\"^2T\",\"~u69db5c2a-601b-45ea-ab8f-13e01ea7787b\",536872436]],[\"^84\",[2692,\"^4<\",181,536872437]],[\"^84\",[2693,\"^48\",1775983729638,536872439]],[\"^84\",[2693,\"^7@\",\"a0\",536872439]],[\"^84\",[2693,\"^4S\",2692,536872439]],[\"^84\",[2693,\"^3X\",2692,536872439]],[\"^84\",[2693,\"^6:\",\"hello\",536872442]],[\"^84\",[2693,\"^S\",536872442,536872444]],[\"^84\",[2693,\"^3;\",1775983729647,536872442]],[\"^84\",[2693,\"^2T\",\"~u69db5c2b-3d88-4bad-ae1a-e068d1950520\",536872439]],[\"^84\",[2693,\"^4<\",181,536872440]],[\"^84\",[2694,\"^48\",1775983729648,536872443]],[\"^84\",[2694,\"^7@\",\"a1\",536872443]],[\"^84\",[2694,\"^4S\",2692,536872443]],[\"^84\",[2694,\"^3X\",2692,536872443]],[\"^84\",[2694,\"^6:\",\"worjfdaf\",536872445]],[\"^84\",[2694,\"^S\",536872445,536872447]],[\"^84\",[2694,\"^3;\",1775983729659,536872445]],[\"^84\",[2694,\"^2T\",\"~u69db5c2b-6a3d-4726-a69e-41d53fd5a0bf\",536872443]],[\"^84\",[2694,\"^4<\",181,536872443]],[\"^84\",[2695,\"^48\",1775983729661,536872446]],[\"^84\",[2695,\"^7@\",\"a2\",536872446]],[\"^84\",[2695,\"^4S\",2692,536872446]],[\"^84\",[2695,\"^3X\",2692,536872446]],[\"^84\",[2695,\"^6:\",\"\",536872446]],[\"^84\",[2695,\"^S\",536872445,536872447]],[\"^84\",[2695,\"^3;\",1775983729661,536872446]],[\"^84\",[2695,\"^2T\",\"~u69db5c2c-b373-49b6-a1f1-029b2f8ca8c8\",536872446]],[\"^84\",[2695,\"^4<\",181,536872446]],[\"^84\",[2696,\"^48\",1775983729672,536872448]],[\"^84\",[2696,\"^7@\",\"a0\",536872451]],[\"^84\",[2696,\"^4S\",2692,536872448]],[\"^84\",[2696,\"^3X\",2695,536872451]],[\"^84\",[2696,\"^6:\",\"f\",536872453]],[\"^84\",[2696,\"^S\",536872453,536872455]],[\"^84\",[2696,\"^3;\",1775983729688,536872453]],[\"^84\",[2696,\"^2T\",\"~u69db5c2c-ba52-4f79-85f8-a7655369ba6c\",536872448]],[\"^84\",[2696,\"^4<\",181,536872449]],[\"^84\",[2697,\"^48\",1775983729689,536872454]],[\"^84\",[2697,\"^7@\",\"a1\",536872454]],[\"^84\",[2697,\"^4S\",2692,536872454]],[\"^84\",[2697,\"^3X\",2695,536872454]],[\"^84\",[2697,\"^6:\",\"af\",536872456]],[\"^84\",[2697,\"^S\",536872456,536872458]],[\"^84\",[2697,\"^3;\",1775983729700,536872456]],[\"^84\",[2697,\"^2T\",\"~u69db5c2c-f588-4d67-89cf-637ed39de614\",536872454]],[\"^84\",[2697,\"^4<\",181,536872454]],[\"^84\",[2698,\"^48\",1775983729701,536872457]],[\"^84\",[2698,\"^7@\",\"a2\",536872457]],[\"^84\",[2698,\"^4S\",2692,536872457]],[\"^84\",[2698,\"^3X\",2695,536872457]],[\"^84\",[2698,\"^6:\",\"as\",536872459]],[\"^84\",[2698,\"^S\",536872459,536872461]],[\"^84\",[2698,\"^3;\",1775983729712,536872459]],[\"^84\",[2698,\"^2T\",\"~u69db5c2d-eb41-41f0-97cb-c9dace0102c2\",536872457]],[\"^84\",[2698,\"^4<\",181,536872457]],[\"^84\",[2699,\"^48\",1775983729714,536872460]],[\"^84\",[2699,\"^7@\",\"a3\",536872460]],[\"^84\",[2699,\"^4S\",2692,536872460]],[\"^84\",[2699,\"^3X\",2695,536872460]],[\"^84\",[2699,\"^6:\",\"fa\",536872462]],[\"^84\",[2699,\"^S\",536872462,536872464]],[\"^84\",[2699,\"^3;\",1775983729725,536872462]],[\"^84\",[2699,\"^2T\",\"~u69db5c2d-02cd-4b60-b743-09feff4e4825\",536872460]],[\"^84\",[2699,\"^4<\",181,536872460]],[\"^84\",[2700,\"^48\",1775983729726,536872463]],[\"^84\",[2700,\"^7@\",\"a4\",536872463]],[\"^84\",[2700,\"^4S\",2692,536872463]],[\"^84\",[2700,\"^3X\",2695,536872463]],[\"^84\",[2700,\"^6:\",\"f\",536872465]],[\"^84\",[2700,\"^S\",536872465,536872467]],[\"^84\",[2700,\"^3;\",1775983729738,536872465]],[\"^84\",[2700,\"^2T\",\"~u69db5c2d-a17f-4516-b106-304a1befd700\",536872463]],[\"^84\",[2700,\"^4<\",181,536872463]],[\"^84\",[2701,\"^48\",1775983729739,536872466]],[\"^84\",[2701,\"^7@\",\"a5\",536872466]],[\"^84\",[2701,\"^4S\",2692,536872466]],[\"^84\",[2701,\"^3X\",2695,536872466]],[\"^84\",[2701,\"^1S\",2702,536872474]],[\"^84\",[2701,\"^6:\",\"af\",536872468]],[\"^84\",[2701,\"^S\",536872474,536872475]],[\"^84\",[2701,\"^3;\",1775983665047,536872474]],[\"^84\",[2701,\"^2T\",\"~u69db5c2d-167b-455d-9de8-a098caf456e3\",536872466]],[\"^84\",[2701,\"^4<\",181,536872466]],[\"^84\",[2701,\"^4?\",2703,536872474]],[\"^84\",[2702,\"^48\",1775983729759,536872471]],[\"^84\",[2702,\"^7M\",\"p10\",536872471]],[\"^84\",[2702,\"^7@\",\"d3BHk\",536872471]],[\"^84\",[2702,\"^1S\",58,536872473]],[\"^84\",[2702,\"^1S\",124,536872473]],[\"^84\",[2702,\"^69\",124,536872471]],[\"^84\",[2702,\"^6:\",\"p10\",536872471]],[\"^84\",[2702,\"^S\",536872471,536872473]],[\"^84\",[2702,\"^3;\",1775983729759,536872471]],[\"^84\",[2702,\"^2T\",\"~u00000002-1449-8430-2100-000000000000\",536872471]],[\"^84\",[2702,\"^<\",\"^=\",536872471]],[\"^84\",[2702,\"^;\",\"^4?\",536872471]],[\"^84\",[2702,\"^>\",true,536872471]],[\"^84\",[2702,\"^C\",\"^D\",536872471]],[\"^84\",[2702,\"^4<\",181,536872472]],[\"^84\",[2702,\"^1K\",\"^8>\",536872471]],[\"^84\",[2703,\"^48\",1775983665044,536872474]],[\"^84\",[2703,\"^7@\",\"d3BHj\",536872474]],[\"^84\",[2703,\"^4S\",2692,536872474]],[\"^84\",[2703,\"^3X\",2701,536872474]],[\"^84\",[2703,\"^1S\",2702,536872474]],[\"^84\",[2703,\"^6:\",\"hello\",536872476]],[\"^84\",[2703,\"^S\",536872476,536872478]],[\"^84\",[2703,\"^3;\",1775983729784,536872476]],[\"^84\",[2703,\"^2T\",\"~u69db5c31-45d6-443b-8f8f-1b1808c86b4b\",536872474]],[\"^84\",[2703,\"^4<\",181,536872474]],[\"^84\",[2703,\"^6?\",2702,536872474]],[\"^84\",[2704,\"^48\",1775983729785,536872477]],[\"^84\",[2704,\"^7@\",\"a0\",536872477]],[\"^84\",[2704,\"^4S\",2692,536872477]],[\"^84\",[2704,\"^3X\",2703,536872477]],[\"^84\",[2704,\"^6:\",\"fjdalfjklaf\",536872479]],[\"^84\",[2704,\"^S\",536872479,536872481]],[\"^84\",[2704,\"^3;\",1775983729797,536872479]],[\"^84\",[2704,\"^2T\",\"~u69db5c32-c449-4614-89c5-afdad827a965\",536872477]],[\"^84\",[2704,\"^4<\",181,536872477]],[\"^84\",[2705,\"^;\",\"^4O\",536872484]],[\"^84\",[2705,\"^28\",1775984161304,536872484]],[\"^84\",[2706,\"^;\",\"^4J\",536872485]],[\"^84\",[2706,\"^28\",1775984161307,536872485]],[\"^84\",[2778,\"^48\",1775984527252,536872747]],[\"^84\",[2778,\"^7M\",\"page 10\",536872747]],[\"^84\",[2778,\"^1S\",58,536872749]],[\"^84\",[2778,\"^1S\",104,536872749]],[\"^84\",[2778,\"^69\",104,536872747]],[\"^84\",[2778,\"^6:\",\"page 10\",536872747]],[\"^84\",[2778,\"^S\",536872879,536872881]],[\"^84\",[2778,\"^3;\",1775984527765,536872879]],[\"^84\",[2778,\"^2T\",\"~u69db5f78-689c-4382-9a3d-d2caf761adb6\",536872747]],[\"^84\",[2778,\"^4<\",181,536872748]],[\"^84\",[2793,\"^48\",1775984527610,536872833]],[\"^84\",[2793,\"^7@\",\"a0\",536872833]],[\"^84\",[2793,\"^4S\",2778,536872833]],[\"^84\",[2793,\"^3X\",2778,536872833]],[\"^84\",[2793,\"^6:\",\"hellofda\",536872836]],[\"^84\",[2793,\"^S\",536872836,536872838]],[\"^84\",[2793,\"^3;\",1775984527618,536872836]],[\"^84\",[2793,\"^2T\",\"~u69db5f78-dcde-4e98-9f1a-1bca6c316e8d\",536872833]],[\"^84\",[2793,\"^4<\",181,536872834]],[\"^84\",[2794,\"^48\",1775984527619,536872837]],[\"^84\",[2794,\"^7@\",\"a1\",536872837]],[\"^84\",[2794,\"^4S\",2778,536872837]],[\"^84\",[2794,\"^3X\",2778,536872837]],[\"^84\",[2794,\"^6:\",\"fa\",536872839]],[\"^84\",[2794,\"^S\",536872839,536872841]],[\"^84\",[2794,\"^3;\",1775984527629,536872839]],[\"^84\",[2794,\"^2T\",\"~u69db5f79-a66e-4a39-aecc-339726c89430\",536872837]],[\"^84\",[2794,\"^4<\",181,536872837]],[\"^84\",[2795,\"^48\",1775984527630,536872840]],[\"^84\",[2795,\"^7@\",\"a0\",536872842]],[\"^84\",[2795,\"^4S\",2778,536872840]],[\"^84\",[2795,\"^3X\",2794,536872842]],[\"^84\",[2795,\"^6:\",\"fdasf\",536872844]],[\"^84\",[2795,\"^S\",536872844,536872846]],[\"^84\",[2795,\"^3;\",1775984527646,536872844]],[\"^84\",[2795,\"^2T\",\"~u69db5f79-fd04-4c4f-a0d5-726877b307a3\",536872840]],[\"^84\",[2795,\"^4<\",181,536872840]],[\"^84\",[2796,\"^48\",1775984527647,536872845]],[\"^84\",[2796,\"^7@\",\"a1\",536872845]],[\"^84\",[2796,\"^4S\",2778,536872845]],[\"^84\",[2796,\"^3X\",2794,536872845]],[\"^84\",[2796,\"^6:\",\"af\",536872847]],[\"^84\",[2796,\"^S\",536872847,536872849]],[\"^84\",[2796,\"^3;\",1775984527656,536872847]],[\"^84\",[2796,\"^2T\",\"~u69db5f7a-d6b1-4bf8-90c6-a70ba0bec381\",536872845]],[\"^84\",[2796,\"^4<\",181,536872845]],[\"^84\",[2797,\"^48\",1775984527658,536872848]],[\"^84\",[2797,\"^7@\",\"a2\",536872848]],[\"^84\",[2797,\"^4S\",2778,536872848]],[\"^84\",[2797,\"^3X\",2794,536872848]],[\"^84\",[2797,\"^6:\",\"as\",536872850]],[\"^84\",[2797,\"^S\",536872850,536872852]],[\"^84\",[2797,\"^3;\",1775984527666,536872850]],[\"^84\",[2797,\"^2T\",\"~u69db5f7a-e8a7-4800-bd8c-b2c14b6648a8\",536872848]],[\"^84\",[2797,\"^4<\",181,536872848]],[\"^84\",[2798,\"^48\",1775984527668,536872851]],[\"^84\",[2798,\"^7@\",\"a3\",536872851]],[\"^84\",[2798,\"^4S\",2778,536872851]],[\"^84\",[2798,\"^3X\",2794,536872851]],[\"^84\",[2798,\"^6:\",\"fdas\",536872853]],[\"^84\",[2798,\"^S\",536872853,536872855]],[\"^84\",[2798,\"^3;\",1775984527677,536872853]],[\"^84\",[2798,\"^2T\",\"~u69db5f7a-f814-4036-b2d5-06b1688f319f\",536872851]],[\"^84\",[2798,\"^4<\",181,536872851]],[\"^84\",[2799,\"^48\",1775984527678,536872854]],[\"^84\",[2799,\"^7@\",\"a2\",536872856]],[\"^84\",[2799,\"^4S\",2778,536872854]],[\"^84\",[2799,\"^3X\",2778,536872856]],[\"^84\",[2799,\"^6:\",\"fs\",536872858]],[\"^84\",[2799,\"^S\",536872858,536872860]],[\"^84\",[2799,\"^3;\",1775984527694,536872858]],[\"^84\",[2799,\"^2T\",\"~u69db5f7b-2941-4704-88d5-3d10f2b585f6\",536872854]],[\"^84\",[2799,\"^4<\",181,536872854]],[\"^84\",[2800,\"^48\",1775984527696,536872859]],[\"^84\",[2800,\"^7@\",\"a3\",536872859]],[\"^84\",[2800,\"^4S\",2778,536872859]],[\"^84\",[2800,\"^3X\",2778,536872859]],[\"^84\",[2800,\"^6:\",\"af\",536872861]],[\"^84\",[2800,\"^S\",536872861,536872863]],[\"^84\",[2800,\"^3;\",1775984527705,536872861]],[\"^84\",[2800,\"^2T\",\"~u69db5f7b-11a3-4387-b2d8-e4360d0574b0\",536872859]],[\"^84\",[2800,\"^4<\",181,536872859]],[\"^84\",[2801,\"^48\",1775984527706,536872862]],[\"^84\",[2801,\"^7@\",\"a4\",536872862]],[\"^84\",[2801,\"^4S\",2778,536872862]],[\"^84\",[2801,\"^3X\",2778,536872862]],[\"^84\",[2801,\"^6:\",\"as\",536872862]],[\"^84\",[2801,\"^S\",536872861,536872863]],[\"^84\",[2801,\"^3;\",1775984527706,536872862]],[\"^84\",[2801,\"^2T\",\"~u69db5f7b-24f0-4258-b5b5-7c86c9fb264e\",536872862]],[\"^84\",[2801,\"^4<\",181,536872862]],[\"^84\",[2802,\"^48\",1775984527715,536872864]],[\"^84\",[2802,\"^7@\",\"a3V\",536872864]],[\"^84\",[2802,\"^4S\",2778,536872864]],[\"^84\",[2802,\"^3X\",2778,536872864]],[\"^84\",[2802,\"^6:\",\"fd\",536872867]],[\"^84\",[2802,\"^S\",536872867,536872869]],[\"^84\",[2802,\"^3;\",1775984527722,536872867]],[\"^84\",[2802,\"^2T\",\"~u69db5f7b-02a5-40e9-baf8-befba0d845c5\",536872864]],[\"^84\",[2802,\"^4<\",181,536872865]],[\"^84\",[2803,\"^48\",1775984527723,536872868]],[\"^84\",[2803,\"^7@\",\"a3l\",536872868]],[\"^84\",[2803,\"^4S\",2778,536872868]],[\"^84\",[2803,\"^3X\",2778,536872868]],[\"^84\",[2803,\"^6:\",\"af\",536872870]],[\"^84\",[2803,\"^S\",536872870,536872872]],[\"^84\",[2803,\"^3;\",1775984527732,536872870]],[\"^84\",[2803,\"^2T\",\"~u69db5f7c-4a9c-4a5f-ad77-4d171ab63937\",536872868]],[\"^84\",[2803,\"^4<\",181,536872868]],[\"^84\",[2804,\"^48\",1775984527734,536872871]],[\"^84\",[2804,\"^7@\",\"a3t\",536872871]],[\"^84\",[2804,\"^4S\",2778,536872871]],[\"^84\",[2804,\"^3X\",2778,536872871]],[\"^84\",[2804,\"^6:\",\"af\",536872873]],[\"^84\",[2804,\"^S\",536872873,536872875]],[\"^84\",[2804,\"^3;\",1775984527743,536872873]],[\"^84\",[2804,\"^2T\",\"~u69db5f7c-e498-4db4-9791-53e4560eb1cc\",536872871]],[\"^84\",[2804,\"^4<\",181,536872871]],[\"^84\",[2805,\"^48\",1775984527744,536872874]],[\"^84\",[2805,\"^7@\",\"a3x\",536872874]],[\"^84\",[2805,\"^4S\",2778,536872874]],[\"^84\",[2805,\"^3X\",2778,536872874]],[\"^84\",[2805,\"^6:\",\"af\",536872876]],[\"^84\",[2805,\"^S\",536872876,536872878]],[\"^84\",[2805,\"^3;\",1775984527754,536872876]],[\"^84\",[2805,\"^2T\",\"~u69db5f7c-7746-45d5-8989-646ee3b9289b\",536872874]],[\"^84\",[2805,\"^4<\",181,536872874]],[\"^84\",[2806,\"^48\",1775984527755,536872877]],[\"^84\",[2806,\"^7@\",\"a3z\",536872877]],[\"^84\",[2806,\"^4S\",2778,536872877]],[\"^84\",[2806,\"^3X\",2778,536872877]],[\"^84\",[2806,\"^6:\",\"a\",536872879]],[\"^84\",[2806,\"^S\",536872879,536872881]],[\"^84\",[2806,\"^3;\",1775984527764,536872879]],[\"^84\",[2806,\"^2T\",\"~u69db5f7c-8423-4ed9-bb36-bf5156b4d757\",536872877]],[\"^84\",[2806,\"^4<\",181,536872877]],[\"^84\",[2931,\"^48\",1775984610679,536873146]],[\"^84\",[2931,\"^7M\",\"page 11\",536873146]],[\"^84\",[2931,\"^1S\",58,536873146]],[\"^84\",[2931,\"^1S\",104,536873146]],[\"^84\",[2931,\"^69\",104,536873146]],[\"^84\",[2931,\"^6:\",\"page 11\",536873146]],[\"^84\",[2931,\"^3;\",1775984651669,536885723]],[\"^84\",[2931,\"^2T\",\"~u69db5fe2-20d8-4c72-8f3a-96b778acec6f\",536873146]],[\"^84\",[2931,\"^4<\",181,536873146]],[\"^84\",[2932,\"^48\",1775984610938,536873147]],[\"^84\",[2932,\"^7@\",\"a0\",536873147]],[\"^84\",[2932,\"^4S\",2931,536873147]],[\"^84\",[2932,\"^3X\",2931,536873147]],[\"^84\",[2932,\"^6:\",\"fjdlafjl\",536885538]],[\"^84\",[2932,\"^3;\",1775984611435,536885538]],[\"^84\",[2932,\"^2T\",\"~u69db5fe2-56e1-428d-bf11-06cb38229be2\",536873147]],[\"^84\",[2932,\"^4<\",181,536873147]],[\"^84\",[2933,\"^48\",1775984611507,536873149]],[\"^84\",[2933,\"^7@\",\"a0\",536885545]],[\"^84\",[2933,\"^4S\",2931,536873149]],[\"^84\",[2933,\"^3X\",2932,536873150]],[\"^84\",[2933,\"^6:\",\"ff\",536885547]],[\"^84\",[2933,\"^3;\",1775984611935,536885547]],[\"^84\",[2933,\"^2T\",\"~u69db5fe3-6de1-4070-96b6-6fb5e16cfd5d\",536873149]],[\"^84\",[2933,\"^4<\",181,536873149]],[\"^84\",[2934,\"^48\",1775984611937,536873151]],[\"^84\",[2934,\"^7@\",\"a0\",536885555]],[\"^84\",[2934,\"^4S\",2931,536873151]],[\"^84\",[2934,\"^3X\",2933,536873154]],[\"^84\",[2934,\"^6:\",\"f\",536885554]],[\"^84\",[2934,\"^3;\",1775984612220,536885554]],[\"^84\",[2934,\"^2T\",\"~u69db5fe3-0419-47c1-9d77-36ba70846eae\",536873151]],[\"^84\",[2934,\"^4<\",181,536873151]],[\"^84\",[2935,\"^48\",1775984612293,536873155]],[\"^84\",[2935,\"^7@\",\"a0\",536885568]],[\"^84\",[2935,\"^4S\",2931,536873155]],[\"^84\",[2935,\"^3X\",2934,536873159]],[\"^84\",[2935,\"^6:\",\"f\",536885564]],[\"^84\",[2935,\"^3;\",1775984612724,536885567]],[\"^84\",[2935,\"^2T\",\"~u69db5fe4-4a1d-4a85-8ffc-13fa73cf7772\",536873155]],[\"^84\",[2935,\"^4<\",181,536873155]],[\"^84\",[2936,\"^48\",1775984612632,536873158]],[\"^84\",[2936,\"^7@\",\"a1\",536885573]],[\"^84\",[2936,\"^4S\",2931,536873158]],[\"^84\",[2936,\"^3X\",2934,536873161]],[\"^84\",[2936,\"^6:\",\"f\",536885569]],[\"^84\",[2936,\"^3;\",1775984612903,536885572]],[\"^84\",[2936,\"^2T\",\"~u69db5fe4-bafe-449d-acb5-c363728d2c3a\",536873158]],[\"^84\",[2936,\"^4<\",181,536873158]],[\"^84\",[2937,\"^48\",1775984612812,536873160]],[\"^84\",[2937,\"^7@\",\"a3\",536873160]],[\"^84\",[2937,\"^4S\",2931,536873160]],[\"^84\",[2937,\"^3X\",2933,536873160]],[\"^84\",[2937,\"^6:\",\"f\",536885574]],[\"^84\",[2937,\"^3;\",1775984613001,536885574]],[\"^84\",[2937,\"^2T\",\"~u69db5fe4-a665-4b11-82ef-9fa78a1d025c\",536873160]],[\"^84\",[2937,\"^4<\",181,536873160]],[\"^84\",[2938,\"^48\",1775984613002,536873162]],[\"^84\",[2938,\"^7@\",\"a0\",536885577]],[\"^84\",[2938,\"^4S\",2931,536873162]],[\"^84\",[2938,\"^3X\",2937,536873163]],[\"^84\",[2938,\"^6:\",\"f\",536885579]],[\"^84\",[2938,\"^3;\",1775984613203,536885579]],[\"^84\",[2938,\"^2T\",\"~u69db5fe4-e871-4f41-a9a6-d2783de5b724\",536873162]],[\"^84\",[2938,\"^4<\",181,536873162]],[\"^84\",[2939,\"^48\",1775984613203,536873164]],[\"^84\",[2939,\"^7@\",\"a2\",536885639]],[\"^84\",[2939,\"^4S\",2931,536873164]],[\"^84\",[2939,\"^3X\",2950,536873186]],[\"^84\",[2939,\"^6:\",\"q\",536885584]],[\"^84\",[2939,\"^3;\",1775984613413,536885584]],[\"^84\",[2939,\"^2T\",\"~u69db5fe5-c93e-4715-a9e2-b311246fe3f1\",536873164]],[\"^84\",[2939,\"^4<\",181,536873164]],[\"^84\",[2940,\"^48\",1775984613413,536873166]],[\"^84\",[2940,\"^7@\",\"a1\",536873166]],[\"^84\",[2940,\"^4S\",2931,536873166]],[\"^84\",[2940,\"^3X\",2938,536873166]],[\"^84\",[2940,\"^6:\",\"\",536873166]],[\"^84\",[2940,\"^3;\",1775984613413,536873166]],[\"^84\",[2940,\"^2T\",\"~u69db5fe5-b337-41af-9146-6e6e01e72130\",536873166]],[\"^84\",[2940,\"^4<\",181,536873166]],[\"^84\",[2941,\"^48\",1775984613561,536873168]],[\"^84\",[2941,\"^7@\",\"a2\",536885721]],[\"^84\",[2941,\"^4S\",2931,536873168]],[\"^84\",[2941,\"^3X\",2931,536873469]],[\"^84\",[2941,\"^6:\",\"fdafdasf\",536885723]],[\"^84\",[2941,\"^3;\",1775984651668,536885723]],[\"^84\",[2941,\"^2T\",\"~u69db5fe5-ce1b-4fb0-802c-ca3c7deba63a\",536873168]],[\"^84\",[2941,\"^4<\",181,536873168]],[\"^84\",[2942,\"^48\",1775984613782,536873170]],[\"^84\",[2942,\"^7@\",\"Zz\",536873170]],[\"^84\",[2942,\"^4S\",2931,536873170]],[\"^84\",[2942,\"^3X\",2940,536873170]],[\"^84\",[2942,\"^6:\",\"ff\",536885603]],[\"^84\",[2942,\"^3;\",1775984614190,536885606]],[\"^84\",[2942,\"^2T\",\"~u69db5fe5-5fad-4cca-bed6-f9e7c1e2a37d\",536873170]],[\"^84\",[2942,\"^4<\",181,536873170]],[\"^84\",[2943,\"^48\",1775984613943,536873172]],[\"^84\",[2943,\"^7@\",\"a1\",536885638]],[\"^84\",[2943,\"^4S\",2931,536873172]],[\"^84\",[2943,\"^3X\",2950,536873186]],[\"^84\",[2943,\"^6:\",\"\",536873172]],[\"^84\",[2943,\"^3;\",1775984613943,536873172]],[\"^84\",[2943,\"^2T\",\"~u69db5fe5-deee-4706-a78d-aaf4007518c1\",536873172]],[\"^84\",[2943,\"^4<\",181,536873172]],[\"^84\",[2944,\"^48\",1775984614111,536873173]],[\"^84\",[2944,\"^7@\",\"a0\",536885612]],[\"^84\",[2944,\"^4S\",2931,536873173]],[\"^84\",[2944,\"^3X\",2942,536873176]],[\"^84\",[2944,\"^6:\",\"f\",536885608]],[\"^84\",[2944,\"^3;\",1775984614359,536885611]],[\"^84\",[2944,\"^2T\",\"~u69db5fe6-fd79-4fa8-bfab-30986aa83bcc\",536873173]],[\"^84\",[2944,\"^4<\",181,536873173]],[\"^84\",[2945,\"^48\",1775984614283,536873175]],[\"^84\",[2945,\"^7@\",\"a1\",536885617]],[\"^84\",[2945,\"^4S\",2931,536873175]],[\"^84\",[2945,\"^3X\",2942,536873178]],[\"^84\",[2945,\"^6:\",\"f\",536885613]],[\"^84\",[2945,\"^3;\",1775984614536,536885616]],[\"^84\",[2945,\"^2T\",\"~u69db5fe6-437d-47c3-91e6-7eed23097b73\",536873175]],[\"^84\",[2945,\"^4<\",181,536873175]],[\"^84\",[2946,\"^48\",1775984614454,536873177]],[\"^84\",[2946,\"^7@\",\"ZzS\",536873177]],[\"^84\",[2946,\"^4S\",2931,536873177]],[\"^84\",[2946,\"^3X\",2940,536873177]],[\"^84\",[2946,\"^6:\",\"\",536873177]],[\"^84\",[2946,\"^3;\",1775984614454,536873177]],[\"^84\",[2946,\"^2T\",\"~u69db5fe6-a079-4c88-8d9c-bc0609008fe6\",536873177]],[\"^84\",[2946,\"^4<\",181,536873177]],[\"^84\",[2947,\"^48\",1775984614634,536873179]],[\"^84\",[2947,\"^7@\",\"a2\",536885627]],[\"^84\",[2947,\"^4S\",2931,536873179]],[\"^84\",[2947,\"^3X\",2946,536873182]],[\"^84\",[2947,\"^6:\",\"ff\",536885632]],[\"^84\",[2947,\"^3;\",1775984615139,536885632]],[\"^84\",[2947,\"^2T\",\"~u69db5fe6-9f47-45fd-bd1d-78cdad7e5894\",536873179]],[\"^84\",[2947,\"^4<\",181,536873179]],[\"^84\",[2948,\"^48\",1775984614835,536873181]],[\"^84\",[2948,\"^7@\",\"a1\",536873181]],[\"^84\",[2948,\"^4S\",2931,536873181]],[\"^84\",[2948,\"^3X\",2946,536873181]],[\"^84\",[2948,\"^6:\",\"\",536873181]],[\"^84\",[2948,\"^3;\",1775984614835,536873181]],[\"^84\",[2948,\"^2T\",\"~u69db5fe6-6b73-4f8c-a89d-3cca819ee63c\",536873181]],[\"^84\",[2948,\"^4<\",181,536873181]],[\"^84\",[2949,\"^48\",1775984614992,536873183]],[\"^84\",[2949,\"^7@\",\"a0\",536885636]],[\"^84\",[2949,\"^4S\",2931,536873183]],[\"^84\",[2949,\"^3X\",2950,536873185]],[\"^84\",[2949,\"^6:\",\"\",536873183]],[\"^84\",[2949,\"^3;\",1775984614992,536873183]],[\"^84\",[2949,\"^2T\",\"~u69db5fe6-d898-483f-bc9b-0122ac0cffe1\",536873183]],[\"^84\",[2949,\"^4<\",181,536873183]],[\"^84\",[2950,\"^48\",1775984615139,536873184]],[\"^84\",[2950,\"^7@\",\"a1\",536885643]],[\"^84\",[2950,\"^4S\",2931,536873184]],[\"^84\",[2950,\"^3X\",2931,536873190]],[\"^84\",[2950,\"^6:\",\"\",536873184]],[\"^84\",[2950,\"^3;\",1775984615139,536873184]],[\"^84\",[2950,\"^2T\",\"~u69db5fe7-2d4d-43a8-b50b-aa6625c817ae\",536873184]],[\"^84\",[2950,\"^4<\",181,536873184]],[\"^84\",[2988,\"^48\",1775984622069,536873306]],[\"^84\",[2988,\"^7@\",\"a0\",536873309]],[\"^84\",[2988,\"^4S\",180,536873306]],[\"^84\",[2988,\"^3X\",2578,536873309]],[\"^84\",[2988,\"^6:\",\"3\",536873466]],[\"^84\",[2988,\"^S\",536873311,536873466]],[\"^84\",[2988,\"^3;\",1775984622083,536873466]],[\"^84\",[2988,\"^2T\",\"~u69db5fbe-a17a-4787-8615-7455e1eba752\",536873306]],[\"^84\",[2988,\"^4<\",181,536873307]],[\"^84\",[2988,\"^2T\",\"~u69db5fbe-a17a-4787-8615-7455e1eba752\",536873306]],[\"^84\",[2988,\"^4<\",181,536873307]],[\"^84\",[3032,\"^48\",1775984643975,536873385]],[\"^84\",[3032,\"^7@\",\"a1\",536873385]],[\"^84\",[3032,\"^4S\",180,536873385]],[\"^84\",[3032,\"^3X\",2578,536873385]],[\"^84\",[3032,\"^6:\",\"da\",536873388]],[\"^84\",[3032,\"^S\",536873388,536873390]],[\"^84\",[3032,\"^3;\",1775984644155,536873388]],[\"^84\",[3032,\"^2T\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873385]],[\"^84\",[3032,\"^4<\",181,536873386]],[\"^84\",[3033,\"^48\",1775984644156,536873388]],[\"^84\",[3033,\"^7@\",\"a2\",536873388]],[\"^84\",[3033,\"^4S\",180,536873388]],[\"^84\",[3033,\"^3X\",2578,536873388]],[\"^84\",[3033,\"^6:\",\"fda\",536873391]],[\"^84\",[3033,\"^S\",536873391,536873393]],[\"^84\",[3033,\"^3;\",1775984644335,536873391]],[\"^84\",[3033,\"^2T\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873388]],[\"^84\",[3033,\"^4<\",181,536873389]],[\"^84\",[3034,\"^48\",1775984644336,536873391]],[\"^84\",[3034,\"^7@\",\"a3\",536873391]],[\"^84\",[3034,\"^4S\",180,536873391]],[\"^84\",[3034,\"^3X\",2578,536873391]],[\"^84\",[3034,\"^6:\",\"f\",536873394]],[\"^84\",[3034,\"^S\",536873394,536873396]],[\"^84\",[3034,\"^3;\",1775984644505,536873394]],[\"^84\",[3034,\"^2T\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873391]],[\"^84\",[3034,\"^4<\",181,536873392]],[\"^84\",[3035,\"^48\",1775984644506,536873394]],[\"^84\",[3035,\"^7@\",\"a1\",536873397]],[\"^84\",[3035,\"^4S\",180,536873394]],[\"^84\",[3035,\"^3X\",180,536873399]],[\"^84\",[3035,\"^6:\",\"\",536873394]],[\"^84\",[3035,\"^S\",536873399,536873400]],[\"^84\",[3035,\"^3;\",1775984644506,536873394]],[\"^84\",[3035,\"^2T\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873394]],[\"^84\",[3035,\"^4<\",181,536873395]],[\"^84\",[3036,\"^48\",1775984645026,536873401]],[\"^84\",[3036,\"^7@\",\"a2\",536873401]],[\"^84\",[3036,\"^4S\",180,536873401]],[\"^84\",[3036,\"^3X\",180,536873401]],[\"^84\",[3036,\"^6:\",\"\",536873401]],[\"^84\",[3036,\"^S\",536873401,536873403]],[\"^84\",[3036,\"^3;\",1775984645026,536873401]],[\"^84\",[3036,\"^2T\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873401]],[\"^84\",[3036,\"^4<\",181,536873402]],[\"^84\",[3037,\"^48\",1775984645181,536873404]],[\"^84\",[3037,\"^7@\",\"a3\",536873404]],[\"^84\",[3037,\"^4S\",180,536873404]],[\"^84\",[3037,\"^3X\",180,536873404]],[\"^84\",[3037,\"^6:\",\"fdasf\",536873407]],[\"^84\",[3037,\"^S\",536873407,536873409]],[\"^84\",[3037,\"^3;\",1775984645524,536873407]],[\"^84\",[3037,\"^2T\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873404]],[\"^84\",[3037,\"^4<\",181,536873405]],[\"^84\",[3038,\"^48\",1775984645526,536873407]],[\"^84\",[3038,\"^7@\",\"a4\",536873407]],[\"^84\",[3038,\"^4S\",180,536873407]],[\"^84\",[3038,\"^3X\",180,536873407]],[\"^84\",[3038,\"^6:\",\"a\",536873410]],[\"^84\",[3038,\"^S\",536873410,536873412]],[\"^84\",[3038,\"^3;\",1775984645690,536873410]],[\"^84\",[3038,\"^2T\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873407]],[\"^84\",[3038,\"^4<\",181,536873408]],[\"^84\",[3039,\"^48\",1775984645692,536873410]],[\"^84\",[3039,\"^7@\",\"a0\",536873414]],[\"^84\",[3039,\"^4S\",180,536873410]],[\"^84\",[3039,\"^3X\",3038,536873414]],[\"^84\",[3039,\"^6:\",\"ffd\",536873416]],[\"^84\",[3039,\"^S\",536873416,536873418]],[\"^84\",[3039,\"^3;\",1775984646215,536873416]],[\"^84\",[3039,\"^2T\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873410]],[\"^84\",[3039,\"^4<\",181,536873411]],[\"^84\",[3040,\"^48\",1775984646216,536873416]],[\"^84\",[3040,\"^7@\",\"a1\",536873416]],[\"^84\",[3040,\"^4S\",180,536873416]],[\"^84\",[3040,\"^3X\",3038,536873416]],[\"^84\",[3040,\"^6:\",\"asf\",536873419]],[\"^84\",[3040,\"^S\",536873419,536873421]],[\"^84\",[3040,\"^3;\",1775984646415,536873419]],[\"^84\",[3040,\"^2T\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873416]],[\"^84\",[3040,\"^4<\",181,536873417]],[\"^84\",[3041,\"^48\",1775984646416,536873419]],[\"^84\",[3041,\"^7@\",\"a2\",536873424]],[\"^84\",[3041,\"^4S\",180,536873419]],[\"^84\",[3041,\"^3X\",3038,536873424]],[\"^84\",[3041,\"^6:\",\"f\",536873426]],[\"^84\",[3041,\"^S\",536873426,536873428]],[\"^84\",[3041,\"^3;\",1775984646786,536873426]],[\"^84\",[3041,\"^2T\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873419]],[\"^84\",[3041,\"^4<\",181,536873420]],[\"^84\",[3042,\"^48\",1775984646787,536873426]],[\"^84\",[3042,\"^7@\",\"a0\",536873429]],[\"^84\",[3042,\"^4S\",180,536873426]],[\"^84\",[3042,\"^3X\",3040,536873429]],[\"^84\",[3042,\"^6:\",\"\",536873426]],[\"^84\",[3042,\"^S\",536873429,536873430]],[\"^84\",[3042,\"^3;\",1775984646787,536873426]],[\"^84\",[3042,\"^2T\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873426]],[\"^84\",[3042,\"^4<\",181,536873427]],[\"^84\",[3043,\"^48\",1775984646989,536873431]],[\"^84\",[3043,\"^7@\",\"a1\",536873431]],[\"^84\",[3043,\"^4S\",180,536873431]],[\"^84\",[3043,\"^3X\",3040,536873431]],[\"^84\",[3043,\"^6:\",\"\",536873431]],[\"^84\",[3043,\"^S\",536873431,536873433]],[\"^84\",[3043,\"^3;\",1775984646989,536873431]],[\"^84\",[3043,\"^2T\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873431]],[\"^84\",[3043,\"^4<\",181,536873432]],[\"^84\",[3044,\"^48\",1775984647121,536873434]],[\"^84\",[3044,\"^7@\",\"a0\",536873440]],[\"^84\",[3044,\"^4S\",180,536873434]],[\"^84\",[3044,\"^3X\",3042,536873440]],[\"^84\",[3044,\"^6:\",\"\",536873434]],[\"^84\",[3044,\"^S\",536873440,536873441]],[\"^84\",[3044,\"^3;\",1775984647121,536873434]],[\"^84\",[3044,\"^2T\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873434]],[\"^84\",[3044,\"^4<\",181,536873435]],[\"^84\",[3045,\"^48\",1775984647315,536873437]],[\"^84\",[3045,\"^7@\",\"a0l\",536873437]],[\"^84\",[3045,\"^4S\",180,536873437]],[\"^84\",[3045,\"^3X\",3040,536873437]],[\"^84\",[3045,\"^6:\",\"f\",536873442]],[\"^84\",[3045,\"^S\",536873442,536873444]],[\"^84\",[3045,\"^3;\",1775984647522,536873442]],[\"^84\",[3045,\"^2T\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873437]],[\"^84\",[3045,\"^4<\",181,536873438]],[\"^84\",[3046,\"^48\",1775984647523,536873442]],[\"^84\",[3046,\"^7@\",\"a0t\",536873442]],[\"^84\",[3046,\"^4S\",180,536873442]],[\"^84\",[3046,\"^3X\",3040,536873442]],[\"^84\",[3046,\"^6:\",\"\",536873442]],[\"^84\",[3046,\"^S\",536873442,536873444]],[\"^84\",[3046,\"^3;\",1775984647523,536873442]],[\"^84\",[3046,\"^2T\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873442]],[\"^84\",[3046,\"^4<\",181,536873443]]]]]]", :db-after "[\"~#datascript/DB\",[\"^ \",\"~:schema\",[\"^ \",\"~i32\",\"~:logseq.property/priority\",\"~i64\",\"~:logseq.property/ls-type\",\"~i96\",\"~:logseq.property.repeat/repeated?\",\"~i128\",\"~:logseq.property/view-for\",\"~:logseq.property.asset/align\",[\"^ \",\"~:db/ident\",\"^:\",\"~:db/cardinality\",\"~:db.cardinality/one\",\"~:db/index\",true],\"~:logseq.property.view/type.table\",[\"^ \",\"^;\",\"^?\"],\"~:logseq.property/ui-position\",[\"^ \",\"^;\",\"^@\",\"^<\",\"^=\",\"^>\",true],\"~:file/created-at\",[\"^ \"],\"~:logseq.property.repeat/temporal-property\",[\"^ \",\"^;\",\"^B\",\"^<\",\"^=\",\"^>\",true,\"~:db/valueType\",\"~:db.type/ref\"],\"~:logseq.property.user/name\",[\"^ \",\"^;\",\"^E\",\"^<\",\"^=\",\"^>\",true],\"~i1\",\"~:logseq.property.reaction/target\",\"~i33\",\"~:logseq.property/priority.urgent\",\"~i65\",\"~:logseq.property/color.yellow\",\"~i97\",\"~:block/closed-value-property\",\"~:logseq.property.pdf/hl-image\",[\"^ \",\"^;\",\"^M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i129\",\"~:logseq.property.user/avatar\",\"~:logseq.property.fsrs/state\",[\"^ \",\"^;\",\"^P\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.pdf/hl-color\",[\"^ \",\"^;\",\"^Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-initial-schema-version\",[\"^ \",\"^;\",\"^R\"],\"~:block/tx-id\",[\"^ \"],\"~:logseq.property/value\",[\"^ \",\"^;\",\"^T\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.week\",[\"^ \",\"^;\",\"^U\"],\"~i2\",\"~:logseq.kv/db-type\",\"~i34\",\"~:logseq.property/public?\",\"~i66\",\"~:logseq.property.asset/width\",\"~i98\",\"~:logseq.property.reaction/emoji-id\",\"~i130\",\"^E\",\"~:logseq.property.table/sorting\",[\"^ \",\"^;\",\"^12\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/choice-exclusions\",[\"^ \",\"^;\",\"^13\",\"^<\",\"~:db.cardinality/many\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.table/pinned-columns\",[\"^ \",\"^;\",\"^15\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit.day\",[\"^ \",\"^;\",\"^16\"],\"~i3\",\"~:logseq.property.repeat/checked-property\",\"~i35\",\"~:logseq.property.recycle/original-order\",\"~i67\",\"~:logseq.property.asset/remote-metadata\",\"~i99\",\"~:logseq.property/priority.high\",\"~i131\",\"~:logseq.property.asset/size\",\"~:logseq.property.sync/large-title-object\",[\"^ \",\"^;\",\"^1@\",\"^<\",\"^=\",\"^>\",true],\"~i4\",\"~:logseq.class/Pdf-annotation\",\"~i36\",\"~:block/journal-day\",\"~i68\",\"~:logseq.property.view/type.list\",\"~i100\",\"~:logseq.property/color.blue\",\"~i132\",\"~:logseq.property.class/bidirectional-property-title\",\"~:logseq.property/deadline\",[\"^ \",\"^;\",\"^1J\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/type\",[\"^ \",\"^;\",\"^1K\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.repeat/recur-unit.year\",[\"^ \",\"^;\",\"^1L\"],\"~:file/content\",[\"^ \"],\"~:user.property/p3-Bhx1XTp3\",[\"^ \",\"^;\",\"^1N\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i5\",\"~:logseq.property.asset/checksum\",\"~i37\",\"~:logseq.property.node/display-type\",\"~i69\",\"~:block/refs\",\"~i101\",\"~:logseq.property.table/ordered-columns\",\"~:logseq.property.view/sort-groups-desc?\",[\"^ \",\"^;\",\"^1V\",\"^<\",\"^=\",\"^>\",true],\"~i133\",\"~:logseq.property.class/hide-from-node\",\"~:logseq.property.table/sized-columns\",[\"^ \",\"^;\",\"^1Y\",\"^<\",\"^=\",\"^>\",true],\"~:block/alias\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^>\",true,\"^;\",\"^1Z\"],\"~:logseq.property.publish/published-url\",[\"^ \",\"^;\",\"^1[\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.asset/external-url\",[\"^ \",\"^;\",\"^20\",\"^<\",\"^=\",\"^>\",true],\"~i6\",\"^1Y\",\"^O\",[\"^ \",\"^;\",\"^O\",\"^<\",\"^=\",\"^>\",true],\"~i38\",\"~:logseq.class/Template\",\"~:user.property/p5-q5guozE4\",[\"^ \",\"^;\",\"^23\",\"^C\",\"^D\",\"^>\",true,\"^<\",\"^14\"],\"~i70\",\"^?\",\"~i102\",\"~:logseq.property/background-color\",\"~i134\",\"^B\",\"~:kv/value\",[\"^ \"],\"~:logseq.property/scalar-default-value\",[\"^ \",\"^;\",\"^29\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/type\",[\"^ \",\"^;\",\"^2:\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Asset\",[\"^ \",\"^;\",\"^2;\"],\"^19\",[\"^ \",\"^;\",\"^19\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.class/enable-bidirectional?\",[\"^ \",\"^;\",\"^2<\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/color.green\",[\"^ \",\"^;\",\"^2=\"],\"~i7\",\"^1Z\",\"~i39\",\"~:logseq.kv/local-graph-uuid\",\"~i71\",\"~:logseq.property/description\",\"~i103\",\"~:logseq.class/Cards\",\"~i135\",\"~:logseq.property.repeat/recur-unit.minute\",\"~:logseq.property.linked-references/includes\",[\"^ \",\"^;\",\"^2F\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-unit\",[\"^ \",\"^;\",\"^2G\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/checkbox-display-properties\",[\"^ \",\"^;\",\"^2H\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:block/link\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^2I\",\"^<\",\"^=\"],\"~:logseq.property.view/type\",[\"^ \",\"^;\",\"^2J\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.class/Quote-block\",[\"^ \",\"^;\",\"^2K\"],\"~:logseq.property/heading\",[\"^ \",\"^;\",\"^2L\",\"^<\",\"^=\",\"^>\",true],\"~i8\",\"~:logseq.property.view/sort-groups-by-property\",\"~i40\",\"~:logseq.property.pdf/hl-page\",\"~i72\",\"~:logseq.property.recycle/original-page\",\"~i104\",\"~:logseq.class/Page\",\"~:block/uuid\",[\"^ \",\"~:db/unique\",\"~:db.unique/identity\"],\"~i136\",\"~:logseq.property/query\",\"~:logseq.property/color.purple\",[\"^ \",\"^;\",\"^2Y\"],\"~:logseq.property.asset/external-file-name\",[\"^ \",\"^;\",\"^2Z\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.table/hidden-columns\",[\"^ \",\"^;\",\"^2[\",\"^<\",\"^14\",\"^>\",true],\"~:logseq.class/Property\",[\"^ \",\"^;\",\"^30\"],\"~:logseq.kv/graph-git-sha\",[\"^ \",\"^;\",\"^31\"],\"~i9\",\"~:logseq.property/status.doing\",\"~i41\",\"^2H\",\"~i73\",\"~:logseq.property.linked-references/excludes\",\"~i105\",\"^U\",\"^2S\",[\"^ \",\"^;\",\"^2S\"],\"~i137\",\"~:logseq.property.user/email\",\"~:logseq.kv/graph-remote?\",[\"^ \",\"^;\",\"^39\"],\"^2?\",[\"^ \",\"^;\",\"^2?\"],\"~:logseq.kv/graph-rtc-e2ee?\",[\"^ \",\"^;\",\"^3:\"],\"~:block/updated-at\",[\"^ \",\"^>\",true,\"^;\",\"^3;\",\"^<\",\"^=\"],\"^1G\",[\"^ \",\"^;\",\"^1G\"],\"~i10\",\"~:logseq.class/Math-block\",\"~i42\",\"^2Y\",\"~i74\",\"~:logseq.property.history/scalar-value\",\"~i106\",\"~:logseq.property.tldraw/shape\",\"~i138\",\"~:block/collapsed?\",\"^1E\",[\"^ \",\"^;\",\"^1E\"],\"^F\",[\"^ \",\"^;\",\"^F\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i11\",\"~:logseq.property/status.canceled\",\"~:file/size\",[\"^ \"],\"~i43\",\"^P\",\"~:logseq.property/status\",[\"^ \",\"^;\",\"^3I\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i75\",\"^1J\",\"~:logseq.property.class/extends\",[\"^ \",\"^;\",\"^3K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~i107\",\"~:logseq.property.asset/height\",\"~i139\",\"~:logseq.property.repeat/recur-unit.hour\",\"~:logseq.property/hide-empty-value\",[\"^ \",\"^;\",\"^3P\",\"^<\",\"^=\",\"^>\",true],\"^2M\",[\"^ \",\"^;\",\"^2M\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1S\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^1S\",\"^>\",true],\"~i2475\",\"~:user.class/tag2-GCc7Ko89\",\"~i619\",\"~:user.class/tag1-bGi52uW6\",\"~:logseq.class/Card\",[\"^ \",\"^;\",\"^3U\"],\"~:user.property/p2-JBrZedg-\",[\"^ \",\"^;\",\"^3V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^10\",[\"^ \",\"^;\",\"^10\",\"^<\",\"^=\",\"^>\",true],\"~i12\",\"~:block/parent\",\"~i44\",\"^1V\",\"~i76\",\"^2=\",\"~i108\",\"~:logseq.property.code/lang\",\"~i140\",\"~:logseq.property.tldraw/page\",\"~:logseq.property.view/group-by-property\",[\"^ \",\"^;\",\"^43\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/view-context\",[\"^ \",\"^;\",\"^44\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/schema-version\",[\"^ \",\"^;\",\"^45\"],\"^2O\",[\"^ \",\"^;\",\"^2O\",\"^<\",\"^=\",\"^>\",true],\"~i13\",\"^2G\",\"~i45\",\"~:block/created-at\",\"^L\",[\"^ \",\"^C\",\"^D\",\"^;\",\"^L\",\"^>\",true,\"^<\",\"^14\"],\"~i77\",\"~:logseq.property/choice-classes\",\"~i109\",\"~:logseq.property/created-by-ref\",\"^1O\",[\"^ \",\"^;\",\"^1O\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/publishing-public?\",[\"^ \",\"^;\",\"^4=\",\"^<\",\"^=\",\"^>\",true],\"~i141\",\"^31\",\"~:user.property/p10-QpfPhdWj\",[\"^ \",\"^>\",true,\"^C\",\"^D\",\"^<\",\"^=\",\"^;\",\"^4?\"],\"^1Q\",[\"^ \",\"^;\",\"^1Q\",\"^<\",\"^=\",\"^>\",true],\"~i2477\",\"^23\",\"~:logseq.property/status.backlog\",[\"^ \",\"^;\",\"^4A\"],\"~i14\",\"~:logseq.class/Tag\",\"~:file/last-modified-at\",[\"^ \"],\"~i46\",\"^2;\",\"~i78\",\"^2K\",\"~i110\",\"^13\",\"~i142\",\"~:logseq.class/Query\",\"~:logseq.kv/recycle-last-gc-at\",[\"^ \",\"^;\",\"^4J\"],\"~:logseq.property.class/properties\",[\"^ \",\"^;\",\"^4K\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/used-template\",[\"^ \",\"^;\",\"^4L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2702\",\"^4?\",\"~:logseq.property/enable-history?\",[\"^ \",\"^;\",\"^4N\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.kv/graph-last-gc-at\",[\"^ \",\"^;\",\"^4O\"],\"~i15\",\"~:logseq.kv/graph-created-at\",\"~i47\",\"~:block/page\",\"~i79\",\"^29\",\"^3=\",[\"^ \",\"^;\",\"^3=\"],\"~i111\",\"^R\",\"~i143\",\"~:logseq.property/choice-checkbox-state\",\"^48\",[\"^ \",\"^>\",true,\"^;\",\"^48\",\"^<\",\"^=\"],\"^3@\",[\"^ \",\"^;\",\"^3@\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property/page-tags\",[\"^ \",\"^;\",\"^4X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/scheduled\",[\"^ \",\"^;\",\"^4Y\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Task\",[\"^ \",\"^;\",\"^4Z\"],\"^3D\",[\"^ \",\"^;\",\"^3D\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.property.asset/last-visit-page\",[\"^ \",\"^;\",\"^4[\",\"^<\",\"^=\",\"^>\",true],\"^42\",[\"^ \",\"^;\",\"^42\",\"^<\",\"^=\",\"^>\",true],\"~i16\",\"~:logseq.property/icon\",\"~i48\",\"^1L\",\"~i80\",\"^4[\",\"~i112\",\"~:logseq.property.journal/title-format\",\"~i144\",\"^3P\",\"~:logseq.property/built-in?\",[\"^ \",\"^;\",\"^57\",\"^<\",\"^=\",\"^>\",true],\"~i17\",\"^2Z\",\"~i49\",\"~:logseq.property/exclude-from-graph-view\",\"~i81\",\"^4L\",\"^1X\",[\"^ \",\"^;\",\"^1X\",\"^<\",\"^=\",\"^>\",true],\"~i113\",\"^2F\",\"~i145\",\"^3;\",\"^35\",[\"^ \",\"^;\",\"^35\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.view/feature-type\",[\"^ \",\"^;\",\"^5>\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Code-block\",[\"^ \",\"^;\",\"^5?\"],\"^4W\",[\"^ \",\"^;\",\"^4W\",\"^<\",\"^=\",\"^>\",true],\"^17\",[\"^ \",\"^;\",\"^17\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i2705\",\"^4O\",\"~:logseq.property.asset/resize-metadata\",[\"^ \",\"^;\",\"^5A\",\"^<\",\"^=\",\"^>\",true],\"^5\",[\"^ \",\"^;\",\"^5\",\"^<\",\"^=\",\"^>\",true],\"^2C\",[\"^ \",\"^;\",\"^2C\"],\"^38\",[\"^ \",\"^;\",\"^38\",\"^<\",\"^=\",\"^>\",true],\"^9\",[\"^ \",\"^;\",\"^9\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^Z\",[\"^ \",\"^;\",\"^Z\",\"^<\",\"^=\",\"^>\",true],\"~i18\",\"~:logseq.property.history/block\",\"^2X\",[\"^ \",\"^;\",\"^2X\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i50\",\"^15\",\"~i82\",\"~:logseq.property.repeat/recur-unit.month\",\"~i114\",\"~:logseq.property/status.todo\",\"~i146\",\"^20\",\"~:logseq.property/hide?\",[\"^ \",\"^;\",\"^5J\",\"^<\",\"^=\",\"^>\",true],\"^26\",[\"^ \",\"^;\",\"^26\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.repeat/recur-frequency\",[\"^ \",\"^;\",\"^5K\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/default-value\",[\"^ \",\"^;\",\"^5L\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^1C\",[\"^ \",\"^>\",true,\"^;\",\"^1C\",\"^<\",\"^=\"],\"~i2706\",\"^4J\",\"~:user.property/p1-Ka7c_Z9D\",[\"^ \",\"^;\",\"^5N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/color.red\",[\"^ \",\"^;\",\"^5O\"],\"~i19\",\"^Q\",\"~:logseq.class/Root\",[\"^ \",\"^;\",\"^5Q\"],\"~i51\",\"^1@\",\"~i83\",\"~:logseq.property.history/ref-value\",\"~i115\",\"~:logseq.property.history/property\",\"~i147\",\"~:logseq.property/template-applied-to\",\"^2E\",[\"^ \",\"^;\",\"^2E\"],\"~:logseq.property/empty-placeholder\",[\"^ \",\"^;\",\"^5Y\"],\"^40\",[\"^ \",\"^;\",\"^40\",\"^<\",\"^=\",\"^>\",true],\"~i20\",\"^2<\",\"~i52\",\"^45\",\"~i84\",\"~:logseq.property.table/filters\",\"~i116\",\"~:logseq.property.recycle/original-parent\",\"^4<\",[\"^ \",\"^;\",\"^4<\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.kv/graph-uuid\",[\"^ \",\"^;\",\"^64\"],\"~i148\",\"~:logseq.property/status.in-review\",\"~i2196\",[\"^ \"],\"^1=\",[\"^ \",\"^;\",\"^1=\"],\"~:logseq.property/priority.low\",[\"^ \",\"^;\",\"^68\"],\"~:block/tags\",[\"^ \",\"^C\",\"^D\",\"^<\",\"^14\",\"^;\",\"^69\",\"^>\",true],\"~:block/title\",[\"^ \",\"^>\",true,\"^;\",\"^6:\",\"^<\",\"^=\"],\"~:user.property/p4-Et0d5LxL\",[\"^ \",\"^;\",\"^6;\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^3B\",[\"^ \",\"^;\",\"^3B\",\"^<\",\"^=\",\"^>\",true],\"~:logseq.class/Whiteboard\",[\"^ \",\"^;\",\"^6<\"],\"~i21\",\"^6:\",\"~i53\",\"~:logseq.property/created-from-property\",\"~i85\",\"~:logseq.class/Journal\",\"~i117\",\"~:logseq.property.pdf/hl-value\",\"~i149\",\"^1[\",\"^6?\",[\"^ \",\"^;\",\"^6?\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i565\",\"^5N\",\"^51\",[\"^ \",\"^;\",\"^51\",\"^<\",\"^=\",\"^>\",true],\"^1I\",[\"^ \",\"^;\",\"^1I\",\"^<\",\"^=\",\"^>\",true],\"^H\",[\"^ \",\"^;\",\"^H\"],\"^4:\",[\"^ \",\"^;\",\"^4:\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^1?\",[\"^ \",\"^;\",\"^1?\",\"^<\",\"^=\",\"^>\",true],\"~i22\",\"~:logseq.property/classes\",\"~i54\",\"^4=\",\"~i86\",\"^57\",\"~i118\",\"^43\",\"~i150\",\"~:logseq.property.view/type.gallery\",\"~i182\",\"^64\",\"^6G\",[\"^ \",\"^;\",\"^6G\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property/asset\",[\"^ \",\"^;\",\"^6N\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^4Q\",[\"^ \",\"^;\",\"^4Q\"],\"~i23\",\"^39\",\"~i55\",\"~:logseq.property/status.done\",\"~:logseq.property/deleted-at\",[\"^ \",\"^;\",\"^6R\",\"^<\",\"^=\",\"^>\",true],\"~i87\",\"^6<\",\"~i119\",\"^5J\",\"~i151\",\"^5Y\",\"~i183\",\"^3:\",\"~:logseq.property/order-list-type\",[\"^ \",\"^;\",\"^6W\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5:\",[\"^ \",\"^;\",\"^5:\",\"^<\",\"^=\",\"^>\",true],\"~i24\",\"^5A\",\"~:logseq.property/deleted-by-ref\",[\"^ \",\"^;\",\"^6Y\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i56\",\"^T\",\"~:logseq.property.pdf/hl-type\",[\"^ \",\"^;\",\"^6[\",\"^<\",\"^=\",\"^>\",true],\"~i88\",\"^:\",\"~i120\",\"^2I\",\"~i152\",\"^4Z\",\"^;\",[\"^ \",\"^2U\",\"^2V\"],\"^6L\",[\"^ \",\"^;\",\"^6L\"],\"^2A\",[\"^ \",\"^;\",\"^2A\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i568\",\"^3V\",\"^5C\",[\"^ \",\"^;\",\"^5C\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i600\",\"^6;\",\"^3M\",[\"^ \",\"^;\",\"^3M\",\"^<\",\"^=\",\"^>\",true],\"^3X\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^3X\",\"^<\",\"^=\"],\"~i25\",\"^6R\",\"~i57\",\"^2:\",\"~i89\",\"^5Q\",\"^2Q\",[\"^ \",\"^;\",\"^2Q\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~i121\",\"^4K\",\"~i153\",\"~:logseq.property/priority.medium\",\"~i569\",\"^1N\",\"^6A\",[\"^ \",\"^;\",\"^6A\"],\"~i26\",\"^3I\",\"~i58\",\"^69\",\"~i90\",\"^2L\",\"^3O\",[\"^ \",\"^;\",\"^3O\"],\"^J\",[\"^ \",\"^;\",\"^J\"],\"~i122\",\"~:block/order\",\"~i154\",\"^1K\",\"^63\",[\"^ \",\"^;\",\"^63\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^7:\",[\"^ \",\"^;\",\"^7:\"],\"^5V\",[\"^ \",\"^;\",\"^5V\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^6C\",[\"^ \",\"^;\",\"^6C\",\"^<\",\"^=\",\"^>\",true],\"^4I\",[\"^ \",\"^;\",\"^4I\"],\"~i27\",\"^3U\",\"~i59\",\"^6W\",\"~i91\",\"^M\",\"~i123\",\"^4N\",\"~i155\",\"^4Y\",\"^1;\",[\"^ \",\"^;\",\"^1;\",\"^<\",\"^=\",\"^>\",true],\"^3R\",[\"^ \",\"^;\",\"^3R\"],\"^5F\",[\"^ \",\"^;\",\"^5F\"],\"^7@\",[\"^ \",\"^>\",true,\"^;\",\"^7@\",\"^<\",\"^=\"],\"^3\",[\"^ \",\"^;\",\"^3\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"^5T\",[\"^ \",\"^;\",\"^5T\",\"^<\",\"^=\",\"^>\",true,\"^C\",\"^D\"],\"~:logseq.property.fsrs/due\",[\"^ \",\"^;\",\"^7G\",\"^<\",\"^=\",\"^>\",true],\"~i28\",\"^5?\",\"^32\",[\"^ \",\"^;\",\"^32\"],\"~i60\",\"^@\",\"~i92\",\"^44\",\"~i124\",\"^30\",\"^4S\",[\"^ \",\"^C\",\"^D\",\"^>\",true,\"^;\",\"^4S\",\"^<\",\"^=\"],\"~i156\",\"^5L\",\"~:block/name\",[\"^ \",\"^>\",true],\"^1A\",[\"^ \",\"^;\",\"^1A\"],\"^1U\",[\"^ \",\"^;\",\"^1U\",\"^<\",\"^=\",\"^>\",true],\"~:file/path\",[\"^ \",\"^2U\",\"^2V\"],\"~i29\",\"^7G\",\"~i61\",\"^16\",\"~i93\",\"^5>\",\"~i125\",\"^4X\",\"~i157\",\"^6Y\",\"^7\",[\"^ \",\"^;\",\"^7\",\"^<\",\"^=\",\"^>\",true],\"^4C\",[\"^ \",\"^;\",\"^4C\"],\"^66\",[\"^ \",\"^;\",\"^66\"],\"^X\",[\"^ \",\"^;\",\"^X\",\"^<\",\"^=\",\"^>\",true],\"~i30\",\"^6N\",\"~i62\",\"^6[\",\"~i94\",\"^68\",\"~i126\",\"^4A\",\"~i158\",\"^3K\",\"^3F\",[\"^ \",\"^;\",\"^3F\"],\"^5X\",[\"^ \",\"^;\",\"^5X\",\"^<\",\"^14\",\"^>\",true,\"^C\",\"^D\"],\"^22\",[\"^ \",\"^;\",\"^22\"],\"^55\",[\"^ \",\"^;\",\"^55\",\"^<\",\"^=\",\"^>\",true],\"^6Q\",[\"^ \",\"^;\",\"^6Q\"],\"~i31\",\"^5O\",\"~i63\",\"^2[\",\"^V\",[\"^ \",\"^;\",\"^V\"],\"~i95\",\"^5K\",\"~i127\",\"^2J\",\"~i159\",\"^12\",\"^61\",[\"^ \",\"^;\",\"^61\",\"^<\",\"^=\",\"^>\",true],\"^3T\",[\"^ \",\"^;\",\"^3T\"],\"^5H\",[\"^ \",\"^;\",\"^5H\"]],\"~:datoms\",[\"~#list\",[[\"~#datascript/Datom\",[1,\"^48\",1775980635495,536870913]],[\"^84\",[1,\"^7M\",\"reaction target\",536870913]],[\"^84\",[1,\"^7@\",\"d3BHM\",536870913]],[\"^84\",[1,\"^69\",124,536870913]],[\"^84\",[1,\"^6:\",\"Reaction target\",536870913]],[\"^84\",[1,\"^3;\",1775980635495,536870913]],[\"^84\",[1,\"^2T\",\"~u00000002-1127-5718-5000-000000000000\",536870913]],[\"^84\",[1,\"^<\",\"^=\",536870913]],[\"^84\",[1,\"^;\",\"^F\",536870913]],[\"^84\",[1,\"^>\",true,536870913]],[\"^84\",[1,\"^C\",\"^D\",536870913]],[\"^84\",[1,\"^57\",true,536870913]],[\"^84\",[1,\"^5J\",true,536870913]],[\"^84\",[1,\"^X\",false,536870913]],[\"^84\",[1,\"^1K\",\"~:node\",536870913]],[\"^84\",[2,\"^;\",\"^V\",536870913]],[\"^84\",[2,\"^28\",\"db\",536870913]],[\"^84\",[3,\"^48\",1775980635492,536870913]],[\"^84\",[3,\"^7M\",\"repeating checked property\",536870913]],[\"^84\",[3,\"^7@\",\"d3BGY\",536870913]],[\"^84\",[3,\"^69\",124,536870913]],[\"^84\",[3,\"^6:\",\"Repeating Checked Property\",536870913]],[\"^84\",[3,\"^3;\",1775980635492,536870913]],[\"^84\",[3,\"^2T\",\"~u00000002-1866-3655-5300-000000000000\",536870913]],[\"^84\",[3,\"^<\",\"^=\",536870913]],[\"^84\",[3,\"^;\",\"^17\",536870913]],[\"^84\",[3,\"^>\",true,536870913]],[\"^84\",[3,\"^C\",\"^D\",536870913]],[\"^84\",[3,\"^57\",true,536870913]],[\"^84\",[3,\"^5J\",true,536870913]],[\"^84\",[3,\"^1K\",\"~:property\",536870913]],[\"^84\",[4,\"^48\",1775980635496,536870913]],[\"^84\",[4,\"^7M\",\"pdf annotation\",536870913]],[\"^84\",[4,\"^69\",14,536870913]],[\"^84\",[4,\"^6:\",\"PDF Annotation\",536870913]],[\"^84\",[4,\"^3;\",1775980635496,536870913]],[\"^84\",[4,\"^2T\",\"~u00000002-5049-5962-0000-000000000000\",536870913]],[\"^84\",[4,\"^;\",\"^1A\",536870913]],[\"^84\",[4,\"^57\",true,536870913]],[\"^84\",[4,\"^3K\",89,536870913]],[\"^84\",[4,\"^1X\",true,536870913]],[\"^84\",[4,\"^4K\",19,536870913]],[\"^84\",[4,\"^4K\",30,536870913]],[\"^84\",[4,\"^4K\",40,536870913]],[\"^84\",[4,\"^4K\",62,536870913]],[\"^84\",[4,\"^4K\",64,536870913]],[\"^84\",[4,\"^4K\",91,536870913]],[\"^84\",[4,\"^4K\",117,536870913]],[\"^84\",[5,\"^48\",1775980635493,536870913]],[\"^84\",[5,\"^7M\",\"file checksum\",536870913]],[\"^84\",[5,\"^7@\",\"d3BGy\",536870913]],[\"^84\",[5,\"^69\",124,536870913]],[\"^84\",[5,\"^6:\",\"File checksum\",536870913]],[\"^84\",[5,\"^3;\",1775980635493,536870913]],[\"^84\",[5,\"^2T\",\"~u00000002-1011-4169-7900-000000000000\",536870913]],[\"^84\",[5,\"^<\",\"^=\",536870913]],[\"^84\",[5,\"^;\",\"^1O\",536870913]],[\"^84\",[5,\"^>\",true,536870913]],[\"^84\",[5,\"^57\",true,536870913]],[\"^84\",[5,\"^5J\",true,536870913]],[\"^84\",[5,\"^X\",false,536870913]],[\"^84\",[5,\"^1K\",\"~:string\",536870913]],[\"^84\",[6,\"^48\",1775980635493,536870913]],[\"^84\",[6,\"^7M\",\"view columns settings\",536870913]],[\"^84\",[6,\"^7@\",\"d3BGp\",536870913]],[\"^84\",[6,\"^69\",124,536870913]],[\"^84\",[6,\"^6:\",\"View columns settings\",536870913]],[\"^84\",[6,\"^3;\",1775980635493,536870913]],[\"^84\",[6,\"^2T\",\"~u00000002-1675-5105-5500-000000000000\",536870913]],[\"^84\",[6,\"^<\",\"^=\",536870913]],[\"^84\",[6,\"^;\",\"^1Y\",536870913]],[\"^84\",[6,\"^>\",true,536870913]],[\"^84\",[6,\"^57\",true,536870913]],[\"^84\",[6,\"^5J\",true,536870913]],[\"^84\",[6,\"^X\",false,536870913]],[\"^84\",[6,\"^1K\",\"~:map\",536870913]],[\"^84\",[7,\"^48\",1775980635487,536870913]],[\"^84\",[7,\"^7M\",\"alias\",536870913]],[\"^84\",[7,\"^7@\",\"d3BFF\",536870913]],[\"^84\",[7,\"^69\",124,536870913]],[\"^84\",[7,\"^6:\",\"Alias\",536870913]],[\"^84\",[7,\"^3;\",1775980635487,536870913]],[\"^84\",[7,\"^2T\",\"~u00000002-2112-6446-9900-000000000000\",536870913]],[\"^84\",[7,\"^<\",\"^14\",536870913]],[\"^84\",[7,\"^;\",\"^1Z\",536870913]],[\"^84\",[7,\"^>\",true,536870913]],[\"^84\",[7,\"^C\",\"^D\",536870913]],[\"^84\",[7,\"^57\",true,536870913]],[\"^84\",[7,\"^X\",true,536870913]],[\"^84\",[7,\"^1K\",\"~:page\",536870913]],[\"^84\",[7,\"^44\",\"^89\",536870913]],[\"^84\",[8,\"^48\",1775980635492,536870913]],[\"^84\",[8,\"^7M\",\"view sort groups by\",536870913]],[\"^84\",[8,\"^7@\",\"d3BGj\",536870913]],[\"^84\",[8,\"^69\",124,536870913]],[\"^84\",[8,\"^6:\",\"View sort groups by\",536870913]],[\"^84\",[8,\"^3;\",1775980635492,536870913]],[\"^84\",[8,\"^2T\",\"~u00000002-6253-4002-1000-000000000000\",536870913]],[\"^84\",[8,\"^<\",\"^=\",536870913]],[\"^84\",[8,\"^;\",\"^2M\",536870913]],[\"^84\",[8,\"^>\",true,536870913]],[\"^84\",[8,\"^C\",\"^D\",536870913]],[\"^84\",[8,\"^57\",true,536870913]],[\"^84\",[8,\"^5J\",true,536870913]],[\"^84\",[8,\"^X\",false,536870913]],[\"^84\",[8,\"^1K\",\"^86\",536870913]],[\"^84\",[9,\"^L\",26,536870913]],[\"^84\",[9,\"^48\",1775980635491,536870913]],[\"^84\",[9,\"^7@\",\"d3BGA\",536870913]],[\"^84\",[9,\"^4S\",26,536870913]],[\"^84\",[9,\"^3X\",26,536870913]],[\"^84\",[9,\"^6:\",\"Doing\",536870913]],[\"^84\",[9,\"^3;\",1775980635491,536870913]],[\"^84\",[9,\"^2T\",\"~u00000002-1840-1229-0800-000000000000\",536870913]],[\"^84\",[9,\"^;\",\"^32\",536870913]],[\"^84\",[9,\"^57\",true,536870913]],[\"^84\",[9,\"^6?\",26,536870913]],[\"^84\",[9,\"^51\",[\"^ \",\"~:type\",\"~:tabler-icon\",\"~:id\",\"InProgress50\"],536870913]],[\"^84\",[10,\"^48\",1775980635496,536870913]],[\"^84\",[10,\"^7M\",\"math\",536870913]],[\"^84\",[10,\"^69\",14,536870913]],[\"^84\",[10,\"^6:\",\"Math\",536870913]],[\"^84\",[10,\"^3;\",1775980635496,536870913]],[\"^84\",[10,\"^2T\",\"~u00000002-2038-9631-2100-000000000000\",536870913]],[\"^84\",[10,\"^;\",\"^3=\",536870913]],[\"^84\",[10,\"^57\",true,536870913]],[\"^84\",[10,\"^3K\",89,536870913]],[\"^84\",[10,\"^1X\",true,536870913]],[\"^84\",[10,\"^4K\",37,536870913]],[\"^84\",[11,\"^L\",26,536870913]],[\"^84\",[11,\"^48\",1775980635491,536870913]],[\"^84\",[11,\"^7@\",\"d3BGD\",536870913]],[\"^84\",[11,\"^4S\",26,536870913]],[\"^84\",[11,\"^3X\",26,536870913]],[\"^84\",[11,\"^6:\",\"Canceled\",536870913]],[\"^84\",[11,\"^3;\",1775980635491,536870913]],[\"^84\",[11,\"^2T\",\"~u00000002-7526-4326-2000-000000000000\",536870913]],[\"^84\",[11,\"^;\",\"^3F\",536870913]],[\"^84\",[11,\"^57\",true,536870913]],[\"^84\",[11,\"^6?\",26,536870913]],[\"^84\",[11,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Cancelled\"],536870913]],[\"^84\",[12,\"^48\",1775980635488,536870913]],[\"^84\",[12,\"^7M\",\"node parent\",536870913]],[\"^84\",[12,\"^7@\",\"d3BFH\",536870913]],[\"^84\",[12,\"^69\",124,536870913]],[\"^84\",[12,\"^6:\",\"Node parent\",536870913]],[\"^84\",[12,\"^3;\",1775980635488,536870913]],[\"^84\",[12,\"^2T\",\"~u00000002-9183-0906-4000-000000000000\",536870913]],[\"^84\",[12,\"^<\",\"^=\",536870913]],[\"^84\",[12,\"^;\",\"^3X\",536870913]],[\"^84\",[12,\"^>\",true,536870913]],[\"^84\",[12,\"^C\",\"^D\",536870913]],[\"^84\",[12,\"^57\",true,536870913]],[\"^84\",[12,\"^5J\",true,536870913]],[\"^84\",[12,\"^X\",false,536870913]],[\"^84\",[12,\"^1K\",\"~:entity\",536870913]],[\"^84\",[13,\"^48\",1775980635491,536870913]],[\"^84\",[13,\"^7M\",\"repeating recur unit\",536870913]],[\"^84\",[13,\"^7@\",\"d3BGP\",536870913]],[\"^84\",[13,\"^69\",124,536870913]],[\"^84\",[13,\"^6:\",\"Repeating recur unit\",536870913]],[\"^84\",[13,\"^3;\",1775980635491,536870913]],[\"^84\",[13,\"^2T\",\"~u00000002-6903-0624-7000-000000000000\",536870913]],[\"^84\",[13,\"^<\",\"^=\",536870913]],[\"^84\",[13,\"^;\",\"^2G\",536870913]],[\"^84\",[13,\"^>\",true,536870913]],[\"^84\",[13,\"^C\",\"^D\",536870913]],[\"^84\",[13,\"^57\",true,536870913]],[\"^84\",[13,\"^5L\",61,536870913]],[\"^84\",[13,\"^3P\",true,536870913]],[\"^84\",[13,\"^X\",false,536870913]],[\"^84\",[13,\"^1K\",\"~:default\",536870913]],[\"^84\",[14,\"^48\",1775980635496,536870913]],[\"^84\",[14,\"^7M\",\"tag\",536870913]],[\"^84\",[14,\"^69\",14,536870913]],[\"^84\",[14,\"^6:\",\"Tag\",536870913]],[\"^84\",[14,\"^3;\",1775980635496,536870913]],[\"^84\",[14,\"^2T\",\"~u00000002-5389-0208-3000-000000000000\",536870913]],[\"^84\",[14,\"^;\",\"^4C\",536870913]],[\"^84\",[14,\"^57\",true,536870913]],[\"^84\",[14,\"^3K\",89,536870913]],[\"^84\",[15,\"^;\",\"^4Q\",536870913]],[\"^84\",[15,\"^28\",1775980635485,536870913]],[\"^84\",[16,\"^48\",1775980635492,536870913]],[\"^84\",[16,\"^7M\",\"icon\",536870913]],[\"^84\",[16,\"^7@\",\"d3BGZ\",536870913]],[\"^84\",[16,\"^69\",124,536870913]],[\"^84\",[16,\"^6:\",\"Icon\",536870913]],[\"^84\",[16,\"^3;\",1775980635492,536870913]],[\"^84\",[16,\"^2T\",\"~u00000002-5891-2328-5000-000000000000\",536870913]],[\"^84\",[16,\"^<\",\"^=\",536870913]],[\"^84\",[16,\"^;\",\"^51\",536870913]],[\"^84\",[16,\"^>\",true,536870913]],[\"^84\",[16,\"^57\",true,536870913]],[\"^84\",[16,\"^1K\",\"^88\",536870913]],[\"^84\",[17,\"^48\",1775980635493,536870913]],[\"^84\",[17,\"^7M\",\"external file name\",536870913]],[\"^84\",[17,\"^7@\",\"d3BGu\",536870913]],[\"^84\",[17,\"^69\",124,536870913]],[\"^84\",[17,\"^6:\",\"External file name\",536870913]],[\"^84\",[17,\"^3;\",1775980635493,536870913]],[\"^84\",[17,\"^2T\",\"~u00000002-3995-2972-0000-000000000000\",536870913]],[\"^84\",[17,\"^<\",\"^=\",536870913]],[\"^84\",[17,\"^;\",\"^2Z\",536870913]],[\"^84\",[17,\"^>\",true,536870913]],[\"^84\",[17,\"^57\",true,536870913]],[\"^84\",[17,\"^5J\",true,536870913]],[\"^84\",[17,\"^X\",false,536870913]],[\"^84\",[17,\"^1K\",\"^87\",536870913]],[\"^84\",[18,\"^48\",1775980635494,536870913]],[\"^84\",[18,\"^7M\",\"history block\",536870913]],[\"^84\",[18,\"^7@\",\"d3BHB\",536870913]],[\"^84\",[18,\"^69\",124,536870913]],[\"^84\",[18,\"^6:\",\"History block\",536870913]],[\"^84\",[18,\"^3;\",1775980635494,536870913]],[\"^84\",[18,\"^2T\",\"~u00000002-1142-5541-6000-000000000000\",536870913]],[\"^84\",[18,\"^<\",\"^=\",536870913]],[\"^84\",[18,\"^;\",\"^5C\",536870913]],[\"^84\",[18,\"^>\",true,536870913]],[\"^84\",[18,\"^C\",\"^D\",536870913]],[\"^84\",[18,\"^57\",true,536870913]],[\"^84\",[18,\"^5J\",true,536870913]],[\"^84\",[18,\"^1K\",\"^8=\",536870913]],[\"^84\",[19,\"^48\",1775980635490,536870913]],[\"^84\",[19,\"^7M\",\"annotation color\",536870913]],[\"^84\",[19,\"^7@\",\"d3BFo\",536870913]],[\"^84\",[19,\"^69\",124,536870913]],[\"^84\",[19,\"^6:\",\"Annotation color\",536870913]],[\"^84\",[19,\"^3;\",1775980635490,536870913]],[\"^84\",[19,\"^2T\",\"~u00000002-6747-9388-7000-000000000000\",536870913]],[\"^84\",[19,\"^<\",\"^=\",536870913]],[\"^84\",[19,\"^;\",\"^Q\",536870913]],[\"^84\",[19,\"^>\",true,536870913]],[\"^84\",[19,\"^C\",\"^D\",536870913]],[\"^84\",[19,\"^57\",true,536870913]],[\"^84\",[19,\"^5J\",true,536870913]],[\"^84\",[19,\"^1K\",\"^8>\",536870913]],[\"^84\",[20,\"^48\",1775980635489,536870913]],[\"^84\",[20,\"^7M\",\"enable bidirectional properties\",536870913]],[\"^84\",[20,\"^7@\",\"d3BFb\",536870913]],[\"^84\",[20,\"^69\",124,536870913]],[\"^84\",[20,\"^6:\",\"Enable bidirectional properties\",536870913]],[\"^84\",[20,\"^3;\",1775980635489,536870913]],[\"^84\",[20,\"^2T\",\"~u00000002-2917-8613-8000-000000000000\",536870913]],[\"^84\",[20,\"^<\",\"^=\",536870913]],[\"^84\",[20,\"^;\",\"^2<\",536870913]],[\"^84\",[20,\"^>\",true,536870913]],[\"^84\",[20,\"^57\",true,536870913]],[\"^84\",[20,\"^2A\",169,536870913]],[\"^84\",[20,\"^X\",true,536870913]],[\"^84\",[20,\"^1K\",\"~:checkbox\",536870913]],[\"^84\",[20,\"^44\",\"~:class\",536870913]],[\"^84\",[21,\"^48\",1775980635488,536870913]],[\"^84\",[21,\"^7M\",\"node title\",536870913]],[\"^84\",[21,\"^7@\",\"d3BFN\",536870913]],[\"^84\",[21,\"^69\",124,536870913]],[\"^84\",[21,\"^6:\",\"Node title\",536870913]],[\"^84\",[21,\"^3;\",1775980635488,536870913]],[\"^84\",[21,\"^2T\",\"~u00000002-7104-4568-4000-000000000000\",536870913]],[\"^84\",[21,\"^<\",\"^=\",536870913]],[\"^84\",[21,\"^;\",\"^6:\",536870913]],[\"^84\",[21,\"^>\",true,536870913]],[\"^84\",[21,\"^57\",true,536870913]],[\"^84\",[21,\"^5J\",true,536870913]],[\"^84\",[21,\"^X\",false,536870913]],[\"^84\",[21,\"^1K\",\"^87\",536870913]],[\"^84\",[22,\"^48\",1775980635487,536870913]],[\"^84\",[22,\"^7M\",\"property classes\",536870913]],[\"^84\",[22,\"^7@\",\"d3BFD\",536870913]],[\"^84\",[22,\"^69\",124,536870913]],[\"^84\",[22,\"^6:\",\"Property classes\",536870913]],[\"^84\",[22,\"^3;\",1775980635487,536870913]],[\"^84\",[22,\"^2T\",\"~u00000002-9137-5048-6000-000000000000\",536870913]],[\"^84\",[22,\"^<\",\"^14\",536870913]],[\"^84\",[22,\"^;\",\"^6G\",536870913]],[\"^84\",[22,\"^>\",true,536870913]],[\"^84\",[22,\"^C\",\"^D\",536870913]],[\"^84\",[22,\"^57\",true,536870913]],[\"^84\",[22,\"^5J\",true,536870913]],[\"^84\",[22,\"^X\",false,536870913]],[\"^84\",[22,\"^1K\",\"^8=\",536870913]],[\"^84\",[23,\"^;\",\"^39\",536870913]],[\"^84\",[23,\"^28\",true,536870913]],[\"^84\",[24,\"^48\",1775980635494,536870913]],[\"^84\",[24,\"^7M\",\"asset resize metadata\",536870913]],[\"^84\",[24,\"^7@\",\"d3BH2\",536870913]],[\"^84\",[24,\"^69\",124,536870913]],[\"^84\",[24,\"^6:\",\"Asset resize metadata\",536870913]],[\"^84\",[24,\"^3;\",1775980635494,536870913]],[\"^84\",[24,\"^2T\",\"~u00000002-1297-5230-5500-000000000000\",536870913]],[\"^84\",[24,\"^<\",\"^=\",536870913]],[\"^84\",[24,\"^;\",\"^5A\",536870913]],[\"^84\",[24,\"^>\",true,536870913]],[\"^84\",[24,\"^57\",true,536870913]],[\"^84\",[24,\"^5J\",true,536870913]],[\"^84\",[24,\"^X\",false,536870913]],[\"^84\",[24,\"^1K\",\"^88\",536870913]],[\"^84\",[25,\"^48\",1775980635495,536870913]],[\"^84\",[25,\"^7M\",\"deleted at\",536870913]],[\"^84\",[25,\"^7@\",\"d3BHG\",536870913]],[\"^84\",[25,\"^69\",124,536870913]],[\"^84\",[25,\"^6:\",\"Deleted at\",536870913]],[\"^84\",[25,\"^3;\",1775980635495,536870913]],[\"^84\",[25,\"^2T\",\"~u00000002-6852-1375-1000-000000000000\",536870913]],[\"^84\",[25,\"^<\",\"^=\",536870913]],[\"^84\",[25,\"^;\",\"^6R\",536870913]],[\"^84\",[25,\"^>\",true,536870913]],[\"^84\",[25,\"^57\",true,536870913]],[\"^84\",[25,\"^5J\",true,536870913]],[\"^84\",[25,\"^X\",false,536870913]],[\"^84\",[25,\"^1K\",\"~:datetime\",536870913]],[\"^84\",[26,\"^48\",1775980635491,536870913]],[\"^84\",[26,\"^7M\",\"status\",536870913]],[\"^84\",[26,\"^7@\",\"d3BG7\",536870913]],[\"^84\",[26,\"^69\",124,536870913]],[\"^84\",[26,\"^6:\",\"Status\",536870913]],[\"^84\",[26,\"^3;\",1775980635491,536870913]],[\"^84\",[26,\"^2T\",\"~u00000002-9072-1685-3000-000000000000\",536870913]],[\"^84\",[26,\"^<\",\"^=\",536870913]],[\"^84\",[26,\"^;\",\"^3I\",536870913]],[\"^84\",[26,\"^>\",true,536870913]],[\"^84\",[26,\"^C\",\"^D\",536870913]],[\"^84\",[26,\"^57\",true,536870913]],[\"^84\",[26,\"^5L\",114,536870913]],[\"^84\",[26,\"^4N\",true,536870913]],[\"^84\",[26,\"^3P\",true,536870913]],[\"^84\",[26,\"^X\",true,536870913]],[\"^84\",[26,\"^1K\",\"^8>\",536870913]],[\"^84\",[26,\"^@\",\"~:block-left\",536870913]],[\"^84\",[27,\"^48\",1775980635496,536870913]],[\"^84\",[27,\"^7M\",\"card\",536870913]],[\"^84\",[27,\"^69\",14,536870913]],[\"^84\",[27,\"^6:\",\"Card\",536870913]],[\"^84\",[27,\"^3;\",1775980635496,536870913]],[\"^84\",[27,\"^2T\",\"~u00000002-1358-2811-0900-000000000000\",536870913]],[\"^84\",[27,\"^;\",\"^3U\",536870913]],[\"^84\",[27,\"^57\",true,536870913]],[\"^84\",[27,\"^3K\",89,536870913]],[\"^84\",[27,\"^4K\",29,536870913]],[\"^84\",[27,\"^4K\",43,536870913]],[\"^84\",[28,\"^48\",1775980635496,536870913]],[\"^84\",[28,\"^7M\",\"code\",536870913]],[\"^84\",[28,\"^69\",14,536870913]],[\"^84\",[28,\"^6:\",\"Code\",536870913]],[\"^84\",[28,\"^3;\",1775980635496,536870913]],[\"^84\",[28,\"^2T\",\"~u00000002-1454-9866-4100-000000000000\",536870913]],[\"^84\",[28,\"^;\",\"^5?\",536870913]],[\"^84\",[28,\"^57\",true,536870913]],[\"^84\",[28,\"^3K\",89,536870913]],[\"^84\",[28,\"^1X\",true,536870913]],[\"^84\",[28,\"^4K\",37,536870913]],[\"^84\",[28,\"^4K\",108,536870913]],[\"^84\",[29,\"^48\",1775980635494,536870913]],[\"^84\",[29,\"^7M\",\"due\",536870913]],[\"^84\",[29,\"^7@\",\"d3BH4\",536870913]],[\"^84\",[29,\"^69\",124,536870913]],[\"^84\",[29,\"^6:\",\"Due\",536870913]],[\"^84\",[29,\"^3;\",1775980635494,536870913]],[\"^84\",[29,\"^2T\",\"~u00000002-1089-0805-4900-000000000000\",536870913]],[\"^84\",[29,\"^<\",\"^=\",536870913]],[\"^84\",[29,\"^;\",\"^7G\",536870913]],[\"^84\",[29,\"^>\",true,536870913]],[\"^84\",[29,\"^57\",true,536870913]],[\"^84\",[29,\"^5J\",false,536870913]],[\"^84\",[29,\"^X\",false,536870913]],[\"^84\",[29,\"^1K\",\"^8A\",536870913]],[\"^84\",[30,\"^48\",1775980635490,536870913]],[\"^84\",[30,\"^7M\",\"asset\",536870913]],[\"^84\",[30,\"^7@\",\"d3BFl\",536870913]],[\"^84\",[30,\"^69\",124,536870913]],[\"^84\",[30,\"^6:\",\"Asset\",536870913]],[\"^84\",[30,\"^3;\",1775980635490,536870913]],[\"^84\",[30,\"^2T\",\"~u00000002-8768-5679-0000-000000000000\",536870913]],[\"^84\",[30,\"^<\",\"^=\",536870913]],[\"^84\",[30,\"^;\",\"^6N\",536870913]],[\"^84\",[30,\"^>\",true,536870913]],[\"^84\",[30,\"^C\",\"^D\",536870913]],[\"^84\",[30,\"^57\",true,536870913]],[\"^84\",[30,\"^5J\",true,536870913]],[\"^84\",[30,\"^1K\",\"^8=\",536870913]],[\"^84\",[31,\"^L\",19,536870913]],[\"^84\",[31,\"^48\",1775980635490,536870913]],[\"^84\",[31,\"^7@\",\"d3BFq\",536870913]],[\"^84\",[31,\"^4S\",19,536870913]],[\"^84\",[31,\"^3X\",19,536870913]],[\"^84\",[31,\"^6:\",\"red\",536870913]],[\"^84\",[31,\"^3;\",1775980635490,536870913]],[\"^84\",[31,\"^2T\",\"~u00000002-4136-2216-2000-000000000000\",536870913]],[\"^84\",[31,\"^;\",\"^5O\",536870913]],[\"^84\",[31,\"^57\",true,536870913]],[\"^84\",[31,\"^6?\",19,536870913]],[\"^84\",[32,\"^48\",1775980635491,536870913]],[\"^84\",[32,\"^7M\",\"priority\",536870913]],[\"^84\",[32,\"^7@\",\"d3BGE\",536870913]],[\"^84\",[32,\"^69\",124,536870913]],[\"^84\",[32,\"^6:\",\"Priority\",536870913]],[\"^84\",[32,\"^3;\",1775980635491,536870913]],[\"^84\",[32,\"^2T\",\"~u00000002-2392-2841-1000-000000000000\",536870913]],[\"^84\",[32,\"^<\",\"^=\",536870913]],[\"^84\",[32,\"^;\",\"^3\",536870913]],[\"^84\",[32,\"^>\",true,536870913]],[\"^84\",[32,\"^C\",\"^D\",536870913]],[\"^84\",[32,\"^57\",true,536870913]],[\"^84\",[32,\"^4N\",true,536870913]],[\"^84\",[32,\"^3P\",true,536870913]],[\"^84\",[32,\"^X\",true,536870913]],[\"^84\",[32,\"^1K\",\"^8>\",536870913]],[\"^84\",[32,\"^@\",\"^8B\",536870913]],[\"^84\",[33,\"^L\",32,536870913]],[\"^84\",[33,\"^48\",1775980635491,536870913]],[\"^84\",[33,\"^7@\",\"d3BGI\",536870913]],[\"^84\",[33,\"^4S\",32,536870913]],[\"^84\",[33,\"^3X\",32,536870913]],[\"^84\",[33,\"^6:\",\"Urgent\",536870913]],[\"^84\",[33,\"^3;\",1775980635491,536870913]],[\"^84\",[33,\"^2T\",\"~u00000002-1996-4346-6700-000000000000\",536870913]],[\"^84\",[33,\"^;\",\"^H\",536870913]],[\"^84\",[33,\"^57\",true,536870913]],[\"^84\",[33,\"^6?\",32,536870913]],[\"^84\",[33,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlUrgent\"],536870913]],[\"^84\",[34,\"^48\",1775980635487,536870913]],[\"^84\",[34,\"^7M\",\"property public?\",536870913]],[\"^84\",[34,\"^7@\",\"d3BFA\",536870913]],[\"^84\",[34,\"^69\",124,536870913]],[\"^84\",[34,\"^6:\",\"Property public?\",536870913]],[\"^84\",[34,\"^3;\",1775980635487,536870913]],[\"^84\",[34,\"^2T\",\"~u00000002-1843-0851-4900-000000000000\",536870913]],[\"^84\",[34,\"^<\",\"^=\",536870913]],[\"^84\",[34,\"^;\",\"^X\",536870913]],[\"^84\",[34,\"^>\",true,536870913]],[\"^84\",[34,\"^57\",true,536870913]],[\"^84\",[34,\"^5J\",true,536870913]],[\"^84\",[34,\"^1K\",\"^8?\",536870913]],[\"^84\",[35,\"^48\",1775980635495,536870913]],[\"^84\",[35,\"^7M\",\"recycle original order\",536870913]],[\"^84\",[35,\"^7@\",\"d3BHK\",536870913]],[\"^84\",[35,\"^69\",124,536870913]],[\"^84\",[35,\"^6:\",\"Recycle original order\",536870913]],[\"^84\",[35,\"^3;\",1775980635495,536870913]],[\"^84\",[35,\"^2T\",\"~u00000002-8699-2537-0000-000000000000\",536870913]],[\"^84\",[35,\"^<\",\"^=\",536870913]],[\"^84\",[35,\"^;\",\"^19\",536870913]],[\"^84\",[35,\"^>\",true,536870913]],[\"^84\",[35,\"^57\",true,536870913]],[\"^84\",[35,\"^5J\",true,536870913]],[\"^84\",[35,\"^X\",false,536870913]],[\"^84\",[35,\"^1K\",\"^87\",536870913]],[\"^84\",[36,\"^48\",1775980635488,536870913]],[\"^84\",[36,\"^7M\",\"journal date\",536870913]],[\"^84\",[36,\"^7@\",\"d3BFP\",536870913]],[\"^84\",[36,\"^69\",124,536870913]],[\"^84\",[36,\"^6:\",\"Journal date\",536870913]],[\"^84\",[36,\"^3;\",1775980635488,536870913]],[\"^84\",[36,\"^2T\",\"~u00000002-1457-4836-6000-000000000000\",536870913]],[\"^84\",[36,\"^<\",\"^=\",536870913]],[\"^84\",[36,\"^;\",\"^1C\",536870913]],[\"^84\",[36,\"^>\",true,536870913]],[\"^84\",[36,\"^57\",true,536870913]],[\"^84\",[36,\"^5J\",true,536870913]],[\"^84\",[36,\"^X\",false,536870913]],[\"^84\",[36,\"^1K\",\"~:raw-number\",536870913]],[\"^84\",[37,\"^48\",1775980635488,536870913]],[\"^84\",[37,\"^7M\",\"node display type\",536870913]],[\"^84\",[37,\"^7@\",\"d3BFS\",536870913]],[\"^84\",[37,\"^69\",124,536870913]],[\"^84\",[37,\"^6:\",\"Node Display Type\",536870913]],[\"^84\",[37,\"^3;\",1775980635488,536870913]],[\"^84\",[37,\"^2T\",\"~u00000002-4424-4618-9000-000000000000\",536870913]],[\"^84\",[37,\"^<\",\"^=\",536870913]],[\"^84\",[37,\"^;\",\"^1Q\",536870913]],[\"^84\",[37,\"^>\",true,536870913]],[\"^84\",[37,\"^57\",true,536870913]],[\"^84\",[37,\"^5J\",true,536870913]],[\"^84\",[37,\"^X\",false,536870913]],[\"^84\",[37,\"^1K\",\"~:keyword\",536870913]],[\"^84\",[37,\"^44\",\"~:block\",536870913]],[\"^84\",[38,\"^48\",1775980635496,536870913]],[\"^84\",[38,\"^7M\",\"template\",536870913]],[\"^84\",[38,\"^69\",14,536870913]],[\"^84\",[38,\"^6:\",\"Template\",536870913]],[\"^84\",[38,\"^3;\",1775980635496,536870913]],[\"^84\",[38,\"^2T\",\"~u00000002-1720-8548-4600-000000000000\",536870913]],[\"^84\",[38,\"^;\",\"^22\",536870913]],[\"^84\",[38,\"^57\",true,536870913]],[\"^84\",[38,\"^3K\",89,536870913]],[\"^84\",[38,\"^4K\",147,536870913]],[\"^84\",[39,\"^;\",\"^2?\",536870913]],[\"^84\",[39,\"^28\",\"~u00000000-4735-4d82-94bb-c9bfb8039105\",536870913]],[\"^84\",[40,\"^48\",1775980635490,536870913]],[\"^84\",[40,\"^7M\",\"annotation page\",536870913]],[\"^84\",[40,\"^7@\",\"d3BFu\",536870913]],[\"^84\",[40,\"^69\",124,536870913]],[\"^84\",[40,\"^6:\",\"Annotation page\",536870913]],[\"^84\",[40,\"^3;\",1775980635490,536870913]],[\"^84\",[40,\"^2T\",\"~u00000002-7532-8459-6000-000000000000\",536870913]],[\"^84\",[40,\"^<\",\"^=\",536870913]],[\"^84\",[40,\"^;\",\"^2O\",536870913]],[\"^84\",[40,\"^>\",true,536870913]],[\"^84\",[40,\"^57\",true,536870913]],[\"^84\",[40,\"^5J\",true,536870913]],[\"^84\",[40,\"^1K\",\"^8C\",536870913]],[\"^84\",[41,\"^48\",1775980635491,536870913]],[\"^84\",[41,\"^7M\",\"properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^7@\",\"d3BG6\",536870913]],[\"^84\",[41,\"^69\",124,536870913]],[\"^84\",[41,\"^6:\",\"Properties displayed as checkbox\",536870913]],[\"^84\",[41,\"^3;\",1775980635491,536870913]],[\"^84\",[41,\"^2T\",\"~u00000002-3215-3256-9000-000000000000\",536870913]],[\"^84\",[41,\"^<\",\"^14\",536870913]],[\"^84\",[41,\"^;\",\"^2H\",536870913]],[\"^84\",[41,\"^>\",true,536870913]],[\"^84\",[41,\"^C\",\"^D\",536870913]],[\"^84\",[41,\"^57\",true,536870913]],[\"^84\",[41,\"^5J\",true,536870913]],[\"^84\",[41,\"^1K\",\"^86\",536870913]],[\"^84\",[42,\"^L\",19,536870913]],[\"^84\",[42,\"^48\",1775980635490,536870913]],[\"^84\",[42,\"^7@\",\"d3BFt\",536870913]],[\"^84\",[42,\"^4S\",19,536870913]],[\"^84\",[42,\"^3X\",19,536870913]],[\"^84\",[42,\"^6:\",\"purple\",536870913]],[\"^84\",[42,\"^3;\",1775980635490,536870913]],[\"^84\",[42,\"^2T\",\"~u00000002-1104-3848-5600-000000000000\",536870913]],[\"^84\",[42,\"^;\",\"^2Y\",536870913]],[\"^84\",[42,\"^57\",true,536870913]],[\"^84\",[42,\"^6?\",19,536870913]],[\"^84\",[43,\"^48\",1775980635494,536870913]],[\"^84\",[43,\"^7M\",\"state\",536870913]],[\"^84\",[43,\"^7@\",\"d3BH5\",536870913]],[\"^84\",[43,\"^69\",124,536870913]],[\"^84\",[43,\"^6:\",\"State\",536870913]],[\"^84\",[43,\"^3;\",1775980635494,536870913]],[\"^84\",[43,\"^2T\",\"~u00000002-1165-1650-8700-000000000000\",536870913]],[\"^84\",[43,\"^<\",\"^=\",536870913]],[\"^84\",[43,\"^;\",\"^P\",536870913]],[\"^84\",[43,\"^>\",true,536870913]],[\"^84\",[43,\"^57\",true,536870913]],[\"^84\",[43,\"^5J\",false,536870913]],[\"^84\",[43,\"^X\",false,536870913]],[\"^84\",[43,\"^1K\",\"^88\",536870913]],[\"^84\",[44,\"^48\",1775980635492,536870913]],[\"^84\",[44,\"^7M\",\"view sort groups desc\",536870913]],[\"^84\",[44,\"^7@\",\"d3BGk\",536870913]],[\"^84\",[44,\"^69\",124,536870913]],[\"^84\",[44,\"^6:\",\"View sort groups DESC\",536870913]],[\"^84\",[44,\"^3;\",1775980635492,536870913]],[\"^84\",[44,\"^2T\",\"~u00000002-1081-4073-8700-000000000000\",536870913]],[\"^84\",[44,\"^<\",\"^=\",536870913]],[\"^84\",[44,\"^;\",\"^1V\",536870913]],[\"^84\",[44,\"^>\",true,536870913]],[\"^84\",[44,\"^57\",true,536870913]],[\"^84\",[44,\"^5J\",true,536870913]],[\"^84\",[44,\"^X\",false,536870913]],[\"^84\",[44,\"^29\",true,536870913]],[\"^84\",[44,\"^1K\",\"^8?\",536870913]],[\"^84\",[45,\"^48\",1775980635488,536870913]],[\"^84\",[45,\"^7M\",\"node created at\",536870913]],[\"^84\",[45,\"^7@\",\"d3BFQ\",536870913]],[\"^84\",[45,\"^69\",124,536870913]],[\"^84\",[45,\"^6:\",\"Node created at\",536870913]],[\"^84\",[45,\"^3;\",1775980635488,536870913]],[\"^84\",[45,\"^2T\",\"~u00000002-1440-0150-0000-000000000000\",536870913]],[\"^84\",[45,\"^<\",\"^=\",536870913]],[\"^84\",[45,\"^;\",\"^48\",536870913]],[\"^84\",[45,\"^>\",true,536870913]],[\"^84\",[45,\"^57\",true,536870913]],[\"^84\",[45,\"^5J\",true,536870913]],[\"^84\",[45,\"^X\",false,536870913]],[\"^84\",[45,\"^1K\",\"^8A\",536870913]],[\"^84\",[46,\"^48\",1775980635496,536870913]],[\"^84\",[46,\"^7M\",\"asset\",536870913]],[\"^84\",[46,\"^69\",14,536870913]],[\"^84\",[46,\"^6:\",\"Asset\",536870913]],[\"^84\",[46,\"^3;\",1775980635496,536870913]],[\"^84\",[46,\"^2T\",\"~u00000002-7975-0297-0000-000000000000\",536870913]],[\"^84\",[46,\"^;\",\"^2;\",536870913]],[\"^84\",[46,\"^57\",true,536870913]],[\"^84\",[46,\"^3K\",89,536870913]],[\"^84\",[46,\"^1X\",true,536870913]],[\"^84\",[46,\"^4K\",5,536870913]],[\"^84\",[46,\"^4K\",57,536870913]],[\"^84\",[46,\"^4K\",131,536870913]],[\"^84\",[46,\"^2J\",150,536870913]],[\"^84\",[47,\"^48\",1775980635488,536870913]],[\"^84\",[47,\"^7M\",\"node page\",536870913]],[\"^84\",[47,\"^7@\",\"d3BFK\",536870913]],[\"^84\",[47,\"^69\",124,536870913]],[\"^84\",[47,\"^6:\",\"Node page\",536870913]],[\"^84\",[47,\"^3;\",1775980635488,536870913]],[\"^84\",[47,\"^2T\",\"~u00000002-8223-1410-8000-000000000000\",536870913]],[\"^84\",[47,\"^<\",\"^=\",536870913]],[\"^84\",[47,\"^;\",\"^4S\",536870913]],[\"^84\",[47,\"^>\",true,536870913]],[\"^84\",[47,\"^C\",\"^D\",536870913]],[\"^84\",[47,\"^57\",true,536870913]],[\"^84\",[47,\"^5J\",true,536870913]],[\"^84\",[47,\"^X\",false,536870913]],[\"^84\",[47,\"^1K\",\"^8=\",536870913]],[\"^84\",[48,\"^L\",13,536870913]],[\"^84\",[48,\"^48\",1775980635492,536870913]],[\"^84\",[48,\"^7@\",\"d3BGV\",536870913]],[\"^84\",[48,\"^4S\",13,536870913]],[\"^84\",[48,\"^3X\",13,536870913]],[\"^84\",[48,\"^6:\",\"Year\",536870913]],[\"^84\",[48,\"^3;\",1775980635492,536870913]],[\"^84\",[48,\"^2T\",\"~u00000002-1520-4385-2400-000000000000\",536870913]],[\"^84\",[48,\"^;\",\"^1L\",536870913]],[\"^84\",[48,\"^57\",true,536870913]],[\"^84\",[48,\"^6?\",13,536870913]],[\"^84\",[49,\"^48\",1775980635492,536870913]],[\"^84\",[49,\"^7M\",\"excluded from graph view?\",536870913]],[\"^84\",[49,\"^7@\",\"d3BGc\",536870913]],[\"^84\",[49,\"^69\",124,536870913]],[\"^84\",[49,\"^6:\",\"Excluded from Graph view?\",536870913]],[\"^84\",[49,\"^3;\",1775980635492,536870913]],[\"^84\",[49,\"^2T\",\"~u00000002-4524-3306-5000-000000000000\",536870913]],[\"^84\",[49,\"^<\",\"^=\",536870913]],[\"^84\",[49,\"^;\",\"^5:\",536870913]],[\"^84\",[49,\"^>\",true,536870913]],[\"^84\",[49,\"^57\",true,536870913]],[\"^84\",[49,\"^5J\",true,536870913]],[\"^84\",[49,\"^X\",true,536870913]],[\"^84\",[49,\"^1K\",\"^8?\",536870913]],[\"^84\",[49,\"^44\",\"^89\",536870913]],[\"^84\",[50,\"^48\",1775980635493,536870913]],[\"^84\",[50,\"^7M\",\"table view pinned columns\",536870913]],[\"^84\",[50,\"^7@\",\"d3BGq\",536870913]],[\"^84\",[50,\"^69\",124,536870913]],[\"^84\",[50,\"^6:\",\"Table view pinned columns\",536870913]],[\"^84\",[50,\"^3;\",1775980635493,536870913]],[\"^84\",[50,\"^2T\",\"~u00000002-2673-7513-8000-000000000000\",536870913]],[\"^84\",[50,\"^<\",\"^14\",536870913]],[\"^84\",[50,\"^;\",\"^15\",536870913]],[\"^84\",[50,\"^>\",true,536870913]],[\"^84\",[50,\"^C\",\"^D\",536870913]],[\"^84\",[50,\"^57\",true,536870913]],[\"^84\",[50,\"^5J\",true,536870913]],[\"^84\",[50,\"^X\",false,536870913]],[\"^84\",[50,\"^1K\",\"^86\",536870913]],[\"^84\",[51,\"^48\",1775980635495,536870913]],[\"^84\",[51,\"^7M\",\"reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^7@\",\"d3BHP\",536870913]],[\"^84\",[51,\"^69\",124,536870913]],[\"^84\",[51,\"^6:\",\"Reference to large block title stored in remote object storage\",536870913]],[\"^84\",[51,\"^3;\",1775980635495,536870913]],[\"^84\",[51,\"^2T\",\"~u00000002-1044-8271-6500-000000000000\",536870913]],[\"^84\",[51,\"^<\",\"^=\",536870913]],[\"^84\",[51,\"^;\",\"^1@\",536870913]],[\"^84\",[51,\"^>\",true,536870913]],[\"^84\",[51,\"^57\",true,536870913]],[\"^84\",[51,\"^5J\",true,536870913]],[\"^84\",[51,\"^X\",false,536870913]],[\"^84\",[51,\"^1K\",\"^88\",536870913]],[\"^84\",[52,\"^;\",\"^45\",536870913]],[\"^84\",[52,\"^28\",[\"^ \",\"~:major\",65,\"~:minor\",24],536870913]],[\"^84\",[53,\"^48\",1775980635487,536870913]],[\"^84\",[53,\"^7M\",\"created from property\",536870913]],[\"^84\",[53,\"^7@\",\"d3BF9\",536870913]],[\"^84\",[53,\"^69\",124,536870913]],[\"^84\",[53,\"^6:\",\"Created from property\",536870913]],[\"^84\",[53,\"^3;\",1775980635487,536870913]],[\"^84\",[53,\"^2T\",\"~u00000002-8618-9226-7000-000000000000\",536870913]],[\"^84\",[53,\"^<\",\"^=\",536870913]],[\"^84\",[53,\"^;\",\"^6?\",536870913]],[\"^84\",[53,\"^>\",true,536870913]],[\"^84\",[53,\"^C\",\"^D\",536870913]],[\"^84\",[53,\"^57\",true,536870913]],[\"^84\",[53,\"^5J\",true,536870913]],[\"^84\",[53,\"^1K\",\"^8=\",536870913]],[\"^84\",[54,\"^48\",1775980635492,536870913]],[\"^84\",[54,\"^7M\",\"publishing public?\",536870913]],[\"^84\",[54,\"^7@\",\"d3BGa\",536870913]],[\"^84\",[54,\"^69\",124,536870913]],[\"^84\",[54,\"^6:\",\"Publishing Public?\",536870913]],[\"^84\",[54,\"^3;\",1775980635492,536870913]],[\"^84\",[54,\"^2T\",\"~u00000002-1094-6579-3900-000000000000\",536870913]],[\"^84\",[54,\"^<\",\"^=\",536870913]],[\"^84\",[54,\"^;\",\"^4=\",536870913]],[\"^84\",[54,\"^>\",true,536870913]],[\"^84\",[54,\"^57\",true,536870913]],[\"^84\",[54,\"^5J\",true,536870913]],[\"^84\",[54,\"^X\",true,536870913]],[\"^84\",[54,\"^1K\",\"^8?\",536870913]],[\"^84\",[54,\"^44\",\"^89\",536870913]],[\"^84\",[55,\"^L\",26,536870913]],[\"^84\",[55,\"^48\",1775980635491,536870913]],[\"^84\",[55,\"^7@\",\"d3BGC\",536870913]],[\"^84\",[55,\"^4S\",26,536870913]],[\"^84\",[55,\"^3X\",26,536870913]],[\"^84\",[55,\"^6:\",\"Done\",536870913]],[\"^84\",[55,\"^3;\",1775980635491,536870913]],[\"^84\",[55,\"^2T\",\"~u00000002-1827-5820-8200-000000000000\",536870913]],[\"^84\",[55,\"^;\",\"^6Q\",536870913]],[\"^84\",[55,\"^57\",true,536870913]],[\"^84\",[55,\"^4W\",true,536870913]],[\"^84\",[55,\"^6?\",26,536870913]],[\"^84\",[55,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Done\"],536870913]],[\"^84\",[56,\"^48\",1775980635487,536870913]],[\"^84\",[56,\"^7M\",\"property value\",536870913]],[\"^84\",[56,\"^7@\",\"d3BFE\",536870913]],[\"^84\",[56,\"^69\",124,536870913]],[\"^84\",[56,\"^6:\",\"Property value\",536870913]],[\"^84\",[56,\"^3;\",1775980635487,536870913]],[\"^84\",[56,\"^2T\",\"~u00000002-1396-5248-6500-000000000000\",536870913]],[\"^84\",[56,\"^<\",\"^=\",536870913]],[\"^84\",[56,\"^;\",\"^T\",536870913]],[\"^84\",[56,\"^>\",true,536870913]],[\"^84\",[56,\"^57\",true,536870913]],[\"^84\",[56,\"^5J\",true,536870913]],[\"^84\",[56,\"^X\",false,536870913]],[\"^84\",[56,\"^1K\",\"~:any\",536870913]],[\"^84\",[57,\"^48\",1775980635493,536870913]],[\"^84\",[57,\"^7M\",\"file type\",536870913]],[\"^84\",[57,\"^7@\",\"d3BGs\",536870913]],[\"^84\",[57,\"^69\",124,536870913]],[\"^84\",[57,\"^6:\",\"File Type\",536870913]],[\"^84\",[57,\"^3;\",1775980635493,536870913]],[\"^84\",[57,\"^2T\",\"~u00000002-1142-0830-9800-000000000000\",536870913]],[\"^84\",[57,\"^<\",\"^=\",536870913]],[\"^84\",[57,\"^;\",\"^2:\",536870913]],[\"^84\",[57,\"^>\",true,536870913]],[\"^84\",[57,\"^57\",true,536870913]],[\"^84\",[57,\"^5J\",true,536870913]],[\"^84\",[57,\"^X\",false,536870913]],[\"^84\",[57,\"^1K\",\"^87\",536870913]],[\"^84\",[58,\"^48\",1775980635487,536870913]],[\"^84\",[58,\"^7M\",\"tags\",536870913]],[\"^84\",[58,\"^7@\",\"d3BFG\",536870913]],[\"^84\",[58,\"^69\",124,536870913]],[\"^84\",[58,\"^6:\",\"Tags\",536870913]],[\"^84\",[58,\"^3;\",1775980635487,536870913]],[\"^84\",[58,\"^2T\",\"~u00000002-1814-9483-4000-000000000000\",536870913]],[\"^84\",[58,\"^<\",\"^14\",536870913]],[\"^84\",[58,\"^;\",\"^69\",536870913]],[\"^84\",[58,\"^>\",true,536870913]],[\"^84\",[58,\"^C\",\"^D\",536870913]],[\"^84\",[58,\"^57\",true,536870913]],[\"^84\",[58,\"^X\",true,536870913]],[\"^84\",[58,\"^1K\",\"^8@\",536870913]],[\"^84\",[59,\"^48\",1775980635490,536870913]],[\"^84\",[59,\"^7M\",\"list type\",536870913]],[\"^84\",[59,\"^7@\",\"d3BFx\",536870913]],[\"^84\",[59,\"^69\",124,536870913]],[\"^84\",[59,\"^6:\",\"List type\",536870913]],[\"^84\",[59,\"^3;\",1775980635490,536870913]],[\"^84\",[59,\"^2T\",\"~u00000002-6078-1711-1000-000000000000\",536870913]],[\"^84\",[59,\"^<\",\"^=\",536870913]],[\"^84\",[59,\"^;\",\"^6W\",536870913]],[\"^84\",[59,\"^>\",true,536870913]],[\"^84\",[59,\"^C\",\"^D\",536870913]],[\"^84\",[59,\"^57\",true,536870913]],[\"^84\",[59,\"^5J\",true,536870913]],[\"^84\",[59,\"^1K\",\"^8>\",536870913]],[\"^84\",[60,\"^48\",1775980635487,536870913]],[\"^84\",[60,\"^7M\",\"property position\",536870913]],[\"^84\",[60,\"^7@\",\"d3BFC\",536870913]],[\"^84\",[60,\"^69\",124,536870913]],[\"^84\",[60,\"^6:\",\"Property position\",536870913]],[\"^84\",[60,\"^3;\",1775980635487,536870913]],[\"^84\",[60,\"^2T\",\"~u00000002-1869-2008-6400-000000000000\",536870913]],[\"^84\",[60,\"^<\",\"^=\",536870913]],[\"^84\",[60,\"^;\",\"^@\",536870913]],[\"^84\",[60,\"^>\",true,536870913]],[\"^84\",[60,\"^57\",true,536870913]],[\"^84\",[60,\"^5J\",true,536870913]],[\"^84\",[60,\"^1K\",\"^8D\",536870913]],[\"^84\",[61,\"^L\",13,536870913]],[\"^84\",[61,\"^48\",1775980635492,536870913]],[\"^84\",[61,\"^7@\",\"d3BGS\",536870913]],[\"^84\",[61,\"^4S\",13,536870913]],[\"^84\",[61,\"^3X\",13,536870913]],[\"^84\",[61,\"^6:\",\"Day\",536870913]],[\"^84\",[61,\"^3;\",1775980635492,536870913]],[\"^84\",[61,\"^2T\",\"~u00000002-3924-1785-8000-000000000000\",536870913]],[\"^84\",[61,\"^;\",\"^16\",536870913]],[\"^84\",[61,\"^57\",true,536870913]],[\"^84\",[61,\"^6?\",13,536870913]],[\"^84\",[62,\"^48\",1775980635490,536870913]],[\"^84\",[62,\"^7M\",\"annotation type\",536870913]],[\"^84\",[62,\"^7@\",\"d3BFn\",536870913]],[\"^84\",[62,\"^69\",124,536870913]],[\"^84\",[62,\"^6:\",\"Annotation type\",536870913]],[\"^84\",[62,\"^3;\",1775980635490,536870913]],[\"^84\",[62,\"^2T\",\"~u00000002-9984-3783-2000-000000000000\",536870913]],[\"^84\",[62,\"^<\",\"^=\",536870913]],[\"^84\",[62,\"^;\",\"^6[\",536870913]],[\"^84\",[62,\"^>\",true,536870913]],[\"^84\",[62,\"^57\",true,536870913]],[\"^84\",[62,\"^5J\",true,536870913]],[\"^84\",[62,\"^1K\",\"^8D\",536870913]],[\"^84\",[63,\"^48\",1775980635493,536870913]],[\"^84\",[63,\"^7M\",\"view hidden columns\",536870913]],[\"^84\",[63,\"^7@\",\"d3BGn\",536870913]],[\"^84\",[63,\"^69\",124,536870913]],[\"^84\",[63,\"^6:\",\"View hidden columns\",536870913]],[\"^84\",[63,\"^3;\",1775980635493,536870913]],[\"^84\",[63,\"^2T\",\"~u00000002-9750-5719-2000-000000000000\",536870913]],[\"^84\",[63,\"^<\",\"^14\",536870913]],[\"^84\",[63,\"^;\",\"^2[\",536870913]],[\"^84\",[63,\"^>\",true,536870913]],[\"^84\",[63,\"^57\",true,536870913]],[\"^84\",[63,\"^5J\",true,536870913]],[\"^84\",[63,\"^X\",false,536870913]],[\"^84\",[63,\"^1K\",\"^8D\",536870913]],[\"^84\",[64,\"^48\",1775980635490,536870913]],[\"^84\",[64,\"^7M\",\"ls-type\",536870913]],[\"^84\",[64,\"^7@\",\"d3BFm\",536870913]],[\"^84\",[64,\"^69\",124,536870913]],[\"^84\",[64,\"^6:\",\"ls-type\",536870913]],[\"^84\",[64,\"^3;\",1775980635490,536870913]],[\"^84\",[64,\"^2T\",\"~u00000002-3269-7934-5000-000000000000\",536870913]],[\"^84\",[64,\"^<\",\"^=\",536870913]],[\"^84\",[64,\"^;\",\"^5\",536870913]],[\"^84\",[64,\"^>\",true,536870913]],[\"^84\",[64,\"^57\",true,536870913]],[\"^84\",[64,\"^5J\",true,536870913]],[\"^84\",[64,\"^1K\",\"^8D\",536870913]],[\"^84\",[65,\"^L\",19,536870913]],[\"^84\",[65,\"^48\",1775980635490,536870913]],[\"^84\",[65,\"^7@\",\"d3BFp\",536870913]],[\"^84\",[65,\"^4S\",19,536870913]],[\"^84\",[65,\"^3X\",19,536870913]],[\"^84\",[65,\"^6:\",\"yellow\",536870913]],[\"^84\",[65,\"^3;\",1775980635490,536870913]],[\"^84\",[65,\"^2T\",\"~u00000002-1752-1030-0200-000000000000\",536870913]],[\"^84\",[65,\"^;\",\"^J\",536870913]],[\"^84\",[65,\"^57\",true,536870913]],[\"^84\",[65,\"^6?\",19,536870913]],[\"^84\",[66,\"^48\",1775980635493,536870913]],[\"^84\",[66,\"^7M\",\"image width\",536870913]],[\"^84\",[66,\"^7@\",\"d3BGw\",536870913]],[\"^84\",[66,\"^69\",124,536870913]],[\"^84\",[66,\"^6:\",\"Image width\",536870913]],[\"^84\",[66,\"^3;\",1775980635493,536870913]],[\"^84\",[66,\"^2T\",\"~u00000002-1857-8658-3900-000000000000\",536870913]],[\"^84\",[66,\"^<\",\"^=\",536870913]],[\"^84\",[66,\"^;\",\"^Z\",536870913]],[\"^84\",[66,\"^>\",true,536870913]],[\"^84\",[66,\"^57\",true,536870913]],[\"^84\",[66,\"^5J\",true,536870913]],[\"^84\",[66,\"^X\",false,536870913]],[\"^84\",[66,\"^1K\",\"^8C\",536870913]],[\"^84\",[67,\"^48\",1775980635494,536870913]],[\"^84\",[67,\"^7M\",\"file remote metadata\",536870913]],[\"^84\",[67,\"^7@\",\"d3BH0\",536870913]],[\"^84\",[67,\"^69\",124,536870913]],[\"^84\",[67,\"^6:\",\"File remote metadata\",536870913]],[\"^84\",[67,\"^3;\",1775980635494,536870913]],[\"^84\",[67,\"^2T\",\"~u00000002-9907-5046-9000-000000000000\",536870913]],[\"^84\",[67,\"^<\",\"^=\",536870913]],[\"^84\",[67,\"^;\",\"^1;\",536870913]],[\"^84\",[67,\"^>\",true,536870913]],[\"^84\",[67,\"^57\",true,536870913]],[\"^84\",[67,\"^2A\",167,536870913]],[\"^84\",[67,\"^5J\",true,536870913]],[\"^84\",[67,\"^X\",false,536870913]],[\"^84\",[67,\"^1K\",\"^88\",536870913]],[\"^84\",[68,\"^L\",127,536870913]],[\"^84\",[68,\"^48\",1775980635492,536870913]],[\"^84\",[68,\"^7@\",\"d3BGf\",536870913]],[\"^84\",[68,\"^4S\",127,536870913]],[\"^84\",[68,\"^3X\",127,536870913]],[\"^84\",[68,\"^6:\",\"List View\",536870913]],[\"^84\",[68,\"^3;\",1775980635492,536870913]],[\"^84\",[68,\"^2T\",\"~u00000002-1164-8285-0200-000000000000\",536870913]],[\"^84\",[68,\"^;\",\"^1E\",536870913]],[\"^84\",[68,\"^57\",true,536870913]],[\"^84\",[68,\"^6?\",127,536870913]],[\"^84\",[68,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"list\"],536870913]],[\"^84\",[69,\"^48\",1775980635488,536870913]],[\"^84\",[69,\"^7M\",\"node references\",536870913]],[\"^84\",[69,\"^7@\",\"d3BFL\",536870913]],[\"^84\",[69,\"^69\",124,536870913]],[\"^84\",[69,\"^6:\",\"Node references\",536870913]],[\"^84\",[69,\"^3;\",1775980635488,536870913]],[\"^84\",[69,\"^2T\",\"~u00000002-1214-4953-4900-000000000000\",536870913]],[\"^84\",[69,\"^<\",\"^14\",536870913]],[\"^84\",[69,\"^;\",\"^1S\",536870913]],[\"^84\",[69,\"^>\",true,536870913]],[\"^84\",[69,\"^C\",\"^D\",536870913]],[\"^84\",[69,\"^57\",true,536870913]],[\"^84\",[69,\"^5J\",true,536870913]],[\"^84\",[69,\"^X\",false,536870913]],[\"^84\",[69,\"^1K\",\"^8=\",536870913]],[\"^84\",[70,\"^L\",127,536870913]],[\"^84\",[70,\"^48\",1775980635492,536870913]],[\"^84\",[70,\"^7@\",\"d3BGe\",536870913]],[\"^84\",[70,\"^4S\",127,536870913]],[\"^84\",[70,\"^3X\",127,536870913]],[\"^84\",[70,\"^6:\",\"Table View\",536870913]],[\"^84\",[70,\"^3;\",1775980635492,536870913]],[\"^84\",[70,\"^2T\",\"~u00000002-1942-5424-0000-000000000000\",536870913]],[\"^84\",[70,\"^;\",\"^?\",536870913]],[\"^84\",[70,\"^57\",true,536870913]],[\"^84\",[70,\"^6?\",127,536870913]],[\"^84\",[70,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"table\"],536870913]],[\"^84\",[71,\"^48\",1775980635488,536870913]],[\"^84\",[71,\"^7M\",\"description\",536870913]],[\"^84\",[71,\"^7@\",\"d3BFT\",536870913]],[\"^84\",[71,\"^69\",124,536870913]],[\"^84\",[71,\"^6:\",\"Description\",536870913]],[\"^84\",[71,\"^3;\",1775980635488,536870913]],[\"^84\",[71,\"^2T\",\"~u00000002-3362-3620-0000-000000000000\",536870913]],[\"^84\",[71,\"^<\",\"^=\",536870913]],[\"^84\",[71,\"^;\",\"^2A\",536870913]],[\"^84\",[71,\"^>\",true,536870913]],[\"^84\",[71,\"^C\",\"^D\",536870913]],[\"^84\",[71,\"^57\",true,536870913]],[\"^84\",[71,\"^X\",true,536870913]],[\"^84\",[71,\"^1K\",\"^8>\",536870913]],[\"^84\",[72,\"^48\",1775980635495,536870913]],[\"^84\",[72,\"^7M\",\"recycle original page\",536870913]],[\"^84\",[72,\"^7@\",\"d3BHJ\",536870913]],[\"^84\",[72,\"^69\",124,536870913]],[\"^84\",[72,\"^6:\",\"Recycle original page\",536870913]],[\"^84\",[72,\"^3;\",1775980635495,536870913]],[\"^84\",[72,\"^2T\",\"~u00000002-1658-7897-9900-000000000000\",536870913]],[\"^84\",[72,\"^<\",\"^=\",536870913]],[\"^84\",[72,\"^;\",\"^2Q\",536870913]],[\"^84\",[72,\"^>\",true,536870913]],[\"^84\",[72,\"^C\",\"^D\",536870913]],[\"^84\",[72,\"^57\",true,536870913]],[\"^84\",[72,\"^5J\",true,536870913]],[\"^84\",[72,\"^X\",false,536870913]],[\"^84\",[72,\"^1K\",\"^85\",536870913]],[\"^84\",[73,\"^48\",1775980635490,536870913]],[\"^84\",[73,\"^7M\",\"excluded references\",536870913]],[\"^84\",[73,\"^7@\",\"d3BFz\",536870913]],[\"^84\",[73,\"^69\",124,536870913]],[\"^84\",[73,\"^6:\",\"Excluded references\",536870913]],[\"^84\",[73,\"^3;\",1775980635490,536870913]],[\"^84\",[73,\"^2T\",\"~u00000002-2426-7588-9000-000000000000\",536870913]],[\"^84\",[73,\"^<\",\"^14\",536870913]],[\"^84\",[73,\"^;\",\"^35\",536870913]],[\"^84\",[73,\"^>\",true,536870913]],[\"^84\",[73,\"^C\",\"^D\",536870913]],[\"^84\",[73,\"^57\",true,536870913]],[\"^84\",[73,\"^5J\",true,536870913]],[\"^84\",[73,\"^1K\",\"^85\",536870913]],[\"^84\",[74,\"^48\",1775980635494,536870913]],[\"^84\",[74,\"^7M\",\"history scalar value\",536870913]],[\"^84\",[74,\"^7@\",\"d3BHE\",536870913]],[\"^84\",[74,\"^69\",124,536870913]],[\"^84\",[74,\"^6:\",\"History scalar value\",536870913]],[\"^84\",[74,\"^3;\",1775980635494,536870913]],[\"^84\",[74,\"^2T\",\"~u00000002-2393-3777-5000-000000000000\",536870913]],[\"^84\",[74,\"^<\",\"^=\",536870913]],[\"^84\",[74,\"^;\",\"^3@\",536870913]],[\"^84\",[74,\"^>\",true,536870913]],[\"^84\",[74,\"^57\",true,536870913]],[\"^84\",[74,\"^5J\",true,536870913]],[\"^84\",[74,\"^1K\",\"^8H\",536870913]],[\"^84\",[75,\"^48\",1775980635491,536870913]],[\"^84\",[75,\"^7M\",\"deadline\",536870913]],[\"^84\",[75,\"^7@\",\"d3BGJ\",536870913]],[\"^84\",[75,\"^69\",124,536870913]],[\"^84\",[75,\"^6:\",\"Deadline\",536870913]],[\"^84\",[75,\"^3;\",1775980635491,536870913]],[\"^84\",[75,\"^2T\",\"~u00000002-1685-9016-0400-000000000000\",536870913]],[\"^84\",[75,\"^<\",\"^=\",536870913]],[\"^84\",[75,\"^;\",\"^1J\",536870913]],[\"^84\",[75,\"^>\",true,536870913]],[\"^84\",[75,\"^57\",true,536870913]],[\"^84\",[75,\"^2A\",166,536870913]],[\"^84\",[75,\"^3P\",true,536870913]],[\"^84\",[75,\"^X\",true,536870913]],[\"^84\",[75,\"^1K\",\"^8A\",536870913]],[\"^84\",[75,\"^@\",\"~:block-below\",536870913]],[\"^84\",[76,\"^L\",19,536870913]],[\"^84\",[76,\"^48\",1775980635490,536870913]],[\"^84\",[76,\"^7@\",\"d3BFr\",536870913]],[\"^84\",[76,\"^4S\",19,536870913]],[\"^84\",[76,\"^3X\",19,536870913]],[\"^84\",[76,\"^6:\",\"green\",536870913]],[\"^84\",[76,\"^3;\",1775980635490,536870913]],[\"^84\",[76,\"^2T\",\"~u00000002-1992-2016-2600-000000000000\",536870913]],[\"^84\",[76,\"^;\",\"^2=\",536870913]],[\"^84\",[76,\"^57\",true,536870913]],[\"^84\",[76,\"^6?\",19,536870913]],[\"^84\",[77,\"^48\",1775980635491,536870913]],[\"^84\",[77,\"^7M\",\"choice classes\",536870913]],[\"^84\",[77,\"^7@\",\"d3BG4\",536870913]],[\"^84\",[77,\"^69\",124,536870913]],[\"^84\",[77,\"^6:\",\"Choice classes\",536870913]],[\"^84\",[77,\"^3;\",1775980635491,536870913]],[\"^84\",[77,\"^2T\",\"~u00000002-7629-6621-9000-000000000000\",536870913]],[\"^84\",[77,\"^<\",\"^14\",536870913]],[\"^84\",[77,\"^;\",\"^4:\",536870913]],[\"^84\",[77,\"^>\",true,536870913]],[\"^84\",[77,\"^C\",\"^D\",536870913]],[\"^84\",[77,\"^57\",true,536870913]],[\"^84\",[77,\"^5J\",true,536870913]],[\"^84\",[77,\"^X\",false,536870913]],[\"^84\",[77,\"^1K\",\"^8@\",536870913]],[\"^84\",[77,\"^44\",\"~:never\",536870913]],[\"^84\",[78,\"^48\",1775980635496,536870913]],[\"^84\",[78,\"^7M\",\"quote\",536870913]],[\"^84\",[78,\"^69\",14,536870913]],[\"^84\",[78,\"^6:\",\"Quote\",536870913]],[\"^84\",[78,\"^3;\",1775980635496,536870913]],[\"^84\",[78,\"^2T\",\"~u00000002-1176-1666-1700-000000000000\",536870913]],[\"^84\",[78,\"^;\",\"^2K\",536870913]],[\"^84\",[78,\"^57\",true,536870913]],[\"^84\",[78,\"^3K\",89,536870913]],[\"^84\",[78,\"^1X\",true,536870913]],[\"^84\",[78,\"^4K\",37,536870913]],[\"^84\",[79,\"^48\",1775980635489,536870913]],[\"^84\",[79,\"^7M\",\"non ref type default value\",536870913]],[\"^84\",[79,\"^7@\",\"d3BFW\",536870913]],[\"^84\",[79,\"^69\",124,536870913]],[\"^84\",[79,\"^6:\",\"Non ref type default value\",536870913]],[\"^84\",[79,\"^3;\",1775980635489,536870913]],[\"^84\",[79,\"^2T\",\"~u00000002-1595-7230-1400-000000000000\",536870913]],[\"^84\",[79,\"^<\",\"^=\",536870913]],[\"^84\",[79,\"^;\",\"^29\",536870913]],[\"^84\",[79,\"^>\",true,536870913]],[\"^84\",[79,\"^57\",true,536870913]],[\"^84\",[79,\"^5J\",true,536870913]],[\"^84\",[79,\"^X\",false,536870913]],[\"^84\",[79,\"^1K\",\"^8H\",536870913]],[\"^84\",[79,\"^44\",\"^86\",536870913]],[\"^84\",[80,\"^48\",1775980635494,536870913]],[\"^84\",[80,\"^7M\",\"last visit page\",536870913]],[\"^84\",[80,\"^7@\",\"d3BGz\",536870913]],[\"^84\",[80,\"^69\",124,536870913]],[\"^84\",[80,\"^6:\",\"Last visit page\",536870913]],[\"^84\",[80,\"^3;\",1775980635494,536870913]],[\"^84\",[80,\"^2T\",\"~u00000002-2107-8035-3500-000000000000\",536870913]],[\"^84\",[80,\"^<\",\"^=\",536870913]],[\"^84\",[80,\"^;\",\"^4[\",536870913]],[\"^84\",[80,\"^>\",true,536870913]],[\"^84\",[80,\"^57\",true,536870913]],[\"^84\",[80,\"^5J\",true,536870913]],[\"^84\",[80,\"^X\",false,536870913]],[\"^84\",[80,\"^1K\",\"^8C\",536870913]],[\"^84\",[81,\"^48\",1775980635495,536870913]],[\"^84\",[81,\"^7M\",\"used template\",536870913]],[\"^84\",[81,\"^7@\",\"d3BHN\",536870913]],[\"^84\",[81,\"^69\",124,536870913]],[\"^84\",[81,\"^6:\",\"Used template\",536870913]],[\"^84\",[81,\"^3;\",1775980635495,536870913]],[\"^84\",[81,\"^2T\",\"~u00000002-9803-6990-6000-000000000000\",536870913]],[\"^84\",[81,\"^<\",\"^=\",536870913]],[\"^84\",[81,\"^;\",\"^4L\",536870913]],[\"^84\",[81,\"^>\",true,536870913]],[\"^84\",[81,\"^C\",\"^D\",536870913]],[\"^84\",[81,\"^57\",true,536870913]],[\"^84\",[81,\"^6G\",38,536870913]],[\"^84\",[81,\"^5J\",true,536870913]],[\"^84\",[81,\"^X\",false,536870913]],[\"^84\",[81,\"^1K\",\"^85\",536870913]],[\"^84\",[82,\"^L\",13,536870913]],[\"^84\",[82,\"^48\",1775980635492,536870913]],[\"^84\",[82,\"^7@\",\"d3BGU\",536870913]],[\"^84\",[82,\"^4S\",13,536870913]],[\"^84\",[82,\"^3X\",13,536870913]],[\"^84\",[82,\"^6:\",\"Month\",536870913]],[\"^84\",[82,\"^3;\",1775980635492,536870913]],[\"^84\",[82,\"^2T\",\"~u00000002-2073-3937-9700-000000000000\",536870913]],[\"^84\",[82,\"^;\",\"^5F\",536870913]],[\"^84\",[82,\"^57\",true,536870913]],[\"^84\",[82,\"^6?\",13,536870913]],[\"^84\",[83,\"^48\",1775980635494,536870913]],[\"^84\",[83,\"^7M\",\"history value\",536870913]],[\"^84\",[83,\"^7@\",\"d3BHD\",536870913]],[\"^84\",[83,\"^69\",124,536870913]],[\"^84\",[83,\"^6:\",\"History value\",536870913]],[\"^84\",[83,\"^3;\",1775980635494,536870913]],[\"^84\",[83,\"^2T\",\"~u00000002-5131-3603-7000-000000000000\",536870913]],[\"^84\",[83,\"^<\",\"^=\",536870913]],[\"^84\",[83,\"^;\",\"^5T\",536870913]],[\"^84\",[83,\"^>\",true,536870913]],[\"^84\",[83,\"^C\",\"^D\",536870913]],[\"^84\",[83,\"^57\",true,536870913]],[\"^84\",[83,\"^5J\",true,536870913]],[\"^84\",[83,\"^1K\",\"^8=\",536870913]],[\"^84\",[84,\"^48\",1775980635493,536870913]],[\"^84\",[84,\"^7M\",\"view filters\",536870913]],[\"^84\",[84,\"^7@\",\"d3BGm\",536870913]],[\"^84\",[84,\"^69\",124,536870913]],[\"^84\",[84,\"^6:\",\"View filters\",536870913]],[\"^84\",[84,\"^3;\",1775980635493,536870913]],[\"^84\",[84,\"^2T\",\"~u00000002-1702-3936-3300-000000000000\",536870913]],[\"^84\",[84,\"^<\",\"^=\",536870913]],[\"^84\",[84,\"^;\",\"^61\",536870913]],[\"^84\",[84,\"^>\",true,536870913]],[\"^84\",[84,\"^57\",true,536870913]],[\"^84\",[84,\"^5J\",true,536870913]],[\"^84\",[84,\"^X\",false,536870913]],[\"^84\",[84,\"^1K\",\"^88\",536870913]],[\"^84\",[85,\"^48\",1775980635496,536870913]],[\"^84\",[85,\"^7M\",\"journal\",536870913]],[\"^84\",[85,\"^69\",14,536870913]],[\"^84\",[85,\"^6:\",\"Journal\",536870913]],[\"^84\",[85,\"^3;\",1775980635496,536870913]],[\"^84\",[85,\"^2T\",\"~u00000002-1979-7410-8100-000000000000\",536870913]],[\"^84\",[85,\"^;\",\"^6A\",536870913]],[\"^84\",[85,\"^57\",true,536870913]],[\"^84\",[85,\"^3K\",104,536870913]],[\"^84\",[85,\"^55\",\"MMM do, yyyy\",536870913]],[\"^84\",[86,\"^48\",1775980635486,536870913]],[\"^84\",[86,\"^7M\",\"built in?\",536870913]],[\"^84\",[86,\"^7@\",\"d3BF7\",536870913]],[\"^84\",[86,\"^69\",124,536870913]],[\"^84\",[86,\"^6:\",\"Built in?\",536870913]],[\"^84\",[86,\"^3;\",1775980635486,536870913]],[\"^84\",[86,\"^2T\",\"~u00000002-1125-9581-6000-000000000000\",536870913]],[\"^84\",[86,\"^<\",\"^=\",536870913]],[\"^84\",[86,\"^;\",\"^57\",536870913]],[\"^84\",[86,\"^>\",true,536870913]],[\"^84\",[86,\"^57\",true,536870913]],[\"^84\",[86,\"^5J\",true,536870913]],[\"^84\",[86,\"^1K\",\"^8?\",536870913]],[\"^84\",[87,\"^48\",1775980635496,536870913]],[\"^84\",[87,\"^7M\",\"whiteboard\",536870913]],[\"^84\",[87,\"^69\",14,536870913]],[\"^84\",[87,\"^6:\",\"Whiteboard\",536870913]],[\"^84\",[87,\"^3;\",1775980635496,536870913]],[\"^84\",[87,\"^2T\",\"~u00000002-1013-6984-5200-000000000000\",536870913]],[\"^84\",[87,\"^;\",\"^6<\",536870913]],[\"^84\",[87,\"^57\",true,536870913]],[\"^84\",[87,\"^3K\",104,536870913]],[\"^84\",[88,\"^48\",1775980635494,536870913]],[\"^84\",[88,\"^7M\",\"asset alignment\",536870913]],[\"^84\",[88,\"^7@\",\"d3BH3\",536870913]],[\"^84\",[88,\"^69\",124,536870913]],[\"^84\",[88,\"^6:\",\"Asset alignment\",536870913]],[\"^84\",[88,\"^3;\",1775980635494,536870913]],[\"^84\",[88,\"^2T\",\"~u00000002-7135-0412-8000-000000000000\",536870913]],[\"^84\",[88,\"^<\",\"^=\",536870913]],[\"^84\",[88,\"^;\",\"^:\",536870913]],[\"^84\",[88,\"^>\",true,536870913]],[\"^84\",[88,\"^57\",true,536870913]],[\"^84\",[88,\"^5J\",true,536870913]],[\"^84\",[88,\"^X\",false,536870913]],[\"^84\",[88,\"^1K\",\"^8D\",536870913]],[\"^84\",[89,\"^48\",1775980635496,536870913]],[\"^84\",[89,\"^7M\",\"root tag\",536870913]],[\"^84\",[89,\"^69\",14,536870913]],[\"^84\",[89,\"^6:\",\"Root Tag\",536870913]],[\"^84\",[89,\"^3;\",1775980635496,536870913]],[\"^84\",[89,\"^2T\",\"~u00000002-2737-8382-7000-000000000000\",536870913]],[\"^84\",[89,\"^;\",\"^5Q\",536870913]],[\"^84\",[89,\"^57\",true,536870913]],[\"^84\",[90,\"^48\",1775980635489,536870913]],[\"^84\",[90,\"^7M\",\"heading\",536870913]],[\"^84\",[90,\"^7@\",\"d3BFk\",536870913]],[\"^84\",[90,\"^69\",124,536870913]],[\"^84\",[90,\"^6:\",\"Heading\",536870913]],[\"^84\",[90,\"^3;\",1775980635489,536870913]],[\"^84\",[90,\"^2T\",\"~u00000002-1858-7494-1500-000000000000\",536870913]],[\"^84\",[90,\"^<\",\"^=\",536870913]],[\"^84\",[90,\"^;\",\"^2L\",536870913]],[\"^84\",[90,\"^>\",true,536870913]],[\"^84\",[90,\"^57\",true,536870913]],[\"^84\",[90,\"^5J\",true,536870913]],[\"^84\",[90,\"^1K\",\"^8H\",536870913]],[\"^84\",[91,\"^48\",1775980635490,536870913]],[\"^84\",[91,\"^7M\",\"annotation image\",536870913]],[\"^84\",[91,\"^7@\",\"d3BFv\",536870913]],[\"^84\",[91,\"^69\",124,536870913]],[\"^84\",[91,\"^6:\",\"Annotation image\",536870913]],[\"^84\",[91,\"^3;\",1775980635490,536870913]],[\"^84\",[91,\"^2T\",\"~u00000002-1377-6700-9000-000000000000\",536870913]],[\"^84\",[91,\"^<\",\"^=\",536870913]],[\"^84\",[91,\"^;\",\"^M\",536870913]],[\"^84\",[91,\"^>\",true,536870913]],[\"^84\",[91,\"^C\",\"^D\",536870913]],[\"^84\",[91,\"^57\",true,536870913]],[\"^84\",[91,\"^5J\",true,536870913]],[\"^84\",[91,\"^1K\",\"^8=\",536870913]],[\"^84\",[92,\"^48\",1775980635487,536870913]],[\"^84\",[92,\"^7M\",\"property view context\",536870913]],[\"^84\",[92,\"^7@\",\"d3BFB\",536870913]],[\"^84\",[92,\"^69\",124,536870913]],[\"^84\",[92,\"^6:\",\"Property view context\",536870913]],[\"^84\",[92,\"^3;\",1775980635487,536870913]],[\"^84\",[92,\"^2T\",\"~u00000002-1547-3958-2800-000000000000\",536870913]],[\"^84\",[92,\"^<\",\"^=\",536870913]],[\"^84\",[92,\"^;\",\"^44\",536870913]],[\"^84\",[92,\"^>\",true,536870913]],[\"^84\",[92,\"^57\",true,536870913]],[\"^84\",[92,\"^5J\",true,536870913]],[\"^84\",[92,\"^1K\",\"^8D\",536870913]],[\"^84\",[93,\"^48\",1775980635492,536870913]],[\"^84\",[93,\"^7M\",\"view feature type\",536870913]],[\"^84\",[93,\"^7@\",\"d3BGh\",536870913]],[\"^84\",[93,\"^69\",124,536870913]],[\"^84\",[93,\"^6:\",\"View Feature Type\",536870913]],[\"^84\",[93,\"^3;\",1775980635492,536870913]],[\"^84\",[93,\"^2T\",\"~u00000002-9391-4187-1000-000000000000\",536870913]],[\"^84\",[93,\"^<\",\"^=\",536870913]],[\"^84\",[93,\"^;\",\"^5>\",536870913]],[\"^84\",[93,\"^>\",true,536870913]],[\"^84\",[93,\"^57\",true,536870913]],[\"^84\",[93,\"^5J\",true,536870913]],[\"^84\",[93,\"^X\",false,536870913]],[\"^84\",[93,\"^1K\",\"^8D\",536870913]],[\"^84\",[94,\"^L\",32,536870913]],[\"^84\",[94,\"^48\",1775980635491,536870913]],[\"^84\",[94,\"^7@\",\"d3BGF\",536870913]],[\"^84\",[94,\"^4S\",32,536870913]],[\"^84\",[94,\"^3X\",32,536870913]],[\"^84\",[94,\"^6:\",\"Low\",536870913]],[\"^84\",[94,\"^3;\",1775980635491,536870913]],[\"^84\",[94,\"^2T\",\"~u00000002-2107-4537-4800-000000000000\",536870913]],[\"^84\",[94,\"^;\",\"^68\",536870913]],[\"^84\",[94,\"^57\",true,536870913]],[\"^84\",[94,\"^6?\",32,536870913]],[\"^84\",[94,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlLow\"],536870913]],[\"^84\",[95,\"^48\",1775980635491,536870913]],[\"^84\",[95,\"^7M\",\"repeating recur frequency\",536870913]],[\"^84\",[95,\"^7@\",\"d3BGN\",536870913]],[\"^84\",[95,\"^69\",124,536870913]],[\"^84\",[95,\"^6:\",\"Repeating recur frequency\",536870913]],[\"^84\",[95,\"^3;\",1775980635491,536870913]],[\"^84\",[95,\"^2T\",\"~u00000002-8716-1592-2000-000000000000\",536870913]],[\"^84\",[95,\"^<\",\"^=\",536870913]],[\"^84\",[95,\"^;\",\"^5K\",536870913]],[\"^84\",[95,\"^>\",true,536870913]],[\"^84\",[95,\"^C\",\"^D\",536870913]],[\"^84\",[95,\"^57\",true,536870913]],[\"^84\",[95,\"^5L\",175,536870913]],[\"^84\",[95,\"^3P\",true,536870913]],[\"^84\",[95,\"^X\",false,536870913]],[\"^84\",[95,\"^1K\",\"~:number\",536870913]],[\"^84\",[96,\"^48\",1775980635492,536870913]],[\"^84\",[96,\"^7M\",\"node repeats?\",536870913]],[\"^84\",[96,\"^7@\",\"d3BGW\",536870913]],[\"^84\",[96,\"^69\",124,536870913]],[\"^84\",[96,\"^6:\",\"Node Repeats?\",536870913]],[\"^84\",[96,\"^3;\",1775980635492,536870913]],[\"^84\",[96,\"^2T\",\"~u00000002-1908-1217-8900-000000000000\",536870913]],[\"^84\",[96,\"^<\",\"^=\",536870913]],[\"^84\",[96,\"^;\",\"^7\",536870913]],[\"^84\",[96,\"^>\",true,536870913]],[\"^84\",[96,\"^57\",true,536870913]],[\"^84\",[96,\"^5J\",true,536870913]],[\"^84\",[96,\"^1K\",\"^8?\",536870913]],[\"^84\",[97,\"^48\",1775980635488,536870913]],[\"^84\",[97,\"^7M\",\"closed value property\",536870913]],[\"^84\",[97,\"^7@\",\"d3BFO\",536870913]],[\"^84\",[97,\"^69\",124,536870913]],[\"^84\",[97,\"^6:\",\"Closed value property\",536870913]],[\"^84\",[97,\"^3;\",1775980635488,536870913]],[\"^84\",[97,\"^2T\",\"~u00000002-1157-7928-1300-000000000000\",536870913]],[\"^84\",[97,\"^<\",\"^14\",536872483]],[\"^84\",[97,\"^;\",\"^L\",536870913]],[\"^84\",[97,\"^>\",true,536870913]],[\"^84\",[97,\"^C\",\"^D\",536870913]],[\"^84\",[97,\"^57\",true,536870913]],[\"^84\",[97,\"^5J\",true,536870913]],[\"^84\",[97,\"^X\",false,536870913]],[\"^84\",[97,\"^1K\",\"^8=\",536870913]],[\"^84\",[98,\"^48\",1775980635495,536870913]],[\"^84\",[98,\"^7M\",\"reaction emoji\",536870913]],[\"^84\",[98,\"^7@\",\"d3BHL\",536870913]],[\"^84\",[98,\"^69\",124,536870913]],[\"^84\",[98,\"^6:\",\"Reaction emoji\",536870913]],[\"^84\",[98,\"^3;\",1775980635495,536870913]],[\"^84\",[98,\"^2T\",\"~u00000002-9877-5864-5000-000000000000\",536870913]],[\"^84\",[98,\"^<\",\"^=\",536870913]],[\"^84\",[98,\"^;\",\"^10\",536870913]],[\"^84\",[98,\"^>\",true,536870913]],[\"^84\",[98,\"^57\",true,536870913]],[\"^84\",[98,\"^5J\",true,536870913]],[\"^84\",[98,\"^X\",false,536870913]],[\"^84\",[98,\"^1K\",\"^87\",536870913]],[\"^84\",[99,\"^L\",32,536870913]],[\"^84\",[99,\"^48\",1775980635491,536870913]],[\"^84\",[99,\"^7@\",\"d3BGH\",536870913]],[\"^84\",[99,\"^4S\",32,536870913]],[\"^84\",[99,\"^3X\",32,536870913]],[\"^84\",[99,\"^6:\",\"High\",536870913]],[\"^84\",[99,\"^3;\",1775980635491,536870913]],[\"^84\",[99,\"^2T\",\"~u00000002-5672-2766-8000-000000000000\",536870913]],[\"^84\",[99,\"^;\",\"^1=\",536870913]],[\"^84\",[99,\"^57\",true,536870913]],[\"^84\",[99,\"^6?\",32,536870913]],[\"^84\",[99,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlHigh\"],536870913]],[\"^84\",[100,\"^L\",19,536870913]],[\"^84\",[100,\"^48\",1775980635490,536870913]],[\"^84\",[100,\"^7@\",\"d3BFs\",536870913]],[\"^84\",[100,\"^4S\",19,536870913]],[\"^84\",[100,\"^3X\",19,536870913]],[\"^84\",[100,\"^6:\",\"blue\",536870913]],[\"^84\",[100,\"^3;\",1775980635490,536870913]],[\"^84\",[100,\"^2T\",\"~u00000002-1836-0512-4100-000000000000\",536870913]],[\"^84\",[100,\"^;\",\"^1G\",536870913]],[\"^84\",[100,\"^57\",true,536870913]],[\"^84\",[100,\"^6?\",19,536870913]],[\"^84\",[101,\"^48\",1775980635493,536870913]],[\"^84\",[101,\"^7M\",\"view ordered columns\",536870913]],[\"^84\",[101,\"^7@\",\"d3BGo\",536870913]],[\"^84\",[101,\"^69\",124,536870913]],[\"^84\",[101,\"^6:\",\"View ordered columns\",536870913]],[\"^84\",[101,\"^3;\",1775980635493,536870913]],[\"^84\",[101,\"^2T\",\"~u00000002-1485-5871-0000-000000000000\",536870913]],[\"^84\",[101,\"^<\",\"^=\",536870913]],[\"^84\",[101,\"^;\",\"^1U\",536870913]],[\"^84\",[101,\"^>\",true,536870913]],[\"^84\",[101,\"^57\",true,536870913]],[\"^84\",[101,\"^5J\",true,536870913]],[\"^84\",[101,\"^X\",false,536870913]],[\"^84\",[101,\"^1K\",\"~:coll\",536870913]],[\"^84\",[102,\"^48\",1775980635489,536870913]],[\"^84\",[102,\"^7M\",\"background color\",536870913]],[\"^84\",[102,\"^7@\",\"d3BFj\",536870913]],[\"^84\",[102,\"^69\",124,536870913]],[\"^84\",[102,\"^6:\",\"Background color\",536870913]],[\"^84\",[102,\"^3;\",1775980635489,536870913]],[\"^84\",[102,\"^2T\",\"~u00000002-5191-2660-6000-000000000000\",536870913]],[\"^84\",[102,\"^<\",\"^=\",536870913]],[\"^84\",[102,\"^;\",\"^26\",536870913]],[\"^84\",[102,\"^>\",true,536870913]],[\"^84\",[102,\"^C\",\"^D\",536870913]],[\"^84\",[102,\"^57\",true,536870913]],[\"^84\",[102,\"^5J\",true,536870913]],[\"^84\",[102,\"^1K\",\"^8>\",536870913]],[\"^84\",[103,\"^48\",1775980635496,536870913]],[\"^84\",[103,\"^7M\",\"cards\",536870913]],[\"^84\",[103,\"^69\",14,536870913]],[\"^84\",[103,\"^6:\",\"Cards\",536870913]],[\"^84\",[103,\"^3;\",1775980635496,536870913]],[\"^84\",[103,\"^2T\",\"~u00000002-1284-2651-6700-000000000000\",536870913]],[\"^84\",[103,\"^;\",\"^2C\",536870913]],[\"^84\",[103,\"^57\",true,536870913]],[\"^84\",[103,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[103,\"^3K\",142,536870913]],[\"^84\",[104,\"^48\",1775980635496,536870913]],[\"^84\",[104,\"^7M\",\"page\",536870913]],[\"^84\",[104,\"^69\",14,536870913]],[\"^84\",[104,\"^6:\",\"Page\",536870913]],[\"^84\",[104,\"^3;\",1775980635496,536870913]],[\"^84\",[104,\"^2T\",\"~u00000002-1484-3403-2900-000000000000\",536870913]],[\"^84\",[104,\"^;\",\"^2S\",536870913]],[\"^84\",[104,\"^57\",true,536870913]],[\"^84\",[104,\"^3K\",89,536870913]],[\"^84\",[105,\"^L\",13,536870913]],[\"^84\",[105,\"^48\",1775980635492,536870913]],[\"^84\",[105,\"^7@\",\"d3BGT\",536870913]],[\"^84\",[105,\"^4S\",13,536870913]],[\"^84\",[105,\"^3X\",13,536870913]],[\"^84\",[105,\"^6:\",\"Week\",536870913]],[\"^84\",[105,\"^3;\",1775980635492,536870913]],[\"^84\",[105,\"^2T\",\"~u00000002-2130-9244-4900-000000000000\",536870913]],[\"^84\",[105,\"^;\",\"^U\",536870913]],[\"^84\",[105,\"^57\",true,536870913]],[\"^84\",[105,\"^6?\",13,536870913]],[\"^84\",[106,\"^48\",1775980635490,536870913]],[\"^84\",[106,\"^7M\",\"tldraw shape\",536870913]],[\"^84\",[106,\"^7@\",\"d3BG1\",536870913]],[\"^84\",[106,\"^69\",124,536870913]],[\"^84\",[106,\"^6:\",\"Tldraw Shape\",536870913]],[\"^84\",[106,\"^3;\",1775980635490,536870913]],[\"^84\",[106,\"^2T\",\"~u00000002-1313-2454-2000-000000000000\",536870913]],[\"^84\",[106,\"^<\",\"^=\",536870913]],[\"^84\",[106,\"^;\",\"^3B\",536870913]],[\"^84\",[106,\"^>\",true,536870913]],[\"^84\",[106,\"^57\",true,536870913]],[\"^84\",[106,\"^5J\",true,536870913]],[\"^84\",[106,\"^1K\",\"^88\",536870913]],[\"^84\",[107,\"^48\",1775980635493,536870913]],[\"^84\",[107,\"^7M\",\"image height\",536870913]],[\"^84\",[107,\"^7@\",\"d3BGx\",536870913]],[\"^84\",[107,\"^69\",124,536870913]],[\"^84\",[107,\"^6:\",\"Image height\",536870913]],[\"^84\",[107,\"^3;\",1775980635493,536870913]],[\"^84\",[107,\"^2T\",\"~u00000002-3306-1650-4000-000000000000\",536870913]],[\"^84\",[107,\"^<\",\"^=\",536870913]],[\"^84\",[107,\"^;\",\"^3M\",536870913]],[\"^84\",[107,\"^>\",true,536870913]],[\"^84\",[107,\"^57\",true,536870913]],[\"^84\",[107,\"^5J\",true,536870913]],[\"^84\",[107,\"^X\",false,536870913]],[\"^84\",[107,\"^1K\",\"^8C\",536870913]],[\"^84\",[108,\"^48\",1775980635488,536870913]],[\"^84\",[108,\"^7M\",\"code mode\",536870913]],[\"^84\",[108,\"^7@\",\"d3BFU\",536870913]],[\"^84\",[108,\"^69\",124,536870913]],[\"^84\",[108,\"^6:\",\"Code Mode\",536870913]],[\"^84\",[108,\"^3;\",1775980635488,536870913]],[\"^84\",[108,\"^2T\",\"~u00000002-8578-9716-5000-000000000000\",536870913]],[\"^84\",[108,\"^<\",\"^=\",536870913]],[\"^84\",[108,\"^;\",\"^40\",536870913]],[\"^84\",[108,\"^>\",true,536870913]],[\"^84\",[108,\"^57\",true,536870913]],[\"^84\",[108,\"^5J\",true,536870913]],[\"^84\",[108,\"^X\",false,536870913]],[\"^84\",[108,\"^1K\",\"^87\",536870913]],[\"^84\",[108,\"^44\",\"^8E\",536870913]],[\"^84\",[109,\"^48\",1775980635495,536870913]],[\"^84\",[109,\"^7M\",\"node created by\",536870913]],[\"^84\",[109,\"^7@\",\"d3BHF\",536870913]],[\"^84\",[109,\"^69\",124,536870913]],[\"^84\",[109,\"^6:\",\"Node created by\",536870913]],[\"^84\",[109,\"^3;\",1775980635495,536870913]],[\"^84\",[109,\"^2T\",\"~u00000002-8544-3390-8000-000000000000\",536870913]],[\"^84\",[109,\"^<\",\"^=\",536870913]],[\"^84\",[109,\"^;\",\"^4<\",536870913]],[\"^84\",[109,\"^>\",true,536870913]],[\"^84\",[109,\"^C\",\"^D\",536870913]],[\"^84\",[109,\"^57\",true,536870913]],[\"^84\",[109,\"^5J\",true,536870913]],[\"^84\",[109,\"^1K\",\"^8=\",536870913]],[\"^84\",[110,\"^48\",1775980635491,536870913]],[\"^84\",[110,\"^7M\",\"choice exclusions\",536870913]],[\"^84\",[110,\"^7@\",\"d3BG5\",536870913]],[\"^84\",[110,\"^69\",124,536870913]],[\"^84\",[110,\"^6:\",\"Choice exclusions\",536870913]],[\"^84\",[110,\"^3;\",1775980635491,536870913]],[\"^84\",[110,\"^2T\",\"~u00000002-1233-5229-4600-000000000000\",536870913]],[\"^84\",[110,\"^<\",\"^14\",536870913]],[\"^84\",[110,\"^;\",\"^13\",536870913]],[\"^84\",[110,\"^>\",true,536870913]],[\"^84\",[110,\"^C\",\"^D\",536870913]],[\"^84\",[110,\"^57\",true,536870913]],[\"^84\",[110,\"^5J\",true,536870913]],[\"^84\",[110,\"^X\",false,536870913]],[\"^84\",[110,\"^1K\",\"^85\",536870913]],[\"^84\",[110,\"^44\",\"^8J\",536870913]],[\"^84\",[111,\"^;\",\"^R\",536870913]],[\"^84\",[111,\"^28\",[\"^ \",\"^8F\",65,\"^8G\",24],536870913]],[\"^84\",[112,\"^48\",1775980635490,536870913]],[\"^84\",[112,\"^7M\",\"title format\",536870913]],[\"^84\",[112,\"^7@\",\"d3BG2\",536870913]],[\"^84\",[112,\"^69\",124,536870913]],[\"^84\",[112,\"^6:\",\"Title Format\",536870913]],[\"^84\",[112,\"^3;\",1775980635490,536870913]],[\"^84\",[112,\"^2T\",\"~u00000002-1536-4979-5400-000000000000\",536870913]],[\"^84\",[112,\"^<\",\"^=\",536870913]],[\"^84\",[112,\"^;\",\"^55\",536870913]],[\"^84\",[112,\"^>\",true,536870913]],[\"^84\",[112,\"^57\",true,536870913]],[\"^84\",[112,\"^X\",false,536870913]],[\"^84\",[112,\"^1K\",\"^87\",536870913]],[\"^84\",[113,\"^48\",1775980635490,536870913]],[\"^84\",[113,\"^7M\",\"included references\",536870913]],[\"^84\",[113,\"^7@\",\"d3BFy\",536870913]],[\"^84\",[113,\"^69\",124,536870913]],[\"^84\",[113,\"^6:\",\"Included references\",536870913]],[\"^84\",[113,\"^3;\",1775980635490,536870913]],[\"^84\",[113,\"^2T\",\"~u00000002-1680-5777-0300-000000000000\",536870913]],[\"^84\",[113,\"^<\",\"^14\",536870913]],[\"^84\",[113,\"^;\",\"^2F\",536870913]],[\"^84\",[113,\"^>\",true,536870913]],[\"^84\",[113,\"^C\",\"^D\",536870913]],[\"^84\",[113,\"^57\",true,536870913]],[\"^84\",[113,\"^5J\",true,536870913]],[\"^84\",[113,\"^1K\",\"^85\",536870913]],[\"^84\",[114,\"^L\",26,536870913]],[\"^84\",[114,\"^48\",1775980635491,536870913]],[\"^84\",[114,\"^7@\",\"d3BG9\",536870913]],[\"^84\",[114,\"^4S\",26,536870913]],[\"^84\",[114,\"^3X\",26,536870913]],[\"^84\",[114,\"^6:\",\"Todo\",536870913]],[\"^84\",[114,\"^3;\",1775980635491,536870913]],[\"^84\",[114,\"^2T\",\"~u00000002-1615-5853-7700-000000000000\",536870913]],[\"^84\",[114,\"^;\",\"^5H\",536870913]],[\"^84\",[114,\"^57\",true,536870913]],[\"^84\",[114,\"^4W\",false,536870913]],[\"^84\",[114,\"^6?\",26,536870913]],[\"^84\",[114,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Todo\"],536870913]],[\"^84\",[115,\"^48\",1775980635494,536870913]],[\"^84\",[115,\"^7M\",\"history property\",536870913]],[\"^84\",[115,\"^7@\",\"d3BHC\",536870913]],[\"^84\",[115,\"^69\",124,536870913]],[\"^84\",[115,\"^6:\",\"History property\",536870913]],[\"^84\",[115,\"^3;\",1775980635494,536870913]],[\"^84\",[115,\"^2T\",\"~u00000002-1600-4090-8200-000000000000\",536870913]],[\"^84\",[115,\"^<\",\"^=\",536870913]],[\"^84\",[115,\"^;\",\"^5V\",536870913]],[\"^84\",[115,\"^>\",true,536870913]],[\"^84\",[115,\"^C\",\"^D\",536870913]],[\"^84\",[115,\"^57\",true,536870913]],[\"^84\",[115,\"^5J\",true,536870913]],[\"^84\",[115,\"^1K\",\"^86\",536870913]],[\"^84\",[116,\"^48\",1775980635495,536870913]],[\"^84\",[116,\"^7M\",\"recycle original parent\",536870913]],[\"^84\",[116,\"^7@\",\"d3BHI\",536870913]],[\"^84\",[116,\"^69\",124,536870913]],[\"^84\",[116,\"^6:\",\"Recycle original parent\",536870913]],[\"^84\",[116,\"^3;\",1775980635495,536870913]],[\"^84\",[116,\"^2T\",\"~u00000002-4970-0399-4000-000000000000\",536870913]],[\"^84\",[116,\"^<\",\"^=\",536870913]],[\"^84\",[116,\"^;\",\"^63\",536870913]],[\"^84\",[116,\"^>\",true,536870913]],[\"^84\",[116,\"^C\",\"^D\",536870913]],[\"^84\",[116,\"^57\",true,536870913]],[\"^84\",[116,\"^5J\",true,536870913]],[\"^84\",[116,\"^X\",false,536870913]],[\"^84\",[116,\"^1K\",\"^85\",536870913]],[\"^84\",[117,\"^48\",1775980635490,536870913]],[\"^84\",[117,\"^7M\",\"annotation data\",536870913]],[\"^84\",[117,\"^7@\",\"d3BFw\",536870913]],[\"^84\",[117,\"^69\",124,536870913]],[\"^84\",[117,\"^6:\",\"Annotation data\",536870913]],[\"^84\",[117,\"^3;\",1775980635490,536870913]],[\"^84\",[117,\"^2T\",\"~u00000002-5458-2940-2000-000000000000\",536870913]],[\"^84\",[117,\"^<\",\"^=\",536870913]],[\"^84\",[117,\"^;\",\"^6C\",536870913]],[\"^84\",[117,\"^>\",true,536870913]],[\"^84\",[117,\"^57\",true,536870913]],[\"^84\",[117,\"^5J\",true,536870913]],[\"^84\",[117,\"^1K\",\"^88\",536870913]],[\"^84\",[118,\"^48\",1775980635492,536870913]],[\"^84\",[118,\"^7M\",\"view group by property\",536870913]],[\"^84\",[118,\"^7@\",\"d3BGi\",536870913]],[\"^84\",[118,\"^69\",124,536870913]],[\"^84\",[118,\"^6:\",\"View group by property\",536870913]],[\"^84\",[118,\"^3;\",1775980635492,536870913]],[\"^84\",[118,\"^2T\",\"~u00000002-8092-1623-6000-000000000000\",536870913]],[\"^84\",[118,\"^<\",\"^=\",536870913]],[\"^84\",[118,\"^;\",\"^43\",536870913]],[\"^84\",[118,\"^>\",true,536870913]],[\"^84\",[118,\"^C\",\"^D\",536870913]],[\"^84\",[118,\"^57\",true,536870913]],[\"^84\",[118,\"^5J\",true,536870913]],[\"^84\",[118,\"^X\",false,536870913]],[\"^84\",[118,\"^1K\",\"^86\",536870913]],[\"^84\",[119,\"^48\",1775980635487,536870913]],[\"^84\",[119,\"^7M\",\"hide this property or page\",536870913]],[\"^84\",[119,\"^7@\",\"d3BF8\",536870913]],[\"^84\",[119,\"^69\",124,536870913]],[\"^84\",[119,\"^6:\",\"Hide this property or page\",536870913]],[\"^84\",[119,\"^3;\",1775980635487,536870913]],[\"^84\",[119,\"^2T\",\"~u00000002-6836-5746-0000-000000000000\",536870913]],[\"^84\",[119,\"^<\",\"^=\",536870913]],[\"^84\",[119,\"^;\",\"^5J\",536870913]],[\"^84\",[119,\"^>\",true,536870913]],[\"^84\",[119,\"^57\",true,536870913]],[\"^84\",[119,\"^5J\",true,536870913]],[\"^84\",[119,\"^1K\",\"^8?\",536870913]],[\"^84\",[120,\"^48\",1775980635488,536870913]],[\"^84\",[120,\"^7M\",\"node links to\",536870913]],[\"^84\",[120,\"^7@\",\"d3BFM\",536870913]],[\"^84\",[120,\"^69\",124,536870913]],[\"^84\",[120,\"^6:\",\"Node links to\",536870913]],[\"^84\",[120,\"^3;\",1775980635488,536870913]],[\"^84\",[120,\"^2T\",\"~u00000002-1872-3999-9300-000000000000\",536870913]],[\"^84\",[120,\"^<\",\"^=\",536870913]],[\"^84\",[120,\"^;\",\"^2I\",536870913]],[\"^84\",[120,\"^>\",true,536870913]],[\"^84\",[120,\"^C\",\"^D\",536870913]],[\"^84\",[120,\"^57\",true,536870913]],[\"^84\",[120,\"^5J\",true,536870913]],[\"^84\",[120,\"^X\",false,536870913]],[\"^84\",[120,\"^1K\",\"^8=\",536870913]],[\"^84\",[121,\"^48\",1775980635489,536870913]],[\"^84\",[121,\"^7M\",\"tag properties\",536870913]],[\"^84\",[121,\"^7@\",\"d3BFZ\",536870913]],[\"^84\",[121,\"^69\",124,536870913]],[\"^84\",[121,\"^6:\",\"Tag Properties\",536870913]],[\"^84\",[121,\"^3;\",1775980635489,536870913]],[\"^84\",[121,\"^2T\",\"~u00000002-2123-7120-5000-000000000000\",536870913]],[\"^84\",[121,\"^<\",\"^14\",536870913]],[\"^84\",[121,\"^;\",\"^4K\",536870913]],[\"^84\",[121,\"^>\",true,536870913]],[\"^84\",[121,\"^C\",\"^D\",536870913]],[\"^84\",[121,\"^57\",true,536870913]],[\"^84\",[121,\"^X\",true,536870913]],[\"^84\",[121,\"^1K\",\"^86\",536870913]],[\"^84\",[121,\"^44\",\"^8J\",536870913]],[\"^84\",[122,\"^48\",1775980635488,536870913]],[\"^84\",[122,\"^7M\",\"node order\",536870913]],[\"^84\",[122,\"^7@\",\"d3BFI\",536870913]],[\"^84\",[122,\"^69\",124,536870913]],[\"^84\",[122,\"^6:\",\"Node order\",536870913]],[\"^84\",[122,\"^3;\",1775980635488,536870913]],[\"^84\",[122,\"^2T\",\"~u00000002-1429-2824-3700-000000000000\",536870913]],[\"^84\",[122,\"^<\",\"^=\",536870913]],[\"^84\",[122,\"^;\",\"^7@\",536870913]],[\"^84\",[122,\"^>\",true,536870913]],[\"^84\",[122,\"^57\",true,536870913]],[\"^84\",[122,\"^5J\",true,536870913]],[\"^84\",[122,\"^X\",false,536870913]],[\"^84\",[122,\"^1K\",\"^87\",536870913]],[\"^84\",[123,\"^48\",1775980635494,536870913]],[\"^84\",[123,\"^7M\",\"enable property history\",536870913]],[\"^84\",[123,\"^7@\",\"d3BH9\",536870913]],[\"^84\",[123,\"^69\",124,536870913]],[\"^84\",[123,\"^6:\",\"Enable property history\",536870913]],[\"^84\",[123,\"^3;\",1775980635494,536870913]],[\"^84\",[123,\"^2T\",\"~u00000002-8058-5960-2000-000000000000\",536870913]],[\"^84\",[123,\"^<\",\"^=\",536870913]],[\"^84\",[123,\"^;\",\"^4N\",536870913]],[\"^84\",[123,\"^>\",true,536870913]],[\"^84\",[123,\"^57\",true,536870913]],[\"^84\",[123,\"^2A\",162,536870913]],[\"^84\",[123,\"^X\",true,536870913]],[\"^84\",[123,\"^1K\",\"^8?\",536870913]],[\"^84\",[123,\"^44\",\"^86\",536870913]],[\"^84\",[124,\"^48\",1775980635496,536870913]],[\"^84\",[124,\"^7M\",\"property\",536870913]],[\"^84\",[124,\"^69\",14,536870913]],[\"^84\",[124,\"^6:\",\"Property\",536870913]],[\"^84\",[124,\"^3;\",1775980635496,536870913]],[\"^84\",[124,\"^2T\",\"~u00000002-1038-7670-4800-000000000000\",536870913]],[\"^84\",[124,\"^;\",\"^30\",536870913]],[\"^84\",[124,\"^57\",true,536870913]],[\"^84\",[124,\"^3K\",89,536870913]],[\"^84\",[125,\"^48\",1775980635489,536870913]],[\"^84\",[125,\"^7M\",\"page tags\",536870913]],[\"^84\",[125,\"^7@\",\"d3BFh\",536870913]],[\"^84\",[125,\"^69\",124,536870913]],[\"^84\",[125,\"^6:\",\"Page Tags\",536870913]],[\"^84\",[125,\"^3;\",1775980635489,536870913]],[\"^84\",[125,\"^2T\",\"~u00000002-2133-5311-8500-000000000000\",536870913]],[\"^84\",[125,\"^<\",\"^14\",536870913]],[\"^84\",[125,\"^;\",\"^4X\",536870913]],[\"^84\",[125,\"^>\",true,536870913]],[\"^84\",[125,\"^C\",\"^D\",536870913]],[\"^84\",[125,\"^57\",true,536870913]],[\"^84\",[125,\"^2A\",164,536870913]],[\"^84\",[125,\"^X\",true,536870913]],[\"^84\",[125,\"^1K\",\"^89\",536870913]],[\"^84\",[125,\"^44\",\"^89\",536870913]],[\"^84\",[126,\"^L\",26,536870913]],[\"^84\",[126,\"^48\",1775980635491,536870913]],[\"^84\",[126,\"^7@\",\"d3BG8\",536870913]],[\"^84\",[126,\"^4S\",26,536870913]],[\"^84\",[126,\"^3X\",26,536870913]],[\"^84\",[126,\"^6:\",\"Backlog\",536870913]],[\"^84\",[126,\"^3;\",1775980635491,536870913]],[\"^84\",[126,\"^2T\",\"~u00000002-7233-3491-0000-000000000000\",536870913]],[\"^84\",[126,\"^;\",\"^4A\",536870913]],[\"^84\",[126,\"^57\",true,536870913]],[\"^84\",[126,\"^6?\",26,536870913]],[\"^84\",[126,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"Backlog\"],536870913]],[\"^84\",[127,\"^48\",1775980635492,536870913]],[\"^84\",[127,\"^7M\",\"view type\",536870913]],[\"^84\",[127,\"^7@\",\"d3BGd\",536870913]],[\"^84\",[127,\"^69\",124,536870913]],[\"^84\",[127,\"^6:\",\"View Type\",536870913]],[\"^84\",[127,\"^3;\",1775980635492,536870913]],[\"^84\",[127,\"^2T\",\"~u00000002-2182-3760-7000-000000000000\",536870913]],[\"^84\",[127,\"^<\",\"^=\",536870913]],[\"^84\",[127,\"^;\",\"^2J\",536870913]],[\"^84\",[127,\"^>\",true,536870913]],[\"^84\",[127,\"^C\",\"^D\",536870913]],[\"^84\",[127,\"^57\",true,536870913]],[\"^84\",[127,\"^5L\",70,536870913]],[\"^84\",[127,\"^5J\",true,536870913]],[\"^84\",[127,\"^X\",false,536870913]],[\"^84\",[127,\"^1K\",\"^8>\",536870913]],[\"^84\",[128,\"^48\",1775980635493,536870913]],[\"^84\",[128,\"^7M\",\"this view belongs to\",536870913]],[\"^84\",[128,\"^7@\",\"d3BGr\",536870913]],[\"^84\",[128,\"^69\",124,536870913]],[\"^84\",[128,\"^6:\",\"This view belongs to\",536870913]],[\"^84\",[128,\"^3;\",1775980635493,536870913]],[\"^84\",[128,\"^2T\",\"~u00000002-3627-4319-0000-000000000000\",536870913]],[\"^84\",[128,\"^<\",\"^=\",536870913]],[\"^84\",[128,\"^;\",\"^9\",536870913]],[\"^84\",[128,\"^>\",true,536870913]],[\"^84\",[128,\"^C\",\"^D\",536870913]],[\"^84\",[128,\"^57\",true,536870913]],[\"^84\",[128,\"^5J\",true,536870913]],[\"^84\",[128,\"^X\",false,536870913]],[\"^84\",[128,\"^1K\",\"^85\",536870913]],[\"^84\",[129,\"^48\",1775980635494,536870913]],[\"^84\",[129,\"^7M\",\"user avatar\",536870913]],[\"^84\",[129,\"^7@\",\"d3BH8\",536870913]],[\"^84\",[129,\"^69\",124,536870913]],[\"^84\",[129,\"^6:\",\"User Avatar\",536870913]],[\"^84\",[129,\"^3;\",1775980635494,536870913]],[\"^84\",[129,\"^2T\",\"~u00000002-4165-4885-8000-000000000000\",536870913]],[\"^84\",[129,\"^<\",\"^=\",536870913]],[\"^84\",[129,\"^;\",\"^O\",536870913]],[\"^84\",[129,\"^>\",true,536870913]],[\"^84\",[129,\"^57\",true,536870913]],[\"^84\",[129,\"^5J\",false,536870913]],[\"^84\",[129,\"^X\",true,536870913]],[\"^84\",[129,\"^1K\",\"^87\",536870913]],[\"^84\",[130,\"^48\",1775980635494,536870913]],[\"^84\",[130,\"^7M\",\"user name\",536870913]],[\"^84\",[130,\"^7@\",\"d3BH6\",536870913]],[\"^84\",[130,\"^69\",124,536870913]],[\"^84\",[130,\"^6:\",\"User Name\",536870913]],[\"^84\",[130,\"^3;\",1775980635494,536870913]],[\"^84\",[130,\"^2T\",\"~u00000002-1360-0260-1600-000000000000\",536870913]],[\"^84\",[130,\"^<\",\"^=\",536870913]],[\"^84\",[130,\"^;\",\"^E\",536870913]],[\"^84\",[130,\"^>\",true,536870913]],[\"^84\",[130,\"^57\",true,536870913]],[\"^84\",[130,\"^5J\",false,536870913]],[\"^84\",[130,\"^X\",true,536870913]],[\"^84\",[130,\"^1K\",\"^87\",536870913]],[\"^84\",[131,\"^48\",1775980635493,536870913]],[\"^84\",[131,\"^7M\",\"file size\",536870913]],[\"^84\",[131,\"^7@\",\"d3BGv\",536870913]],[\"^84\",[131,\"^69\",124,536870913]],[\"^84\",[131,\"^6:\",\"File Size\",536870913]],[\"^84\",[131,\"^3;\",1775980635493,536870913]],[\"^84\",[131,\"^2T\",\"~u00000002-1167-8621-9000-000000000000\",536870913]],[\"^84\",[131,\"^<\",\"^=\",536870913]],[\"^84\",[131,\"^;\",\"^1?\",536870913]],[\"^84\",[131,\"^>\",true,536870913]],[\"^84\",[131,\"^57\",true,536870913]],[\"^84\",[131,\"^5J\",true,536870913]],[\"^84\",[131,\"^X\",false,536870913]],[\"^84\",[131,\"^1K\",\"^8C\",536870913]],[\"^84\",[132,\"^48\",1775980635489,536870913]],[\"^84\",[132,\"^7M\",\"bidirectional property title\",536870913]],[\"^84\",[132,\"^7@\",\"d3BFa\",536870913]],[\"^84\",[132,\"^69\",124,536870913]],[\"^84\",[132,\"^6:\",\"Bidirectional property title\",536870913]],[\"^84\",[132,\"^3;\",1775980635489,536870913]],[\"^84\",[132,\"^2T\",\"~u00000002-6050-5418-7000-000000000000\",536870913]],[\"^84\",[132,\"^<\",\"^=\",536870913]],[\"^84\",[132,\"^;\",\"^1I\",536870913]],[\"^84\",[132,\"^>\",true,536870913]],[\"^84\",[132,\"^57\",true,536870913]],[\"^84\",[132,\"^X\",true,536870913]],[\"^84\",[132,\"^1K\",\"^87\",536870913]],[\"^84\",[132,\"^44\",\"^8@\",536870913]],[\"^84\",[133,\"^48\",1775980635489,536870913]],[\"^84\",[133,\"^7M\",\"hide from node\",536870913]],[\"^84\",[133,\"^7@\",\"d3BFf\",536870913]],[\"^84\",[133,\"^69\",124,536870913]],[\"^84\",[133,\"^6:\",\"Hide from Node\",536870913]],[\"^84\",[133,\"^3;\",1775980635489,536870913]],[\"^84\",[133,\"^2T\",\"~u00000002-2610-3727-0000-000000000000\",536870913]],[\"^84\",[133,\"^<\",\"^=\",536870913]],[\"^84\",[133,\"^;\",\"^1X\",536870913]],[\"^84\",[133,\"^>\",true,536870913]],[\"^84\",[133,\"^57\",true,536870913]],[\"^84\",[133,\"^X\",true,536870913]],[\"^84\",[133,\"^1K\",\"^8?\",536870913]],[\"^84\",[133,\"^44\",\"^8@\",536870913]],[\"^84\",[134,\"^48\",1775980635492,536870913]],[\"^84\",[134,\"^7M\",\"repeating temporal property\",536870913]],[\"^84\",[134,\"^7@\",\"d3BGX\",536870913]],[\"^84\",[134,\"^69\",124,536870913]],[\"^84\",[134,\"^6:\",\"Repeating Temporal Property\",536870913]],[\"^84\",[134,\"^3;\",1775980635492,536870913]],[\"^84\",[134,\"^2T\",\"~u00000002-8346-1078-4000-000000000000\",536870913]],[\"^84\",[134,\"^<\",\"^=\",536870913]],[\"^84\",[134,\"^;\",\"^B\",536870913]],[\"^84\",[134,\"^>\",true,536870913]],[\"^84\",[134,\"^C\",\"^D\",536870913]],[\"^84\",[134,\"^57\",true,536870913]],[\"^84\",[134,\"^5J\",true,536870913]],[\"^84\",[134,\"^1K\",\"^86\",536870913]],[\"^84\",[135,\"^L\",13,536870913]],[\"^84\",[135,\"^48\",1775980635492,536870913]],[\"^84\",[135,\"^7@\",\"d3BGQ\",536870913]],[\"^84\",[135,\"^4S\",13,536870913]],[\"^84\",[135,\"^3X\",13,536870913]],[\"^84\",[135,\"^6:\",\"Minute\",536870913]],[\"^84\",[135,\"^3;\",1775980635492,536870913]],[\"^84\",[135,\"^2T\",\"~u00000002-1513-6550-8500-000000000000\",536870913]],[\"^84\",[135,\"^;\",\"^2E\",536870913]],[\"^84\",[135,\"^57\",true,536870913]],[\"^84\",[135,\"^6?\",13,536870913]],[\"^84\",[136,\"^48\",1775980635489,536870913]],[\"^84\",[136,\"^7M\",\"query\",536870913]],[\"^84\",[136,\"^7@\",\"d3BFg\",536870913]],[\"^84\",[136,\"^69\",124,536870913]],[\"^84\",[136,\"^6:\",\"Query\",536870913]],[\"^84\",[136,\"^3;\",1775980635489,536870913]],[\"^84\",[136,\"^2T\",\"~u00000002-9741-4126-0000-000000000000\",536870913]],[\"^84\",[136,\"^<\",\"^=\",536870913]],[\"^84\",[136,\"^;\",\"^2X\",536870913]],[\"^84\",[136,\"^>\",true,536870913]],[\"^84\",[136,\"^C\",\"^D\",536870913]],[\"^84\",[136,\"^57\",true,536870913]],[\"^84\",[136,\"^5J\",true,536870913]],[\"^84\",[136,\"^X\",true,536870913]],[\"^84\",[136,\"^1K\",\"^8>\",536870913]],[\"^84\",[136,\"^44\",\"^8E\",536870913]],[\"^84\",[137,\"^48\",1775980635494,536870913]],[\"^84\",[137,\"^7M\",\"user email\",536870913]],[\"^84\",[137,\"^7@\",\"d3BH7\",536870913]],[\"^84\",[137,\"^69\",124,536870913]],[\"^84\",[137,\"^6:\",\"User Email\",536870913]],[\"^84\",[137,\"^3;\",1775980635494,536870913]],[\"^84\",[137,\"^2T\",\"~u00000002-1655-2060-6300-000000000000\",536870913]],[\"^84\",[137,\"^<\",\"^=\",536870913]],[\"^84\",[137,\"^;\",\"^38\",536870913]],[\"^84\",[137,\"^>\",true,536870913]],[\"^84\",[137,\"^57\",true,536870913]],[\"^84\",[137,\"^5J\",false,536870913]],[\"^84\",[137,\"^X\",true,536870913]],[\"^84\",[137,\"^1K\",\"^87\",536870913]],[\"^84\",[138,\"^48\",1775980635488,536870913]],[\"^84\",[138,\"^7M\",\"node collapsed?\",536870913]],[\"^84\",[138,\"^7@\",\"d3BFJ\",536870913]],[\"^84\",[138,\"^69\",124,536870913]],[\"^84\",[138,\"^6:\",\"Node collapsed?\",536870913]],[\"^84\",[138,\"^3;\",1775980635488,536870913]],[\"^84\",[138,\"^2T\",\"~u00000002-2140-2109-9100-000000000000\",536870913]],[\"^84\",[138,\"^<\",\"^=\",536870913]],[\"^84\",[138,\"^;\",\"^3D\",536870913]],[\"^84\",[138,\"^>\",true,536870913]],[\"^84\",[138,\"^57\",true,536870913]],[\"^84\",[138,\"^5J\",true,536870913]],[\"^84\",[138,\"^X\",false,536870913]],[\"^84\",[138,\"^1K\",\"^8?\",536870913]],[\"^84\",[139,\"^L\",13,536870913]],[\"^84\",[139,\"^48\",1775980635492,536870913]],[\"^84\",[139,\"^7@\",\"d3BGR\",536870913]],[\"^84\",[139,\"^4S\",13,536870913]],[\"^84\",[139,\"^3X\",13,536870913]],[\"^84\",[139,\"^6:\",\"Hour\",536870913]],[\"^84\",[139,\"^3;\",1775980635492,536870913]],[\"^84\",[139,\"^2T\",\"~u00000002-1438-8849-5400-000000000000\",536870913]],[\"^84\",[139,\"^;\",\"^3O\",536870913]],[\"^84\",[139,\"^57\",true,536870913]],[\"^84\",[139,\"^6?\",13,536870913]],[\"^84\",[140,\"^48\",1775980635490,536870913]],[\"^84\",[140,\"^7M\",\"tldraw page\",536870913]],[\"^84\",[140,\"^7@\",\"d3BG0\",536870913]],[\"^84\",[140,\"^69\",124,536870913]],[\"^84\",[140,\"^6:\",\"Tldraw Page\",536870913]],[\"^84\",[140,\"^3;\",1775980635490,536870913]],[\"^84\",[140,\"^2T\",\"~u00000002-3546-2145-7000-000000000000\",536870913]],[\"^84\",[140,\"^<\",\"^=\",536870913]],[\"^84\",[140,\"^;\",\"^42\",536870913]],[\"^84\",[140,\"^>\",true,536870913]],[\"^84\",[140,\"^57\",true,536870913]],[\"^84\",[140,\"^5J\",true,536870913]],[\"^84\",[140,\"^1K\",\"^88\",536870913]],[\"^84\",[141,\"^;\",\"^31\",536870913]],[\"^84\",[141,\"^28\",\"bf04d4cf5-dirty\",536870913]],[\"^84\",[142,\"^48\",1775980635496,536870913]],[\"^84\",[142,\"^7M\",\"query\",536870913]],[\"^84\",[142,\"^69\",14,536870913]],[\"^84\",[142,\"^6:\",\"Query\",536870913]],[\"^84\",[142,\"^3;\",1775980635496,536870913]],[\"^84\",[142,\"^2T\",\"~u00000002-2324-8016-6000-000000000000\",536870913]],[\"^84\",[142,\"^;\",\"^4I\",536870913]],[\"^84\",[142,\"^57\",true,536870913]],[\"^84\",[142,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"search\"],536870913]],[\"^84\",[142,\"^3K\",89,536870913]],[\"^84\",[142,\"^4K\",136,536870913]],[\"^84\",[143,\"^48\",1775980635491,536870913]],[\"^84\",[143,\"^7M\",\"choice checkbox state\",536870913]],[\"^84\",[143,\"^7@\",\"d3BG3\",536870913]],[\"^84\",[143,\"^69\",124,536870913]],[\"^84\",[143,\"^6:\",\"Choice checkbox state\",536870913]],[\"^84\",[143,\"^3;\",1775980635491,536870913]],[\"^84\",[143,\"^2T\",\"~u00000002-1272-4228-6300-000000000000\",536870913]],[\"^84\",[143,\"^<\",\"^=\",536870913]],[\"^84\",[143,\"^;\",\"^4W\",536870913]],[\"^84\",[143,\"^>\",true,536870913]],[\"^84\",[143,\"^57\",true,536870913]],[\"^84\",[143,\"^5J\",true,536870913]],[\"^84\",[143,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^48\",1775980635489,536870913]],[\"^84\",[144,\"^7M\",\"hide empty value\",536870913]],[\"^84\",[144,\"^7@\",\"d3BFd\",536870913]],[\"^84\",[144,\"^69\",124,536870913]],[\"^84\",[144,\"^6:\",\"Hide empty value\",536870913]],[\"^84\",[144,\"^3;\",1775980635489,536870913]],[\"^84\",[144,\"^2T\",\"~u00000002-2062-3258-9900-000000000000\",536870913]],[\"^84\",[144,\"^<\",\"^=\",536870913]],[\"^84\",[144,\"^;\",\"^3P\",536870913]],[\"^84\",[144,\"^>\",true,536870913]],[\"^84\",[144,\"^57\",true,536870913]],[\"^84\",[144,\"^2A\",160,536870913]],[\"^84\",[144,\"^X\",true,536870913]],[\"^84\",[144,\"^1K\",\"^8?\",536870913]],[\"^84\",[144,\"^44\",\"^86\",536870913]],[\"^84\",[145,\"^48\",1775980635488,536870913]],[\"^84\",[145,\"^7M\",\"node updated at\",536870913]],[\"^84\",[145,\"^7@\",\"d3BFR\",536870913]],[\"^84\",[145,\"^69\",124,536870913]],[\"^84\",[145,\"^6:\",\"Node updated at\",536870913]],[\"^84\",[145,\"^3;\",1775980635488,536870913]],[\"^84\",[145,\"^2T\",\"~u00000002-1516-5505-5100-000000000000\",536870913]],[\"^84\",[145,\"^<\",\"^=\",536870913]],[\"^84\",[145,\"^;\",\"^3;\",536870913]],[\"^84\",[145,\"^>\",true,536870913]],[\"^84\",[145,\"^57\",true,536870913]],[\"^84\",[145,\"^5J\",true,536870913]],[\"^84\",[145,\"^X\",false,536870913]],[\"^84\",[145,\"^1K\",\"^8A\",536870913]],[\"^84\",[146,\"^48\",1775980635493,536870913]],[\"^84\",[146,\"^7M\",\"external url\",536870913]],[\"^84\",[146,\"^7@\",\"d3BGt\",536870913]],[\"^84\",[146,\"^69\",124,536870913]],[\"^84\",[146,\"^6:\",\"External URL\",536870913]],[\"^84\",[146,\"^3;\",1775980635493,536870913]],[\"^84\",[146,\"^2T\",\"~u00000002-1364-7751-6300-000000000000\",536870913]],[\"^84\",[146,\"^<\",\"^=\",536870913]],[\"^84\",[146,\"^;\",\"^20\",536870913]],[\"^84\",[146,\"^>\",true,536870913]],[\"^84\",[146,\"^57\",true,536870913]],[\"^84\",[146,\"^5J\",false,536870913]],[\"^84\",[146,\"^X\",true,536870913]],[\"^84\",[146,\"^1K\",\"^87\",536870913]],[\"^84\",[147,\"^48\",1775980635495,536870913]],[\"^84\",[147,\"^7M\",\"apply template to tags\",536870913]],[\"^84\",[147,\"^7@\",\"d3BHO\",536870913]],[\"^84\",[147,\"^69\",124,536870913]],[\"^84\",[147,\"^6:\",\"Apply template to tags\",536870913]],[\"^84\",[147,\"^3;\",1775980635495,536870913]],[\"^84\",[147,\"^2T\",\"~u00000002-4291-2432-2000-000000000000\",536870913]],[\"^84\",[147,\"^<\",\"^14\",536870913]],[\"^84\",[147,\"^;\",\"^5X\",536870913]],[\"^84\",[147,\"^>\",true,536870913]],[\"^84\",[147,\"^C\",\"^D\",536870913]],[\"^84\",[147,\"^57\",true,536870913]],[\"^84\",[147,\"^X\",true,536870913]],[\"^84\",[147,\"^1K\",\"^8@\",536870913]],[\"^84\",[148,\"^L\",26,536870913]],[\"^84\",[148,\"^48\",1775980635491,536870913]],[\"^84\",[148,\"^7@\",\"d3BGB\",536870913]],[\"^84\",[148,\"^4S\",26,536870913]],[\"^84\",[148,\"^3X\",26,536870913]],[\"^84\",[148,\"^6:\",\"In Review\",536870913]],[\"^84\",[148,\"^3;\",1775980635491,536870913]],[\"^84\",[148,\"^2T\",\"~u00000002-1870-0014-4300-000000000000\",536870913]],[\"^84\",[148,\"^;\",\"^66\",536870913]],[\"^84\",[148,\"^57\",true,536870913]],[\"^84\",[148,\"^6?\",26,536870913]],[\"^84\",[148,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"InReview\"],536870913]],[\"^84\",[149,\"^48\",1775980635492,536870913]],[\"^84\",[149,\"^7M\",\"published url\",536870913]],[\"^84\",[149,\"^7@\",\"d3BGb\",536870913]],[\"^84\",[149,\"^69\",124,536870913]],[\"^84\",[149,\"^6:\",\"Published URL\",536870913]],[\"^84\",[149,\"^3;\",1775980635492,536870913]],[\"^84\",[149,\"^2T\",\"~u00000002-2045-1825-0100-000000000000\",536870913]],[\"^84\",[149,\"^<\",\"^=\",536870913]],[\"^84\",[149,\"^;\",\"^1[\",536870913]],[\"^84\",[149,\"^>\",true,536870913]],[\"^84\",[149,\"^C\",\"^D\",536870913]],[\"^84\",[149,\"^57\",true,536870913]],[\"^84\",[149,\"^X\",true,536870913]],[\"^84\",[149,\"^1K\",\"~:url\",536870913]],[\"^84\",[149,\"^44\",\"^89\",536870913]],[\"^84\",[150,\"^L\",127,536870913]],[\"^84\",[150,\"^48\",1775980635492,536870913]],[\"^84\",[150,\"^7@\",\"d3BGg\",536870913]],[\"^84\",[150,\"^4S\",127,536870913]],[\"^84\",[150,\"^3X\",127,536870913]],[\"^84\",[150,\"^6:\",\"Gallery View\",536870913]],[\"^84\",[150,\"^3;\",1775980635492,536870913]],[\"^84\",[150,\"^2T\",\"~u00000002-1506-0511-2000-000000000000\",536870913]],[\"^84\",[150,\"^;\",\"^6L\",536870913]],[\"^84\",[150,\"^57\",true,536870913]],[\"^84\",[150,\"^6?\",127,536870913]],[\"^84\",[150,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"layout-grid\"],536870913]],[\"^84\",[151,\"^2T\",\"~u00000004-1595-0218-3700-000000000000\",536870913]],[\"^84\",[151,\"^;\",\"^5Y\",536870913]],[\"^84\",[152,\"^48\",1775980635496,536870913]],[\"^84\",[152,\"^7M\",\"task\",536870913]],[\"^84\",[152,\"^69\",14,536870913]],[\"^84\",[152,\"^6:\",\"Task\",536870913]],[\"^84\",[152,\"^3;\",1775980635496,536870913]],[\"^84\",[152,\"^2T\",\"~u00000002-1282-1814-5700-000000000000\",536870913]],[\"^84\",[152,\"^;\",\"^4Z\",536870913]],[\"^84\",[152,\"^57\",true,536870913]],[\"^84\",[152,\"^3K\",89,536870913]],[\"^84\",[152,\"^4K\",26,536870913]],[\"^84\",[152,\"^4K\",32,536870913]],[\"^84\",[152,\"^4K\",75,536870913]],[\"^84\",[152,\"^4K\",155,536870913]],[\"^84\",[153,\"^L\",32,536870913]],[\"^84\",[153,\"^48\",1775980635491,536870913]],[\"^84\",[153,\"^7@\",\"d3BGG\",536870913]],[\"^84\",[153,\"^4S\",32,536870913]],[\"^84\",[153,\"^3X\",32,536870913]],[\"^84\",[153,\"^6:\",\"Medium\",536870913]],[\"^84\",[153,\"^3;\",1775980635491,536870913]],[\"^84\",[153,\"^2T\",\"~u00000002-1829-3222-7800-000000000000\",536870913]],[\"^84\",[153,\"^;\",\"^7:\",536870913]],[\"^84\",[153,\"^57\",true,536870913]],[\"^84\",[153,\"^6?\",32,536870913]],[\"^84\",[153,\"^51\",[\"^ \",\"^8:\",\"^8;\",\"^8<\",\"priorityLvlMedium\"],536870913]],[\"^84\",[154,\"^48\",1775980635486,536870913]],[\"^84\",[154,\"^7M\",\"property type\",536870913]],[\"^84\",[154,\"^7@\",\"d3BF6\",536870913]],[\"^84\",[154,\"^69\",124,536870913]],[\"^84\",[154,\"^6:\",\"Property type\",536870913]],[\"^84\",[154,\"^3;\",1775980635486,536870913]],[\"^84\",[154,\"^2T\",\"~u00000002-8384-2404-0000-000000000000\",536870913]],[\"^84\",[154,\"^<\",\"^=\",536870913]],[\"^84\",[154,\"^;\",\"^1K\",536870913]],[\"^84\",[154,\"^>\",true,536870913]],[\"^84\",[154,\"^57\",true,536870913]],[\"^84\",[154,\"^5J\",true,536870913]],[\"^84\",[154,\"^1K\",\"^8D\",536870913]],[\"^84\",[155,\"^48\",1775980635491,536870913]],[\"^84\",[155,\"^7M\",\"scheduled\",536870913]],[\"^84\",[155,\"^7@\",\"d3BGL\",536870913]],[\"^84\",[155,\"^69\",124,536870913]],[\"^84\",[155,\"^6:\",\"Scheduled\",536870913]],[\"^84\",[155,\"^3;\",1775980635491,536870913]],[\"^84\",[155,\"^2T\",\"~u00000002-1644-5209-4300-000000000000\",536870913]],[\"^84\",[155,\"^<\",\"^=\",536870913]],[\"^84\",[155,\"^;\",\"^4Y\",536870913]],[\"^84\",[155,\"^>\",true,536870913]],[\"^84\",[155,\"^57\",true,536870913]],[\"^84\",[155,\"^2A\",177,536870913]],[\"^84\",[155,\"^3P\",true,536870913]],[\"^84\",[155,\"^X\",true,536870913]],[\"^84\",[155,\"^1K\",\"^8A\",536870913]],[\"^84\",[155,\"^@\",\"^8I\",536870913]],[\"^84\",[156,\"^48\",1775980635489,536870913]],[\"^84\",[156,\"^7M\",\"default value\",536870913]],[\"^84\",[156,\"^7@\",\"d3BFV\",536870913]],[\"^84\",[156,\"^69\",124,536870913]],[\"^84\",[156,\"^6:\",\"Default value\",536870913]],[\"^84\",[156,\"^3;\",1775980635489,536870913]],[\"^84\",[156,\"^2T\",\"~u00000002-8920-7966-2000-000000000000\",536870913]],[\"^84\",[156,\"^<\",\"^=\",536870913]],[\"^84\",[156,\"^;\",\"^5L\",536870913]],[\"^84\",[156,\"^>\",true,536870913]],[\"^84\",[156,\"^C\",\"^D\",536870913]],[\"^84\",[156,\"^57\",true,536870913]],[\"^84\",[156,\"^5J\",true,536870913]],[\"^84\",[156,\"^X\",false,536870913]],[\"^84\",[156,\"^1K\",\"^8=\",536870913]],[\"^84\",[156,\"^44\",\"^86\",536870913]],[\"^84\",[157,\"^48\",1775980635495,536870913]],[\"^84\",[157,\"^7M\",\"deleted by\",536870913]],[\"^84\",[157,\"^7@\",\"d3BHH\",536870913]],[\"^84\",[157,\"^69\",124,536870913]],[\"^84\",[157,\"^6:\",\"Deleted by\",536870913]],[\"^84\",[157,\"^3;\",1775980635495,536870913]],[\"^84\",[157,\"^2T\",\"~u00000002-1998-0431-1200-000000000000\",536870913]],[\"^84\",[157,\"^<\",\"^=\",536870913]],[\"^84\",[157,\"^;\",\"^6Y\",536870913]],[\"^84\",[157,\"^>\",true,536870913]],[\"^84\",[157,\"^C\",\"^D\",536870913]],[\"^84\",[157,\"^57\",true,536870913]],[\"^84\",[157,\"^5J\",true,536870913]],[\"^84\",[157,\"^X\",false,536870913]],[\"^84\",[157,\"^1K\",\"^8=\",536870913]],[\"^84\",[158,\"^48\",1775980635489,536870913]],[\"^84\",[158,\"^7M\",\"extends\",536870913]],[\"^84\",[158,\"^7@\",\"d3BFX\",536870913]],[\"^84\",[158,\"^69\",124,536870913]],[\"^84\",[158,\"^6:\",\"Extends\",536870913]],[\"^84\",[158,\"^3;\",1775980635489,536870913]],[\"^84\",[158,\"^2T\",\"~u00000002-7475-9380-3000-000000000000\",536870913]],[\"^84\",[158,\"^<\",\"^14\",536870913]],[\"^84\",[158,\"^;\",\"^3K\",536870913]],[\"^84\",[158,\"^>\",true,536870913]],[\"^84\",[158,\"^C\",\"^D\",536870913]],[\"^84\",[158,\"^57\",true,536870913]],[\"^84\",[158,\"^2A\",163,536870913]],[\"^84\",[158,\"^X\",true,536870913]],[\"^84\",[158,\"^1K\",\"^8@\",536870913]],[\"^84\",[158,\"^44\",\"^8@\",536870913]],[\"^84\",[159,\"^48\",1775980635493,536870913]],[\"^84\",[159,\"^7M\",\"view sorting\",536870913]],[\"^84\",[159,\"^7@\",\"d3BGl\",536870913]],[\"^84\",[159,\"^69\",124,536870913]],[\"^84\",[159,\"^6:\",\"View sorting\",536870913]],[\"^84\",[159,\"^3;\",1775980635493,536870913]],[\"^84\",[159,\"^2T\",\"~u00000002-2081-0259-4000-000000000000\",536870913]],[\"^84\",[159,\"^<\",\"^=\",536870913]],[\"^84\",[159,\"^;\",\"^12\",536870913]],[\"^84\",[159,\"^>\",true,536870913]],[\"^84\",[159,\"^57\",true,536870913]],[\"^84\",[159,\"^5J\",true,536870913]],[\"^84\",[159,\"^X\",false,536870913]],[\"^84\",[159,\"^1K\",\"^8L\",536870913]],[\"^84\",[160,\"^48\",1775980635489,536870913]],[\"^84\",[160,\"^7@\",\"d3BFe\",536870913]],[\"^84\",[160,\"^4S\",144,536870913]],[\"^84\",[160,\"^3X\",144,536870913]],[\"^84\",[160,\"^6:\",\"Hides a property's value on any node when empty e.g. when a property appears on a node through a tag.\",536870913]],[\"^84\",[160,\"^3;\",1775980635489,536870913]],[\"^84\",[160,\"^2T\",\"~u00000004-1118-8585-8000-000000000000\",536870913]],[\"^84\",[160,\"^57\",true,536870913]],[\"^84\",[160,\"^6?\",71,536870913]],[\"^84\",[161,\"^2T\",\"~u00000004-2116-2824-5200-000000000000\",536870913]],[\"^84\",[161,\"^1M\",\"\",536870913]],[\"^84\",[161,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[161,\"^7N\",\"logseq/publish.js\",536870913]],[\"^84\",[162,\"^48\",1775980635494,536870913]],[\"^84\",[162,\"^7@\",\"d3BHA\",536870913]],[\"^84\",[162,\"^4S\",123,536870913]],[\"^84\",[162,\"^3X\",123,536870913]],[\"^84\",[162,\"^6:\",\"Records history anytime a property's value changes on a node.\",536870913]],[\"^84\",[162,\"^3;\",1775980635494,536870913]],[\"^84\",[162,\"^2T\",\"~u00000004-1438-1481-0000-000000000000\",536870913]],[\"^84\",[162,\"^57\",true,536870913]],[\"^84\",[162,\"^6?\",71,536870913]],[\"^84\",[163,\"^48\",1775980635489,536870913]],[\"^84\",[163,\"^7@\",\"d3BFY\",536870913]],[\"^84\",[163,\"^4S\",158,536870913]],[\"^84\",[163,\"^3X\",158,536870913]],[\"^84\",[163,\"^6:\",\"This enables tags to inherit properties from other tags\",536870913]],[\"^84\",[163,\"^3;\",1775980635489,536870913]],[\"^84\",[163,\"^2T\",\"~u00000004-4485-6574-6000-000000000000\",536870913]],[\"^84\",[163,\"^57\",true,536870913]],[\"^84\",[163,\"^6?\",71,536870913]],[\"^84\",[164,\"^48\",1775980635489,536870913]],[\"^84\",[164,\"^7@\",\"d3BFi\",536870913]],[\"^84\",[164,\"^4S\",125,536870913]],[\"^84\",[164,\"^3X\",125,536870913]],[\"^84\",[164,\"^6:\",\"Provides a way for a page to associate to another page i.e. backward compatible tagging.\",536870913]],[\"^84\",[164,\"^3;\",1775980635489,536870913]],[\"^84\",[164,\"^2T\",\"~u00000004-1223-8671-8000-000000000000\",536870913]],[\"^84\",[164,\"^57\",true,536870913]],[\"^84\",[164,\"^6?\",71,536870913]],[\"^84\",[165,\"^48\",1775980635496,536870913]],[\"^84\",[165,\"^7M\",\"library\",536870913]],[\"^84\",[165,\"^69\",104,536870913]],[\"^84\",[165,\"^6:\",\"Library\",536870913]],[\"^84\",[165,\"^3;\",1775980635496,536870913]],[\"^84\",[165,\"^2T\",\"~u00000004-1294-7765-6000-000000000000\",536870913]],[\"^84\",[165,\"^57\",true,536870913]],[\"^84\",[166,\"^48\",1775980635491,536870913]],[\"^84\",[166,\"^7@\",\"d3BGK\",536870913]],[\"^84\",[166,\"^4S\",75,536870913]],[\"^84\",[166,\"^3X\",75,536870913]],[\"^84\",[166,\"^6:\",\"Use it to finish something at a specific date(time).\",536870913]],[\"^84\",[166,\"^3;\",1775980635491,536870913]],[\"^84\",[166,\"^2T\",\"~u00000004-1356-3664-2200-000000000000\",536870913]],[\"^84\",[166,\"^57\",true,536870913]],[\"^84\",[166,\"^6?\",71,536870913]],[\"^84\",[167,\"^48\",1775980635494,536870913]],[\"^84\",[167,\"^7@\",\"d3BH1\",536870913]],[\"^84\",[167,\"^4S\",67,536870913]],[\"^84\",[167,\"^3X\",67,536870913]],[\"^84\",[167,\"^6:\",\"Metadata of asset in remote storage\",536870913]],[\"^84\",[167,\"^3;\",1775980635494,536870913]],[\"^84\",[167,\"^2T\",\"~u00000004-5450-2102-9000-000000000000\",536870913]],[\"^84\",[167,\"^57\",true,536870913]],[\"^84\",[167,\"^6?\",71,536870913]],[\"^84\",[168,\"^48\",1775980635496,536870913]],[\"^84\",[168,\"^7M\",\"quick add\",536870913]],[\"^84\",[168,\"^69\",104,536870913]],[\"^84\",[168,\"^6:\",\"Quick add\",536870913]],[\"^84\",[168,\"^3;\",1775980635496,536870913]],[\"^84\",[168,\"^2T\",\"~u00000004-7336-8251-3000-000000000000\",536870913]],[\"^84\",[168,\"^57\",true,536870913]],[\"^84\",[168,\"^5J\",true,536870913]],[\"^84\",[169,\"^48\",1775980635489,536870913]],[\"^84\",[169,\"^7@\",\"d3BFc\",536870913]],[\"^84\",[169,\"^4S\",20,536870913]],[\"^84\",[169,\"^3X\",20,536870913]],[\"^84\",[169,\"^6:\",\"When enabled, this tag will show reverse nodes that link to the current node via properties.\",536870913]],[\"^84\",[169,\"^3;\",1775980635489,536870913]],[\"^84\",[169,\"^2T\",\"~u00000004-1830-0481-2500-000000000000\",536870913]],[\"^84\",[169,\"^57\",true,536870913]],[\"^84\",[169,\"^6?\",71,536870913]],[\"^84\",[170,\"^48\",1775980635496,536870913]],[\"^84\",[170,\"^7M\",\"$$$views\",536870913]],[\"^84\",[170,\"^69\",104,536870913]],[\"^84\",[170,\"^6:\",\"$$$views\",536870913]],[\"^84\",[170,\"^3;\",1775980635496,536870913]],[\"^84\",[170,\"^2T\",\"~u00000004-1906-3437-5800-000000000000\",536870913]],[\"^84\",[170,\"^57\",true,536870913]],[\"^84\",[170,\"^5J\",true,536870913]],[\"^84\",[171,\"^48\",1775980635496,536870913]],[\"^84\",[171,\"^7M\",\"contents\",536870913]],[\"^84\",[171,\"^69\",104,536870913]],[\"^84\",[171,\"^6:\",\"Contents\",536870913]],[\"^84\",[171,\"^3;\",1775980635496,536870913]],[\"^84\",[171,\"^2T\",\"~u00000004-1690-2597-3200-000000000000\",536870913]],[\"^84\",[171,\"^57\",true,536870913]],[\"^84\",[172,\"^2T\",\"~u00000004-1713-4660-3800-000000000000\",536870913]],[\"^84\",[172,\"^1M\",\"\",536870913]],[\"^84\",[172,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[172,\"^7N\",\"logseq/custom.css\",536870913]],[\"^84\",[173,\"^48\",1775980635496,536870913]],[\"^84\",[173,\"^7M\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^69\",104,536870913]],[\"^84\",[173,\"^6:\",\"$$$favorites\",536870913]],[\"^84\",[173,\"^3;\",1775980635496,536870913]],[\"^84\",[173,\"^2T\",\"~u00000004-1018-5888-4100-000000000000\",536870913]],[\"^84\",[173,\"^57\",true,536870913]],[\"^84\",[173,\"^5J\",true,536870913]],[\"^84\",[174,\"^2T\",\"~u00000004-1335-6485-2300-000000000000\",536870913]],[\"^84\",[174,\"^1M\",\"\",536870913]],[\"^84\",[174,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[174,\"^7N\",\"logseq/custom.js\",536870913]],[\"^84\",[175,\"^48\",1775980635491,536870913]],[\"^84\",[175,\"^7@\",\"d3BGO\",536870913]],[\"^84\",[175,\"^4S\",95,536870913]],[\"^84\",[175,\"^3X\",95,536870913]],[\"^84\",[175,\"^3;\",1775980635491,536870913]],[\"^84\",[175,\"^2T\",\"~u00000004-1654-1034-2600-000000000000\",536870913]],[\"^84\",[175,\"^57\",true,536870913]],[\"^84\",[175,\"^6?\",95,536870913]],[\"^84\",[175,\"^T\",1,536870913]],[\"^84\",[176,\"^2T\",\"~u00000004-3919-3813-3000-000000000000\",536870913]],[\"^84\",[176,\"^1M\",\"{:meta/version 1\\n\\n ;; Hide empty block properties\\n ;; Default value: false\\n ;; :ui/hide-empty-properties? false\\n\\n ;; Enable tooltip preview on hover.\\n ;; Default value: true\\n :ui/enable-tooltip? true\\n\\n ;; Display brackets [[]] around page references.\\n ;; Default value: true\\n ;; :ui/show-brackets? true\\n\\n ;; Display all lines of a block when referencing ((block)).\\n ;; Default value: false\\n :ui/show-full-blocks? false\\n\\n ;; Automatically expand block references when zooming in.\\n ;; Default value: true\\n :ui/auto-expand-block-refs? true\\n\\n ;; Disable accent marks when searching.\\n ;; After changing this setting, rebuild the search index by pressing (^C ^S).\\n ;; Default value: true\\n :feature/enable-search-remove-accents? true\\n\\n ;; Enable journals.\\n ;; Default value: true\\n ;; :feature/enable-journals? true\\n\\n ;; Enable flashcards.\\n ;; Default value: true\\n ;; :feature/enable-flashcards? true\\n\\n ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query.\\n ;; Default value: false\\n ;; :feature/disable-scheduled-and-deadline-query? false\\n\\n ;; Specify the number of days displayed in the future for\\n ;; the 'scheduled tasks and deadlines' query.\\n ;; Example usage:\\n ;; Display all scheduled and deadline blocks for the next 14 days:\\n ;; :scheduled/future-days 14\\n ;; Default value: 7\\n ;; :scheduled/future-days 7\\n\\n ;; Specify the first day of the week.\\n ;; Available options:\\n ;; - integer from 0 to 6 (Monday to Sunday)\\n ;; Default value: 6 (Sunday)\\n :start-of-week 6\\n\\n ;; Specify a custom CSS import.\\n ;; This option takes precedence over the local `logseq/custom.css` file.\\n ;; Example usage:\\n ;; :custom-css-url \\\"@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');\\\"\\n\\n ;; Specify a custom JS import.\\n ;; This option takes precedence over the local `logseq/custom.js` file.\\n ;; Example usage:\\n ;; :custom-js-url \\\"https://cdn.logseq.com/custom.js\\\"\\n\\n ;; Set bullet indentation when exporting\\n ;; Available options:\\n ;; - `:eight-spaces` as eight spaces\\n ;; - `:four-spaces` as four spaces\\n ;; - `:two-spaces` as two spaces\\n ;; - `:tab` as a tab character (default)\\n ;; :export/bullet-indentation :tab\\n\\n ;; Publish all pages within the Graph\\n ;; Regardless of whether individual pages have been marked as public.\\n ;; Default value: false\\n ;; :publishing/all-pages-public? false\\n\\n ;; Define the default home page and sidebar status.\\n ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden.\\n ;; The `:page` value represents the name of the page displayed at startup.\\n ;; Available options for `:sidebar` are:\\n ;; - \\\"Contents\\\" to display the Contents page in the right sidebar.\\n ;; - A specific page name to display in the right sidebar.\\n ;; - An array of multiple pages, e.g., [\\\"Contents\\\" \\\"Page A\\\" \\\"Page B\\\"].\\n ;; If `:sidebar` remains unset, the right sidebar will stay hidden.\\n ;; Examples:\\n ;; 1. Set \\\"Changelog\\\" as the home page and display \\\"Contents\\\" in the right sidebar:\\n ;; :default-home {:page \\\"Changelog\\\", :sidebar \\\"Contents\\\"}\\n ;; 2. Set \\\"Jun 3rd, 2021\\\" as the home page without the right sidebar:\\n ;; :default-home {:page \\\"Jun 3rd, 2021\\\"}\\n ;; 3. Set \\\"home\\\" as the home page and display multiple pages in the right sidebar:\\n ;; :default-home {:page \\\"home\\\", :sidebar [\\\"Page A\\\" \\\"Page B\\\"]}\\n\\n ;; Configure custom shortcuts.\\n ;; Syntax:\\n ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`.\\n ;; 2. A space between keys represents key chords, e.g., `t s` means\\n ;; pressing `t` followed by `s`.\\n ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac.\\n ;; 4. Use false to disable a specific shortcut.\\n ;; 5. You can define multiple bindings for a single action, e.g., [\\\"ctrl+j\\\" \\\"down\\\"].\\n ;; The full list of configurable shortcuts is available at:\\n ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs\\n ;; Example:\\n ;; :shortcuts\\n ;; {:editor/new-block \\\"enter\\\"\\n ;; :editor/new-line \\\"shift+enter\\\"\\n ;; :editor/insert-link \\\"mod+shift+k\\\"\\n ;; :editor/highlight false\\n ;; :ui/toggle-settings \\\"t s\\\"\\n ;; :editor/up [\\\"ctrl+k\\\" \\\"up\\\"]\\n ;; :editor/down [\\\"ctrl+j\\\" \\\"down\\\"]\\n ;; :editor/left [\\\"ctrl+h\\\" \\\"left\\\"]\\n ;; :editor/right [\\\"ctrl+l\\\" \\\"right\\\"]}\\n :shortcuts {}\\n\\n ;; Configure the behavior of pressing Enter in document mode.\\n ;; if set to true, pressing Enter will create a new block.\\n ;; Default value: false\\n :shortcut/doc-mode-enter-for-new-block? false\\n\\n ;; Block content larger than `block/title-max-length` will not be searchable\\n ;; or editable for performance.\\n ;; Default value: 10000\\n :block/title-max-length 10000\\n\\n ;; Display command documentation on hover.\\n ;; Default value: true\\n :ui/show-command-doc? true\\n\\n ;; Display empty bullet points.\\n ;; Default value: false\\n :ui/show-empty-bullets? false\\n\\n ;; Pre-defined :view function to use with advanced queries.\\n :query/views\\n {:pprint\\n (fn [r] [:pre.code (pprint r)])}\\n\\n ;; Advanced queries `:result-transform` function.\\n ;; Transform the query result before displaying it.\\n ;; Example usage for DB graphs:\\n;; :query/result-transforms\\n;; {:sort-by-priority\\n;; (fn [result] (sort-by (fn [h] (get h :logseq.property/priority \\\"Z\\\")) result))}\\n\\n;; Queries will be displayed at the bottom of today's journal page.\\n;; Example usage:\\n;; :default-queries\\n;; {:journals []}\\n\\n ;; Add custom commands to the command palette\\n ;; Example usage:\\n ;; :commands\\n ;; [\\n ;; [\\\"js\\\" \\\"Javascript\\\"]\\n ;; [\\\"md\\\" \\\"Markdown\\\"]\\n ;; ]\\n :commands []\\n\\n ;; Enable collapsing blocks with titles but no children.\\n ;; By default, only blocks with children can be collapsed.\\n ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing\\n ;; blocks with titles (multiple lines) and content. For example:\\n ;; - block title\\n ;; block content\\n ;; Default value: false\\n :outliner/block-title-collapse-enabled? false\\n\\n ;; Macros replace texts and will make you more productive.\\n ;; Example usage:\\n ;; Change the :macros value below to:\\n ;; {\\\"poem\\\" \\\"Rose is $1, violet's $2. Life's ordered: Org assists you.\\\"}\\n ;; input \\\"{{poem red,blue}}\\\"\\n ;; becomes\\n ;; Rose is red, violet's blue. Life's ordered: Org assists you.\\n :macros {}\\n\\n ;; Configure the default expansion level for linked references.\\n ;; For example, consider the following block hierarchy:\\n ;; - a [[page]] (level 1)\\n ;; - b (level 2)\\n ;; - c (level 3)\\n ;; - d (level 4)\\n ;;\\n ;; With the default value of level 2, block b will be collapsed.\\n ;; If the level's value is set to 3, block c will be collapsed.\\n ;; Default value: 2\\n :ref/default-open-blocks-level 2\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/settings\\n ;; {:orphan-pages? true ; Default value: true\\n ;; :builtin-pages? false ; Default value: false\\n ;; :excluded-pages? false ; Default value: false\\n ;; :journal? false} ; Default value: false\\n\\n ;; Graph view configuration.\\n ;; Example usage:\\n ;; :graph/forcesettings\\n ;; {:link-dist 180 ; Default value: 180\\n ;; :charge-strength -600 ; Default value: -600\\n ;; :charge-range 600} ; Default value: 600\\n\\n ;; Mobile photo upload configuration.\\n ;; :mobile/photo\\n ;; {:allow-editing? true\\n ;; :quality 80}\\n\\n ;; Mobile features options\\n ;; Gestures\\n ;; Example usage:\\n ;; :mobile\\n ;; {:gestures/disabled-in-block-with-tags [\\\"kanban\\\"]}\\n\\n ;; Extra CodeMirror options\\n ;; See https://codemirror.net/5/doc/manual.html#config for possible options\\n ;; Example usage:\\n ;; :editor/extra-codemirror-options\\n ;; {:lineWrapping false ; Default value: false\\n ;; :lineNumbers true ; Default value: true\\n ;; :readOnly false} ; Default value: false\\n\\n ;; Enable logical outdenting\\n ;; Default value: false\\n ;; :editor/logical-outdenting? false\\n\\n ;; Prefer pasting the file when text and a file are in the clipboard.\\n ;; Default value: false\\n ;; :editor/preferred-pasting-file? false\\n\\n ;; Quick capture templates for receiving content from other apps.\\n ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded\\n ;; by receiving content from other apps. Note: the {} cannot be omitted.\\n ;; - {time}: capture time\\n ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference\\n ;; - {text}: text that users selected before sharing.\\n ;; - {url}: URL or assets path for media files stored in Logseq.\\n ;; You can also reorder them or use only one or two of them in the template.\\n ;; You can also insert or format any text in the template, as shown in the following examples.\\n ;; :quick-capture-templates\\n ;; {:text \\\"[[quick capture]] **{time}**: {text} from {url}\\\"\\n ;; :media \\\"[[quick capture]] **{time}**: {url}\\\"}\\n\\n ;; Quick capture options.\\n ;; - insert-today? Insert the capture at the end of today's journal page (boolean).\\n ;; - redirect-page? Redirect to the quick capture page after capturing (boolean).\\n ;; - default-page The default page to capture to if insert-today? is false (string).\\n ;; :quick-capture-options\\n ;; {:insert-today? false ;; Default value: true\\n ;; :redirect-page? false ;; Default value: false\\n ;; :default-page \\\"quick capture\\\"} ;; Default page: \\\"quick capture\\\"\\n\\n }\\n\",536870913]],[\"^84\",[176,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[176,\"^7N\",\"logseq/config.edn\",536870913]],[\"^84\",[177,\"^48\",1775980635491,536870913]],[\"^84\",[177,\"^7@\",\"d3BGM\",536870913]],[\"^84\",[177,\"^4S\",155,536870913]],[\"^84\",[177,\"^3X\",155,536870913]],[\"^84\",[177,\"^6:\",\"Use it to plan something to start at a specific date(time).\",536870913]],[\"^84\",[177,\"^3;\",1775980635491,536870913]],[\"^84\",[177,\"^2T\",\"~u00000004-9817-6380-0000-000000000000\",536870913]],[\"^84\",[177,\"^57\",true,536870913]],[\"^84\",[177,\"^6?\",71,536870913]],[\"^84\",[178,\"^2T\",\"~u00000004-4049-4381-0000-000000000000\",536870913]],[\"^84\",[178,\"^1M\",\"\",536870913]],[\"^84\",[178,\"^A\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^4D\",\"~m1775980635486\",536870913]],[\"^84\",[178,\"^7N\",\"logseq/publish.css\",536870913]],[\"^84\",[179,\"^48\",1775980635496,536870913]],[\"^84\",[179,\"^7M\",\"recycle\",536870913]],[\"^84\",[179,\"^69\",104,536870913]],[\"^84\",[179,\"^6:\",\"Recycle\",536870913]],[\"^84\",[179,\"^3;\",1775980635496,536870913]],[\"^84\",[179,\"^2T\",\"~u00000004-7238-1304-3000-000000000000\",536870913]],[\"^84\",[179,\"^57\",true,536870913]],[\"^84\",[179,\"^5J\",true,536870913]],[\"^84\",[180,\"^48\",1775980636002,536870913]],[\"^84\",[180,\"^1C\",20260412,536870913]],[\"^84\",[180,\"^7M\",\"apr 12th, 2026\",536870913]],[\"^84\",[180,\"^1S\",58,536870913]],[\"^84\",[180,\"^1S\",85,536870913]],[\"^84\",[180,\"^69\",85,536870913]],[\"^84\",[180,\"^6:\",\"Apr 12th, 2026\",536870913]],[\"^84\",[180,\"^S\",536873532,536873534]],[\"^84\",[180,\"^3;\",1775984657858,536873532]],[\"^84\",[180,\"^2T\",\"~u00000001-2026-0412-0000-000000000000\",536870913]],[\"^84\",[180,\"^4<\",181,536870913]],[\"^84\",[181,\"^48\",1775980636008,536870913]],[\"^84\",[181,\"^7M\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^1S\",58,536870913]],[\"^84\",[181,\"^1S\",104,536870913]],[\"^84\",[181,\"^1S\",130,536870913]],[\"^84\",[181,\"^1S\",137,536870913]],[\"^84\",[181,\"^69\",104,536870913]],[\"^84\",[181,\"^6:\",\"tiensonqin\",536870913]],[\"^84\",[181,\"^3;\",1775980636008,536870913]],[\"^84\",[181,\"^2T\",\"~ue7cd12e2-f9f7-46ed-a17e-85d95a780003\",536870913]],[\"^84\",[181,\"^38\",\"tienson@logseq.com\",536870913]],[\"^84\",[181,\"^E\",\"tiensonqin\",536870913]],[\"^84\",[182,\"^;\",\"^64\",536870913]],[\"^84\",[182,\"^28\",\"~u952b4187-ac01-41d3-b5fb-24250ac70d56\",536870913]],[\"^84\",[183,\"^;\",\"^3:\",536870913]],[\"^84\",[183,\"^28\",true,536870913]],[\"^84\",[565,\"^48\",1775980817925,536870913]],[\"^84\",[565,\"^7M\",\"p1\",536870913]],[\"^84\",[565,\"^7@\",\"d3BHX\",536870913]],[\"^84\",[565,\"^1S\",58,536870913]],[\"^84\",[565,\"^1S\",124,536870913]],[\"^84\",[565,\"^69\",124,536870913]],[\"^84\",[565,\"^6:\",\"p1\",536870913]],[\"^84\",[565,\"^3;\",1775980817925,536870913]],[\"^84\",[565,\"^2T\",\"~u00000002-1157-0363-3400-000000000000\",536870913]],[\"^84\",[565,\"^<\",\"^=\",536870913]],[\"^84\",[565,\"^;\",\"^5N\",536870913]],[\"^84\",[565,\"^>\",true,536870913]],[\"^84\",[565,\"^C\",\"^D\",536870913]],[\"^84\",[565,\"^4<\",181,536870913]],[\"^84\",[565,\"^1K\",\"^8>\",536870913]],[\"^84\",[568,\"^48\",1775980818025,536870913]],[\"^84\",[568,\"^7M\",\"p2\",536870913]],[\"^84\",[568,\"^7@\",\"d3BHa\",536870913]],[\"^84\",[568,\"^1S\",58,536870913]],[\"^84\",[568,\"^1S\",124,536870913]],[\"^84\",[568,\"^69\",124,536870913]],[\"^84\",[568,\"^6:\",\"p2\",536870913]],[\"^84\",[568,\"^3;\",1775980818025,536870913]],[\"^84\",[568,\"^2T\",\"~u00000002-6891-0936-5000-000000000000\",536870913]],[\"^84\",[568,\"^<\",\"^=\",536870913]],[\"^84\",[568,\"^;\",\"^3V\",536870913]],[\"^84\",[568,\"^>\",true,536870913]],[\"^84\",[568,\"^C\",\"^D\",536870913]],[\"^84\",[568,\"^4<\",181,536870913]],[\"^84\",[568,\"^1K\",\"^8K\",536870913]],[\"^84\",[569,\"^48\",1775980818039,536870913]],[\"^84\",[569,\"^7M\",\"p3\",536870913]],[\"^84\",[569,\"^7@\",\"d3BHb\",536870913]],[\"^84\",[569,\"^1S\",58,536870913]],[\"^84\",[569,\"^1S\",104,536870913]],[\"^84\",[569,\"^1S\",124,536870913]],[\"^84\",[569,\"^69\",124,536870913]],[\"^84\",[569,\"^6:\",\"p3\",536870913]],[\"^84\",[569,\"^3;\",1775980818056,536870913]],[\"^84\",[569,\"^2T\",\"~u00000002-2093-0438-6800-000000000000\",536870913]],[\"^84\",[569,\"^<\",\"^14\",536870913]],[\"^84\",[569,\"^;\",\"^1N\",536870913]],[\"^84\",[569,\"^>\",true,536870913]],[\"^84\",[569,\"^C\",\"^D\",536870913]],[\"^84\",[569,\"^6G\",104,536870913]],[\"^84\",[569,\"^4<\",181,536870913]],[\"^84\",[569,\"^1K\",\"^85\",536870913]],[\"^84\",[573,\"^48\",1775980818095,536870913]],[\"^84\",[573,\"^7M\",\"page 1\",536870913]],[\"^84\",[573,\"^1S\",58,536870913]],[\"^84\",[573,\"^1S\",104,536870913]],[\"^84\",[573,\"^69\",104,536870913]],[\"^84\",[573,\"^6:\",\"page 1\",536870913]],[\"^84\",[573,\"^3;\",1775980818095,536870913]],[\"^84\",[573,\"^2T\",\"~u69db5102-b394-49cc-af0d-6e3ee0752cdf\",536870913]],[\"^84\",[573,\"^4<\",181,536870913]],[\"^84\",[574,\"^48\",1775980818106,536870913]],[\"^84\",[574,\"^7M\",\"page 2\",536870913]],[\"^84\",[574,\"^1S\",58,536870913]],[\"^84\",[574,\"^1S\",104,536870913]],[\"^84\",[574,\"^69\",104,536870913]],[\"^84\",[574,\"^6:\",\"page 2\",536870913]],[\"^84\",[574,\"^3;\",1775980818106,536870913]],[\"^84\",[574,\"^2T\",\"~u69db5103-ac5c-4d04-b098-88b435041434\",536870913]],[\"^84\",[574,\"^4<\",181,536870913]],[\"^84\",[577,\"^48\",1775980800102,536870913]],[\"^84\",[577,\"^7@\",\"d3BHW\",536870913]],[\"^84\",[577,\"^4S\",568,536870913]],[\"^84\",[577,\"^3X\",568,536870913]],[\"^84\",[577,\"^1S\",568,536870913]],[\"^84\",[577,\"^3;\",1775980800102,536870913]],[\"^84\",[577,\"^2T\",\"~u69db5100-05c8-4909-b79e-c56130f4d1e9\",536870913]],[\"^84\",[577,\"^4<\",181,536870913]],[\"^84\",[577,\"^6?\",568,536870913]],[\"^84\",[577,\"^T\",10,536870913]],[\"^84\",[593,\"^48\",1775980982459,536870913]],[\"^84\",[593,\"^7@\",\"d3BHf\",536870913]],[\"^84\",[593,\"^4S\",568,536870913]],[\"^84\",[593,\"^3X\",568,536870913]],[\"^84\",[593,\"^1S\",568,536870913]],[\"^84\",[593,\"^3;\",1775980982459,536870913]],[\"^84\",[593,\"^2T\",\"~u69db51b6-97ff-4bf6-9547-69dc12a2972e\",536870913]],[\"^84\",[593,\"^4<\",181,536870913]],[\"^84\",[593,\"^6?\",568,536870913]],[\"^84\",[593,\"^T\",10,536870913]],[\"^84\",[597,\"^48\",1775980958586,536870913]],[\"^84\",[597,\"^7@\",\"d3BHe\",536870913]],[\"^84\",[597,\"^4S\",568,536870913]],[\"^84\",[597,\"^3X\",568,536870913]],[\"^84\",[597,\"^1S\",568,536870913]],[\"^84\",[597,\"^3;\",1775980958586,536870913]],[\"^84\",[597,\"^2T\",\"~u69db519e-9f15-4a6a-a142-fe9d6e954212\",536870913]],[\"^84\",[597,\"^4<\",181,536870913]],[\"^84\",[597,\"^6?\",568,536870913]],[\"^84\",[597,\"^T\",10,536870913]],[\"^84\",[599,\"^48\",1775981040693,536870913]],[\"^84\",[599,\"^7M\",\"page 3\",536870913]],[\"^84\",[599,\"^1S\",58,536870913]],[\"^84\",[599,\"^1S\",104,536870913]],[\"^84\",[599,\"^69\",104,536870913]],[\"^84\",[599,\"^6:\",\"page 3\",536870913]],[\"^84\",[599,\"^3;\",1775981040693,536870913]],[\"^84\",[599,\"^2T\",\"~u69db51a5-4a8e-4ebf-992e-d31c157eaf5b\",536870913]],[\"^84\",[599,\"^4<\",181,536870913]],[\"^84\",[600,\"^48\",1775981040720,536870913]],[\"^84\",[600,\"^7M\",\"p4\",536870913]],[\"^84\",[600,\"^7@\",\"d3BHh\",536870913]],[\"^84\",[600,\"^1S\",58,536870913]],[\"^84\",[600,\"^1S\",124,536870913]],[\"^84\",[600,\"^69\",124,536870913]],[\"^84\",[600,\"^6:\",\"p4\",536870913]],[\"^84\",[600,\"^3;\",1775981028998,536870913]],[\"^84\",[600,\"^2T\",\"~u00000002-5835-7822-8000-000000000000\",536870913]],[\"^84\",[600,\"^<\",\"^=\",536870913]],[\"^84\",[600,\"^;\",\"^6;\",536870913]],[\"^84\",[600,\"^>\",true,536870913]],[\"^84\",[600,\"^C\",\"^D\",536870913]],[\"^84\",[600,\"^4<\",181,536870913]],[\"^84\",[600,\"^1K\",\"^8>\",536870913]],[\"^84\",[605,\"^48\",1775981057423,536870913]],[\"^84\",[605,\"^7@\",\"Zz\",536870913]],[\"^84\",[605,\"^4S\",170,536870913]],[\"^84\",[605,\"^3X\",170,536870913]],[\"^84\",[605,\"^1S\",170,536870913]],[\"^84\",[605,\"^6:\",\"All\",536870913]],[\"^84\",[605,\"^3;\",1775981057423,536870913]],[\"^84\",[605,\"^2T\",\"~u00000006-1867-5878-9000-000000000000\",536870913]],[\"^84\",[605,\"^4<\",181,536870913]],[\"^84\",[605,\"^9\",170,536870913]],[\"^84\",[605,\"^5>\",\"~:all-pages\",536870913]],[\"^84\",[619,\"^3D\",true,536870913]],[\"^84\",[619,\"^48\",1775981500541,536870913]],[\"^84\",[619,\"^7M\",\"tag1\",536870913]],[\"^84\",[619,\"^1S\",14,536870913]],[\"^84\",[619,\"^1S\",58,536870913]],[\"^84\",[619,\"^1S\",89,536870913]],[\"^84\",[619,\"^1S\",158,536870913]],[\"^84\",[619,\"^69\",14,536870913]],[\"^84\",[619,\"^6:\",\"tag1\",536870913]],[\"^84\",[619,\"^3;\",1775981500541,536870913]],[\"^84\",[619,\"^2T\",\"~u69db50d9-c23a-4aa4-bb79-33d5dbe54593\",536870913]],[\"^84\",[619,\"^;\",\"^3T\",536870913]],[\"^84\",[619,\"^4<\",181,536870913]],[\"^84\",[619,\"^3K\",89,536870913]],[\"^84\",[620,\"^48\",1775981500540,536870913]],[\"^84\",[620,\"^7M\",\"page 4\",536870913]],[\"^84\",[620,\"^1S\",58,536870913]],[\"^84\",[620,\"^1S\",104,536870913]],[\"^84\",[620,\"^1S\",619,536870913]],[\"^84\",[620,\"^69\",104,536870913]],[\"^84\",[620,\"^69\",619,536870913]],[\"^84\",[620,\"^6:\",\"page 4\",536870913]],[\"^84\",[620,\"^3;\",1775981500561,536870913]],[\"^84\",[620,\"^2T\",\"~u69db53ab-5e4a-4039-b2ba-4220ca0b20cd\",536870913]],[\"^84\",[620,\"^4<\",181,536870913]],[\"^84\",[621,\"^48\",1775981500553,536870913]],[\"^84\",[621,\"^7@\",\"a0\",536870913]],[\"^84\",[621,\"^4S\",620,536870913]],[\"^84\",[621,\"^3X\",620,536870913]],[\"^84\",[621,\"^6:\",\"test\",536870913]],[\"^84\",[621,\"^3;\",1775981500560,536870913]],[\"^84\",[621,\"^2T\",\"~u69db53ac-7264-4191-bac4-eee78a2ff031\",536870913]],[\"^84\",[621,\"^4<\",181,536870913]],[\"^84\",[622,\"^48\",1775981513870,536870913]],[\"^84\",[622,\"^7@\",\"a0\",536870913]],[\"^84\",[622,\"^4S\",170,536870913]],[\"^84\",[622,\"^3X\",170,536870913]],[\"^84\",[622,\"^1S\",619,536870913]],[\"^84\",[622,\"^6:\",\"All\",536870913]],[\"^84\",[622,\"^3;\",1775981513870,536870913]],[\"^84\",[622,\"^2T\",\"~u00000006-1742-3452-4000-000000000000\",536870913]],[\"^84\",[622,\"^4<\",181,536870913]],[\"^84\",[622,\"^9\",619,536870913]],[\"^84\",[622,\"^5>\",\"~:class-objects\",536870913]],[\"^84\",[1530,\"^48\",1775982125944,536870913]],[\"^84\",[1530,\"^7M\",\"page 5\",536870913]],[\"^84\",[1530,\"^1S\",58,536870913]],[\"^84\",[1530,\"^1S\",104,536870913]],[\"^84\",[1530,\"^69\",104,536870913]],[\"^84\",[1530,\"^6:\",\"page 5\",536870913]],[\"^84\",[1530,\"^3;\",1775982125944,536870913]],[\"^84\",[1530,\"^2T\",\"~u69db557e-1cd8-48b3-87aa-1b8a684ca17f\",536870913]],[\"^84\",[1530,\"^4<\",181,536870913]],[\"^84\",[1732,\"^48\",1775982384424,536870913]],[\"^84\",[1732,\"^7M\",\"page 6\",536870913]],[\"^84\",[1732,\"^1S\",58,536870913]],[\"^84\",[1732,\"^1S\",104,536870913]],[\"^84\",[1732,\"^69\",104,536870913]],[\"^84\",[1732,\"^6:\",\"page 6\",536870913]],[\"^84\",[1732,\"^3;\",1775982413000,536870913]],[\"^84\",[1732,\"^2T\",\"~u69db5730-ad32-46c0-bb99-f87817d4443a\",536870913]],[\"^84\",[1732,\"^4<\",181,536870913]],[\"^84\",[1733,\"^48\",1775982384655,536870913]],[\"^84\",[1733,\"^7@\",\"a0\",536870913]],[\"^84\",[1733,\"^4S\",1732,536870913]],[\"^84\",[1733,\"^3X\",1732,536870913]],[\"^84\",[1733,\"^6:\",\"hello\",536870913]],[\"^84\",[1733,\"^3;\",1775982389175,536870913]],[\"^84\",[1733,\"^2T\",\"~u69db5730-c5a5-4116-89f5-26341ef44073\",536870913]],[\"^84\",[1733,\"^4<\",181,536870913]],[\"^84\",[1742,\"^48\",1775982389180,536870913]],[\"^84\",[1742,\"^7@\",\"a1\",536870913]],[\"^84\",[1742,\"^4S\",1732,536870913]],[\"^84\",[1742,\"^3X\",1732,536870913]],[\"^84\",[1742,\"^6:\",\"fjdlafda\",536870913]],[\"^84\",[1742,\"^3;\",1775982389685,536870913]],[\"^84\",[1742,\"^2T\",\"~u69db5731-9fe9-4c6b-a183-ffb2e6471f45\",536870913]],[\"^84\",[1743,\"^48\",1775982389690,536870913]],[\"^84\",[1743,\"^7@\",\"a2\",536870913]],[\"^84\",[1743,\"^4S\",1732,536870913]],[\"^84\",[1743,\"^3X\",1732,536870913]],[\"^84\",[1743,\"^6:\",\"f\",536870913]],[\"^84\",[1743,\"^3;\",1775982389754,536870913]],[\"^84\",[1743,\"^2T\",\"~u69db5732-3643-4e28-906b-581fbbb2234a\",536870913]],[\"^84\",[1744,\"^48\",1775982389755,536870913]],[\"^84\",[1744,\"^7@\",\"a3\",536870913]],[\"^84\",[1744,\"^4S\",1732,536870913]],[\"^84\",[1744,\"^3X\",1732,536870913]],[\"^84\",[1744,\"^6:\",\"adf\",536870913]],[\"^84\",[1744,\"^3;\",1775982389841,536870913]],[\"^84\",[1744,\"^2T\",\"~u69db5732-9fbc-49f2-8473-db80eee4e879\",536870913]],[\"^84\",[1745,\"^48\",1775982389843,536870913]],[\"^84\",[1745,\"^7@\",\"a4\",536870913]],[\"^84\",[1745,\"^4S\",1732,536870913]],[\"^84\",[1745,\"^3X\",1732,536870913]],[\"^84\",[1745,\"^6:\",\"f\",536870913]],[\"^84\",[1745,\"^3;\",1775982389926,536870913]],[\"^84\",[1745,\"^2T\",\"~u69db5732-a4fa-48bc-b850-1ea2dd125db8\",536870913]],[\"^84\",[1746,\"^48\",1775982389927,536870913]],[\"^84\",[1746,\"^7@\",\"a5\",536870913]],[\"^84\",[1746,\"^4S\",1732,536870913]],[\"^84\",[1746,\"^3X\",1732,536870913]],[\"^84\",[1746,\"^6:\",\"af\",536870913]],[\"^84\",[1746,\"^3;\",1775982390009,536870913]],[\"^84\",[1746,\"^2T\",\"~u69db5732-c89c-439e-91db-385e7cf0884a\",536870913]],[\"^84\",[1747,\"^48\",1775982390010,536870913]],[\"^84\",[1747,\"^7@\",\"a6\",536870913]],[\"^84\",[1747,\"^4S\",1732,536870913]],[\"^84\",[1747,\"^3X\",1732,536870913]],[\"^84\",[1747,\"^6:\",\"af\",536870913]],[\"^84\",[1747,\"^3;\",1775982390090,536870913]],[\"^84\",[1747,\"^2T\",\"~u69db5732-389f-48f7-a3c9-9ab01e4d06b3\",536870913]],[\"^84\",[1748,\"^48\",1775982390091,536870913]],[\"^84\",[1748,\"^7@\",\"a7\",536870913]],[\"^84\",[1748,\"^4S\",1732,536870913]],[\"^84\",[1748,\"^3X\",1732,536870913]],[\"^84\",[1748,\"^6:\",\"a\",536870913]],[\"^84\",[1748,\"^3;\",1775982390176,536870913]],[\"^84\",[1748,\"^2T\",\"~u69db5733-aeac-4823-bd0e-ba84d7fcc3aa\",536870913]],[\"^84\",[1749,\"^48\",1775982390178,536870913]],[\"^84\",[1749,\"^7@\",\"a8\",536870913]],[\"^84\",[1749,\"^4S\",1732,536870913]],[\"^84\",[1749,\"^3X\",1732,536870913]],[\"^84\",[1749,\"^6:\",\"so cool\",536870913]],[\"^84\",[1749,\"^3;\",1775982411832,536870913]],[\"^84\",[1749,\"^2T\",\"~u69db5733-9bfe-49db-96cb-03fe97bce35f\",536870913]],[\"^84\",[1749,\"^4<\",181,536870913]],[\"^84\",[1754,\"^48\",1775982411836,536870913]],[\"^84\",[1754,\"^7@\",\"a0\",536870913]],[\"^84\",[1754,\"^4S\",1732,536870913]],[\"^84\",[1754,\"^3X\",1749,536870913]],[\"^84\",[1754,\"^6:\",\"awesome work\",536870913]],[\"^84\",[1754,\"^3;\",1775982412416,536870913]],[\"^84\",[1754,\"^2T\",\"~u69db5739-bca6-4992-96ec-45334b44f4c4\",536870913]],[\"^84\",[1755,\"^48\",1775982412417,536870913]],[\"^84\",[1755,\"^7@\",\"a1\",536870913]],[\"^84\",[1755,\"^4S\",1732,536870913]],[\"^84\",[1755,\"^3X\",1749,536870913]],[\"^84\",[1755,\"^6:\",\"great\",536870913]],[\"^84\",[1755,\"^3;\",1775982412496,536870913]],[\"^84\",[1755,\"^2T\",\"~u69db573a-8ec0-46e1-bc26-d7632e93d96f\",536870913]],[\"^84\",[1756,\"^48\",1775982412499,536870913]],[\"^84\",[1756,\"^7@\",\"a2\",536870913]],[\"^84\",[1756,\"^4S\",1732,536870913]],[\"^84\",[1756,\"^3X\",1749,536870913]],[\"^84\",[1756,\"^6:\",\"sounds amazing\",536870913]],[\"^84\",[1756,\"^3;\",1775982412747,536870913]],[\"^84\",[1756,\"^2T\",\"~u69db573b-ec3d-4f66-8e8d-26c9ff157a6b\",536870913]],[\"^84\",[1757,\"^48\",1775982412833,536870913]],[\"^84\",[1757,\"^7@\",\"a3\",536870913]],[\"^84\",[1757,\"^4S\",1732,536870913]],[\"^84\",[1757,\"^3X\",1749,536870913]],[\"^84\",[1757,\"^6:\",\"stuff really just work\",536870913]],[\"^84\",[1757,\"^3;\",1775982413000,536870913]],[\"^84\",[1757,\"^2T\",\"~u69db573f-8a13-43cc-82af-060e89cfd1fa\",536870913]],[\"^84\",[1836,\"^48\",1775983070467,536870913]],[\"^84\",[1836,\"^7M\",\"page 7\",536870913]],[\"^84\",[1836,\"^1S\",58,536870913]],[\"^84\",[1836,\"^1S\",104,536870913]],[\"^84\",[1836,\"^69\",104,536870913]],[\"^84\",[1836,\"^6:\",\"page 7\",536870913]],[\"^84\",[1836,\"^3;\",1775983683136,536884972]],[\"^84\",[1836,\"^2T\",\"~u69db59de-5992-4e9c-aba5-12bbae69d657\",536870913]],[\"^84\",[1836,\"^4<\",181,536870913]],[\"^84\",[1852,\"^48\",1775983078379,536870913]],[\"^84\",[1852,\"^7@\",\"a0\",536870913]],[\"^84\",[1852,\"^4S\",1836,536870913]],[\"^84\",[1852,\"^3X\",1836,536870913]],[\"^84\",[1852,\"^6:\",\"fjdlafjldaf\",536870913]],[\"^84\",[1852,\"^3;\",1775983078467,536870913]],[\"^84\",[1852,\"^2T\",\"~u69db59de-1710-4353-9b7f-a6843d27c9f7\",536870913]],[\"^84\",[1853,\"^48\",1775983078469,536870913]],[\"^84\",[1853,\"^7@\",\"a1\",536870913]],[\"^84\",[1853,\"^4S\",1836,536870913]],[\"^84\",[1853,\"^3X\",1836,536870913]],[\"^84\",[1853,\"^6:\",\"da\",536870913]],[\"^84\",[1853,\"^3;\",1775983078547,536870913]],[\"^84\",[1853,\"^2T\",\"~u69db59df-bbd0-4c0b-b89b-40d255373829\",536870913]],[\"^84\",[1854,\"^48\",1775983078548,536870913]],[\"^84\",[1854,\"^7@\",\"a2\",536870913]],[\"^84\",[1854,\"^4S\",1836,536870913]],[\"^84\",[1854,\"^3X\",1836,536870913]],[\"^84\",[1854,\"^6:\",\"fd\",536870913]],[\"^84\",[1854,\"^3;\",1775983078649,536870913]],[\"^84\",[1854,\"^2T\",\"~u69db59df-ecc8-48f2-ab1d-b885294463c8\",536870913]],[\"^84\",[1855,\"^48\",1775983078652,536870913]],[\"^84\",[1855,\"^7@\",\"a0\",536870913]],[\"^84\",[1855,\"^4S\",1836,536870913]],[\"^84\",[1855,\"^3X\",1854,536870913]],[\"^84\",[1855,\"^6:\",\"aff\",536870913]],[\"^84\",[1855,\"^3;\",1775983078804,536870913]],[\"^84\",[1855,\"^2T\",\"~u69db59df-fbd3-4baa-b307-e439f1f660bc\",536870913]],[\"^84\",[1856,\"^48\",1775983078806,536870913]],[\"^84\",[1856,\"^7@\",\"a1\",536870913]],[\"^84\",[1856,\"^4S\",1836,536870913]],[\"^84\",[1856,\"^3X\",1854,536870913]],[\"^84\",[1856,\"^6:\",\"daqf\",536870913]],[\"^84\",[1856,\"^3;\",1775983078892,536870913]],[\"^84\",[1856,\"^2T\",\"~u69db59e0-6768-4059-b647-6b522367a85d\",536870913]],[\"^84\",[1857,\"^48\",1775983078897,536870913]],[\"^84\",[1857,\"^7@\",\"a2\",536870913]],[\"^84\",[1857,\"^4S\",1836,536870913]],[\"^84\",[1857,\"^3X\",1854,536870913]],[\"^84\",[1857,\"^6:\",\"f\",536870913]],[\"^84\",[1857,\"^3;\",1775983079138,536870913]],[\"^84\",[1857,\"^2T\",\"~u69db59e0-16b6-48d5-ae4d-c6832cec5580\",536870913]],[\"^84\",[1858,\"^48\",1775983079140,536870913]],[\"^84\",[1858,\"^7@\",\"a0\",536870913]],[\"^84\",[1858,\"^4S\",1836,536870913]],[\"^84\",[1858,\"^3X\",1857,536870913]],[\"^84\",[1858,\"^6:\",\"f\",536870913]],[\"^84\",[1858,\"^3;\",1775983079488,536870913]],[\"^84\",[1858,\"^2T\",\"~u69db59e0-1a63-4e46-97b2-9696843f7c47\",536870913]],[\"^84\",[1859,\"^48\",1775983079489,536870913]],[\"^84\",[1859,\"^7@\",\"a1\",536870913]],[\"^84\",[1859,\"^4S\",1836,536870913]],[\"^84\",[1859,\"^3X\",1857,536870913]],[\"^84\",[1859,\"^6:\",\"f\",536870913]],[\"^84\",[1859,\"^3;\",1775983079572,536870913]],[\"^84\",[1859,\"^2T\",\"~u69db59e1-da15-45cb-9f42-a87f80023c55\",536870913]],[\"^84\",[1860,\"^48\",1775983079643,536870913]],[\"^84\",[1860,\"^7@\",\"a0\",536870913]],[\"^84\",[1860,\"^4S\",1836,536870913]],[\"^84\",[1860,\"^3X\",1859,536870913]],[\"^84\",[1860,\"^6:\",\"f\",536870913]],[\"^84\",[1860,\"^3;\",1775983079815,536870913]],[\"^84\",[1860,\"^2T\",\"~u69db59e1-f52e-4e72-b110-c313fbd76c44\",536870913]],[\"^84\",[1861,\"^48\",1775983079817,536870913]],[\"^84\",[1861,\"^7@\",\"a1\",536870913]],[\"^84\",[1861,\"^4S\",1836,536870913]],[\"^84\",[1861,\"^3X\",1859,536870913]],[\"^84\",[1861,\"^6:\",\"f\",536884964]],[\"^84\",[1861,\"^3;\",1775983682382,536884964]],[\"^84\",[1861,\"^2T\",\"~u69db59e1-bc2d-49e6-ad28-ade10a3e506c\",536870913]],[\"^84\",[2384,\"^48\",1775983682383,536871818]],[\"^84\",[2384,\"^7@\",\"a0V\",536871818]],[\"^84\",[2384,\"^4S\",1836,536871818]],[\"^84\",[2384,\"^3X\",1859,536871818]],[\"^84\",[2384,\"^6:\",\"fd\",536884966]],[\"^84\",[2384,\"^3;\",1775983682880,536884966]],[\"^84\",[2384,\"^2T\",\"~u69db59e1-5216-491c-b6ff-36ba8fb23f4d\",536871818]],[\"^84\",[2385,\"^48\",1775983682881,536871819]],[\"^84\",[2385,\"^7@\",\"a0l\",536871819]],[\"^84\",[2385,\"^4S\",1836,536871819]],[\"^84\",[2385,\"^3X\",1859,536871819]],[\"^84\",[2385,\"^6:\",\"af\",536884968]],[\"^84\",[2385,\"^3;\",1775983682974,536884968]],[\"^84\",[2385,\"^2T\",\"~u69db59e1-b11f-4372-9155-50825d6fd4ed\",536871819]],[\"^84\",[2386,\"^48\",1775983682975,536871820]],[\"^84\",[2386,\"^7@\",\"a0t\",536871820]],[\"^84\",[2386,\"^4S\",1836,536871820]],[\"^84\",[2386,\"^3X\",1859,536871820]],[\"^84\",[2386,\"^6:\",\"asf\",536884970]],[\"^84\",[2386,\"^3;\",1775983683045,536884970]],[\"^84\",[2386,\"^2T\",\"~u69db59e2-58f7-4f9e-aa44-34b587b0a687\",536871820]],[\"^84\",[2387,\"^48\",1775983683046,536871821]],[\"^84\",[2387,\"^7@\",\"a0x\",536871821]],[\"^84\",[2387,\"^4S\",1836,536871821]],[\"^84\",[2387,\"^3X\",1859,536871821]],[\"^84\",[2387,\"^6:\",\"sf\",536884972]],[\"^84\",[2387,\"^3;\",1775983683135,536884972]],[\"^84\",[2387,\"^2T\",\"~u69db59e2-7991-45be-ba25-1cdb2e3519a9\",536871821]],[\"^84\",[2388,\"^48\",1775983683137,536871822]],[\"^84\",[2388,\"^7@\",\"a0z\",536871822]],[\"^84\",[2388,\"^4S\",1836,536871822]],[\"^84\",[2388,\"^3X\",1859,536871822]],[\"^84\",[2388,\"^6:\",\"\",536871822]],[\"^84\",[2388,\"^3;\",1775983683137,536871822]],[\"^84\",[2388,\"^2T\",\"~u69db59e2-1a51-43ba-8a3b-ceb25f6aa499\",536871822]],[\"^84\",[2460,\"^48\",1775983689295,536871863]],[\"^84\",[2460,\"^7M\",\"page 9\",536871863]],[\"^84\",[2460,\"^1S\",58,536871863]],[\"^84\",[2460,\"^1S\",104,536871863]],[\"^84\",[2460,\"^69\",104,536871863]],[\"^84\",[2460,\"^6:\",\"page 9\",536871863]],[\"^84\",[2460,\"^3;\",1775983693372,536885142]],[\"^84\",[2460,\"^2T\",\"~u69db5c49-fb5d-4f7a-90a9-cd294f0aef44\",536871863]],[\"^84\",[2460,\"^4<\",181,536871863]],[\"^84\",[2461,\"^48\",1775983689528,536871864]],[\"^84\",[2461,\"^7@\",\"a0\",536871864]],[\"^84\",[2461,\"^4S\",2460,536871864]],[\"^84\",[2461,\"^3X\",2460,536871864]],[\"^84\",[2461,\"^6:\",\"fjldafjlas\",536885075]],[\"^84\",[2461,\"^3;\",1775983690319,536885075]],[\"^84\",[2461,\"^2T\",\"~u69db5c49-9f9e-49e8-9ead-2c36ec84031b\",536871864]],[\"^84\",[2461,\"^4<\",181,536871864]],[\"^84\",[2462,\"^48\",1775983690320,536871865]],[\"^84\",[2462,\"^7@\",\"a0\",536885079]],[\"^84\",[2462,\"^4S\",2460,536871865]],[\"^84\",[2462,\"^3X\",2461,536871866]],[\"^84\",[2462,\"^6:\",\"f\",536885078]],[\"^84\",[2462,\"^3;\",1775983690437,536885078]],[\"^84\",[2462,\"^2T\",\"~u69db5c4a-c58e-4a90-9cd6-4f692890f42a\",536871865]],[\"^84\",[2462,\"^4<\",181,536871865]],[\"^84\",[2463,\"^48\",1775983690514,536871867]],[\"^84\",[2463,\"^7@\",\"a0\",536885089]],[\"^84\",[2463,\"^4S\",2460,536871867]],[\"^84\",[2463,\"^3X\",2462,536871870]],[\"^84\",[2463,\"^6:\",\"f\",536885088]],[\"^84\",[2463,\"^3;\",1775983690873,536885088]],[\"^84\",[2463,\"^2T\",\"~u69db5c4a-0212-4c6f-bee0-24b88a12deee\",536871867]],[\"^84\",[2463,\"^4<\",181,536871867]],[\"^84\",[2464,\"^48\",1775983690916,536871871]],[\"^84\",[2464,\"^7@\",\"Zz\",536871871]],[\"^84\",[2464,\"^4S\",2460,536871871]],[\"^84\",[2464,\"^3X\",2462,536871871]],[\"^84\",[2464,\"^6:\",\"\",536871871]],[\"^84\",[2464,\"^3;\",1775983690916,536871871]],[\"^84\",[2464,\"^2T\",\"~u69db5c4a-2364-4fb7-bdd1-223745f4c69a\",536871871]],[\"^84\",[2464,\"^4<\",181,536871871]],[\"^84\",[2465,\"^48\",1775983691100,536871872]],[\"^84\",[2465,\"^7@\",\"a0\",536885101]],[\"^84\",[2465,\"^4S\",2460,536871872]],[\"^84\",[2465,\"^3X\",2464,536871874]],[\"^84\",[2465,\"^6:\",\"f\",536885097]],[\"^84\",[2465,\"^3;\",1775983691353,536885100]],[\"^84\",[2465,\"^2T\",\"~u69db5c4b-932f-49b8-9522-2f5d6a962f25\",536871872]],[\"^84\",[2465,\"^4<\",181,536871872]],[\"^84\",[2466,\"^48\",1775983691319,536871873]],[\"^84\",[2466,\"^7@\",\"a1\",536885105]],[\"^84\",[2466,\"^4S\",2460,536871873]],[\"^84\",[2466,\"^3X\",2464,536871876]],[\"^84\",[2466,\"^6:\",\"\",536871873]],[\"^84\",[2466,\"^3;\",1775983691319,536871873]],[\"^84\",[2466,\"^2T\",\"~u69db5c4b-9d2c-4516-b00c-25b6e2a7d6de\",536871873]],[\"^84\",[2466,\"^4<\",181,536871873]],[\"^84\",[2467,\"^48\",1775983691450,536871875]],[\"^84\",[2467,\"^7@\",\"a2\",536885111]],[\"^84\",[2467,\"^4S\",2460,536871875]],[\"^84\",[2467,\"^3X\",2464,536871878]],[\"^84\",[2467,\"^6:\",\"f\",536885107]],[\"^84\",[2467,\"^3;\",1775983691677,536885110]],[\"^84\",[2467,\"^2T\",\"~u69db5c4b-7c2d-4bb4-9aaa-5a9f1a445f52\",536871875]],[\"^84\",[2467,\"^4<\",181,536871875]],[\"^84\",[2468,\"^48\",1775983691628,536871877]],[\"^84\",[2468,\"^7@\",\"Zzx\",536871877]],[\"^84\",[2468,\"^4S\",2460,536871877]],[\"^84\",[2468,\"^3X\",2462,536871877]],[\"^84\",[2468,\"^6:\",\"f\",536885112]],[\"^84\",[2468,\"^3;\",1775983691774,536885112]],[\"^84\",[2468,\"^2T\",\"~u69db5c4b-56f8-422b-b193-b482957b3b31\",536871877]],[\"^84\",[2468,\"^4<\",181,536871877]],[\"^84\",[2469,\"^48\",1775983691775,536871879]],[\"^84\",[2469,\"^7@\",\"a0\",536885115]],[\"^84\",[2469,\"^4S\",2460,536871879]],[\"^84\",[2469,\"^3X\",2468,536871880]],[\"^84\",[2469,\"^6:\",\"f\",536885117]],[\"^84\",[2469,\"^3;\",1775983691973,536885117]],[\"^84\",[2469,\"^2T\",\"~u69db5c4b-0f65-4034-9d3e-a53826620eb4\",536871879]],[\"^84\",[2469,\"^4<\",181,536871879]],[\"^84\",[2470,\"^48\",1775983691974,536871881]],[\"^84\",[2470,\"^7@\",\"Zz\",536871881]],[\"^84\",[2470,\"^4S\",2460,536871881]],[\"^84\",[2470,\"^3X\",2468,536871881]],[\"^84\",[2470,\"^6:\",\"\",536871881]],[\"^84\",[2470,\"^3;\",1775983691974,536871881]],[\"^84\",[2470,\"^2T\",\"~u69db5c4b-8a1c-4a55-9c85-2c33cc41abf3\",536871881]],[\"^84\",[2470,\"^4<\",181,536871881]],[\"^84\",[2471,\"^48\",1775983692146,536871882]],[\"^84\",[2471,\"^7@\",\"a0\",536885131]],[\"^84\",[2471,\"^4S\",2460,536871882]],[\"^84\",[2471,\"^3X\",2470,536871886]],[\"^84\",[2471,\"^6:\",\"f\",536885127]],[\"^84\",[2471,\"^3;\",1775983692532,536885130]],[\"^84\",[2471,\"^2T\",\"~u69db5c4c-2d33-4497-a4ca-9fa095153cef\",536871882]],[\"^84\",[2471,\"^4<\",181,536871882]],[\"^84\",[2472,\"^48\",1775983692485,536871885]],[\"^84\",[2472,\"^7@\",\"a1\",536885136]],[\"^84\",[2472,\"^4S\",2460,536871885]],[\"^84\",[2472,\"^3X\",2470,536871888]],[\"^84\",[2472,\"^6:\",\"f\",536885132]],[\"^84\",[2472,\"^3;\",1775983692712,536885135]],[\"^84\",[2472,\"^2T\",\"~u69db5c4c-65af-484b-80c2-221d316cd911\",536871885]],[\"^84\",[2472,\"^4<\",181,536871885]],[\"^84\",[2473,\"^48\",1775983692668,536871887]],[\"^84\",[2473,\"^7@\",\"Zzt\",536871887]],[\"^84\",[2473,\"^4S\",2460,536871887]],[\"^84\",[2473,\"^3X\",2468,536871887]],[\"^84\",[2473,\"^6:\",\"f\",536885137]],[\"^84\",[2473,\"^3;\",1775983692810,536885137]],[\"^84\",[2473,\"^2T\",\"~u69db5c4c-8ca4-40fa-b76f-65bdfb599fa4\",536871887]],[\"^84\",[2473,\"^4<\",181,536871887]],[\"^84\",[2474,\"^48\",1775983692811,536871889]],[\"^84\",[2474,\"^7@\",\"a0\",536885140]],[\"^84\",[2474,\"^4S\",2460,536871889]],[\"^84\",[2474,\"^3X\",2473,536871890]],[\"^84\",[2474,\"^1S\",58,536871893]],[\"^84\",[2474,\"^1S\",2475,536871893]],[\"^84\",[2474,\"^69\",2475,536871893]],[\"^84\",[2474,\"^6:\",\"f\",536885142]],[\"^84\",[2474,\"^3;\",1775983693372,536885142]],[\"^84\",[2474,\"^2T\",\"~u69db5c4c-ddf7-4a3a-a05f-8a31dc7acded\",536871889]],[\"^84\",[2474,\"^4<\",181,536871889]],[\"^84\",[2475,\"^3D\",false,536885153]],[\"^84\",[2475,\"^48\",1775983696483,536871892]],[\"^84\",[2475,\"^7M\",\"tag2\",536871892]],[\"^84\",[2475,\"^1S\",14,536871892]],[\"^84\",[2475,\"^1S\",58,536871892]],[\"^84\",[2475,\"^1S\",89,536871892]],[\"^84\",[2475,\"^1S\",121,536871896]],[\"^84\",[2475,\"^1S\",158,536871892]],[\"^84\",[2475,\"^1S\",565,536871896]],[\"^84\",[2475,\"^1S\",568,536871897]],[\"^84\",[2475,\"^1S\",2477,536871899]],[\"^84\",[2475,\"^69\",14,536871892]],[\"^84\",[2475,\"^6:\",\"tag2\",536871892]],[\"^84\",[2475,\"^3;\",1775983696483,536871892]],[\"^84\",[2475,\"^2T\",\"~u69db5c50-3b82-4cb1-a425-271e7c8e6a03\",536871892]],[\"^84\",[2475,\"^;\",\"^3R\",536871892]],[\"^84\",[2475,\"^4<\",181,536871892]],[\"^84\",[2475,\"^3K\",89,536871892]],[\"^84\",[2475,\"^4K\",565,536871896]],[\"^84\",[2475,\"^4K\",568,536871897]],[\"^84\",[2475,\"^4K\",2477,536871899]],[\"^84\",[2476,\"^48\",1775983698445,536871894]],[\"^84\",[2476,\"^7@\",\"Zy\",536871894]],[\"^84\",[2476,\"^4S\",170,536871894]],[\"^84\",[2476,\"^3X\",170,536871894]],[\"^84\",[2476,\"^1S\",2475,536871894]],[\"^84\",[2476,\"^6:\",\"All\",536871894]],[\"^84\",[2476,\"^3;\",1775983698445,536871894]],[\"^84\",[2476,\"^2T\",\"~u00000006-2710-8879-5000-000000000000\",536871894]],[\"^84\",[2476,\"^4<\",181,536871894]],[\"^84\",[2476,\"^9\",2475,536871894]],[\"^84\",[2476,\"^5>\",\"^8O\",536871894]],[\"^84\",[2477,\"^48\",1775983711630,536871898]],[\"^84\",[2477,\"^7M\",\"p5\",536871898]],[\"^84\",[2477,\"^7@\",\"d3BK2\",536871898]],[\"^84\",[2477,\"^1S\",58,536871898]],[\"^84\",[2477,\"^1S\",104,536871900]],[\"^84\",[2477,\"^1S\",124,536871898]],[\"^84\",[2477,\"^69\",124,536871898]],[\"^84\",[2477,\"^6:\",\"p5\",536871898]],[\"^84\",[2477,\"^3;\",1775983717913,536885166]],[\"^84\",[2477,\"^2T\",\"~u00000002-1345-4970-5000-000000000000\",536871898]],[\"^84\",[2477,\"^<\",\"^14\",536885166]],[\"^84\",[2477,\"^;\",\"^23\",536871898]],[\"^84\",[2477,\"^>\",true,536871898]],[\"^84\",[2477,\"^C\",\"^D\",536871898]],[\"^84\",[2477,\"^6G\",104,536871900]],[\"^84\",[2477,\"^4<\",181,536871898]],[\"^84\",[2477,\"^1K\",\"^85\",536871898]],[\"^84\",[2577,\"^48\",1775983728413,536872235]],[\"^84\",[2577,\"^7@\",\"a0\",536872235]],[\"^84\",[2577,\"^4S\",180,536872235]],[\"^84\",[2577,\"^3X\",180,536872235]],[\"^84\",[2577,\"^6:\",\"1\",536872238]],[\"^84\",[2577,\"^S\",536872238,536872240]],[\"^84\",[2577,\"^3;\",1775983728422,536872238]],[\"^84\",[2577,\"^2T\",\"~u69db5bf3-a739-4564-9347-27fae2cc394d\",536872235]],[\"^84\",[2577,\"^4<\",181,536872236]],[\"^84\",[2578,\"^48\",1775983728424,536872239]],[\"^84\",[2578,\"^7@\",\"a0\",536872241]],[\"^84\",[2578,\"^4S\",180,536872239]],[\"^84\",[2578,\"^3X\",2577,536872241]],[\"^84\",[2578,\"^6:\",\"2\",536872243]],[\"^84\",[2578,\"^S\",536872243,536872245]],[\"^84\",[2578,\"^3;\",1775983728449,536872243]],[\"^84\",[2578,\"^2T\",\"~u69db5bf5-0ac5-4b52-82f3-b4d6d8314401\",536872239]],[\"^84\",[2578,\"^4<\",181,536872239]],[\"^84\",[2692,\"^48\",1775983729631,536872436]],[\"^84\",[2692,\"^7M\",\"page 8\",536872436]],[\"^84\",[2692,\"^1S\",58,536872438]],[\"^84\",[2692,\"^1S\",104,536872438]],[\"^84\",[2692,\"^69\",104,536872436]],[\"^84\",[2692,\"^6:\",\"page 8\",536872436]],[\"^84\",[2692,\"^S\",536872479,536872481]],[\"^84\",[2692,\"^3;\",1775983729797,536872479]],[\"^84\",[2692,\"^2T\",\"~u69db5c2a-601b-45ea-ab8f-13e01ea7787b\",536872436]],[\"^84\",[2692,\"^4<\",181,536872437]],[\"^84\",[2693,\"^48\",1775983729638,536872439]],[\"^84\",[2693,\"^7@\",\"a0\",536872439]],[\"^84\",[2693,\"^4S\",2692,536872439]],[\"^84\",[2693,\"^3X\",2692,536872439]],[\"^84\",[2693,\"^6:\",\"hello\",536872442]],[\"^84\",[2693,\"^S\",536872442,536872444]],[\"^84\",[2693,\"^3;\",1775983729647,536872442]],[\"^84\",[2693,\"^2T\",\"~u69db5c2b-3d88-4bad-ae1a-e068d1950520\",536872439]],[\"^84\",[2693,\"^4<\",181,536872440]],[\"^84\",[2694,\"^48\",1775983729648,536872443]],[\"^84\",[2694,\"^7@\",\"a1\",536872443]],[\"^84\",[2694,\"^4S\",2692,536872443]],[\"^84\",[2694,\"^3X\",2692,536872443]],[\"^84\",[2694,\"^6:\",\"worjfdaf\",536872445]],[\"^84\",[2694,\"^S\",536872445,536872447]],[\"^84\",[2694,\"^3;\",1775983729659,536872445]],[\"^84\",[2694,\"^2T\",\"~u69db5c2b-6a3d-4726-a69e-41d53fd5a0bf\",536872443]],[\"^84\",[2694,\"^4<\",181,536872443]],[\"^84\",[2695,\"^48\",1775983729661,536872446]],[\"^84\",[2695,\"^7@\",\"a2\",536872446]],[\"^84\",[2695,\"^4S\",2692,536872446]],[\"^84\",[2695,\"^3X\",2692,536872446]],[\"^84\",[2695,\"^6:\",\"\",536872446]],[\"^84\",[2695,\"^S\",536872445,536872447]],[\"^84\",[2695,\"^3;\",1775983729661,536872446]],[\"^84\",[2695,\"^2T\",\"~u69db5c2c-b373-49b6-a1f1-029b2f8ca8c8\",536872446]],[\"^84\",[2695,\"^4<\",181,536872446]],[\"^84\",[2696,\"^48\",1775983729672,536872448]],[\"^84\",[2696,\"^7@\",\"a0\",536872451]],[\"^84\",[2696,\"^4S\",2692,536872448]],[\"^84\",[2696,\"^3X\",2695,536872451]],[\"^84\",[2696,\"^6:\",\"f\",536872453]],[\"^84\",[2696,\"^S\",536872453,536872455]],[\"^84\",[2696,\"^3;\",1775983729688,536872453]],[\"^84\",[2696,\"^2T\",\"~u69db5c2c-ba52-4f79-85f8-a7655369ba6c\",536872448]],[\"^84\",[2696,\"^4<\",181,536872449]],[\"^84\",[2697,\"^48\",1775983729689,536872454]],[\"^84\",[2697,\"^7@\",\"a1\",536872454]],[\"^84\",[2697,\"^4S\",2692,536872454]],[\"^84\",[2697,\"^3X\",2695,536872454]],[\"^84\",[2697,\"^6:\",\"af\",536872456]],[\"^84\",[2697,\"^S\",536872456,536872458]],[\"^84\",[2697,\"^3;\",1775983729700,536872456]],[\"^84\",[2697,\"^2T\",\"~u69db5c2c-f588-4d67-89cf-637ed39de614\",536872454]],[\"^84\",[2697,\"^4<\",181,536872454]],[\"^84\",[2698,\"^48\",1775983729701,536872457]],[\"^84\",[2698,\"^7@\",\"a2\",536872457]],[\"^84\",[2698,\"^4S\",2692,536872457]],[\"^84\",[2698,\"^3X\",2695,536872457]],[\"^84\",[2698,\"^6:\",\"as\",536872459]],[\"^84\",[2698,\"^S\",536872459,536872461]],[\"^84\",[2698,\"^3;\",1775983729712,536872459]],[\"^84\",[2698,\"^2T\",\"~u69db5c2d-eb41-41f0-97cb-c9dace0102c2\",536872457]],[\"^84\",[2698,\"^4<\",181,536872457]],[\"^84\",[2699,\"^48\",1775983729714,536872460]],[\"^84\",[2699,\"^7@\",\"a3\",536872460]],[\"^84\",[2699,\"^4S\",2692,536872460]],[\"^84\",[2699,\"^3X\",2695,536872460]],[\"^84\",[2699,\"^6:\",\"fa\",536872462]],[\"^84\",[2699,\"^S\",536872462,536872464]],[\"^84\",[2699,\"^3;\",1775983729725,536872462]],[\"^84\",[2699,\"^2T\",\"~u69db5c2d-02cd-4b60-b743-09feff4e4825\",536872460]],[\"^84\",[2699,\"^4<\",181,536872460]],[\"^84\",[2700,\"^48\",1775983729726,536872463]],[\"^84\",[2700,\"^7@\",\"a4\",536872463]],[\"^84\",[2700,\"^4S\",2692,536872463]],[\"^84\",[2700,\"^3X\",2695,536872463]],[\"^84\",[2700,\"^6:\",\"f\",536872465]],[\"^84\",[2700,\"^S\",536872465,536872467]],[\"^84\",[2700,\"^3;\",1775983729738,536872465]],[\"^84\",[2700,\"^2T\",\"~u69db5c2d-a17f-4516-b106-304a1befd700\",536872463]],[\"^84\",[2700,\"^4<\",181,536872463]],[\"^84\",[2701,\"^48\",1775983729739,536872466]],[\"^84\",[2701,\"^7@\",\"a5\",536872466]],[\"^84\",[2701,\"^4S\",2692,536872466]],[\"^84\",[2701,\"^3X\",2695,536872466]],[\"^84\",[2701,\"^1S\",2702,536872474]],[\"^84\",[2701,\"^6:\",\"af\",536872468]],[\"^84\",[2701,\"^S\",536872474,536872475]],[\"^84\",[2701,\"^3;\",1775983665047,536872474]],[\"^84\",[2701,\"^2T\",\"~u69db5c2d-167b-455d-9de8-a098caf456e3\",536872466]],[\"^84\",[2701,\"^4<\",181,536872466]],[\"^84\",[2701,\"^4?\",2703,536872474]],[\"^84\",[2702,\"^48\",1775983729759,536872471]],[\"^84\",[2702,\"^7M\",\"p10\",536872471]],[\"^84\",[2702,\"^7@\",\"d3BHk\",536872471]],[\"^84\",[2702,\"^1S\",58,536872473]],[\"^84\",[2702,\"^1S\",124,536872473]],[\"^84\",[2702,\"^69\",124,536872471]],[\"^84\",[2702,\"^6:\",\"p10\",536872471]],[\"^84\",[2702,\"^S\",536872471,536872473]],[\"^84\",[2702,\"^3;\",1775983729759,536872471]],[\"^84\",[2702,\"^2T\",\"~u00000002-1449-8430-2100-000000000000\",536872471]],[\"^84\",[2702,\"^<\",\"^=\",536872471]],[\"^84\",[2702,\"^;\",\"^4?\",536872471]],[\"^84\",[2702,\"^>\",true,536872471]],[\"^84\",[2702,\"^C\",\"^D\",536872471]],[\"^84\",[2702,\"^4<\",181,536872472]],[\"^84\",[2702,\"^1K\",\"^8>\",536872471]],[\"^84\",[2703,\"^48\",1775983665044,536872474]],[\"^84\",[2703,\"^7@\",\"d3BHj\",536872474]],[\"^84\",[2703,\"^4S\",2692,536872474]],[\"^84\",[2703,\"^3X\",2701,536872474]],[\"^84\",[2703,\"^1S\",2702,536872474]],[\"^84\",[2703,\"^6:\",\"hello\",536872476]],[\"^84\",[2703,\"^S\",536872476,536872478]],[\"^84\",[2703,\"^3;\",1775983729784,536872476]],[\"^84\",[2703,\"^2T\",\"~u69db5c31-45d6-443b-8f8f-1b1808c86b4b\",536872474]],[\"^84\",[2703,\"^4<\",181,536872474]],[\"^84\",[2703,\"^6?\",2702,536872474]],[\"^84\",[2704,\"^48\",1775983729785,536872477]],[\"^84\",[2704,\"^7@\",\"a0\",536872477]],[\"^84\",[2704,\"^4S\",2692,536872477]],[\"^84\",[2704,\"^3X\",2703,536872477]],[\"^84\",[2704,\"^6:\",\"fjdalfjklaf\",536872479]],[\"^84\",[2704,\"^S\",536872479,536872481]],[\"^84\",[2704,\"^3;\",1775983729797,536872479]],[\"^84\",[2704,\"^2T\",\"~u69db5c32-c449-4614-89c5-afdad827a965\",536872477]],[\"^84\",[2704,\"^4<\",181,536872477]],[\"^84\",[2705,\"^;\",\"^4O\",536872484]],[\"^84\",[2705,\"^28\",1775984161304,536872484]],[\"^84\",[2706,\"^;\",\"^4J\",536872485]],[\"^84\",[2706,\"^28\",1775984161307,536872485]],[\"^84\",[2778,\"^48\",1775984527252,536872747]],[\"^84\",[2778,\"^7M\",\"page 10\",536872747]],[\"^84\",[2778,\"^1S\",58,536872749]],[\"^84\",[2778,\"^1S\",104,536872749]],[\"^84\",[2778,\"^69\",104,536872747]],[\"^84\",[2778,\"^6:\",\"page 10\",536872747]],[\"^84\",[2778,\"^S\",536872879,536872881]],[\"^84\",[2778,\"^3;\",1775984527765,536872879]],[\"^84\",[2778,\"^2T\",\"~u69db5f78-689c-4382-9a3d-d2caf761adb6\",536872747]],[\"^84\",[2778,\"^4<\",181,536872748]],[\"^84\",[2793,\"^48\",1775984527610,536872833]],[\"^84\",[2793,\"^7@\",\"a0\",536872833]],[\"^84\",[2793,\"^4S\",2778,536872833]],[\"^84\",[2793,\"^3X\",2778,536872833]],[\"^84\",[2793,\"^6:\",\"hellofda\",536872836]],[\"^84\",[2793,\"^S\",536872836,536872838]],[\"^84\",[2793,\"^3;\",1775984527618,536872836]],[\"^84\",[2793,\"^2T\",\"~u69db5f78-dcde-4e98-9f1a-1bca6c316e8d\",536872833]],[\"^84\",[2793,\"^4<\",181,536872834]],[\"^84\",[2794,\"^48\",1775984527619,536872837]],[\"^84\",[2794,\"^7@\",\"a1\",536872837]],[\"^84\",[2794,\"^4S\",2778,536872837]],[\"^84\",[2794,\"^3X\",2778,536872837]],[\"^84\",[2794,\"^6:\",\"fa\",536872839]],[\"^84\",[2794,\"^S\",536872839,536872841]],[\"^84\",[2794,\"^3;\",1775984527629,536872839]],[\"^84\",[2794,\"^2T\",\"~u69db5f79-a66e-4a39-aecc-339726c89430\",536872837]],[\"^84\",[2794,\"^4<\",181,536872837]],[\"^84\",[2795,\"^48\",1775984527630,536872840]],[\"^84\",[2795,\"^7@\",\"a0\",536872842]],[\"^84\",[2795,\"^4S\",2778,536872840]],[\"^84\",[2795,\"^3X\",2794,536872842]],[\"^84\",[2795,\"^6:\",\"fdasf\",536872844]],[\"^84\",[2795,\"^S\",536872844,536872846]],[\"^84\",[2795,\"^3;\",1775984527646,536872844]],[\"^84\",[2795,\"^2T\",\"~u69db5f79-fd04-4c4f-a0d5-726877b307a3\",536872840]],[\"^84\",[2795,\"^4<\",181,536872840]],[\"^84\",[2796,\"^48\",1775984527647,536872845]],[\"^84\",[2796,\"^7@\",\"a1\",536872845]],[\"^84\",[2796,\"^4S\",2778,536872845]],[\"^84\",[2796,\"^3X\",2794,536872845]],[\"^84\",[2796,\"^6:\",\"af\",536872847]],[\"^84\",[2796,\"^S\",536872847,536872849]],[\"^84\",[2796,\"^3;\",1775984527656,536872847]],[\"^84\",[2796,\"^2T\",\"~u69db5f7a-d6b1-4bf8-90c6-a70ba0bec381\",536872845]],[\"^84\",[2796,\"^4<\",181,536872845]],[\"^84\",[2797,\"^48\",1775984527658,536872848]],[\"^84\",[2797,\"^7@\",\"a2\",536872848]],[\"^84\",[2797,\"^4S\",2778,536872848]],[\"^84\",[2797,\"^3X\",2794,536872848]],[\"^84\",[2797,\"^6:\",\"as\",536872850]],[\"^84\",[2797,\"^S\",536872850,536872852]],[\"^84\",[2797,\"^3;\",1775984527666,536872850]],[\"^84\",[2797,\"^2T\",\"~u69db5f7a-e8a7-4800-bd8c-b2c14b6648a8\",536872848]],[\"^84\",[2797,\"^4<\",181,536872848]],[\"^84\",[2798,\"^48\",1775984527668,536872851]],[\"^84\",[2798,\"^7@\",\"a3\",536872851]],[\"^84\",[2798,\"^4S\",2778,536872851]],[\"^84\",[2798,\"^3X\",2794,536872851]],[\"^84\",[2798,\"^6:\",\"fdas\",536872853]],[\"^84\",[2798,\"^S\",536872853,536872855]],[\"^84\",[2798,\"^3;\",1775984527677,536872853]],[\"^84\",[2798,\"^2T\",\"~u69db5f7a-f814-4036-b2d5-06b1688f319f\",536872851]],[\"^84\",[2798,\"^4<\",181,536872851]],[\"^84\",[2799,\"^48\",1775984527678,536872854]],[\"^84\",[2799,\"^7@\",\"a2\",536872856]],[\"^84\",[2799,\"^4S\",2778,536872854]],[\"^84\",[2799,\"^3X\",2778,536872856]],[\"^84\",[2799,\"^6:\",\"fs\",536872858]],[\"^84\",[2799,\"^S\",536872858,536872860]],[\"^84\",[2799,\"^3;\",1775984527694,536872858]],[\"^84\",[2799,\"^2T\",\"~u69db5f7b-2941-4704-88d5-3d10f2b585f6\",536872854]],[\"^84\",[2799,\"^4<\",181,536872854]],[\"^84\",[2800,\"^48\",1775984527696,536872859]],[\"^84\",[2800,\"^7@\",\"a3\",536872859]],[\"^84\",[2800,\"^4S\",2778,536872859]],[\"^84\",[2800,\"^3X\",2778,536872859]],[\"^84\",[2800,\"^6:\",\"af\",536872861]],[\"^84\",[2800,\"^S\",536872861,536872863]],[\"^84\",[2800,\"^3;\",1775984527705,536872861]],[\"^84\",[2800,\"^2T\",\"~u69db5f7b-11a3-4387-b2d8-e4360d0574b0\",536872859]],[\"^84\",[2800,\"^4<\",181,536872859]],[\"^84\",[2801,\"^48\",1775984527706,536872862]],[\"^84\",[2801,\"^7@\",\"a4\",536872862]],[\"^84\",[2801,\"^4S\",2778,536872862]],[\"^84\",[2801,\"^3X\",2778,536872862]],[\"^84\",[2801,\"^6:\",\"as\",536872862]],[\"^84\",[2801,\"^S\",536872861,536872863]],[\"^84\",[2801,\"^3;\",1775984527706,536872862]],[\"^84\",[2801,\"^2T\",\"~u69db5f7b-24f0-4258-b5b5-7c86c9fb264e\",536872862]],[\"^84\",[2801,\"^4<\",181,536872862]],[\"^84\",[2802,\"^48\",1775984527715,536872864]],[\"^84\",[2802,\"^7@\",\"a3V\",536872864]],[\"^84\",[2802,\"^4S\",2778,536872864]],[\"^84\",[2802,\"^3X\",2778,536872864]],[\"^84\",[2802,\"^6:\",\"fd\",536872867]],[\"^84\",[2802,\"^S\",536872867,536872869]],[\"^84\",[2802,\"^3;\",1775984527722,536872867]],[\"^84\",[2802,\"^2T\",\"~u69db5f7b-02a5-40e9-baf8-befba0d845c5\",536872864]],[\"^84\",[2802,\"^4<\",181,536872865]],[\"^84\",[2803,\"^48\",1775984527723,536872868]],[\"^84\",[2803,\"^7@\",\"a3l\",536872868]],[\"^84\",[2803,\"^4S\",2778,536872868]],[\"^84\",[2803,\"^3X\",2778,536872868]],[\"^84\",[2803,\"^6:\",\"af\",536872870]],[\"^84\",[2803,\"^S\",536872870,536872872]],[\"^84\",[2803,\"^3;\",1775984527732,536872870]],[\"^84\",[2803,\"^2T\",\"~u69db5f7c-4a9c-4a5f-ad77-4d171ab63937\",536872868]],[\"^84\",[2803,\"^4<\",181,536872868]],[\"^84\",[2804,\"^48\",1775984527734,536872871]],[\"^84\",[2804,\"^7@\",\"a3t\",536872871]],[\"^84\",[2804,\"^4S\",2778,536872871]],[\"^84\",[2804,\"^3X\",2778,536872871]],[\"^84\",[2804,\"^6:\",\"af\",536872873]],[\"^84\",[2804,\"^S\",536872873,536872875]],[\"^84\",[2804,\"^3;\",1775984527743,536872873]],[\"^84\",[2804,\"^2T\",\"~u69db5f7c-e498-4db4-9791-53e4560eb1cc\",536872871]],[\"^84\",[2804,\"^4<\",181,536872871]],[\"^84\",[2805,\"^48\",1775984527744,536872874]],[\"^84\",[2805,\"^7@\",\"a3x\",536872874]],[\"^84\",[2805,\"^4S\",2778,536872874]],[\"^84\",[2805,\"^3X\",2778,536872874]],[\"^84\",[2805,\"^6:\",\"af\",536872876]],[\"^84\",[2805,\"^S\",536872876,536872878]],[\"^84\",[2805,\"^3;\",1775984527754,536872876]],[\"^84\",[2805,\"^2T\",\"~u69db5f7c-7746-45d5-8989-646ee3b9289b\",536872874]],[\"^84\",[2805,\"^4<\",181,536872874]],[\"^84\",[2806,\"^48\",1775984527755,536872877]],[\"^84\",[2806,\"^7@\",\"a3z\",536872877]],[\"^84\",[2806,\"^4S\",2778,536872877]],[\"^84\",[2806,\"^3X\",2778,536872877]],[\"^84\",[2806,\"^6:\",\"a\",536872879]],[\"^84\",[2806,\"^S\",536872879,536872881]],[\"^84\",[2806,\"^3;\",1775984527764,536872879]],[\"^84\",[2806,\"^2T\",\"~u69db5f7c-8423-4ed9-bb36-bf5156b4d757\",536872877]],[\"^84\",[2806,\"^4<\",181,536872877]],[\"^84\",[2931,\"^48\",1775984610679,536873146]],[\"^84\",[2931,\"^7M\",\"page 11\",536873146]],[\"^84\",[2931,\"^1S\",58,536873146]],[\"^84\",[2931,\"^1S\",104,536873146]],[\"^84\",[2931,\"^69\",104,536873146]],[\"^84\",[2931,\"^6:\",\"page 11\",536873146]],[\"^84\",[2931,\"^3;\",1775984651669,536885723]],[\"^84\",[2931,\"^2T\",\"~u69db5fe2-20d8-4c72-8f3a-96b778acec6f\",536873146]],[\"^84\",[2931,\"^4<\",181,536873146]],[\"^84\",[2932,\"^48\",1775984610938,536873147]],[\"^84\",[2932,\"^7@\",\"a0\",536873147]],[\"^84\",[2932,\"^4S\",2931,536873147]],[\"^84\",[2932,\"^3X\",2931,536873147]],[\"^84\",[2932,\"^6:\",\"fjdlafjl\",536885538]],[\"^84\",[2932,\"^3;\",1775984611435,536885538]],[\"^84\",[2932,\"^2T\",\"~u69db5fe2-56e1-428d-bf11-06cb38229be2\",536873147]],[\"^84\",[2932,\"^4<\",181,536873147]],[\"^84\",[2933,\"^48\",1775984611507,536873149]],[\"^84\",[2933,\"^7@\",\"a0\",536885545]],[\"^84\",[2933,\"^4S\",2931,536873149]],[\"^84\",[2933,\"^3X\",2932,536873150]],[\"^84\",[2933,\"^6:\",\"ff\",536885547]],[\"^84\",[2933,\"^3;\",1775984611935,536885547]],[\"^84\",[2933,\"^2T\",\"~u69db5fe3-6de1-4070-96b6-6fb5e16cfd5d\",536873149]],[\"^84\",[2933,\"^4<\",181,536873149]],[\"^84\",[2934,\"^48\",1775984611937,536873151]],[\"^84\",[2934,\"^7@\",\"a0\",536885555]],[\"^84\",[2934,\"^4S\",2931,536873151]],[\"^84\",[2934,\"^3X\",2933,536873154]],[\"^84\",[2934,\"^6:\",\"f\",536885554]],[\"^84\",[2934,\"^3;\",1775984612220,536885554]],[\"^84\",[2934,\"^2T\",\"~u69db5fe3-0419-47c1-9d77-36ba70846eae\",536873151]],[\"^84\",[2934,\"^4<\",181,536873151]],[\"^84\",[2935,\"^48\",1775984612293,536873155]],[\"^84\",[2935,\"^7@\",\"a0\",536885568]],[\"^84\",[2935,\"^4S\",2931,536873155]],[\"^84\",[2935,\"^3X\",2934,536873159]],[\"^84\",[2935,\"^6:\",\"f\",536885564]],[\"^84\",[2935,\"^3;\",1775984612724,536885567]],[\"^84\",[2935,\"^2T\",\"~u69db5fe4-4a1d-4a85-8ffc-13fa73cf7772\",536873155]],[\"^84\",[2935,\"^4<\",181,536873155]],[\"^84\",[2936,\"^48\",1775984612632,536873158]],[\"^84\",[2936,\"^7@\",\"a1\",536885573]],[\"^84\",[2936,\"^4S\",2931,536873158]],[\"^84\",[2936,\"^3X\",2934,536873161]],[\"^84\",[2936,\"^6:\",\"f\",536885569]],[\"^84\",[2936,\"^3;\",1775984612903,536885572]],[\"^84\",[2936,\"^2T\",\"~u69db5fe4-bafe-449d-acb5-c363728d2c3a\",536873158]],[\"^84\",[2936,\"^4<\",181,536873158]],[\"^84\",[2937,\"^48\",1775984612812,536873160]],[\"^84\",[2937,\"^7@\",\"a3\",536873160]],[\"^84\",[2937,\"^4S\",2931,536873160]],[\"^84\",[2937,\"^3X\",2933,536873160]],[\"^84\",[2937,\"^6:\",\"f\",536885574]],[\"^84\",[2937,\"^3;\",1775984613001,536885574]],[\"^84\",[2937,\"^2T\",\"~u69db5fe4-a665-4b11-82ef-9fa78a1d025c\",536873160]],[\"^84\",[2937,\"^4<\",181,536873160]],[\"^84\",[2938,\"^48\",1775984613002,536873162]],[\"^84\",[2938,\"^7@\",\"a0\",536885577]],[\"^84\",[2938,\"^4S\",2931,536873162]],[\"^84\",[2938,\"^3X\",2937,536873163]],[\"^84\",[2938,\"^6:\",\"f\",536885579]],[\"^84\",[2938,\"^3;\",1775984613203,536885579]],[\"^84\",[2938,\"^2T\",\"~u69db5fe4-e871-4f41-a9a6-d2783de5b724\",536873162]],[\"^84\",[2938,\"^4<\",181,536873162]],[\"^84\",[2939,\"^48\",1775984613203,536873164]],[\"^84\",[2939,\"^7@\",\"a2\",536885639]],[\"^84\",[2939,\"^4S\",2931,536873164]],[\"^84\",[2939,\"^3X\",2950,536873186]],[\"^84\",[2939,\"^6:\",\"q\",536885584]],[\"^84\",[2939,\"^3;\",1775984613413,536885584]],[\"^84\",[2939,\"^2T\",\"~u69db5fe5-c93e-4715-a9e2-b311246fe3f1\",536873164]],[\"^84\",[2939,\"^4<\",181,536873164]],[\"^84\",[2940,\"^48\",1775984613413,536873166]],[\"^84\",[2940,\"^7@\",\"a1\",536873166]],[\"^84\",[2940,\"^4S\",2931,536873166]],[\"^84\",[2940,\"^3X\",2938,536873166]],[\"^84\",[2940,\"^6:\",\"\",536873166]],[\"^84\",[2940,\"^3;\",1775984613413,536873166]],[\"^84\",[2940,\"^2T\",\"~u69db5fe5-b337-41af-9146-6e6e01e72130\",536873166]],[\"^84\",[2940,\"^4<\",181,536873166]],[\"^84\",[2941,\"^48\",1775984613561,536873168]],[\"^84\",[2941,\"^7@\",\"a2\",536885721]],[\"^84\",[2941,\"^4S\",2931,536873168]],[\"^84\",[2941,\"^3X\",2931,536873469]],[\"^84\",[2941,\"^6:\",\"fdafdasf\",536885723]],[\"^84\",[2941,\"^3;\",1775984651668,536885723]],[\"^84\",[2941,\"^2T\",\"~u69db5fe5-ce1b-4fb0-802c-ca3c7deba63a\",536873168]],[\"^84\",[2941,\"^4<\",181,536873168]],[\"^84\",[2942,\"^48\",1775984613782,536873170]],[\"^84\",[2942,\"^7@\",\"Zz\",536873170]],[\"^84\",[2942,\"^4S\",2931,536873170]],[\"^84\",[2942,\"^3X\",2940,536873170]],[\"^84\",[2942,\"^6:\",\"ff\",536885603]],[\"^84\",[2942,\"^3;\",1775984614190,536885606]],[\"^84\",[2942,\"^2T\",\"~u69db5fe5-5fad-4cca-bed6-f9e7c1e2a37d\",536873170]],[\"^84\",[2942,\"^4<\",181,536873170]],[\"^84\",[2943,\"^48\",1775984613943,536873172]],[\"^84\",[2943,\"^7@\",\"a1\",536885638]],[\"^84\",[2943,\"^4S\",2931,536873172]],[\"^84\",[2943,\"^3X\",2950,536873186]],[\"^84\",[2943,\"^6:\",\"\",536873172]],[\"^84\",[2943,\"^3;\",1775984613943,536873172]],[\"^84\",[2943,\"^2T\",\"~u69db5fe5-deee-4706-a78d-aaf4007518c1\",536873172]],[\"^84\",[2943,\"^4<\",181,536873172]],[\"^84\",[2944,\"^48\",1775984614111,536873173]],[\"^84\",[2944,\"^7@\",\"a0\",536885612]],[\"^84\",[2944,\"^4S\",2931,536873173]],[\"^84\",[2944,\"^3X\",2942,536873176]],[\"^84\",[2944,\"^6:\",\"f\",536885608]],[\"^84\",[2944,\"^3;\",1775984614359,536885611]],[\"^84\",[2944,\"^2T\",\"~u69db5fe6-fd79-4fa8-bfab-30986aa83bcc\",536873173]],[\"^84\",[2944,\"^4<\",181,536873173]],[\"^84\",[2945,\"^48\",1775984614283,536873175]],[\"^84\",[2945,\"^7@\",\"a1\",536885617]],[\"^84\",[2945,\"^4S\",2931,536873175]],[\"^84\",[2945,\"^3X\",2942,536873178]],[\"^84\",[2945,\"^6:\",\"f\",536885613]],[\"^84\",[2945,\"^3;\",1775984614536,536885616]],[\"^84\",[2945,\"^2T\",\"~u69db5fe6-437d-47c3-91e6-7eed23097b73\",536873175]],[\"^84\",[2945,\"^4<\",181,536873175]],[\"^84\",[2946,\"^48\",1775984614454,536873177]],[\"^84\",[2946,\"^7@\",\"ZzS\",536873177]],[\"^84\",[2946,\"^4S\",2931,536873177]],[\"^84\",[2946,\"^3X\",2940,536873177]],[\"^84\",[2946,\"^6:\",\"\",536873177]],[\"^84\",[2946,\"^3;\",1775984614454,536873177]],[\"^84\",[2946,\"^2T\",\"~u69db5fe6-a079-4c88-8d9c-bc0609008fe6\",536873177]],[\"^84\",[2946,\"^4<\",181,536873177]],[\"^84\",[2947,\"^48\",1775984614634,536873179]],[\"^84\",[2947,\"^7@\",\"a2\",536885627]],[\"^84\",[2947,\"^4S\",2931,536873179]],[\"^84\",[2947,\"^3X\",2946,536873182]],[\"^84\",[2947,\"^6:\",\"ff\",536885632]],[\"^84\",[2947,\"^3;\",1775984615139,536885632]],[\"^84\",[2947,\"^2T\",\"~u69db5fe6-9f47-45fd-bd1d-78cdad7e5894\",536873179]],[\"^84\",[2947,\"^4<\",181,536873179]],[\"^84\",[2948,\"^48\",1775984614835,536873181]],[\"^84\",[2948,\"^7@\",\"a1\",536873181]],[\"^84\",[2948,\"^4S\",2931,536873181]],[\"^84\",[2948,\"^3X\",2946,536873181]],[\"^84\",[2948,\"^6:\",\"\",536873181]],[\"^84\",[2948,\"^3;\",1775984614835,536873181]],[\"^84\",[2948,\"^2T\",\"~u69db5fe6-6b73-4f8c-a89d-3cca819ee63c\",536873181]],[\"^84\",[2948,\"^4<\",181,536873181]],[\"^84\",[2949,\"^48\",1775984614992,536873183]],[\"^84\",[2949,\"^7@\",\"a0\",536885636]],[\"^84\",[2949,\"^4S\",2931,536873183]],[\"^84\",[2949,\"^3X\",2950,536873185]],[\"^84\",[2949,\"^6:\",\"\",536873183]],[\"^84\",[2949,\"^3;\",1775984614992,536873183]],[\"^84\",[2949,\"^2T\",\"~u69db5fe6-d898-483f-bc9b-0122ac0cffe1\",536873183]],[\"^84\",[2949,\"^4<\",181,536873183]],[\"^84\",[2950,\"^48\",1775984615139,536873184]],[\"^84\",[2950,\"^7@\",\"a1\",536885643]],[\"^84\",[2950,\"^4S\",2931,536873184]],[\"^84\",[2950,\"^3X\",2931,536873190]],[\"^84\",[2950,\"^6:\",\"\",536873184]],[\"^84\",[2950,\"^3;\",1775984615139,536873184]],[\"^84\",[2950,\"^2T\",\"~u69db5fe7-2d4d-43a8-b50b-aa6625c817ae\",536873184]],[\"^84\",[2950,\"^4<\",181,536873184]],[\"^84\",[2988,\"^48\",1775984622069,536873306]],[\"^84\",[2988,\"^7@\",\"a0\",536873309]],[\"^84\",[2988,\"^4S\",180,536873306]],[\"^84\",[2988,\"^3X\",2578,536873309]],[\"^84\",[2988,\"^6:\",\"3f\",536873475]],[\"^84\",[2988,\"^S\",536873475,536873477]],[\"^84\",[2988,\"^3;\",1775984657451,536873475]],[\"^84\",[2988,\"^2T\",\"~u69db5fbe-a17a-4787-8615-7455e1eba752\",536873306]],[\"^84\",[2988,\"^4<\",181,536873307]],[\"^84\",[3104,\"^48\",1775984657454,536873476]],[\"^84\",[3104,\"^7@\",\"a1\",536873476]],[\"^84\",[3104,\"^4S\",180,536873476]],[\"^84\",[3104,\"^3X\",2578,536873476]],[\"^84\",[3104,\"^6:\",\"da\",536873478]],[\"^84\",[3104,\"^S\",536873478,536873480]],[\"^84\",[3104,\"^3;\",1775984657472,536873478]],[\"^84\",[3104,\"^2T\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873476]],[\"^84\",[3104,\"^4<\",181,536873476]],[\"^84\",[3105,\"^48\",1775984657475,536873479]],[\"^84\",[3105,\"^7@\",\"a2\",536873479]],[\"^84\",[3105,\"^4S\",180,536873479]],[\"^84\",[3105,\"^3X\",2578,536873479]],[\"^84\",[3105,\"^6:\",\"fda\",536873481]],[\"^84\",[3105,\"^S\",536873481,536873483]],[\"^84\",[3105,\"^3;\",1775984657494,536873481]],[\"^84\",[3105,\"^2T\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873479]],[\"^84\",[3105,\"^4<\",181,536873479]],[\"^84\",[3106,\"^48\",1775984657497,536873482]],[\"^84\",[3106,\"^7@\",\"a3\",536873482]],[\"^84\",[3106,\"^4S\",180,536873482]],[\"^84\",[3106,\"^3X\",2578,536873482]],[\"^84\",[3106,\"^6:\",\"f\",536873484]],[\"^84\",[3106,\"^S\",536873484,536873486]],[\"^84\",[3106,\"^3;\",1775984657516,536873484]],[\"^84\",[3106,\"^2T\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873482]],[\"^84\",[3106,\"^4<\",181,536873482]],[\"^84\",[3107,\"^48\",1775984657519,536873485]],[\"^84\",[3107,\"^7@\",\"a1\",536873487]],[\"^84\",[3107,\"^4S\",180,536873485]],[\"^84\",[3107,\"^3X\",180,536873489]],[\"^84\",[3107,\"^6:\",\"\",536873485]],[\"^84\",[3107,\"^S\",536873489,536873490]],[\"^84\",[3107,\"^3;\",1775984657519,536873485]],[\"^84\",[3107,\"^2T\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873485]],[\"^84\",[3107,\"^4<\",181,536873485]],[\"^84\",[3108,\"^48\",1775984657567,536873491]],[\"^84\",[3108,\"^7@\",\"a2\",536873491]],[\"^84\",[3108,\"^4S\",180,536873491]],[\"^84\",[3108,\"^3X\",180,536873491]],[\"^84\",[3108,\"^6:\",\"\",536873491]],[\"^84\",[3108,\"^S\",536873491,536873493]],[\"^84\",[3108,\"^3;\",1775984657567,536873491]],[\"^84\",[3108,\"^2T\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873491]],[\"^84\",[3108,\"^4<\",181,536873492]],[\"^84\",[3109,\"^48\",1775984657583,536873494]],[\"^84\",[3109,\"^7@\",\"a3\",536873494]],[\"^84\",[3109,\"^4S\",180,536873494]],[\"^84\",[3109,\"^3X\",180,536873494]],[\"^84\",[3109,\"^6:\",\"fdasf\",536873497]],[\"^84\",[3109,\"^S\",536873497,536873499]],[\"^84\",[3109,\"^3;\",1775984657602,536873497]],[\"^84\",[3109,\"^2T\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873494]],[\"^84\",[3109,\"^4<\",181,536873495]],[\"^84\",[3110,\"^48\",1775984657605,536873498]],[\"^84\",[3110,\"^7@\",\"a4\",536873498]],[\"^84\",[3110,\"^4S\",180,536873498]],[\"^84\",[3110,\"^3X\",180,536873498]],[\"^84\",[3110,\"^6:\",\"a\",536873500]],[\"^84\",[3110,\"^S\",536873500,536873502]],[\"^84\",[3110,\"^3;\",1775984657625,536873500]],[\"^84\",[3110,\"^2T\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873498]],[\"^84\",[3110,\"^4<\",181,536873498]],[\"^84\",[3111,\"^48\",1775984657627,536873501]],[\"^84\",[3111,\"^7@\",\"a0\",536873504]],[\"^84\",[3111,\"^4S\",180,536873501]],[\"^84\",[3111,\"^3X\",3110,536873504]],[\"^84\",[3111,\"^6:\",\"ffd\",536873506]],[\"^84\",[3111,\"^S\",536873506,536873508]],[\"^84\",[3111,\"^3;\",1775984657668,536873506]],[\"^84\",[3111,\"^2T\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873501]],[\"^84\",[3111,\"^4<\",181,536873501]],[\"^84\",[3112,\"^48\",1775984657671,536873507]],[\"^84\",[3112,\"^7@\",\"a1\",536873507]],[\"^84\",[3112,\"^4S\",180,536873507]],[\"^84\",[3112,\"^3X\",3110,536873507]],[\"^84\",[3112,\"^6:\",\"asf\",536873509]],[\"^84\",[3112,\"^S\",536873509,536873511]],[\"^84\",[3112,\"^3;\",1775984657690,536873509]],[\"^84\",[3112,\"^2T\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873507]],[\"^84\",[3112,\"^4<\",181,536873507]],[\"^84\",[3113,\"^48\",1775984657693,536873510]],[\"^84\",[3113,\"^7@\",\"a2\",536873514]],[\"^84\",[3113,\"^4S\",180,536873510]],[\"^84\",[3113,\"^3X\",3110,536873514]],[\"^84\",[3113,\"^6:\",\"f\",536873516]],[\"^84\",[3113,\"^S\",536873516,536873518]],[\"^84\",[3113,\"^3;\",1775984657739,536873516]],[\"^84\",[3113,\"^2T\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873510]],[\"^84\",[3113,\"^4<\",181,536873510]],[\"^84\",[3114,\"^48\",1775984657742,536873517]],[\"^84\",[3114,\"^7@\",\"a0\",536873519]],[\"^84\",[3114,\"^4S\",180,536873517]],[\"^84\",[3114,\"^3X\",3112,536873519]],[\"^84\",[3114,\"^6:\",\"\",536873517]],[\"^84\",[3114,\"^S\",536873519,536873520]],[\"^84\",[3114,\"^3;\",1775984657742,536873517]],[\"^84\",[3114,\"^2T\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873517]],[\"^84\",[3114,\"^4<\",181,536873517]],[\"^84\",[3115,\"^48\",1775984657775,536873521]],[\"^84\",[3115,\"^7@\",\"a1\",536873521]],[\"^84\",[3115,\"^4S\",180,536873521]],[\"^84\",[3115,\"^3X\",3112,536873521]],[\"^84\",[3115,\"^6:\",\"\",536873521]],[\"^84\",[3115,\"^S\",536873521,536873523]],[\"^84\",[3115,\"^3;\",1775984657775,536873521]],[\"^84\",[3115,\"^2T\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873521]],[\"^84\",[3115,\"^4<\",181,536873522]],[\"^84\",[3116,\"^48\",1775984657790,536873524]],[\"^84\",[3116,\"^7@\",\"a0\",536873530]],[\"^84\",[3116,\"^4S\",180,536873524]],[\"^84\",[3116,\"^3X\",3114,536873530]],[\"^84\",[3116,\"^6:\",\"\",536873524]],[\"^84\",[3116,\"^S\",536873530,536873531]],[\"^84\",[3116,\"^3;\",1775984657790,536873524]],[\"^84\",[3116,\"^2T\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873524]],[\"^84\",[3116,\"^4<\",181,536873525]],[\"^84\",[3117,\"^48\",1775984657810,536873527]],[\"^84\",[3117,\"^7@\",\"a0l\",536873527]],[\"^84\",[3117,\"^4S\",180,536873527]],[\"^84\",[3117,\"^3X\",3112,536873527]],[\"^84\",[3117,\"^6:\",\"f\",536873532]],[\"^84\",[3117,\"^S\",536873532,536873534]],[\"^84\",[3117,\"^3;\",1775984657857,536873532]],[\"^84\",[3117,\"^2T\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873527]],[\"^84\",[3117,\"^4<\",181,536873528]],[\"^84\",[3118,\"^48\",1775984657861,536873533]],[\"^84\",[3118,\"^7@\",\"a0t\",536873533]],[\"^84\",[3118,\"^4S\",180,536873533]],[\"^84\",[3118,\"^3X\",3112,536873533]],[\"^84\",[3118,\"^6:\",\"\",536873533]],[\"^84\",[3118,\"^S\",536873532,536873534]],[\"^84\",[3118,\"^3;\",1775984657861,536873533]],[\"^84\",[3118,\"^2T\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873533]],[\"^84\",[3118,\"^4<\",181,536873533]]]]]]", :tx-data "[[\"~#datascript/Datom\",[3046,\"~:block/tx-id\",536873442,536873445,false]],[\"^0\",[3046,\"~:logseq.property/created-by-ref\",181,536873445,false]],[\"^0\",[3046,\"~:block/page\",180,536873445,false]],[\"^0\",[3046,\"~:block/order\",\"a0t\",536873445,false]],[\"^0\",[3046,\"~:block/parent\",3040,536873445,false]],[\"^0\",[3046,\"~:block/title\",\"\",536873445,false]],[\"^0\",[3046,\"~:block/created-at\",1775984647523,536873445,false]],[\"^0\",[3046,\"~:block/updated-at\",1775984647523,536873445,false]],[\"^0\",[3045,\"^1\",536873442,536873445,false]],[\"^0\",[3045,\"^1\",536873437,536873445]],[\"^0\",[180,\"^1\",536873442,536873445,false]],[\"^0\",[180,\"^1\",536873440,536873445]],[\"^0\",[3045,\"^6\",\"f\",536873445,false]],[\"^0\",[3045,\"^6\",\"\",536873445]],[\"^0\",[3045,\"^8\",1775984647522,536873445,false]],[\"^0\",[3045,\"^8\",1775984647315,536873445]],[\"^0\",[180,\"^8\",1775984647522,536873445,false]],[\"^0\",[180,\"^8\",1775984646786,536873445]],[\"^0\",[3046,\"~:block/uuid\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873445,false]],[\"^0\",[3044,\"^1\",536873440,536873446,false]],[\"^0\",[3044,\"^1\",536873434,536873446]],[\"^0\",[180,\"^1\",536873440,536873446,false]],[\"^0\",[180,\"^1\",536873437,536873446]],[\"^0\",[3044,\"^4\",\"a0\",536873446,false]],[\"^0\",[3044,\"^4\",\"a0V\",536873446]],[\"^0\",[3044,\"^5\",3042,536873446,false]],[\"^0\",[3044,\"^5\",3040,536873446]],[\"^0\",[3045,\"^1\",536873437,536873447,false]],[\"^0\",[3045,\"^2\",181,536873447,false]],[\"^0\",[3045,\"^3\",180,536873447,false]],[\"^0\",[3045,\"^4\",\"a0l\",536873447,false]],[\"^0\",[3045,\"^5\",3040,536873447,false]],[\"^0\",[3045,\"^6\",\"\",536873447,false]],[\"^0\",[3045,\"^7\",1775984647315,536873447,false]],[\"^0\",[3045,\"^8\",1775984647315,536873447,false]],[\"^0\",[180,\"^1\",536873437,536873447,false]],[\"^0\",[180,\"^1\",536873434,536873447]],[\"^0\",[3045,\"^9\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873447,false]],[\"^0\",[3044,\"^1\",536873434,536873448,false]],[\"^0\",[3044,\"^2\",181,536873448,false]],[\"^0\",[3044,\"^3\",180,536873448,false]],[\"^0\",[3044,\"^4\",\"a0V\",536873448,false]],[\"^0\",[3044,\"^5\",3040,536873448,false]],[\"^0\",[3044,\"^6\",\"\",536873448,false]],[\"^0\",[3044,\"^7\",1775984647121,536873448,false]],[\"^0\",[3044,\"^8\",1775984647121,536873448,false]],[\"^0\",[180,\"^1\",536873434,536873448,false]],[\"^0\",[180,\"^1\",536873431,536873448]],[\"^0\",[3044,\"^9\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873448,false]],[\"^0\",[3043,\"^1\",536873431,536873449,false]],[\"^0\",[3043,\"^2\",181,536873449,false]],[\"^0\",[3043,\"^3\",180,536873449,false]],[\"^0\",[3043,\"^4\",\"a1\",536873449,false]],[\"^0\",[3043,\"^5\",3040,536873449,false]],[\"^0\",[3043,\"^6\",\"\",536873449,false]],[\"^0\",[3043,\"^7\",1775984646989,536873449,false]],[\"^0\",[3043,\"^8\",1775984646989,536873449,false]],[\"^0\",[180,\"^1\",536873431,536873449,false]],[\"^0\",[180,\"^1\",536873429,536873449]],[\"^0\",[3043,\"^9\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873449,false]],[\"^0\",[3042,\"^1\",536873429,536873450,false]],[\"^0\",[3042,\"^1\",536873426,536873450]],[\"^0\",[180,\"^1\",536873429,536873450,false]],[\"^0\",[180,\"^1\",536873426,536873450]],[\"^0\",[3042,\"^4\",\"a0\",536873450,false]],[\"^0\",[3042,\"^4\",\"a1V\",536873450]],[\"^0\",[3042,\"^5\",3040,536873450,false]],[\"^0\",[3042,\"^5\",3038,536873450]],[\"^0\",[3042,\"^1\",536873426,536873451,false]],[\"^0\",[3042,\"^2\",181,536873451,false]],[\"^0\",[3042,\"^3\",180,536873451,false]],[\"^0\",[3042,\"^4\",\"a1V\",536873451,false]],[\"^0\",[3042,\"^5\",3038,536873451,false]],[\"^0\",[3042,\"^6\",\"\",536873451,false]],[\"^0\",[3042,\"^7\",1775984646787,536873451,false]],[\"^0\",[3042,\"^8\",1775984646787,536873451,false]],[\"^0\",[3041,\"^1\",536873426,536873451,false]],[\"^0\",[3041,\"^1\",536873424,536873451]],[\"^0\",[180,\"^1\",536873426,536873451,false]],[\"^0\",[180,\"^1\",536873424,536873451]],[\"^0\",[3041,\"^6\",\"f\",536873451,false]],[\"^0\",[3041,\"^6\",\"\",536873451]],[\"^0\",[3041,\"^8\",1775984646786,536873451,false]],[\"^0\",[3041,\"^8\",1775984646416,536873451]],[\"^0\",[180,\"^8\",1775984646786,536873451,false]],[\"^0\",[180,\"^8\",1775984646415,536873451]],[\"^0\",[3042,\"^9\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873451,false]],[\"^0\",[3041,\"^1\",536873424,536873452,false]],[\"^0\",[3041,\"^1\",536873422,536873452]],[\"^0\",[180,\"^1\",536873424,536873452,false]],[\"^0\",[180,\"^1\",536873422,536873452]],[\"^0\",[3041,\"^4\",\"a2\",536873452,false]],[\"^0\",[3041,\"^4\",\"a0\",536873452]],[\"^0\",[3041,\"^5\",3038,536873452,false]],[\"^0\",[3041,\"^5\",3040,536873452]],[\"^0\",[3041,\"^1\",536873422,536873453,false]],[\"^0\",[3041,\"^1\",536873419,536873453]],[\"^0\",[180,\"^1\",536873422,536873453,false]],[\"^0\",[180,\"^1\",536873419,536873453]],[\"^0\",[3041,\"^4\",\"a0\",536873453,false]],[\"^0\",[3041,\"^4\",\"a2\",536873453]],[\"^0\",[3041,\"^5\",3040,536873453,false]],[\"^0\",[3041,\"^5\",3038,536873453]],[\"^0\",[3041,\"^1\",536873419,536873454,false]],[\"^0\",[3041,\"^2\",181,536873454,false]],[\"^0\",[3041,\"^3\",180,536873454,false]],[\"^0\",[3041,\"^4\",\"a2\",536873454,false]],[\"^0\",[3041,\"^5\",3038,536873454,false]],[\"^0\",[3041,\"^6\",\"\",536873454,false]],[\"^0\",[3041,\"^7\",1775984646416,536873454,false]],[\"^0\",[3041,\"^8\",1775984646416,536873454,false]],[\"^0\",[3040,\"^1\",536873419,536873454,false]],[\"^0\",[3040,\"^1\",536873416,536873454]],[\"^0\",[180,\"^1\",536873419,536873454,false]],[\"^0\",[180,\"^1\",536873416,536873454]],[\"^0\",[3040,\"^6\",\"asf\",536873454,false]],[\"^0\",[3040,\"^6\",\"\",536873454]],[\"^0\",[3040,\"^8\",1775984646415,536873454,false]],[\"^0\",[3040,\"^8\",1775984646216,536873454]],[\"^0\",[180,\"^8\",1775984646415,536873454,false]],[\"^0\",[180,\"^8\",1775984646215,536873454]],[\"^0\",[3041,\"^9\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873454,false]],[\"^0\",[3040,\"^1\",536873416,536873455,false]],[\"^0\",[3040,\"^2\",181,536873455,false]],[\"^0\",[3040,\"^3\",180,536873455,false]],[\"^0\",[3040,\"^4\",\"a1\",536873455,false]],[\"^0\",[3040,\"^5\",3038,536873455,false]],[\"^0\",[3040,\"^6\",\"\",536873455,false]],[\"^0\",[3040,\"^7\",1775984646216,536873455,false]],[\"^0\",[3040,\"^8\",1775984646216,536873455,false]],[\"^0\",[3039,\"^1\",536873416,536873455,false]],[\"^0\",[3039,\"^1\",536873413,536873455]],[\"^0\",[180,\"^1\",536873416,536873455,false]],[\"^0\",[180,\"^1\",536873413,536873455]],[\"^0\",[3039,\"^6\",\"ffd\",536873455,false]],[\"^0\",[3039,\"^6\",\"f\",536873455]],[\"^0\",[3039,\"^8\",1775984646215,536873455,false]],[\"^0\",[3039,\"^8\",1775984645864,536873455]],[\"^0\",[180,\"^8\",1775984646215,536873455,false]],[\"^0\",[180,\"^8\",1775984645864,536873455]],[\"^0\",[3040,\"^9\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873455,false]],[\"^0\",[3039,\"^1\",536873413,536873456,false]],[\"^0\",[3039,\"^1\",536873410,536873456]],[\"^0\",[180,\"^1\",536873413,536873456,false]],[\"^0\",[180,\"^1\",536873410,536873456]],[\"^0\",[3039,\"^4\",\"a0\",536873456,false]],[\"^0\",[3039,\"^4\",\"a5\",536873456]],[\"^0\",[3039,\"^5\",3038,536873456,false]],[\"^0\",[3039,\"^5\",180,536873456]],[\"^0\",[3039,\"^6\",\"f\",536873456,false]],[\"^0\",[3039,\"^6\",\"\",536873456]],[\"^0\",[3039,\"^8\",1775984645864,536873456,false]],[\"^0\",[3039,\"^8\",1775984645692,536873456]],[\"^0\",[180,\"^8\",1775984645864,536873456,false]],[\"^0\",[180,\"^8\",1775984645691,536873456]],[\"^0\",[3039,\"^1\",536873410,536873457,false]],[\"^0\",[3039,\"^2\",181,536873457,false]],[\"^0\",[3039,\"^3\",180,536873457,false]],[\"^0\",[3039,\"^4\",\"a5\",536873457,false]],[\"^0\",[3039,\"^5\",180,536873457,false]],[\"^0\",[3039,\"^6\",\"\",536873457,false]],[\"^0\",[3039,\"^7\",1775984645692,536873457,false]],[\"^0\",[3039,\"^8\",1775984645692,536873457,false]],[\"^0\",[3038,\"^1\",536873410,536873457,false]],[\"^0\",[3038,\"^1\",536873407,536873457]],[\"^0\",[180,\"^1\",536873410,536873457,false]],[\"^0\",[180,\"^1\",536873407,536873457]],[\"^0\",[3038,\"^6\",\"a\",536873457,false]],[\"^0\",[3038,\"^6\",\"\",536873457]],[\"^0\",[3038,\"^8\",1775984645690,536873457,false]],[\"^0\",[3038,\"^8\",1775984645526,536873457]],[\"^0\",[180,\"^8\",1775984645691,536873457,false]],[\"^0\",[180,\"^8\",1775984645525,536873457]],[\"^0\",[3039,\"^9\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873457,false]],[\"^0\",[3038,\"^1\",536873407,536873458,false]],[\"^0\",[3038,\"^2\",181,536873458,false]],[\"^0\",[3038,\"^3\",180,536873458,false]],[\"^0\",[3038,\"^4\",\"a4\",536873458,false]],[\"^0\",[3038,\"^5\",180,536873458,false]],[\"^0\",[3038,\"^6\",\"\",536873458,false]],[\"^0\",[3038,\"^7\",1775984645526,536873458,false]],[\"^0\",[3038,\"^8\",1775984645526,536873458,false]],[\"^0\",[3037,\"^1\",536873407,536873458,false]],[\"^0\",[3037,\"^1\",536873404,536873458]],[\"^0\",[180,\"^1\",536873407,536873458,false]],[\"^0\",[180,\"^1\",536873404,536873458]],[\"^0\",[3037,\"^6\",\"fdasf\",536873458,false]],[\"^0\",[3037,\"^6\",\"\",536873458]],[\"^0\",[3037,\"^8\",1775984645524,536873458,false]],[\"^0\",[3037,\"^8\",1775984645181,536873458]],[\"^0\",[180,\"^8\",1775984645525,536873458,false]],[\"^0\",[180,\"^8\",1775984644505,536873458]],[\"^0\",[3038,\"^9\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873458,false]],[\"^0\",[3037,\"^1\",536873404,536873459,false]],[\"^0\",[3037,\"^2\",181,536873459,false]],[\"^0\",[3037,\"^3\",180,536873459,false]],[\"^0\",[3037,\"^4\",\"a3\",536873459,false]],[\"^0\",[3037,\"^5\",180,536873459,false]],[\"^0\",[3037,\"^6\",\"\",536873459,false]],[\"^0\",[3037,\"^7\",1775984645181,536873459,false]],[\"^0\",[3037,\"^8\",1775984645181,536873459,false]],[\"^0\",[180,\"^1\",536873404,536873459,false]],[\"^0\",[180,\"^1\",536873401,536873459]],[\"^0\",[3037,\"^9\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873459,false]],[\"^0\",[3036,\"^1\",536873401,536873460,false]],[\"^0\",[3036,\"^2\",181,536873460,false]],[\"^0\",[3036,\"^3\",180,536873460,false]],[\"^0\",[3036,\"^4\",\"a2\",536873460,false]],[\"^0\",[3036,\"^5\",180,536873460,false]],[\"^0\",[3036,\"^6\",\"\",536873460,false]],[\"^0\",[3036,\"^7\",1775984645026,536873460,false]],[\"^0\",[3036,\"^8\",1775984645026,536873460,false]],[\"^0\",[180,\"^1\",536873401,536873460,false]],[\"^0\",[180,\"^1\",536873399,536873460]],[\"^0\",[3036,\"^9\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873460,false]],[\"^0\",[3035,\"^1\",536873399,536873461,false]],[\"^0\",[3035,\"^1\",536873397,536873461]],[\"^0\",[180,\"^1\",536873399,536873461,false]],[\"^0\",[180,\"^1\",536873397,536873461]],[\"^0\",[3035,\"^5\",180,536873461,false]],[\"^0\",[3035,\"^5\",2577,536873461]],[\"^0\",[3035,\"^1\",536873397,536873462,false]],[\"^0\",[3035,\"^1\",536873394,536873462]],[\"^0\",[180,\"^1\",536873397,536873462,false]],[\"^0\",[180,\"^1\",536873394,536873462]],[\"^0\",[3035,\"^4\",\"a1\",536873462,false]],[\"^0\",[3035,\"^4\",\"a4\",536873462]],[\"^0\",[3035,\"^5\",2577,536873462,false]],[\"^0\",[3035,\"^5\",2578,536873462]],[\"^0\",[3035,\"^1\",536873394,536873463,false]],[\"^0\",[3035,\"^2\",181,536873463,false]],[\"^0\",[3035,\"^3\",180,536873463,false]],[\"^0\",[3035,\"^4\",\"a4\",536873463,false]],[\"^0\",[3035,\"^5\",2578,536873463,false]],[\"^0\",[3035,\"^6\",\"\",536873463,false]],[\"^0\",[3035,\"^7\",1775984644506,536873463,false]],[\"^0\",[3035,\"^8\",1775984644506,536873463,false]],[\"^0\",[3034,\"^1\",536873394,536873463,false]],[\"^0\",[3034,\"^1\",536873391,536873463]],[\"^0\",[180,\"^1\",536873394,536873463,false]],[\"^0\",[180,\"^1\",536873391,536873463]],[\"^0\",[3034,\"^6\",\"f\",536873463,false]],[\"^0\",[3034,\"^6\",\"\",536873463]],[\"^0\",[3034,\"^8\",1775984644505,536873463,false]],[\"^0\",[3034,\"^8\",1775984644336,536873463]],[\"^0\",[180,\"^8\",1775984644505,536873463,false]],[\"^0\",[180,\"^8\",1775984644335,536873463]],[\"^0\",[3035,\"^9\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873463,false]],[\"^0\",[3034,\"^1\",536873391,536873464,false]],[\"^0\",[3034,\"^2\",181,536873464,false]],[\"^0\",[3034,\"^3\",180,536873464,false]],[\"^0\",[3034,\"^4\",\"a3\",536873464,false]],[\"^0\",[3034,\"^5\",2578,536873464,false]],[\"^0\",[3034,\"^6\",\"\",536873464,false]],[\"^0\",[3034,\"^7\",1775984644336,536873464,false]],[\"^0\",[3034,\"^8\",1775984644336,536873464,false]],[\"^0\",[3033,\"^1\",536873391,536873464,false]],[\"^0\",[3033,\"^1\",536873388,536873464]],[\"^0\",[180,\"^1\",536873391,536873464,false]],[\"^0\",[180,\"^1\",536873388,536873464]],[\"^0\",[3033,\"^6\",\"fda\",536873464,false]],[\"^0\",[3033,\"^6\",\"\",536873464]],[\"^0\",[3033,\"^8\",1775984644335,536873464,false]],[\"^0\",[3033,\"^8\",1775984644156,536873464]],[\"^0\",[180,\"^8\",1775984644335,536873464,false]],[\"^0\",[180,\"^8\",1775984644155,536873464]],[\"^0\",[3034,\"^9\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873464,false]],[\"^0\",[3033,\"^1\",536873388,536873465,false]],[\"^0\",[3033,\"^2\",181,536873465,false]],[\"^0\",[3033,\"^3\",180,536873465,false]],[\"^0\",[3033,\"^4\",\"a2\",536873465,false]],[\"^0\",[3033,\"^5\",2578,536873465,false]],[\"^0\",[3033,\"^6\",\"\",536873465,false]],[\"^0\",[3033,\"^7\",1775984644156,536873465,false]],[\"^0\",[3033,\"^8\",1775984644156,536873465,false]],[\"^0\",[3032,\"^1\",536873388,536873465,false]],[\"^0\",[3032,\"^1\",536873385,536873465]],[\"^0\",[180,\"^1\",536873388,536873465,false]],[\"^0\",[180,\"^1\",536873385,536873465]],[\"^0\",[3032,\"^6\",\"da\",536873465,false]],[\"^0\",[3032,\"^6\",\"\",536873465]],[\"^0\",[3032,\"^8\",1775984644155,536873465,false]],[\"^0\",[3032,\"^8\",1775984643975,536873465]],[\"^0\",[180,\"^8\",1775984644155,536873465,false]],[\"^0\",[180,\"^8\",1775984643974,536873465]],[\"^0\",[3033,\"^9\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873465,false]],[\"^0\",[3032,\"^1\",536873385,536873466,false]],[\"^0\",[3032,\"^2\",181,536873466,false]],[\"^0\",[3032,\"^3\",180,536873466,false]],[\"^0\",[3032,\"^4\",\"a1\",536873466,false]],[\"^0\",[3032,\"^5\",2578,536873466,false]],[\"^0\",[3032,\"^6\",\"\",536873466,false]],[\"^0\",[3032,\"^7\",1775984643975,536873466,false]],[\"^0\",[3032,\"^8\",1775984643975,536873466,false]],[\"^0\",[2988,\"^1\",536873385,536873466,false]],[\"^0\",[2988,\"^1\",536873311,536873466]],[\"^0\",[180,\"^1\",536873385,536873466,false]],[\"^0\",[180,\"^1\",536873383,536873466]],[\"^0\",[2988,\"^6\",\"3f\",536873466,false]],[\"^0\",[2988,\"^6\",\"3\",536873466]],[\"^0\",[2988,\"^8\",1775984643973,536873466,false]],[\"^0\",[2988,\"^8\",1775984622083,536873466]],[\"^0\",[180,\"^8\",1775984643974,536873466,false]],[\"^0\",[180,\"^8\",1775984622122,536873466]],[\"^0\",[3032,\"^9\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873466,false]],[\"^0\",[180,\"^1\",536873383,536873467,false]],[\"^0\",[180,\"^1\",536873381,536873467]],[\"^0\",[3047,\"^2\",181,536873467]],[\"^0\",[3047,\"^9\",\"~u69db5f8b-d66e-43df-ab5f-7d57a066f4e0\",536873467]],[\"^0\",[3047,\"^8\",1775984523541,536873467]],[\"^0\",[3047,\"^6\",\"f\",536873467]],[\"^0\",[3047,\"^3\",180,536873467]],[\"^0\",[3047,\"^4\",\"a4\",536873467]],[\"^0\",[3047,\"^7\",1775984523179,536873467]],[\"^0\",[3048,\"^2\",181,536873467]],[\"^0\",[3048,\"^9\",\"~u69db5f8a-c743-4d46-8a33-844916e45c4b\",536873467]],[\"^0\",[3048,\"^8\",1775984523266,536873467]],[\"^0\",[3048,\"^6\",\"f\",536873467]],[\"^0\",[3048,\"^3\",180,536873467]],[\"^0\",[3048,\"^4\",\"a3\",536873467]],[\"^0\",[3048,\"^7\",1775984523002,536873467]],[\"^0\",[3049,\"^2\",181,536873467]],[\"^0\",[3049,\"^9\",\"~u69db5f8a-9e79-49ce-9a5c-dad08cd22189\",536873467]],[\"^0\",[3049,\"^8\",1775984522893,536873467]],[\"^0\",[3049,\"^6\",\"\",536873467]],[\"^0\",[3049,\"^3\",180,536873467]],[\"^0\",[3049,\"^4\",\"a1\",536873467]],[\"^0\",[3049,\"^7\",1775984522893,536873467]],[\"^0\",[3050,\"^2\",181,536873467]],[\"^0\",[3050,\"^9\",\"~u69db5f8a-3da9-40e5-a22f-c8f9b13a78af\",536873467]],[\"^0\",[3050,\"^8\",1775984522893,536873467]],[\"^0\",[3050,\"^6\",\"f\",536873467]],[\"^0\",[3050,\"^3\",180,536873467]],[\"^0\",[3050,\"^4\",\"a2\",536873467]],[\"^0\",[3050,\"^7\",1775984522698,536873467]],[\"^0\",[3051,\"^2\",181,536873467]],[\"^0\",[3051,\"^9\",\"~u69db5f8b-0b14-4a38-a6a9-e7447e0dcac9\",536873467]],[\"^0\",[3051,\"^8\",1775984523459,536873467]],[\"^0\",[3051,\"^6\",\"\",536873467]],[\"^0\",[3051,\"^3\",180,536873467]],[\"^0\",[3051,\"^4\",\"a4\",536873467]],[\"^0\",[3051,\"^7\",1775984523459,536873467]],[\"^0\",[3047,\"^5\",3052,536873467]],[\"^0\",[3048,\"^5\",3052,536873467]],[\"^0\",[3049,\"^5\",3052,536873467]],[\"^0\",[3050,\"^5\",3052,536873467]],[\"^0\",[3052,\"^2\",181,536873467]],[\"^0\",[3052,\"^9\",\"~u69db5f8a-bf4b-4013-806f-f2a6e1c0cc73\",536873467]],[\"^0\",[3052,\"^8\",1775984522620,536873467]],[\"^0\",[3052,\"^6\",\"e\",536873467]],[\"^0\",[3052,\"^3\",180,536873467]],[\"^0\",[3052,\"^4\",\"a0\",536873467]],[\"^0\",[3052,\"^7\",1775984522530,536873467]],[\"^0\",[3051,\"^5\",3053,536873467]],[\"^0\",[3052,\"^5\",3053,536873467]],[\"^0\",[3053,\"^2\",181,536873467]],[\"^0\",[3053,\"^9\",\"~u69db5f8a-dea4-4434-a3ae-5b13b90882ce\",536873467]],[\"^0\",[3053,\"^8\",1775984522529,536873467]],[\"^0\",[3053,\"^6\",\"f\",536873467]],[\"^0\",[3053,\"^5\",180,536873467]],[\"^0\",[3053,\"^3\",180,536873467]],[\"^0\",[3053,\"^4\",\"a6\",536873467]],[\"^0\",[3053,\"^7\",1775984522301,536873467]],[\"^0\",[3054,\"^2\",181,536873467]],[\"^0\",[3054,\"^9\",\"~u69db5f8a-20b6-4e5e-b82d-9351b6709e3c\",536873467]],[\"^0\",[3054,\"^8\",1775984522366,536873467]],[\"^0\",[3054,\"^6\",\"a\",536873467]],[\"^0\",[3054,\"^3\",180,536873467]],[\"^0\",[3054,\"^4\",\"a0\",536873467]],[\"^0\",[3054,\"^7\",1775984522126,536873467]],[\"^0\",[3054,\"^5\",3055,536873467]],[\"^0\",[3055,\"^2\",181,536873467]],[\"^0\",[3055,\"^9\",\"~u69db5f89-2322-4e14-98a7-edc3728c12e4\",536873467]],[\"^0\",[3055,\"^8\",1775984522125,536873467]],[\"^0\",[3055,\"^6\",\"f\",536873467]],[\"^0\",[3055,\"^5\",180,536873467]],[\"^0\",[3055,\"^3\",180,536873467]],[\"^0\",[3055,\"^4\",\"a4\",536873467]],[\"^0\",[3055,\"^7\",1775984521960,536873467]],[\"^0\",[3056,\"^2\",181,536873467]],[\"^0\",[3056,\"^9\",\"~u69db5e9c-1424-4f8a-a0be-2d8c42355472\",536873467]],[\"^0\",[3056,\"^8\",1775984521958,536873467]],[\"^0\",[3056,\"^6\",\"10fa\",536873467]],[\"^0\",[3056,\"^5\",180,536873467]],[\"^0\",[3056,\"^3\",180,536873467]],[\"^0\",[3056,\"^4\",\"a3\",536873467]],[\"^0\",[3056,\"^7\",1775984284987,536873467]],[\"^0\",[3057,\"^2\",181,536873467]],[\"^0\",[3057,\"^9\",\"~u69db5e9c-e1fb-4d8a-8ce9-1e05986eea79\",536873467]],[\"^0\",[3057,\"^8\",1775984284986,536873467]],[\"^0\",[3057,\"^6\",\"9\",536873467]],[\"^0\",[3057,\"^5\",180,536873467]],[\"^0\",[3057,\"^3\",180,536873467]],[\"^0\",[3057,\"^4\",\"a2\",536873467]],[\"^0\",[3057,\"^7\",1775984284238,536873467]],[\"^0\",[3058,\"^2\",181,536873467]],[\"^0\",[3058,\"^9\",\"~u69db5e99-341b-4196-85af-9f5a75828be9\",536873467]],[\"^0\",[3058,\"^8\",1775984293031,536873467]],[\"^0\",[3058,\"^1\",536872554,536873467]],[\"^0\",[3058,\"^6\",\"8\",536873467]],[\"^0\",[3058,\"^5\",180,536873467]],[\"^0\",[3058,\"^3\",180,536873467]],[\"^0\",[3058,\"^4\",\"a1l\",536873467]],[\"^0\",[3058,\"^7\",1775984293021,536873467]],[\"^0\",[3059,\"^2\",181,536873467]],[\"^0\",[3059,\"^9\",\"~u69db5e98-e930-44d2-acb0-76bb2b2cfa85\",536873467]],[\"^0\",[3059,\"^8\",1775984293020,536873467]],[\"^0\",[3059,\"^1\",536872551,536873467]],[\"^0\",[3059,\"^6\",\"7\",536873467]],[\"^0\",[3059,\"^5\",180,536873467]],[\"^0\",[3059,\"^3\",180,536873467]],[\"^0\",[3059,\"^4\",\"a1V\",536873467]],[\"^0\",[3059,\"^7\",1775984293011,536873467]],[\"^0\",[3060,\"^2\",181,536873467]],[\"^0\",[3060,\"^9\",\"~u69db5e43-06ef-40dc-9f74-cffd52768f40\",536873467]],[\"^0\",[3060,\"^8\",1775984196746,536873467]],[\"^0\",[3060,\"^6\",\"6\",536873467]],[\"^0\",[3060,\"^5\",180,536873467]],[\"^0\",[3060,\"^3\",180,536873467]],[\"^0\",[3060,\"^4\",\"a1\",536873467]],[\"^0\",[3060,\"^7\",1775984195215,536873467]],[\"^0\",[180,\"^1\",536873381,536873468,false]],[\"^0\",[180,\"^1\",536873378,536873468]],[\"^0\",[3061,\"~:logseq.property.history/ref-value\",114,536873468]],[\"^0\",[3061,\"~:logseq.property.history/property\",26,536873468]],[\"^0\",[3061,\"^2\",181,536873468]],[\"^0\",[3061,\"^9\",\"~u69db5fda-6885-4fac-b5b0-134fa03e071d\",536873468]],[\"^0\",[3061,\"^8\",1775984602776,536873468]],[\"^0\",[3061,\"^1\",536873345,536873468]],[\"^0\",[3061,\"^7\",1775984602776,536873468]],[\"^0\",[3062,\"^:\",114,536873468]],[\"^0\",[3062,\"^;\",26,536873468]],[\"^0\",[3062,\"^2\",181,536873468]],[\"^0\",[3062,\"^9\",\"~u69db5fd1-d725-489f-9c7d-bb0fa8fd5421\",536873468]],[\"^0\",[3062,\"^8\",1775984593967,536873468]],[\"^0\",[3062,\"^1\",536873345,536873468]],[\"^0\",[3062,\"^7\",1775984593967,536873468]],[\"^0\",[3063,\"^:\",114,536873468]],[\"^0\",[3063,\"^;\",26,536873468]],[\"^0\",[3063,\"^2\",181,536873468]],[\"^0\",[3063,\"^9\",\"~u69db5fdb-d3de-4f3b-b69e-ebd4279e7d9c\",536873468]],[\"^0\",[3063,\"^8\",1775984603032,536873468]],[\"^0\",[3063,\"^1\",536873353,536873468]],[\"^0\",[3063,\"^7\",1775984603032,536873468]],[\"^0\",[3064,\"^:\",114,536873468]],[\"^0\",[3064,\"^;\",26,536873468]],[\"^0\",[3064,\"^2\",181,536873468]],[\"^0\",[3064,\"^9\",\"~u69db5fd3-254e-4d8a-9093-0e30e2ea565d\",536873468]],[\"^0\",[3064,\"^8\",1775984595023,536873468]],[\"^0\",[3064,\"^1\",536873353,536873468]],[\"^0\",[3064,\"^7\",1775984595023,536873468]],[\"^0\",[3065,\"^:\",55,536873468]],[\"^0\",[3065,\"^;\",26,536873468]],[\"^0\",[3065,\"^2\",181,536873468]],[\"^0\",[3065,\"^9\",\"~u69db5fda-fd9c-4f43-95ea-1f1f138f42fc\",536873468]],[\"^0\",[3065,\"^8\",1775984602508,536873468]],[\"^0\",[3065,\"^1\",536873336,536873468]],[\"^0\",[3065,\"^7\",1775984602508,536873468]],[\"^0\",[3066,\"^:\",55,536873468]],[\"^0\",[3066,\"^;\",26,536873468]],[\"^0\",[3066,\"^2\",181,536873468]],[\"^0\",[3066,\"^9\",\"~u69db5fd0-dd43-44a3-a49e-9c9eb3ee21de\",536873468]],[\"^0\",[3066,\"^8\",1775984592739,536873468]],[\"^0\",[3066,\"^1\",536873336,536873468]],[\"^0\",[3066,\"^7\",1775984592739,536873468]],[\"^0\",[3067,\"^:\",9,536873468]],[\"^0\",[3067,\"^;\",26,536873468]],[\"^0\",[3067,\"^2\",181,536873468]],[\"^0\",[3067,\"^9\",\"~u69db5fda-5536-432b-8527-beb3ef42e823\",536873468]],[\"^0\",[3067,\"^8\",1775984602438,536873468]],[\"^0\",[3067,\"^1\",536873333,536873468]],[\"^0\",[3067,\"^7\",1775984602438,536873468]],[\"^0\",[3068,\"^:\",9,536873468]],[\"^0\",[3068,\"^;\",26,536873468]],[\"^0\",[3068,\"^2\",181,536873468]],[\"^0\",[3068,\"^9\",\"~u69db5fd0-467e-4233-848d-d5a8a17132c1\",536873468]],[\"^0\",[3068,\"^8\",1775984592497,536873468]],[\"^0\",[3068,\"^1\",536873333,536873468]],[\"^0\",[3068,\"^7\",1775984592497,536873468]],[\"^0\",[3069,\"^:\",114,536873468]],[\"^0\",[3069,\"^;\",26,536873468]],[\"^0\",[3069,\"^2\",181,536873468]],[\"^0\",[3069,\"^9\",\"~u69db5fda-8f2b-461f-8fcf-eb2048e612db\",536873468]],[\"^0\",[3069,\"^8\",1775984602366,536873468]],[\"^0\",[3069,\"^1\",536873330,536873468]],[\"^0\",[3069,\"^7\",1775984602366,536873468]],[\"^0\",[3070,\"^:\",114,536873468]],[\"^0\",[3070,\"^;\",26,536873468]],[\"^0\",[3070,\"^2\",181,536873468]],[\"^0\",[3070,\"^9\",\"~u69db5fd0-7219-4848-add2-93885c48cdf0\",536873468]],[\"^0\",[3070,\"^8\",1775984592269,536873468]],[\"^0\",[3070,\"^1\",536873330,536873468]],[\"^0\",[3070,\"^7\",1775984592269,536873468]],[\"^0\",[3071,\"^:\",9,536873468]],[\"^0\",[3071,\"^;\",26,536873468]],[\"^0\",[3071,\"^2\",181,536873468]],[\"^0\",[3071,\"^9\",\"~u69db5fdb-7cd3-4fb8-b24a-6658a716ec9e\",536873468]],[\"^0\",[3071,\"^8\",1775984603456,536873468]],[\"^0\",[3071,\"^1\",536873367,536873468]],[\"^0\",[3071,\"^7\",1775984603456,536873468]],[\"^0\",[3072,\"^:\",9,536873468]],[\"^0\",[3072,\"^;\",26,536873468]],[\"^0\",[3072,\"^2\",181,536873468]],[\"^0\",[3072,\"^9\",\"~u69db5fd4-cb2f-4364-a9b5-3c4321628245\",536873468]],[\"^0\",[3072,\"^8\",1775984596073,536873468]],[\"^0\",[3072,\"^1\",536873367,536873468]],[\"^0\",[3072,\"^7\",1775984596073,536873468]],[\"^0\",[3073,\"^:\",114,536873468]],[\"^0\",[3073,\"^;\",26,536873468]],[\"^0\",[3073,\"^2\",181,536873468]],[\"^0\",[3073,\"^9\",\"~u69db5fdb-0542-4ec3-a6fd-4e9b3b805963\",536873468]],[\"^0\",[3073,\"^8\",1775984603383,536873468]],[\"^0\",[3073,\"^1\",536873364,536873468]],[\"^0\",[3073,\"^7\",1775984603383,536873468]],[\"^0\",[3074,\"^:\",114,536873468]],[\"^0\",[3074,\"^;\",26,536873468]],[\"^0\",[3074,\"^2\",181,536873468]],[\"^0\",[3074,\"^9\",\"~u69db5fd3-6534-42d8-a5c2-4fe02aa6e94c\",536873468]],[\"^0\",[3074,\"^8\",1775984595867,536873468]],[\"^0\",[3074,\"^1\",536873364,536873468]],[\"^0\",[3074,\"^7\",1775984595867,536873468]],[\"^0\",[3075,\"^:\",114,536873468]],[\"^0\",[3075,\"^;\",26,536873468]],[\"^0\",[3075,\"^2\",181,536873468]],[\"^0\",[3075,\"^9\",\"~u69db5fdb-aa3e-4767-825c-00af0198e1e7\",536873468]],[\"^0\",[3075,\"^8\",1775984603802,536873468]],[\"^0\",[3075,\"^1\",536873378,536873468]],[\"^0\",[3075,\"^7\",1775984603802,536873468]],[\"^0\",[3076,\"^:\",114,536873468]],[\"^0\",[3076,\"^;\",26,536873468]],[\"^0\",[3076,\"^2\",181,536873468]],[\"^0\",[3076,\"^9\",\"~u69db5fd5-10c1-4bec-b4ad-10be544167c4\",536873468]],[\"^0\",[3076,\"^8\",1775984597060,536873468]],[\"^0\",[3076,\"^1\",536873378,536873468]],[\"^0\",[3076,\"^7\",1775984597060,536873468]],[\"^0\",[3077,\"^2\",181,536873468]],[\"^0\",[3077,\"^9\",\"~u69db5fd3-0f51-4773-8902-d00d889dbcde\",536873468]],[\"^0\",[3077,\"^8\",1775984622278,536873468]],[\"^0\",[3077,\"^1\",536873361,536873468]],[\"^0\",[3077,\"^6\",\"4\",536873468]],[\"^0\",[3077,\"^3\",180,536873468]],[\"^0\",[3077,\"^4\",\"aK\",536873468]],[\"^0\",[3077,\"^7\",1775984622278,536873468]],[\"^0\",[3078,\"^2\",181,536873468]],[\"^0\",[3078,\"^9\",\"~u69db5fd4-2a7a-4d88-be50-4a536a4f29dd\",536873468]],[\"^0\",[3078,\"^8\",1775984622334,536873468]],[\"^0\",[3078,\"^1\",536873375,536873468]],[\"^0\",[3078,\"^6\",\"3\",536873468]],[\"^0\",[3078,\"^3\",180,536873468]],[\"^0\",[3078,\"^4\",\"aO\",536873468]],[\"^0\",[3078,\"^7\",1775984622334,536873468]],[\"^0\",[3079,\"^2\",181,536873468]],[\"^0\",[3079,\"^9\",\"~u69db5fd0-426f-4608-9177-97a62a20a0a6\",536873468]],[\"^0\",[3079,\"^8\",1775984622136,536873468]],[\"^0\",[3079,\"^1\",536873327,536873468]],[\"^0\",[3079,\"^6\",\"4\",536873468]],[\"^0\",[3079,\"^3\",180,536873468]],[\"^0\",[3079,\"^4\",\"a5\",536873468]],[\"^0\",[3079,\"^7\",1775984622136,536873468]],[\"^0\",[3080,\"^2\",181,536873468]],[\"^0\",[3080,\"^9\",\"~u69db5fd4-4b47-4531-8f7c-dd8aa76170e7\",536873468]],[\"^0\",[3080,\"^8\",1775984622334,536873468]],[\"^0\",[3080,\"^1\",536873375,536873468]],[\"^0\",[3080,\"^6\",\"4\",536873468]],[\"^0\",[3080,\"^3\",180,536873468]],[\"^0\",[3080,\"^4\",\"aP\",536873468]],[\"^0\",[3080,\"^7\",1775984622334,536873468]],[\"^0\",[3081,\"^2\",181,536873468]],[\"^0\",[3081,\"^9\",\"~u69db5fd1-440e-44a7-8a00-607bbf2af4a5\",536873468]],[\"^0\",[3081,\"^8\",1775984622189,536873468]],[\"^0\",[3081,\"^1\",536873339,536873468]],[\"^0\",[3081,\"^6\",\"4\",536873468]],[\"^0\",[3081,\"^3\",180,536873468]],[\"^0\",[3081,\"^4\",\"aA\",536873468]],[\"^0\",[3081,\"^7\",1775984622189,536873468]],[\"^0\",[3082,\"^2\",181,536873468]],[\"^0\",[3082,\"^9\",\"~u69db5fd3-5271-4dc5-8af7-8c69d0477130\",536873468]],[\"^0\",[3082,\"^8\",1775984622278,536873468]],[\"^0\",[3082,\"^1\",536873361,536873468]],[\"^0\",[3082,\"^6\",\"3\",536873468]],[\"^0\",[3082,\"^3\",180,536873468]],[\"^0\",[3082,\"^4\",\"aJ\",536873468]],[\"^0\",[3082,\"^7\",1775984622278,536873468]],[\"^0\",[3082,\"^5\",3083,536873468]],[\"^0\",[3083,\"^2\",181,536873468]],[\"^0\",[3083,\"^9\",\"~u69db5fd3-edce-49f7-b2b0-449ee103901a\",536873468]],[\"^0\",[3083,\"^8\",1775984622278,536873468]],[\"^0\",[3083,\"^1\",536873361,536873468]],[\"^0\",[3083,\"^6\",\"2\",536873468]],[\"^0\",[3083,\"^3\",180,536873468]],[\"^0\",[3083,\"^4\",\"aI\",536873468]],[\"^0\",[3083,\"^7\",1775984622278,536873468]],[\"^0\",[3084,\"^2\",181,536873468]],[\"^0\",[3084,\"^9\",\"~u69db5fc0-a560-43fb-a22b-f2d6961ffb31\",536873468]],[\"^0\",[3084,\"^8\",1775984622121,536873468]],[\"^0\",[3084,\"^1\",536873321,536873468]],[\"^0\",[3084,\"^6\",\"5\",536873468]],[\"^0\",[3084,\"^3\",180,536873468]],[\"^0\",[3084,\"^4\",\"a0\",536873468]],[\"^0\",[3084,\"^7\",1775984622101,536873468]],[\"^0\",[3080,\"^5\",3085,536873468]],[\"^0\",[3085,\"^2\",181,536873468]],[\"^0\",[3085,\"^9\",\"~u69db5fd4-6a39-4cf4-b88f-a038a98f1d81\",536873468]],[\"^0\",[3085,\"^8\",1775984622334,536873468]],[\"^0\",[3085,\"^1\",536873375,536873468]],[\"^0\",[3085,\"^6\",\"1\",536873468]],[\"^0\",[3085,\"^3\",180,536873468]],[\"^0\",[3085,\"^4\",\"aL\",536873468]],[\"^0\",[3085,\"^7\",1775984622334,536873468]],[\"^0\",[3079,\"^5\",3086,536873468]],[\"^0\",[3086,\"^2\",181,536873468]],[\"^0\",[3086,\"^9\",\"~u69db5fcf-5698-4512-ae34-c401492c972f\",536873468]],[\"^0\",[3086,\"^8\",1775984622136,536873468]],[\"^0\",[3086,\"^1\",536873327,536873468]],[\"^0\",[3086,\"^6\",\"1\",536873468]],[\"^0\",[3086,\"^3\",180,536873468]],[\"^0\",[3086,\"^4\",\"a1\",536873468]],[\"^0\",[3086,\"^7\",1775984622136,536873468]],[\"^0\",[3087,\"^2\",181,536873468]],[\"^0\",[3087,\"^9\",\"~u69db5fd1-e0be-4836-9bbf-8da40fce6b26\",536873468]],[\"^0\",[3087,\"^8\",1775984622189,536873468]],[\"^0\",[3087,\"^1\",536873339,536873468]],[\"^0\",[3087,\"^6\",\"3\",536873468]],[\"^0\",[3087,\"^3\",180,536873468]],[\"^0\",[3087,\"^4\",\"a9\",536873468]],[\"^0\",[3087,\"^7\",1775984622189,536873468]],[\"^0\",[3061,\"~:logseq.property.history/block\",3088,536873468]],[\"^0\",[3062,\"^<\",3088,536873468]],[\"^0\",[3088,\"~:logseq.property/status\",114,536873468]],[\"^0\",[3088,\"^2\",181,536873468]],[\"^0\",[3088,\"^9\",\"~u69db5fd1-68fc-4084-8518-ea383e66995c\",536873468]],[\"^0\",[3088,\"^8\",1775984622233,536873468]],[\"^0\",[3088,\"^1\",536873350,536873468]],[\"^0\",[3088,\"^6\",\"1\",536873468]],[\"^0\",[3088,\"~:block/tags\",152,536873468]],[\"^0\",[3088,\"~:block/refs\",152,536873468]],[\"^0\",[3088,\"^?\",58,536873468]],[\"^0\",[3088,\"^?\",26,536873468]],[\"^0\",[3088,\"^3\",180,536873468]],[\"^0\",[3088,\"^4\",\"aB\",536873468]],[\"^0\",[3088,\"^7\",1775984622233,536873468]],[\"^0\",[3088,\"^5\",3089,536873468]],[\"^0\",[3081,\"^5\",3089,536873468]],[\"^0\",[3089,\"^2\",181,536873468]],[\"^0\",[3089,\"^9\",\"~u69db5fd1-c79f-451c-a03a-3661fda1b5ad\",536873468]],[\"^0\",[3089,\"^8\",1775984622189,536873468]],[\"^0\",[3089,\"^1\",536873339,536873468]],[\"^0\",[3089,\"^6\",\"1\",536873468]],[\"^0\",[3089,\"^5\",3079,536873468]],[\"^0\",[3089,\"^3\",180,536873468]],[\"^0\",[3089,\"^4\",\"a7\",536873468]],[\"^0\",[3089,\"^7\",1775984622189,536873468]],[\"^0\",[3090,\"^2\",181,536873468]],[\"^0\",[3090,\"^9\",\"~u69db5fd2-b71f-405a-9d3d-b36dfd103fb4\",536873468]],[\"^0\",[3090,\"^8\",1775984622233,536873468]],[\"^0\",[3090,\"^1\",536873350,536873468]],[\"^0\",[3090,\"^6\",\"4\",536873468]],[\"^0\",[3090,\"^5\",3088,536873468]],[\"^0\",[3090,\"^3\",180,536873468]],[\"^0\",[3090,\"^4\",\"aF\",536873468]],[\"^0\",[3090,\"^7\",1775984622233,536873468]],[\"^0\",[3087,\"^5\",3091,536873468]],[\"^0\",[3091,\"^2\",181,536873468]],[\"^0\",[3091,\"^9\",\"~u69db5fd1-ff5c-4d87-b07b-7f2eb8f22130\",536873468]],[\"^0\",[3091,\"^8\",1775984622189,536873468]],[\"^0\",[3091,\"^1\",536873339,536873468]],[\"^0\",[3091,\"^6\",\"2\",536873468]],[\"^0\",[3091,\"^5\",3089,536873468]],[\"^0\",[3091,\"^3\",180,536873468]],[\"^0\",[3091,\"^4\",\"a8\",536873468]],[\"^0\",[3091,\"^7\",1775984622189,536873468]],[\"^0\",[3063,\"^<\",3092,536873468]],[\"^0\",[3064,\"^<\",3092,536873468]],[\"^0\",[3092,\"^=\",114,536873468]],[\"^0\",[3092,\"^2\",181,536873468]],[\"^0\",[3092,\"^9\",\"~u69db5fd2-9363-4c82-b248-588df3b44428\",536873468]],[\"^0\",[3092,\"^8\",1775984595016,536873468]],[\"^0\",[3092,\"^1\",536873353,536873468]],[\"^0\",[3092,\"^6\",\"5\",536873468]],[\"^0\",[3092,\"^>\",152,536873468]],[\"^0\",[3092,\"^?\",152,536873468]],[\"^0\",[3092,\"^?\",58,536873468]],[\"^0\",[3092,\"^?\",26,536873468]],[\"^0\",[3092,\"^5\",3090,536873468]],[\"^0\",[3092,\"^3\",180,536873468]],[\"^0\",[3092,\"^4\",\"aG\",536873468]],[\"^0\",[3092,\"^7\",1775984622233,536873468]],[\"^0\",[3065,\"^<\",3093,536873468]],[\"^0\",[3066,\"^<\",3093,536873468]],[\"^0\",[3067,\"^<\",3093,536873468]],[\"^0\",[3068,\"^<\",3093,536873468]],[\"^0\",[3069,\"^<\",3093,536873468]],[\"^0\",[3070,\"^<\",3093,536873468]],[\"^0\",[3093,\"^=\",55,536873468]],[\"^0\",[3093,\"^2\",181,536873468]],[\"^0\",[3093,\"^9\",\"~u69db5fd0-4aa1-42da-9278-a70c3fa778e9\",536873468]],[\"^0\",[3093,\"^8\",1775984592737,536873468]],[\"^0\",[3093,\"^1\",536873336,536873468]],[\"^0\",[3093,\"^6\",\"5\",536873468]],[\"^0\",[3093,\"^>\",152,536873468]],[\"^0\",[3093,\"^?\",152,536873468]],[\"^0\",[3093,\"^?\",58,536873468]],[\"^0\",[3093,\"^?\",26,536873468]],[\"^0\",[3093,\"^5\",3079,536873468]],[\"^0\",[3093,\"^3\",180,536873468]],[\"^0\",[3093,\"^4\",\"a6\",536873468]],[\"^0\",[3093,\"^7\",1775984622136,536873468]],[\"^0\",[3071,\"^<\",3094,536873468]],[\"^0\",[3072,\"^<\",3094,536873468]],[\"^0\",[3073,\"^<\",3094,536873468]],[\"^0\",[3074,\"^<\",3094,536873468]],[\"^0\",[3094,\"^=\",9,536873468]],[\"^0\",[3094,\"^2\",181,536873468]],[\"^0\",[3094,\"^9\",\"~u69db5fd3-06ca-4550-a5b8-8dd78ef72d4a\",536873468]],[\"^0\",[3094,\"^8\",1775984596072,536873468]],[\"^0\",[3094,\"^1\",536873367,536873468]],[\"^0\",[3094,\"^6\",\"5\",536873468]],[\"^0\",[3094,\"^>\",152,536873468]],[\"^0\",[3094,\"^?\",152,536873468]],[\"^0\",[3094,\"^?\",58,536873468]],[\"^0\",[3094,\"^?\",26,536873468]],[\"^0\",[3094,\"^5\",3077,536873468]],[\"^0\",[3094,\"^3\",180,536873468]],[\"^0\",[3094,\"^4\",\"aL\",536873468]],[\"^0\",[3094,\"^7\",1775984622278,536873468]],[\"^0\",[3095,\"^2\",181,536873468]],[\"^0\",[3095,\"^9\",\"~u69db5fd1-7a39-4314-b8cb-b1855c78e084\",536873468]],[\"^0\",[3095,\"^8\",1775984622189,536873468]],[\"^0\",[3095,\"^1\",536873339,536873468]],[\"^0\",[3095,\"^6\",\"5\",536873468]],[\"^0\",[3095,\"^5\",3081,536873468]],[\"^0\",[3095,\"^3\",180,536873468]],[\"^0\",[3095,\"^4\",\"aB\",536873468]],[\"^0\",[3095,\"^7\",1775984622189,536873468]],[\"^0\",[3096,\"^2\",181,536873468]],[\"^0\",[3096,\"^9\",\"~u69db5fd0-ebdb-4f66-bebb-9398b8c37e1a\",536873468]],[\"^0\",[3096,\"^8\",1775984622136,536873468]],[\"^0\",[3096,\"^1\",536873327,536873468]],[\"^0\",[3096,\"^6\",\"2\",536873468]],[\"^0\",[3096,\"^5\",3086,536873468]],[\"^0\",[3096,\"^3\",180,536873468]],[\"^0\",[3096,\"^4\",\"a3\",536873468]],[\"^0\",[3096,\"^7\",1775984622136,536873468]],[\"^0\",[3078,\"^5\",3097,536873468]],[\"^0\",[3097,\"^2\",181,536873468]],[\"^0\",[3097,\"^9\",\"~u69db5fd4-600b-476b-8c23-c1027a259cef\",536873468]],[\"^0\",[3097,\"^8\",1775984622334,536873468]],[\"^0\",[3097,\"^1\",536873375,536873468]],[\"^0\",[3097,\"^6\",\"2\",536873468]],[\"^0\",[3097,\"^5\",3085,536873468]],[\"^0\",[3097,\"^3\",180,536873468]],[\"^0\",[3097,\"^4\",\"aN\",536873468]],[\"^0\",[3097,\"^7\",1775984622334,536873468]],[\"^0\",[3098,\"^2\",181,536873468]],[\"^0\",[3098,\"^9\",\"~u69db5fd0-d884-4bc4-9fe0-a44b108bc919\",536873468]],[\"^0\",[3098,\"^8\",1775984622136,536873468]],[\"^0\",[3098,\"^1\",536873327,536873468]],[\"^0\",[3098,\"^6\",\"3\",536873468]],[\"^0\",[3098,\"^5\",3096,536873468]],[\"^0\",[3098,\"^3\",180,536873468]],[\"^0\",[3098,\"^4\",\"a4\",536873468]],[\"^0\",[3098,\"^7\",1775984622136,536873468]],[\"^0\",[3085,\"^5\",3099,536873468]],[\"^0\",[3077,\"^5\",3099,536873468]],[\"^0\",[3083,\"^5\",3099,536873468]],[\"^0\",[3099,\"^2\",181,536873468]],[\"^0\",[3099,\"^9\",\"~u69db5fd3-b9a5-41e2-8011-05f45de17fa7\",536873468]],[\"^0\",[3099,\"^8\",1775984622278,536873468]],[\"^0\",[3099,\"^1\",536873361,536873468]],[\"^0\",[3099,\"^6\",\"1\",536873468]],[\"^0\",[3099,\"^5\",3088,536873468]],[\"^0\",[3099,\"^3\",180,536873468]],[\"^0\",[3099,\"^4\",\"aG\",536873468]],[\"^0\",[3099,\"^7\",1775984622278,536873468]],[\"^0\",[3075,\"^<\",3100,536873468]],[\"^0\",[3076,\"^<\",3100,536873468]],[\"^0\",[3100,\"^=\",114,536873468]],[\"^0\",[3100,\"^2\",181,536873468]],[\"^0\",[3100,\"^9\",\"~u69db5fd4-83fc-4a35-8408-f4e61cd23a56\",536873468]],[\"^0\",[3100,\"^8\",1775984597058,536873468]],[\"^0\",[3100,\"^1\",536873378,536873468]],[\"^0\",[3100,\"^6\",\"5\",536873468]],[\"^0\",[3100,\"^>\",152,536873468]],[\"^0\",[3100,\"^?\",152,536873468]],[\"^0\",[3100,\"^?\",58,536873468]],[\"^0\",[3100,\"^?\",26,536873468]],[\"^0\",[3100,\"^5\",3080,536873468]],[\"^0\",[3100,\"^3\",180,536873468]],[\"^0\",[3100,\"^4\",\"aQ\",536873468]],[\"^0\",[3100,\"^7\",1775984622334,536873468]],[\"^0\",[3101,\"^2\",181,536873468]],[\"^0\",[3101,\"^9\",\"~u69db5fd2-825c-45d8-9da0-cd3e1f488648\",536873468]],[\"^0\",[3101,\"^8\",1775984622233,536873468]],[\"^0\",[3101,\"^1\",536873350,536873468]],[\"^0\",[3101,\"^6\",\"2\",536873468]],[\"^0\",[3101,\"^5\",3088,536873468]],[\"^0\",[3101,\"^3\",180,536873468]],[\"^0\",[3101,\"^4\",\"aD\",536873468]],[\"^0\",[3101,\"^7\",1775984622233,536873468]],[\"^0\",[3102,\"^2\",181,536873468]],[\"^0\",[3102,\"^9\",\"~u69db5fd2-f30e-4bc8-a67e-08ac5322c1cc\",536873468]],[\"^0\",[3102,\"^8\",1775984622233,536873468]],[\"^0\",[3102,\"^1\",536873350,536873468]],[\"^0\",[3102,\"^6\",\"3\",536873468]],[\"^0\",[3102,\"^5\",3101,536873468]],[\"^0\",[3102,\"^3\",180,536873468]],[\"^0\",[3102,\"^4\",\"aE\",536873468]],[\"^0\",[3102,\"^7\",1775984622233,536873468]],[\"^0\",[3086,\"^5\",3103,536873468]],[\"^0\",[3084,\"^5\",3103,536873468]],[\"^0\",[3103,\"^2\",181,536873468]],[\"^0\",[3103,\"^9\",\"~u69db5fbf-6687-487d-b0a1-668adb961cab\",536873468]],[\"^0\",[3103,\"^8\",1775984622100,536873468]],[\"^0\",[3103,\"^1\",536873316,536873468]],[\"^0\",[3103,\"^6\",\"4\",536873468]],[\"^0\",[3103,\"^5\",2577,536873468]],[\"^0\",[3103,\"^3\",180,536873468]],[\"^0\",[3103,\"^4\",\"a1\",536873468]],[\"^0\",[3103,\"^7\",1775984622084,536873468]],[\"^0\",[2941,\"^5\",2950,536873469,false]],[\"^0\",[2941,\"^5\",2931,536873469]],[\"^0\",[2941,\"^4\",\"a3\",536873469,false]],[\"^0\",[2941,\"^4\",\"a2\",536885721]],[\"^0\",[2931,\"^8\",1775984615139,536873470,false]],[\"^0\",[2941,\"^8\",1775984613561,536873470,false]],[\"^0\",[2931,\"^8\",1775984651669,536885723]],[\"^0\",[2941,\"^8\",1775984651668,536885723]],[\"^0\",[2941,\"^6\",\"\",536873470,false]],[\"^0\",[2941,\"^6\",\"fdafdasf\",536885723]],[\"^0\",[3103,\"^7\",1775984622084,536873471,false]],[\"^0\",[3103,\"^4\",\"a1\",536873471,false]],[\"^0\",[3103,\"^3\",180,536873471,false]],[\"^0\",[3103,\"^5\",2577,536873471,false]],[\"^0\",[3103,\"^6\",\"4\",536873471,false]],[\"^0\",[3103,\"^1\",536873316,536873471,false]],[\"^0\",[3103,\"^8\",1775984622100,536873471,false]],[\"^0\",[3103,\"^9\",\"~u69db5fbf-6687-487d-b0a1-668adb961cab\",536873471,false]],[\"^0\",[3103,\"^2\",181,536873471,false]],[\"^0\",[3084,\"^5\",3103,536873471,false]],[\"^0\",[3086,\"^5\",3103,536873471,false]],[\"^0\",[3090,\"^7\",1775984622233,536873471,false]],[\"^0\",[3090,\"^4\",\"aF\",536873471,false]],[\"^0\",[3090,\"^3\",180,536873471,false]],[\"^0\",[3090,\"^5\",3088,536873471,false]],[\"^0\",[3090,\"^6\",\"4\",536873471,false]],[\"^0\",[3090,\"^1\",536873350,536873471,false]],[\"^0\",[3090,\"^8\",1775984622233,536873471,false]],[\"^0\",[3090,\"^9\",\"~u69db5fd2-b71f-405a-9d3d-b36dfd103fb4\",536873471,false]],[\"^0\",[3090,\"^2\",181,536873471,false]],[\"^0\",[3092,\"^5\",3090,536873471,false]],[\"^0\",[3089,\"^7\",1775984622189,536873471,false]],[\"^0\",[3089,\"^4\",\"a7\",536873471,false]],[\"^0\",[3089,\"^3\",180,536873471,false]],[\"^0\",[3089,\"^5\",3079,536873471,false]],[\"^0\",[3089,\"^6\",\"1\",536873471,false]],[\"^0\",[3089,\"^1\",536873339,536873471,false]],[\"^0\",[3089,\"^8\",1775984622189,536873471,false]],[\"^0\",[3089,\"^9\",\"~u69db5fd1-c79f-451c-a03a-3661fda1b5ad\",536873471,false]],[\"^0\",[3089,\"^2\",181,536873471,false]],[\"^0\",[3081,\"^5\",3089,536873471,false]],[\"^0\",[3088,\"^5\",3089,536873471,false]],[\"^0\",[3091,\"^5\",3089,536873471,false]],[\"^0\",[3080,\"^7\",1775984622334,536873471,false]],[\"^0\",[3080,\"^4\",\"aP\",536873471,false]],[\"^0\",[3080,\"^3\",180,536873471,false]],[\"^0\",[3080,\"^5\",3085,536873471,false]],[\"^0\",[3080,\"^6\",\"4\",536873471,false]],[\"^0\",[3080,\"^1\",536873375,536873471,false]],[\"^0\",[3080,\"^8\",1775984622334,536873471,false]],[\"^0\",[3080,\"^9\",\"~u69db5fd4-4b47-4531-8f7c-dd8aa76170e7\",536873471,false]],[\"^0\",[3080,\"^2\",181,536873471,false]],[\"^0\",[3100,\"^5\",3080,536873471,false]],[\"^0\",[3093,\"^7\",1775984622136,536873471,false]],[\"^0\",[3093,\"^4\",\"a6\",536873471,false]],[\"^0\",[3093,\"^3\",180,536873471,false]],[\"^0\",[3093,\"^5\",3079,536873471,false]],[\"^0\",[3093,\"^?\",26,536873471,false]],[\"^0\",[3093,\"^?\",58,536873471,false]],[\"^0\",[3093,\"^?\",152,536873471,false]],[\"^0\",[3093,\"^>\",152,536873471,false]],[\"^0\",[3093,\"^6\",\"5\",536873471,false]],[\"^0\",[3093,\"^1\",536873336,536873471,false]],[\"^0\",[3093,\"^8\",1775984592737,536873471,false]],[\"^0\",[3093,\"^9\",\"~u69db5fd0-4aa1-42da-9278-a70c3fa778e9\",536873471,false]],[\"^0\",[3093,\"^2\",181,536873471,false]],[\"^0\",[3093,\"^=\",55,536873471,false]],[\"^0\",[3065,\"^<\",3093,536873471,false]],[\"^0\",[3066,\"^<\",3093,536873471,false]],[\"^0\",[3067,\"^<\",3093,536873471,false]],[\"^0\",[3068,\"^<\",3093,536873471,false]],[\"^0\",[3069,\"^<\",3093,536873471,false]],[\"^0\",[3070,\"^<\",3093,536873471,false]],[\"^0\",[3077,\"^7\",1775984622278,536873471,false]],[\"^0\",[3077,\"^4\",\"aK\",536873471,false]],[\"^0\",[3077,\"^3\",180,536873471,false]],[\"^0\",[3077,\"^5\",3099,536873471,false]],[\"^0\",[3077,\"^6\",\"4\",536873471,false]],[\"^0\",[3077,\"^1\",536873361,536873471,false]],[\"^0\",[3077,\"^8\",1775984622278,536873471,false]],[\"^0\",[3077,\"^9\",\"~u69db5fd3-0f51-4773-8902-d00d889dbcde\",536873471,false]],[\"^0\",[3077,\"^2\",181,536873471,false]],[\"^0\",[3094,\"^5\",3077,536873471,false]],[\"^0\",[3096,\"^7\",1775984622136,536873471,false]],[\"^0\",[3096,\"^4\",\"a3\",536873471,false]],[\"^0\",[3096,\"^3\",180,536873471,false]],[\"^0\",[3096,\"^5\",3086,536873471,false]],[\"^0\",[3096,\"^6\",\"2\",536873471,false]],[\"^0\",[3096,\"^1\",536873327,536873471,false]],[\"^0\",[3096,\"^8\",1775984622136,536873471,false]],[\"^0\",[3096,\"^9\",\"~u69db5fd0-ebdb-4f66-bebb-9398b8c37e1a\",536873471,false]],[\"^0\",[3096,\"^2\",181,536873471,false]],[\"^0\",[3098,\"^5\",3096,536873471,false]],[\"^0\",[3086,\"^7\",1775984622136,536873471,false]],[\"^0\",[3086,\"^4\",\"a1\",536873471,false]],[\"^0\",[3086,\"^3\",180,536873471,false]],[\"^0\",[3086,\"^6\",\"1\",536873471,false]],[\"^0\",[3086,\"^1\",536873327,536873471,false]],[\"^0\",[3086,\"^8\",1775984622136,536873471,false]],[\"^0\",[3086,\"^9\",\"~u69db5fcf-5698-4512-ae34-c401492c972f\",536873471,false]],[\"^0\",[3086,\"^2\",181,536873471,false]],[\"^0\",[3079,\"^5\",3086,536873471,false]],[\"^0\",[3099,\"^7\",1775984622278,536873471,false]],[\"^0\",[3099,\"^4\",\"aG\",536873471,false]],[\"^0\",[3099,\"^3\",180,536873471,false]],[\"^0\",[3099,\"^5\",3088,536873471,false]],[\"^0\",[3099,\"^6\",\"1\",536873471,false]],[\"^0\",[3099,\"^1\",536873361,536873471,false]],[\"^0\",[3099,\"^8\",1775984622278,536873471,false]],[\"^0\",[3099,\"^9\",\"~u69db5fd3-b9a5-41e2-8011-05f45de17fa7\",536873471,false]],[\"^0\",[3099,\"^2\",181,536873471,false]],[\"^0\",[3083,\"^5\",3099,536873471,false]],[\"^0\",[3085,\"^5\",3099,536873471,false]],[\"^0\",[3085,\"^7\",1775984622334,536873471,false]],[\"^0\",[3085,\"^4\",\"aL\",536873471,false]],[\"^0\",[3085,\"^3\",180,536873471,false]],[\"^0\",[3085,\"^6\",\"1\",536873471,false]],[\"^0\",[3085,\"^1\",536873375,536873471,false]],[\"^0\",[3085,\"^8\",1775984622334,536873471,false]],[\"^0\",[3085,\"^9\",\"~u69db5fd4-6a39-4cf4-b88f-a038a98f1d81\",536873471,false]],[\"^0\",[3085,\"^2\",181,536873471,false]],[\"^0\",[3097,\"^5\",3085,536873471,false]],[\"^0\",[3094,\"^7\",1775984622278,536873471,false]],[\"^0\",[3094,\"^4\",\"aL\",536873471,false]],[\"^0\",[3094,\"^3\",180,536873471,false]],[\"^0\",[3094,\"^?\",26,536873471,false]],[\"^0\",[3094,\"^?\",58,536873471,false]],[\"^0\",[3094,\"^?\",152,536873471,false]],[\"^0\",[3094,\"^>\",152,536873471,false]],[\"^0\",[3094,\"^6\",\"5\",536873471,false]],[\"^0\",[3094,\"^1\",536873367,536873471,false]],[\"^0\",[3094,\"^8\",1775984596072,536873471,false]],[\"^0\",[3094,\"^9\",\"~u69db5fd3-06ca-4550-a5b8-8dd78ef72d4a\",536873471,false]],[\"^0\",[3094,\"^2\",181,536873471,false]],[\"^0\",[3094,\"^=\",9,536873471,false]],[\"^0\",[3071,\"^<\",3094,536873471,false]],[\"^0\",[3072,\"^<\",3094,536873471,false]],[\"^0\",[3073,\"^<\",3094,536873471,false]],[\"^0\",[3074,\"^<\",3094,536873471,false]],[\"^0\",[3084,\"^7\",1775984622101,536873471,false]],[\"^0\",[3084,\"^4\",\"a0\",536873471,false]],[\"^0\",[3084,\"^3\",180,536873471,false]],[\"^0\",[3084,\"^6\",\"5\",536873471,false]],[\"^0\",[3084,\"^1\",536873321,536873471,false]],[\"^0\",[3084,\"^8\",1775984622121,536873471,false]],[\"^0\",[3084,\"^9\",\"~u69db5fc0-a560-43fb-a22b-f2d6961ffb31\",536873471,false]],[\"^0\",[3084,\"^2\",181,536873471,false]],[\"^0\",[3088,\"^7\",1775984622233,536873471,false]],[\"^0\",[3088,\"^4\",\"aB\",536873471,false]],[\"^0\",[3088,\"^3\",180,536873471,false]],[\"^0\",[3088,\"^?\",26,536873471,false]],[\"^0\",[3088,\"^?\",58,536873471,false]],[\"^0\",[3088,\"^?\",152,536873471,false]],[\"^0\",[3088,\"^>\",152,536873471,false]],[\"^0\",[3088,\"^6\",\"1\",536873471,false]],[\"^0\",[3088,\"^1\",536873350,536873471,false]],[\"^0\",[3088,\"^8\",1775984622233,536873471,false]],[\"^0\",[3088,\"^9\",\"~u69db5fd1-68fc-4084-8518-ea383e66995c\",536873471,false]],[\"^0\",[3088,\"^2\",181,536873471,false]],[\"^0\",[3088,\"^=\",114,536873471,false]],[\"^0\",[3061,\"^<\",3088,536873471,false]],[\"^0\",[3062,\"^<\",3088,536873471,false]],[\"^0\",[3101,\"^5\",3088,536873471,false]],[\"^0\",[3082,\"^7\",1775984622278,536873471,false]],[\"^0\",[3082,\"^4\",\"aJ\",536873471,false]],[\"^0\",[3082,\"^3\",180,536873471,false]],[\"^0\",[3082,\"^5\",3083,536873471,false]],[\"^0\",[3082,\"^6\",\"3\",536873471,false]],[\"^0\",[3082,\"^1\",536873361,536873471,false]],[\"^0\",[3082,\"^8\",1775984622278,536873471,false]],[\"^0\",[3082,\"^9\",\"~u69db5fd3-5271-4dc5-8af7-8c69d0477130\",536873471,false]],[\"^0\",[3082,\"^2\",181,536873471,false]],[\"^0\",[3101,\"^7\",1775984622233,536873471,false]],[\"^0\",[3101,\"^4\",\"aD\",536873471,false]],[\"^0\",[3101,\"^3\",180,536873471,false]],[\"^0\",[3101,\"^6\",\"2\",536873471,false]],[\"^0\",[3101,\"^1\",536873350,536873471,false]],[\"^0\",[3101,\"^8\",1775984622233,536873471,false]],[\"^0\",[3101,\"^9\",\"~u69db5fd2-825c-45d8-9da0-cd3e1f488648\",536873471,false]],[\"^0\",[3101,\"^2\",181,536873471,false]],[\"^0\",[3102,\"^5\",3101,536873471,false]],[\"^0\",[3092,\"^7\",1775984622233,536873471,false]],[\"^0\",[3092,\"^4\",\"aG\",536873471,false]],[\"^0\",[3092,\"^3\",180,536873471,false]],[\"^0\",[3092,\"^?\",26,536873471,false]],[\"^0\",[3092,\"^?\",58,536873471,false]],[\"^0\",[3092,\"^?\",152,536873471,false]],[\"^0\",[3092,\"^>\",152,536873471,false]],[\"^0\",[3092,\"^6\",\"5\",536873471,false]],[\"^0\",[3092,\"^1\",536873353,536873471,false]],[\"^0\",[3092,\"^8\",1775984595016,536873471,false]],[\"^0\",[3092,\"^9\",\"~u69db5fd2-9363-4c82-b248-588df3b44428\",536873471,false]],[\"^0\",[3092,\"^2\",181,536873471,false]],[\"^0\",[3092,\"^=\",114,536873471,false]],[\"^0\",[3063,\"^<\",3092,536873471,false]],[\"^0\",[3064,\"^<\",3092,536873471,false]],[\"^0\",[3079,\"^7\",1775984622136,536873471,false]],[\"^0\",[3079,\"^4\",\"a5\",536873471,false]],[\"^0\",[3079,\"^3\",180,536873471,false]],[\"^0\",[3079,\"^6\",\"4\",536873471,false]],[\"^0\",[3079,\"^1\",536873327,536873471,false]],[\"^0\",[3079,\"^8\",1775984622136,536873471,false]],[\"^0\",[3079,\"^9\",\"~u69db5fd0-426f-4608-9177-97a62a20a0a6\",536873471,false]],[\"^0\",[3079,\"^2\",181,536873471,false]],[\"^0\",[3100,\"^7\",1775984622334,536873471,false]],[\"^0\",[3100,\"^4\",\"aQ\",536873471,false]],[\"^0\",[3100,\"^3\",180,536873471,false]],[\"^0\",[3100,\"^?\",26,536873471,false]],[\"^0\",[3100,\"^?\",58,536873471,false]],[\"^0\",[3100,\"^?\",152,536873471,false]],[\"^0\",[3100,\"^>\",152,536873471,false]],[\"^0\",[3100,\"^6\",\"5\",536873471,false]],[\"^0\",[3100,\"^1\",536873378,536873471,false]],[\"^0\",[3100,\"^8\",1775984597058,536873471,false]],[\"^0\",[3100,\"^9\",\"~u69db5fd4-83fc-4a35-8408-f4e61cd23a56\",536873471,false]],[\"^0\",[3100,\"^2\",181,536873471,false]],[\"^0\",[3100,\"^=\",114,536873471,false]],[\"^0\",[3075,\"^<\",3100,536873471,false]],[\"^0\",[3076,\"^<\",3100,536873471,false]],[\"^0\",[3087,\"^7\",1775984622189,536873471,false]],[\"^0\",[3087,\"^4\",\"a9\",536873471,false]],[\"^0\",[3087,\"^3\",180,536873471,false]],[\"^0\",[3087,\"^5\",3091,536873471,false]],[\"^0\",[3087,\"^6\",\"3\",536873471,false]],[\"^0\",[3087,\"^1\",536873339,536873471,false]],[\"^0\",[3087,\"^8\",1775984622189,536873471,false]],[\"^0\",[3087,\"^9\",\"~u69db5fd1-e0be-4836-9bbf-8da40fce6b26\",536873471,false]],[\"^0\",[3087,\"^2\",181,536873471,false]],[\"^0\",[3081,\"^7\",1775984622189,536873471,false]],[\"^0\",[3081,\"^4\",\"aA\",536873471,false]],[\"^0\",[3081,\"^3\",180,536873471,false]],[\"^0\",[3081,\"^6\",\"4\",536873471,false]],[\"^0\",[3081,\"^1\",536873339,536873471,false]],[\"^0\",[3081,\"^8\",1775984622189,536873471,false]],[\"^0\",[3081,\"^9\",\"~u69db5fd1-440e-44a7-8a00-607bbf2af4a5\",536873471,false]],[\"^0\",[3081,\"^2\",181,536873471,false]],[\"^0\",[3095,\"^5\",3081,536873471,false]],[\"^0\",[3095,\"^7\",1775984622189,536873471,false]],[\"^0\",[3095,\"^4\",\"aB\",536873471,false]],[\"^0\",[3095,\"^3\",180,536873471,false]],[\"^0\",[3095,\"^6\",\"5\",536873471,false]],[\"^0\",[3095,\"^1\",536873339,536873471,false]],[\"^0\",[3095,\"^8\",1775984622189,536873471,false]],[\"^0\",[3095,\"^9\",\"~u69db5fd1-7a39-4314-b8cb-b1855c78e084\",536873471,false]],[\"^0\",[3095,\"^2\",181,536873471,false]],[\"^0\",[3083,\"^7\",1775984622278,536873471,false]],[\"^0\",[3083,\"^4\",\"aI\",536873471,false]],[\"^0\",[3083,\"^3\",180,536873471,false]],[\"^0\",[3083,\"^6\",\"2\",536873471,false]],[\"^0\",[3083,\"^1\",536873361,536873471,false]],[\"^0\",[3083,\"^8\",1775984622278,536873471,false]],[\"^0\",[3083,\"^9\",\"~u69db5fd3-edce-49f7-b2b0-449ee103901a\",536873471,false]],[\"^0\",[3083,\"^2\",181,536873471,false]],[\"^0\",[3102,\"^7\",1775984622233,536873471,false]],[\"^0\",[3102,\"^4\",\"aE\",536873471,false]],[\"^0\",[3102,\"^3\",180,536873471,false]],[\"^0\",[3102,\"^6\",\"3\",536873471,false]],[\"^0\",[3102,\"^1\",536873350,536873471,false]],[\"^0\",[3102,\"^8\",1775984622233,536873471,false]],[\"^0\",[3102,\"^9\",\"~u69db5fd2-f30e-4bc8-a67e-08ac5322c1cc\",536873471,false]],[\"^0\",[3102,\"^2\",181,536873471,false]],[\"^0\",[3097,\"^7\",1775984622334,536873471,false]],[\"^0\",[3097,\"^4\",\"aN\",536873471,false]],[\"^0\",[3097,\"^3\",180,536873471,false]],[\"^0\",[3097,\"^6\",\"2\",536873471,false]],[\"^0\",[3097,\"^1\",536873375,536873471,false]],[\"^0\",[3097,\"^8\",1775984622334,536873471,false]],[\"^0\",[3097,\"^9\",\"~u69db5fd4-600b-476b-8c23-c1027a259cef\",536873471,false]],[\"^0\",[3097,\"^2\",181,536873471,false]],[\"^0\",[3078,\"^5\",3097,536873471,false]],[\"^0\",[3078,\"^7\",1775984622334,536873471,false]],[\"^0\",[3078,\"^4\",\"aO\",536873471,false]],[\"^0\",[3078,\"^3\",180,536873471,false]],[\"^0\",[3078,\"^6\",\"3\",536873471,false]],[\"^0\",[3078,\"^1\",536873375,536873471,false]],[\"^0\",[3078,\"^8\",1775984622334,536873471,false]],[\"^0\",[3078,\"^9\",\"~u69db5fd4-2a7a-4d88-be50-4a536a4f29dd\",536873471,false]],[\"^0\",[3078,\"^2\",181,536873471,false]],[\"^0\",[3091,\"^7\",1775984622189,536873471,false]],[\"^0\",[3091,\"^4\",\"a8\",536873471,false]],[\"^0\",[3091,\"^3\",180,536873471,false]],[\"^0\",[3091,\"^6\",\"2\",536873471,false]],[\"^0\",[3091,\"^1\",536873339,536873471,false]],[\"^0\",[3091,\"^8\",1775984622189,536873471,false]],[\"^0\",[3091,\"^9\",\"~u69db5fd1-ff5c-4d87-b07b-7f2eb8f22130\",536873471,false]],[\"^0\",[3091,\"^2\",181,536873471,false]],[\"^0\",[3098,\"^7\",1775984622136,536873471,false]],[\"^0\",[3098,\"^4\",\"a4\",536873471,false]],[\"^0\",[3098,\"^3\",180,536873471,false]],[\"^0\",[3098,\"^6\",\"3\",536873471,false]],[\"^0\",[3098,\"^1\",536873327,536873471,false]],[\"^0\",[3098,\"^8\",1775984622136,536873471,false]],[\"^0\",[3098,\"^9\",\"~u69db5fd0-d884-4bc4-9fe0-a44b108bc919\",536873471,false]],[\"^0\",[3098,\"^2\",181,536873471,false]],[\"^0\",[3076,\"^7\",1775984597060,536873471,false]],[\"^0\",[3076,\"^1\",536873378,536873471,false]],[\"^0\",[3076,\"^8\",1775984597060,536873471,false]],[\"^0\",[3076,\"^9\",\"~u69db5fd5-10c1-4bec-b4ad-10be544167c4\",536873471,false]],[\"^0\",[3076,\"^2\",181,536873471,false]],[\"^0\",[3076,\"^;\",26,536873471,false]],[\"^0\",[3076,\"^:\",114,536873471,false]],[\"^0\",[3075,\"^7\",1775984603802,536873471,false]],[\"^0\",[3075,\"^1\",536873378,536873471,false]],[\"^0\",[3075,\"^8\",1775984603802,536873471,false]],[\"^0\",[3075,\"^9\",\"~u69db5fdb-aa3e-4767-825c-00af0198e1e7\",536873471,false]],[\"^0\",[3075,\"^2\",181,536873471,false]],[\"^0\",[3075,\"^;\",26,536873471,false]],[\"^0\",[3075,\"^:\",114,536873471,false]],[\"^0\",[3074,\"^7\",1775984595867,536873471,false]],[\"^0\",[3074,\"^1\",536873364,536873471,false]],[\"^0\",[3074,\"^8\",1775984595867,536873471,false]],[\"^0\",[3074,\"^9\",\"~u69db5fd3-6534-42d8-a5c2-4fe02aa6e94c\",536873471,false]],[\"^0\",[3074,\"^2\",181,536873471,false]],[\"^0\",[3074,\"^;\",26,536873471,false]],[\"^0\",[3074,\"^:\",114,536873471,false]],[\"^0\",[3073,\"^7\",1775984603383,536873471,false]],[\"^0\",[3073,\"^1\",536873364,536873471,false]],[\"^0\",[3073,\"^8\",1775984603383,536873471,false]],[\"^0\",[3073,\"^9\",\"~u69db5fdb-0542-4ec3-a6fd-4e9b3b805963\",536873471,false]],[\"^0\",[3073,\"^2\",181,536873471,false]],[\"^0\",[3073,\"^;\",26,536873471,false]],[\"^0\",[3073,\"^:\",114,536873471,false]],[\"^0\",[3072,\"^7\",1775984596073,536873471,false]],[\"^0\",[3072,\"^1\",536873367,536873471,false]],[\"^0\",[3072,\"^8\",1775984596073,536873471,false]],[\"^0\",[3072,\"^9\",\"~u69db5fd4-cb2f-4364-a9b5-3c4321628245\",536873471,false]],[\"^0\",[3072,\"^2\",181,536873471,false]],[\"^0\",[3072,\"^;\",26,536873471,false]],[\"^0\",[3072,\"^:\",9,536873471,false]],[\"^0\",[3071,\"^7\",1775984603456,536873471,false]],[\"^0\",[3071,\"^1\",536873367,536873471,false]],[\"^0\",[3071,\"^8\",1775984603456,536873471,false]],[\"^0\",[3071,\"^9\",\"~u69db5fdb-7cd3-4fb8-b24a-6658a716ec9e\",536873471,false]],[\"^0\",[3071,\"^2\",181,536873471,false]],[\"^0\",[3071,\"^;\",26,536873471,false]],[\"^0\",[3071,\"^:\",9,536873471,false]],[\"^0\",[3070,\"^7\",1775984592269,536873471,false]],[\"^0\",[3070,\"^1\",536873330,536873471,false]],[\"^0\",[3070,\"^8\",1775984592269,536873471,false]],[\"^0\",[3070,\"^9\",\"~u69db5fd0-7219-4848-add2-93885c48cdf0\",536873471,false]],[\"^0\",[3070,\"^2\",181,536873471,false]],[\"^0\",[3070,\"^;\",26,536873471,false]],[\"^0\",[3070,\"^:\",114,536873471,false]],[\"^0\",[3069,\"^7\",1775984602366,536873471,false]],[\"^0\",[3069,\"^1\",536873330,536873471,false]],[\"^0\",[3069,\"^8\",1775984602366,536873471,false]],[\"^0\",[3069,\"^9\",\"~u69db5fda-8f2b-461f-8fcf-eb2048e612db\",536873471,false]],[\"^0\",[3069,\"^2\",181,536873471,false]],[\"^0\",[3069,\"^;\",26,536873471,false]],[\"^0\",[3069,\"^:\",114,536873471,false]],[\"^0\",[3068,\"^7\",1775984592497,536873471,false]],[\"^0\",[3068,\"^1\",536873333,536873471,false]],[\"^0\",[3068,\"^8\",1775984592497,536873471,false]],[\"^0\",[3068,\"^9\",\"~u69db5fd0-467e-4233-848d-d5a8a17132c1\",536873471,false]],[\"^0\",[3068,\"^2\",181,536873471,false]],[\"^0\",[3068,\"^;\",26,536873471,false]],[\"^0\",[3068,\"^:\",9,536873471,false]],[\"^0\",[3067,\"^7\",1775984602438,536873471,false]],[\"^0\",[3067,\"^1\",536873333,536873471,false]],[\"^0\",[3067,\"^8\",1775984602438,536873471,false]],[\"^0\",[3067,\"^9\",\"~u69db5fda-5536-432b-8527-beb3ef42e823\",536873471,false]],[\"^0\",[3067,\"^2\",181,536873471,false]],[\"^0\",[3067,\"^;\",26,536873471,false]],[\"^0\",[3067,\"^:\",9,536873471,false]],[\"^0\",[3066,\"^7\",1775984592739,536873471,false]],[\"^0\",[3066,\"^1\",536873336,536873471,false]],[\"^0\",[3066,\"^8\",1775984592739,536873471,false]],[\"^0\",[3066,\"^9\",\"~u69db5fd0-dd43-44a3-a49e-9c9eb3ee21de\",536873471,false]],[\"^0\",[3066,\"^2\",181,536873471,false]],[\"^0\",[3066,\"^;\",26,536873471,false]],[\"^0\",[3066,\"^:\",55,536873471,false]],[\"^0\",[3065,\"^7\",1775984602508,536873471,false]],[\"^0\",[3065,\"^1\",536873336,536873471,false]],[\"^0\",[3065,\"^8\",1775984602508,536873471,false]],[\"^0\",[3065,\"^9\",\"~u69db5fda-fd9c-4f43-95ea-1f1f138f42fc\",536873471,false]],[\"^0\",[3065,\"^2\",181,536873471,false]],[\"^0\",[3065,\"^;\",26,536873471,false]],[\"^0\",[3065,\"^:\",55,536873471,false]],[\"^0\",[3064,\"^7\",1775984595023,536873471,false]],[\"^0\",[3064,\"^1\",536873353,536873471,false]],[\"^0\",[3064,\"^8\",1775984595023,536873471,false]],[\"^0\",[3064,\"^9\",\"~u69db5fd3-254e-4d8a-9093-0e30e2ea565d\",536873471,false]],[\"^0\",[3064,\"^2\",181,536873471,false]],[\"^0\",[3064,\"^;\",26,536873471,false]],[\"^0\",[3064,\"^:\",114,536873471,false]],[\"^0\",[3063,\"^7\",1775984603032,536873471,false]],[\"^0\",[3063,\"^1\",536873353,536873471,false]],[\"^0\",[3063,\"^8\",1775984603032,536873471,false]],[\"^0\",[3063,\"^9\",\"~u69db5fdb-d3de-4f3b-b69e-ebd4279e7d9c\",536873471,false]],[\"^0\",[3063,\"^2\",181,536873471,false]],[\"^0\",[3063,\"^;\",26,536873471,false]],[\"^0\",[3063,\"^:\",114,536873471,false]],[\"^0\",[3062,\"^7\",1775984593967,536873471,false]],[\"^0\",[3062,\"^1\",536873345,536873471,false]],[\"^0\",[3062,\"^8\",1775984593967,536873471,false]],[\"^0\",[3062,\"^9\",\"~u69db5fd1-d725-489f-9c7d-bb0fa8fd5421\",536873471,false]],[\"^0\",[3062,\"^2\",181,536873471,false]],[\"^0\",[3062,\"^;\",26,536873471,false]],[\"^0\",[3062,\"^:\",114,536873471,false]],[\"^0\",[3061,\"^7\",1775984602776,536873471,false]],[\"^0\",[3061,\"^1\",536873345,536873471,false]],[\"^0\",[3061,\"^8\",1775984602776,536873471,false]],[\"^0\",[3061,\"^9\",\"~u69db5fda-6885-4fac-b5b0-134fa03e071d\",536873471,false]],[\"^0\",[3061,\"^2\",181,536873471,false]],[\"^0\",[3061,\"^;\",26,536873471,false]],[\"^0\",[3061,\"^:\",114,536873471,false]],[\"^0\",[180,\"^1\",536873378,536873472,false]],[\"^0\",[180,\"^1\",536873471,536873472]],[\"^0\",[3060,\"^7\",1775984195215,536873473,false]],[\"^0\",[3060,\"^4\",\"a1\",536873473,false]],[\"^0\",[3060,\"^3\",180,536873473,false]],[\"^0\",[3060,\"^5\",180,536873473,false]],[\"^0\",[3060,\"^6\",\"6\",536873473,false]],[\"^0\",[3060,\"^8\",1775984196746,536873473,false]],[\"^0\",[3060,\"^9\",\"~u69db5e43-06ef-40dc-9f74-cffd52768f40\",536873473,false]],[\"^0\",[3060,\"^2\",181,536873473,false]],[\"^0\",[3059,\"^7\",1775984293011,536873473,false]],[\"^0\",[3059,\"^4\",\"a1V\",536873473,false]],[\"^0\",[3059,\"^3\",180,536873473,false]],[\"^0\",[3059,\"^5\",180,536873473,false]],[\"^0\",[3059,\"^6\",\"7\",536873473,false]],[\"^0\",[3059,\"^1\",536872551,536873473,false]],[\"^0\",[3059,\"^8\",1775984293020,536873473,false]],[\"^0\",[3059,\"^9\",\"~u69db5e98-e930-44d2-acb0-76bb2b2cfa85\",536873473,false]],[\"^0\",[3059,\"^2\",181,536873473,false]],[\"^0\",[3058,\"^7\",1775984293021,536873473,false]],[\"^0\",[3058,\"^4\",\"a1l\",536873473,false]],[\"^0\",[3058,\"^3\",180,536873473,false]],[\"^0\",[3058,\"^5\",180,536873473,false]],[\"^0\",[3058,\"^6\",\"8\",536873473,false]],[\"^0\",[3058,\"^1\",536872554,536873473,false]],[\"^0\",[3058,\"^8\",1775984293031,536873473,false]],[\"^0\",[3058,\"^9\",\"~u69db5e99-341b-4196-85af-9f5a75828be9\",536873473,false]],[\"^0\",[3058,\"^2\",181,536873473,false]],[\"^0\",[3057,\"^7\",1775984284238,536873473,false]],[\"^0\",[3057,\"^4\",\"a2\",536873473,false]],[\"^0\",[3057,\"^3\",180,536873473,false]],[\"^0\",[3057,\"^5\",180,536873473,false]],[\"^0\",[3057,\"^6\",\"9\",536873473,false]],[\"^0\",[3057,\"^8\",1775984284986,536873473,false]],[\"^0\",[3057,\"^9\",\"~u69db5e9c-e1fb-4d8a-8ce9-1e05986eea79\",536873473,false]],[\"^0\",[3057,\"^2\",181,536873473,false]],[\"^0\",[3056,\"^7\",1775984284987,536873473,false]],[\"^0\",[3056,\"^4\",\"a3\",536873473,false]],[\"^0\",[3056,\"^3\",180,536873473,false]],[\"^0\",[3056,\"^5\",180,536873473,false]],[\"^0\",[3056,\"^6\",\"10fa\",536873473,false]],[\"^0\",[3056,\"^8\",1775984521958,536873473,false]],[\"^0\",[3056,\"^9\",\"~u69db5e9c-1424-4f8a-a0be-2d8c42355472\",536873473,false]],[\"^0\",[3056,\"^2\",181,536873473,false]],[\"^0\",[3055,\"^7\",1775984521960,536873473,false]],[\"^0\",[3055,\"^4\",\"a4\",536873473,false]],[\"^0\",[3055,\"^3\",180,536873473,false]],[\"^0\",[3055,\"^5\",180,536873473,false]],[\"^0\",[3055,\"^6\",\"f\",536873473,false]],[\"^0\",[3055,\"^8\",1775984522125,536873473,false]],[\"^0\",[3055,\"^9\",\"~u69db5f89-2322-4e14-98a7-edc3728c12e4\",536873473,false]],[\"^0\",[3055,\"^2\",181,536873473,false]],[\"^0\",[3054,\"^5\",3055,536873473,false]],[\"^0\",[3054,\"^7\",1775984522126,536873473,false]],[\"^0\",[3054,\"^4\",\"a0\",536873473,false]],[\"^0\",[3054,\"^3\",180,536873473,false]],[\"^0\",[3054,\"^6\",\"a\",536873473,false]],[\"^0\",[3054,\"^8\",1775984522366,536873473,false]],[\"^0\",[3054,\"^9\",\"~u69db5f8a-20b6-4e5e-b82d-9351b6709e3c\",536873473,false]],[\"^0\",[3054,\"^2\",181,536873473,false]],[\"^0\",[3053,\"^7\",1775984522301,536873473,false]],[\"^0\",[3053,\"^4\",\"a6\",536873473,false]],[\"^0\",[3053,\"^3\",180,536873473,false]],[\"^0\",[3053,\"^5\",180,536873473,false]],[\"^0\",[3053,\"^6\",\"f\",536873473,false]],[\"^0\",[3053,\"^8\",1775984522529,536873473,false]],[\"^0\",[3053,\"^9\",\"~u69db5f8a-dea4-4434-a3ae-5b13b90882ce\",536873473,false]],[\"^0\",[3053,\"^2\",181,536873473,false]],[\"^0\",[3051,\"^5\",3053,536873473,false]],[\"^0\",[3052,\"^5\",3053,536873473,false]],[\"^0\",[3051,\"^7\",1775984523459,536873473,false]],[\"^0\",[3051,\"^4\",\"a4\",536873473,false]],[\"^0\",[3051,\"^3\",180,536873473,false]],[\"^0\",[3051,\"^6\",\"\",536873473,false]],[\"^0\",[3051,\"^8\",1775984523459,536873473,false]],[\"^0\",[3051,\"^9\",\"~u69db5f8b-0b14-4a38-a6a9-e7447e0dcac9\",536873473,false]],[\"^0\",[3051,\"^2\",181,536873473,false]],[\"^0\",[3052,\"^7\",1775984522530,536873473,false]],[\"^0\",[3052,\"^4\",\"a0\",536873473,false]],[\"^0\",[3052,\"^3\",180,536873473,false]],[\"^0\",[3052,\"^6\",\"e\",536873473,false]],[\"^0\",[3052,\"^8\",1775984522620,536873473,false]],[\"^0\",[3052,\"^9\",\"~u69db5f8a-bf4b-4013-806f-f2a6e1c0cc73\",536873473,false]],[\"^0\",[3052,\"^2\",181,536873473,false]],[\"^0\",[3047,\"^5\",3052,536873473,false]],[\"^0\",[3048,\"^5\",3052,536873473,false]],[\"^0\",[3049,\"^5\",3052,536873473,false]],[\"^0\",[3050,\"^5\",3052,536873473,false]],[\"^0\",[3047,\"^7\",1775984523179,536873473,false]],[\"^0\",[3047,\"^4\",\"a4\",536873473,false]],[\"^0\",[3047,\"^3\",180,536873473,false]],[\"^0\",[3047,\"^6\",\"f\",536873473,false]],[\"^0\",[3047,\"^8\",1775984523541,536873473,false]],[\"^0\",[3047,\"^9\",\"~u69db5f8b-d66e-43df-ab5f-7d57a066f4e0\",536873473,false]],[\"^0\",[3047,\"^2\",181,536873473,false]],[\"^0\",[3048,\"^7\",1775984523002,536873473,false]],[\"^0\",[3048,\"^4\",\"a3\",536873473,false]],[\"^0\",[3048,\"^3\",180,536873473,false]],[\"^0\",[3048,\"^6\",\"f\",536873473,false]],[\"^0\",[3048,\"^8\",1775984523266,536873473,false]],[\"^0\",[3048,\"^9\",\"~u69db5f8a-c743-4d46-8a33-844916e45c4b\",536873473,false]],[\"^0\",[3048,\"^2\",181,536873473,false]],[\"^0\",[3049,\"^7\",1775984522893,536873473,false]],[\"^0\",[3049,\"^4\",\"a1\",536873473,false]],[\"^0\",[3049,\"^3\",180,536873473,false]],[\"^0\",[3049,\"^6\",\"\",536873473,false]],[\"^0\",[3049,\"^8\",1775984522893,536873473,false]],[\"^0\",[3049,\"^9\",\"~u69db5f8a-9e79-49ce-9a5c-dad08cd22189\",536873473,false]],[\"^0\",[3049,\"^2\",181,536873473,false]],[\"^0\",[3050,\"^7\",1775984522698,536873473,false]],[\"^0\",[3050,\"^4\",\"a2\",536873473,false]],[\"^0\",[3050,\"^3\",180,536873473,false]],[\"^0\",[3050,\"^6\",\"f\",536873473,false]],[\"^0\",[3050,\"^8\",1775984522893,536873473,false]],[\"^0\",[3050,\"^9\",\"~u69db5f8a-3da9-40e5-a22f-c8f9b13a78af\",536873473,false]],[\"^0\",[3050,\"^2\",181,536873473,false]],[\"^0\",[180,\"^1\",536873471,536873474,false]],[\"^0\",[180,\"^1\",536873473,536873474]],[\"^0\",[180,\"^8\",1775984622122,536873475,false]],[\"^0\",[180,\"^8\",1775984657451,536873475]],[\"^0\",[2988,\"^6\",\"3\",536873475,false]],[\"^0\",[2988,\"^6\",\"3f\",536873475]],[\"^0\",[2988,\"^8\",1775984622083,536873475,false]],[\"^0\",[2988,\"^8\",1775984657451,536873475]],[\"^0\",[3104,\"^9\",\"~u69db6003-4b1b-4ba2-bb70-1740cc2fc85f\",536873476]],[\"^0\",[3104,\"^8\",1775984657454,536873476]],[\"^0\",[3104,\"^7\",1775984657454,536873476]],[\"^0\",[3104,\"^6\",\"\",536873476]],[\"^0\",[3104,\"^5\",2578,536873476]],[\"^0\",[3104,\"^4\",\"a1\",536873476]],[\"^0\",[3104,\"^3\",180,536873476]],[\"^0\",[3104,\"^2\",181,536873476]],[\"^0\",[180,\"^1\",536873473,536873477,false]],[\"^0\",[180,\"^1\",536873475,536873477]],[\"^0\",[2988,\"^1\",536873311,536873477,false]],[\"^0\",[2988,\"^1\",536873475,536873477]],[\"^0\",[3104,\"^1\",536873475,536873477]],[\"^0\",[180,\"^8\",1775984657451,536873478,false]],[\"^0\",[180,\"^8\",1775984657472,536873478]],[\"^0\",[3104,\"^6\",\"\",536873478,false]],[\"^0\",[3104,\"^6\",\"da\",536873478]],[\"^0\",[3104,\"^8\",1775984657454,536873478,false]],[\"^0\",[3104,\"^8\",1775984657472,536873478]],[\"^0\",[3105,\"^9\",\"~u69db6004-e461-4bd0-8284-882fc8983e4d\",536873479]],[\"^0\",[3105,\"^8\",1775984657475,536873479]],[\"^0\",[3105,\"^7\",1775984657475,536873479]],[\"^0\",[3105,\"^6\",\"\",536873479]],[\"^0\",[3105,\"^5\",2578,536873479]],[\"^0\",[3105,\"^4\",\"a2\",536873479]],[\"^0\",[3105,\"^3\",180,536873479]],[\"^0\",[3105,\"^2\",181,536873479]],[\"^0\",[180,\"^1\",536873475,536873480,false]],[\"^0\",[180,\"^1\",536873478,536873480]],[\"^0\",[3104,\"^1\",536873475,536873480,false]],[\"^0\",[3104,\"^1\",536873478,536873480]],[\"^0\",[3105,\"^1\",536873478,536873480]],[\"^0\",[180,\"^8\",1775984657472,536873481,false]],[\"^0\",[180,\"^8\",1775984657495,536873481]],[\"^0\",[3105,\"^6\",\"\",536873481,false]],[\"^0\",[3105,\"^6\",\"fda\",536873481]],[\"^0\",[3105,\"^8\",1775984657475,536873481,false]],[\"^0\",[3105,\"^8\",1775984657494,536873481]],[\"^0\",[3106,\"^9\",\"~u69db6004-5b7e-4a25-ac86-a11f6684a3db\",536873482]],[\"^0\",[3106,\"^8\",1775984657497,536873482]],[\"^0\",[3106,\"^7\",1775984657497,536873482]],[\"^0\",[3106,\"^6\",\"\",536873482]],[\"^0\",[3106,\"^5\",2578,536873482]],[\"^0\",[3106,\"^4\",\"a3\",536873482]],[\"^0\",[3106,\"^3\",180,536873482]],[\"^0\",[3106,\"^2\",181,536873482]],[\"^0\",[180,\"^1\",536873478,536873483,false]],[\"^0\",[180,\"^1\",536873481,536873483]],[\"^0\",[3105,\"^1\",536873478,536873483,false]],[\"^0\",[3105,\"^1\",536873481,536873483]],[\"^0\",[3106,\"^1\",536873481,536873483]],[\"^0\",[180,\"^8\",1775984657495,536873484,false]],[\"^0\",[180,\"^8\",1775984657517,536873484]],[\"^0\",[3106,\"^6\",\"\",536873484,false]],[\"^0\",[3106,\"^6\",\"f\",536873484]],[\"^0\",[3106,\"^8\",1775984657497,536873484,false]],[\"^0\",[3106,\"^8\",1775984657516,536873484]],[\"^0\",[3107,\"^9\",\"~u69db6004-352a-406c-a9af-cc8ac23a2e76\",536873485]],[\"^0\",[3107,\"^8\",1775984657519,536873485]],[\"^0\",[3107,\"^7\",1775984657519,536873485]],[\"^0\",[3107,\"^6\",\"\",536873485]],[\"^0\",[3107,\"^5\",2578,536873485]],[\"^0\",[3107,\"^4\",\"a4\",536873485]],[\"^0\",[3107,\"^3\",180,536873485]],[\"^0\",[3107,\"^2\",181,536873485]],[\"^0\",[180,\"^1\",536873481,536873486,false]],[\"^0\",[180,\"^1\",536873484,536873486]],[\"^0\",[3106,\"^1\",536873481,536873486,false]],[\"^0\",[3106,\"^1\",536873484,536873486]],[\"^0\",[3107,\"^1\",536873484,536873486]],[\"^0\",[3107,\"^5\",2578,536873487,false]],[\"^0\",[3107,\"^5\",2577,536873487]],[\"^0\",[3107,\"^4\",\"a4\",536873487,false]],[\"^0\",[3107,\"^4\",\"a1\",536873487]],[\"^0\",[180,\"^1\",536873484,536873488,false]],[\"^0\",[180,\"^1\",536873487,536873488]],[\"^0\",[3107,\"^1\",536873484,536873488,false]],[\"^0\",[3107,\"^1\",536873487,536873488]],[\"^0\",[3107,\"^5\",2577,536873489,false]],[\"^0\",[3107,\"^5\",180,536873489]],[\"^0\",[180,\"^1\",536873487,536873490,false]],[\"^0\",[180,\"^1\",536873489,536873490]],[\"^0\",[3107,\"^1\",536873487,536873490,false]],[\"^0\",[3107,\"^1\",536873489,536873490]],[\"^0\",[3108,\"^9\",\"~u69db6005-bf3e-4211-bca2-8385312d0bec\",536873491]],[\"^0\",[3108,\"^8\",1775984657567,536873491]],[\"^0\",[3108,\"^7\",1775984657567,536873491]],[\"^0\",[3108,\"^6\",\"\",536873491]],[\"^0\",[3108,\"^5\",180,536873491]],[\"^0\",[3108,\"^4\",\"a2\",536873491]],[\"^0\",[3108,\"^3\",180,536873491]],[\"^0\",[3108,\"^2\",181,536873492]],[\"^0\",[180,\"^1\",536873489,536873493,false]],[\"^0\",[180,\"^1\",536873491,536873493]],[\"^0\",[3108,\"^1\",536873491,536873493]],[\"^0\",[3109,\"^9\",\"~u69db6005-4042-4c05-b85f-c1605639abc0\",536873494]],[\"^0\",[3109,\"^8\",1775984657583,536873494]],[\"^0\",[3109,\"^7\",1775984657583,536873494]],[\"^0\",[3109,\"^6\",\"\",536873494]],[\"^0\",[3109,\"^5\",180,536873494]],[\"^0\",[3109,\"^4\",\"a3\",536873494]],[\"^0\",[3109,\"^3\",180,536873494]],[\"^0\",[3109,\"^2\",181,536873495]],[\"^0\",[180,\"^1\",536873491,536873496,false]],[\"^0\",[180,\"^1\",536873494,536873496]],[\"^0\",[3109,\"^1\",536873494,536873496]],[\"^0\",[180,\"^8\",1775984657517,536873497,false]],[\"^0\",[180,\"^8\",1775984657602,536873497]],[\"^0\",[3109,\"^6\",\"\",536873497,false]],[\"^0\",[3109,\"^6\",\"fdasf\",536873497]],[\"^0\",[3109,\"^8\",1775984657583,536873497,false]],[\"^0\",[3109,\"^8\",1775984657602,536873497]],[\"^0\",[3110,\"^9\",\"~u69db6005-316f-46a2-a5cb-9fd68c2dedb5\",536873498]],[\"^0\",[3110,\"^8\",1775984657605,536873498]],[\"^0\",[3110,\"^7\",1775984657605,536873498]],[\"^0\",[3110,\"^6\",\"\",536873498]],[\"^0\",[3110,\"^5\",180,536873498]],[\"^0\",[3110,\"^4\",\"a4\",536873498]],[\"^0\",[3110,\"^3\",180,536873498]],[\"^0\",[3110,\"^2\",181,536873498]],[\"^0\",[180,\"^1\",536873494,536873499,false]],[\"^0\",[180,\"^1\",536873497,536873499]],[\"^0\",[3109,\"^1\",536873494,536873499,false]],[\"^0\",[3109,\"^1\",536873497,536873499]],[\"^0\",[3110,\"^1\",536873497,536873499]],[\"^0\",[180,\"^8\",1775984657602,536873500,false]],[\"^0\",[180,\"^8\",1775984657625,536873500]],[\"^0\",[3110,\"^6\",\"\",536873500,false]],[\"^0\",[3110,\"^6\",\"a\",536873500]],[\"^0\",[3110,\"^8\",1775984657605,536873500,false]],[\"^0\",[3110,\"^8\",1775984657625,536873500]],[\"^0\",[3111,\"^9\",\"~u69db6005-dcf6-42b3-a752-a2088de28bc1\",536873501]],[\"^0\",[3111,\"^8\",1775984657627,536873501]],[\"^0\",[3111,\"^7\",1775984657627,536873501]],[\"^0\",[3111,\"^6\",\"\",536873501]],[\"^0\",[3111,\"^5\",180,536873501]],[\"^0\",[3111,\"^4\",\"a5\",536873501]],[\"^0\",[3111,\"^3\",180,536873501]],[\"^0\",[3111,\"^2\",181,536873501]],[\"^0\",[180,\"^1\",536873497,536873502,false]],[\"^0\",[180,\"^1\",536873500,536873502]],[\"^0\",[3110,\"^1\",536873497,536873502,false]],[\"^0\",[3110,\"^1\",536873500,536873502]],[\"^0\",[3111,\"^1\",536873500,536873502]],[\"^0\",[180,\"^8\",1775984657625,536873503,false]],[\"^0\",[180,\"^8\",1775984657645,536873503]],[\"^0\",[3111,\"^6\",\"\",536873503,false]],[\"^0\",[3111,\"^6\",\"f\",536873503]],[\"^0\",[3111,\"^8\",1775984657627,536873503,false]],[\"^0\",[3111,\"^8\",1775984657645,536873503]],[\"^0\",[3111,\"^5\",180,536873503,false]],[\"^0\",[3111,\"^5\",3110,536873504]],[\"^0\",[3111,\"^4\",\"a5\",536873503,false]],[\"^0\",[3111,\"^4\",\"a0\",536873504]],[\"^0\",[180,\"^1\",536873500,536873505,false]],[\"^0\",[180,\"^1\",536873503,536873505]],[\"^0\",[3111,\"^1\",536873500,536873505,false]],[\"^0\",[3111,\"^1\",536873503,536873505]],[\"^0\",[180,\"^8\",1775984657645,536873506,false]],[\"^0\",[180,\"^8\",1775984657668,536873506]],[\"^0\",[3111,\"^6\",\"f\",536873506,false]],[\"^0\",[3111,\"^6\",\"ffd\",536873506]],[\"^0\",[3111,\"^8\",1775984657645,536873506,false]],[\"^0\",[3111,\"^8\",1775984657668,536873506]],[\"^0\",[3112,\"^9\",\"~u69db6006-0b93-40c4-a1ea-da80dcf60d74\",536873507]],[\"^0\",[3112,\"^8\",1775984657671,536873507]],[\"^0\",[3112,\"^7\",1775984657671,536873507]],[\"^0\",[3112,\"^6\",\"\",536873507]],[\"^0\",[3112,\"^5\",3110,536873507]],[\"^0\",[3112,\"^4\",\"a1\",536873507]],[\"^0\",[3112,\"^3\",180,536873507]],[\"^0\",[3112,\"^2\",181,536873507]],[\"^0\",[180,\"^1\",536873503,536873508,false]],[\"^0\",[180,\"^1\",536873506,536873508]],[\"^0\",[3111,\"^1\",536873503,536873508,false]],[\"^0\",[3111,\"^1\",536873506,536873508]],[\"^0\",[3112,\"^1\",536873506,536873508]],[\"^0\",[180,\"^8\",1775984657668,536873509,false]],[\"^0\",[180,\"^8\",1775984657691,536873509]],[\"^0\",[3112,\"^6\",\"\",536873509,false]],[\"^0\",[3112,\"^6\",\"asf\",536873509]],[\"^0\",[3112,\"^8\",1775984657671,536873509,false]],[\"^0\",[3112,\"^8\",1775984657690,536873509]],[\"^0\",[3113,\"^9\",\"~u69db6006-6371-49cb-9a40-32c83291c527\",536873510]],[\"^0\",[3113,\"^8\",1775984657693,536873510]],[\"^0\",[3113,\"^7\",1775984657693,536873510]],[\"^0\",[3113,\"^6\",\"\",536873510]],[\"^0\",[3113,\"^5\",3110,536873510]],[\"^0\",[3113,\"^4\",\"a2\",536873510]],[\"^0\",[3113,\"^3\",180,536873510]],[\"^0\",[3113,\"^2\",181,536873510]],[\"^0\",[180,\"^1\",536873506,536873511,false]],[\"^0\",[180,\"^1\",536873509,536873511]],[\"^0\",[3112,\"^1\",536873506,536873511,false]],[\"^0\",[3112,\"^1\",536873509,536873511]],[\"^0\",[3113,\"^1\",536873509,536873511]],[\"^0\",[3113,\"^5\",3110,536873512,false]],[\"^0\",[3113,\"^5\",3112,536873512]],[\"^0\",[3113,\"^4\",\"a2\",536873512,false]],[\"^0\",[3113,\"^4\",\"a0\",536873512]],[\"^0\",[180,\"^1\",536873509,536873513,false]],[\"^0\",[180,\"^1\",536873512,536873513]],[\"^0\",[3113,\"^1\",536873509,536873513,false]],[\"^0\",[3113,\"^1\",536873512,536873513]],[\"^0\",[3113,\"^5\",3112,536873514,false]],[\"^0\",[3113,\"^5\",3110,536873514]],[\"^0\",[3113,\"^4\",\"a0\",536873514,false]],[\"^0\",[3113,\"^4\",\"a2\",536873514]],[\"^0\",[180,\"^1\",536873512,536873515,false]],[\"^0\",[180,\"^1\",536873514,536873515]],[\"^0\",[3113,\"^1\",536873512,536873515,false]],[\"^0\",[3113,\"^1\",536873514,536873515]],[\"^0\",[180,\"^8\",1775984657691,536873516,false]],[\"^0\",[180,\"^8\",1775984657739,536873516]],[\"^0\",[3113,\"^6\",\"\",536873516,false]],[\"^0\",[3113,\"^6\",\"f\",536873516]],[\"^0\",[3113,\"^8\",1775984657693,536873516,false]],[\"^0\",[3113,\"^8\",1775984657739,536873516]],[\"^0\",[3114,\"^9\",\"~u69db6006-5eb2-4b00-8071-bb9ceb56c463\",536873517]],[\"^0\",[3114,\"^8\",1775984657742,536873517]],[\"^0\",[3114,\"^7\",1775984657742,536873517]],[\"^0\",[3114,\"^6\",\"\",536873517]],[\"^0\",[3114,\"^5\",3110,536873517]],[\"^0\",[3114,\"^4\",\"a1V\",536873517]],[\"^0\",[3114,\"^3\",180,536873517]],[\"^0\",[3114,\"^2\",181,536873517]],[\"^0\",[180,\"^1\",536873514,536873518,false]],[\"^0\",[180,\"^1\",536873516,536873518]],[\"^0\",[3113,\"^1\",536873514,536873518,false]],[\"^0\",[3113,\"^1\",536873516,536873518]],[\"^0\",[3114,\"^1\",536873516,536873518]],[\"^0\",[3114,\"^5\",3110,536873519,false]],[\"^0\",[3114,\"^5\",3112,536873519]],[\"^0\",[3114,\"^4\",\"a1V\",536873519,false]],[\"^0\",[3114,\"^4\",\"a0\",536873519]],[\"^0\",[180,\"^1\",536873516,536873520,false]],[\"^0\",[180,\"^1\",536873519,536873520]],[\"^0\",[3114,\"^1\",536873516,536873520,false]],[\"^0\",[3114,\"^1\",536873519,536873520]],[\"^0\",[3115,\"^9\",\"~u69db6006-80f3-4ba7-827e-5ad6c3820a81\",536873521]],[\"^0\",[3115,\"^8\",1775984657775,536873521]],[\"^0\",[3115,\"^7\",1775984657775,536873521]],[\"^0\",[3115,\"^6\",\"\",536873521]],[\"^0\",[3115,\"^5\",3112,536873521]],[\"^0\",[3115,\"^4\",\"a1\",536873521]],[\"^0\",[3115,\"^3\",180,536873521]],[\"^0\",[3115,\"^2\",181,536873522]],[\"^0\",[180,\"^1\",536873519,536873523,false]],[\"^0\",[180,\"^1\",536873521,536873523]],[\"^0\",[3115,\"^1\",536873521,536873523]],[\"^0\",[3116,\"^9\",\"~u69db6007-4901-4222-8dcd-d5a80af5ddf5\",536873524]],[\"^0\",[3116,\"^8\",1775984657790,536873524]],[\"^0\",[3116,\"^7\",1775984657790,536873524]],[\"^0\",[3116,\"^6\",\"\",536873524]],[\"^0\",[3116,\"^5\",3112,536873524]],[\"^0\",[3116,\"^4\",\"a0V\",536873524]],[\"^0\",[3116,\"^3\",180,536873524]],[\"^0\",[3116,\"^2\",181,536873525]],[\"^0\",[180,\"^1\",536873521,536873526,false]],[\"^0\",[180,\"^1\",536873524,536873526]],[\"^0\",[3116,\"^1\",536873524,536873526]],[\"^0\",[3117,\"^9\",\"~u69db6007-2ce7-45bb-92f2-d74812f3aa09\",536873527]],[\"^0\",[3117,\"^8\",1775984657810,536873527]],[\"^0\",[3117,\"^7\",1775984657810,536873527]],[\"^0\",[3117,\"^6\",\"\",536873527]],[\"^0\",[3117,\"^5\",3112,536873527]],[\"^0\",[3117,\"^4\",\"a0l\",536873527]],[\"^0\",[3117,\"^3\",180,536873527]],[\"^0\",[3117,\"^2\",181,536873528]],[\"^0\",[180,\"^1\",536873524,536873529,false]],[\"^0\",[180,\"^1\",536873527,536873529]],[\"^0\",[3117,\"^1\",536873527,536873529]],[\"^0\",[3116,\"^5\",3112,536873530,false]],[\"^0\",[3116,\"^5\",3114,536873530]],[\"^0\",[3116,\"^4\",\"a0V\",536873530,false]],[\"^0\",[3116,\"^4\",\"a0\",536873530]],[\"^0\",[180,\"^1\",536873527,536873531,false]],[\"^0\",[180,\"^1\",536873530,536873531]],[\"^0\",[3116,\"^1\",536873524,536873531,false]],[\"^0\",[3116,\"^1\",536873530,536873531]],[\"^0\",[180,\"^8\",1775984657739,536873532,false]],[\"^0\",[180,\"^8\",1775984657858,536873532]],[\"^0\",[3117,\"^6\",\"\",536873532,false]],[\"^0\",[3117,\"^6\",\"f\",536873532]],[\"^0\",[3117,\"^8\",1775984657810,536873532,false]],[\"^0\",[3117,\"^8\",1775984657857,536873532]],[\"^0\",[3118,\"^9\",\"~u69db6007-e894-4caf-b4a2-babfa729f39c\",536873533]],[\"^0\",[3118,\"^8\",1775984657861,536873533]],[\"^0\",[3118,\"^7\",1775984657861,536873533]],[\"^0\",[3118,\"^6\",\"\",536873533]],[\"^0\",[3118,\"^5\",3112,536873533]],[\"^0\",[3118,\"^4\",\"a0t\",536873533]],[\"^0\",[3118,\"^3\",180,536873533]],[\"^0\",[3118,\"^2\",181,536873533]],[\"^0\",[180,\"^1\",536873530,536873534,false]],[\"^0\",[180,\"^1\",536873532,536873534]],[\"^0\",[3117,\"^1\",536873527,536873534,false]],[\"^0\",[3117,\"^1\",536873532,536873534]],[\"^0\",[3118,\"^1\",536873532,536873534]]]"} \ No newline at end of file diff --git a/deps/db-sync/test/logseq/db_sync/index_test.cljs b/deps/db-sync/test/logseq/db_sync/index_test.cljs new file mode 100644 index 0000000..6d22f21 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/index_test.cljs @@ -0,0 +1,197 @@ +(ns logseq.db-sync.index-test + (:require [cljs.test :refer [async deftest is]] + [clojure.string :as string] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [promesa.core :as p])) + +(def ^:private graph-e2ee-migration-sql + "alter table graphs add column graph_e2ee integer default 1") +(def ^:private graph-ready-for-use-migration-sql + "alter table graphs add column graph_ready_for_use integer default 1") +(def ^:private graph-members-graph-id-created-at-index-sql + "create index if not exists idx_graph_members_graph_id_created_at on graph_members (graph_id, created_at)") +(def ^:private graphs-user-id-updated-at-index-sql + "create index if not exists idx_graphs_user_id_updated_at on graphs (user_id, updated_at desc)") +(def ^:private users-email-index-sql + "create index if not exists idx_users_email on users (email)") + +(deftest index-list-includes-graph-e2ee-flag-test + (async done + (let [rows #js [#js {"graph_id" "graph-1" + "graph_name" "Graph 1" + "schema_version" "65" + "role" "manager" + "invited_by" nil + "created_at" 10 + "updated_at" 20 + "graph_e2ee" 0} + #js {"graph_id" "graph-2" + "graph_name" "Graph 2" + "schema_version" "65" + "role" "member" + "invited_by" "u1" + "created_at" 11 + "updated_at" 21 + "graph_e2ee" 1 + "graph_ready_for_use" 0}]] + (-> (p/with-redefs [common/ (p/with-redefs [common/ (p/with-redefs [common/now-ms (fn [] 1234) + common/ (p/with-redefs [common/now-ms (fn [] 1234) + common/ (p/with-redefs [common/ (p/with-redefs [common/ (p/with-redefs [common/ (p/with-redefs [common/js body))})) + +(defn- get-json [url] + (js/fetch url #js {:method "GET" :headers (auth-headers)})) + +(defn- parse-json [resp] + (.json resp)) + +(defn- start-test-server [] + (let [suffix (str (random-uuid)) + dir (str "tmp/db-sync-node-test/" suffix)] + (node-server/start! {:port 0 + :data-dir dir}))) +;; FIXME: Tests are disabled until they stop hanging +#_(deftest node-adapter-http-roundtrip-test + (async done + (p/let [{:keys [base-url stop!]} (start-test-server) + health-resp (js/fetch (str base-url "/health")) + health-body (parse-json health-resp)] + (testing "health" + (is (.-ok health-resp)) + (is (= true (aget health-body "ok")))) + ;; FIXME: Test hangs here due to an exception + (p/let [create-resp (post-json (str base-url "/graphs") {:graph-name "Test Graph"}) + create-body (parse-json create-resp) + graph-id (aget create-body "graph-id") + access-resp (get-json (str base-url "/graphs/" graph-id "/access")) + access-body (parse-json access-resp) + sync-health (get-json (str base-url "/sync/" graph-id "/health")) + sync-health-body (parse-json sync-health)] + (testing "graph access" + (is (.-ok create-resp)) + (is (string? graph-id)) + (is (.-ok access-resp)) + (is (= true (aget access-body "ok")))) + (testing "sync health" + (is (.-ok sync-health)) + (is (= true (aget sync-health-body "ok")))) + (p/let [tx-data [{:block/uuid (random-uuid) + :block/title "hello"}] + tx-entry {:tx (protocol/tx->transit tx-data) + :outliner-op :save-block} + tx-resp (post-json (str base-url "/sync/" graph-id "/tx/batch") + {:t-before 0 + :txs [tx-entry]}) + tx-body (parse-json tx-resp) + pull-resp (get-json (str base-url "/sync/" graph-id "/pull?since=0")) + pull-body (parse-json pull-resp)] + (testing "tx batch" + (is (.-ok tx-resp)) + (is (= "tx/batch/ok" (aget tx-body "type")))) + (testing "pull" + (is (.-ok pull-resp)) + (is (= "pull/ok" (aget pull-body "type"))) + (is (pos? (count (aget pull-body "txs")))) + (is (= "save-block" + (aget (aget pull-body "txs" 0) "outliner-op")))) + (p/then (stop!) (fn [] (done)))))))) + +#_(deftest node-adapter-websocket-test + (async done + (p/let [{:keys [base-url stop!]} (start-test-server) + create-resp (post-json (str base-url "/graphs") {:graph-name "WS Graph"}) + create-body (parse-json create-resp) + graph-id (aget create-body "graph-id")] + (testing "websocket hello and changed" + (let [ws-url (str (string/replace base-url "http" "ws") "/sync/" graph-id) + ws-module (js/require "ws") + WebSocket (or (.-WebSocket ws-module) ws-module) + client (new WebSocket ws-url #js {:headers (auth-headers)}) + messages (atom []) + push-message (fn [data] + (let [text (if (string? data) data (.toString data))] + (swap! messages conj (js/JSON.parse text))))] + (.on client "message" push-message) + (.on client "open" + (fn [] + (.send client (protocol/encode-message {:type "hello" :client "test"})))) + (p/let [_ (p/delay 50) + tx-data [{:block/uuid (random-uuid) + :block/title "ws"}] + txs (protocol/tx->transit tx-data) + _ (post-json (str base-url "/sync/" graph-id "/tx/batch") + {:t-before 0 + :txs txs}) + _ (p/delay 100)] + (let [types (set (map #(aget % "type") @messages))] + (is (contains? types "hello")) + (is (contains? types "changed"))) + (.close client) + (p/then (stop!) (fn [] (done))))))))) diff --git a/deps/db-sync/test/logseq/db_sync/node_config_test.cljs b/deps/db-sync/test/logseq/db_sync/node_config_test.cljs new file mode 100644 index 0000000..1c9c388 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/node_config_test.cljs @@ -0,0 +1,31 @@ +(ns logseq.db-sync.node-config-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.db-sync.node.config :as config])) + +(defn- throws? [f] + (try + (f) + false + (catch :default _ true))) + +(deftest normalize-config-drops-unknown-keys-test + (let [cfg (config/normalize-config {:port 7777 + :unknown-key "value" + :legacy-auth-key "value"})] + (is (= 7777 (:port cfg))) + (is (nil? (:unknown-key cfg))) + (is (nil? (:legacy-auth-key cfg))))) + +(deftest normalize-config-storage-driver-test + (testing "sqlite storage driver accepted" + (let [cfg (config/normalize-config {:storage-driver "sqlite"})] + (is (= "sqlite" (:storage-driver cfg))))) + (testing "unsupported storage driver throws" + (is (throws? #(config/normalize-config {:storage-driver "other"}))))) + +(deftest normalize-config-assets-driver-test + (testing "filesystem assets driver accepted" + (let [cfg (config/normalize-config {:assets-driver "filesystem"})] + (is (= "filesystem" (:assets-driver cfg))))) + (testing "unsupported assets driver throws" + (is (throws? #(config/normalize-config {:assets-driver "s3"}))))) diff --git a/deps/db-sync/test/logseq/db_sync/node_server_test.cljs b/deps/db-sync/test/logseq/db_sync/node_server_test.cljs new file mode 100644 index 0000000..d562f16 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/node_server_test.cljs @@ -0,0 +1,163 @@ +(ns logseq.db-sync.node-server-test + (:require [cljs.test :refer [async deftest is]] + [logseq.db-sync.node.server :as node-server] + [logseq.db-sync.platform.node :as platform-node] + [logseq.db-sync.worker.auth :as auth] + [promesa.core :as p])) + +(defn- fetch-with-timeout [url timeout-ms] + (let [timeout-sentinel ::timeout] + {:sentinel timeout-sentinel + :promise (js/Promise. + (fn [resolve reject] + (let [controller (js/AbortController.) + timeout-id (js/setTimeout + (fn [] + (.abort controller)) + timeout-ms)] + (-> (js/fetch url #js {:method "GET" + :headers #js {"authorization" "Bearer test.token.sig"} + :signal (.-signal controller)}) + (.then (fn [response] + (js/clearTimeout timeout-id) + (resolve response))) + (.catch (fn [error] + (js/clearTimeout timeout-id) + (if (= "AbortError" (.-name error)) + (resolve timeout-sentinel) + (reject error))))))))})) + +(deftest node-server-returns-500-when-auth-claims-rejects-test + (async done + (let [stop-server! (atom nil) + test-url (atom nil)] + (-> (p/with-redefs [auth/auth-claims + (fn [_request _env] + (p/rejected (ex-info "jwks" {})))] + (p/let [{:keys [base-url stop!]} (node-server/start! {:port 0 + :data-dir (str "tmp/db-sync-node-server-test/" (random-uuid))}) + _ (reset! stop-server! stop!) + _ (reset! test-url (str base-url "/graphs")) + {:keys [promise sentinel]} (fetch-with-timeout @test-url 1200) + response promise] + (if (identical? response sentinel) + (is false "request timed out") + (p/let [body (.json response)] + (is (= 500 (.-status response))) + (is (= "server error" (aget body "error"))))))) + (p/then + (fn [] + (if-let [stop! @stop-server!] + (-> (stop!) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done)))) + (done)))) + (p/catch + (fn [error] + (if-let [stop! @stop-server!] + (-> (stop!) + (p/then (fn [] + (is false (str error)) + (done))) + (p/catch (fn [stop-error] + (is false (str error)) + (is false (str stop-error)) + (done)))) + (do + (is false (str error)) + (done))))))))) + +(deftest node-server-request-origin-uses-configured-base-url-host-test + (async done + (let [stop-server! (atom nil) + request-opts (atom []) + original-request-from-node platform-node/request-from-node] + (-> (p/with-redefs [platform-node/request-from-node + (fn [req opts] + (swap! request-opts conj opts) + (original-request-from-node req opts))] + (p/let [{:keys [port stop!]} (node-server/start! {:port 0 + :base-url "https://sync.example.test:9443" + :data-dir (str "tmp/db-sync-node-server-base-url-test/" (random-uuid))}) + _ (reset! stop-server! stop!) + response (js/fetch (str "http://localhost:" port "/health"))] + (is (= 200 (.-status response))) + (is (some #(= {:scheme "https" + :host "sync.example.test:9443"} + %) + @request-opts)))) + (p/then + (fn [] + (if-let [stop! @stop-server!] + (-> (stop!) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done)))) + (done)))) + (p/catch + (fn [error] + (if-let [stop! @stop-server!] + (-> (stop!) + (p/then (fn [] + (is false (str error)) + (done))) + (p/catch (fn [stop-error] + (is false (str error)) + (is false (str stop-error)) + (done)))) + (do + (is false (str error)) + (done))))))))) + +(deftest node-server-logs-request-failed-marker-when-auth-claims-rejects-test + (async done + (let [stop-server! (atom nil) + original-console-error (.-error js/console) + logged-errors (atom [])] + (aset js/console + "error" + (fn [& args] + (swap! logged-errors conj args))) + (-> (p/with-redefs [auth/auth-claims + (fn [_request _env] + (p/rejected (ex-info "jwks" {})))] + (p/let [{:keys [base-url stop!]} (node-server/start! {:port 0 + :data-dir (str "tmp/db-sync-node-server-log-test/" (random-uuid))}) + _ (reset! stop-server! stop!) + _ (reset! logged-errors []) + {:keys [promise sentinel]} (fetch-with-timeout (str base-url "/graphs") 1200) + response promise] + (if (identical? response sentinel) + (is false "request timed out") + (do + (is (= 500 (.-status response))) + (is (some #(= ":db-sync/node-request-failed" (first %)) + @logged-errors)))))) + (p/then + (fn [] + (aset js/console "error" original-console-error) + (if-let [stop! @stop-server!] + (-> (stop!) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done)))) + (done)))) + (p/catch + (fn [error] + (aset js/console "error" original-console-error) + (if-let [stop! @stop-server!] + (-> (stop!) + (p/then (fn [] + (is false (str error)) + (done))) + (p/catch (fn [stop-error] + (is false (str error)) + (is false (str stop-error)) + (done)))) + (do + (is false (str error)) + (done))))))))) diff --git a/deps/db-sync/test/logseq/db_sync/normalize_test.cljs b/deps/db-sync/test/logseq/db_sync/normalize_test.cljs new file mode 100644 index 0000000..2810857 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/normalize_test.cljs @@ -0,0 +1,382 @@ +(ns logseq.db-sync.normalize-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.common.normalize :as db-normalize] + [logseq.db.test.helper :as db-test])) + +(defn- new-conn [] + (db-test/create-conn)) + +(defn- create-page! + [conn title] + (let [page-uuid (random-uuid)] + (d/transact! conn [{:block/uuid page-uuid + :block/name title + :block/title title}]) + page-uuid)) + +(defn- op-e-a-v + [datom] + (subvec (vec datom) 0 4)) + +(defn- tx-touches-uuid? + [tx-data block-uuid] + (let [lookup [:block/uuid block-uuid] + block-uuid-str (str block-uuid)] + (boolean + (some (fn [item] + (let [item (vec item)] + (case (count item) + 5 (or (= lookup (nth item 1)) + (= block-uuid-str (nth item 1)) + (= block-uuid (nth item 3))) + 2 (or (= lookup (second item)) + (= block-uuid-str (second item))) + false))) + tx-data)))) + +(defn- seeded-rng + [seed0] + (let [state (atom (bit-or (long seed0) 0))] + (fn [] + (let [s (swap! state + (fn [x] + (let [x (bit-xor x (bit-shift-left x 13)) + x (bit-xor x (bit-shift-right x 17)) + x (bit-xor x (bit-shift-left x 5))] + (bit-or x 0))))] + (/ (double (unsigned-bit-shift-right s 0)) 4294967296.0))))) + +(defn- rand-int* + [rng n] + (js/Math.floor (* (rng) n))) + +(defn- pick-rand + [rng coll] + (when (seq coll) + (nth coll (rand-int* rng (count coll))))) + +(defn- normal-block-uuids + [db] + (->> (d/datoms db :avet :block/uuid) + (map :e) + distinct + (keep (fn [eid] + (let [ent (d/entity db eid)] + (when (and (uuid? (:block/uuid ent)) + (not (ldb/built-in? ent)) + (nil? (:block/name ent)) + (some? (:block/page ent))) + (:block/uuid ent))))) + vec)) + +(defn- block-state + [db] + (->> (d/datoms db :avet :block/uuid) + (map :e) + distinct + (keep (fn [eid] + (let [ent (d/entity db eid)] + (when (and (uuid? (:block/uuid ent)) + (not (ldb/built-in? ent)) + (or (string? (:block/name ent)) + (some? (:block/page ent)))) + [(:block/uuid ent) + {:block/uuid (:block/uuid ent) + :block/name (:block/name ent) + :block/title (:block/title ent) + :block/page (some-> ent :block/page :block/uuid) + :block/parent (some-> ent :block/parent :block/uuid) + :block/order (:block/order ent)}])))) + (into (sorted-map)))) + +(deftest normalize-tx-data-keeps-title-retract-without-replacement-test + (let [conn (new-conn) + page-uuid (create-page! conn "Page") + tx-report (d/transact! conn [[:db/retract [:block/uuid page-uuid] :block/title "Page"]]) + normalized (db-normalize/normalize-tx-data (:db-after tx-report) + (:db-before tx-report) + (:tx-data tx-report)) + tx-data (mapv op-e-a-v normalized)] + (testing "keeps :block/title retract when no replacement title exists in same tx" + (is (= [[:db/retract [:block/uuid page-uuid] :block/title "Page"]] + tx-data))))) + +(deftest normalize-tx-data-drops-title-retract-when-replaced-test + (let [conn (new-conn) + page-uuid (create-page! conn "Page") + tx-report (d/transact! conn [{:block/uuid page-uuid + :block/title "Page 2"}]) + normalized (db-normalize/normalize-tx-data (:db-after tx-report) + (:db-before tx-report) + (:tx-data tx-report)) + tx-data (mapv op-e-a-v normalized)] + (testing "keeps old :block/title retract and new add during title update" + (is (some #(= [:db/add [:block/uuid page-uuid] :block/title "Page 2"] %) tx-data)) + (is (some #(= [:db/retract [:block/uuid page-uuid] :block/title "Page"] %) tx-data))))) + +(deftest normalize-tx-data-keeps-recreated-normal-blocks-test + (testing "retract + recreate for normal blocks should not drop recreated entity datoms" + (let [conn (new-conn) + page-uuid (create-page! conn "Page") + target-uuid (random-uuid) + sibling-uuid (random-uuid) + child-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid target-uuid + :block/title "old-target" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a0"} + {:block/uuid sibling-uuid + :block/title "sibling" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a1"} + {:block/uuid child-uuid + :block/title "child" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid target-uuid] + :block/order "a0"}]) + db-before @conn + + ;; Shape A: retract then recreate with tempid + explicit :block/uuid add. + tx-report-a (d/with db-before + [[:db/retractEntity [:block/uuid target-uuid]] + [:db/add -1 :block/uuid target-uuid] + [:db/add -1 :block/title "new-target-a"] + [:db/add -1 :block/page [:block/uuid page-uuid]] + [:db/add -1 :block/parent [:block/uuid page-uuid]] + [:db/add -1 :block/order "a0"] + [:db/add [:block/uuid sibling-uuid] :block/title "sibling-2"]] + {}) + normalized-a (db-normalize/normalize-tx-data (:db-after tx-report-a) + (:db-before tx-report-a) + (:tx-data tx-report-a)) + + ;; Shape B: same recreate, plus child reparent to recreated target. + tx-report-b (d/with db-before + [[:db/retractEntity [:block/uuid target-uuid]] + [:db/add -1 :block/uuid target-uuid] + [:db/add -1 :block/title "new-target-b"] + [:db/add -1 :block/page [:block/uuid page-uuid]] + [:db/add -1 :block/parent [:block/uuid page-uuid]] + [:db/add -1 :block/order "a2"] + [:db/add [:block/uuid child-uuid] :block/parent [:block/uuid target-uuid]] + [:db/add [:block/uuid sibling-uuid] :block/title "sibling-3"]] + {}) + normalized-b (db-normalize/normalize-tx-data (:db-after tx-report-b) + (:db-before tx-report-b) + (:tx-data tx-report-b))] + (is (some? (d/entity (:db-after tx-report-a) [:block/uuid target-uuid]))) + (is (some? (d/entity (:db-after tx-report-b) [:block/uuid target-uuid]))) + (is (tx-touches-uuid? normalized-a target-uuid) + (str "shape A unexpectedly dropped recreated block from normalized tx: " (pr-str normalized-a))) + (is (tx-touches-uuid? normalized-b target-uuid) + (str "shape B unexpectedly dropped recreated block from normalized tx: " (pr-str normalized-b)))))) + +(deftest apply-client-delete-blocks-removes-current-server-subtree-test + (testing "a delete-blocks tx deletes children that exist on the server but not in the client tx" + (let [conn (new-conn) + page-uuid (create-page! conn "Page") + parent-uuid (random-uuid) + child-uuid (random-uuid) + now (js/Date.now) + _ (d/transact! conn [{:block/uuid parent-uuid + :block/title "parent" + :block/created-at now + :block/updated-at now + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a0"} + {:block/uuid child-uuid + :block/title "child" + :block/created-at now + :block/updated-at now + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid parent-uuid] + :block/order "a0"}]) + result (try + (ldb/transact! conn + [[:db/retractEntity [:block/uuid parent-uuid]]] + {:op :apply-client-tx + :outliner-op :delete-blocks}) + :ok + (catch :default e + {:message (.-message e) + :data (ex-data e)}))] + (is (= :ok result)) + (is (nil? (d/entity @conn [:block/uuid parent-uuid]))) + (is (nil? (d/entity @conn [:block/uuid child-uuid])))))) + +(deftest normalize-delete-blocks-keeps-property-history-retract-test + (testing "a normalized delete-blocks tx still removes property history rows for the deleted block" + (let [conn (new-conn) + page-uuid (create-page! conn "Page") + block-uuid (random-uuid) + history-uuid (random-uuid) + property-uuid (random-uuid) + value-uuid (random-uuid) + now (js/Date.now)] + (d/transact! conn [{:block/uuid property-uuid + :block/name "p" + :block/title "P" + :db/ident :user.property/p + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :logseq.property/type :default} + {:block/uuid value-uuid + :block/title "value" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a0"} + {:block/uuid block-uuid + :block/title "target" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a1" + :block/created-at now + :block/updated-at now} + {:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block [:block/uuid block-uuid] + :logseq.property.history/property :user.property/p + :logseq.property.history/ref-value [:block/uuid value-uuid]}]) + (let [client-conn (d/conn-from-db @conn) + tx-report (ldb/transact! conn + [[:db/retractEntity [:block/uuid block-uuid]]] + {:outliner-op :delete-blocks}) + normalized (db-normalize/normalize-tx-data (:db-after tx-report) + (:db-before tx-report) + (:tx-data tx-report))] + (is (some #(= [:db/retractEntity [:block/uuid history-uuid]] %) normalized)) + (ldb/transact! client-conn normalized {:transact-remote? true + :outliner-op :delete-blocks}) + (is (nil? (d/entity @client-conn [:block/uuid block-uuid]))) + (is (nil? (d/entity @client-conn [:block/uuid history-uuid]))))))) + +(deftest remote-delete-blocks-history-ref-retract-deletes-history-entity-test + (testing "remote delete-blocks tx fully removes property history rows when their history block ref is retracted" + (let [conn (new-conn) + page-uuid (create-page! conn "Page") + block-uuid (random-uuid) + history-uuid (random-uuid) + property-uuid (random-uuid) + value-uuid (random-uuid) + now (js/Date.now)] + (d/transact! conn [{:block/uuid property-uuid + :block/name "p" + :block/title "P" + :db/ident :user.property/p + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :logseq.property/type :default} + {:block/uuid value-uuid + :block/title "value" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a0"} + {:block/uuid block-uuid + :block/title "target" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "a1" + :block/created-at now + :block/updated-at now} + {:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block [:block/uuid block-uuid] + :logseq.property.history/property :user.property/p + :logseq.property.history/ref-value [:block/uuid value-uuid]}]) + (ldb/transact! conn + [[:db/retract + [:block/uuid history-uuid] + :logseq.property.history/block + [:block/uuid block-uuid]]] + {:transact-remote? true + :outliner-op :delete-blocks}) + (is (nil? (d/entity @conn [:block/uuid history-uuid])))))) + +(deftest normalize-tx-data-replay-equivalence-for-retract-recreate-fuzz-test + (testing "normalized tx-data should replay to same db-after for normal-block retract/recreate patterns" + (let [conn (new-conn) + page-uuid (create-page! conn "Page")] + (d/transact! conn (mapv (fn [idx] + {:block/uuid (random-uuid) + :block/title (str "seed-block-" idx) + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order (str "a" idx)}) + (range 5))) + (dotimes [seed 300] + (let [rng (seeded-rng (+ 777 seed)) + db-before @conn + blocks (normal-block-uuids db-before) + target (pick-rand rng blocks) + sibling (pick-rand rng (remove #(= % target) blocks)) + move-candidate (pick-rand rng (remove #(= % target) blocks)) + case-id (mod seed 6) + base [[:db/retractEntity [:block/uuid target]] + [:db/add -1 :block/uuid target] + [:db/add -1 :block/title (str "fuzz-target-" seed)] + [:db/add -1 :block/page [:block/uuid page-uuid]] + [:db/add -1 :block/parent [:block/uuid page-uuid]] + [:db/add -1 :block/order (str "z" (mod seed 5))]] + tx-data (case case-id + 0 + (cond-> base + sibling (conj [:db/add [:block/uuid sibling] :block/title (str "fuzz-sibling-title-" seed)])) + + 1 + (cond-> base + sibling (conj [:db/retractEntity [:block/uuid sibling]])) + + 2 + (if sibling + (into base + [[:db/retractEntity [:block/uuid sibling]] + [:db/add -2 :block/uuid sibling] + [:db/add -2 :block/title (str "fuzz-sibling-recreated-" seed)] + [:db/add -2 :block/page [:block/uuid page-uuid]] + [:db/add -2 :block/parent [:block/uuid page-uuid]] + [:db/add -2 :block/order (str "y" (mod seed 5))]]) + base) + + 3 + (into base + [[:db/add -2 :block/uuid (random-uuid)] + [:db/add -2 :block/title (str "fuzz-fresh-" seed)] + [:db/add -2 :block/page [:block/uuid page-uuid]] + [:db/add -2 :block/parent [:block/uuid page-uuid]] + [:db/add -2 :block/order (str "x" (mod seed 7))]]) + + 4 + (if sibling + (into base + [[:db/retractEntity [:block/uuid sibling]] + [:db/add -2 :block/uuid sibling] + [:db/add -2 :block/title (str "fuzz-sibling-reparented-" seed)] + [:db/add -2 :block/page [:block/uuid page-uuid]] + [:db/add -2 :block/parent [:block/uuid target]] + [:db/add -2 :block/order "a0"]]) + base) + + ;; 5 + (cond-> base + move-candidate + (conj [:db/add [:block/uuid move-candidate] :block/parent [:block/uuid target]]))) + tx-report (d/with db-before tx-data {}) + normalized (db-normalize/normalize-tx-data (:db-after tx-report) + (:db-before tx-report) + (:tx-data tx-report)) + replay-report (d/with db-before normalized {}) + expected (block-state (:db-after tx-report)) + actual (block-state (:db-after replay-report))] + (is (= expected actual) + (str "seed=" seed + "\noriginal=" (pr-str tx-data) + "\nnormalized=" (pr-str normalized) + "\nexpected-count=" (count expected) + " actual-count=" (count actual)))))))) diff --git a/deps/db-sync/test/logseq/db_sync/storage_test.cljs b/deps/db-sync/test/logseq/db_sync/storage_test.cljs new file mode 100644 index 0000000..7d0546f --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/storage_test.cljs @@ -0,0 +1,272 @@ +(ns logseq.db-sync.storage-test + (:require ["better-sqlite3" :as sqlite3] + [clojure.string :as string] + [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db-sync.common :as common] + [logseq.db-sync.storage :as storage] + [logseq.db.common.normalize :as db-normalize] + [logseq.db-sync.test-sql :as test-sql])) + +(def sqlite (if (find-ns 'nbb.core) (aget sqlite3 "default") sqlite3)) + +(defn- select-sql? + [sql] + (string/starts-with? (-> sql string/trim string/lower-case) "select")) + +(defn- run-sql + [^js stmt args] + (.apply (.-run stmt) stmt (to-array args))) + +(defn- all-sql + [^js stmt args] + (.apply (.-all stmt) stmt (to-array args))) + +(defn- with-memory-sql + [f] + (let [db (new sqlite ":memory:" nil) + sql #js {:exec (fn [sql-str & args] + (let [stmt (.prepare db sql-str)] + (if (select-sql? sql-str) + (all-sql stmt args) + (do + (run-sql stmt args) + nil)))) + :close (fn [] + (.close db))}] + (try + (f sql) + (finally + (.close sql))))) + +(defn- seeded-rng + [seed0] + (let [state (atom (bit-or (long seed0) 0))] + (fn [] + (let [s (swap! state + (fn [x] + (let [x (bit-xor x (bit-shift-left x 13)) + x (bit-xor x (bit-shift-right x 17)) + x (bit-xor x (bit-shift-left x 5))] + (bit-or x 0))))] + (/ (double (unsigned-bit-shift-right s 0)) 4294967296.0))))) + +(defn- rand-int* + [rng n] + (js/Math.floor (* (rng) n))) + +(defn- pick-rand + [rng coll] + (when (seq coll) + (nth coll (rand-int* rng (count coll))))) + +(defn- normal-block-uuids + [db] + (->> (d/datoms db :avet :block/uuid) + (map :e) + distinct + (keep (fn [eid] + (let [ent (d/entity db eid)] + (when (and (uuid? (:block/uuid ent)) + (not (ldb/built-in? ent)) + (nil? (:block/name ent)) + (some? (:block/page ent))) + (:block/uuid ent))))) + vec)) + +(deftest t-meta-test + (let [sql (test-sql/make-sql)] + (storage/init-schema! sql) + (is (= 0 (storage/get-t sql))) + (storage/set-t! sql 1) + (is (= 1 (storage/get-t sql))) + (storage/set-t! sql 2) + (is (= 2 (storage/get-t sql))))) + +(deftest tx-log-test + (let [sql (test-sql/make-sql)] + (storage/init-schema! sql) + (storage/append-tx! sql 1 "tx-1" 100 :save-block) + (storage/append-tx! sql 2 "tx-2" 200 :move-blocks) + (storage/append-tx! sql 3 "tx-3" 300 nil) + (let [result (storage/fetch-tx-since sql 1)] + (is (= [{:t 2 :tx "tx-2" :outliner-op :move-blocks} + {:t 3 :tx "tx-3" :outliner-op nil}] + result))))) + +(deftest stale-checksum-no-op-transact-does-not-throw-test + (testing "a no-op tx should not throw and should keep incremental checksum state" + (with-memory-sql + (fn [sql] + (let [stale-checksum "f4b78e83776d45fb"] + (storage/init-schema! sql) + (storage/set-checksum! sql stale-checksum) + (let [conn (storage/open-conn sql) + result (try + (d/transact! conn + [] + {:outliner-op :rebase}) + :ok + (catch :default e + e))] + (is (= :ok result)) + (is (= stale-checksum + (storage/get-checksum sql))))))))) + +(deftest initial-checksum-does-not-overwrite-existing-checksum-test + (testing "snapshot checksum initialization must not replace an existing incremental checksum" + (let [sql (test-sql/make-sql) + existing-checksum "aaaaaaaaaaaaaaaa" + snapshot-checksum "bbbbbbbbbbbbbbbb"] + (storage/init-schema! sql) + (storage/set-initial-checksum! sql existing-checksum) + (let [result (try + (storage/set-initial-checksum! sql snapshot-checksum) + :ok + (catch :default error + error))] + (is (not= :ok result)) + (is (= existing-checksum (storage/get-checksum sql))))))) + +(deftest initial-checksum-rejects-non-empty-tx-log-test + (testing "snapshot checksum initialization must not run after tx history already advanced" + (let [sql (test-sql/make-sql) + snapshot-checksum "bbbbbbbbbbbbbbbb"] + (storage/init-schema! sql) + (storage/append-tx! sql 1 "tx-1" 100 :save-block) + (storage/set-t! sql 1) + (let [result (try + (storage/set-initial-checksum! sql snapshot-checksum) + :ok + (catch :default error + error))] + (is (not= :ok result)) + (is (nil? (storage/get-checksum sql))))))) + +(deftest stale-checksum-transact-keeps-kvs-and-tx-log-consistent-test + (testing "stale checksum should not fail transact; kvs and tx_log/t should advance together" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + stale-checksum "ffffffffffffffff" + page-uuid (random-uuid)] + ;; Use a stale checksum and ensure append path remains consistent. + (storage/set-checksum! sql stale-checksum) + (let [result (try + (d/transact! conn [{:block/uuid page-uuid + :block/name "repro-kvs-ahead-page"}]) + :ok + (catch :default e + e))] + (is (= :ok result)) + (is (= 1 (storage/get-t sql))) + (is (= 1 (count (storage/fetch-tx-since sql 0)))) + (is (not= stale-checksum (storage/get-checksum sql))) + (let [restored-conn (storage/open-conn sql)] + (is (= page-uuid + (:block/uuid (d/entity @restored-conn [:block/uuid page-uuid]))))))))))) + +(deftest tx-log-append-failure-does-not-advance-checksum-test + (testing "server checksum and t should not advance when the tx log append fails" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + failed-block-uuid (random-uuid)] + (d/transact! conn [{:block/uuid page-uuid + :block/name "tx-log-failure-repro"}]) + (let [checksum-before (storage/get-checksum sql) + t-before (storage/get-t sql) + original-sql-exec common/sql-exec + result (with-redefs [common/sql-exec + (fn [sql sql-str & args] + (if (string/includes? sql-str "insert into tx_log") + (throw (js/Error. "tx log append failed")) + (apply original-sql-exec sql sql-str args)))] + (try + (d/transact! conn [{:block/uuid failed-block-uuid + :block/title "failed append" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]}]) + :ok + (catch :default error + error)))] + (is (not= :ok result)) + (is (= t-before (storage/get-t sql))) + (is (= checksum-before (storage/get-checksum sql))))))))) + +(deftest normalize-drop-can-hide-kvs-mutation-from-tx-log-test + (testing "if normalize drops tx payload, tx_log can miss persisted kvs state changes" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid)] + (with-redefs [db-normalize/normalize-tx-data (fn [_db-after _db-before _tx-data] + [])] + (d/transact! conn [{:block/uuid page-uuid + :block/name "normalize-drop-repro"}])) + (is (= 1 (storage/get-t sql))) + (let [entries (storage/fetch-tx-since sql 0)] + (is (= 1 (count entries))) + (is (= [] + (common/read-transit (:tx (first entries))))) + (let [restored-conn (storage/open-conn sql)] + (is (= page-uuid + (:block/uuid (d/entity @restored-conn [:block/uuid page-uuid]))))))))))) + +(deftest randomized-normal-block-retract-recreate-does-not-throw-checksum-mismatch-test + (testing "normal block retract/recreate patterns should not naturally trigger server checksum mismatch" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + rng (seeded-rng 424242) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "repro-page" + :block/title "repro-page"}]) + _ (d/transact! conn (mapv (fn [idx] + {:block/uuid (random-uuid) + :block/title (str "seed-" idx) + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order (str "a" idx)}) + (range 5))) + *mismatch (atom nil)] + (dotimes [step 500] + (when-not @*mismatch + (let [db @conn + blocks (normal-block-uuids db) + target (pick-rand rng blocks) + sibling (pick-rand rng (remove #(= % target) blocks)) + add-fresh? (< (rng) 0.3) + tx-data (cond-> [[:db/retractEntity [:block/uuid target]] + [:db/add -1 :block/uuid target] + [:db/add -1 :block/title (str "rr-" step)] + [:db/add -1 :block/page [:block/uuid page-uuid]] + [:db/add -1 :block/parent [:block/uuid page-uuid]] + [:db/add -1 :block/order (str "z" (mod step 7))]] + sibling + (conj [:db/add [:block/uuid sibling] :block/title (str "sib-" step)]) + add-fresh? + (into [[:db/add -2 :block/uuid (random-uuid)] + [:db/add -2 :block/title (str "fresh-" step)] + [:db/add -2 :block/page [:block/uuid page-uuid]] + [:db/add -2 :block/parent [:block/uuid page-uuid]] + [:db/add -2 :block/order (str "x" (mod step 9))]]))] + (try + (d/transact! conn tx-data) + (catch :default e + (let [message (or (ex-message e) (some-> e .-message) (str e))] + (if (string/includes? message "server checksum doesn't match") + (reset! *mismatch {:step step + :tx-data tx-data + :error message}) + ;; tx can be invalid due random -2 fresh fields; ignore non-checksum failures + nil))))))) + (is (nil? @*mismatch) + (str "found checksum mismatch repro: " (pr-str @*mismatch)))))))) diff --git a/deps/db-sync/test/logseq/db_sync/test_runner.cljs b/deps/db-sync/test/logseq/db_sync/test_runner.cljs new file mode 100644 index 0000000..75ece9c --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/test_runner.cljs @@ -0,0 +1,36 @@ +(ns logseq.db-sync.test-runner + (:require [cljs.test :as ct] + [logseq.db-sync.checksum-test] + [logseq.db-sync.common-test] + [logseq.db-sync.index-test] + [logseq.db-sync.malli-schema-test] + [logseq.db-sync.node-adapter-test] + [logseq.db-sync.node-config-test] + [logseq.db-sync.node-server-test] + [logseq.db-sync.normalize-test] + [logseq.db-sync.storage-test] + [logseq.db-sync.tx-sanitize-test] + [logseq.db-sync.worker-auth-test] + [logseq.db-sync.worker-dispatch-test] + [logseq.db-sync.worker-handler-assets-test] + [logseq.db-sync.worker-handler-index-test] + [logseq.db-sync.worker-handler-sync-test] + [logseq.db-sync.worker-handler-ws-test] + [shadow.test :as st] + [shadow.test.env :as env])) + +(derive ::node ::ct/default) + +(defmethod ct/report [::node :end-run-tests] [m] + (if (ct/successful? m) + (js/process.exit 0) + (js/process.exit 1))) + +(defn ^:dev/after-load reset-test-data! [] + (-> (env/get-test-data) + (env/reset-test-data!))) + +(defn main [& _args] + (reset-test-data!) + (let [test-env (ct/empty-env ::node)] + (st/run-all-tests test-env nil))) diff --git a/deps/db-sync/test/logseq/db_sync/test_sql.cljs b/deps/db-sync/test/logseq/db_sync/test_sql.cljs new file mode 100644 index 0000000..7128fb3 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/test_sql.cljs @@ -0,0 +1,56 @@ +(ns logseq.db-sync.test-sql + (:require [clojure.string :as string])) + +(defn- js-key [k] + (cond + (keyword? k) (string/replace (name k) "-" "_") + (string? k) k + :else (str k))) + +(defn- js-row [m] + (let [o (js-obj)] + (doseq [[k v] m] + (aset o (js-key k) v)) + o)) + +(defn- js-rows [rows] + (into-array (map js-row rows))) + +(defn make-sql [] + (let [state (atom {:tx-log {} + :meta {}})] + #js {:exec (fn [sql & args] + (cond + (string/includes? sql "insert into tx_log") + (let [[t tx created-at outliner-op] args] + (swap! state update :tx-log assoc t {:t t + :tx tx + :created-at created-at + :outliner-op outliner-op}) + nil) + + (string/includes? sql "select t, tx, outliner_op from tx_log") + (let [since (first args) + rows (->> (:tx-log @state) + vals + (filter (fn [row] (> (:t row) since))) + (sort-by :t) + (map (fn [row] {:t (:t row) + :tx (:tx row) + :outliner-op (:outliner-op row)})))] + (js-rows rows)) + + (string/includes? sql "insert into sync_meta") + (let [[k v] args] + (swap! state update :meta assoc k v) + nil) + + (string/includes? sql "select value from sync_meta") + (let [k (first args) + value (get-in @state [:meta k])] + (if (some? value) + (js-rows [{:value value}]) + (js-rows []))) + + :else + nil))})) diff --git a/deps/db-sync/test/logseq/db_sync/tx_sanitize_test.cljs b/deps/db-sync/test/logseq/db_sync/tx_sanitize_test.cljs new file mode 100644 index 0000000..ba5155c --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/tx_sanitize_test.cljs @@ -0,0 +1,104 @@ +(ns logseq.db-sync.tx-sanitize-test + (:require [cljs.test :refer [deftest is testing]] + [clojure.set :as set] + [datascript.core :as d] + [logseq.db-sync.tx-sanitize :as tx-sanitize] + [logseq.db.test.helper :as db-test])) + +(def ^:private migration-deleted-attrs + #{:block/path-refs + :block/pre-block? + :logseq.property.embedding/hnsw-label + :logseq.property.embedding/hnsw-label-updated-at}) + +(def ^:private graph-backup-folder-ops + #{[:db/retractEntity :logseq.kv/graph-backup-folder] + [:db/add :logseq.kv/graph-backup-folder :logseq.kv/value "/tmp/backup"]}) + +(defn- tx-item-attrs + [item] + (cond + (map? item) + (set (keys item)) + + (and (vector? item) (<= 4 (count item))) + #{(nth item 2)} + + :else + #{})) + +(deftest sanitize-tx-drops-migration-deleted-attrs-test + (testing "remote txs from older clients should not reintroduce attrs deleted by client migrations" + (let [conn (db-test/create-conn) + block-uuid #uuid "11111111-1111-1111-1111-111111111111" + tx-data [[:db/add [:block/uuid block-uuid] :block/title "remote title"] + [:db/add [:block/uuid block-uuid] :block/path-refs #uuid "22222222-2222-2222-2222-222222222222"] + [:db/retract [:block/uuid block-uuid] :block/pre-block? true] + [:db/add [:block/uuid block-uuid] :logseq.property.embedding/hnsw-label "label"] + [:db/retract [:block/uuid block-uuid] :logseq.property.embedding/hnsw-label-updated-at 123]] + sanitized (tx-sanitize/sanitize-tx @conn tx-data) + sanitized-attrs (set (mapcat tx-item-attrs sanitized))] + (is (empty? (set/intersection migration-deleted-attrs sanitized-attrs))) + (is (some #(= [:db/add [:block/uuid block-uuid] :block/title "remote title"] %) sanitized))))) + +(deftest sanitize-tx-drops-ignored-kv-entity-ops-test + (testing "remote txs should not apply KV entities that are excluded from sync" + (let [block-uuid #uuid "11111111-1111-1111-1111-111111111111" + conn (db-test/create-conn) + tx-data (into [[:db/add [:block/uuid block-uuid] :block/title "remote title"]] + graph-backup-folder-ops) + sanitized (tx-sanitize/sanitize-tx @conn tx-data)] + (is (empty? (set/intersection graph-backup-folder-ops (set sanitized)))) + (is (some #(= [:db/add [:block/uuid block-uuid] :block/title "remote title"] %) sanitized))))) + +(deftest sanitize-tx-drops-same-tx-ignored-kv-tempid-ops-test + (testing "remote txs should drop all ops for tempids identified as ignored KV entities" + (let [block-uuid #uuid "11111111-1111-1111-1111-111111111111" + conn (db-test/create-conn) + ignored-ops #{[:db/add "kv-temp" :db/ident :logseq.kv/graph-backup-folder] + [:db/add "kv-temp" :logseq.kv/value "/tmp/backup"] + [:db/retractEntity "kv-temp"]} + tx-data (into [[:db/add [:block/uuid block-uuid] :block/title "remote title"]] + ignored-ops) + sanitized (tx-sanitize/sanitize-tx @conn tx-data)] + (is (empty? (set/intersection ignored-ops (set sanitized)))) + (is (some #(= [:db/add [:block/uuid block-uuid] :block/title "remote title"] %) sanitized))))) + +(deftest sanitize-tx-drops-same-tx-ignored-kv-map-ops-test + (testing "remote txs should drop map-form ignored KV entities and following tempid ops" + (let [block-uuid #uuid "11111111-1111-1111-1111-111111111111" + conn (db-test/create-conn) + ignored-map {:db/id -1 + :db/ident :logseq.kv/graph-backup-folder + :logseq.kv/value "/tmp/backup"} + ignored-ops #{ignored-map + [:db/add -1 :logseq.kv/value "/tmp/backup-2"] + [:db/retractEntity -1]} + tx-data (into [[:db/add [:block/uuid block-uuid] :block/title "remote title"]] + ignored-ops) + sanitized (tx-sanitize/sanitize-tx @conn tx-data)] + (is (empty? (set/intersection ignored-ops (set sanitized)))) + (is (some #(= [:db/add [:block/uuid block-uuid] :block/title "remote title"] %) sanitized))))) + +(deftest sanitize-delete-blocks-retracts-property-value-children-test + (testing "delete-blocks adds retracts for generated property value children" + (let [conn (db-test/create-conn-with-blocks + {:properties {:user.property/cli-http-prop {:logseq.property/type :default}} + :pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Parent" + :build/properties + {:user.property/cli-http-prop + {:build/property-value :block + :block/title "Property value"}}}]}]}) + parent (db-test/find-block-by-content @conn "Parent") + property-value (db-test/find-block-by-content @conn "Property value") + tx-data [[:db/retractEntity [:block/uuid (:block/uuid parent)]]] + sanitized (tx-sanitize/sanitize-tx @conn + tx-data + {:drop-missing-retract-ops? true + :drop-ops-targeting-retracted-entities? true + :retract-touched-descendants? true})] + (is (= (:db/id property-value) + (:db/id (:user.property/cli-http-prop (d/entity @conn (:db/id parent)))))) + (is (some #(= [:db/retractEntity (:db/id property-value)] %) sanitized))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_auth_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_auth_test.cljs new file mode 100644 index 0000000..a34e3eb --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_auth_test.cljs @@ -0,0 +1,107 @@ +(ns logseq.db-sync.worker-auth-test + (:require [cljs.test :refer [async deftest is]] + [logseq.common.authorization :as authorization] + [logseq.db-sync.worker.auth :as auth] + [promesa.core :as p])) + +(deftest cognito-client-id-allowlist-test + (let [env #js {"COGNITO_CLIENT_ID" "web-client" + "COGNITO_CLIENT_IDS" "chatgpt-client, admin-client"}] + (is (authorization/client-id-allowed? env "web-client")) + (is (authorization/client-id-allowed? env "chatgpt-client")) + (is (authorization/client-id-allowed? env "admin-client")) + (is (not (authorization/client-id-allowed? env "unknown-client"))))) + +(deftest cognito-client-id-allowlist-fails-closed-test + (is (not (authorization/client-id-allowed? #js {} nil))) + (is (not (authorization/client-id-allowed? #js {} ""))) + (is (not (authorization/client-id-allowed? #js {"COGNITO_CLIENT_ID" ""} ""))) + (is (not (authorization/client-id-allowed? #js {"COGNITO_CLIENT_IDS" " , "} "chatgpt-client"))) + (is (not (authorization/client-id-allowed? #js {"COGNITO_CLIENT_ID" "web-client"} nil)))) + +(deftest auth-claims-uses-jwt-verification-test + (async done + (let [request (js/Request. "http://localhost/graphs" + #js {:headers #js {"authorization" "Bearer dev-token"}})] + (-> (p/with-redefs [authorization/verify-jwt + (fn [token _env] + (js/Promise.resolve #js {"sub" (str "jwt:" token)}))] + (p/let [claims (auth/auth-claims request #js {})] + (is (= "jwt:dev-token" (aget claims "sub"))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest auth-claims-without-token-returns-nil-test + (async done + (let [request (js/Request. "http://localhost/graphs")] + (-> (p/let [claims (auth/auth-claims request #js {})] + (is (nil? claims))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest auth-claims-expired-token-returns-nil-test + (async done + (let [request (js/Request. "http://localhost/graphs" + #js {:headers #js {"authorization" "Bearer expired-token"}})] + (-> (p/with-redefs [authorization/verify-jwt + (fn [_token _env] + (p/rejected (ex-info "exp" {})))] + (p/let [claims (auth/auth-claims request #js {})] + (is (nil? claims)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest auth-claims-jwks-error-propagates-test + (async done + (let [request (js/Request. "http://localhost/graphs" + #js {:headers #js {"authorization" "Bearer broken-token"}})] + (-> (p/with-redefs [authorization/verify-jwt + (fn [_token _env] + (p/rejected (ex-info "jwks" {})))] + (auth/auth-claims request #js {})) + (p/then (fn [_] + (is false "expected rejection when jwks fetch fails") + (done))) + (p/catch (fn [error] + (is (= "jwks" (ex-message error))) + (done))))))) + +(deftest auth-claims-jwks-error-falls-back-to-unsafe-claims-when-enabled-test + (async done + (let [token "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.signature" + request (js/Request. "http://localhost/graphs" + #js {:headers #js {"authorization" (str "Bearer " token)}}) + env #js {"DB_SYNC_ALLOW_UNVERIFIED_JWT_CLAIMS" "true"}] + (-> (p/with-redefs [authorization/verify-jwt + (fn [_token _env] + (p/rejected (ex-info "jwks" {})))] + (p/let [claims (auth/auth-claims request env)] + (is (= "u1" (aget claims "sub"))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest auth-claims-expired-jwt-short-circuits-verification-test + (async done + (let [expired-token "eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjEsInN1YiI6InUxIn0.signature" + request (js/Request. "http://localhost/graphs" + #js {:headers #js {"authorization" (str "Bearer " expired-token)}}) + verify-called? (atom false)] + (-> (p/with-redefs [authorization/verify-jwt + (fn [_token _env] + (reset! verify-called? true) + (p/rejected (ex-info "should-not-be-called" {})))] + (p/let [claims (auth/auth-claims request #js {})] + (is (nil? claims)) + (is (false? @verify-called?)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_dispatch_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_dispatch_test.cljs new file mode 100644 index 0000000..4ccaeef --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_dispatch_test.cljs @@ -0,0 +1,392 @@ +(ns logseq.db-sync.worker-dispatch-test + (:require [cljs.test :refer [async deftest is]] + [clojure.string :as string] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [logseq.db-sync.worker.auth :as auth] + [logseq.db-sync.worker.dispatch :as dispatch] + [logseq.db-sync.worker.handler.assets :as assets-handler] + [logseq.db-sync.worker.handler.index :as index-handler] + [logseq.db-sync.worker.http :as http] + [promesa.core :as p])) + +(defn- ok-json-response [] + (js/Response. (js/JSON.stringify #js {:ok true}) + #js {:status 200 + :headers #js {"content-type" "application/json"}})) + +(defn- make-do-namespace [] + #js {:idFromName (fn [_graph-id] "do-id") + :get (fn [_do-id] + #js {:fetch (fn [_request] + (js/Promise.resolve (ok-json-response)))})}) + +(declare capturing-do-namespace json-body) + +(deftest admin-token-bypasses-graph-access-check-for-sync-route-test + (async done + (let [access-check-calls (atom 0) + request (js/Request. "http://localhost/sync/graph-1/snapshot/download" + #js {:method "GET" + :headers #js {"x-db-sync-admin-token" "test-admin-token"}}) + env #js {"DB_SYNC_ADMIN_TOKEN" "test-admin-token" + "LOGSEQ_SYNC_DO" (make-do-namespace)}] + (-> (p/with-redefs [index-handler/graph-access-response (fn [_request _env _graph-id] + (swap! access-check-calls inc) + (p/resolved (http/unauthorized)))] + (p/let [resp (dispatch/handle-worker-fetch request env) + text (.text resp) + body (js->clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= true (:ok body))) + (is (= 0 @access-check-calls)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest public-sync-route-rejects-semantic-tail-without-touching-durable-object-test + (async done + (let [forwarded (atom []) + request (js/Request. "http://localhost/sync/graph-1/semantic/pages" + #js {:method "GET"}) + env #js {"LOGSEQ_SYNC_DO" (capturing-do-namespace forwarded)}] + (-> (p/with-redefs [index-handler/graph-access-response + (fn [_request _env _graph-id] + (p/resolved (ok-json-response)))] + (p/let [response (dispatch/handle-worker-fetch request env) + body (json-body response)] + (is (= 404 (.-status response))) + (is (= "not found" (:error body))) + (is (empty? @forwarded)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest public-sync-route-still-forwards-legacy-snapshot-request-test + (async done + (let [forwarded (atom []) + request (js/Request. "http://localhost/sync/graph-1/snapshot/download" + #js {:method "GET"}) + env #js {"LOGSEQ_SYNC_DO" (capturing-do-namespace forwarded)}] + (-> (p/with-redefs [index-handler/graph-access-response + (fn [_request _env _graph-id] + (p/resolved (ok-json-response)))] + (p/let [response (dispatch/handle-worker-fetch request env)] + (is (= 200 (.-status response))) + (is (= 1 (count @forwarded))) + (is (= "/snapshot/download" + (.-pathname (js/URL. (.-url (first @forwarded)))))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(defn- json-body [response] + (p/let [text (.text response)] + (js->clj (js/JSON.parse text) :keywordize-keys true))) + +(defn- semantic-request + ([path scope] + (semantic-request path scope "GET")) + ([path scope method] + {:request (js/Request. (str "http://localhost" path) + #js {:method method + :headers #js {"authorization" "Bearer semantic-token"}}) + :claims #js {"sub" "user-1" "scope" scope}})) + +(defn- graph-row [e2ee?] + #js {:graph_e2ee (if e2ee? 1 0)}) + +(defn- rate-limiter [success? calls] + #js {:limit (fn [opts] + (swap! calls conj (js->clj opts :keywordize-keys true)) + (p/resolved #js {:success success?}))}) + +(defn- capturing-do-namespace [requests] + #js {:idFromName (fn [graph-id] graph-id) + :get (fn [_] + #js {:fetch (fn [request] + (swap! requests conj request) + (p/resolved (ok-json-response)))})}) + +(deftest openapi-document-describes-semantic-api-and-oauth-scopes-test + (async done + (-> (p/let [response (dispatch/handle-worker-fetch + (js/Request. "http://localhost/openapi.json") + #js {}) + body (json-body response)] + (is (= 200 (.-status response))) + (is (= "3.1.0" (:openapi body))) + (is (= ["logseq/read"] + (get-in body [:paths (keyword "/api/v1/graphs") :get :security 0 :oauth]))) + (is (= ["logseq/read"] + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/pages") :get :security 0 :oauth]))) + (is (= ["logseq/write"] + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/blocks/{block-id}") :patch :security 0 :oauth]))) + (is (= ["logseq/write"] + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/pages/{page-id}") :delete :security 0 :oauth]))) + (doseq [path ["/api/v1/graphs/{graph-id}/pages/{page-id}" + "/api/v1/graphs/{graph-id}/tags/{tag-id}" + "/api/v1/graphs/{graph-id}/properties/{property-id}"]] + (is (= #{:get :patch :delete} (set (keys (get-in body [:paths (keyword path)])))))) + (is (= ["logseq/write"] + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/blocks/{block-id}/properties/{property-id}") + :delete :security 0 :oauth]))) + (let [description (->> (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/blocks/{block-id}/properties/{property-id}") + :put :parameters]) + (filter #(= "property-id" (:name %))) first :description)] + (is (and (string? description) + (string/includes? description "exact title")))) + (is (= ["logseq/write"] + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/block-properties/batch-delete") + :post :security 0 :oauth]))) + (is (= ["block-ids" "target-id" "position"] + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/block-moves") + :post :requestBody :content :application/json :schema :required]))) + (is (= "array" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/block-moves") + :post :requestBody :content :application/json :schema + :properties :block-ids :type]))) + (is (= "#/components/schemas/BlockResponse" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/pages/{page-id}/blocks") + :get :responses :200 :content :application/json :schema + :properties :blocks :items :$ref]))) + (is (= "#/components/schemas/BlockResponse" + (get-in body [:components :schemas :BlockResponse :properties :children :items :$ref]))) + (is (= "listTasks" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/tasks") :get :operationId]))) + (is (= "createTask" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/tasks") :post :operationId]))) + (is (= ["todo" "doing" "in-review" "done" "canceled" "backlog"] + (get-in body [:components :schemas :TaskStatusSelector :anyOf 0 :enum]))) + (is (= "#/components/schemas/EntitySelector" + (get-in body [:components :schemas :TaskStatusSelector :anyOf 1 :$ref]))) + (is (= "#/components/schemas/PropertyChoice" + (get-in body [:components :schemas :TaskResponse :allOf 1 :properties :status :$ref]))) + (is (= "#/components/schemas/TaskResponse" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/tasks") + :get :responses :200 :content :application/json :schema + :properties :tasks :items :$ref]))) + (is (= "#/components/schemas/TaskResponse" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/tasks") + :post :responses :201 :content :application/json :schema :$ref]))) + (is (= "#/components/schemas/PropertyResponse" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/properties/{property-id}") + :get :responses :200 :content :application/json :schema :$ref]))) + (is (= ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset" + "map" "json" "string"] + (get-in body [:components :schemas :PropertyType :enum]))) + (is (= "#/components/schemas/PropertyType" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/properties") + :post :requestBody :content :application/json :schema + :properties :type :$ref]))) + (doseq [[path response-key schema] + [["/api/v1/graphs/{graph-id}/pages" :pages "PageResponse"] + ["/api/v1/graphs/{graph-id}/tags" :tags "TagResponse"] + ["/api/v1/graphs/{graph-id}/properties" :properties "PropertyResponse"] + ["/api/v1/graphs/{graph-id}/search" :results "SearchResultResponse"]]] + (is (= (str "#/components/schemas/" schema) + (get-in body [:paths (keyword path) :get :responses :200 + :content :application/json :schema :properties + response-key :items :$ref])))) + (is (= "#/components/schemas/PropertyChoice" + (get-in body [:components :schemas :PropertyResponse :properties + :choices :items :$ref]))) + (is (= "#/components/schemas/PropertyValue" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/blocks/{block-id}/properties/{property-id}") + :put :requestBody :content :application/json :schema + :properties :value :$ref]))) + (is (= {:type "boolean" :default false} + (select-keys + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/block-properties/batch-set") + :post :requestBody :content :application/json :schema + :properties :isResetExistingValues]) + [:type :default]))) + (is (string/includes? + (get-in body [:components :schemas :PropertyValue :description]) + "UUID, ident, or title")) + (is (string/includes? + (get-in body [:components :schemas :PropertyValue :description]) + "TODO")) + (doseq [path ["/api/v1/graphs/{graph-id}/pages" + "/api/v1/graphs/{graph-id}/tasks" + "/api/v1/graphs/{graph-id}/tags" + "/api/v1/graphs/{graph-id}/tags/{tag-id}/objects" + "/api/v1/graphs/{graph-id}/properties" + "/api/v1/graphs/{graph-id}/assets" + "/api/v1/graphs/{graph-id}/search"]] + (is (= #{"created-after" "updated-after"} + (->> (get-in body [:paths (keyword path) :get :parameters]) + (map :name) + (filter #{"created-after" "updated-after"}) + set)) + path)) + (is (= "listAssets" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets") :get :operationId]))) + (is (= "createAsset" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets") :post :operationId]))) + (is (string/includes? + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets/{asset-block-id}") + :get :description]) + "display image")) + (is (= "binary" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets") :post + :requestBody :content :application/octet-stream :schema :format]))) + (is (= #{"file-name" "size" "title" "page-id" "checksum" "encoding"} + (->> (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets") + :post :parameters]) + (map :name) + (filter #{"file-name" "size" "title" "page-id" "checksum" "encoding"}) + set))) + (is (= "#/components/schemas/AssetResponse" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets") :get + :responses :200 :content :application/json :schema + :properties :assets :items :$ref]))) + (is (= "#/components/schemas/AssetResponse" + (get-in body [:paths (keyword "/api/v1/graphs/{graph-id}/assets") :post + :responses :201 :content :application/json :schema :$ref]))) + (doseq [[path method] [["/api/v1/graphs/{graph-id}/properties" :post] + ["/api/v1/graphs/{graph-id}/properties/{property-id}" :patch] + ["/api/v1/graphs/{graph-id}/blocks/{block-id}/properties/{property-id}" :put] + ["/api/v1/graphs/{graph-id}/block-properties/batch-set" :post]]] + (let [description (get-in body [:paths (keyword path) method :description])] + (is (string/includes? description "DB graph")) + (is (string/includes? description "typed")) + (is (string/includes? description "key:: value")))) + (doseq [[_ path-operations] (:paths body) + [_ operation] path-operations] + (is (seq (:summary operation))) + (is (seq (:description operation))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done)))))) + +(deftest semantic-api-lists-non-e2ee-graphs-with-pagination-test + (async done + (let [{:keys [request claims]} (semantic-request "/api/v1/graphs?name=test-mcp&limit=1" "logseq/read") + calls (atom []) + limit-calls (atom []) + env #js {"DB" #js {} + "SEMANTIC_READ_RATE_LIMITER" (rate-limiter true limit-calls)}] + (-> (p/with-redefs [auth/auth-claims (fn [_ _] (p/resolved claims)) + index/ (p/with-redefs [auth/auth-claims (fn [_ _] (p/resolved claims))] + (p/let [response (dispatch/handle-worker-fetch request #js {}) + body (json-body response)] + (is (= 403 (.-status response))) + (is (= "insufficient scope" (:error body))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest semantic-api-rejects-e2ee-graphs-before-durable-object-test + (async done + (let [{:keys [request claims]} (semantic-request "/api/v1/graphs/graph-1/pages" "logseq/read") + forwarded (atom []) + env #js {"DB" #js {} + "LOGSEQ_SYNC_DO" (capturing-do-namespace forwarded) + "SEMANTIC_READ_RATE_LIMITER" (rate-limiter true (atom []))}] + (-> (p/with-redefs [auth/auth-claims (fn [_ _] (p/resolved claims)) + index-handler/graph-access-response (fn [_ _ _] (p/resolved (ok-json-response))) + common/ (p/with-redefs [auth/auth-claims (fn [_ _] (p/resolved claims)) + index-handler/graph-access-response (fn [_ _ _] (p/resolved (ok-json-response))) + common/ (p/with-redefs [auth/auth-claims (fn [_ _] (p/resolved claims)) + index-handler/graph-access-response (fn [_ _ _] (p/resolved (ok-json-response))) + common/ forwarded-request .-url js/URL.)] + (is (= 200 (.-status response))) + (is (= 1 (count @forwarded))) + (when forwarded-url + (is (= "/semantic/pages" (.-pathname forwarded-url))) + (is (= "10" (.get (.-searchParams forwarded-url) "limit"))) + (is (= "graph-1" (.get (.-searchParams forwarded-url) "graph-id")))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest admin-token-bypasses-graph-access-check-for-assets-route-test + (async done + (let [access-check-calls (atom 0) + request (js/Request. "http://localhost/assets/graph-1/snapshot-1.snapshot" + #js {:method "GET" + :headers #js {"x-db-sync-admin-token" "test-admin-token"}}) + env #js {"DB_SYNC_ADMIN_TOKEN" "test-admin-token"}] + (-> (p/with-redefs [index-handler/graph-access-response (fn [_request _env _graph-id] + (swap! access-check-calls inc) + (p/resolved (http/unauthorized))) + assets-handler/handle (fn [_request _env] + (ok-json-response))] + (p/let [resp (dispatch/handle-worker-fetch request env) + text (.text resp) + body (js->clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= true (:ok body))) + (is (= 0 @access-check-calls)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_handler_assets_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_handler_assets_test.cljs new file mode 100644 index 0000000..a9c230b --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_handler_assets_test.cljs @@ -0,0 +1,144 @@ +(ns logseq.db-sync.worker-handler-assets-test + (:require [cljs.test :refer [async deftest is]] + [logseq.db-sync.index :as index] + [logseq.db-sync.worker.handler.assets :as assets] + [promesa.core :as p])) + +(defn- bytes->stream + [^js payload] + (js/ReadableStream. + #js {:start (fn [controller] + (.enqueue controller payload) + (.close controller))})) + +(defn- put-request-with-size + [size] + #js {:url "http://localhost/assets/graph-1/asset-1.bin" + :method "PUT" + :headers #js {:get (constantly nil)} + :arrayBuffer (fn [] (p/resolved #js {:byteLength size}))}) + +(deftest encrypted-assets-allow-upload-at-larger-limit-test + (async done + (let [request (put-request-with-size (* 200 1024 1024)) + put-calls (atom 0) + env #js {:DB #js {} + :LOGSEQ_SYNC_ASSETS + #js {:put (fn [& _] + (swap! put-calls inc) + (p/resolved nil))}}] + (-> (p/with-redefs [index/ (p/with-redefs [index/ (p/with-redefs [index/ (p/let [resp (assets/handle request env)] + (is (= 200 (.-status resp))) + (is (= "4" (.get (.-headers resp) "content-length"))) + (is (= "4" (.get (.-headers resp) "x-asset-size")))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest assets-get-includes-content-length-with-stream-body-without-fixed-length-stream-test + (async done + (let [payload (js/Uint8Array. #js [1 2 3 4]) + request (js/Request. "http://localhost/assets/graph-1/snapshot-1.snapshot" + #js {:method "GET"}) + env #js {:LOGSEQ_SYNC_ASSETS + #js {:get (fn [_key] + (js/Promise.resolve + #js {:body (bytes->stream payload) + :size 4 + :httpMetadata #js {:contentType "application/octet-stream"}}))}} + original-fixed-length-stream (.-FixedLengthStream js/globalThis) + restore! #(aset js/globalThis "FixedLengthStream" original-fixed-length-stream)] + (aset js/globalThis "FixedLengthStream" js/undefined) + (-> (p/let [resp (assets/handle request env) + buf (.arrayBuffer resp)] + (is (= 200 (.-status resp))) + (is (= "4" (.get (.-headers resp) "content-length"))) + (is (= "4" (.get (.-headers resp) "x-asset-size"))) + (is (= 4 (.-byteLength buf)))) + (p/then (fn [] + (restore!) + (done))) + (p/catch (fn [error] + (restore!) + (is false (str error)) + (done))))))) + +(deftest assets-get-serves-image-inline-with-extension-content-type-test + (async done + (let [payload (js/Uint8Array. #js [1 2 3 4]) + request (js/Request. "http://localhost/assets/graph-1/image-id.png" + #js {:method "GET"}) + env #js {:LOGSEQ_SYNC_ASSETS + #js {:get (fn [_key] + (p/resolved + #js {:body payload + :size 4 + ;; Existing Code Mode uploads stored the transfer MIME type. + :httpMetadata #js {:contentType "application/json"}}))}}] + (-> (p/let [resp (assets/handle request env)] + (is (= 200 (.-status resp))) + (is (= "image/png" (.get (.-headers resp) "content-type"))) + (is (= "inline; filename=\"image-id.png\"" + (.get (.-headers resp) "content-disposition")))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_handler_index_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_handler_index_test.cljs new file mode 100644 index 0000000..48789e0 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_handler_index_test.cljs @@ -0,0 +1,260 @@ +(ns logseq.db-sync.worker-handler-index-test + (:require [cljs.test :refer [async deftest is]] + [clojure.string :as string] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [logseq.db-sync.worker.auth :as auth] + [logseq.db-sync.worker.handler.index :as index-handler] + [promesa.core :as p])) + +(deftest graph-access-response-with-timing-caches-result-test + (async done + (let [request (js/Request. "http://localhost/sync/graph-1" + #js {:headers #js {"authorization" "Bearer token-cache-hit"}}) + env #js {"DB" #js {}} + auth-count (atom 0) + query-count (atom 0)] + (-> (p/with-redefs [auth/auth-claims (fn [_request _env] + (swap! auth-count inc) + (p/resolved #js {"sub" "user-1"})) + index/ (p/with-redefs [auth/auth-claims (fn [_request _env] + (p/resolved #js {"sub" "user-2"})) + index/ (p/with-redefs [common/read-json (fn [_] + (p/resolved #js {"graph-name" "Graph 1" + "schema-version" "65"})) + index/clj (js/JSON.parse text) :keywordize-keys true) + graph-insert (first @d1-runs)] + (is (= 200 (.-status resp))) + (is (string/includes? (:sql graph-insert) "graph_ready_for_use")) + (is (= 1 (nth (:args graph-insert) 5))) + (is (= true (:graph-ready-for-use? body))))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest graphs-list-includes-user-rsa-keys-exists-flag-true-test + (async done + (let [request (js/Request. "http://localhost/graphs" #js {:method "GET"}) + url (js/URL. (.-url request))] + (-> (p/with-redefs [index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= [] (:graphs body))) + (is (= true (:user-rsa-keys-exists? body))))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest graphs-list-includes-user-rsa-keys-exists-flag-false-test + (async done + (let [request (js/Request. "http://localhost/graphs" #js {:method "GET"}) + url (js/URL. (.-url request))] + (-> (p/with-redefs [index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= [] (:graphs body))) + (is (= false (:user-rsa-keys-exists? body))))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest graphs-create-e2ee-requires-user-rsa-key-pair-test + (async done + (let [request (js/Request. "http://localhost/graphs" #js {:method "POST"}) + url (js/URL. (.-url request)) + index-upsert-calls* (atom 0)] + (-> (p/with-redefs [common/read-json (fn [_] + (p/resolved #js {"graph-name" "Graph E2EE" + "schema-version" "65" + "graph-e2ee?" true})) + index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 400 (.-status resp))) + (is (= "missing user rsa key pair" (:error body))) + (is (zero? @index-upsert-calls*)))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest graphs-create-non-e2ee-skips-user-rsa-key-pair-test + (async done + (let [request (js/Request. "http://localhost/graphs" #js {:method "POST"}) + url (js/URL. (.-url request)) + index-upsert-calls* (atom 0)] + (-> (p/with-redefs [common/read-json (fn [_] + (p/resolved #js {"graph-name" "Graph Plain" + "schema-version" "65" + "graph-e2ee?" false})) + index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (string? (:graph-id body))) + (is (= false (:graph-e2ee? body))) + (is (= 1 @index-upsert-calls*)))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest graphs-delete-supports-node-delete-hook-without-do-namespace-test + (async done + (let [request (js/Request. "http://localhost/graphs/graph-1" #js {:method "DELETE"}) + url (js/URL. (.-url request)) + hook-calls* (atom [])] + (-> (p/with-redefs [index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= {:graph-id "graph-1" :deleted true} body)) + (is (= ["graph-1"] @hook-calls*)))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs new file mode 100644 index 0000000..3bb9c52 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs @@ -0,0 +1,3041 @@ +(ns logseq.db-sync.worker-handler-sync-test + (:require ["better-sqlite3" :as sqlite3] + [cljs.test :refer [async deftest is testing]] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db-sync.checksum :as sync-checksum] + [logseq.db-sync.common :as common] + [logseq.db-sync.index :as index] + [logseq.db-sync.protocol :as protocol] + [logseq.db-sync.snapshot :as snapshot] + [logseq.db-sync.storage :as storage] + [logseq.db-sync.test-sql :as test-sql] + [logseq.db-sync.worker.handler.sync :as sync-handler] + [logseq.db-sync.worker.handler.semantic :as semantic-handler] + [logseq.db-sync.worker.asset-link :as asset-link] + [logseq.db-sync.worker.ws :as ws] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.sqlite.export :as sqlite-export] + [logseq.outliner.core :as outliner-core] + [logseq.outliner.page :as outliner-page] + [logseq.outliner.property :as outliner-property] + [promesa.core :as p])) + +(def sqlite (if (find-ns 'nbb.core) (aget sqlite3 "default") sqlite3)) + +(defn- select-sql? + [sql] + (string/starts-with? (-> sql string/trim string/lower-case) "select")) + +(defn- run-sql + [^js stmt args] + (.apply (.-run stmt) stmt (to-array args))) + +(defn- all-sql + [^js stmt args] + (.apply (.-all stmt) stmt (to-array args))) + +(defn- with-memory-sql + [f] + (let [db (new sqlite ":memory:" nil) + sql #js {:_db db + :exec (fn [sql-str & args] + (let [stmt (.prepare db sql-str)] + (if (select-sql? sql-str) + (all-sql stmt args) + (do + (run-sql stmt args) + nil)))) + :close (fn [] + (.close db))}] + (try + (f sql) + (finally + (.close sql))))) + +(defn- with-memory-sql-async [f] + (let [db (new sqlite ":memory:" nil) + sql #js {:_db db + :exec (fn [sql-str & args] + (let [stmt (.prepare db sql-str)] + (if (select-sql? sql-str) + (all-sql stmt args) + (do (run-sql stmt args) nil)))) + :close (fn [] (.close db))}] + (-> (f sql) + (p/finally #(.close sql))))) + +(defn- semantic-json-request [path method body] + (js/Request. (str "http://localhost" path) + (clj->js (cond-> {:method method + :headers {"content-type" "application/json"}} + body (assoc :body (js/JSON.stringify (clj->js body))))))) + +(defn- json-body [response] + (p/let [text (.text response)] + (js->clj (js/JSON.parse text) :keywordize-keys true))) + +(deftest semantic-create-page-delegates-to-outliner-and-broadcasts-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [self #js {:sql sql :conn (storage/open-conn sql) :schema-ready true} + calls (atom []) + broadcasts (atom []) + page-id (random-uuid) + request (semantic-json-request "/semantic/pages?graph-id=graph-1" "POST" {:title "Inbox"})] + (-> (p/with-redefs [outliner-page/create! (fn [conn title opts] + (swap! calls conj [conn title opts]) + [title page-id]) + ws/broadcast! (fn [_ sender message] + (swap! broadcasts conj [sender message]))] + (p/let [response (sync-handler/handle-http self request) + body (json-body response)] + (is (= 201 (.-status response))) + (is (= "Inbox" (:title body))) + (is (= (str page-id) (:uuid body))) + (is (= 1 (count @calls))) + (is (= "Inbox" (second (first @calls)))) + (is (= 1 (count @broadcasts))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-delete-page-delegates-to-outliner-and-broadcasts-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [self #js {:sql sql :conn (storage/open-conn sql) :schema-ready true} + page-id (random-uuid) + _ (d/transact! (.-conn self) [{:db/ident :logseq.class/Page} + {:block/uuid page-id :block/name "inbox" :block/title "Inbox" + :block/tags :logseq.class/Page}]) + calls (atom []) + broadcasts (atom []) + request (semantic-json-request (str "/semantic/pages/" page-id "?graph-id=graph-1") "DELETE" nil)] + (-> (p/with-redefs [outliner-page/delete! (fn [conn id] + (swap! calls conj [conn id])) + ws/broadcast! (fn [_ sender message] + (swap! broadcasts conj [sender message]))] + (p/let [response (sync-handler/handle-http self request)] + (is (= 204 (.-status response))) + (is (= [[(.-conn self) page-id]] @calls)) + (is (= 1 (count @broadcasts))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-block-write-routes-delegate-to-outliner-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [self #js {:sql sql :conn (storage/open-conn sql) :schema-ready true} + page-id (random-uuid) + block-id (random-uuid) + reference-id (random-uuid) + created-reference-id (random-uuid) + _ (d/transact! (.-conn self) [{:db/ident :logseq.class/Page} + {:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page} + {:block/uuid reference-id :block/name "reference" :block/title "Reference" + :block/tags :logseq.class/Page} + {:block/uuid block-id :block/title "Existing" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "a0"}]) + calls (atom []) + requests [(semantic-json-request (str "/semantic/blocks/" page-id "/children?graph-id=graph-1") "POST" + {:position "append" + :blocks [{:title (str "New block links to [[Reference]], [[New Reference]], and [[" + block-id "]]") + :children [{:title "Nested block links to [[Reference]]"}]}]}) + (semantic-json-request (str "/semantic/blocks/" block-id "?graph-id=graph-1") "PATCH" + {:title "Edited block links to [[Reference]]"}) + (semantic-json-request (str "/semantic/blocks/" block-id "?graph-id=graph-1") "DELETE" nil)]] + (-> (p/with-redefs [outliner-core/insert-blocks! (fn [_ blocks target opts] + (swap! calls conj [:insert blocks target opts]) + {:tx-data []}) + outliner-core/save-block! (fn [_ block & opts] + (swap! calls conj [:save block opts]) + {:tx-data []}) + outliner-core/delete-blocks! (fn [_ blocks opts] + (swap! calls conj [:delete blocks opts]) + {:tx-data []}) + outliner-page/create! (fn [conn title _] + (d/transact! conn [{:block/uuid created-reference-id + :block/name "new reference" + :block/title title + :block/tags :logseq.class/Page}]) + [title created-reference-id]) + ws/broadcast! (fn [& _] nil)] + (p/let [responses (p/all (map #(sync-handler/handle-http self %) requests)) + insert-body (json-body (first responses))] + (is (= [201 200 204] (mapv #(.-status %) responses))) + (is (uuid? (some-> (get-in insert-body [:blocks 0 :children 0 :uuid]) uuid))) + (let [[[_ inserted-blocks _ _] [_ nested-blocks _ _]] (filter #(= :insert (first %)) @calls) + [_ saved-block _] (first (filter #(= :save (first %)) @calls)) + expected-refs #{reference-id created-reference-id block-id}] + (is (= (str "New block links to [[" reference-id "]], [[" created-reference-id + "]], and [[" block-id "]]") + (:block/title (first inserted-blocks)))) + (is (= (set (map #(vector :block/uuid %) expected-refs)) + (set (:block/refs (first inserted-blocks))))) + (is (= (str "Nested block links to [[" reference-id "]]") + (:block/title (first nested-blocks)))) + (is (= #{[:block/uuid reference-id]} (set (:block/refs (first nested-blocks))))) + (is (= (str "Edited block links to [[" reference-id "]]") + (:block/title saved-block))) + (is (= #{[:block/uuid reference-id]} (set (:block/refs saved-block))))) + (is (= #{:insert :save :delete} (set (map first @calls)))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-read-routes-return-pages-page-tree-and-search-results-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-id (random-uuid) + block-id (random-uuid) + child-id (random-uuid) + hidden-id (random-uuid) + second-root-id (random-uuid) + _ (d/transact! conn [{:db/ident :logseq.class/Page} + {:block/uuid page-id + :block/name "inbox" + :block/title "Inbox" + :block/tags :logseq.class/Page} + {:block/uuid block-id + :block/title "Review roadmap" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "a0"} + {:block/uuid child-id + :block/title "Child block" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid block-id] + :block/order "a0"} + {:block/uuid hidden-id + :block/title "Hidden roadmap" + :logseq.property/hide? true + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "00"} + {:block/uuid second-root-id + :block/title "Second root" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "a1"}]) + self #js {:sql sql :conn conn :schema-ready true} + requests [(semantic-json-request "/semantic/pages?graph-id=graph-1" "GET" nil) + (semantic-json-request (str "/semantic/pages/" page-id "/blocks?graph-id=graph-1&limit=1") "GET" nil) + (semantic-json-request "/semantic/search?graph-id=graph-1&q=roadmap&types=blocks" "GET" nil) + (semantic-json-request "/semantic/search?graph-id=graph-1&q=roadmap" "GET" nil) + (semantic-json-request "/semantic/search?graph-id=graph-1&q=roadmap&types=" "GET" nil)]] + (-> (p/let [responses (p/all (map #(sync-handler/handle-http self %) requests)) + bodies (p/all (map json-body responses))] + (is (= [200 200 200 200 200] (mapv #(.-status %) responses)) (pr-str bodies)) + (is (= "Inbox" (get-in bodies [0 :blocks 0 :title]))) + (is (= "Review roadmap" (get-in bodies [1 :blocks 0 :title]))) + (is (= "Child block" (get-in bodies [1 :blocks 0 :children 0 :title]))) + (is (string? (get-in bodies [1 :next-cursor]))) + (doseq [index [2 3 4]] + (is (= (str block-id) (get-in bodies [index :results 0 :uuid]))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-page-blocks-only-expands-paginated-roots-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-id (random-uuid) + root-a-id (random-uuid) + root-b-id (random-uuid) + child-a-id (random-uuid) + child-b-id (random-uuid) + _ (d/transact! conn [{:db/ident :logseq.class/Page} + {:block/uuid page-id + :block/name "inbox" + :block/title "Inbox" + :block/tags :logseq.class/Page} + {:block/uuid root-a-id + :block/title "Root A" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "a0"} + {:block/uuid child-a-id + :block/title "Child A" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid root-a-id] + :block/order "a0"} + {:block/uuid root-b-id + :block/title "Root B" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "b0"} + {:block/uuid child-b-id + :block/title "Child B" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid root-b-id] + :block/order "a0"}]) + url (js/URL. (str "https://example.test/semantic/pages/" page-id + "/blocks?graph-id=graph-1&limit=1")) + response (#'semantic-handler/paginated-page-blocks-response + @conn + (d/entity @conn [:block/uuid page-id]) + url)] + (-> (p/let [body (json-body response)] + (is (= 200 (.-status response))) + (is (= 1 (count (:blocks body)))) + (is (= (str root-a-id) (get-in body [:blocks 0 :uuid]))) + (is (= "Child A" (get-in body [:blocks 0 :children 0 :title]))) + (is (string? (:next-cursor body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-search-results-remain-lazy-before-pagination-test + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql)] + (d/transact! conn [{:db/ident :logseq.class/Page} + {:block/uuid (random-uuid) + :block/title "Roadmap one" + :block/tags :logseq.class/Page} + {:block/uuid (random-uuid) + :block/title "Roadmap two" + :block/tags :logseq.class/Page}]) + (is (not (vector? (#'semantic-handler/search-results @conn "roadmap" "blocks")))))))) + +(deftest semantic-move-blocks-moves-all-addressed-blocks-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-id (random-uuid) + block-ids [(random-uuid) (random-uuid)] + target-id (random-uuid) + _ (d/transact! conn + (into [{:db/ident :logseq.class/Page} + {:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page}] + (map-indexed + (fn [index block-id] + {:block/uuid block-id + :block/title (str "Block " index) + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order (str "a" index)}) + (conj block-ids target-id)))) + self #js {:sql sql :conn conn :schema-ready true} + calls (atom []) + valid-request (semantic-json-request "/semantic/block-moves?graph-id=graph-1" "POST" + {:block-ids (mapv str block-ids) + :target-id (str target-id) + :position "last-child"}) + missing-request (semantic-json-request "/semantic/block-moves?graph-id=graph-1" "POST" + {:block-ids [(str (random-uuid))] + :target-id (str target-id) + :position "last-child"})] + (-> (p/with-redefs [outliner-core/move-blocks! (fn [_ blocks target opts] + (swap! calls conj [blocks target opts])) + ws/broadcast! (fn [& _] nil)] + (p/let [valid-response (sync-handler/handle-http self valid-request) + valid-body (json-body valid-response) + missing-response (sync-handler/handle-http self missing-request)] + (is (= 200 (.-status valid-response))) + (is (= (mapv str block-ids) (:uuids valid-body))) + (is (= 400 (.-status missing-response))) + (is (= 1 (count @calls))) + (let [[blocks target opts] (first @calls)] + (is (= block-ids (mapv :block/uuid blocks))) + (is (= target-id (:block/uuid target))) + (is (= {:sibling? false :bottom? true} opts))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-collection-routes-use-cursor-pagination-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + pages (mapv (fn [title] + {:block/uuid (random-uuid) + :block/name (string/lower-case title) + :block/title title + :block/tags :logseq.class/Page}) + ["Alpha" "中文页面" "日本語ページ"]) + _ (d/transact! conn (into [{:db/ident :logseq.class/Page}] pages)) + self #js {:sql sql :conn conn :schema-ready true} + first-request (semantic-json-request "/semantic/pages?graph-id=graph-1&limit=2" "GET" nil)] + (-> (p/let [first-response (sync-handler/handle-http self first-request) + first-body (json-body first-response) + cursor (:next-cursor first-body) + second-response (sync-handler/handle-http + self + (semantic-json-request + (str "/semantic/pages?graph-id=graph-1&limit=2&cursor=" + (js/encodeURIComponent cursor)) + "GET" nil)) + second-body (json-body second-response)] + (is (= 2 (count (:blocks first-body)))) + (is (string? cursor)) + (is (= 1 (count (:blocks second-body)))) + (is (= #{"Alpha" "中文页面" "日本語ページ"} + (set (map :title (concat (:blocks first-body) (:blocks second-body)))))) + (is (nil? (:next-cursor second-body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-list-routes-filter-by-created-and-updated-time-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + old-page-id (random-uuid) + new-page-id (random-uuid) + target-tag-id (random-uuid) + old-tag-id (random-uuid) + new-tag-id (random-uuid) + old-property-id (random-uuid) + new-property-id (random-uuid) + old-object-id (random-uuid) + new-object-id (random-uuid) + old-task-id (random-uuid) + new-task-id (random-uuid) + _ (d/transact! + conn + [{:block/uuid old-page-id :block/name "old timed page" :block/title "Old timed page" + :block/tags :logseq.class/Page :block/created-at 1000 :block/updated-at 1000} + {:block/uuid new-page-id :block/name "new timed page" :block/title "New timed page" + :block/tags :logseq.class/Page :block/created-at 3000 :block/updated-at 3000} + {:db/ident :user.class/TimeTarget :block/uuid target-tag-id + :block/name "time target" :block/title "Time target" + :block/tags :logseq.class/Tag :block/created-at 3000 :block/updated-at 3000} + {:db/ident :user.class/OldTimed :block/uuid old-tag-id + :block/name "old timed tag" :block/title "Old timed tag" + :block/tags :logseq.class/Tag :block/created-at 1000 :block/updated-at 1000} + {:db/ident :user.class/NewTimed :block/uuid new-tag-id + :block/name "new timed tag" :block/title "New timed tag" + :block/tags :logseq.class/Tag :block/created-at 3000 :block/updated-at 3000} + {:db/ident :user.property/old-timed :block/uuid old-property-id + :block/name "old timed property" :block/title "Old timed property" + :block/tags :logseq.class/Property :logseq.property/type :default + :db/cardinality :db.cardinality/one :block/created-at 1000 :block/updated-at 1000} + {:db/ident :user.property/new-timed :block/uuid new-property-id + :block/name "new timed property" :block/title "New timed property" + :block/tags :logseq.class/Property :logseq.property/type :default + :db/cardinality :db.cardinality/one :block/created-at 3000 :block/updated-at 3000} + {:block/uuid old-object-id :block/title "Old timed object" + :block/page [:block/uuid old-page-id] :block/parent [:block/uuid old-page-id] + :block/order "a0" :block/tags :user.class/TimeTarget + :block/created-at 1000 :block/updated-at 1000} + {:block/uuid new-object-id :block/title "New timed object" + :block/page [:block/uuid new-page-id] :block/parent [:block/uuid new-page-id] + :block/order "a0" :block/tags :user.class/TimeTarget + :block/created-at 3000 :block/updated-at 3000} + {:block/uuid old-task-id :block/title "Old timed task" + :block/page [:block/uuid old-page-id] :block/parent [:block/uuid old-page-id] + :block/order "a1" :block/tags :logseq.class/Task + :logseq.property/status :logseq.property/status.todo + :block/created-at 1000 :block/updated-at 1000} + {:block/uuid new-task-id :block/title "New timed task" + :block/page [:block/uuid new-page-id] :block/parent [:block/uuid new-page-id] + :block/order "a1" :block/tags :logseq.class/Task + :logseq.property/status :logseq.property/status.todo + :block/created-at 3000 :block/updated-at 3000}]) + self #js {:sql sql :conn conn :schema-ready true} + paths [(str "/semantic/pages?graph-id=graph-1&created-after=2000") + (str "/semantic/tasks?graph-id=graph-1&updated-after=2000") + (str "/semantic/tags?graph-id=graph-1&created-after=2000") + (str "/semantic/tags/" target-tag-id "/objects?graph-id=graph-1&updated-after=2000") + (str "/semantic/properties?graph-id=graph-1&created-after=2000") + (str "/semantic/search?graph-id=graph-1&q=timed&types=blocks&updated-after=2000")] + keys [:blocks :tasks :tags :objects :properties :results]] + (-> (p/let [responses (p/all (map #(sync-handler/handle-http + self (semantic-json-request % "GET" nil)) paths)) + bodies (p/all (map json-body responses)) + invalid-response (sync-handler/handle-http + self (semantic-json-request + "/semantic/pages?graph-id=graph-1&created-after=week" + "GET" nil))] + (is (= [200 200 200 200 200 200] (mapv #(.-status %) responses))) + (doseq [[body response-key] (map vector bodies keys)] + (let [titles (set (map :title (get body response-key)))] + (is (some #(string/starts-with? % "New timed") titles) + (str response-key " " titles)) + (is (not-any? #(string/starts-with? % "Old timed") titles) + (str response-key " " titles)))) + (is (= 400 (.-status invalid-response)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-collection-route-rejects-invalid-cursor-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [self #js {:sql sql :conn (storage/open-conn sql) :schema-ready true} + request (semantic-json-request "/semantic/pages?graph-id=graph-1&cursor=not-a-cursor" "GET" nil)] + (-> (p/let [response (sync-handler/handle-http self request) + body (json-body response)] + (is (= 400 (.-status response))) + (is (= "invalid cursor" (:error body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-reverse-reference-routes-use-indexed-pagination-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-id (random-uuid) + tag-id (random-uuid) + hidden-id (random-uuid) + block-ids [(random-uuid) (random-uuid)] + _ (d/transact! conn [{:db/ident :logseq.class/Page} + {:db/ident :logseq.class/Tag} + {:block/uuid page-id :block/name "target" :block/title "Target" + :block/tags :logseq.class/Page} + {:block/uuid tag-id :block/name "project" :block/title "Project" + :block/tags :logseq.class/Tag} + {:block/uuid hidden-id :block/title "Hidden" + :logseq.property/hide? true + :block/tags [:block/uuid tag-id] + :block/refs [:block/uuid page-id]} + {:block/uuid (first block-ids) :block/title "Alpha" + :block/tags [:block/uuid tag-id] + :block/refs [:block/uuid page-id]} + {:block/uuid (second block-ids) :block/title "Beta" + :block/tags [:block/uuid tag-id] + :block/refs [:block/uuid page-id]}]) + self #js {:sql sql :conn conn :schema-ready true} + requests [(semantic-json-request + (str "/semantic/tags/" tag-id "/objects?graph-id=graph-1&limit=1") "GET" nil) + (semantic-json-request + (str "/semantic/pages/" page-id "/references?graph-id=graph-1&limit=1") "GET" nil)]] + (-> (p/let [responses (p/all (map #(sync-handler/handle-http self %) requests)) + bodies (p/all (map json-body responses))] + (is (= [200 200] (mapv #(.-status %) responses))) + (is (= ["Alpha"] (mapv :title (:objects (first bodies))))) + (is (= ["Alpha"] (mapv :title (:references (second bodies))))) + (is (every? string? (map :next-cursor bodies)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-collection-route-rejects-invalid-cursor-element-types-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [self #js {:sql sql :conn (storage/open-conn sql) :schema-ready true} + cursor (js/btoa (js/JSON.stringify #js ["a" #js {}])) + request (semantic-json-request + (str "/semantic/pages?graph-id=graph-1&cursor=" (js/encodeURIComponent cursor)) + "GET" nil)] + (-> (p/let [response (sync-handler/handle-http self request) + body (json-body response)] + (is (= 400 (.-status response))) + (is (= "invalid cursor" (:error body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-page-routes-exclude-tags-and-properties-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-id (random-uuid) + tag-id (random-uuid) + property-id (random-uuid) + _ (d/transact! conn [{:db/ident :logseq.class/Page} + {:db/ident :logseq.class/Tag} + {:db/ident :logseq.class/Property} + {:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page} + {:block/uuid tag-id :block/name "tag" :block/title "Tag" + :block/tags :logseq.class/Tag} + {:block/uuid property-id :block/name "property" :block/title "Property" + :block/tags :logseq.class/Property}]) + self #js {:sql sql :conn conn :schema-ready true} + requests [(semantic-json-request "/semantic/pages?graph-id=graph-1" "GET" nil) + (semantic-json-request (str "/semantic/pages/" tag-id "?graph-id=graph-1") "GET" nil) + (semantic-json-request (str "/semantic/pages/" property-id "?graph-id=graph-1") "GET" nil)]] + (-> (p/let [responses (p/all (map #(sync-handler/handle-http self %) requests)) + list-body (json-body (first responses))] + (is (= [200 404 404] (mapv #(.-status %) responses))) + (is (= ["Page"] (mapv :title (:blocks list-body))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-set-block-property-resolves-class-values-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + page-id (random-uuid) + block-ids (repeatedly 4 random-uuid) + task (d/entity @conn :logseq.class/Task) + tags-property (d/entity @conn :block/tags) + _ (d/transact! conn + (into [{:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page + :block/created-at 1 :block/updated-at 1}] + (map-indexed + (fn [index block-id] + {:block/uuid block-id :block/title (str "Block " index) + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order (str "a" index) + :block/created-at 1 :block/updated-at 1}) + block-ids))) + self #js {:sql sql :conn conn :schema-ready true} + property-id (:block/uuid tags-property) + values [(str (:block/uuid task)) "logseq.class/Task" "Task" [(str (:block/uuid task))]] + request-for (fn [block-id value] + (semantic-json-request + (str "/semantic/blocks/" block-id "/properties/" property-id + "?graph-id=graph-1") + "PUT" {:value value}))] + (-> (p/let [response-1 (sync-handler/handle-http self (request-for (nth block-ids 0) (nth values 0))) + response-2 (sync-handler/handle-http self (request-for (nth block-ids 1) (nth values 1))) + response-3 (sync-handler/handle-http self (request-for (nth block-ids 2) (nth values 2))) + response-4 (sync-handler/handle-http self (request-for (nth block-ids 3) (nth values 3))) + invalid-response (sync-handler/handle-http + self (request-for (first block-ids) "Missing Class")) + invalid-body (json-body invalid-response)] + (is (= [200 200 200 200] + (mapv #(.-status %) [response-1 response-2 response-3 response-4]))) + (doseq [block-id block-ids] + (is (= #{:logseq.class/Task} + (set (map :db/ident (:block/tags (d/entity @conn [:block/uuid block-id]))))))) + (is (= 400 (.-status invalid-response))) + (is (= "property value must resolve to an existing class by UUID, ident, or title" + (:error invalid-body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-batch-set-many-property-can-append-or-reset-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + page-id (random-uuid) + block-id (random-uuid) + project-id (random-uuid) + task (d/entity @conn :logseq.class/Task) + tags-property (d/entity @conn :block/tags) + _ (d/transact! conn + [{:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page + :block/created-at 1 :block/updated-at 1} + {:db/ident :user.class/Project + :block/uuid project-id :block/name "project" :block/title "Project" + :block/tags :logseq.class/Tag} + {:block/uuid block-id :block/title "Block" + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order "a0" :block/tags :user.class/Project + :block/created-at 1 :block/updated-at 1}]) + self #js {:sql sql :conn conn :schema-ready true} + request-for (fn [body] + (semantic-json-request + "/semantic/block-properties/batch-set?graph-id=graph-1" + "POST" body)) + entry {:block-id (str block-id) + :property-id (str (:block/uuid tags-property)) + :value [(str (:block/uuid task))]}] + (-> (p/let [append-response (sync-handler/handle-http + self (request-for {:entries [entry]})) + _ (is (= 200 (.-status append-response))) + _ (is (= #{:user.class/Project :logseq.class/Task} + (set (map :db/ident + (:block/tags (d/entity @conn [:block/uuid block-id])))))) + reset-response (sync-handler/handle-http + self (request-for {:entries [entry] + :isResetExistingValues true}))] + (is (= 200 (.-status reset-response))) + (is (= #{:logseq.class/Task} + (set (map :db/ident + (:block/tags (d/entity @conn [:block/uuid block-id]))))))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-status-property-accepts-built-in-aliases-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + page-id (random-uuid) + block-ids (repeatedly 3 random-uuid) + status-property (d/entity @conn :logseq.property/status) + _ (d/transact! conn + (into [{:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page + :block/created-at 1 :block/updated-at 1}] + (map-indexed + (fn [index block-id] + {:block/uuid block-id :block/title (str "Block " index) + :block/page [:block/uuid page-id] + :block/parent [:block/uuid page-id] + :block/order (str "a" index) + :block/created-at 1 :block/updated-at 1}) + block-ids))) + self #js {:sql sql :conn conn :schema-ready true} + property-id (:block/uuid status-property) + single-request (semantic-json-request + (str "/semantic/blocks/" (first block-ids) "/properties/" + property-id "?graph-id=graph-1") + "PUT" {:value "TODO"}) + batch-request (semantic-json-request + "/semantic/block-properties/batch-set?graph-id=graph-1" + "POST" {:entries [{:block-id (str (second block-ids)) + :property-id (str property-id) + :value "DONE"}]}) + priority-request (semantic-json-request + (str "/semantic/blocks/" (nth block-ids 2) + "/properties/Priority?graph-id=graph-1") + "PUT" {:value "urgent"}) + invalid-property-request (semantic-json-request + (str "/semantic/blocks/" (nth block-ids 2) + "/properties/Missing%20Property?graph-id=graph-1") + "PUT" {:value "urgent"})] + (-> (p/let [single-response (sync-handler/handle-http self single-request) + batch-response (sync-handler/handle-http self batch-request) + priority-response (sync-handler/handle-http self priority-request) + invalid-property-response (sync-handler/handle-http self invalid-property-request) + invalid-property-body (json-body invalid-property-response)] + (is (= [200 200 200] + (mapv #(.-status %) [single-response batch-response priority-response]))) + (is (= [:logseq.property/status.todo :logseq.property/status.done] + (mapv #(-> (d/entity @conn [:block/uuid %]) + :logseq.property/status :db/ident) + (take 2 block-ids)))) + (is (= :logseq.property/priority.urgent + (-> (d/entity @conn [:block/uuid (nth block-ids 2)]) + :logseq.property/priority :db/ident))) + (is (= 400 (.-status invalid-property-response))) + (is (= "property not found" (:error invalid-property-body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-property-routes-accept-ident-and-title-selectors-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + property (outliner-property/upsert-property! + conn nil {:logseq.property/type :default + :db/cardinality :db.cardinality/one} + {:property-name "API estimate"}) + property-ident (:db/ident property) + self #js {:sql sql :conn conn :schema-ready true} + get-request (fn [selector] + (semantic-json-request + (str "/semantic/properties/" + (js/encodeURIComponent selector) + "?graph-id=graph-1") "GET" nil))] + (-> (p/let [ident-selector (str (namespace property-ident) "/" (name property-ident)) + ident-response (sync-handler/handle-http self (get-request ident-selector)) + ident-body (json-body ident-response) + title-response (sync-handler/handle-http self (get-request "API estimate")) + title-body (json-body title-response)] + (is (= 200 (.-status ident-response))) + (is (= ident-selector (:ident ident-body))) + (is (= 200 (.-status title-response))) + (is (= (:uuid ident-body) (:uuid title-body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-property-routes-reject-invalid-schema-and-built-in-delete-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + status-id (:block/uuid (d/entity @conn :logseq.property/status)) + self #js {:sql sql :conn conn :schema-ready true} + invalid-create (semantic-json-request + "/semantic/properties?graph-id=graph-1" "POST" + {:title "Broken" :type "not-a-property-type"}) + invalid-update (semantic-json-request + (str "/semantic/properties/" status-id "?graph-id=graph-1") "PATCH" + {:cardinality "not-a-cardinality"}) + delete-built-in (semantic-json-request + (str "/semantic/properties/" status-id "?graph-id=graph-1") "DELETE" nil)] + (-> (p/let [create-response (sync-handler/handle-http self invalid-create) + update-response (sync-handler/handle-http self invalid-update) + delete-response (sync-handler/handle-http self delete-built-in)] + (is (= 400 (.-status create-response))) + (is (= 400 (.-status update-response))) + (is (= 400 (.-status delete-response))) + (is (some? (d/entity @conn :logseq.property/status)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-task-routes-create-and-list-db-tasks-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + page-id (random-uuid) + todo-id (random-uuid) + second-todo-id (random-uuid) + done-id (random-uuid) + hidden-id (random-uuid) + custom-status-id (random-uuid) + _ (d/transact! conn [{:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page + :block/created-at 1 :block/updated-at 1} + {:block/uuid todo-id :block/title "Alpha todo" + :block/tags :logseq.class/Task + :logseq.property/status :logseq.property/status.todo + :block/created-at 1 :block/updated-at 1} + {:block/uuid second-todo-id :block/title "Beta todo" + :block/tags :logseq.class/Task + :logseq.property/status :logseq.property/status.todo + :block/created-at 1 :block/updated-at 1} + {:block/uuid done-id :block/title "Completed" + :block/tags :logseq.class/Task + :logseq.property/status :logseq.property/status.done + :block/created-at 1 :block/updated-at 1} + {:block/uuid hidden-id :block/title "Hidden todo" + :block/tags :logseq.class/Task + :logseq.property/status :logseq.property/status.todo + :logseq.property/hide? true + :block/created-at 1 :block/updated-at 1}]) + _ (outliner-property/upsert-closed-value! + conn :logseq.property/status {:id custom-status-id :value "Blocked"}) + self #js {:sql sql :conn conn :schema-ready true} + list-request (semantic-json-request + "/semantic/tasks?graph-id=graph-1&status=todo&limit=1" "GET" nil) + create-request (semantic-json-request + "/semantic/tasks?graph-id=graph-1" "POST" + {:title "Ship task API" :page-id (str page-id) + :status (str custom-status-id) :priority "high"}) + invalid-request (semantic-json-request + "/semantic/tasks?graph-id=graph-1" "POST" + {:title "Invalid task" :page-id (str page-id) + :status "not-a-status"}) + status-property-id (:block/uuid (d/entity @conn :logseq.property/status)) + property-request (semantic-json-request + (str "/semantic/properties/" status-property-id "?graph-id=graph-1") + "GET" nil)] + (-> (p/let [list-response (sync-handler/handle-http self list-request) + list-body (json-body list-response) + create-response (sync-handler/handle-http self create-request) + create-body (json-body create-response) + invalid-response (sync-handler/handle-http self invalid-request) + invalid-body (json-body invalid-response) + property-response (sync-handler/handle-http self property-request) + property-body (json-body property-response)] + (is (= 200 (.-status list-response))) + (is (= ["Alpha todo"] (mapv :title (:tasks list-body)))) + (is (= ["logseq.property/status.todo"] + (mapv #(get-in % [:status :ident]) (:tasks list-body)))) + (is (string? (:next-cursor list-body))) + (is (= 201 (.-status create-response))) + (is (= "Ship task API" (:title create-body))) + (is (= (str custom-status-id) (get-in create-body [:status :uuid]))) + (is (= "logseq.property/priority.high" (get-in create-body [:priority :ident]))) + (let [created (d/entity @conn [:block/uuid (uuid (:uuid create-body))])] + (is (= #{:logseq.class/Task} (set (map :db/ident (:block/tags created))))) + (is (= custom-status-id + (:block/uuid (:logseq.property/status created)))) + (is (= :logseq.property/priority.high + (:db/ident (:logseq.property/priority created))))) + (is (= 400 (.-status invalid-response))) + (is (= "invalid task status" (:error invalid-body))) + (is (= 200 (.-status property-response))) + (is (contains? (set (map :uuid (:choices property-body))) + (str custom-status-id)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-create-task-uses-one-transaction-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + tx-reports (atom []) + self #js {:sql sql :conn conn :schema-ready true} + request (semantic-json-request + "/semantic/tasks?graph-id=graph-1" "POST" + {:title "Atomic task" :status "TODO" :priority "HIGH" + :scheduled 20260713 :deadline 20260714})] + (d/listen! conn ::semantic-create-task-tx + #(swap! tx-reports conj %)) + (-> (p/let [response (sync-handler/handle-http self request) + body (json-body response)] + (d/unlisten! conn ::semantic-create-task-tx) + (is (= 201 (.-status response))) + (is (= 1 (count @tx-reports))) + (is (= 1 (count (d/datoms @conn :avet :block/journal-day)))) + (is (= "logseq.property/status.todo" (get-in body [:status :ident]))) + (is (= "logseq.property/priority.high" (get-in body [:priority :ident]))) + (is (= 20260713 (:scheduled body))) + (is (= 20260714 (:deadline body)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (d/unlisten! conn ::semantic-create-task-tx) + (is false (str error)) + (done))))))))) + +(deftest semantic-invalid-task-does-not-create-today-journal-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + self #js {:sql sql :conn conn :schema-ready true} + request (semantic-json-request + "/semantic/tasks?graph-id=graph-1" "POST" + {:title "Invalid task" :status "not-a-status"})] + (-> (p/let [response (sync-handler/handle-http self request) + body (json-body response)] + (is (= 400 (.-status response))) + (is (= "invalid task status" (:error body))) + (is (empty? (d/datoms @conn :avet :block/journal-day)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-asset-get-returns-valid-temporary-link-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + asset-id (random-uuid) + env #js {"ASSET_LINK_SECRET" "test-asset-link-secret"} + _ (d/transact! conn [{:block/uuid asset-id + :block/title "diagram.png" + :logseq.property.asset/type "png" + :logseq.property.asset/size 42}]) + self #js {:sql sql :conn conn :schema-ready true :env env} + request (semantic-json-request + (str "/semantic/assets/" asset-id "?graph-id=graph-1") "GET" nil)] + (-> (p/let [response (sync-handler/handle-http self request) + body (json-body response) + link-request (js/Request. (:url body)) + valid? (asset-link/ (p/let [create-response (sync-handler/handle-http + self (upload-request "photo.png" "Photo" 4 checksum)) + create-body (json-body create-response) + list-response (sync-handler/handle-http + self (semantic-json-request + "/semantic/assets?graph-id=graph-1&created-after=2000" + "GET" nil)) + list-body (json-body list-response) + duplicate-response (sync-handler/handle-http + self (upload-request "copy.png" "Copy" 4 checksum)) + failed-response (p/with-redefs + [outliner-core/insert-blocks! + (fn [& _] (throw (js/Error. "insert failed")))] + (sync-handler/handle-http + self (upload-request "broken.pdf" "Broken" 4 + (apply str (repeat 64 "b"))))) + oversized-response (sync-handler/handle-http + self (upload-request "huge.zip" "Huge" 104857601 + (apply str (repeat 64 "c"))))] + (is (= 201 (.-status create-response))) + (is (= "Photo" (:title create-body))) + (is (= "png" (:type create-body))) + (is (= 4 (:size create-body))) + (is (= 64 (count (:checksum create-body)))) + (is (= 2 (count @puts))) + (when-let [[key payload options] (first @puts)] + (is (= (str "graph-1/" (:uuid create-body) ".png") key)) + (is (fn? (.-getReader payload))) + (is (= "application/octet-stream" (aget (aget options "httpMetadata") "contentType"))) + (is (= (:checksum create-body) (aget (aget options "customMetadata") "checksum")))) + (when (string? (:uuid create-body)) + (let [asset (d/entity @conn [:block/uuid (uuid (:uuid create-body))])] + (is (= page-id (:block/uuid (:block/page asset)))) + (is (= #{:logseq.class/Asset} (set (map :db/ident (:block/tags asset))))) + (is (= {:checksum checksum :type "png"} + (:logseq.property.asset/remote-metadata asset))))) + (is (= 200 (.-status list-response))) + (is (= [(:uuid create-body)] (mapv :uuid (:assets list-body)))) + (is (= 409 (.-status duplicate-response))) + (is (= 500 (.-status failed-response))) + (is (= 1 (count @deletes))) + (is (some-> (first @deletes) (string/ends-with? ".pdf") true?)) + (is (= 413 (.-status oversized-response))) + (is (= 2 (count @puts)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(deftest semantic-base64-asset-upload-decodes-before-r2-test + (async done + (with-memory-sql-async + (fn [sql] + (storage/init-schema! sql) + (let [conn (sqlite-export/create-conn) + page-id (random-uuid) + uploaded (atom []) + bucket #js {:put (fn [_key payload _options] + (p/let [buffer (.arrayBuffer (js/Response. payload))] + (swap! uploaded conj (js/Uint8Array. buffer)) + #js {})) + :delete (fn [_] (p/resolved nil))} + _ (d/transact! conn [{:block/uuid page-id :block/name "page" :block/title "Page" + :block/tags :logseq.class/Page}]) + self #js {:sql sql :conn conn :schema-ready true + :env #js {"LOGSEQ_SYNC_ASSETS" bucket}} + request (fn [body checksum] + (js/Request. + (str "http://localhost/semantic/assets?graph-id=graph-1" + "&file-name=image.png&page-id=" page-id + "&size=4&checksum=" checksum "&encoding=base64") + #js {:method "POST" + :headers #js {"content-type" "text/plain"} + :body body}))] + (-> (p/let [response (sync-handler/handle-http + self (request "AQIDBA==" (apply str (repeat 64 "d")))) + invalid-response (sync-handler/handle-http + self (request "not base64!" (apply str (repeat 64 "e"))))] + (is (= 201 (.-status response))) + (let [^js payload (first @uploaded)] + (is (= 4 (.-byteLength payload))) + (is (= 1 (aget payload 0))) + (is (= 2 (aget payload 1))) + (is (= 3 (aget payload 2))) + (is (= 4 (aget payload 3)))) + (is (= 400 (.-status invalid-response))) + (is (= 1 (count @uploaded)))) + (p/then (fn [] (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))))) + +(defn- seeded-rng + [seed0] + (let [state (atom (bit-or (long seed0) 0))] + (fn [] + (let [s (swap! state + (fn [x] + (let [x (bit-xor x (bit-shift-left x 13)) + x (bit-xor x (bit-shift-right x 17)) + x (bit-xor x (bit-shift-left x 5))] + (bit-or x 0))))] + (/ (double (unsigned-bit-shift-right s 0)) 4294967296.0))))) + +(defn- rand-int* + [rng n] + (js/Math.floor (* (rng) n))) + +(defn- pick-rand + [rng coll] + (when (seq coll) + (nth coll (rand-int* rng (count coll))))) + +(defn- block-uuids-by-predicate + [db pred] + (->> (d/datoms db :avet :block/uuid) + (map :e) + distinct + (keep (fn [eid] + (let [ent (d/entity db eid) + uuid (:block/uuid ent)] + (when (and uuid (pred ent)) + (str uuid))))) + vec)) + +(defn- page-uuids + [db] + (block-uuids-by-predicate db #(some? (:block/name %)))) + +(defn- non-page-block-uuids + [db] + (block-uuids-by-predicate db #(nil? (:block/name %)))) + +(defn- all-block-uuids + [db] + (block-uuids-by-predicate db (constantly true))) + +(defn- gen-server-tx-entry + [rng db step] + (let [page-ids (page-uuids db) + block-ids (non-page-block-uuids db) + all-ids (all-block-uuids db) + op (rand-int* rng 6)] + (case op + ;; Explicit empty rebase no-op + 0 {:tx (protocol/tx->transit []) + :outliner-op :rebase} + + ;; stale retract in :fix should be sanitized away (often no-op) + 1 {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid (random-uuid)]]]) + :outliner-op :fix} + + ;; update title + 2 (if-let [target-id (pick-rand rng all-ids)] + {:tx (protocol/tx->transit [[:db/add [:block/uuid (uuid target-id)] + :block/title + (str "server-fuzz-title-" step)]]) + :outliner-op :save-block} + {:tx (protocol/tx->transit []) + :outliner-op :rebase}) + + ;; move block parent/page + 3 (if (and (seq block-ids) (seq page-ids)) + (let [child (pick-rand rng block-ids) + parent (or (pick-rand rng block-ids) + child) + page (pick-rand rng page-ids)] + {:tx (protocol/tx->transit [[:db/add [:block/uuid (uuid child)] + :block/parent + [:block/uuid (uuid parent)]] + [:db/add [:block/uuid (uuid child)] + :block/page + [:block/uuid (uuid page)]]]) + :outliner-op :move-blocks}) + {:tx (protocol/tx->transit []) + :outliner-op :rebase}) + + ;; add block + 4 (if (seq page-ids) + (let [page (pick-rand rng page-ids) + parent (or (pick-rand rng block-ids) + page) + new-uuid (random-uuid)] + {:tx (protocol/tx->transit [{:db/id -1 + :block/uuid new-uuid + :block/title (str "server-fuzz-add-" step) + :block/order (str "a" (rand-int* rng 9)) + :block/parent [:block/uuid (uuid parent)] + :block/page [:block/uuid (uuid page)]}]) + :outliner-op :insert-blocks}) + {:tx (protocol/tx->transit []) + :outliner-op :rebase}) + + ;; delete non-page block + (if-let [victim (pick-rand rng block-ids)] + {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid (uuid victim)]]]) + :outliner-op :delete-blocks} + {:tx (protocol/tx->transit []) + :outliner-op :rebase})))) + +(defn- empty-sql [] + #js {:exec (fn [& _] #js [])}) + +(defn- make-server-self + [] + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true}] + {:sql sql + :conn conn + :self self})) + +(defn- apply-entries! + [^js self entries] + (loop [t-before (storage/get-t (.-sql self)) + remaining entries] + (if-let [entry (first remaining)] + (let [response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (recur (:t response) (next remaining))) + t-before))) + +(defn- apply-batch-with-t! + [^js self t-before entries] + (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil entries t-before))) + +(defn- assert-server-checksum-step! + [sql conn prev-t prev-checksum response label] + (let [stored-checksum (storage/get-checksum sql) + recomputed-checksum (sync-checksum/recompute-checksum @conn) + new-t (storage/get-t sql) + accepted? (= "tx/batch/ok" (:type response)) + advanced? (> new-t prev-t)] + (is (= new-t (:t response)) + (str label " response.t should match storage t")) + (if accepted? + (if advanced? + (do + (is (string? stored-checksum) + (str label " stored checksum missing after mutation")) + (is (= recomputed-checksum stored-checksum) + (str label " stored checksum should equal full recompute"))) + (is (= prev-checksum stored-checksum) + (str label " checksum changed on no-op accepted batch"))) + (do + (is (= "tx/reject" (:type response)) + (str label " expected tx rejection")) + (is (= prev-t new-t) + (str label " rejected tx should not change t")) + (is (= prev-checksum stored-checksum) + (str label " rejected tx should not change checksum")))) + {:accepted? accepted? + :advanced? advanced? + :t new-t + :checksum stored-checksum})) + +(defn- block-placement + [db block-uuid] + (let [ent (d/pull db [{:block/parent [:block/uuid :block/name]} + {:block/page [:block/uuid :block/name]} + :block/order] + [:block/uuid block-uuid])] + {:parent-uuid (get-in ent [:block/parent :block/uuid]) + :parent-page? (boolean (get-in ent [:block/parent :block/name])) + :page-uuid (get-in ent [:block/page :block/uuid]) + :order (:block/order ent)})) + +(defn- no-op-rebase-entry + [] + {:tx (protocol/tx->transit []) + :outliner-op :rebase}) + +(defn- tx-entry-applicable? + [db {:keys [tx]}] + (try + (d/with db (protocol/transit->tx tx)) + true + (catch :default _ + false))) + +(defn- tx-entries-applicable? + [db entries] + (every? (partial tx-entry-applicable? db) entries)) + +(defn- make-insert-command + [rng db step] + (let [pages (page-uuids db) + blocks (non-page-block-uuids db)] + (if-let [page-id (pick-rand rng pages)] + (let [parent-id (or (pick-rand rng blocks) page-id) + new-uuid (random-uuid) + entry {:tx (protocol/tx->transit [{:db/id -1 + :block/uuid new-uuid + :block/title (str "rand-insert-" step) + :block/order (str "a" step "-" (rand-int* rng 9)) + :block/parent [:block/uuid (uuid parent-id)] + :block/page [:block/uuid (uuid page-id)]}]) + :outliner-op :insert-blocks} + inverse {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid new-uuid]]]) + :outliner-op :delete-blocks}] + {:forward [entry] + :inverse [inverse] + :undoable? true}) + {:forward [(no-op-rebase-entry)] + :undoable? false}))) + +(defn- make-title-command + [rng db step] + (if-let [target-id (pick-rand rng (all-block-uuids db))] + (let [target-uuid (uuid target-id) + old-title (or (:block/title (d/pull db [:block/title] [:block/uuid target-uuid])) "") + new-title (str "rand-title-" step)] + {:forward [{:tx (protocol/tx->transit [[:db/add [:block/uuid target-uuid] + :block/title + new-title]]) + :outliner-op :save-block}] + :inverse [{:tx (protocol/tx->transit [[:db/add [:block/uuid target-uuid] + :block/title + old-title]]) + :outliner-op :save-block}] + :undoable? true}) + {:forward [(no-op-rebase-entry)] + :undoable? false})) + +(defn- make-move-like-command + [db target-id new-parent-id new-page-id new-order outliner-op] + (let [target-uuid (uuid target-id) + placement (block-placement db target-uuid)] + (if (and (:parent-uuid placement) (:page-uuid placement)) + {:forward [{:tx (protocol/tx->transit [[:db/add [:block/uuid target-uuid] + :block/parent + [:block/uuid (uuid new-parent-id)]] + [:db/add [:block/uuid target-uuid] + :block/page + [:block/uuid (uuid new-page-id)]] + [:db/add [:block/uuid target-uuid] + :block/order + new-order]]) + :outliner-op outliner-op}] + :inverse [{:tx (protocol/tx->transit [[:db/add [:block/uuid target-uuid] + :block/parent + [:block/uuid (:parent-uuid placement)]] + [:db/add [:block/uuid target-uuid] + :block/page + [:block/uuid (:page-uuid placement)]] + [:db/add [:block/uuid target-uuid] + :block/order + (:order placement)]]) + :outliner-op outliner-op}] + :undoable? true} + {:forward [(no-op-rebase-entry)] + :undoable? false}))) + +(defn- make-random-move-command + [rng db step] + (let [blocks (non-page-block-uuids db) + pages (page-uuids db)] + (if (and (seq blocks) (seq pages)) + (let [target-id (pick-rand rng blocks) + parent-candidates (vec (remove #{target-id} (concat blocks pages))) + parent-id (or (pick-rand rng parent-candidates) (pick-rand rng pages)) + page-id (pick-rand rng pages)] + (make-move-like-command db target-id parent-id page-id (str "m" step) :move-blocks)) + {:forward [(no-op-rebase-entry)] + :undoable? false}))) + +(defn- make-random-indent-command + [rng db step] + (let [blocks (non-page-block-uuids db) + pages (page-uuids db)] + (if (and (seq blocks) (seq pages)) + (let [child-id (pick-rand rng blocks) + parent-candidates (vec (remove #{child-id} blocks)) + parent-id (or (pick-rand rng parent-candidates) + (pick-rand rng pages)) + page-id (pick-rand rng pages)] + (make-move-like-command db child-id parent-id page-id (str "i" step) :indent-blocks)) + {:forward [(no-op-rebase-entry)] + :undoable? false}))) + +(defn- make-random-outdent-command + [rng db step] + (let [candidates (->> (non-page-block-uuids db) + (keep (fn [block-id] + (let [placement (block-placement db (uuid block-id))] + (when (and (:parent-uuid placement) + (not (:parent-page? placement)) + (:page-uuid placement)) + block-id)))) + vec)] + (if-let [child-id (pick-rand rng candidates)] + (let [child-uuid (uuid child-id) + placement (block-placement db child-uuid) + parent-placement (block-placement db (:parent-uuid placement))] + (if-let [grandparent-uuid (:parent-uuid parent-placement)] + (make-move-like-command db child-id (str grandparent-uuid) (str (:page-uuid placement)) (str "o" step "-" (rand-int* rng 9)) :outdent-blocks) + {:forward [(no-op-rebase-entry)] + :undoable? false})) + {:forward [(no-op-rebase-entry)] + :undoable? false}))) + +(defn- make-random-delete-entry + [rng db] + (if-let [victim-id (pick-rand rng (non-page-block-uuids db))] + {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid (uuid victim-id)]]]) + :outliner-op :delete-blocks} + (no-op-rebase-entry))) + +(defn- make-stale-add-after-delete-conflict + [rng db step] + (let [blocks (non-page-block-uuids db) + pages (page-uuids db)] + (when (and (seq blocks) (seq pages)) + (let [victim-id (pick-rand rng blocks) + page-id (pick-rand rng pages) + stale-child-uuid (random-uuid)] + {:delete-entry {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid (uuid victim-id)]]]) + :outliner-op :delete-blocks} + :stale-add-entry {:tx (protocol/tx->transit [{:db/id -1 + :block/uuid stale-child-uuid + :block/title (str "stale-child-" step) + :block/order (str "c" step) + :block/parent [:block/uuid (uuid victim-id)] + :block/page [:block/uuid (uuid page-id)]}]) + :outliner-op :insert-blocks}})))) + +(defn- request-url + ([] + (request-url "/sync/graph-1/snapshot/download?graph-id=graph-1")) + ([path] + (let [request (js/Request. (str "http://localhost" path) + #js {:method "GET"})] + {:request request + :url (js/URL. (.-url request))}))) + +(defn- passthrough-compression-stream-constructor [] + (js* "function(_format){ return new TransformStream(); }")) + +(deftest snapshot-download-uses-gzip-encoding-when-compression-supported-test + (async done + (let [sql (empty-sql) + conn (d/create-conn db-schema/schema) + self #js {:env #js {} + :conn conn + :schema-ready true + :sql sql} + {:keys [request url]} (request-url) + expected-url "http://localhost/sync/graph-1/snapshot/stream" + original-compression-stream (.-CompressionStream js/globalThis) + restore! #(aset js/globalThis "CompressionStream" original-compression-stream)] + (aset js/globalThis + "CompressionStream" + (passthrough-compression-stream-constructor)) + (-> (p/let [resp (sync-handler/handle {:self self + :request request + :url url + :route {:handler :sync/snapshot-download}}) + text (.text resp) + body (js->clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= true (:ok body))) + (is (= "stream/graph-1.snapshot" (:key body))) + (is (= expected-url (:url body))) + (is (= "gzip" (:content-encoding body)))) + (p/then (fn [] + (restore!) + (done))) + (p/catch (fn [error] + (restore!) + (is false (str error)) + (done))))))) + +(deftest snapshot-download-omits-gzip-encoding-when-disabled-in-env-test + (async done + (let [sql (empty-sql) + conn (d/create-conn db-schema/schema) + self #js {:env #js {"DB_SYNC_SNAPSHOT_STREAM_GZIP" "false"} + :conn conn + :schema-ready true + :sql sql} + {:keys [request url]} (request-url)] + (-> (p/let [resp (sync-handler/handle {:self self + :request request + :url url + :route {:handler :sync/snapshot-download}}) + text (.text resp) + body (js->clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= true (:ok body))) + (is (not (contains? body :content-encoding))) + (done)) + (p/then (fn [] + nil)) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest snapshot-download-stream-route-returns-framed-kvs-rows-test + (async done + (let [rows [[1 "row-1" nil] + [2 "row-2" nil]] + sql (empty-sql) + conn (d/create-conn db-schema/schema) + self #js {:env #js {} + :conn conn + :schema-ready true + :sql sql} + {:keys [request]} (request-url "/sync/graph-1/snapshot/stream?graph-id=graph-1") + original-compression-stream (.-CompressionStream js/globalThis) + restore! #(aset js/globalThis "CompressionStream" original-compression-stream)] + (aset js/globalThis + "CompressionStream" + (passthrough-compression-stream-constructor)) + (-> (p/with-redefs [sync-handler/fetch-snapshot-kvs-rows (fn [_sql last-addr _limit] + (if (neg? last-addr) rows [])) + sync-handler/snapshot-row-count (fn [_sql] (count rows))] + (p/let [resp (sync-handler/handle-http self request) + encoding (.get (.-headers resp) "content-encoding") + content-type (.get (.-headers resp) "content-type") + buf (.arrayBuffer resp) + payload (js/Uint8Array. buf) + rows (snapshot/finalize-framed-buffer payload) + addrs (mapv first rows)] + (is (= 200 (.-status resp))) + (is (= "gzip" encoding)) + (is (= "application/transit+json" content-type)) + (is (= 2 (count rows))) + (is (= (sort addrs) addrs)) + (is (every? (fn [[addr content _addresses]] + (and (int? addr) + (string? content))) + rows))) + (is (= [[1 "row-1" nil] + [2 "row-2" nil]] + rows))) + (p/then (fn [] + (restore!) + (done))) + (p/catch (fn [error] + (restore!) + (is false (str error)) + (done))))))) + +(deftest snapshot-download-stream-route-returns-uncompressed-framed-kvs-rows-when-disabled-in-env-test + (async done + (let [rows [[1 "row-1" nil] + [2 "row-2" nil]] + sql (empty-sql) + conn (d/create-conn db-schema/schema) + self #js {:env #js {"DB_SYNC_SNAPSHOT_STREAM_GZIP" "false"} + :conn conn + :schema-ready true + :sql sql} + {:keys [request]} (request-url "/sync/graph-1/snapshot/stream?graph-id=graph-1")] + (-> (p/with-redefs [sync-handler/fetch-snapshot-kvs-rows (fn [_sql last-addr _limit] + (if (neg? last-addr) rows [])) + sync-handler/snapshot-row-count (fn [_sql] (count rows))] + (p/let [resp (sync-handler/handle-http self request) + encoding (.get (.-headers resp) "content-encoding") + content-type (.get (.-headers resp) "content-type") + buf (.arrayBuffer resp) + payload (js/Uint8Array. buf) + rows (snapshot/finalize-framed-buffer payload) + addrs (mapv first rows)] + (is (= 200 (.-status resp))) + (is (nil? encoding)) + (is (= "application/transit+json" content-type)) + (is (= 2 (count rows))) + (is (= (sort addrs) addrs)) + (is (= [[1 "row-1" nil] + [2 "row-2" nil]] + rows)))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest ensure-schema-fallback-probes-existing-tables-test + (async done + (let [self #js {:sql (empty-sql)} + schema-probes (atom []) + {:keys [request url]} (request-url "/sync/graph-1/pull?graph-id=graph-1&since=0")] + (-> (p/with-redefs [storage/init-schema! (fn [_] + (throw (js/Error. "ddl rejected"))) + common/sql-exec (fn [_ sql-str & _args] + (swap! schema-probes conj sql-str) + #js []) + storage/fetch-tx-since (fn [_ _] []) + storage/get-t (fn [_] 7) + sync-handler/current-checksum (fn [_] "checksum-ok")] + (p/let [resp (sync-handler/handle {:self self + :request request + :url url + :route {:handler :sync/pull}}) + text (.text resp) + body (js->clj (js/JSON.parse text) :keywordize-keys true) + probe-set (set @schema-probes)] + (is (= 200 (.-status resp))) + (is (= 7 (:t body))) + (is (= "checksum-ok" (:checksum body))) + (is (contains? probe-set "select 1 from kvs limit 1")) + (is (contains? probe-set "select 1 from tx_log limit 1")) + (is (contains? probe-set "select 1 from sync_meta limit 1")))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest sync-repair-blocks-http-route-is-not-found-test + (let [{:keys [self]} (make-server-self) + {:keys [request]} (request-url "/sync/graph-1/repair/blocks?graph-id=graph-1")] + (is (= 404 (.-status (sync-handler/handle-http self request)))))) + +(deftest tx-batch-rejects-stale-lookup-entity-updates-test + (testing "stale lookup-ref entity updates reject the tx batch" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + missing-uuid (random-uuid) + created-uuid (random-uuid) + tx-data [[:db/add [:block/uuid missing-uuid] :block/title "stale" 1] + [:db/add [:block/uuid missing-uuid] :block/updated-at 1773188050934 1] + [:db/add "temp-1" :block/uuid created-uuid 2] + [:db/add "temp-1" :block/title "ok" 2]] + tx-entry {:tx (protocol/tx->transit tx-data) + :outliner-op :save-block} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] 0))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= 0 (:t response))) + (is (= [missing-uuid] (:missing-block-uuids response))) + (is (nil? (d/entity @conn [:block/uuid created-uuid]))) + (is (nil? (d/entity @conn [:block/uuid missing-uuid]))) + (let [pull-response (sync-handler/pull-response self 0)] + (is (= "pull/ok" (:type pull-response))) + (is (empty? (:txs pull-response))))))) + +(deftest tx-batch-adds-request-context-to-transact-meta-test + (testing "graph id and client revision should be present in transact failure diagnostics" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + tx-metas (atom []) + tx-entry {:tx (protocol/tx->transit [[:db/add -1 :block/title "context"]]) + :outliner-op :save-block} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (d/listen! conn ::capture-request-context-tx-meta + (fn [tx-report] + (swap! tx-metas conj (:tx-meta tx-report)))) + (try + (sync-handler/handle-tx-batch! + self + nil + [tx-entry] + 0 + {:graph-id "graph-context" + :client-revision "revision-context"}) + (finally + (d/unlisten! conn ::capture-request-context-tx-meta))))] + (is (= "tx/batch/ok" (:type response))) + (is (some #(= {:op :apply-client-tx + :outliner-op :save-block + :graph-id "graph-context" + :client-revision "revision-context"} + (select-keys % [:op :outliner-op :graph-id :client-revision])) + @tx-metas))))) + +(deftest tx-batch-rejects-move-blocks-when-target-is-missing-test + (testing "move-blocks against a block missing on the server should request client repair" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + parent-uuid (random-uuid) + missing-block-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "stale-move-page" + :block/title "stale-move-page"} + {:block/uuid parent-uuid + :block/title "existing-parent" + :block/order "a0" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + tx-id (random-uuid) + tx-entry {:tx-id tx-id + :tx (protocol/tx->transit + [[:db/retract [:block/uuid missing-block-uuid] + :block/parent + [:block/uuid page-uuid] + 537062408] + [:db/add [:block/uuid missing-block-uuid] + :block/parent + [:block/uuid parent-uuid] + 537062408] + [:db/retract [:block/uuid missing-block-uuid] + :block/order + "a0" + 537062408] + [:db/add [:block/uuid missing-block-uuid] + :block/order + "a3" + 537062408]]) + :outliner-op :move-blocks} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= t-before (:t response))) + (is (= tx-id (:failed-tx-id response))) + (is (= [missing-block-uuid] (:missing-block-uuids response))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (empty? @changed-messages))))) + +(deftest tx-batch-delete-blocks-ignores-redundant-updates-and-deletes-descendants-test + (testing "delete-blocks should retract current descendants even when tx-data omits them" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + parent-uuid (random-uuid) + child-uuid (random-uuid) + property-value-uuid (random-uuid) + now 1783043128375 + _ (d/transact! conn [{:db/ident :user.property/sync-report} + {:block/uuid page-uuid + :block/name "delete-descendants-page" + :block/title "delete-descendants-page" + :block/created-at now + :block/updated-at now} + {:block/uuid parent-uuid + :block/title "parent" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid] + :block/order "a0" + :block/created-at now + :block/updated-at now} + {:block/uuid child-uuid + :block/title "child" + :block/parent [:block/uuid parent-uuid] + :block/page [:block/uuid page-uuid] + :block/order "a1" + :block/created-at now + :block/updated-at now} + {:block/uuid property-value-uuid + :block/title "property value" + :block/parent [:block/uuid child-uuid] + :block/page [:block/uuid page-uuid] + :block/order "a4" + :logseq.property/created-from-property :user.property/sync-report + :block/created-at now + :block/updated-at now}]) + t-before (storage/get-t sql) + tx-entry {:tx (protocol/tx->transit + [[:db/add [:block/uuid child-uuid] + :block/parent + [:block/uuid page-uuid] + 537866038] + [:db/add [:block/uuid property-value-uuid] + :block/updated-at + (inc now) + 537866038] + [:db/retractEntity [:block/uuid parent-uuid]] + [:db/retractEntity [:block/uuid child-uuid]]]) + :outliner-op :delete-blocks} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (nil? (d/entity @conn [:block/uuid parent-uuid]))) + (is (nil? (d/entity @conn [:block/uuid child-uuid]))) + (is (nil? (d/entity @conn [:block/uuid property-value-uuid])))))) + +(deftest tx-batch-keeps-created-by-ref-lookup-payload-test + (testing "created-by lookup payload is preserved for save-block tx" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + missing-user-uuid (random-uuid) + missing-user-ref [:block/uuid missing-user-uuid] + tx-entry {:tx (protocol/tx->transit [[:db/add -1 :block/uuid page-uuid] + [:db/add -1 :block/name "created-by-sanitize-page"] + [:db/add -1 :block/title "created-by-sanitize-page"] + [:db/add -1 :logseq.property/created-by-ref missing-user-ref]]) + :outliner-op :save-block} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] 0)) + page (d/entity @conn [:block/uuid page-uuid])] + (is (= "tx/batch/ok" (:type response))) + (is (= 1 (:t response))) + (is (some? page)) + (is (= "created-by-sanitize-page" (:block/title page))) + (is (= missing-user-ref (:logseq.property/created-by-ref page)))))) + +(deftest tx-batch-rejects-missing-page-ref-lookups-test + (testing "missing page refs/tags lookup refs reject create-page tx" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + missing-ref-uuid (random-uuid) + missing-tag-uuid (random-uuid) + missing-user-uuid (random-uuid) + tx-entry {:tx (protocol/tx->transit [[:db/add -1 :block/uuid page-uuid] + [:db/add -1 :block/name "optional-ref-sanitize-page"] + [:db/add -1 :block/title "optional-ref-sanitize-page"] + [:db/add -1 :block/refs [:block/uuid missing-ref-uuid]] + [:db/add -1 :block/tags [:block/uuid missing-tag-uuid]] + [:db/add -1 :logseq.property/created-by-ref [:block/uuid missing-user-uuid]]]) + :outliner-op :create-page} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] 0)) + page (d/entity @conn [:block/uuid page-uuid])] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= 0 (:t response))) + (is (nil? page))))) + +(deftest tx-batch-rejects-while-snapshot-upload-is-in-progress-test + (let [sql (test-sql/make-sql) + conn (d/create-conn db-schema/schema) + self #js {:sql sql + :conn conn + :schema-ready true} + tx-data [[:db/add -1 :block/title "blocked"]] + tx-entry {:tx (protocol/tx->transit tx-data) + :outliner-op :save-block} + response (with-redefs [storage/get-meta (fn [_ k] + (when (= :snapshot-uploading? k) + "true"))] + (sync-handler/handle-tx-batch! self nil [tx-entry] 0))] + (is (= "tx/reject" (:type response))) + (is (= "snapshot upload in progress" (:reason response))))) + +(deftest tx-batch-applies-db-migration-entry-test + (testing "db migration entries apply with migration transact semantics" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + tx-entry {:tx (protocol/tx->transit [[:db/add -1 :block/title "migration-only"]]) + :outliner-op :db-migrate} + tx-metas (atom []) + _ (d/listen! conn ::capture-db-migrate-tx-meta + (fn [tx-report] + (swap! tx-metas conj (:tx-meta tx-report)))) + response (try + (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] 0)) + (finally + (d/unlisten! conn ::capture-db-migrate-tx-meta)))] + (is (= "tx/batch/ok" (:type response))) + (is (= 1 (:t response))) + (is (some (fn [tx-meta] + (and (:db-migrate? tx-meta) + (:skip-validate-db? tx-meta))) + @tx-metas)) + (is (= "migration-only" + (:block/title (first (d/q '[:find [(pull ?e [:block/title]) ...] + :where [?e :block/title "migration-only"]] + @conn)))))))) + +(defn- large-block-insert-tx + [page-uuid block-count] + (vec + (mapcat (fn [idx] + (let [block-uuid (random-uuid) + eid (str block-uuid)] + [[:db/add eid :block/uuid block-uuid idx] + [:db/add eid :block/title (str "large-op-block-" idx) idx] + [:db/add eid :block/page [:block/uuid page-uuid] idx] + [:db/add eid :block/parent [:block/uuid page-uuid] idx] + [:db/add eid :block/order "a0" idx] + [:db/add eid :block/created-at idx idx] + [:db/add eid :block/updated-at idx idx]])) + (range block-count)))) + +(defn- block-title-prefix-count + [db prefix] + (->> (d/datoms db :avet :block/title) + (filter (fn [datom] + (string/starts-with? (:v datom) prefix))) + count)) + +(deftest tx-batch-applies-large-entry-in-ordered-chunks-test + (testing "large tx entries are executed as ordered chunks while preserving final state" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-op-page" + :block/title "large-op-page"}]) + t-before (storage/get-t sql) + block-count 1200 + tx-data (large-block-insert-tx page-uuid block-count) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + tx-report-count (atom 0) + response (try + (d/listen! conn ::large-entry-chunks + (fn [_tx-report] + (swap! tx-report-count inc))) + (with-redefs [sync-checksum/recompute-checksum + (fn [_db] + (throw (ex-info "large tx should update checksum incrementally" + {:type :test/full-checksum-recompute}))) + ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before)) + (finally + (d/unlisten! conn ::large-entry-chunks)))] + (is (= "tx/batch/ok" (:type response))) + (is (> @tx-report-count 1)) + (is (= (+ t-before @tx-report-count) (:t response))) + (is (= @tx-report-count (count (storage/fetch-tx-since sql t-before)))) + (is (= block-count + (block-title-prefix-count @conn "large-op-block-")))))))) + +(deftest tx-batch-applies-large-entry-with-negative-tempids-in-ordered-chunks-test + (testing "large tx entries with tempids are still accepted and chunked safely" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-tempid-page" + :block/title "large-tempid-page"}]) + t-before (storage/get-t sql) + tx-data (vec + (mapcat (fn [idx] + (let [eid (- -1 idx)] + [[:db/add eid :block/uuid (random-uuid)] + [:db/add eid :block/title (str "large-tempid-block-" idx)] + [:db/add eid :block/page [:block/uuid page-uuid]] + [:db/add eid :block/parent [:block/uuid page-uuid]] + [:db/add eid :block/order "a0"] + [:db/add eid :block/created-at idx] + [:db/add eid :block/updated-at idx]])) + (range 300))) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + tx-report-count (atom 0) + response (try + (d/listen! conn ::large-tempid-entry-chunks + (fn [_tx-report] + (swap! tx-report-count inc))) + (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before)) + (finally + (d/unlisten! conn ::large-tempid-entry-chunks)))] + (is (= "tx/batch/ok" (:type response))) + (is (> @tx-report-count 1)) + (is (= (+ t-before @tx-report-count) (:t response))) + (is (= @tx-report-count (count (storage/fetch-tx-since sql t-before)))) + (is (= 300 (block-title-prefix-count @conn "large-tempid-block-")))))))) + +(deftest tx-batch-applies-large-entry-with-dependent-blocks-across-chunks-test + (testing "large tx chunking preserves ordered parent-before-child dependencies" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-dependency-page" + :block/title "large-dependency-page"}]) + t-before (storage/get-t sql) + parent-uuid (random-uuid) + child-uuid (random-uuid) + parent-eid "large-dependency-parent" + child-eid "large-dependency-child" + filler-tx (large-block-insert-tx page-uuid 70) + parent-tx [[:db/add parent-eid :block/uuid parent-uuid] + [:db/add parent-eid :block/title "large-dependency-parent"] + [:db/add parent-eid :block/page [:block/uuid page-uuid]] + [:db/add parent-eid :block/parent [:block/uuid page-uuid]] + [:db/add parent-eid :block/order "z0"] + [:db/add parent-eid :block/created-at 1] + [:db/add parent-eid :block/updated-at 1]] + child-tx [[:db/add child-eid :block/uuid child-uuid] + [:db/add child-eid :block/title "large-dependency-child"] + [:db/add child-eid :block/page [:block/uuid page-uuid]] + [:db/add child-eid :block/parent [:block/uuid parent-uuid]] + [:db/add child-eid :block/order "z1"] + [:db/add child-eid :block/created-at 2] + [:db/add child-eid :block/updated-at 2]] + tx-data (vec (concat filler-tx parent-tx child-tx)) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + tx-data-counts (atom []) + response (try + (d/listen! conn ::large-dependent-block-chunks + (fn [{:keys [tx-data]}] + (swap! tx-data-counts conj (count tx-data)))) + (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before)) + (finally + (d/unlisten! conn ::large-dependent-block-chunks)))] + (is (= "tx/batch/ok" (:type response))) + (is (= [497 7] @tx-data-counts)) + (is (= @tx-data-counts + (mapv (comp count protocol/transit->tx :tx) + (storage/fetch-tx-since sql t-before)))) + (is (= parent-uuid + (get-in (d/pull @conn [{:block/parent [:block/uuid]}] + [:block/uuid child-uuid]) + [:block/parent :block/uuid]))) + (is (= 70 (block-title-prefix-count @conn "large-op-block-"))) + (is (= 3 (block-title-prefix-count @conn "large-dependency-")))))))) + +(deftest tx-batch-applies-large-delete-entry-with-descendants-across-chunks-test + (testing "delete-blocks descendant expansion is still applied in ordered chunks" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + parent-uuid (random-uuid) + child-count 520 + child-uuids (repeatedly child-count random-uuid) + _ (d/transact! conn + (vec + (concat + [{:block/uuid page-uuid + :block/name "large-delete-page" + :block/title "large-delete-page"} + {:block/uuid parent-uuid + :block/title "large-delete-parent" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid] + :block/order "d0" + :block/created-at 1 + :block/updated-at 1}] + (map-indexed + (fn [idx child-uuid] + {:block/uuid child-uuid + :block/title (str "large-delete-child-" idx) + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid parent-uuid] + :block/order (str "d" (inc idx)) + :block/created-at idx + :block/updated-at idx}) + child-uuids)))) + t-before (storage/get-t sql) + tx-entry {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid parent-uuid]]]) + :tx-id (random-uuid) + :outliner-op :delete-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + tx-report-count (atom 0) + response (try + (d/listen! conn ::large-delete-block-chunks + (fn [_tx-report] + (swap! tx-report-count inc))) + (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before)) + (finally + (d/unlisten! conn ::large-delete-block-chunks)))] + (is (= "tx/batch/ok" (:type response))) + (is (> @tx-report-count 1)) + (is (> (:t response) t-before)) + (is (pos? (count (storage/fetch-tx-since sql t-before)))) + (is (nil? (d/entity @conn [:block/uuid parent-uuid]))) + (is (every? nil? (map #(d/entity @conn [:block/uuid %]) child-uuids))) + (is (zero? (block-title-prefix-count @conn "large-delete-child-")))))))) + +(deftest tx-batch-rolls-back-large-entry-when-live-chunk-fails-test + (testing "large tx live chunk failure leaves no persisted partial chunks" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-rollback-page" + :block/title "large-rollback-page"}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + missing-parent-uuid (random-uuid) + tx-data* (large-block-insert-tx page-uuid 1200) + [_op entity _attr _value tx] (nth tx-data* 150) + tx-data (assoc tx-data* + 150 [:db/add entity :block/page [:block/uuid missing-parent-uuid] tx]) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= t-before (storage/get-t sql))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (zero? (block-title-prefix-count @(.-conn self) "large-op-block-")))))))) + +(deftest tx-batch-rolls-back-large-entry-when-later-live-chunk-fails-test + (testing "large tx later chunk failure leaves no persisted partial chunks" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-later-rollback-page" + :block/title "large-later-rollback-page"}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + missing-parent-uuid (random-uuid) + tx-data* (large-block-insert-tx page-uuid 1200) + [_op entity _attr _value tx] (nth tx-data* 700) + tx-data (assoc tx-data* + 700 [:db/add entity :block/page [:block/uuid missing-parent-uuid] tx]) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= t-before (storage/get-t sql))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (zero? (block-title-prefix-count @(.-conn self) "large-op-block-")))))))) + +(deftest tx-batch-large-entry-uses-bounded-visible-tx-reports-test + (testing "large tx chunks are visible and each tx report stays bounded" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-validation-page" + :block/title "large-validation-page"}]) + t-before (storage/get-t sql) + block-count 1200 + tx-data (large-block-insert-tx page-uuid block-count) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + tx-data-counts (atom []) + response (try + (d/listen! conn ::large-visible-tx-report-size + (fn [{:keys [tx-data]}] + (swap! tx-data-counts conj (count tx-data)))) + (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before)) + (finally + (d/unlisten! conn ::large-visible-tx-report-size)))] + (is (= "tx/batch/ok" (:type response))) + (is (= (count tx-data) (reduce + @tx-data-counts))) + (is (every? #(<= % 500) @tx-data-counts))))))) + +(deftest tx-batch-large-entry-updates-checksum-once-for-logical-tx-test + (testing "large tx chunks append visible tx-log rows but update stored checksum once for the full logical tx" + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-checksum-page" + :block/title "large-checksum-page"}]) + t-before (storage/get-t sql) + block-count 1200 + tx-data (large-block-insert-tx page-uuid block-count) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + original-set-checksum! storage/set-checksum! + checksum-updates (atom []) + response (with-redefs [storage/set-checksum! (fn [sql* checksum] + (swap! checksum-updates conj checksum) + (original-set-checksum! sql* checksum)) + ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (> (:t response) (inc t-before))) + (is (= 1 (count @checksum-updates))) + (is (= (sync-checksum/recompute-checksum @conn) + (storage/get-checksum sql)))))))) + +(deftest finished-snapshot-upload-persists-provided-checksum-test + (async done + (let [sql (test-sql/make-sql) + checksum "1be70518babe8784" + conn (d/create-conn db-schema/schema) + self #js {:sql sql + :conn conn + :schema-ready true + :env #js {"DB" nil}} + request (js/Request. (str "http://localhost/sync/graph-1/snapshot/upload?graph-id=graph-1&finished=true&checksum=" checksum) + #js {:method "POST" + :body (js/Uint8Array. 0)})] + (d/transact! conn [{:block/uuid (random-uuid) + :block/title "uploaded"}]) + (is (nil? (storage/get-checksum sql))) + (-> (p/with-redefs [sync-handler/import-snapshot-stream! (fn [_self _stream _reset?] + (p/resolved 0)) + sync-handler/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 200 (.-status resp))) + (is (= {:ok true :count 0} body)) + (is (= checksum (storage/get-checksum sql))))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest snapshot-upload-returns-413-when-sqlite-row-is-too-large-test + (async done + (let [sql (test-sql/make-sql) + conn (d/create-conn db-schema/schema) + self #js {:sql sql + :conn conn + :schema-ready true + :env #js {"DB" nil}} + request (js/Request. "http://localhost/sync/graph-1/snapshot/upload?graph-id=graph-1&finished=true" + #js {:method "POST" + :body (js/Uint8Array. 0)})] + (-> (p/with-redefs [sync-handler/import-snapshot-stream! (fn [_self _stream _reset?] + (p/rejected (js/Error. "string or blob too big: SQLITE_TOOBIG"))) + sync-handler/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 413 (.-status resp))) + (is (= {:error "snapshot row too large"} body)))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest import-snapshot-stream-first-non-empty-chunk-applies-reset-test + (async done + (let [rows [[42 "payload" nil]] + frame (#'sync-handler/frame-bytes (snapshot/encode-rows rows)) + stream (js/ReadableStream. + #js {:start (fn [controller] + (.enqueue controller frame) + (.close controller))}) + applied (atom []) + self #js {:sql (test-sql/make-sql) + :conn (d/create-conn db-schema/schema) + :schema-ready true}] + (-> (p/with-redefs [sync-handler/import-snapshot! + (fn [_self rows* reset?] + (swap! applied conj {:rows rows* + :reset? reset?}))] + (p/let [count (#'sync-handler/import-snapshot-stream! self stream true)] + (is (= 1 count)) + (is (= [{:rows rows + :reset? true}] + @applied)))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest tx-batch-rejects-when-a-tx-entry-fails-test + (testing "db transact failure rejects the batch" + (let [sql (test-sql/make-sql) + conn (d/create-conn db-schema/schema) + self #js {:sql sql + :conn conn + :schema-ready true} + tx-entry-1 {:tx (protocol/tx->transit [[:db/add -1 :block/title "ok"]]) + :outliner-op :save-block} + tx-entry-2 {:tx (protocol/tx->transit [[:db/add -2 :block/title "bad"]]) + :outliner-op :save-block} + apply-calls (atom 0) + apply-tx-entry (fn [_conn tx-entry] + (swap! apply-calls inc) + (when (= 2 @apply-calls) + (throw (ex-info "DB write failed with invalid data" + {:tx-entry tx-entry})))) + response (with-redefs [ws/broadcast! (fn [& _] nil) + sync-handler/apply-tx-entry! (fn + ([conn tx-entry] + (apply-tx-entry conn tx-entry)) + ([_self conn tx-entry _request-context] + (apply-tx-entry conn tx-entry)))] + (sync-handler/handle-tx-batch! self nil [tx-entry-1 tx-entry-2] 0))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= 0 (:t response))) + (is (nil? (:data response))) + (is (= 2 @apply-calls))))) + +(deftest tx-batch-reject-includes-success-and-failed-tx-ids-test + (testing "partial failure returns success and failed tx ids and broadcasts changed once" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + success-tx-id (random-uuid) + failed-tx-id (random-uuid) + success-block-uuid (random-uuid) + missing-uuid (random-uuid) + tx-entry-1 {:tx-id success-tx-id + :tx (protocol/tx->transit [{:db/id -1 + :block/uuid success-block-uuid + :block/title "ok"}]) + :outliner-op :save-block} + tx-entry-2 {:tx-id failed-tx-id + :tx (protocol/tx->transit [[:db/add [:block/uuid missing-uuid] :block/title "stale" 1]]) + :outliner-op :save-block} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry-1 tx-entry-2] 0))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= 1 (:t response))) + (is (= [success-tx-id] (:success-tx-ids response))) + (is (= failed-tx-id (:failed-tx-id response))) + (is (= [{:type "changed" :t 1}] @changed-messages)) + (is (some? (d/entity @conn [:block/uuid success-block-uuid]))) + (is (nil? (d/entity @conn [:block/uuid missing-uuid])))))) + +(deftest tx-batch-accepts-stale-delete-as-idempotent-test + (doseq [outliner-op [:delete-blocks :delete-page]] + (testing (str outliner-op " succeeds when the target is already absent") + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + stale-delete-tx-id (random-uuid) + missing-delete-uuid (random-uuid) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + stale-delete-entry {:tx-id stale-delete-tx-id + :tx (protocol/tx->transit + [[:db/retractEntity [:block/uuid missing-delete-uuid]]]) + :outliner-op outliner-op} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [stale-delete-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (= t-before (:t response))) + (is (= checksum-before (:checksum response))) + (is (empty? @changed-messages)))))) + +(deftest tx-batch-rejects-empty-delete-input-test + (testing "an originally empty delete remains invalid" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + empty-delete-tx-id (random-uuid) + t-before (storage/get-t sql) + empty-delete-entry {:tx-id empty-delete-tx-id + :tx (protocol/tx->transit []) + :outliner-op :delete-blocks} + response (sync-handler/handle-tx-batch! self nil [empty-delete-entry] t-before)] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= empty-delete-tx-id (:failed-tx-id response)))))) + +(deftest tx-batch-reports-stale-delete-success-before-later-failure-test + (testing "an idempotent delete is acknowledged before a later tx fails" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + stale-delete-tx-id (random-uuid) + later-failed-tx-id (random-uuid) + missing-delete-uuid (random-uuid) + missing-update-uuid (random-uuid) + t-before (storage/get-t sql) + stale-delete-entry {:tx-id stale-delete-tx-id + :tx (protocol/tx->transit + [[:db/retractEntity [:block/uuid missing-delete-uuid]]]) + :outliner-op :delete-blocks} + later-failed-entry {:tx-id later-failed-tx-id + :tx (protocol/tx->transit + [[:db/add [:block/uuid missing-update-uuid] :block/title "stale" 1]]) + :outliner-op :save-block} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [stale-delete-entry later-failed-entry] t-before))] + (is (= "tx/reject" (:type response))) + (is (= "db transact failed" (:reason response))) + (is (= t-before (:t response))) + (is (= later-failed-tx-id (:failed-tx-id response))) + (is (= [stale-delete-tx-id] (:success-tx-ids response))) + (is (empty? @changed-messages))))) + +(deftest tx-batch-ignores-empty-rebase-entry-test + (testing "empty rebase entry is a no-op: no t increment, no tx-log append, no changed broadcast" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + t-before (storage/get-t sql) + tx-entry {:tx (protocol/tx->transit []) + :outliner-op :rebase} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (= t-before (:t response))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (empty? @changed-messages))))) + +(deftest tx-batch-mixed-empty-rebase-and-real-entry-test + (testing "empty rebase entry is ignored while real tx still applies" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + t-before (storage/get-t sql) + noop-rebase-entry {:tx (protocol/tx->transit []) + :outliner-op :rebase} + block-uuid (random-uuid) + real-entry {:tx (protocol/tx->transit [[:db/add -1 :block/uuid block-uuid] + [:db/add -1 :block/title "applied"]]) + :outliner-op :save-block} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [noop-rebase-entry real-entry] t-before)) + txs (storage/fetch-tx-since sql t-before)] + (is (= "tx/batch/ok" (:type response))) + (is (= (inc t-before) (:t response))) + (is (= 1 (count txs))) + (is (= :save-block (:outliner-op (first txs)))) + (is (= [{:type "changed" :t (inc t-before)}] @changed-messages))))) + +(deftest tx-batch-ignores-stale-rebase-with-missing-lookup-entity-test + (testing "stale rebase lookup refs to missing entities are treated as no-op" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + parent-uuid (random-uuid) + missing-block-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "rebase-stale-page" + :block/title "rebase-stale-page"} + {:block/uuid parent-uuid + :block/title "existing-parent" + :block/order "a0" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + tx-entry {:tx (protocol/tx->transit + [[:db/retract [:block/uuid missing-block-uuid] + :block/parent + [:block/uuid page-uuid] + 536882158] + [:db/add [:block/uuid missing-block-uuid] + :block/parent + [:block/uuid parent-uuid] + 536882158] + [:db/retract [:block/uuid missing-block-uuid] + :block/order + "a100001V" + 536882158] + [:db/add [:block/uuid missing-block-uuid] + :block/order + "a0" + 536882158]]) + :outliner-op :rebase} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (= t-before (:t response))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (empty? @changed-messages))))) + +(deftest tx-batch-acknowledges-noop-stale-rebase-with-tx-id-test + (testing "an accepted no-op should acknowledge its client tx id" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + tx-id (random-uuid) + page-uuid (random-uuid) + parent-uuid (random-uuid) + missing-block-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "rebase-stale-page-with-tx-id" + :block/title "rebase-stale-page-with-tx-id"} + {:block/uuid parent-uuid + :block/title "existing-parent" + :block/order "a0" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + tx-entry {:tx-id tx-id + :tx (protocol/tx->transit + [[:db/retract [:block/uuid missing-block-uuid] + :block/parent + [:block/uuid page-uuid] + 536882158] + [:db/add [:block/uuid missing-block-uuid] + :block/parent + [:block/uuid parent-uuid] + 536882158]]) + :outliner-op :rebase} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (= t-before (:t response))) + (is (nil? (:failed-tx-id response))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (empty? @changed-messages))))) + +(deftest tx-batch-ignores-stale-fix-with-missing-lookup-entity-test + (testing "stale fix lookup refs to missing entities are treated as no-op" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + sibling-uuid (random-uuid) + missing-block-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "fix-stale-page" + :block/title "fix-stale-page"} + {:block/uuid sibling-uuid + :block/title "existing-sibling" + :block/order "a5Uzl" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + tx-entry {:tx (protocol/tx->transit + [[:db/retract [:block/uuid missing-block-uuid] + :block/order + "a5Uzl" + 536871101] + [:db/add [:block/uuid missing-block-uuid] + :block/order + "a5c" + 536871101] + [:db/retract [:block/uuid sibling-uuid] + :block/order + "a5Uzl" + 536871101] + [:db/add [:block/uuid sibling-uuid] + :block/order + "a5k" + 536871101]]) + :outliner-op :fix} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (= t-before (:t response))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (empty? @changed-messages))))) + +(deftest server-incremental-checksum-matches-full-recompute-fuzz-test + (testing "server stored checksum stays equal to full recompute across randomized tx/rebase/no-op sequences" + (doseq [seed (range 1 11)] + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + root-block-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name (str "server-fuzz-page-" seed) + :block/title (str "server-fuzz-page-" seed)} + {:block/uuid root-block-uuid + :block/title (str "server-fuzz-root-" seed) + :block/order "a0" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]}]) + rng (seeded-rng seed)] + (loop [step 0 + prev-t (storage/get-t sql) + prev-checksum (storage/get-checksum sql)] + (when (< step 60) + (let [entry (gen-server-tx-entry rng @conn step) + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [entry] prev-t)) + new-t (:t response) + stored-checksum (storage/get-checksum sql) + recomputed-checksum (sync-checksum/recompute-checksum @conn)] + (is (= "tx/batch/ok" (:type response)) + (str "expected tx/batch/ok at seed " seed " step " step)) + (is (= new-t (storage/get-t sql)) + (str "t mismatch at seed " seed " step " step)) + (if (> new-t prev-t) + (do + (is (string? stored-checksum) + (str "stored checksum missing after mutation at seed " seed " step " step)) + (is (= recomputed-checksum stored-checksum) + (str "checksum mismatch at seed " seed " step " step + " recomputed=" recomputed-checksum + " stored=" stored-checksum))) + (is (= prev-checksum stored-checksum) + (str "checksum changed on no-op batch at seed " seed " step " step))) + (recur (inc step) new-t stored-checksum)))))))) + +(deftest server-checksum-is-invariant-across-commuting-batch-order-test + (testing "server checksum converges when commuting tx entries are applied in opposite order" + (let [page-uuid (random-uuid) + block-a-uuid (random-uuid) + block-b-uuid (random-uuid) + seed-db! (fn [conn] + (d/transact! conn [{:block/uuid page-uuid + :block/name "server-order-page" + :block/title "server-order-page"} + {:block/uuid block-a-uuid + :block/title "A0" + :block/order "a0" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]} + {:block/uuid block-b-uuid + :block/title "B0" + :block/order "a1" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]}])) + entry-a {:tx (protocol/tx->transit [[:db/add [:block/uuid block-a-uuid] + :block/title + "A1"]]) + :outliner-op :save-block} + entry-b {:tx (protocol/tx->transit [[:db/add [:block/uuid block-b-uuid] + :block/order + "a9"]]) + :outliner-op :save-block} + {:keys [self conn sql]} (make-server-self) + _ (seed-db! conn) + _ (apply-entries! self [entry-a entry-b]) + checksum-ab (storage/get-checksum sql) + recompute-ab (sync-checksum/recompute-checksum @conn) + pull-ab [(d/pull @conn [:block/title :block/order] [:block/uuid block-a-uuid]) + (d/pull @conn [:block/title :block/order] [:block/uuid block-b-uuid])] + {:keys [self conn sql]} (make-server-self) + _ (seed-db! conn) + _ (apply-entries! self [entry-b entry-a]) + checksum-ba (storage/get-checksum sql) + recompute-ba (sync-checksum/recompute-checksum @conn) + pull-ba [(d/pull @conn [:block/title :block/order] [:block/uuid block-a-uuid]) + (d/pull @conn [:block/title :block/order] [:block/uuid block-b-uuid])]] + (is (= recompute-ab checksum-ab)) + (is (= recompute-ba checksum-ba)) + (is (= checksum-ab checksum-ba)) + (is (= pull-ab pull-ba))))) + +(deftest server-checksum-is-invariant-across-tx-partitioning-test + (testing "server checksum converges when identical tx-data is sent as one entry or split entries" + (let [page-uuid (random-uuid) + block-a-uuid (random-uuid) + block-b-uuid (random-uuid) + seed-db! (fn [conn] + (d/transact! conn [{:block/uuid page-uuid + :block/name "server-partition-page" + :block/title "server-partition-page"} + {:block/uuid block-a-uuid + :block/title "A0" + :block/order "a0" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]} + {:block/uuid block-b-uuid + :block/title "B0" + :block/order "a1" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]}])) + datom-a [:db/add [:block/uuid block-a-uuid] :block/title "A2"] + datom-b [:db/add [:block/uuid block-b-uuid] :block/order "a8"] + one-entry {:tx (protocol/tx->transit [datom-a datom-b]) + :outliner-op :save-block} + split-entry-a {:tx (protocol/tx->transit [datom-a]) + :outliner-op :save-block} + split-entry-b {:tx (protocol/tx->transit [datom-b]) + :outliner-op :save-block} + {:keys [self conn sql]} (make-server-self) + _ (seed-db! conn) + _ (apply-entries! self [one-entry]) + checksum-one (storage/get-checksum sql) + recompute-one (sync-checksum/recompute-checksum @conn) + pull-one [(d/pull @conn [:block/title :block/order] [:block/uuid block-a-uuid]) + (d/pull @conn [:block/title :block/order] [:block/uuid block-b-uuid])] + {:keys [self conn sql]} (make-server-self) + _ (seed-db! conn) + _ (apply-entries! self [split-entry-a split-entry-b]) + checksum-split (storage/get-checksum sql) + recompute-split (sync-checksum/recompute-checksum @conn) + pull-split [(d/pull @conn [:block/title :block/order] [:block/uuid block-a-uuid]) + (d/pull @conn [:block/title :block/order] [:block/uuid block-b-uuid])]] + (is (= recompute-one checksum-one)) + (is (= recompute-split checksum-split)) + (is (= checksum-one checksum-split)) + (is (= pull-one pull-split))))) + +(deftest server-checksum-remains-correct-under-random-outliner-conflicts-test + (testing "random insert/move/indent/outdent/delete with stale-client conflicts and undo/redo keeps checksum correct" + (doseq [seed (range 31 35)] + (let [{:keys [self conn sql]} (make-server-self) + page-uuid (random-uuid) + root-uuid (random-uuid) + child-a-uuid (random-uuid) + child-b-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name (str "outliner-fuzz-page-" seed) + :block/title (str "outliner-fuzz-page-" seed)} + {:block/uuid root-uuid + :block/title "root" + :block/order "a0" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]} + {:block/uuid child-a-uuid + :block/title "child-a" + :block/order "a1" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid root-uuid]} + {:block/uuid child-b-uuid + :block/title "child-b" + :block/order "a2" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid root-uuid]}]) + rng (seeded-rng (* seed 7919))] + (loop [step 0 + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + undo-stack [] + redo-stack []] + (when (< step 80) + (let [db @conn + op (rand-int* rng 11)] + (cond + ;; explicit conflict scenario: delete parent then stale client inserts child under deleted parent + (= op 0) + (if-let [{:keys [delete-entry stale-add-entry]} (make-stale-add-after-delete-conflict rng db step)] + (let [delete-response (apply-batch-with-t! self t-before [delete-entry]) + delete-state (assert-server-checksum-step! sql conn t-before checksum-before delete-response + (str "seed " seed " step " step " delete-before-stale-add")) + stale-response (apply-batch-with-t! self (:t delete-state) [stale-add-entry]) + stale-state (assert-server-checksum-step! sql conn (:t delete-state) (:checksum delete-state) stale-response + (str "seed " seed " step " step " stale-add-after-delete"))] + (is (= "tx/reject" (:type stale-response)) + (str "seed " seed " step " step " stale child insert should be rejected")) + (recur (inc step) (:t stale-state) (:checksum stale-state) undo-stack redo-stack)) + (let [noop-response (apply-batch-with-t! self t-before [(no-op-rebase-entry)]) + noop-state (assert-server-checksum-step! sql conn t-before checksum-before noop-response + (str "seed " seed " step " step " fallback-noop"))] + (recur (inc step) (:t noop-state) (:checksum noop-state) undo-stack redo-stack))) + + ;; undo + (= op 1) + (if-let [{:keys [forward inverse]} (peek undo-stack)] + (let [entries (if (tx-entries-applicable? db inverse) + inverse + [(no-op-rebase-entry)]) + response (apply-batch-with-t! self t-before entries) + state (assert-server-checksum-step! sql conn t-before checksum-before response + (str "seed " seed " step " step " undo"))] + (recur (inc step) + (:t state) + (:checksum state) + (pop undo-stack) + (if (:advanced? state) + (conj redo-stack {:forward forward :inverse inverse}) + redo-stack))) + (let [noop-response (apply-batch-with-t! self t-before [(no-op-rebase-entry)]) + noop-state (assert-server-checksum-step! sql conn t-before checksum-before noop-response + (str "seed " seed " step " step " undo-noop"))] + (recur (inc step) (:t noop-state) (:checksum noop-state) undo-stack redo-stack))) + + ;; redo + (= op 2) + (if-let [{:keys [forward inverse]} (peek redo-stack)] + (let [entries (if (tx-entries-applicable? db forward) + forward + [(no-op-rebase-entry)]) + response (apply-batch-with-t! self t-before entries) + state (assert-server-checksum-step! sql conn t-before checksum-before response + (str "seed " seed " step " step " redo"))] + (recur (inc step) + (:t state) + (:checksum state) + (if (:advanced? state) + (conj undo-stack {:forward forward :inverse inverse}) + undo-stack) + (pop redo-stack))) + (let [noop-response (apply-batch-with-t! self t-before [(no-op-rebase-entry)]) + noop-state (assert-server-checksum-step! sql conn t-before checksum-before noop-response + (str "seed " seed " step " step " redo-noop"))] + (recur (inc step) (:t noop-state) (:checksum noop-state) undo-stack redo-stack))) + + :else + (let [command (case op + 3 (make-insert-command rng db step) + 4 (make-random-move-command rng db step) + 5 (make-random-indent-command rng db step) + 6 (make-random-outdent-command rng db step) + 7 (make-title-command rng db step) + 8 {:forward [(make-random-delete-entry rng db)] + :undoable? false} + 9 (make-random-move-command rng db step) + 10 (make-random-indent-command rng db step) + {:forward [(no-op-rebase-entry)] + :undoable? false}) + entries (if (tx-entries-applicable? db (:forward command)) + (:forward command) + [(no-op-rebase-entry)]) + response (apply-batch-with-t! self t-before entries) + state (assert-server-checksum-step! sql conn t-before checksum-before response + (str "seed " seed " step " step " op " op)) + command-applied? (and (:undoable? command) (:advanced? state)) + next-undo (if command-applied? + (conj undo-stack {:forward (:forward command) + :inverse (:inverse command)}) + undo-stack) + next-redo (if (:advanced? state) [] redo-stack)] + (recur (inc step) (:t state) (:checksum state) next-undo next-redo)))))))))) + +(defn- seed-page-with-block-tree! + [conn] + (let [page-uuid (random-uuid) + parent-uuid (random-uuid) + child-a-uuid (random-uuid) + child-b-uuid (random-uuid) + now 1775549093572] + (d/transact! conn [{:block/uuid page-uuid + :block/name "sync-repro-page" + :block/title "sync-repro-page" + :block/created-at now + :block/updated-at now} + {:block/uuid parent-uuid + :block/title "parent" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid] + :block/order "a0" + :block/created-at now + :block/updated-at now} + {:block/uuid child-a-uuid + :block/title "child-a" + :block/parent [:block/uuid parent-uuid] + :block/page [:block/uuid page-uuid] + :block/order "a1" + :block/created-at now + :block/updated-at now} + {:block/uuid child-b-uuid + :block/title "child-b" + :block/parent [:block/uuid parent-uuid] + :block/page [:block/uuid page-uuid] + :block/order "a2" + :block/created-at now + :block/updated-at now}]) + {:page-uuid page-uuid + :parent-uuid parent-uuid + :child-a-uuid child-a-uuid + :child-b-uuid child-b-uuid})) + +(deftest tx-batch-stale-retract-block-includes-current-descendants-test + (testing "stale block retract should still delete descendants attached in current db" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + {:keys [parent-uuid child-a-uuid child-b-uuid]} (seed-page-with-block-tree! conn) + t-before (storage/get-t sql) + stale-delete-entry {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid parent-uuid]]]) + :outliner-op :delete-blocks} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [stale-delete-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (number? (:t response))) + (is (nil? (d/entity @conn [:block/uuid parent-uuid]))) + (is (nil? (d/entity @conn [:block/uuid child-a-uuid]))) + (is (nil? (d/entity @conn [:block/uuid child-b-uuid])))))) + +(deftest tx-batch-stale-retract-page-includes-current-page-tree-test + (testing "stale page retract should still delete page tree to avoid orphan blocks" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + {:keys [page-uuid parent-uuid child-a-uuid child-b-uuid]} (seed-page-with-block-tree! conn) + t-before (storage/get-t sql) + stale-delete-entry {:tx (protocol/tx->transit [[:db/retractEntity [:block/uuid page-uuid]]]) + :outliner-op :delete-page} + response (with-redefs [ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [stale-delete-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (number? (:t response))) + (is (nil? (d/entity @conn [:block/uuid page-uuid]))) + (is (nil? (d/entity @conn [:block/uuid parent-uuid]))) + (is (nil? (d/entity @conn [:block/uuid child-a-uuid]))) + (is (nil? (d/entity @conn [:block/uuid child-b-uuid])))))) + +(deftest sync-pull-is-blocked-when-graph-is-not-ready-for-use-test + (async done + (let [self #js {:env #js {"DB" :db} + :sql (empty-sql)} + {:keys [request url]} (request-url "/sync/graph-1/pull?graph-id=graph-1&since=0")] + (-> (p/with-redefs [index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 409 (.-status resp))) + (is (= "graph not ready" (:error body))))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) + +(deftest snapshot-download-is-blocked-when-graph-is-not-ready-for-use-test + (async done + (let [bucket #js {:put (fn [& _] + (throw (js/Error. "should-not-upload-snapshot")))} + self #js {:env #js {"DB" :db + "LOGSEQ_SYNC_ASSETS" bucket} + :sql (empty-sql)} + {:keys [request url]} (request-url)] + (-> (p/with-redefs [index/clj (js/JSON.parse text) :keywordize-keys true)] + (is (= 409 (.-status resp))) + (is (= "graph not ready" (:error body))))) + (p/then (fn [] + (done))) + (p/catch (fn [error] + (is false (str error)) + (done))))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_handler_ws_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_handler_ws_test.cljs new file mode 100644 index 0000000..f7f4137 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_handler_ws_test.cljs @@ -0,0 +1,190 @@ +(ns logseq.db-sync.worker-handler-ws-test + (:require [cljs-bean.core :as bean] + [cljs.test :refer [async deftest is]] + [datascript.core :as d] + [logseq.db-sync.protocol :as protocol] + [logseq.db-sync.storage :as storage] + [logseq.db-sync.test-sql :as test-sql] + [logseq.db-sync.worker.handler.sync :as sync-handler] + [logseq.db-sync.worker.handler.ws :as ws-handler] + [logseq.db-sync.worker.presence :as presence] + [logseq.db-sync.worker.ws :as ws] + [promesa.core :as p])) + +(deftest presence-message-broadcast-excludes-source-client-test + (let [source-ws #js {:readyState 1} + peer-ws #js {:readyState 1} + send-events (atom []) + self #js {:state #js {:getWebSockets (fn [] #js [source-ws peer-ws])}} + raw (protocol/encode-message {:type "presence" + :editing-block-uuid "block-1"})] + (with-redefs [presence/get-user (fn [_self _ws] {:user-id "user-1"}) + presence/update-presence! (fn [_self _ws _patch] nil) + ws/send! (fn [target msg] + (swap! send-events conj {:ws target + :msg msg}))] + (ws-handler/handle-ws-message! self source-ws raw)) + + (is (= [peer-ws] + (mapv :ws @send-events))) + (is (= [{:type "presence" + :editing-block-uuid "block-1" + :user-id "user-1"}] + (mapv :msg @send-events))))) + +(deftest hello-message-includes-checksum-test + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + ws #js {:readyState 1} + sent (atom nil) + self #js {:conn conn + :schema-ready true + :sql sql} + raw (protocol/encode-message {:type "hello" + :client "test"})] + (d/transact! conn [{:block/uuid (random-uuid) + :block/title "hello"}]) + (with-redefs [ws/send! (fn [_target msg] + (reset! sent msg))] + (ws-handler/handle-ws-message! self ws raw)) + (is (= "hello" (:type @sent))) + (is (number? (:t @sent))) + (is (string? (:checksum @sent))))) + +(deftest hello-message-omits-nil-checksum-test + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + ws #js {:readyState 1} + sent (atom nil) + self #js {:conn conn + :schema-ready true + :sql sql} + raw (protocol/encode-message {:type "hello" + :client "test"})] + (with-redefs [ws/send! (fn [_target msg] + (reset! sent msg))] + (ws-handler/handle-ws-message! self ws raw)) + (is (= "hello" (:type @sent))) + (is (false? (contains? @sent :checksum))))) + +(deftest tx-batch-message-adds-graph-and-user-context-to-transact-meta-test + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + ws #js {:readyState 1} + sent (atom nil) + tx-metas (atom []) + self #js {:conn conn + :graph-id "graph-ws" + :schema-ready true + :sql sql} + raw (protocol/encode-message + {:type "tx/batch" + :client-revision "revision-ws" + :t-before 0 + :txs [{:tx (protocol/tx->transit [[:db/add -1 :block/title "ws context"]]) + :outliner-op :save-block}]})] + (with-redefs [presence/get-user (fn [_self _ws] + {:user-id "user-1" + :username "alice"}) + ws/broadcast! (fn [& _] nil) + ws/send! (fn [_target msg] + (reset! sent msg))] + (d/listen! conn ::capture-ws-context-tx-meta + (fn [tx-report] + (swap! tx-metas conj (:tx-meta tx-report)))) + (try + (ws-handler/handle-ws-message! self ws raw) + (finally + (d/unlisten! conn ::capture-ws-context-tx-meta)))) + (is (= "tx/batch/ok" (:type @sent))) + (is (some #(= {:op :apply-client-tx + :outliner-op :save-block + :graph-id "graph-ws" + :client-revision "revision-ws" + :username "alice"} + (select-keys % [:op :outliner-op :graph-id :client-revision :username])) + @tx-metas)))) + +(deftest ws-send-serializes-tx-reject-uuids-as-strings-test + (let [raw* (atom nil) + ws #js {:readyState 1 + :send (fn [raw] (reset! raw* raw))} + success-tx-id (random-uuid) + failed-tx-id (random-uuid)] + (ws/send! ws {:type "tx/reject" + :reason "db transact failed" + :t 3 + :success-tx-ids [success-tx-id] + :failed-tx-id failed-tx-id}) + (let [message (-> @raw* js/JSON.parse (js->clj :keywordize-keys true))] + (is (= "tx/reject" (:type message))) + (is (= [(str success-tx-id)] (:success-tx-ids message))) + (is (= (str failed-tx-id) (:failed-tx-id message)))))) + +(deftest online-users-broadcast-restored-attachment-user-test + (let [attachment* (atom nil) + ws #js {:readyState 1 + :serializeAttachment (fn [attachment] + (reset! attachment* attachment)) + :deserializeAttachment (fn [] + @attachment*)} + self #js {} + restored-self #js {} + user {:user-id "user-1" + :email "user@example.com" + :username "alice"} + sent (atom nil)] + (presence/add-presence! self ws user) + (is (= {:presence/user user} + (bean/->clj (.deserializeAttachment ws)))) + (swap! (presence/presence* restored-self) + assoc + ws + (presence/attachment->user (.deserializeAttachment ws))) + (with-redefs [ws/broadcast! (fn [_self _sender message] + (reset! sent message))] + (presence/broadcast-online-users! restored-self)) + (is (= {:type "online-users" + :online-users [user]} + @sent)))) + +(deftest websocket-connection-is-rejected-while-snapshot-upload-is-in-progress-test + (async done + (let [accepted (atom []) + presence-events (atom []) + self #js {:state #js {:acceptWebSocket (fn [socket] + (swap! accepted conj socket))}} + request (js/Request. "http://localhost/sync/graph-1/ws?graph-id=graph-1" + #js {:method "GET"})] + (-> (p/with-redefs [sync-handler/ (p/with-redefs [sync-handler/ sql string/trim string/lower-case) "select")) + +(defn- run-sql + [^js stmt args] + (.apply (.-run stmt) stmt (to-array args))) + +(defn- all-sql + [^js stmt args] + (.apply (.-all stmt) stmt (to-array args))) + +(defn- with-memory-sql + [f] + (let [db (new sqlite ":memory:" nil) + sql #js {:_db db + :exec (fn [sql-str & args] + (let [stmt (.prepare db sql-str)] + (if (select-sql? sql-str) + (all-sql stmt args) + (do + (run-sql stmt args) + nil)))) + :close (fn [] + (.close db))}] + (try + (f sql) + (finally + (.close sql))))) + +(defn- large-block-insert-tx + [page-uuid block-count] + (let [block-uuids (mapv (fn [_idx] (random-uuid)) (range block-count))] + {:block-uuids block-uuids + :tx-data + (vec + (mapcat (fn [idx] + (let [block-uuid (nth block-uuids idx) + eid (str block-uuid)] + [[:db/add eid :block/uuid block-uuid idx] + [:db/add eid :block/title (str "large-memory-block-" idx) idx] + [:db/add eid :block/page [:block/uuid page-uuid] idx] + [:db/add eid :block/parent [:block/uuid page-uuid] idx] + [:db/add eid :block/order "a0" idx] + [:db/add eid :block/created-at idx idx] + [:db/add eid :block/updated-at idx idx]])) + (range block-count)))})) + +(defn- sample-blocks-present? + [db block-uuids] + (every? (fn [idx] + (some? (d/entity db [:block/uuid (nth block-uuids idx)]))) + [0 (quot (count block-uuids) 2) (dec (count block-uuids))])) + +(defn- assert! + [condition message] + (when-not condition + (throw (js/Error. message)))) + +(defn- run-large-op-memory-test! + [{:keys [skip-final-store? skip-validation?]}] + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-memory-page" + :block/title "large-memory-page"}]) + t-before (storage/get-t sql) + {:keys [tx-data block-uuids]} (large-block-insert-tx page-uuid 2000) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks} + self #js {:sql sql + :conn conn + :schema-ready true} + tx-report-count (atom 0) + response (try + (d/listen! conn ::large-op-memory-chunks + (fn [_tx-report] + (swap! tx-report-count inc))) + (with-redefs [d/store (if skip-final-store? + (fn [_db] nil) + d/store) + db-validate/validate-tx-report + (if skip-validation? + (fn [_tx-report _options] [true nil]) + db-validate/validate-tx-report) + ws/broadcast! (fn [& _] nil)] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before)) + (finally + (d/unlisten! conn ::large-op-memory-chunks)))] + (assert! (= "tx/batch/ok" (:type response)) + (str "expected tx/batch/ok, got " (pr-str response))) + (assert! (> @tx-report-count 1) + (str "expected more than one chunk, got " @tx-report-count)) + (assert! (= (+ t-before @tx-report-count) (:t response)) + (str "expected response t to advance by visible chunks, got " (:t response))) + (assert! (= @tx-report-count (count (storage/fetch-tx-since sql t-before))) + "expected large tx chunks to be persisted as visible tx log entries") + (assert! (sample-blocks-present? @conn block-uuids) + "expected sampled large tx blocks to be persisted"))))) + +(defn- run-client-split-large-op-memory-test! + [{:keys [serial?]}] + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "large-memory-page" + :block/title "large-memory-page"}]) + t-before (storage/get-t sql) + {:keys [tx-data block-uuids]} (large-block-insert-tx page-uuid 2000) + tx-id (random-uuid) + tx-entries (mapv (fn [chunk] + {:tx (protocol/tx->transit chunk) + :tx-id tx-id + :outliner-op :insert-blocks}) + (partition-all 392 tx-data)) + self #js {:sql sql + :conn conn + :schema-ready true} + tx-report-count (atom 0) + response (try + (d/listen! conn ::large-op-memory-client-split + (fn [_tx-report] + (swap! tx-report-count inc))) + (with-redefs [ws/broadcast! (fn [& _] nil)] + (if serial? + (loop [t-before* t-before + [tx-entry & more] tx-entries + response nil] + (if tx-entry + (let [response* (sync-handler/handle-tx-batch! self nil [tx-entry] t-before*)] + (assert! (= "tx/batch/ok" (:type response*)) + (str "expected tx/batch/ok, got " (pr-str response*))) + (recur (:t response*) more response*)) + response)) + (sync-handler/handle-tx-batch! self nil tx-entries t-before))) + (finally + (d/unlisten! conn ::large-op-memory-client-split)))] + (assert! (= "tx/batch/ok" (:type response)) + (str "expected tx/batch/ok, got " (pr-str response))) + (assert! (= (count tx-entries) @tx-report-count) + (str "expected one tx report per client split entry, got " @tx-report-count)) + (assert! (= (+ t-before @tx-report-count) (:t response)) + (str "expected response t to advance by client split entries, got " (:t response))) + (assert! (= @tx-report-count (count (storage/fetch-tx-since sql t-before))) + "expected client split tx entries to be persisted as visible tx log entries") + (assert! (sample-blocks-present? @conn block-uuids) + "expected sampled client split blocks to be persisted"))))) + +(defn- run-open-sql-memory-test! + [] + (with-memory-sql + (fn [sql] + (storage/init-schema! sql) + (let [conn (storage/open-conn sql) + page-uuid (random-uuid)] + (d/transact! conn [{:block/uuid page-uuid + :block/name "large-memory-page" + :block/title "large-memory-page"}]) + (assert! (some? (d/entity @conn [:block/uuid page-uuid])) + "expected setup page to be persisted"))))) + +(defn- run-prepare-memory-test! + [] + (let [page-uuid (random-uuid) + {:keys [tx-data]} (large-block-insert-tx page-uuid 2000) + tx-entry {:tx (protocol/tx->transit tx-data) + :tx-id (random-uuid) + :outliner-op :insert-blocks}] + (assert! (= 14000 (count tx-data)) + "expected generated tx data") + (assert! (string? (:tx tx-entry)) + "expected transit tx payload"))) + +(defn- run-mode! + [mode] + (case mode + "baseline" nil + "open-sql" (run-open-sql-memory-test!) + "prepare" (run-prepare-memory-test!) + "apply" (run-large-op-memory-test! nil) + "apply-client-split" (run-client-split-large-op-memory-test! nil) + "apply-client-split-serial" (run-client-split-large-op-memory-test! {:serial? true}) + "apply-no-store" (run-large-op-memory-test! {:skip-final-store? true}) + "apply-no-validation" (run-large-op-memory-test! {:skip-validation? true}) + "apply-no-store-validation" (run-large-op-memory-test! {:skip-final-store? true + :skip-validation? true}) + (run-large-op-memory-test! nil))) + +(defn main [& args] + (try + (let [mode (or (first args) "apply")] + (run-mode! mode) + (js/console.log (str "large op memory test passed: " mode))) + (js/process.exit 0) + (catch :default error + (js/console.error error) + (js/process.exit 1)))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_routes_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_routes_test.cljs new file mode 100644 index 0000000..25f1847 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_routes_test.cljs @@ -0,0 +1,89 @@ +(ns logseq.db-sync.worker-routes-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.db-sync.worker.routes.index :as routes] + [logseq.db-sync.worker.routes.semantic :as semantic-routes])) + +(deftest match-route-graphs-test + (testing "graphs routes" + (let [match (routes/match-route "GET" "/graphs")] + (is (= :graphs/list (:handler match)))) + (let [match (routes/match-route "POST" "/graphs")] + (is (= :graphs/create (:handler match)))) + (let [match (routes/match-route "GET" "/graphs/graph-1/access")] + (is (= :graphs/access (:handler match))) + (is (= "graph-1" (get-in match [:path-params :graph-id])))) + (let [match (routes/match-route "GET" "/graphs/graph-2/members")] + (is (= :graph-members/list (:handler match))) + (is (= "graph-2" (get-in match [:path-params :graph-id])))) + (let [match (routes/match-route "POST" "/graphs/graph-3/members")] + (is (= :graph-members/create (:handler match)))) + (let [match (routes/match-route "PUT" "/graphs/graph-4/members/user-9")] + (is (= :graph-members/update (:handler match))) + (is (= "graph-4" (get-in match [:path-params :graph-id]))) + (is (= "user-9" (get-in match [:path-params :member-id])))) + (let [match (routes/match-route "DELETE" "/graphs/graph-5")] + (is (= :graphs/delete (:handler match))) + (is (= "graph-5" (get-in match [:path-params :graph-id])))))) + +(deftest match-route-e2ee-test + (testing "e2ee routes" + (let [match (routes/match-route "GET" "/e2ee/user-keys")] + (is (= :e2ee/user-keys-get (:handler match)))) + (let [match (routes/match-route "POST" "/e2ee/user-keys")] + (is (= :e2ee/user-keys-post (:handler match)))) + (let [match (routes/match-route "GET" "/e2ee/user-public-key")] + (is (= :e2ee/user-public-key-get (:handler match)))) + (let [match (routes/match-route "GET" "/e2ee/graphs/graph-7/aes-key")] + (is (= :e2ee/graph-aes-key-get (:handler match))) + (is (= "graph-7" (get-in match [:path-params :graph-id])))) + (let [match (routes/match-route "POST" "/e2ee/graphs/graph-8/aes-key")] + (is (= :e2ee/graph-aes-key-post (:handler match))) + (is (= "graph-8" (get-in match [:path-params :graph-id])))) + (let [match (routes/match-route "POST" "/e2ee/graphs/graph-9/grant-access")] + (is (= :e2ee/grant-access (:handler match))) + (is (= "graph-9" (get-in match [:path-params :graph-id])))))) + +(deftest match-route-method-mismatch-test + (testing "method mismatch returns nil" + (is (nil? (routes/match-route "GET" "/graphs/graph-1/members/user-9"))) + (is (nil? (routes/match-route "PUT" "/e2ee/user-keys"))))) + +(deftest semantic-internal-routes-ignore-public-only-operations-test + (is (nil? (semantic-routes/match-internal "GET" "/api/v1/graphs"))) + (is (= :semantic/pages-list + (:handler (semantic-routes/match-internal "GET" "/semantic/pages"))))) + +(deftest semantic-move-blocks-route-test + (is (= :semantic/blocks-move + (:handler (semantic-routes/match-public "POST" "/api/v1/graphs/graph-1/block-moves")))) + (is (= :semantic/blocks-move + (:handler (semantic-routes/match-internal "POST" "/semantic/block-moves")))) + (is (nil? (semantic-routes/match-public "POST" "/api/v1/graphs/graph-1/blocks/block-1/move")))) + +(deftest semantic-reverse-reference-routes-test + (is (= :semantic/tags-objects + (:handler (semantic-routes/match-public + "GET" "/api/v1/graphs/graph-1/tags/tag-1/objects")))) + (is (= :semantic/tags-objects + (:handler (semantic-routes/match-internal "GET" "/semantic/tags/tag-1/objects")))) + (is (= :semantic/pages-references + (:handler (semantic-routes/match-public + "GET" "/api/v1/graphs/graph-1/pages/page-1/references")))) + (is (= :semantic/pages-references + (:handler (semantic-routes/match-internal "GET" "/semantic/pages/page-1/references"))))) + +(deftest semantic-task-routes-test + (doseq [[method handler] [["GET" :semantic/tasks-list] + ["POST" :semantic/tasks-create]]] + (is (= handler + (:handler (semantic-routes/match-public method "/api/v1/graphs/graph-1/tasks")))) + (is (= handler + (:handler (semantic-routes/match-internal method "/semantic/tasks")))))) + +(deftest semantic-asset-collection-routes-test + (doseq [[method handler] [["GET" :semantic/assets-list] + ["POST" :semantic/assets-create]]] + (is (= handler + (:handler (semantic-routes/match-public method "/api/v1/graphs/graph-1/assets")))) + (is (= handler + (:handler (semantic-routes/match-internal method "/semantic/assets")))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_sync_routes_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_sync_routes_test.cljs new file mode 100644 index 0000000..639c591 --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_sync_routes_test.cljs @@ -0,0 +1,27 @@ +(ns logseq.db-sync.worker-sync-routes-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.db-sync.worker.routes.sync :as sync-routes])) + +(deftest match-route-sync-test + (testing "sync routes" + (let [match (sync-routes/match-route "GET" "/health")] + (is (= :sync/health (:handler match)))) + (let [match (sync-routes/match-route "GET" "/pull")] + (is (= :sync/pull (:handler match)))) + (is (nil? (sync-routes/match-route "GET" "/repair/blocks"))) + (let [match (sync-routes/match-route "GET" "/snapshot/download")] + (is (= :sync/snapshot-download (:handler match)))) + (let [match (sync-routes/match-route "GET" "/snapshot/stream")] + (is (= :sync/snapshot-stream (:handler match)))) + (let [match (sync-routes/match-route "DELETE" "/admin/reset")] + (is (= :sync/admin-reset (:handler match)))) + (let [match (sync-routes/match-route "POST" "/tx/batch")] + (is (= :sync/tx-batch (:handler match)))) + (let [match (sync-routes/match-route "POST" "/snapshot/upload")] + (is (= :sync/snapshot-upload (:handler match)))))) + +(deftest match-route-sync-method-mismatch-test + (testing "sync method mismatch returns nil" + (is (nil? (sync-routes/match-route "POST" "/health"))) + (is (nil? (sync-routes/match-route "GET" "/admin/reset"))) + (is (nil? (sync-routes/match-route "PUT" "/tx/batch"))))) diff --git a/deps/db-sync/test/logseq/db_sync/worker_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_test.cljs new file mode 100644 index 0000000..bb52c2e --- /dev/null +++ b/deps/db-sync/test/logseq/db_sync/worker_test.cljs @@ -0,0 +1,30 @@ +(ns logseq.db-sync.worker-test + (:require [cljs.test :refer [deftest is]] + [datascript.core :as d] + [logseq.db-sync.order :as sync-order] + [logseq.db.common.order :as db-order] + [logseq.db.frontend.schema :as db-schema])) + +(defn- new-conn [] + (d/create-conn db-schema/schema)) + +(deftest duplicate-order-fix-test + (let [conn (new-conn) + parent (random-uuid) + block-a (random-uuid) + block-b (random-uuid) + order-a (db-order/gen-key nil nil) + order-b (db-order/gen-key order-a nil)] + (d/transact! conn [{:block/uuid parent} + {:block/uuid block-a + :block/parent [:block/uuid parent] + :block/order order-a} + {:block/uuid block-b + :block/parent [:block/uuid parent] + :block/order order-b}]) + (let [tx [[:db/add [:block/uuid block-b] :block/order order-a]] + _ (sync-order/fix-duplicate-orders! conn tx {}) + order-a' (:block/order (d/entity @conn [:block/uuid block-a])) + order-b' (:block/order (d/entity @conn [:block/uuid block-b]))] + (is (= order-a order-a')) + (is (not= order-a' order-b'))))) diff --git a/deps/db-sync/worker/api_docs.mjs b/deps/db-sync/worker/api_docs.mjs new file mode 100644 index 0000000..aaa814d --- /dev/null +++ b/deps/db-sync/worker/api_docs.mjs @@ -0,0 +1,9 @@ +export function apiDocsResponse(path, html) { + if (path !== "/api-docs" && path !== "/api-docs/") return null; + return new Response(html, { + headers: { + "cache-control": "public, max-age=300", + "content-type": "text/html; charset=utf-8", + }, + }); +} diff --git a/deps/db-sync/worker/api_docs_test.mjs b/deps/db-sync/worker/api_docs_test.mjs new file mode 100644 index 0000000..66b8ddf --- /dev/null +++ b/deps/db-sync/worker/api_docs_test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { apiDocsResponse } from "./api_docs.mjs"; + +test("API docs are served as standalone HTML from both docs paths", async () => { + const html = "Logseq Server API"; + + for (const path of ["/api-docs", "/api-docs/"]) { + const response = apiDocsResponse(path, html); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/html; charset=utf-8"); + assert.equal(await response.text(), html); + } +}); + +test("API docs helper ignores unrelated paths", () => { + assert.equal(apiDocsResponse("/openapi.json", "docs"), null); +}); diff --git a/deps/db-sync/worker/asset_image.mjs b/deps/db-sync/worker/asset_image.mjs new file mode 100644 index 0000000..33576d0 --- /dev/null +++ b/deps/db-sync/worker/asset_image.mjs @@ -0,0 +1,232 @@ +function bytesToBase64(bytes) { + const chunkSize = 32 * 1024; + const chunks = []; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))); + } + return btoa(chunks.join("")); +} + +const MAX_DISPLAY_IMAGE_SIZE = 10 * 1024 * 1024; + +export const ASSET_IMAGE_RESOURCE_URI = "ui://widget/logseq-asset-image.html"; + +const ASSET_IMAGE_COMPONENT_HTML = ` + + + + + + + +
+ +
+
+ + + + +`.trim(); + +export function assertDisplayableImageMetadata(metadata) { + if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) { + throw new Error("asset has an invalid size"); + } + if (metadata.size > MAX_DISPLAY_IMAGE_SIZE) { + throw new Error("asset is too large to display directly (10MB maximum)"); + } +} + +export async function assetImageResult(response, metadata) { + const mimeType = response.headers.get("content-type")?.split(";", 1)[0]; + if (!response.ok || !mimeType?.startsWith("image/")) { + throw new Error(`asset response is not an image (${response.status} ${mimeType ?? "unknown"})`); + } + const payload = new Uint8Array(await response.arrayBuffer()); + const data = bytesToBase64(payload); + const title = metadata.title ?? "Image asset"; + const uuid = metadata.uuid; + return { + structuredContent: { + uuid, + title, + mimeType, + size: payload.length, + }, + content: [ + { type: "image", data, mimeType }, + { type: "text", text: `${title} (${uuid})` }, + ], + _meta: { + ui: { resourceUri: ASSET_IMAGE_RESOURCE_URI }, + "openai/outputTemplate": ASSET_IMAGE_RESOURCE_URI, + image: { + data, + mimeType, + title, + uuid, + }, + }, + }; +} + +export async function assetImageResource() { + return { + contents: [ + { + uri: ASSET_IMAGE_RESOURCE_URI, + mimeType: "text/html;profile=mcp-app", + text: ASSET_IMAGE_COMPONENT_HTML, + _meta: { + ui: { + prefersBorder: true, + }, + }, + }, + ], + }; +} diff --git a/deps/db-sync/worker/asset_image_test.mjs b/deps/db-sync/worker/asset_image_test.mjs new file mode 100644 index 0000000..8128f28 --- /dev/null +++ b/deps/db-sync/worker/asset_image_test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import * as assetImage from "./asset_image.mjs"; + +test("asset image result returns MCP image content", async () => { + const response = new Response(new Uint8Array([1, 2, 3, 4]), { + headers: { "content-type": "image/jpeg" }, + }); + + assert.deepEqual(await assetImage.assetImageResult(response, { uuid: "asset-1", title: "Photo" }), { + structuredContent: { + uuid: "asset-1", + title: "Photo", + mimeType: "image/jpeg", + size: 4, + }, + content: [ + { type: "image", data: "AQIDBA==", mimeType: "image/jpeg" }, + { type: "text", text: "Photo (asset-1)" }, + ], + _meta: { + ui: { resourceUri: "ui://widget/logseq-asset-image.html" }, + "openai/outputTemplate": "ui://widget/logseq-asset-image.html", + image: { + data: "AQIDBA==", + mimeType: "image/jpeg", + title: "Photo", + uuid: "asset-1", + }, + }, + }); +}); + +test("asset image result rejects non-image responses", async () => { + await assert.rejects( + assetImage.assetImageResult(new Response("{}", { headers: { "content-type": "application/json" } }), {}), + /not an image/, + ); +}); + +test("asset image display rejects payloads that are unsafe to base64 in Worker memory", () => { + assert.throws( + () => assetImage.assertDisplayableImageMetadata({ size: 10 * 1024 * 1024 + 1 }), + /too large to display/, + ); +}); + +test("asset image resource is an MCP app component that renders tool results", async () => { + assert.equal(typeof assetImage.assetImageResource, "function"); + const resource = await assetImage.assetImageResource(); + const content = resource.contents[0]; + + assert.equal(content.uri, assetImage.ASSET_IMAGE_RESOURCE_URI); + assert.equal(assetImage.ASSET_IMAGE_RESOURCE_URI, "ui://widget/logseq-asset-image.html"); + assert.equal(content.mimeType, "text/html;profile=mcp-app"); + assert.match(content.text, /ui\/notifications\/tool-result/); + assert.match(content.text, /toolResponseMetadata/); + assert.match(content.text, /createObjectURL/); + assert.match(content.text, /notifyIntrinsicHeight/); + assert.match(content.text, /requestDisplayMode/); + assert.match(content.text, /image-button/); + assert.match(content.text, /zoom-in/); + assert.doesNotMatch(content.text, /https?:\/\//); +}); diff --git a/deps/db-sync/worker/chatgpt_app.mjs b/deps/db-sync/worker/chatgpt_app.mjs new file mode 100644 index 0000000..fa45853 --- /dev/null +++ b/deps/db-sync/worker/chatgpt_app.mjs @@ -0,0 +1,160 @@ +import { ASSET_IMAGE_RESOURCE_URI } from "./asset_image.mjs"; + +const writeScopes = ["logseq/read", "logseq/write"]; + +const imageOutputSchema = { + type: "object", + properties: { + uuid: { type: "string", description: "Image asset block UUID" }, + title: { type: "string", description: "Image asset title" }, + mimeType: { type: "string", description: "Image MIME type" }, + size: { type: "integer", description: "Image size in bytes" }, + }, + required: ["uuid", "title", "mimeType", "size"], + additionalProperties: false, +}; + +const fileInputSchema = { + type: "object", + properties: { + download_url: { type: "string", description: "Temporary HTTPS URL for the original ChatGPT file" }, + file_id: { type: "string", description: "ChatGPT file identifier" }, + mime_type: { type: "string", description: "File MIME type" }, + file_name: { type: "string", description: "Original file name including its extension" }, + }, + required: ["download_url", "file_id"], + additionalProperties: false, +}; + +const assetOutputSchema = { + type: "object", + properties: { + uuid: { type: "string", description: "Asset block UUID" }, + title: { type: "string", description: "Asset block title" }, + type: { type: "string", description: "Asset file extension" }, + size: { type: "integer", description: "Original file size in bytes" }, + checksum: { type: "string", description: "SHA-256 checksum of the original file" }, + }, + required: ["uuid", "title", "type", "size", "checksum"], + additionalProperties: false, +}; + +function oauthSecurity(scopes) { + return [{ type: "oauth2", scopes }]; +} + +export function chatGptToolDescriptors() { + const searchSecurity = oauthSecurity(writeScopes); + const executeSecurity = oauthSecurity(writeScopes); + const imageSecurity = oauthSecurity(["logseq/read"]); + return [ + { + name: "search", + title: "Discover Logseq operations", + description: "Search the Logseq DB graph OpenAPI document before making requests. For tasks, discover the dedicated /tasks operations: never use Markdown TODO syntax to create tasks and never use graph search to list tasks. Use upload_asset for ChatGPT attachments and never encode an attachment as base64 in Code Mode. This API uses DB-version typed properties; never use legacy file-graph key:: value syntax. Pass JavaScript as an async arrow function; codemode.spec() returns the OpenAPI document with references resolved inline.", + inputSchema: { + type: "object", + properties: { + code: { type: "string", description: "JavaScript async arrow function that searches the Logseq OpenAPI document" }, + }, + required: ["code"], + additionalProperties: false, + }, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + idempotentHint: true, + }, + securitySchemes: searchSecurity, + _meta: { + securitySchemes: searchSecurity, + "openai/toolInvocation/invoking": "Discovering Logseq operations…", + "openai/toolInvocation/invoked": "Logseq operations ready", + }, + }, + { + name: "execute", + title: "Use Logseq", + description: "Read or change the user's non-encrypted Logseq DB graph through the semantic API. Use the dedicated /tasks operations for DB tasks: never use Markdown TODO syntax to create tasks and never use graph search to list tasks. Use upload_asset for ChatGPT attachments and never encode an attachment as base64 in Code Mode. Use DB-version typed properties and property UUIDs or idents; never write legacy file-graph key:: value syntax into block titles. First use search to discover endpoints. Pass JavaScript as an async arrow function; codemode.request(options) calls only documented Logseq endpoints.", + inputSchema: { + type: "object", + properties: { + code: { type: "string", description: "JavaScript async arrow function that calls the Logseq API" }, + }, + required: ["code"], + additionalProperties: false, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: true, + }, + securitySchemes: executeSecurity, + _meta: { + securitySchemes: executeSecurity, + "openai/toolInvocation/invoking": "Updating Logseq…", + "openai/toolInvocation/invoked": "Logseq request complete", + }, + }, + { + name: "upload_asset", + title: "Upload Logseq asset", + description: "Upload the original ChatGPT attachment, unchanged and without base64 re-encoding, to a non-encrypted Logseq DB graph and create its Asset block. Optionally append it to a page. The file must not exceed 100MB; its original name is preserved when available.", + inputSchema: { + type: "object", + properties: { + graphId: { type: "string", description: "Graph UUID" }, + file: fileInputSchema, + pageId: { type: "string", description: "Optional destination page UUID, ident, or title" }, + title: { type: "string", description: "Optional asset block title" }, + }, + required: ["graphId", "file"], + additionalProperties: false, + }, + outputSchema: assetOutputSchema, + annotations: { + readOnlyHint: false, + openWorldHint: true, + destructiveHint: false, + idempotentHint: false, + }, + securitySchemes: executeSecurity, + _meta: { + securitySchemes: executeSecurity, + "openai/fileParams": ["file"], + "openai/toolInvocation/invoking": "Uploading Logseq asset…", + "openai/toolInvocation/invoked": "Logseq asset uploaded", + }, + }, + { + name: "get_asset_image", + title: "Display Logseq image asset", + description: "Fetch an image asset up to 10MB from a non-encrypted Logseq DB graph and return it as image content that ChatGPT can display directly. Use this tool instead of getAsset when the user asks to view or display an image. It is read-only and does not download to local storage or modify the graph.", + inputSchema: { + type: "object", + properties: { + graphId: { type: "string", description: "Graph UUID" }, + assetBlockId: { type: "string", description: "Image asset block UUID" }, + }, + required: ["graphId", "assetBlockId"], + additionalProperties: false, + }, + outputSchema: imageOutputSchema, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + idempotentHint: true, + }, + securitySchemes: imageSecurity, + _meta: { + securitySchemes: imageSecurity, + ui: { resourceUri: ASSET_IMAGE_RESOURCE_URI }, + "openai/outputTemplate": ASSET_IMAGE_RESOURCE_URI, + "openai/toolInvocation/invoking": "Loading Logseq image…", + "openai/toolInvocation/invoked": "Logseq image ready", + }, + }, + ]; +} diff --git a/deps/db-sync/worker/chatgpt_asset_upload.mjs b/deps/db-sync/worker/chatgpt_asset_upload.mjs new file mode 100644 index 0000000..5b31431 --- /dev/null +++ b/deps/db-sync/worker/chatgpt_asset_upload.mjs @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; + +const MAX_ASSET_SIZE = 100 * 1024 * 1024; +const MIME_TYPE_EXTENSIONS = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/gif", "gif"], + ["image/webp", "webp"], + ["image/bmp", "bmp"], + ["image/svg+xml", "svg"], + ["image/x-icon", "ico"], + ["application/pdf", "pdf"], +]); + +function requiredFileLength(response) { + const value = response.headers.get("content-length"); + if (!/^\d+$/.test(value ?? "")) { + throw new Error("ChatGPT file download must include content-length"); + } + const size = Number(value); + if (!Number.isSafeInteger(size) || size > MAX_ASSET_SIZE) { + throw new Error("ChatGPT file is larger than 100MB"); + } + return size; +} + +function fixedLengthBody(body, size) { + if (typeof FixedLengthStream !== "function") return { body, pipePromise: null }; + const fixed = new FixedLengthStream(size); + return { + body: fixed.readable, + pipePromise: body.pipeTo(fixed.writable), + }; +} + +function validateInput({ graphId, file }) { + if (!graphId) throw new Error("graphId is required"); + if (!file?.file_id) throw new Error("file.file_id is required"); + if (!file?.download_url) throw new Error("file.download_url is required"); + const downloadUrl = new URL(file.download_url); + if (downloadUrl.protocol !== "https:") { + throw new Error("file.download_url must use HTTPS"); + } + return downloadUrl; +} + +function resolvedFileName(file, downloadUrl, contentType) { + if (file.file_name) return file.file_name; + const pathName = decodeURIComponent(downloadUrl.pathname.split("/").pop() ?? ""); + if (/\.[a-z0-9]+$/i.test(pathName)) return pathName; + const extension = MIME_TYPE_EXTENSIONS.get(file.mime_type ?? contentType); + if (!extension) throw new Error("file.file_name is required for this file type"); + return `${file.file_id}.${extension}`; +} + +export async function uploadChatGptAsset(input, dependencies) { + const { + origin, + authorization, + env, + ctx, + workerFetch, + fetchFile = fetch, + } = dependencies; + const downloadUrl = validateInput(input); + const bucket = env.LOGSEQ_SYNC_ASSETS; + if (!bucket) throw new Error("missing assets bucket"); + + const fileResponse = await fetchFile(downloadUrl); + if (!fileResponse.ok || !fileResponse.body) { + throw new Error(`ChatGPT file download failed: ${fileResponse.status}`); + } + const size = requiredFileLength(fileResponse); + const contentType = input.file.mime_type ?? fileResponse.headers.get("content-type"); + const fileName = resolvedFileName(input.file, downloadUrl, contentType); + const hash = createHash("sha256"); + let received = 0; + const hashingStream = new TransformStream({ + transform(chunk, controller) { + const payload = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); + received += payload.byteLength; + if (received > size) throw new Error("ChatGPT file exceeds declared content-length"); + hash.update(payload); + controller.enqueue(payload); + }, + }); + const hashedBody = fileResponse.body.pipeThrough(hashingStream); + const { body, pipePromise } = fixedLengthBody(hashedBody, size); + const tempKey = `chatgpt-uploads/${crypto.randomUUID()}`; + + try { + const uploadResults = await Promise.allSettled([ + bucket.put(tempKey, body, { + httpMetadata: { contentType: contentType ?? "application/octet-stream" }, + }), + pipePromise, + ].filter(Boolean)); + const uploadFailure = uploadResults.find((result) => result.status === "rejected"); + if (uploadFailure) throw uploadFailure.reason; + if (received !== size) throw new Error("ChatGPT file is shorter than declared content-length"); + + const stored = await bucket.get(tempKey); + if (!stored?.body) throw new Error("temporary ChatGPT upload is missing"); + const url = new URL(`/api/v1/graphs/${encodeURIComponent(input.graphId)}/assets`, origin); + url.searchParams.set("file-name", fileName); + url.searchParams.set("size", String(size)); + url.searchParams.set("checksum", hash.digest("hex")); + if (input.pageId) url.searchParams.set("page-id", input.pageId); + if (input.title) url.searchParams.set("title", input.title); + + const headers = new Headers(); + if (authorization) headers.set("authorization", authorization); + headers.set("content-type", contentType ?? "application/octet-stream"); + const response = await workerFetch(new Request(url, { + method: "POST", + headers, + body: stored.body, + duplex: "half", + }), env, ctx); + const result = await response.json(); + if (!response.ok) { + throw new Error(`Logseq API request failed: ${response.status} ${JSON.stringify(result)}`); + } + return result; + } finally { + await bucket.delete(tempKey); + } +} diff --git a/deps/db-sync/worker/entry.mjs b/deps/db-sync/worker/entry.mjs new file mode 100644 index 0000000..f7f84b7 --- /dev/null +++ b/deps/db-sync/worker/entry.mjs @@ -0,0 +1,191 @@ +import { DynamicWorkerExecutor } from "@cloudflare/codemode"; +import { openApiMcpServer } from "@cloudflare/codemode/mcp"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { chatGptToolDescriptors } from "./chatgpt_app.mjs"; +import { uploadChatGptAsset } from "./chatgpt_asset_upload.mjs"; +import { + ASSET_IMAGE_RESOURCE_URI, + assertDisplayableImageMetadata, + assetImageResource, + assetImageResult, +} from "./asset_image.mjs"; +import { apiDocsResponse } from "./api_docs.mjs"; +import { logseqIconResponse, setLogseqMcpServerInfoIcons } from "./logseq_icon.mjs"; +import { ensureMcpAcceptHeader } from "./mcp_request.mjs"; +import { openAiAppsChallengeResponse } from "./openai_app_challenge.mjs"; +import { semanticRequestBody, semanticRequestUrl } from "./semantic_request.mjs"; +import apiDocsHtml from "./dist/api-docs.generated.mjs"; +import worker, { SyncDO } from "./dist/worker/main.js"; + +function protectedResourceMetadata(request, env) { + const url = new URL(request.url); + return Response.json({ + resource: `${url.origin}/mcp`, + authorization_servers: [env.COGNITO_ISSUER], + scopes_supported: ["logseq/read", "logseq/write"], + bearer_methods_supported: ["header"], + }); +} + +async function semanticSpec(request, env, ctx) { + const url = new URL("/openapi.json", request.url); + const response = await worker.fetch(new Request(url), env, ctx); + if (!response.ok) { + throw new Error(`OpenAPI request failed: ${response.status}`); + } + return response.json(); +} + +async function handleMcp(request, env, ctx) { + const authorization = request.headers.get("authorization"); + const server = openApiMcpServer({ + spec: await semanticSpec(request, env, ctx), + executor: new DynamicWorkerExecutor({ loader: env.LOADER }), + name: "logseq", + version: "1.0.0", + request: async (options) => { + if (!options.path.startsWith("/api/v1/")) { + throw new Error("Only Logseq server API paths are allowed"); + } + const url = semanticRequestUrl(options, request.url); + const headers = {}; + if (authorization) headers.authorization = authorization; + if (options.contentType) headers["content-type"] = options.contentType; + else if (options.body !== undefined) headers["content-type"] = "application/json"; + + const response = await worker.fetch( + new Request(url, { + method: options.method, + headers, + body: semanticRequestBody(options, url), + }), + env, + ctx, + ); + if (response.status === 204) return null; + const result = (response.headers.get("content-type") ?? "").includes("application/json") + ? await response.json() + : await response.text(); + if (!response.ok) { + throw new Error(`Logseq API request failed: ${response.status} ${JSON.stringify(result)}`); + } + return result; + }, + }); + setLogseqMcpServerInfoIcons(server, request); + + server.registerResource("asset-image", ASSET_IMAGE_RESOURCE_URI, { + title: "Logseq image asset", + description: "Renders a Logseq image asset returned by the image asset tool.", + mimeType: "text/html;profile=mcp-app", + }, assetImageResource); + + server.registerTool("upload_asset", { + title: "Upload Logseq asset", + description: "Upload an original ChatGPT attachment to a Logseq DB graph.", + inputSchema: { + graphId: z.string(), + file: z.object({ + download_url: z.string(), + file_id: z.string(), + mime_type: z.string().optional(), + file_name: z.string().optional(), + }), + pageId: z.string().optional(), + title: z.string().optional(), + }, + outputSchema: { + uuid: z.string(), + title: z.string(), + type: z.string(), + size: z.number().int().nonnegative(), + checksum: z.string(), + }, + annotations: { + readOnlyHint: false, + openWorldHint: true, + destructiveHint: false, + idempotentHint: false, + }, + _meta: { + "openai/fileParams": ["file"], + }, + }, (input) => uploadChatGptAsset(input, { + origin: new URL(request.url).origin, + authorization, + env, + ctx, + workerFetch: worker.fetch.bind(worker), + }).then((result) => ({ + structuredContent: result, + content: [{ type: "text", text: `Uploaded Logseq asset ${result.title} (${result.uuid}).` }], + }))); + + server.registerTool("get_asset_image", { + title: "Display Logseq image asset", + description: "Return a Logseq image asset as MCP image content for direct display.", + inputSchema: { + graphId: z.string(), + assetBlockId: z.string(), + }, + outputSchema: { + uuid: z.string(), + title: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + }, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + idempotentHint: true, + }, + _meta: { + ui: { resourceUri: ASSET_IMAGE_RESOURCE_URI }, + "openai/outputTemplate": ASSET_IMAGE_RESOURCE_URI, + }, + }, async ({ graphId, assetBlockId }) => { + const metadataUrl = new URL( + `/api/v1/graphs/${encodeURIComponent(graphId)}/assets/${encodeURIComponent(assetBlockId)}`, + request.url, + ); + const metadataResponse = await worker.fetch(new Request(metadataUrl, { + headers: authorization ? { authorization } : {}, + }), env, ctx); + const metadata = await metadataResponse.json(); + if (!metadataResponse.ok) { + throw new Error(`Logseq API request failed: ${metadataResponse.status} ${JSON.stringify(metadata)}`); + } + assertDisplayableImageMetadata(metadata); + const imageResponse = await worker.fetch(new Request(metadata.url), env, ctx); + return assetImageResult(imageResponse, metadata); + }); + + const transport = new WebStandardStreamableHTTPServerTransport(); + await server.connect(transport); + server.server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: chatGptToolDescriptors(), + })); + return transport.handleRequest(request); +} + +export default { + async fetch(request, env, ctx) { + const path = new URL(request.url).pathname; + const challengeResponse = openAiAppsChallengeResponse(request, env); + if (challengeResponse) return challengeResponse; + const docsResponse = apiDocsResponse(path, apiDocsHtml); + if (docsResponse) return docsResponse; + if (path === "/.well-known/oauth-protected-resource" || + path === "/.well-known/oauth-protected-resource/mcp") { + return protectedResourceMetadata(request, env); + } + if (path === "/mcp/icon.png") return logseqIconResponse(); + if (path === "/mcp") return handleMcp(ensureMcpAcceptHeader(request), env, ctx); + return worker.fetch(request, env, ctx); + }, +}; + +export { SyncDO }; diff --git a/deps/db-sync/worker/entry_test.mjs b/deps/db-sync/worker/entry_test.mjs new file mode 100644 index 0000000..9908b00 --- /dev/null +++ b/deps/db-sync/worker/entry_test.mjs @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { chatGptToolDescriptors } from "./chatgpt_app.mjs"; +import { + logseqIconResponse, + setLogseqMcpServerInfoIcons, +} from "./logseq_icon.mjs"; +import { ensureMcpAcceptHeader } from "./mcp_request.mjs"; +import { openAiAppsChallengeResponse } from "./openai_app_challenge.mjs"; +import { semanticRequestBody, semanticRequestUrl } from "./semantic_request.mjs"; +import { uploadChatGptAsset } from "./chatgpt_asset_upload.mjs"; + +test("OpenAI app domain challenge returns the configured token as plain text", async () => { + const token = "openai-domain-verification-token"; + const response = openAiAppsChallengeResponse( + new Request("https://api.logseq.io/.well-known/openai-apps-challenge"), + { OPENAI_APPS_CHALLENGE: token }, + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8"); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(await response.text(), token); +}); + +test("OpenAI app domain challenge fails fast when the token is not configured", () => { + assert.throws( + () => openAiAppsChallengeResponse( + new Request("https://api.logseq.io/.well-known/openai-apps-challenge"), + {}, + ), + /OPENAI_APPS_CHALLENGE is required/, + ); +}); + +test("OpenAI app domain challenge route does not match nested paths", async () => { + const response = openAiAppsChallengeResponse( + new Request("https://api.logseq.io/mcp/.well-known/openai-apps-challenge"), + { OPENAI_APPS_CHALLENGE: "token" }, + ); + + assert.equal(response, null); +}); + +test("OpenAI app domain challenge route only handles GET", () => { + const response = openAiAppsChallengeResponse( + new Request("https://api.logseq.io/.well-known/openai-apps-challenge", { method: "POST" }), + { OPENAI_APPS_CHALLENGE: "token" }, + ); + + assert.equal(response, null); +}); + +test("Code Mode derives decoded asset size instead of trusting the agent", () => { + const url = semanticRequestUrl({ + method: "POST", + path: "/api/v1/graphs/graph-1/assets", + query: { encoding: "base64", size: 3, checksum: "a".repeat(64) }, + body: "AQIDBA==", + rawBody: true, + }, "https://staging.example/mcp"); + + assert.equal(url.searchParams.get("size"), "4"); +}); + +test("Code Mode rejects invalid base64 before forwarding", () => { + assert.throws(() => semanticRequestUrl({ + path: "/api/v1/graphs/graph-1/assets", + query: { encoding: "base64", size: 1 }, + body: "not base64!", + }, "https://staging.example/mcp"), /invalid base64/); + +}); + +test("Code Mode always forwards base64 assets as a raw body", () => { + const options = { + path: "/api/v1/graphs/graph-1/assets", + query: { encoding: "base64", size: 3 }, + body: "AQIDBA==", + }; + const url = semanticRequestUrl(options, "https://staging.example/mcp"); + + assert.equal(semanticRequestBody(options, url), "AQIDBA=="); +}); + +test("ChatGPT tool descriptors declare titles, impact, and per-tool auth", () => { + const descriptors = chatGptToolDescriptors(); + const search = descriptors.find((tool) => tool.name === "search"); + const execute = descriptors.find((tool) => tool.name === "execute"); + + assert.deepEqual(search.annotations, { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + idempotentHint: true, + }); + assert.deepEqual(search.securitySchemes, [ + { type: "oauth2", scopes: ["logseq/read", "logseq/write"] }, + ]); + assert.deepEqual(search._meta.securitySchemes, search.securitySchemes); + + assert.deepEqual(execute.annotations, { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: true, + }); + assert.deepEqual(execute.securitySchemes, [ + { type: "oauth2", scopes: ["logseq/read", "logseq/write"] }, + ]); + assert.deepEqual(execute._meta.securitySchemes, execute.securitySchemes); +}); + +test("ChatGPT tools require DB graph property semantics", () => { + const descriptors = chatGptToolDescriptors().filter((tool) => ["search", "execute"].includes(tool.name)); + + for (const tool of descriptors) { + assert.match(tool.description, /DB graph/); + assert.match(tool.description, /typed properties/); + assert.match(tool.description, /key:: value/); + } +}); + +test("ChatGPT tools describe the DB Task workflow", () => { + const descriptors = chatGptToolDescriptors().filter((tool) => ["search", "execute"].includes(tool.name)); + + for (const tool of descriptors) { + assert.match(tool.description, /\/tasks/); + assert.match(tool.description, /never use Markdown TODO/); + assert.match(tool.description, /never use graph search to list tasks/); + } +}); + +test("ChatGPT tools direct attachment uploads to the file-aware tool", () => { + for (const tool of chatGptToolDescriptors().filter((descriptor) => ["search", "execute"].includes(descriptor.name))) { + assert.match(tool.description, /upload_asset/); + assert.match(tool.description, /never encode.*base64/i); + } +}); + +test("ChatGPT exposes the asset attachment as an OpenAI file parameter", () => { + const tool = chatGptToolDescriptors().find((descriptor) => descriptor.name === "upload_asset"); + + assert.equal(tool.title, "Upload Logseq asset"); + assert.deepEqual(tool._meta["openai/fileParams"], ["file"]); + assert.equal(tool.inputSchema.properties.file.type, "object"); + assert.deepEqual(tool.inputSchema.properties.file.required, ["download_url", "file_id"]); + assert.deepEqual(tool.securitySchemes, [{ type: "oauth2", scopes: ["logseq/read", "logseq/write"] }]); + assert.deepEqual(tool.annotations, { + readOnlyHint: false, + openWorldHint: true, + destructiveHint: false, + idempotentHint: false, + }); +}); + +test("ChatGPT asset upload preserves the original bytes and derives metadata", async () => { + const original = new Uint8Array(106816); + for (let index = 0; index < original.length; index += 1) original[index] = index % 251; + const tempObjects = new Map(); + const bucket = { + async put(key, body) { + tempObjects.set(key, new Uint8Array(await new Response(body).arrayBuffer())); + }, + async get(key) { + const payload = tempObjects.get(key); + return payload ? { body: new Response(payload).body } : null; + }, + async delete(key) { + tempObjects.delete(key); + }, + }; + let semanticRequest; + const result = await uploadChatGptAsset({ + graphId: "graph-1", + pageId: "page-1", + title: "Logseq Logo 2.0", + file: { + download_url: "https://files.openai.example/logo.jpg", + file_id: "file-1", + mime_type: "image/jpeg", + file_name: "logo.jpg", + }, + }, { + origin: "https://api.logseq.io", + authorization: "Bearer token", + env: { LOGSEQ_SYNC_ASSETS: bucket }, + fetchFile: async () => new Response(original, { + headers: { "content-length": String(original.byteLength), "content-type": "image/jpeg" }, + }), + workerFetch: async (request) => { + semanticRequest = request; + const uploaded = new Uint8Array(await request.arrayBuffer()); + assert.deepEqual(uploaded, original); + return Response.json({ uuid: "asset-1", size: uploaded.byteLength }, { status: 201 }); + }, + }); + + const url = new URL(semanticRequest.url); + assert.equal(url.pathname, "/api/v1/graphs/graph-1/assets"); + assert.equal(url.searchParams.get("file-name"), "logo.jpg"); + assert.equal(url.searchParams.get("page-id"), "page-1"); + assert.equal(url.searchParams.get("title"), "Logseq Logo 2.0"); + assert.equal(url.searchParams.get("size"), "106816"); + assert.equal(url.searchParams.get("checksum"), "2845255da015e951337b70413a1af5b661c59e41cfe5ba4c64dd1b8168143b60"); + assert.equal(semanticRequest.headers.get("authorization"), "Bearer token"); + assert.equal(semanticRequest.headers.get("content-type"), "image/jpeg"); + assert.deepEqual(result, { uuid: "asset-1", size: 106816 }); + assert.equal(tempObjects.size, 0); +}); + +test("ChatGPT asset upload rejects missing length without buffering the file", async () => { + await assert.rejects(() => uploadChatGptAsset({ + graphId: "graph-1", + file: { + download_url: "https://files.openai.example/logo.jpg", + file_id: "file-1", + file_name: "logo.jpg", + }, + }, { + origin: "https://api.logseq.io", + env: { LOGSEQ_SYNC_ASSETS: {} }, + fetchFile: async () => new Response(new Uint8Array([1, 2, 3])), + }), /content-length/); +}); + +test("ChatGPT exposes a read-only image asset tool", () => { + const tool = chatGptToolDescriptors().find((descriptor) => descriptor.name === "get_asset_image"); + + assert.equal(tool.title, "Display Logseq image asset"); + assert.deepEqual(tool.annotations, { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + idempotentHint: true, + }); + assert.deepEqual(tool.securitySchemes, [{ type: "oauth2", scopes: ["logseq/read"] }]); +}); + +test("ChatGPT image asset tool declares a UI resource for ordinary chat rendering", () => { + const tool = chatGptToolDescriptors().find((descriptor) => descriptor.name === "get_asset_image"); + + assert.ok(tool._meta?.ui); + assert.equal(tool._meta.ui.resourceUri, "ui://widget/logseq-asset-image.html"); + assert.equal(tool._meta["openai/outputTemplate"], "ui://widget/logseq-asset-image.html"); + assert.equal(tool.outputSchema.type, "object"); + assert.deepEqual(tool.outputSchema.required, ["uuid", "title", "mimeType", "size"]); +}); + +test("MCP serverInfo advertises the Logseq icon from the worker origin", () => { + const server = { server: { _serverInfo: { name: "logseq", version: "1.0.0" } } }; + + setLogseqMcpServerInfoIcons(server, new Request("https://staging.example/mcp")); + + assert.deepEqual(server.server._serverInfo.icons, [{ + src: "https://staging.example/mcp/icon.png", + mimeType: "image/png", + sizes: ["192x192"], + }]); +}); + +test("MCP icon response serves a PNG logo", async () => { + const response = logseqIconResponse(); + const payload = new Uint8Array(await response.arrayBuffer()); + + assert.equal(response.headers.get("content-type"), "image/png"); + assert.match(response.headers.get("cache-control"), /public/); + assert.deepEqual([...payload.slice(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +}); + +test("MCP requests with JSON-only Accept are upgraded for streamable HTTP discovery", () => { + const request = new Request("https://staging.example/mcp", { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); + + const upgraded = ensureMcpAcceptHeader(request); + + assert.notEqual(upgraded, request); + assert.equal(upgraded.headers.get("accept"), "application/json, text/event-stream"); + assert.equal(upgraded.headers.get("content-type"), "application/json"); +}); + +test("MCP requests without Accept are upgraded for streamable HTTP discovery", () => { + const request = new Request("https://staging.example/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); + + const upgraded = ensureMcpAcceptHeader(request); + + assert.equal(upgraded.headers.get("accept"), "application/json, text/event-stream"); +}); + +test("MCP requests that already support streamable HTTP are preserved", () => { + const request = new Request("https://staging.example/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); + + assert.equal(ensureMcpAcceptHeader(request), request); +}); diff --git a/deps/db-sync/worker/logseq_icon.mjs b/deps/db-sync/worker/logseq_icon.mjs new file mode 100644 index 0000000..dc9e2c6 --- /dev/null +++ b/deps/db-sync/worker/logseq_icon.mjs @@ -0,0 +1,34 @@ +const LOGSEQ_ICON_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABcdSURBVHgB7Z17cNzVdcfP/a08WHanWJbt1jZ+yMTgR0hHppiAaQ1y3BpocBNnprTQf8B/dKbQPzoNyUxnCjPwD/Wk7YydiTODSdIAdajl1ATLELCwia3UBEmEgGRIBtmyYxFbthRiWWKw9uaecx97VxK2JO/u7+7e80mW3+5aq8fu+Z7XfQlYuVoCw0RKAgwTMSwAJmpYAEzUsACYqGEBMFHDAmCihgXARA0LgIkaFgATNSwAJmpYAEzUsACYqGEBMFHDAmCihgXARA0LgIkaFgATNSwAJmpYAEzUsACYqGEBMFHDAmCihgXARA0LgIkaFgATNSwAJmpYAEzUsACYqGEBMFHDAmCihgXARA0LgIkaFgATNVXABMnUadVQU1sLcxfOhxm1M6FmVi09XzNrJl2HLgzCoLr19Z6FnhO/psdd7/0SmInBAggENPi5C66B5fU3wJJlS9X9+TBRUAQohtZDR6DzrbfpMXNpBJ8Rli511y8lo79xzc0kgkKBxt/R/jbs3/MS9J89C8zYlKUA0FCmVk9z6QDSc+JkWXk89PIN99ypBPAZKCZ9veeg5dUD0PLKAWBGUxYCQINfUf858pZLln2GcuKxsCnAqe6T0Nn+iyBz4lIZ/khaXj0Ie/+nEZh8ghbA1GnTYM36tXDrF26fVHqA3m//nn1KCL9KPQ3Av2XdxjvV37IW0qLt8BHY/Z3nQErOei1BCkAIQYaCnrIQeTEKob3lDSWGJkgD7ODc99DmSRW2hWbvzt2cDnkEJwD0lPcrYylGioBCaHz6GYoIpWLm7Fp48KsPf2raVmowTdzytce4Q2TIwJz5j0EgoLH8w7/+c9E8ZbWKJqtUtwUjTCnqA/T8Dz36CPzB1X8IoVA1ZQpc/OQTcgL4PsROMCPB6PlL5Skb7tkADz32SEHbjiPBDtXmRx4u6s+YLEtUM0EVAlwLQEAC+Kt7v1TSNAEHnR569GtF+5nrVP0SStozkrkLr1H/lcD+PxABrFqzGupValJqtJf+p4J7aUyz0vh7xgv9vRI4AkAIAlAfwrqNd0FaoAiw6C4U6FXR+4fM0IULwBFAk7oAbrzt86mnCjjAdve9X4YrAQtKNKjFqns1Y1aYqY/l1PGTugYAHQFiLobTFYD6EG5Zn97AkM+t62+n1uuVGAOmFHXXlXaEdzK0/6SFrvSXRl4MpyYAfPNnqjYhFqOhsOmB++GqqVfBpDBGpAvMsOk6+j5d6VeOvBWamgDIW14flrfEemDN+jsmlRvj3yPUrTrAtqfP/t0v0BoCAv/QyAvhVCPA8lWfg9DAVGiyXSEps9DfG+7U474zvdC8+0eA776uWARHACgxNsdGjzm1OjxvicaPk+8mZRbKmfYcPwEhgp2fHU9s0Qav/i+5BUSkEwEo7Mq8+fwhgVEAf8eJ2YhOJTpa2yE00PifevzfTeqjfL9IALgJSpRcANRxMLcQpwkg+HstNvXJeLtClFIIICPr6nwPQoE8vzL+nu4TIJKEIgBJVej0J/b5QKVPgaA8WIH1yXhbhN7XoRB2f/u7ZrApXXqOdcPWrz9Kxg/o9fGWmKsh9tHglIpg/aaHPCUXO1TUJRzPF3ueFF9zrreX8u00RXC46ccq7XkS+s+e84xfOM8fe/FrSScFoipM0rYeoTJ34YIJDRL5aQX+fadUMYx5d7/qvJSSro6jsE15/aZndsLQ4JBOefB3sp6fjT+P9NYDKMNaeO1imLtoAYRK26H/d0Y0Hux0CBvhzvf/FjrfbFdjA9Ng7uKFUEzQ8Bu/tQOaG/fAwEe/078zGX3GXakG4NZnHiXdF0jYAgw7LEJ7yfo/g2CZt3A+9J/rG9fXonHJbFbnTORpJRlan0qHdm3fAft3/R80fGUjrFp7GxQKjC6tBw9By75X4OPBQeotCN/L+zl/4hW/jKOkApCmtWiTig9P/BpCRk8bljnhXoJcqmQNz9zPAokBc/FGJYTmXXugbsX1JASMCrgQaLxgTYGeXt/eUw6kW/9udkpDgv39JF8EpvND5TmKlKdA51H6CIBeErQIsF2IH+pEjKCUWFOR2rVe/gW2GKY2L+hSR0+8p6tQFoq7U7S93qJuh8kZzF20EK5Sf3/N7Fp1m2W/Ef30QfXeDA1coME1HMX9WD3OM19l6PT9k0S/t8bopRNAkhMvCYWNfyQljwD5j4GM4dYNX4AQGRoYAGvJYiIGREaH1m8iAQkia8SPxm2vQGkg0tVpzT73M+zYQi64CPc6MPWGNXbp+vq5YpeGG41wBQtgTErfBvU6JXjtbH0LQmUybVrffMkzJ6YIxVsmY9Ij/Zw0ObpIhGfEOoUR5N3BpDEmlQG/m6P+PbHfK0M/RyRV+jkxOt9n4x+b9NYDkAYEfKDSoJBGTn3sxDad1YzfgHRKbvpBed0YZaRVVc5otfEnxoCT3EgtvUY9l6kyIsk4AQnzPUQm9z1ITCQo7Vjwqjs+zOVI513y82l1v3n3CxAaWJvYacNSTnz5YN64AHp04/2tcfs3Mt4MXtVjZfR4k8ao86KG97yNHvS88Nqb3mRD5vKkNxnOK9Q+6Hw/uCjg/z7j6QKNCysGlxppo8fURYwQBRm+8fhWBCLx25rme9jCm9ubkyLdCOAVaI3bnw5i/oylo/Xn7r5uAl2ZgfkRQXpFa17u7wxc5PL8kXN4vBan9L83MynSWxDj96ZBULrRvCuMVAhbjm20bjZXrBclpfBnZPo9+8QfzBKjDJ4pHKkIIK837eXJLS+/Cu0HD0PaYGtWWMM0ffxiIu2N8/aSk4oA8ovKXGsPP/69z+xUAz/dkBbo/XHXBDuWBZxfVzTpLYrH//gpAP4mSgg4Q/TZb2wr+SxKS7NdNO6NV/AmspVL+s1irwi0hWDf2bPw1ONbSh4JcFIZpj+u0ARuKVY6QWyPbnpBeQNOOA3hjf0HaJuRBUuvhWKD822e/c9tuRYjtiDtyimOABVLGAIYy8D0NBh4/+fvQP/pXpiHMyenF2fSXJsqvJ/f+m24ePGiG7DK68AwFUswB2ToxSRilL3hc7i2tfPNNhUNphd8Ycne/34OfryzES4OD+cNMo1cO8tUJsEckWTXCeDuajRrUqqbvWJaRDMpcSuVWQVZWEIrqLbvUF2fs2atrE57pOClgzER3iF5tkVKQhjOF4S6WZOcUVsLy2+q1wtLFo0vKuBIc9uBw9DZ2gYfdLzn5tDbEVj6ySOmF3ABXNmEeUyq2TjL7h8kzFx6YR5L/98VuMMcpkbzFi+Cq2fV5tUKv1Uevv/MGTjVfYJSKY0uMHAujfSnGCfCef9Szp/HlWc1StBzF86nreLxbDF6btZMagLg3zfWHko4XXtocJBax3gfDwHEFi6uPsP7eGZySNNLQiTcc4Kt8ZnFJFYMNi2ilImihRwxUCupbtArscC0mEyHyS0mgbzJeHpFVW4qcrGNH3fExm1XcCfpSx38XQisED48cRI+OPqrIA8PT5NgzwmWvgC8aABmF2bpi8J9nfc9YMQkMWFXUJn+vllG6FKfIub8/kn3K+pvSH1HPDwhsqPtbeho/0XqB4inTdAnxTvcrmtglhViJMDF6lgfyNxSQunNj/S8v35xblTXXz5YrOkOaORLli2ljXbx2NdQt4HE6IAnyHe0xSmG8hCAxTNwIWFELSDtjDI3hmClIfyVUmYinr4W3vAxb8dD8tDwQzX6TwMjA4qhveWNaIr/8hKAwUxQMLavF5jnooD5t7wX5KY1jI4KhQG9fcM9dwZ36MdkwLqh+YV9VDNUelQoSwGMwvdWJgLkEHZFS662YMMfFyiE9pYj0HroiN5jtAKpDAGkALYq123cAPW3hnsecKFAIezfsw/e+mnlpUYsgAmCUQTz+4Z7NpRdjn+loBCe++ZTakzlJFQKLIAJgF5/0wP3VWS6MxFaXj1AESHk7e3HCwtgnKxZfzvl+rF5/U8Do8GOLVtzJ06WKSyAS2Cnad9175co7WFGg90ijAblSkn3Bi0n0Pixp/93/7iZBrKYsdFRcRo07dxdlgUyR4AxIOOvnQkPPvJwUefpVBI4ovzstqeoXVpOQuAVHyOwnp+Nf2JglHzwq+X3nrEARkDGX4YfZAjY9w67ZeUCC8Bj5uxaNv4rpNwcCAvAIangZeO/clAE9z+8mVrGoe+pxAIwrNt4F3d7Cggu+rn73i9D6EQtALv/541rbqZ2HlNYcFr4Lev+nO6HGgmijwAzVbhu2MjGXyzu/ttNUHfdtcG2RqMWAK4uu+OLGzjvLzKbHvx7mFo9FUIkSgHYcIxz+evXVP505rTBohinkoSYBUUbAXBh/aYH7gemNKz767tgRk14kTY6AdiDrOvXrIYZszj1KSWbNt+nt7gJiPgigNlKZdVtnwemtOC2MEvUTZ+QG0Y+FGUK9MfXzKcPgyk9DSoVgoA6QlEJQKc/WVjzl7cDkw7oeK6eWWP2d0qfuCKA2VeUvX+6rLoNO28yiDQoKgHgHkK4nndGGc1WrETW/EWD2wE8baKLACvq/wSYdMFJcrSxAAugNNjcHzeMm7voGmDSB9NQ2t075TQoGgHQ5omYAi27Dpj0qVu2lDaxTzsKRCGAbFZvpT5v0QJgwgDPRgihExRHBKD/SriaJ70FA+4kMU+JQJhuUFqpUBQCkGb0t6a2Bphw+CMTBbA7l9Z06YoXQILnf0m9bXr19OnAhMM03GXPnOnAEaBIYP6v31tziAYTDLTNJJ33JjgCFBVzmgwep8SEAx51az0/R4AiIfyDIYEJCTzRJzs8TFGAI0CxcIdISn2EEhMMelq0ucMCKB42CvSfiftI0NDoO9Or09NhVQQn6ZhiPG1QxW+6TwATDgJy+X9ag2KRTIbTAy3kcZhg6Dl+3HRBZWoL5qOKABfOD6g0iEUQCv4RS9wFKhbCe3PVtevdTmDCoOfYCfL8IhHmJNvSiyCCCGDyzESnQT1cBwRBT1c3DA6c1/m//V8KnaA4agD1JlONpa5tB38CTPp0dRw1n4uERCScAhULadYB21GwoYFBlQYdBSZdOlpbgZoTqv2Zpc8IUiGOcQD1JlOeSQmngE5685m06Dt9hpyQUJ5f12iJnrSYAnF0gajZhiLIkAjaDxxSHYgLwKRDV0enHvgSZgBYAE+FKCboYezbiyIYVO23lr0vA5MOP216BZfB4Fx1ishproqMZ1cIk/5QFFBvfsvelzgKpAC2oXuOd5upD4lJTxOOAEVHN5xVCqTvDw2qKPDiS8CUlrYDr+tojJ5fmPwnRaIZCaYJh2ByTvQ46gNoaeIoUEqw+G0/eMjUYromSzP/R6KJAORrMAXKZOiNp1pAtURfe74RmNLw3Jb/oiisGv/a8AF0LZAice0MJ3TfGcwNxXDoxZdVXtoBTHFpe+116Dl2nCKvBF2LiRQHwCxx7Q0KfgqkbyiCxq3b1QAZp0LFAlOfZhVpXdpDKRCYDhBvjFVaTOuNPFBGfxB9Z8/Bs09+A5ji0PSd70O/eo+TqioXecn0eHfotBAuCohMFYkB56Y0P78LmMKyf+cu6PhZq0t9ZKLHZEQmSd37I9EJgDpCJgroWsCGZKEE8EPVGt0HTGHoPPImvPa/P3RpD5ioS48D2aIgyghAtQAJwBg/huWqKSQIDNftzQeBuTIw72/85nby9DbtEZgCmS5QKAdnV0GE6CV4+EGYRlxGvw0St+hQwti1bTs9rm9YC8zEQePf8W+Pq8HGIedk8KZnpKPPtaMy6ZOBOfMfgwix7Tc3FilyD/DfOo78DGbOmQ1z6xYDM37Q+J9+9AkqeinFVM5FYp1lCuAEGw8BHZIXrQAQd26AmZVIj+mz0cMzKAK8v+SzK4G5PD1dx+B7TzwJfb29utODnh9TS/T6mYzx/mERtQAcxuvb0WLaq8aE6GPvdNB9FsGlaWs+AD/4j61w/qOPtOGj18fUUt2SKUYEAfT9R8ICAL0/jZ2g5TIhc6I8Xo+/06lGi9+FJStXwFTeYXoUzT/YBU3f/T4MqxpKj69o45eJ9vxgWqAhImDl6rAkmSJk/FgIZ/VNqJscvqhqZUnP4+mS6/7mK1wcGzDf3731W7TAxR9XAXfTU89tdA0RFoCHLYtxF2nhCQDUTQ5nVX2QpY1cV92xFhqUEGaoIjlWOt94ExqV8Q9euODWWEAVev8pVPBKkfEWvIRrYiyAMSAhZLWxy4ufaMPHyGB2MsZ/q5kzi0RQf0dc0QC9ftOO70Enju7iE4nx+hgBTO4PbrAr/BMZWABjYPN/EgDmtSgGEw3wvqQUyRy8V7cI7vv6v1R8NBgaGICWH+1TI+VNzuuDmUyYxWuVzv3R89NzAXt9HxbAJaCawEQCqgucGC6SAKQRAWa4lZwWodFjoYszZiUdaGfy/SpMc7THByMIO8Uk5LTHhwVwGSgaOBGYiCBNOoRRAY9gMofwIctvuhFu+eKdZd821R6/CQ6/uI8Mn94H2lrGm9Js0h+6Uh0gtQjKCBbAZXALNrL6FBMy9uxFI4SsqwvyhSBhxuxZ1DGqU0Iop6jQpcY9sMBte+0ADJ4f0AvWzQZWaORZO4dKef1EGT9NbzCTCXFvn2wAZ/9OBBbABHApEXo6EoDUdYH064KsKaK1EPD/y2++CVas/lO6Tp0+DUIDPTwOZKHh29VxeiwkMZuJYT8/MVc9fVy6dRXpb21yJbAAJoCwLT2T95PB227RsK4L0F9idLBfB3ZBvrm/5IaVsFyJoe6zK1KdZ4Sevuudd2mbEjJ6u0EVCGfY0iwcstPG7WMyfv/rytX6gQUwaSgzyko65FnXB1IXyHKYxgwoElBkkC4qOPEIPd6Mp6XXrVypxLCcxIC3YkSIftW6xHk6p9QNDf/DY8dhUOX4duQjyej9OV0RK8wU5kR7fWnXUJt+v93VDSDsHv94YAEUgqw18GFTI2S9LlFOCEKf1+q2abH1grSHRasi86rqaph3bR1MrZ7mBFEzZ466VgN+XPi4mkSSm1rQd/o0Xc8pQ/9YpTN4CMjQ+fPQc6wb+n5zmtqWiO3u2gfCrM5yJ9V5Rm83DnBeH8xUZrPBWLkbvoUFcIUIz6pcfWCMXXrdI2HHFUzrlIRhXiu94tnORsodGCFz6YnMGbGLKN7UehSQ/kW8ASh3QIgxXAAqVt3mVBJyaY7ZNoa8Pv4F9LLECcVNIa8Q40eiXBBTSKwx2D2HKCUSdullRguAagQtDkERgV6odILt1Kxx5jLPwq2B56ZrS3OSin9N3O+Qt72IgLxTcexzEvQ2JNLchOvp565g0x77Wm8eTyUZvoUFUCByxiG0J7aPyY2iF5UuQmRdFDCr0qQXMfClWS2AxNs1jTpO4Hl+t5240DZuxGNXu1mPLc3243oXZlyULtyqLGpdZjLmfBbr6c1qudxSoYqGBVAE/Khgp1ij0dNOCGjkOG1Af6FLicjc6MR03DEhVxtQRDFf6y/cGWWgdnGPTZ8Sk/aAHsAS5mpTIfMi1dfHr61yv690UQiigAVQRKwQ/HQil8Sbs3Ez2ti08eFYAhhDz5rndUSw38JhXu8W8qAx59Z0ui6NcEVr7nV0sQfTJTotypqBPv/3jgEWQAlxhmUjQybjnteeFyeSmRpAZuyr8vaQch0kNO6M/z0TFw9yKZJ0HR4TG0w6ZJ+XuZ8dKSyAlBhldF6nxkUAetoU0uAUkPseAK7e0FFGm7pu3uhpCSKvZSmi9PKXggUQGL6x5h57vXevD2+Nm7pBfroFtiXLhn45It0asfzwDXmkF2cjnzwsACZqWABM1LAAmKhhATBRwwJgooYFwEQNC4CJGhYAEzUsACZqWABM1LAAmKhhATBRwwJgooYFwEQNC4CJGhYAEzUsACZqWABM1LAAmKhhATBRwwJgooYFwEQNC4CJGhYAEzUsACZqWABM1LAAmKj5PXRS6jSCFik3AAAAAElFTkSuQmCC"; + +export function logseqMcpIcons(request) { + return [{ + src: new URL("/mcp/icon.png", request.url).href, + mimeType: "image/png", + sizes: ["192x192"], + }]; +} + +export function setLogseqMcpServerInfoIcons(server, request) { + server.server._serverInfo = { + ...server.server._serverInfo, + icons: logseqMcpIcons(request), + }; +} + +function base64ToBytes(base64) { + const raw = atob(base64); + const payload = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) { + payload[i] = raw.charCodeAt(i); + } + return payload; +} + +export function logseqIconResponse() { + return new Response(base64ToBytes(LOGSEQ_ICON_PNG_BASE64), { + headers: { + "cache-control": "public, max-age=86400", + "content-type": "image/png", + }, + }); +} diff --git a/deps/db-sync/worker/mcp_request.mjs b/deps/db-sync/worker/mcp_request.mjs new file mode 100644 index 0000000..5f103fe --- /dev/null +++ b/deps/db-sync/worker/mcp_request.mjs @@ -0,0 +1,12 @@ +export function ensureMcpAcceptHeader(request) { + const accept = request.headers.get("accept") ?? ""; + const normalizedAccept = accept.toLowerCase(); + if (normalizedAccept.includes("application/json") && + normalizedAccept.includes("text/event-stream")) { + return request; + } + + const headers = new Headers(request.headers); + headers.set("accept", "application/json, text/event-stream"); + return new Request(request, { headers }); +} diff --git a/deps/db-sync/worker/migrations/0001_init.sql b/deps/db-sync/worker/migrations/0001_init.sql new file mode 100644 index 0000000..6a3f72b --- /dev/null +++ b/deps/db-sync/worker/migrations/0001_init.sql @@ -0,0 +1,42 @@ +create table if not exists graphs ( + graph_id text primary key, + graph_name text, + user_id text, + schema_version text, + created_at integer, + updated_at integer +); + +create table if not exists users ( + id text primary key, + email text, + email_verified integer, + username text +); + +create table if not exists user_rsa_keys ( + user_id text primary key, + public_key text, + encrypted_private_key text, + created_at integer, + updated_at integer +); + +create table if not exists graph_members ( + user_id text, + graph_id text, + role text, + invited_by text, + created_at integer, + primary key (user_id, graph_id), + check (role in ('manager', 'member')) +); + +create table if not exists graph_aes_keys ( + graph_id text, + user_id text, + encrypted_aes_key text, + created_at integer, + updated_at integer, + primary key (graph_id, user_id) +); diff --git a/deps/db-sync/worker/migrations/0002_add_graph_e2ee.sql b/deps/db-sync/worker/migrations/0002_add_graph_e2ee.sql new file mode 100644 index 0000000..9474581 --- /dev/null +++ b/deps/db-sync/worker/migrations/0002_add_graph_e2ee.sql @@ -0,0 +1,2 @@ +alter table graphs +add column graph_e2ee integer default 1; diff --git a/deps/db-sync/worker/migrations/0003_add_indexes.sql b/deps/db-sync/worker/migrations/0003_add_indexes.sql new file mode 100644 index 0000000..5e187a9 --- /dev/null +++ b/deps/db-sync/worker/migrations/0003_add_indexes.sql @@ -0,0 +1,8 @@ +create index if not exists idx_graph_members_graph_id_created_at +on graph_members (graph_id, created_at); + +create index if not exists idx_graphs_user_id_updated_at +on graphs (user_id, updated_at desc); + +create index if not exists idx_users_email +on users (email); diff --git a/deps/db-sync/worker/migrations/0004_add_graph_ready_for_use.sql b/deps/db-sync/worker/migrations/0004_add_graph_ready_for_use.sql new file mode 100644 index 0000000..e1c8fb9 --- /dev/null +++ b/deps/db-sync/worker/migrations/0004_add_graph_ready_for_use.sql @@ -0,0 +1,2 @@ +alter table graphs +add column graph_ready_for_use integer default 1; diff --git a/deps/db-sync/worker/migrations/0005_add_user_created_at.sql b/deps/db-sync/worker/migrations/0005_add_user_created_at.sql new file mode 100644 index 0000000..78b030d --- /dev/null +++ b/deps/db-sync/worker/migrations/0005_add_user_created_at.sql @@ -0,0 +1,2 @@ +alter table users +add column created_at integer; diff --git a/deps/db-sync/worker/migrations/0006_add_daily_active_entities.sql b/deps/db-sync/worker/migrations/0006_add_daily_active_entities.sql new file mode 100644 index 0000000..72901c1 --- /dev/null +++ b/deps/db-sync/worker/migrations/0006_add_daily_active_entities.sql @@ -0,0 +1,11 @@ +create table if not exists daily_active_entities ( + day_utc text, + entity_type text, + entity_id text, + first_seen_at integer, + primary key (day_utc, entity_type, entity_id), + check (entity_type in ('user', 'graph')) +); + +create index if not exists idx_daily_active_entities_type_day +on daily_active_entities (entity_type, day_utc); diff --git a/deps/db-sync/worker/openai_app_challenge.mjs b/deps/db-sync/worker/openai_app_challenge.mjs new file mode 100644 index 0000000..33a86b9 --- /dev/null +++ b/deps/db-sync/worker/openai_app_challenge.mjs @@ -0,0 +1,16 @@ +const OPENAI_APPS_CHALLENGE_PATH = "/.well-known/openai-apps-challenge"; + +export function openAiAppsChallengeResponse(request, env) { + const url = new URL(request.url); + if (request.method !== "GET" || url.pathname !== OPENAI_APPS_CHALLENGE_PATH) return null; + + const token = env.OPENAI_APPS_CHALLENGE; + if (!token) throw new Error("OPENAI_APPS_CHALLENGE is required"); + + return new Response(token, { + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }, + }); +} diff --git a/deps/db-sync/worker/scripts/delete_graphs_for_user.js b/deps/db-sync/worker/scripts/delete_graphs_for_user.js new file mode 100644 index 0000000..bf593f9 --- /dev/null +++ b/deps/db-sync/worker/scripts/delete_graphs_for_user.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +const path = require("node:path"); +const readline = require("node:readline/promises"); +const { stdin, stdout } = require("node:process"); +const { parseArgs } = require("node:util"); +const { + buildAdminGraphDeleteUrl, + buildUserGraphsSql, + buildWranglerArgs, + defaultConfigPath, + fail, + formatUserGraphsResult, + parseWranglerResults, + printUserGraphsTable, + runWranglerQuery, +} = require("./graph_user_lib"); + +function escapeSqlValue(value) { + return value.replaceAll("'", "''"); +} + +function ensureMutationSuccess(output, context) { + if (!Array.isArray(output) || output.length === 0) { + fail(`Unexpected empty response from wrangler while ${context}.`); + } + + output.forEach((statement, index) => { + if (!statement.success) { + fail(`Wrangler mutation failed while ${context} (statement ${index + 1}).`); + } + }); +} + +function deleteGraphAesKeys(options, graphId) { + const sql = `delete from graph_aes_keys where graph_id = '${escapeSqlValue(graphId)}'`; + const wranglerArgs = buildWranglerArgs({ + database: options.database, + config: options.config, + env: options.env, + sql, + }); + ensureMutationSuccess(runWranglerQuery(wranglerArgs), `deleting graph_aes_keys for ${graphId}`); +} + +function printHelp() { + console.log(`Delete db-sync graphs owned by a user from a remote D1 environment. + +Usage: + node worker/scripts/delete_graphs_for_user.js --username [--env prod] + node worker/scripts/delete_graphs_for_user.js --user-id [--env prod] + +Options: + --username Look up the target user by username. + --user-id Look up the target user by user id. + --env Wrangler environment to use. Defaults to "prod". + --database D1 binding or database name. Defaults to "DB". + --config Wrangler config path. Defaults to worker/wrangler.toml. + --base-url Worker base URL. Defaults to DB_SYNC_BASE_URL. + --admin-token Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN. + --help Show this message. +`); +} + +function parseCliArgs(argv) { + const { values } = parseArgs({ + args: argv, + options: { + username: { type: "string" }, + "user-id": { type: "string" }, + env: { type: "string", default: "prod" }, + database: { type: "string", default: "DB" }, + config: { type: "string", default: defaultConfigPath }, + "base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL }, + "admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN }, + help: { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + printHelp(); + process.exit(0); + } + + const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"])); + if (lookupCount !== 1) { + fail("Pass exactly one of --username or --user-id."); + } + + return { + lookupField: values.username ? "username" : "id", + lookupLabel: values.username ? "username" : "user-id", + lookupValue: values.username ?? values["user-id"], + env: values.env, + database: values.database, + config: path.resolve(values.config), + baseUrl: values["base-url"], + adminToken: values["admin-token"], + }; +} + +async function confirmDeletion(result) { + const rl = readline.createInterface({ input: stdin, output: stdout }); + try { + const answer = await rl.question( + `Type DELETE to remove ${result.graphs.length} owned graph(s) for ${result.user.user_id}: `, + ); + return answer.trim() === "DELETE"; + } finally { + rl.close(); + } +} + +async function main() { + const options = parseCliArgs(process.argv.slice(2)); + const lookupSql = buildUserGraphsSql({ ...options, ownedOnly: true }); + const lookupArgs = buildWranglerArgs({ + database: options.database, + config: options.config, + env: options.env, + sql: lookupSql, + }); + const lookupRows = parseWranglerResults(runWranglerQuery(lookupArgs)); + const result = formatUserGraphsResult(lookupRows); + + if (!result) { + fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`); + } + + printUserGraphsTable(result, "Owned graphs to delete"); + if (result.graphs.length === 0) { + console.log("No owned graphs found. Nothing to delete."); + return; + } + + if (!options.baseUrl) { + fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL."); + } + + if (!options.adminToken) { + fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN."); + } + + const confirmed = await confirmDeletion(result); + if (!confirmed) { + console.log("Aborted."); + return; + } + + for (const graph of result.graphs) { + const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), { + method: "DELETE", + headers: { + "x-db-sync-admin-token": options.adminToken, + }, + }); + + if (!response.ok) { + const body = await response.text(); + fail(`Delete failed for ${graph.graph_id}: ${response.status} ${body}`); + } + + deleteGraphAesKeys(options, graph.graph_id); + } + + console.log(`Deleted ${result.graphs.length} owned graph(s).`); +} + +if (require.main === module) { + main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); + }); +} + +module.exports = { + confirmDeletion, + parseCliArgs, +}; diff --git a/deps/db-sync/worker/scripts/delete_user_totally.js b/deps/db-sync/worker/scripts/delete_user_totally.js new file mode 100644 index 0000000..3193f3c --- /dev/null +++ b/deps/db-sync/worker/scripts/delete_user_totally.js @@ -0,0 +1,254 @@ +#!/usr/bin/env node + +const path = require("node:path"); +const readline = require("node:readline/promises"); +const { stdin, stdout } = require("node:process"); +const { parseArgs } = require("node:util"); +const { + buildAdminGraphDeleteUrl, + buildUserGraphsSql, + buildWranglerArgs, + defaultConfigPath, + fail, + formatUserGraphsResult, + parseWranglerResults, + printUserGraphsTable, + runWranglerQuery, +} = require("./graph_user_lib"); + +function printHelp() { + console.log(`Delete a db-sync user and all related data from a remote D1 environment. + +Usage: + node worker/scripts/delete_user_totally.js --username [--env prod] + node worker/scripts/delete_user_totally.js --user-id [--env prod] + +Options: + --username Look up the target user by username. + --user-id Look up the target user by user id. + --env Wrangler environment to use. Defaults to "prod". + --database D1 binding or database name. Defaults to "DB". + --config Wrangler config path. Defaults to worker/wrangler.toml. + --base-url Worker base URL. Defaults to DB_SYNC_BASE_URL. + --admin-token Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN. + --help Show this message. +`); +} + +function parseCliArgs(argv) { + const { values } = parseArgs({ + args: argv, + options: { + username: { type: "string" }, + "user-id": { type: "string" }, + env: { type: "string", default: "prod" }, + database: { type: "string", default: "DB" }, + config: { type: "string", default: defaultConfigPath }, + "base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL }, + "admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN }, + help: { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + printHelp(); + process.exit(0); + } + + const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"])); + if (lookupCount !== 1) { + fail("Pass exactly one of --username or --user-id."); + } + + return { + lookupField: values.username ? "username" : "id", + lookupLabel: values.username ? "username" : "user-id", + lookupValue: values.username ?? values["user-id"], + env: values.env, + database: values.database, + config: path.resolve(values.config), + baseUrl: values["base-url"], + adminToken: values["admin-token"], + }; +} + +function escapeSqlValue(value) { + return value.replaceAll("'", "''"); +} + +function runSelectQuery(options, sql) { + const wranglerArgs = buildWranglerArgs({ + database: options.database, + config: options.config, + env: options.env, + sql, + }); + + return parseWranglerResults(runWranglerQuery(wranglerArgs)); +} + +function runMutationQuery(options, sql) { + const wranglerArgs = buildWranglerArgs({ + database: options.database, + config: options.config, + env: options.env, + sql, + }); + + const output = runWranglerQuery(wranglerArgs); + if (!Array.isArray(output) || output.length === 0) { + throw new Error("Unexpected empty response from wrangler."); + } + + output.forEach((statement, index) => { + if (!statement.success) { + throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`); + } + }); + + return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0); +} + +function sqlCountToNumber(value) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : 0; +} + +function isDeleteConfirmationAccepted(answer, userId) { + const normalizedAnswer = answer.trim(); + return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`; +} + +async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) { + const rl = readline.createInterface({ input: stdin, output: stdout }); + try { + const answer = await rl.question( + `Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `, + ); + return isDeleteConfirmationAccepted(answer, user.user_id); + } finally { + rl.close(); + } +} + +async function deleteOwnedGraphs(options, ownedGraphs) { + for (const graph of ownedGraphs) { + const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), { + method: "DELETE", + headers: { + "x-db-sync-admin-token": options.adminToken, + }, + }); + + if (!response.ok) { + const payload = await response.text(); + fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`); + } + } +} + +async function main() { + const options = parseCliArgs(process.argv.slice(2)); + const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false })); + const result = formatUserGraphsResult(graphRows); + + if (!result) { + fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`); + } + + const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner"); + const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner"); + + printUserGraphsTable(result, "Graphs linked to user"); + console.log(`Owned graphs: ${ownedGraphs.length}`); + console.log(`Member graphs: ${memberGraphs.length}`); + + if (ownedGraphs.length > 0 && !options.baseUrl) { + fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL."); + } + + if (ownedGraphs.length > 0 && !options.adminToken) { + fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN."); + } + + const confirmed = await confirmDeletion({ + user: result.user, + ownedGraphsCount: ownedGraphs.length, + memberGraphsCount: memberGraphs.length, + }); + + if (!confirmed) { + console.log("Aborted."); + return; + } + + if (ownedGraphs.length > 0) { + await deleteOwnedGraphs(options, ownedGraphs); + } + + const escapedUserId = escapeSqlValue(result.user.user_id); + const remainingOwnedGraphRows = runSelectQuery( + options, + `select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`, + ); + const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count); + if (remainingOwnedGraphCount > 0) { + fail( + `Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`, + ); + } + + const deletedGraphAesKeys = runMutationQuery( + options, + `delete from graph_aes_keys where user_id = '${escapedUserId}'`, + ); + const deletedGraphMembers = runMutationQuery( + options, + `delete from graph_members where user_id = '${escapedUserId}'`, + ); + const clearedInvitedBy = runMutationQuery( + options, + `update graph_members set invited_by = null where invited_by = '${escapedUserId}'`, + ); + const deletedUserRsaKeys = runMutationQuery( + options, + `delete from user_rsa_keys where user_id = '${escapedUserId}'`, + ); + const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`); + + if (deletedUsers !== 1) { + fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`); + } + + const userRowsAfterDelete = runSelectQuery( + options, + `select id from users where id = '${escapedUserId}' limit 1`, + ); + if (userRowsAfterDelete.length > 0) { + fail(`User ${result.user.user_id} still exists after deletion.`); + } + + console.table([ + { step: "owned graphs deleted", rows: ownedGraphs.length }, + { step: "graph_aes_keys deleted", rows: deletedGraphAesKeys }, + { step: "graph_members deleted", rows: deletedGraphMembers }, + { step: "graph_members invited_by cleared", rows: clearedInvitedBy }, + { step: "user_rsa_keys deleted", rows: deletedUserRsaKeys }, + { step: "users deleted", rows: deletedUsers }, + ]); + console.log(`Deleted user ${result.user.user_id} successfully.`); +} + +if (require.main === module) { + main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); + }); +} + +module.exports = { + confirmDeletion, + isDeleteConfirmationAccepted, + parseCliArgs, +}; diff --git a/deps/db-sync/worker/scripts/delete_user_totally.test.js b/deps/db-sync/worker/scripts/delete_user_totally.test.js new file mode 100644 index 0000000..9da4958 --- /dev/null +++ b/deps/db-sync/worker/scripts/delete_user_totally.test.js @@ -0,0 +1,59 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); +const test = require("node:test"); + +const { isDeleteConfirmationAccepted, parseCliArgs } = require("./delete_user_totally"); +const { defaultConfigPath } = require("./graph_user_lib"); + +function runCli(args) { + return spawnSync(process.execPath, [path.join(__dirname, "delete_user_totally.js"), ...args], { + encoding: "utf8", + }); +} + +test("parseCliArgs accepts --username", () => { + const parsed = parseCliArgs(["--username", "alice"]); + + assert.equal(parsed.lookupField, "username"); + assert.equal(parsed.lookupLabel, "username"); + assert.equal(parsed.lookupValue, "alice"); + assert.equal(parsed.env, "prod"); + assert.equal(parsed.database, "DB"); + assert.equal(parsed.config, path.resolve(defaultConfigPath)); +}); + +test("parseCliArgs accepts --user-id", () => { + const parsed = parseCliArgs(["--user-id", "user-123"]); + + assert.equal(parsed.lookupField, "id"); + assert.equal(parsed.lookupLabel, "user-id"); + assert.equal(parsed.lookupValue, "user-123"); +}); + +test("CLI --help exits successfully", () => { + const result = runCli(["--help"]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Delete a db-sync user and all related data/); +}); + +test("CLI rejects passing both --username and --user-id", () => { + const result = runCli(["--username", "alice", "--user-id", "user-123"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Pass exactly one of --username or --user-id\./); +}); + +test("confirmation accepts DELETE", () => { + assert.equal(isDeleteConfirmationAccepted("DELETE", "user-123"), true); +}); + +test("confirmation accepts legacy DELETE USER ", () => { + assert.equal(isDeleteConfirmationAccepted("DELETE USER user-123", "user-123"), true); +}); + +test("confirmation rejects unrelated input", () => { + assert.equal(isDeleteConfirmationAccepted("DELETE USER other-user", "user-123"), false); + assert.equal(isDeleteConfirmationAccepted("yes", "user-123"), false); +}); diff --git a/deps/db-sync/worker/scripts/download_graph_db.js b/deps/db-sync/worker/scripts/download_graph_db.js new file mode 100644 index 0000000..5995164 --- /dev/null +++ b/deps/db-sync/worker/scripts/download_graph_db.js @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +const Database = require("better-sqlite3"); +const fs = require("node:fs"); +const path = require("node:path"); +const zlib = require("node:zlib"); +const { parseArgs } = require("node:util"); +const transit = require("transit-js"); +const { fail } = require("./graph_user_lib"); + +const defaultBaseUrl = "https://api.logseq.com"; + +function printHelp() { + console.log(`Download a graph snapshot and store it as a local sqlite debug DB. + +Usage: + node worker/scripts/download_graph_db.js --graph-id --admin-token + node worker/scripts/download_graph_db.js --graph-id --output ./tmp/my-graph.sqlite + +Options: + --graph-id Target graph id. Required. + --admin-token Admin token. Defaults to DB_SYNC_ADMIN_TOKEN. + --base-url Worker base URL. Defaults to DB_SYNC_BASE_URL or https://api.logseq.com. + --output SQLite output path. Defaults to ./tmp/graph-.snapshot.sqlite. + --help Show this message. + +Notes: + The output sqlite matches local graph DB schema and contains only: + kvs(addr, content, addresses). +`); +} + +function sanitizeGraphIdForFilename(graphId) { + return graphId.replaceAll(/[^a-zA-Z0-9.-]/g, "_"); +} + +function parseCliArgs(argv) { + const { values } = parseArgs({ + args: argv, + options: { + "graph-id": { type: "string" }, + "admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN }, + "base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL || defaultBaseUrl }, + output: { type: "string" }, + help: { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + printHelp(); + process.exit(0); + } + + if (!values["graph-id"]) { + fail("Missing required --graph-id."); + } + + if (!values["admin-token"]) { + fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN."); + } + + const output = values.output + ? path.resolve(values.output) + : path.resolve("tmp", `graph-${sanitizeGraphIdForFilename(values["graph-id"])}.snapshot.sqlite`); + + return { + graphId: values["graph-id"], + adminToken: values["admin-token"], + baseUrl: values["base-url"], + output, + }; +} + +function authHeaders(adminToken) { + return { + "x-db-sync-admin-token": adminToken, + }; +} + +function normalizeBaseUrl(baseUrl) { + return baseUrl.replace(/\/+$/, ""); +} + +async function fetchJson(url, adminToken) { + const response = await fetch(url, { + method: "GET", + headers: authHeaders(adminToken), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Request failed (${response.status}) for ${url}: ${body}`); + } + + return response.json(); +} + +async function fetchSnapshotDescriptor(options) { + const baseUrl = normalizeBaseUrl(options.baseUrl); + const url = `${baseUrl}/sync/${encodeURIComponent(options.graphId)}/snapshot/download`; + return fetchJson(url, options.adminToken); +} + +async function fetchSnapshotBytes(url, adminToken) { + const response = await fetch(url, { + method: "GET", + headers: authHeaders(adminToken), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Snapshot download failed (${response.status}) for ${url}: ${body}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const contentEncoding = response.headers.get("content-encoding"); + + return { + buffer, + contentEncoding, + }; +} + +function hasGzipMagic(buffer) { + return buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b; +} + +function maybeDecompressBuffer(buffer, contentEncoding) { + if (contentEncoding === "gzip" && hasGzipMagic(buffer)) { + return zlib.gunzipSync(buffer); + } + + return buffer; +} + +function parseFramedRows(buffer) { + const rows = []; + const reader = transit.reader("json"); + let offset = 0; + + while (offset < buffer.length) { + if (buffer.length - offset < 4) { + throw new Error("Invalid snapshot payload: incomplete frame header"); + } + + const frameLength = buffer.readUInt32BE(offset); + offset += 4; + + if (buffer.length - offset < frameLength) { + throw new Error("Invalid snapshot payload: incomplete frame payload"); + } + + const payload = buffer.subarray(offset, offset + frameLength); + offset += frameLength; + + const batch = reader.read(payload.toString("utf8")); + if (!Array.isArray(batch)) { + throw new Error("Invalid snapshot payload: decoded frame is not an array"); + } + + for (const row of batch) { + if (!Array.isArray(row) || row.length < 2) { + throw new Error("Invalid snapshot payload: row must be [addr, content, addresses?]"); + } + rows.push(row); + } + } + + return rows; +} + +function writeSnapshotSqlite({ + outputPath, + rows, +}) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + if (fs.existsSync(outputPath)) { + fs.rmSync(outputPath); + } + + const db = new Database(outputPath); + try { + db.exec(` + create table if not exists kvs ( + addr INTEGER primary key, + content TEXT, + addresses JSON + ); + `); + + const upsertKvs = db.prepare( + "insert into kvs (addr, content, addresses) values (?, ?, ?) on conflict(addr) do update set content = excluded.content, addresses = excluded.addresses", + ); + + const writeAll = db.transaction(() => { + for (const row of rows) { + const [addr, content, addresses] = row; + upsertKvs.run(addr, content, addresses ?? null); + } + }); + + writeAll(); + } finally { + db.close(); + } +} + +async function main() { + const options = parseCliArgs(process.argv.slice(2)); + const descriptor = await fetchSnapshotDescriptor(options); + if (!descriptor || !descriptor.url) { + fail("Snapshot download response missing URL."); + } + + const snapshot = await fetchSnapshotBytes(descriptor.url, options.adminToken); + const effectiveEncoding = descriptor["content-encoding"] || snapshot.contentEncoding || ""; + const decompressed = maybeDecompressBuffer(snapshot.buffer, effectiveEncoding); + const rows = parseFramedRows(decompressed); + + writeSnapshotSqlite({ + outputPath: options.output, + rows, + }); + + console.log(`Saved graph snapshot sqlite to ${options.output}`); + console.log(`Graph: ${options.graphId}`); + console.log(`Rows: ${rows.length}`); + if (descriptor.key) { + console.log(`Snapshot key: ${descriptor.key}`); + } +} + +if (require.main === module) { + main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); + }); +} + +module.exports = { + parseCliArgs, + parseFramedRows, + sanitizeGraphIdForFilename, + writeSnapshotSqlite, +}; diff --git a/deps/db-sync/worker/scripts/download_graph_db.test.js b/deps/db-sync/worker/scripts/download_graph_db.test.js new file mode 100644 index 0000000..a0894b7 --- /dev/null +++ b/deps/db-sync/worker/scripts/download_graph_db.test.js @@ -0,0 +1,117 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const transit = require("transit-js"); + +const Database = require("better-sqlite3"); + +const { + parseFramedRows, + parseCliArgs, + sanitizeGraphIdForFilename, + writeSnapshotSqlite, +} = require("./download_graph_db"); + +function runCli(args, env = {}) { + return spawnSync(process.execPath, [path.join(__dirname, "download_graph_db.js"), ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +test("parseCliArgs accepts required args and defaults output", () => { + const parsed = parseCliArgs([ + "--graph-id", + "graph-1", + "--admin-token", + "admin-token-value", + ]); + + assert.equal(parsed.graphId, "graph-1"); + assert.equal(parsed.baseUrl, "https://api.logseq.com"); + assert.equal(parsed.adminToken, "admin-token-value"); + assert.equal(parsed.output, path.resolve("tmp", "graph-graph-1.snapshot.sqlite")); +}); + +test("sanitizeGraphIdForFilename replaces unsafe chars", () => { + assert.equal(sanitizeGraphIdForFilename("us-east-1:abc/def"), "us-east-1_abc_def"); +}); + +test("CLI --help exits successfully", () => { + const result = runCli(["--help"]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Download a graph snapshot and store it as a local sqlite debug DB/); +}); + +test("CLI rejects missing --graph-id", () => { + const result = runCli(["--admin-token", "admin-token-value"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Missing required --graph-id\./); +}); + +test("CLI rejects missing admin-token when env is absent", () => { + const result = runCli(["--graph-id", "graph-1"], { DB_SYNC_ADMIN_TOKEN: "" }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Missing admin token/); +}); + +test("parseFramedRows decodes framed transit batches", () => { + const writer = transit.writer("json"); + const rowsA = [ + [1, "line-1", null], + [2, "line-2", null], + ]; + const rowsB = [ + [1000606, "line-1000606", "{\"1\":[2,3]}"], + ]; + + const encodeFrame = (rows) => { + const payload = Buffer.from(writer.write(rows), "utf8"); + const frame = Buffer.allocUnsafe(4 + payload.length); + frame.writeUInt32BE(payload.length, 0); + payload.copy(frame, 4); + return frame; + }; + + const framed = Buffer.concat([encodeFrame(rowsA), encodeFrame(rowsB)]); + const parsed = parseFramedRows(framed); + + assert.deepEqual(parsed, [...rowsA, ...rowsB]); +}); + +test("writeSnapshotSqlite writes kvs-only sqlite like local dbs", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "db-sync-download-test-")); + const dbPath = path.join(tmpDir, "graph-debug.sqlite"); + const rows = [ + [1, "line-1", null], + [2, "line-2", null], + [1000606, "line-1000606", "{\"1\":[2,3]}"], + ]; + + writeSnapshotSqlite({ + outputPath: dbPath, + rows, + }); + + const db = new Database(dbPath, { readonly: true }); + const tableNames = db + .prepare("select name from sqlite_master where type = 'table' order by name") + .all() + .map((row) => row.name); + const rowCount = db.prepare("select count(1) as count from kvs").get().count; + const row1000606 = db.prepare("select addr, content, addresses from kvs where addr = 1000606").get(); + + assert.deepEqual(tableNames, ["kvs"]); + assert.equal(rowCount, 3); + assert.equal(row1000606.addr, 1000606); + assert.equal(row1000606.content, "line-1000606"); + assert.equal(row1000606.addresses, "{\"1\":[2,3]}"); + + db.close(); +}); diff --git a/deps/db-sync/worker/scripts/embed_api_docs.mjs b/deps/db-sync/worker/scripts/embed_api_docs.mjs new file mode 100644 index 0000000..e02b8a5 --- /dev/null +++ b/deps/db-sync/worker/scripts/embed_api_docs.mjs @@ -0,0 +1,7 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const html = readFileSync(new URL("../dist/api-docs.html", import.meta.url), "utf8"); +writeFileSync( + new URL("../dist/api-docs.generated.mjs", import.meta.url), + `export default ${JSON.stringify(html)};\n`, +); diff --git a/deps/db-sync/worker/scripts/graph_user_lib.js b/deps/db-sync/worker/scripts/graph_user_lib.js new file mode 100644 index 0000000..2c0791a --- /dev/null +++ b/deps/db-sync/worker/scripts/graph_user_lib.js @@ -0,0 +1,196 @@ +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +const repoRoot = path.resolve(__dirname, "..", ".."); +const defaultConfigPath = path.join(repoRoot, "worker", "wrangler.toml"); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function escapeSqlValue(value) { + return value.replaceAll("'", "''"); +} + +function sqlBooleanToBool(value) { + if (value === null || value === undefined) return null; + return Number(value) === 1; +} + +function sqlTimestampToIso(value) { + if (value === null || value === undefined || value === "") return null; + const timestamp = Number(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function buildUserGraphsSql({ lookupField, lookupValue, ownedOnly = false }) { + const escapedValue = escapeSqlValue(lookupValue); + const memberUnion = ownedOnly + ? "" + : ` + union all + select g.graph_id, + g.graph_name, + g.user_id as owner_user_id, + m.role as access_role, + m.invited_by, + g.schema_version, + g.graph_e2ee, + g.graph_ready_for_use, + g.created_at, + g.updated_at + from graph_members m + join graphs g on g.graph_id = m.graph_id + join target_user u on m.user_id = u.id + where g.user_id <> u.id`; + + return `with target_user as ( + select id, email, username + from users + where ${lookupField} = '${escapedValue}' + limit 1 +), +matching_graphs as ( + select g.graph_id, + g.graph_name, + g.user_id as owner_user_id, + 'owner' as access_role, + null as invited_by, + g.schema_version, + g.graph_e2ee, + g.graph_ready_for_use, + g.created_at, + g.updated_at + from graphs g + join target_user u on g.user_id = u.id${memberUnion} +) +select u.id as user_id, + u.email as user_email, + u.username as user_username, + g.graph_id, + g.graph_name, + g.access_role, + g.invited_by, + g.owner_user_id, + owner.email as owner_email, + owner.username as owner_username, + g.schema_version, + g.graph_e2ee, + g.graph_ready_for_use, + g.created_at, + g.updated_at +from target_user u +left join matching_graphs g on 1 = 1 +left join users owner on owner.id = g.owner_user_id +order by g.updated_at desc;`; +} + +function buildWranglerArgs({ database, config, env, sql, local = false }) { + const useLocal = local || env === "local"; + const args = [ + "dlx", + "wrangler", + "d1", + "execute", + database, + "--config", + config, + ]; + + if (useLocal) { + args.push("--local"); + } else { + args.push("--env", env, "--remote"); + } + + args.push("--json", "--command", sql); + + return args; +} + +function runWranglerQuery(args) { + const output = execFileSync("pnpm", args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + + return JSON.parse(output); +} + +function parseWranglerResults(output) { + if (!Array.isArray(output) || output.length === 0) { + throw new Error("Unexpected empty response from wrangler."); + } + + const [statement] = output; + if (!statement.success) { + throw new Error("Wrangler reported an unsuccessful D1 query."); + } + + return Array.isArray(statement.results) ? statement.results : []; +} + +function formatUserGraphsResult(rows) { + if (rows.length === 0) { + return null; + } + + const [firstRow] = rows; + const graphs = rows + .filter((row) => row.graph_id) + .map((row) => ({ + graph_id: row.graph_id, + graph_name: row.graph_name, + access_role: row.access_role, + invited_by: row.invited_by ?? null, + owner_user_id: row.owner_user_id, + owner_username: row.owner_username ?? null, + owner_email: row.owner_email ?? null, + schema_version: row.schema_version ?? null, + graph_e2ee: sqlBooleanToBool(row.graph_e2ee), + graph_ready_for_use: sqlBooleanToBool(row.graph_ready_for_use), + created_at: sqlTimestampToIso(row.created_at), + updated_at: sqlTimestampToIso(row.updated_at), + })); + + return { + user: { + user_id: firstRow.user_id, + username: firstRow.user_username ?? null, + email: firstRow.user_email ?? null, + }, + graphs, + }; +} + +function printUserGraphsTable(result, countLabel = "Graphs") { + console.log( + `User: ${result.user.user_id}` + + (result.user.username ? ` (${result.user.username})` : "") + + (result.user.email ? ` <${result.user.email}>` : ""), + ); + console.log(`${countLabel}: ${result.graphs.length}`); + if (result.graphs.length > 0) { + console.table(result.graphs); + } +} + +function buildAdminGraphDeleteUrl(baseUrl, graphId) { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + return `${normalizedBaseUrl}/admin/graphs/${encodeURIComponent(graphId)}`; +} + +module.exports = { + buildAdminGraphDeleteUrl, + buildUserGraphsSql, + buildWranglerArgs, + defaultConfigPath, + fail, + formatUserGraphsResult, + parseWranglerResults, + printUserGraphsTable, + repoRoot, + runWranglerQuery, +}; diff --git a/deps/db-sync/worker/scripts/show_graphs_for_user.js b/deps/db-sync/worker/scripts/show_graphs_for_user.js new file mode 100644 index 0000000..b4a69b5 --- /dev/null +++ b/deps/db-sync/worker/scripts/show_graphs_for_user.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +const path = require("node:path"); +const { parseArgs } = require("node:util"); +const { + buildUserGraphsSql, + buildWranglerArgs, + defaultConfigPath, + fail, + formatUserGraphsResult, + parseWranglerResults, + printUserGraphsTable, + runWranglerQuery, +} = require("./graph_user_lib"); + +function printHelp() { + console.log(`Show db-sync graphs for a user from a remote D1 environment. + +Usage: + node worker/scripts/show_graphs_for_user.js --username [--env prod] [--json] + node worker/scripts/show_graphs_for_user.js --user-id [--env prod] [--json] + +Options: + --username Look up the target user by username. + --user-id Look up the target user by user id. + --env Wrangler environment to use. Defaults to "prod". + --database D1 binding or database name. Defaults to "DB". + --config Wrangler config path. Defaults to worker/wrangler.toml. + --json Print JSON instead of a table. + --help Show this message. +`); +} + +function parseCliArgs(argv) { + const { values } = parseArgs({ + args: argv, + options: { + username: { type: "string" }, + "user-id": { type: "string" }, + env: { type: "string", default: "prod" }, + database: { type: "string", default: "DB" }, + config: { type: "string", default: defaultConfigPath }, + json: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + printHelp(); + process.exit(0); + } + + const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"])); + if (lookupCount !== 1) { + fail("Pass exactly one of --username or --user-id."); + } + + return { + lookupField: values.username ? "username" : "id", + lookupLabel: values.username ? "username" : "user-id", + lookupValue: values.username ?? values["user-id"], + env: values.env, + database: values.database, + config: path.resolve(values.config), + json: values.json, + }; +} + +function main() { + const options = parseCliArgs(process.argv.slice(2)); + const sql = buildUserGraphsSql(options); + const wranglerArgs = buildWranglerArgs({ + database: options.database, + config: options.config, + env: options.env, + sql, + }); + const rows = parseWranglerResults(runWranglerQuery(wranglerArgs)); + const result = formatUserGraphsResult(rows); + + if (!result) { + fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`); + } + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printUserGraphsTable(result); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} + +module.exports = { + parseCliArgs, +}; diff --git a/deps/db-sync/worker/scripts/show_usage_stats.js b/deps/db-sync/worker/scripts/show_usage_stats.js new file mode 100644 index 0000000..55184b7 --- /dev/null +++ b/deps/db-sync/worker/scripts/show_usage_stats.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +const path = require("node:path"); +const { parseArgs } = require("node:util"); +const { + buildWranglerArgs, + defaultConfigPath, + fail, + parseWranglerResults, + runWranglerQuery, +} = require("./graph_user_lib"); + +function printHelp() { + console.log(`Show db-sync usage stats from a D1 environment. + +Usage: + node worker/scripts/show_usage_stats.js [--env prod|local] [--days 7] [--json] + +Options: + --env Wrangler environment to use. Defaults to "prod"; use "local" to query the local D1 database. + --days Active window size in days. Defaults to 1. + --database D1 binding or database name. Defaults to "DB". + --config Wrangler config path. Defaults to worker/wrangler.toml. + --json Print JSON instead of a table. + --help Show this message. + +Output: + days + active_since_utc + active_users_last_n_days + active_graphs_last_n_days + total_users + total_graphs + users_created_today + graphs_created_today + today_utc +`); +} + +function parseDays(value) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + fail("--days must be an integer >= 1."); + } + return parsed; +} + +function parseCliArgs(argv) { + const { values } = parseArgs({ + args: argv, + options: { + env: { type: "string", default: "prod" }, + days: { type: "string", default: "1" }, + database: { type: "string", default: "DB" }, + config: { type: "string", default: defaultConfigPath }, + json: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + printHelp(); + process.exit(0); + } + + return { + env: values.env, + days: parseDays(values.days), + database: values.database, + config: path.resolve(values.config), + json: values.json, + }; +} + +function buildUsageStatsSql(days) { + const sinceDays = days - 1; + return `with bounds as ( + select + cast(strftime('%s', 'now', 'start of day') as integer) * 1000 as today_start_ms, + cast(strftime('%s', 'now', 'start of day', '+1 day') as integer) * 1000 as tomorrow_start_ms, + date('now') as today_utc, + date('now', '-${sinceDays} days') as active_since_utc +) +select + ${days} as days, + (select active_since_utc from bounds) as active_since_utc, + (select count(distinct entity_id) + from daily_active_entities a + join bounds b on 1 = 1 + where a.entity_type = 'user' + and a.day_utc >= b.active_since_utc + and a.day_utc <= b.today_utc) as active_users_last_n_days, + (select count(distinct entity_id) + from daily_active_entities a + join bounds b on 1 = 1 + where a.entity_type = 'graph' + and a.day_utc >= b.active_since_utc + and a.day_utc <= b.today_utc) as active_graphs_last_n_days, + (select count(1) from users) as total_users, + (select count(1) from graphs) as total_graphs, + (select count(1) + from users u + join bounds b on 1 = 1 + where u.created_at is not null + and u.created_at >= b.today_start_ms + and u.created_at < b.tomorrow_start_ms) as users_created_today, + (select count(1) + from graphs g + join bounds b on 1 = 1 + where g.created_at is not null + and g.created_at >= b.today_start_ms + and g.created_at < b.tomorrow_start_ms) as graphs_created_today, + (select today_utc from bounds) as today_utc;`; +} + +function sqlCountToNumber(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +} + +function formatUsageStats(rows) { + if (!rows.length) { + return null; + } + + const [row] = rows; + return { + days: sqlCountToNumber(row.days), + active_since_utc: typeof row.active_since_utc === "string" ? row.active_since_utc : null, + active_users_last_n_days: sqlCountToNumber(row.active_users_last_n_days), + active_graphs_last_n_days: sqlCountToNumber(row.active_graphs_last_n_days), + total_users: sqlCountToNumber(row.total_users), + total_graphs: sqlCountToNumber(row.total_graphs), + users_created_today: sqlCountToNumber(row.users_created_today), + graphs_created_today: sqlCountToNumber(row.graphs_created_today), + today_utc: typeof row.today_utc === "string" ? row.today_utc : null, + }; +} + +function printUsageStatsTable(stats) { + console.table([stats]); +} + +function main() { + const options = parseCliArgs(process.argv.slice(2)); + const sql = buildUsageStatsSql(options.days); + const wranglerArgs = buildWranglerArgs({ + database: options.database, + config: options.config, + env: options.env, + sql, + }); + const rows = parseWranglerResults(runWranglerQuery(wranglerArgs)); + const stats = formatUsageStats(rows); + + if (!stats) { + fail("No stats returned from D1."); + } + + if (options.json) { + console.log(JSON.stringify(stats, null, 2)); + } else { + printUsageStatsTable(stats); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/no such table: daily_active_entities/i.test(message)) { + fail("Missing daily_active_entities. Apply worker migration 0006_add_daily_active_entities.sql."); + } + if (/no such column: created_at/i.test(message)) { + fail("Missing users.created_at. Apply worker migration 0005_add_user_created_at.sql."); + } + fail(message); + } +} + +module.exports = { + buildUsageStatsSql, + formatUsageStats, + parseCliArgs, +}; diff --git a/deps/db-sync/worker/scripts/ws_test.js b/deps/db-sync/worker/scripts/ws_test.js new file mode 100644 index 0000000..96a7faa --- /dev/null +++ b/deps/db-sync/worker/scripts/ws_test.js @@ -0,0 +1,32 @@ +const baseUrl = process.env.BASE_URL || "http://127.0.0.1:8787"; +const graphId = process.env.GRAPH_ID || "dev-graph"; + +const wsUrl = baseUrl.replace(/^http/, "ws") + `/sync/${graphId}`; + +const WebSocketCtor = globalThis.WebSocket; +if (!WebSocketCtor) { + console.error("WebSocket not available. Use node>=20 or provide a WebSocket polyfill."); + process.exit(1); +} + +const ws = new WebSocketCtor(wsUrl); +let pending = 0; + +ws.addEventListener("open", () => { + ws.send(JSON.stringify({ type: "hello", client: "ws-test" })); + ws.send(JSON.stringify({ type: "ping" })); + ws.send(JSON.stringify({ type: "pull", since: 0 })); + pending = 3; +}); + +ws.addEventListener("message", (event) => { + console.log(String(event.data)); + pending -= 1; + if (pending <= 0) { + ws.close(); + } +}); + +ws.addEventListener("error", (event) => { + console.error("ws error", event); +}); diff --git a/deps/db-sync/worker/semantic_request.mjs b/deps/db-sync/worker/semantic_request.mjs new file mode 100644 index 0000000..3fb5676 --- /dev/null +++ b/deps/db-sync/worker/semantic_request.mjs @@ -0,0 +1,48 @@ +const MAX_ASSET_SIZE = 100 * 1024 * 1024; + +function decodedBase64Size(value) { + if (typeof value !== "string") throw new Error("base64 asset body must be a string"); + + let length = 0; + let padding = 0; + let sawPadding = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + const code = value.charCodeAt(index); + if (code === 9 || code === 10 || code === 12 || code === 13 || code === 32) continue; + if (character === "=") { + sawPadding = true; + padding += 1; + } else { + const base64Character = (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || code === 43 || code === 47; + if (sawPadding || !base64Character) { + throw new Error("invalid base64 asset body"); + } + } + length += 1; + } + if (length % 4 !== 0 || padding > 2) throw new Error("invalid base64 asset body"); + return (length / 4) * 3 - padding; +} + +export function semanticRequestUrl(options, baseUrl) { + const url = new URL(options.path, baseUrl); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + if (url.searchParams.get("encoding") === "base64") { + const size = decodedBase64Size(options.body); + if (size > MAX_ASSET_SIZE) throw new Error("decoded asset exceeds the 100MB limit"); + url.searchParams.set("size", String(size)); + } + return url; +} + +export function semanticRequestBody(options, url) { + if (options.body === undefined) return undefined; + return options.rawBody || url.searchParams.get("encoding") === "base64" + ? options.body + : JSON.stringify(options.body); +} diff --git a/deps/db-sync/worker/wrangler.toml b/deps/db-sync/worker/wrangler.toml new file mode 100644 index 0000000..f01e068 --- /dev/null +++ b/deps/db-sync/worker/wrangler.toml @@ -0,0 +1,163 @@ +name = "logseq-sync" +main = "entry.mjs" +compatibility_date = "2026-07-11" +compatibility_flags = [ "nodejs_compat" ] + +[[worker_loaders]] +binding = "LOADER" + +[limits] +cpu_ms = 300000 + +[version_metadata] +binding = "CF_VERSION_METADATA" + +[observability] +enabled = true + +[observability.logs] +invocation_logs = false + +[[durable_objects.bindings]] +name = "LOGSEQ_SYNC_DO" +class_name = "SyncDO" + +[[r2_buckets]] +binding = "LOGSEQ_SYNC_ASSETS" +bucket_name = "logseq-sync-assets-prod" + +[[d1_databases]] +binding = "DB" +database_name = "logseq-sync-graph-meta-prod" +database_id = "00325aa2-c805-4693-b599-900a25dcde42" + +[[migrations]] +tag = "v1" +new_sqlite_classes = [ "SyncDO" ] + +[vars] +COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json" +COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8" +COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6" + +[[ratelimits]] +name = "SEMANTIC_READ_RATE_LIMITER" +namespace_id = "1001" + +[ratelimits.simple] +limit = 120 +period = 60 + +[[ratelimits]] +name = "SEMANTIC_WRITE_RATE_LIMITER" +namespace_id = "1002" + +[ratelimits.simple] +limit = 20 +period = 60 + +[env.staging] +name = "logseq-sync-staging" + +[env.staging.limits] +cpu_ms = 300000 + +[env.staging.vars] +COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json" +COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8" +COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6" +COGNITO_CLIENT_IDS = "7cm3bi0ng53iitncabjnb4hs52,7ob5ih73g8rgjbcjj8du3ofhe4" +SENTRY_DSN = "https://dc09d27243b9492bbe15e0dd279ad7de@o416451.ingest.us.sentry.io/5311485" +SENTRY_ENVIRONMENT = "staging" +SENTRY_TRACES_SAMPLE_RATE = "0.1" + +[[env.staging.durable_objects.bindings]] +name = "LOGSEQ_SYNC_DO" +class_name = "SyncDO" + +[[env.staging.migrations]] +tag = "v1" +new_sqlite_classes = [ "SyncDO" ] + +[[env.staging.r2_buckets]] +binding = "LOGSEQ_SYNC_ASSETS" +bucket_name = "logseq-sync-assets-dev" + +[[env.staging.d1_databases]] +binding = "DB" +database_name = "logseq-sync-graph-meta-staging" +database_id = "00325aa2-c805-4693-b599-900a25dcde42" + +[env.staging.version_metadata] +binding = "CF_VERSION_METADATA" + +[[env.staging.worker_loaders]] +binding = "LOADER" + +[[env.staging.ratelimits]] +name = "SEMANTIC_READ_RATE_LIMITER" +namespace_id = "1101" + +[env.staging.ratelimits.simple] +limit = 120 +period = 60 + +[[env.staging.ratelimits]] +name = "SEMANTIC_WRITE_RATE_LIMITER" +namespace_id = "1102" + +[env.staging.ratelimits.simple] +limit = 20 +period = 60 + +[env.prod] +name = "logseq-sync-prod" + +[env.prod.vars] +COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json" +COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8" +COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6" +COGNITO_CLIENT_IDS = "5nrhvuvjn9aagbpff45rjpfmm9" +OPENAI_APPS_CHALLENGE = "LEs8t_lCac2IDOaMNTZ1_wc_hQ5w9UgdTkww9kGxjW8" +SENTRY_DSN = "https://dc09d27243b9492bbe15e0dd279ad7de@o416451.ingest.us.sentry.io/5311485" +SENTRY_ENVIRONMENT = "prod" +SENTRY_TRACES_SAMPLE_RATE = "0.1" + +[[env.prod.durable_objects.bindings]] +name = "LOGSEQ_SYNC_DO" +class_name = "SyncDO" + +[[env.prod.migrations]] +tag = "v1" +new_sqlite_classes = [ "SyncDO" ] + +[[env.prod.r2_buckets]] +binding = "LOGSEQ_SYNC_ASSETS" +bucket_name = "logseq-sync-assets-prod" + +[[env.prod.d1_databases]] +binding = "DB" +database_name = "logseq-sync-graphs-prod" +database_id = "4c80e058-69b5-4985-88d1-f53711d817ba" + +[env.prod.version_metadata] +binding = "CF_VERSION_METADATA" + +[[env.prod.worker_loaders]] +binding = "LOADER" + +[[env.prod.ratelimits]] +name = "SEMANTIC_READ_RATE_LIMITER" +namespace_id = "1201" + +[env.prod.ratelimits.simple] +limit = 120 +period = 60 + +[[env.prod.ratelimits]] +name = "SEMANTIC_WRITE_RATE_LIMITER" +namespace_id = "1202" + +[env.prod.ratelimits.simple] +limit = 20 +period = 60 diff --git a/deps/db/.carve/config.edn b/deps/db/.carve/config.edn new file mode 100644 index 0000000..7c825ea --- /dev/null +++ b/deps/db/.carve/config.edn @@ -0,0 +1,24 @@ +{:paths ["src"] + :api-namespaces [logseq.db.common.sqlite + logseq.db.sqlite.util + logseq.db.common.sqlite-cli + logseq.db.frontend.property + logseq.db.frontend.property.build + logseq.db.frontend.content + logseq.db.common.order + logseq.db.sqlite.create-graph + logseq.db.frontend.malli-schema + logseq.db.frontend.asset + ;; Used outside deps/db by frontend worker and electron modules. + logseq.db.sqlite.backup + ;; Used outside deps/db by frontend handler and worker search. + logseq.db.frontend.block-title + ;; Used outside deps/db by frontend worker query handlers. + logseq.db.frontend.datalog + ;; Some fns are used by frontend but not worth moving over yet + logseq.db.frontend.schema + logseq.db.frontend.validate + logseq.db.test.helper + logseq.db + logseq.db.frontend.property.type] + :report {:format :ignore}} diff --git a/deps/db/.carve/ignore b/deps/db/.carve/ignore new file mode 100644 index 0000000..c46ed14 --- /dev/null +++ b/deps/db/.carve/ignore @@ -0,0 +1,67 @@ +;; API +logseq.db.frontend.rules/db-query-dsl-rules +;; API +logseq.db.frontend.rules/extract-rules +;; API +logseq.db.frontend.rules/rules +;; API +logseq.db.frontend.rules/rules-dependencies +;; API +logseq.db.frontend.query-dsl/pre-transform +;; API +logseq.db.frontend.query-dsl/simplify-query +;; API +logseq.db.frontend.query-dsl/get-timestamp-property +;; API +logseq.db.frontend.query-dsl/custom-readers +;; API +logseq.db.frontend.inputs/resolve-input +;; API +logseq.db.frontend.class/build-new-class +;; API +logseq.db.frontend.class/logseq-class? +;; API +logseq.db.frontend.class/disallowed-inline-tags +;; API +logseq.db.frontend.db-ident/ensure-unique-db-ident +;; API +logseq.db.frontend.db-ident/replace-db-ident-random-suffix +;; API +logseq.db.sqlite.build/create-blocks +;; API +logseq.db.sqlite.export/build-export +;; API +logseq.db.sqlite.export/validate-export +;; API +logseq.db.sqlite.export/build-import +;; API +logseq.db.sqlite.export/diff-exports +;; API +logseq.db.common.view/get-property-values +;; API +logseq.db.common.view/get-view-data +;; API +logseq.db.common.initial-data/with-parent +;; API +logseq.db.common.initial-data/get-block-refs +;; API +logseq.db.common.initial-data/get-block-and-children +;; API +logseq.db.common.initial-data/get-initial-data +;; API +logseq.db.sqlite.debug/find-missing-addresses +;; API +logseq.db.sqlite.debug/find-missing-addresses-node-version +;; API +logseq.db.sqlite.gc/gc-kvs-table! +;; API +logseq.db.sqlite.gc/gc-kvs-table-node-version! +;; API +logseq.db.sqlite.gc/ensure-no-garbage +;; API +logseq.db.frontend.entity-util/entity->map +;; documenting keywords +logseq.db.frontend.kv-entity/kv-entities +;; API +logseq.db.common.normalize/replace-attr-retract-with-retract-entity-v2 +logseq.db.common.normalize/normalize-tx-data diff --git a/deps/db/.clj-kondo/config.edn b/deps/db/.clj-kondo/config.edn new file mode 100644 index 0000000..487ed58 --- /dev/null +++ b/deps/db/.clj-kondo/config.edn @@ -0,0 +1,34 @@ +{:linters + {:aliased-namespace-symbol {:level :warning} + :namespace-name-mismatch {:level :warning} + :used-underscored-binding {:level :warning} + :shadowed-var {:level :warning + ;; FIXME: Remove these as shadowing core fns isn't a good practice + :exclude [val key]} + + :consistent-alias + {:aliases {clojure.string string + logseq.db ldb + logseq.db.common.entity-plus entity-plus + logseq.db.common.order db-order + logseq.db.common.sqlite common-sqlite + logseq.db.common.view db-view + logseq.db.frontend.content db-content + logseq.db.frontend.class db-class + logseq.db.frontend.db db-db + logseq.db.frontend.db-ident db-ident + logseq.db.frontend.inputs db-inputs + logseq.db.frontend.property db-property + logseq.db.frontend.property.build db-property-build + logseq.db.frontend.property.type db-property-type + logseq.db.frontend.rules rules + logseq.db.frontend.schema db-schema + logseq.db.frontend.validate db-validate + logseq.db.sqlite.build sqlite-build + logseq.db.common.initial-data common-initial-data + logseq.db.common.sqlite-cli sqlite-cli + logseq.db.sqlite.create-graph sqlite-create-graph + logseq.db.sqlite.export sqlite-export + logseq.db.sqlite.util sqlite-util}}} + :skip-comments true + :output {:progress true}} diff --git a/deps/db/.gitignore b/deps/db/.gitignore new file mode 100644 index 0000000..db8abf3 --- /dev/null +++ b/deps/db/.gitignore @@ -0,0 +1 @@ +/.clj-kondo/.cache diff --git a/deps/db/README.md b/deps/db/README.md new file mode 100644 index 0000000..712ca1a --- /dev/null +++ b/deps/db/README.md @@ -0,0 +1,71 @@ +## Description + +This library provides an API to the +frontend([datascript](https://github.com/tonsky/datascript)) and +backend([SQLite](https://www.sqlite.org/index.html)) databases from the Logseq +app and the CLI. This library defines the core schema and fns for DB graphs. +There are a few namespaces that also support file graphs for the graph-parser. +This library is compatible with ClojureScript and with +[nbb-logseq](https://github.com/logseq/nbb-logseq) to respectively provide +frontend and commandline functionality. + +## API + +This library is under the parent namespace `logseq.db`. It provides the following namespaces: +* `logseq.db` - main public ns. Most often used by frontend +* `logseq.db.frontend.*` - frontend namespaces for DB graphs +* `logseq.db.sqlite.*` - backend/sqlite namespaces for DB graphs +* `logseq.db.common.*` - frontend and backend namespaces for DB graphs + +The following namespaces are used with file graphs via the graph-parser: `logseq.db.common.order, logseq.db.frontend.entity-util and logseq.db.common.entity-plus`. + +## Usage + +See the frontend for example 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-db.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 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 +``` +### Datalog linting + +Datalog rules for the client are linted through a script that also uses the datalog-parser. To run this linter: +``` +bb lint:rules +``` + +### Managing dependencies + +The package.json dependencies are just for testing and should be updated if there is +new behavior to test. + +The deps.edn dependencies are used by both ClojureScript and nbb-logseq. Their +versions should be backwards compatible with each other with priority given to +the frontend. _No new dependency_ should be introduced to this library without +an understanding of the tradeoffs of adding this to nbb-logseq. diff --git a/deps/db/bb.edn b/deps/db/bb.edn new file mode 100644 index 0000000..7f6157c --- /dev/null +++ b/deps/db/bb.edn @@ -0,0 +1,43 @@ +{:paths ["src"] + :min-bb-version "0.8.156" + :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:rules + {:requires ([logseq.bb-tasks.lint.datalog :as datalog] + [logseq.db.frontend.rules :as rules]) + :doc "Lint datalog rules for parsability and unbound variables" + :task (datalog/lint-rules + (set + (concat (mapcat val (dissoc rules/rules :self-ref)) + ;; TODO: Update linter to handle false positive on :task, :priority, :*property* rules + (rules/extract-rules (dissoc rules/db-query-dsl-rules + :task :priority + :property :simple-query-property :private-property + :property-scalar-default-value + :property-missing-value + :has-property-or-object-property)))))}} + + :tasks/config + {:large-vars + {:max-lines-count 50 + :metadata-exceptions #{:large-vars/doc-var :large-vars/cleanup-todo}}}} diff --git a/deps/db/deps.edn b/deps/db/deps.edn new file mode 100644 index 0000000..35a6216 --- /dev/null +++ b/deps/db/deps.edn @@ -0,0 +1,24 @@ +{: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"} + datascript-transit/datascript-transit {:mvn/version "0.3.0" + :exclusions [datascript/datascript]} + cljs-bean/cljs-bean {:mvn/version "1.9.0"} + com.cognitect/transit-cljs {:mvn/version "0.8.280"} + org.flatland/ordered {:mvn/version "1.15.12"} + + ;; Any other deps should be added here and to nbb.edn + logseq/common {:local/root "../common"} + logseq/clj-fractional-indexing {:git/url "https://github.com/logseq/clj-fractional-indexing" + :sha "1087f0fb18aa8e25ee3bbbb0db983b7a29bce270"} + borkdude/rewrite-edn {:mvn/version "0.5.9"} + metosin/malli {:git/url "https://github.com/metosin/malli" + :sha "52ea58a36ff5172b38dfc526ca638afa7226a4a0"} + medley/medley {:mvn/version "1.4.0"}} + + :aliases + {:clj-kondo + {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/db/nbb.edn b/deps/db/nbb.edn new file mode 100644 index 0000000..2b6f669 --- /dev/null +++ b/deps/db/nbb.edn @@ -0,0 +1,14 @@ +{:paths ["src"] + :deps + {logseq/common + {:local/root "../common"} + medley/medley {:mvn/version "1.4.0"} + metosin/malli {:git/url "https://github.com/metosin/malli" + :sha "52ea58a36ff5172b38dfc526ca638afa7226a4a0"} + ;; Used by db scripts with outliner.cli + borkdude/rewrite-edn {:mvn/version "0.5.9"} + logseq/clj-fractional-indexing {:git/url "https://github.com/logseq/clj-fractional-indexing" + :sha "1087f0fb18aa8e25ee3bbbb0db983b7a29bce270"} + io.github.nextjournal/nbb-test-runner + {:git/sha "b379325cfa5a3306180649da5de3bf5166414e71"} + io.github.pez/baldr {:mvn/version "1.0.9"}}} diff --git a/deps/db/package.json b/deps/db/package.json new file mode 100644 index 0000000..9d342a1 --- /dev/null +++ b/deps/db/package.json @@ -0,0 +1,22 @@ +{ + "name": "@logseq/db", + "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", + "fs-extra": "^11.3.4" + }, + "dependencies": { + "better-sqlite3": "^12.8.0" + }, + "scripts": { + "test": "pnpm exec nbb-logseq -cp test -m nextjournal.test-runner", + "test-v": "pnpm exec nbb-logseq -cp test -m logseq.db.test-runner" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] + } +} diff --git a/deps/db/pnpm-lock.yaml b/deps/db/pnpm-lock.yaml new file mode 100644 index 0000000..7678ce0 --- /dev/null +++ b/deps/db/pnpm-lock.yaml @@ -0,0 +1,339 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 + 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 + fs-extra: + specifier: ^11.3.4 + version: 11.3.4 + +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 + + 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==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + 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==} + + 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==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + 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==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + 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==} + + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + + node-abi@3.71.0: + resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==} + engines: {node: '>=10'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + 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'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + 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_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + 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==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@logseq/nbb-logseq@https://codeload.github.com/logseq/nbb-logseq/tar.gz/4d9f1382ce2cec6f39bf12df49ba425f1bd5f3fb': + dependencies: + import-meta-resolve: 4.1.0 + + 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 + + chownr@1.1.4: {} + + 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 + + expand-template@2.0.3: {} + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + github-from-package@0.0.0: {} + + graceful-fs@4.2.11: {} + + ieee754@1.2.1: {} + + import-meta-resolve@4.1.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + napi-build-utils@1.0.2: {} + + node-abi@3.71.0: + dependencies: + semver: 7.6.3 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + 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 + + safe-buffer@5.2.1: {} + + semver@7.6.3: {} + + 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_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + 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 + + universalify@2.0.1: {} + + util-deprecate@1.0.2: {} + + wrappy@1.0.2: {} diff --git a/deps/db/script/create_graph.cljs b/deps/db/script/create_graph.cljs new file mode 100644 index 0000000..bf30977 --- /dev/null +++ b/deps/db/script/create_graph.cljs @@ -0,0 +1,76 @@ +(ns create-graph + "A script that creates or updates a DB graph given a sqlite.build EDN file. + If the given graph already exists, the EDN file updates the graph." + (:require ["fs" :as fs] + ["path" :as node-path] + [babashka.cli :as cli] + [clojure.edn :as edn] + [logseq.db :as ldb] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.sqlite.export :as sqlite-export] + [logseq.outliner.cli :as outliner-cli] + [clojure.pprint :as pprint] + [nbb.classpath :as cp] + [nbb.core :as nbb])) + +(defn- resolve-path + "If relative path, resolve with $ORIGINAL_PWD" + [path] + (if (node-path/isAbsolute path) + path + (node-path/join (or js/process.env.ORIGINAL_PWD ".") path))) + +(defn- validate-db [db db-name options] + (if-let [errors (:errors + (db-validate/validate-local-db! + db + (merge options {:db-name db-name :verbose true})))] + (do + (println "Found" (count errors) + (if (= 1 (count errors)) "entity" "entities") + "with errors:") + (pprint/pprint errors) + (js/process.exit 1)) + (println "Valid!"))) + +(def spec + "Options spec" + {:help {:alias :h + :desc "Print help"} + :validate {:alias :v + :desc "Validate db after creation"} + :import {:alias :i + :desc "Import edn file using sqlite-export"}}) + +(defn -main [args] + (let [{options :opts args' :args} (cli/parse-args args {:spec spec}) + [graph-dir edn-path] args' + _ (when (or (nil? graph-dir) (nil? edn-path) (:help options)) + (println (str "Usage: $0 GRAPH-NAME EDN-PATH [OPTIONS]\nOptions:\n" + (cli/format-opts {:spec spec}))) + (js/process.exit 1)) + init-conn-args (conj (sqlite-cli/->open-db-args graph-dir)) + sqlite-build-edn (merge (if (:import options) {} {:auto-create-ontology? true}) + (-> (resolve-path edn-path) fs/readFileSync str edn/read-string)) + graph-exists? (fs/existsSync (apply node-path/join init-conn-args)) + db-name (if (= 1 (count init-conn-args)) (first init-conn-args) (second init-conn-args)) + conn (apply outliner-cli/init-conn + (conj init-conn-args {:classpath (cp/get-classpath) :import-type :cli/create-graph})) + {:keys [init-tx block-props-tx misc-tx] :as _txs} + (if (:import options) + (sqlite-export/build-import sqlite-build-edn @conn {}) + (outliner-cli/build-blocks-tx sqlite-build-edn))] + (println "Generating" (count (filter :block/name init-tx)) "pages and" + (count (filter :block/title init-tx)) "blocks ...") + ;; (fs/writeFileSync "txs.edn" (with-out-str (cljs.pprint/pprint _txs))) + ;; (cljs.pprint/pprint _txs) + (ldb/transact! conn init-tx) + (when (seq block-props-tx) (ldb/transact! conn block-props-tx)) + (when (seq misc-tx) (ldb/transact! conn misc-tx)) + (println (if graph-exists? "Updated graph" "Created graph") (str db-name "!")) + (when (:validate options) + (validate-db @conn db-name {})))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/db/script/create_graph/inferred.edn b/deps/db/script/create_graph/inferred.edn new file mode 100644 index 0000000..e86d8c2 --- /dev/null +++ b/deps/db/script/create_graph/inferred.edn @@ -0,0 +1,23 @@ +;; Script that generates classes and properties for a demo of inferring properties. +;; To generate this graph: +;; bb dev:create inferred deps/db/create_graph/inferred.edn +;; +;; To try the demo in the UI, in any page type: +;; - Good Will Hunting #Movie #Ben-Affleck +;; or +;; - DB 3 #Meeting #Tienson +{:auto-create-ontology? true + :classes {:Movie {:build/class-properties [:actor :comment]} + :Meeting {:build/class-properties [:attendee :duration]}} + :properties + {:actor {:logseq.property/type :node + :db/cardinality :many + :build/property-classes [:Person]} + :attendee {:logseq.property/type :node + :db/cardinality :many + :build/property-classes [:Person]}} + :pages-and-blocks + [{:page {:block/title "Matt-Damon" :build/tags [:Person]}} + {:page {:block/title "Ben-Affleck" :build/tags [:Person]}} + {:page {:block/title "Tienson" :build/tags [:Person]}} + {:page {:block/title "Zhiyuan" :build/tags [:Person]}}]} diff --git a/deps/db/script/dump_datoms.cljs b/deps/db/script/dump_datoms.cljs new file mode 100644 index 0000000..0a59ca0 --- /dev/null +++ b/deps/db/script/dump_datoms.cljs @@ -0,0 +1,25 @@ + (ns dump-datoms + "A script that dumps all eavt datoms to a specified edn file + + $ pnpm exec nbb-logseq script/dump_datoms.cljs db-name datoms.edn" + (:require ["fs" :as fs] + ["path" :as node-path] + [clojure.pprint :as pprint] + [datascript.core :as d] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [nbb.core :as nbb])) + +(defn -main [args] + (when (< (count args) 2) + (println "Usage: $0 GRAPH FILE") + (js/process.exit 1)) + (let [[graph-name file*] args + conn (apply sqlite-cli/open-db! (sqlite-cli/->open-db-args graph-name)) + datoms (mapv #(vec %) (d/datoms @conn :eavt)) + parent-dir (or js/process.env.ORIGINAL_PWD ".") + file (node-path/join parent-dir file*)] + (println "Writing" (count datoms) "datoms to" file) + (fs/writeFileSync file (with-out-str (pprint/pprint datoms))))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/db/script/export_client_ops_ops.cljs b/deps/db/script/export_client_ops_ops.cljs new file mode 100644 index 0000000..6b65454 --- /dev/null +++ b/deps/db/script/export_client_ops_ops.cljs @@ -0,0 +1,93 @@ +(ns export-client-ops-ops + "Export client-ops tx entities to an EDN file sorted by :db-sync/created-at asc. + + Examples: + $ pnpm exec nbb-logseq script/export_client_ops_ops.cljs /path/to/client-ops.sqlite + $ pnpm exec nbb-logseq script/export_client_ops_ops.cljs /path/to/client-ops.sqlite --out client-ops-ops.edn" + (:require ["fs" :as fs] + ["path" :as node-path] + [babashka.cli :as cli] + [clojure.pprint :as pprint] + [datascript.core :as d] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [nbb.core :as nbb])) + +(def spec + {:help {:alias :h + :desc "Show help"} + :out {:alias :o + :desc "Output EDN file path" + :coerce :string}}) + +(def op-keys + [:db-sync/tx-id + :db-sync/created-at + :db-sync/pending? + :db-sync/failed? + :db-sync/outliner-op + :db-sync/forward-outliner-ops + :db-sync/inverse-outliner-ops + :db-sync/inferred-outliner-ops? + :db-sync/normalized-tx-data + :db-sync/reversed-tx-data]) + +(defn usage [] + (str "Usage: pnpm exec nbb-logseq script/export_client_ops_ops.cljs [--out ]\n" + "Options:\n" + (cli/format-opts {:spec spec}))) + +(defn resolve-path + [path*] + (if (node-path/isAbsolute path*) + path* + (node-path/join (or js/process.env.ORIGINAL_PWD ".") path*))) + +(defn default-output-path + [input-path] + (let [resolved (resolve-path input-path) + dir (node-path/dirname resolved) + base (node-path/basename resolved) + name (node-path/parse base) + stem (or (.-name name) base)] + (node-path/join dir (str stem "-ops.edn")))) + +(defn entity->op + [db eid] + (let [ent (d/entity db eid)] + (merge + {:db/id (:db/id ent)} + (select-keys (into {} ent) op-keys)))) + +(defn read-ops + [conn] + (let [db @conn] + (->> (d/datoms db :avet :db-sync/created-at) + (map (fn [datom] (entity->op db (:e datom)))) + (sort-by (juxt :db-sync/created-at :db/id)) + vec))) + +(defn -main + [argv] + (let [{:keys [opts args]} (cli/parse-args argv {:spec spec}) + [input-path] args] + (when (or (:help opts) (nil? input-path)) + (println (usage)) + (js/process.exit (if (:help opts) 0 1))) + (let [db-path (resolve-path input-path) + _ (when-not (fs/existsSync db-path) + (println "Client ops db file does not exist:" db-path) + (js/process.exit 1)) + out-path (resolve-path (or (:out opts) (default-output-path db-path))) + open-args (sqlite-cli/->open-db-args db-path) + {:keys [conn sqlite]} (apply sqlite-cli/open-sqlite-datascript! open-args)] + (try + (let [ops (read-ops conn)] + (println "Read" (count ops) "ops from" db-path) + (println "Writing ops EDN to" out-path) + (fs/writeFileSync out-path (with-out-str (pprint/pprint ops)))) + (finally + (when sqlite + (.close sqlite))))))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/db/script/query.cljs b/deps/db/script/query.cljs new file mode 100644 index 0000000..302fc6b --- /dev/null +++ b/deps/db/script/query.cljs @@ -0,0 +1,70 @@ +(ns query + "A script that queries any db graph from the commandline e.g. + + $ pnpm exec nbb-logseq script/query.cljs db-name '[:find (pull ?b [:block/name :block/title]) :where [?b :block/created-at]]'" + (:require ["child_process" :as child-process] + [babashka.cli :as cli] + [clojure.edn :as edn] + [clojure.pprint :as pprint] + [datascript.core :as d] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [logseq.db.frontend.rules :as rules] + [nbb.core :as nbb])) + +(defn- sh + "Run shell cmd synchronously and print to inherited streams by default. Aims + to be similar to babashka.tasks/shell" + [cmd opts] + (child-process/spawnSync (first cmd) + (clj->js (rest cmd)) + (clj->js (merge {:stdio "inherit"} opts)))) + +(def spec + "Options spec" + {:help {:alias :h + :desc "Print help"} + :verbose {:alias :v + :desc "Print more info"} + :raw {:alias :r + :desc "Print results plainly. Useful when piped to bb"} + :additional-graphs {:alias :a + :coerce [] + :desc "Additional graphs to query"} + :entity {:alias :e + :coerce [] + :desc "Lookup entities instead of query"}}) + +(defn query-graph [graph args'' options] + (let [conn (apply sqlite-cli/open-db! (sqlite-cli/->open-db-args graph)) + results (if (:entity options) + (map #(when-let [ent (d/entity @conn + (if (string? %) (edn/read-string %) %))] + (into {:db/id (:db/id ent)} ent)) + (:entity options)) + ;; assumes no :in are in queries + (let [query (into (edn/read-string (first args'')) [:in '$ '%]) + res (d/q query @conn (rules/extract-rules rules/db-query-dsl-rules))] + ;; Remove nesting for most queries which just have one :find binding + (if (= 1 (count (first res))) (mapv first res) res)))] + (when (:verbose options) (println "DB contains" (count (d/datoms @conn :eavt)) "datoms")) + (if (:raw options) + (prn results) + (if (zero? (.-status (child-process/spawnSync "which" #js ["puget"]))) + (sh ["puget"] {:input (pr-str results) :stdio ["pipe" "inherit" "inherit"]}) + (pprint/pprint results))))) + +(defn -main [args] + (let [{options :opts args' :args} (cli/parse-args args {:spec spec}) + [graph-dir & args''] args' + _ (when (or (nil? graph-dir) (:help options)) + (println (str "Usage: $0 GRAPH-NAME [& ARGS] [OPTIONS]\nOptions:\n" + (cli/format-opts {:spec spec}))) + (js/process.exit 1)) + graph-dirs (cond-> [graph-dir] + (:additional-graphs options) + (into (:additional-graphs options)))] + (doseq [graph-dir graph-dirs] + (query-graph graph-dir args'' options)))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/db/script/replay_sync_artifact.cljs b/deps/db/script/replay_sync_artifact.cljs new file mode 100644 index 0000000..f433ba9 --- /dev/null +++ b/deps/db/script/replay_sync_artifact.cljs @@ -0,0 +1,208 @@ +(ns replay-sync-artifact + "Replay sync simulation artifacts with DataScript + checksum logic in nbb." + (:require ["node:fs" :as fs] + ["node:path" :as node-path] + [babashka.cli :as cli] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db-sync.checksum :as sync-checksum] + [logseq.db.frontend.schema :as db-schema] + [nbb.core :as nbb])) + +(def cli-spec + {:help {:alias :h + :desc "Show help"} + :artifact {:alias :a + :desc "Path to artifact.json" + :coerce :string} + :client {:alias :c + :desc "Only replay one client instance index" + :coerce :long} + :round {:alias :r + :desc "Only replay one round index" + :coerce :long} + :pretty {:desc "Pretty-print JSON output"}}) + +(def checksum-attr-map + {"uuid" :block/uuid + "title" :block/title + "name" :block/name + "parent" :block/parent + "page" :block/page + "order" :block/order + ":block/uuid" :block/uuid + ":block/title" :block/title + ":block/name" :block/name + ":block/parent" :block/parent + ":block/page" :block/page + ":block/order" :block/order + "block/uuid" :block/uuid + "block/title" :block/title + "block/name" :block/name + "block/parent" :block/parent + "block/page" :block/page + "block/order" :block/order}) + +(def checksum-attrs (set (vals checksum-attr-map))) + +(defn usage [] + (str "Usage: pnpm exec nbb-logseq -cp src:script:../db-sync/src script/replay_sync_artifact.cljs --artifact [--client ] [--round ] [--pretty]\n" + "Options:\n" + (cli/format-opts {:spec cli-spec}))) + +(defn parse-uuid + [value] + (when (string? value) + (try + (uuid value) + (catch :default _ + nil)))) + +(defn parse-int + [value] + (when-not (nil? value) + (let [n (js/Number value)] + (when (js/Number.isInteger n) + (int n))))) + +(defn parse-attr + [value] + (cond + (keyword? value) + value + + (string? value) + (let [trimmed (string/trim value)] + (or (get checksum-attr-map trimmed) + (get checksum-attr-map (string/lower-case trimmed)) + (when (string/starts-with? trimmed ":") + (get checksum-attr-map (subs trimmed 1))))) + + :else + nil)) + +(defn snapshot-block->entity + [block] + (let [id (parse-int (get block "id")) + uuid-value (parse-uuid (get block "uuid"))] + (when (and id uuid-value) + (cond-> {:db/id id + :block/uuid uuid-value} + (string? (get block "title")) + (assoc :block/title (get block "title")) + + (string? (get block "name")) + (assoc :block/name (get block "name")) + + (string? (get block "order")) + (assoc :block/order (get block "order")) + + (parse-int (get block "parentId")) + (assoc :block/parent (parse-int (get block "parentId"))) + + (parse-int (get block "pageId")) + (assoc :block/page (parse-int (get block "pageId"))))))) + +(defn datom->tx + [datom] + (let [e (parse-int (get datom "e")) + attr (parse-attr (get datom "a")) + added? (not= false (get datom "added")) + value (get datom "v")] + (when (and e (contains? checksum-attrs attr)) + (let [value' (case attr + :block/uuid (or (parse-uuid value) value) + :block/parent (or (parse-int value) value) + :block/page (or (parse-int value) value) + value)] + [(if added? :db/add :db/retract) e attr value'])))) + +(defn replay-round + [client round] + (let [initial-db (get round "initialDb") + blocks (if (map? initial-db) (get initial-db "blocks") nil) + tx-log (-> (get round "txCapture") (get "txLog")) + initial-entities (if (sequential? blocks) + (keep snapshot-block->entity blocks) + []) + db0 (d/db-with (d/empty-db db-schema/schema) initial-entities) + checksum0 (sync-checksum/recompute-checksum db0) + replay-result + (reduce + (fn [{:keys [db checksum mismatches tx-count] :as acc} tx-entry] + (let [datoms (if (map? tx-entry) (get tx-entry "datoms") nil) + tx-data (if (sequential? datoms) (keep datom->tx datoms) []) + op-index (when (map? tx-entry) (parse-int (get tx-entry "opIndex")))] + (if (empty? tx-data) + (assoc acc :tx-count (inc tx-count)) + (let [report (d/with db tx-data) + db' (:db-after report) + incremental (sync-checksum/update-checksum checksum report) + full (sync-checksum/recompute-checksum db')] + {:db db' + :checksum incremental + :tx-count (inc tx-count) + :mismatches + (cond-> mismatches + (not= incremental full) + (conj {:txIndex tx-count + :opIndex op-index + :incremental incremental + :full full + :txSize (count tx-data)}))})))) + {:db db0 + :checksum checksum0 + :mismatches [] + :tx-count 0} + (if (sequential? tx-log) tx-log []))] + {:instanceIndex (parse-int (get client "instanceIndex")) + :round (parse-int (get round "round")) + :initialBlockCount (count initial-entities) + :txCount (:tx-count replay-result) + :mismatchCount (count (:mismatches replay-result)) + :firstMismatch (first (:mismatches replay-result)) + :finalChecksum (:checksum replay-result)})) + +(defn select-rounds + [client opts] + (let [rounds (if (sequential? (get client "rounds")) (get client "rounds") [])] + (if-let [round-index (:round opts)] + (filter #(= round-index (parse-int (get % "round"))) rounds) + rounds))) + +(defn select-clients + [artifact opts] + (let [clients (if (sequential? (get artifact "clients")) (get artifact "clients") [])] + (if-let [instance-index (:client opts)] + (filter #(= instance-index (parse-int (get % "instanceIndex"))) clients) + clients))) + +(defn -main + [argv] + (let [{:keys [opts]} (cli/parse-args argv {:spec cli-spec}) + artifact-path (:artifact opts)] + (when (or (:help opts) (string/blank? artifact-path)) + (println (usage)) + (js/process.exit (if (:help opts) 0 1))) + (let [resolved-path (.resolve node-path artifact-path) + content (.readFileSync fs resolved-path "utf8") + artifact (js->clj (js/JSON.parse content)) + selected-clients (vec (select-clients artifact opts)) + results (->> selected-clients + (mapcat (fn [client] + (map #(replay-round client %) + (select-rounds client opts)))) + (vec)) + payload {:artifactPath resolved-path + :runId (get artifact "runId") + :createdAt (get artifact "createdAt") + :selectedClient (or (:client opts) nil) + :selectedRound (or (:round opts) nil) + :resultCount (count results) + :results results}] + (if (:pretty opts) + (println (js/JSON.stringify (clj->js payload) nil 2)) + (println (js/JSON.stringify (clj->js payload))))))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/db/script/replay_sync_sqlite.cljs b/deps/db/script/replay_sync_sqlite.cljs new file mode 100644 index 0000000..0901eae --- /dev/null +++ b/deps/db/script/replay_sync_sqlite.cljs @@ -0,0 +1,1032 @@ +(ns replay-sync-sqlite + "Replay db-sync rebase/apply flow directly from sqlite artifacts. + + It replays: + 1) server tx_log baseline up to before remote window, + 2) local client ops as applied local changes, + 3) reverse local ops, + 4) apply remote txs, + 5) reapply local ops. + + Supports `legacy` vs `fixed` reapply fallback logic to verify behavior." + (:require ["better-sqlite3" :as sqlite3] + ["fs" :as fs] + ["path" :as node-path] + [babashka.cli :as cli] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.db.frontend.schema :as db-schema] + [logseq.outliner.core :as outliner-core] + [logseq.outliner.op :as outliner-op] + [logseq.outliner.page :as outliner-page] + [logseq.outliner.property :as outliner-property] + [logseq.outliner.recycle :as outliner-recycle] + [nbb.core :as nbb])) + +(def sqlite (if (find-ns 'nbb.core) (aget sqlite3 "default") sqlite3)) + +(def cli-spec + {:help {:alias :h + :desc "Show help"} + :server-db {:alias :s + :desc "Path to server graph db.sqlite containing tx_log" + :coerce :string} + :client-ops-db {:alias :c + :desc "Path to client ops sqlite" + :coerce :string} + :from-t {:alias :f + :desc "Replay remote txs from this t (inclusive). If omitted, use --auto-from-conflict" + :coerce :long} + :to-t {:alias :t + :desc "Replay remote txs to this t (inclusive). Default: all after from-t" + :coerce :long} + :remote-limit {:alias :l + :desc "Cap remote tx count after from-t" + :coerce :long} + :mode {:alias :m + :desc "fixed | legacy | both (default both)" + :coerce :string} + :pending-only {:desc "Only include rows where :db-sync/pending? true"} + :local-created-at-min {:desc "Filter local ops by created-at >= value" + :coerce :long} + :local-created-at-max {:desc "Filter local ops by created-at <= value" + :coerce :long} + :local-tx-id {:desc "Repeatable tx-id UUID filter" + :coerce []} + :auto-from-conflict {:desc "Infer from-t from first remote tx referencing uuids deleted by selected local ops"} + :inspect-only {:desc "Only print inferred conflict info without replay"} + :pretty {:desc "Pretty-print JSON output"}}) + +(def local-op-keys + [:db/id + :db-sync/tx-id + :db-sync/created-at + :db-sync/pending? + :db-sync/failed? + :db-sync/outliner-op + :db-sync/forward-outliner-ops + :db-sync/inverse-outliner-ops + :db-sync/inferred-outliner-ops? + :db-sync/normalized-tx-data + :db-sync/reversed-tx-data]) + +(defn usage [] + (str "Usage:\n" + " pnpm exec nbb-logseq -cp src:../outliner/src:../common/src:../graph-parser/src script/replay_sync_sqlite.cljs \\\n" + " --server-db --client-ops-db [--from-t ] [--to-t ] [--mode both] [--pretty]\n" + "\n" + "Examples:\n" + " pnpm exec nbb-logseq -cp src:../outliner/src:../common/src:../graph-parser/src script/replay_sync_sqlite.cljs \\\n" + " --server-db /path/server/db.sqlite --client-ops-db /path/electron_client_ops.sqlite \\\n" + " --auto-from-conflict --local-created-at-max 1775717890000 --mode both --pretty\n" + "\n" + "Options:\n" + (cli/format-opts {:spec cli-spec}))) + +(defn resolve-path + [path*] + (if (node-path/isAbsolute path*) + path* + (node-path/join (or js/process.env.ORIGINAL_PWD ".") path*))) + +(defn parse-uuid + [value] + (cond + (uuid? value) + value + + (string? value) + (try + (uuid value) + (catch :default _ + nil)) + + :else + nil)) + +(defn uuid-ref + [value] + (cond + (uuid? value) + value + + (string? value) + (parse-uuid value) + + (and (vector? value) + (= :block/uuid (first value))) + (parse-uuid (second value)) + + (map? value) + (some-> (:block/uuid value) parse-uuid) + + :else + nil)) + +(defn collect-uuids + [value] + (cond + (nil? value) + [] + + (uuid? value) + [value] + + (string? value) + (if-let [u (parse-uuid value)] [u] []) + + (vector? value) + (if (and (= :block/uuid (first value)) + (some? (second value))) + (if-let [u (parse-uuid (second value))] + [u] + []) + (mapcat collect-uuids value)) + + (set? value) + (mapcat collect-uuids value) + + (sequential? value) + (mapcat collect-uuids value) + + (map? value) + (mapcat collect-uuids (vals value)) + + :else + [])) + +(defn read-server-tx-log + [server-db-path] + (let [db (new sqlite server-db-path nil)] + (try + (->> (.all (.prepare db "select t, tx, outliner_op from tx_log order by t asc")) + (mapv (fn [row] + (let [t (aget row "t") + tx-str (aget row "tx") + outliner-op (aget row "outliner_op")] + {:t t + :outliner-op (when (string? outliner-op) + (keyword outliner-op)) + :tx-data (ldb/read-transit-str tx-str)})))) + (finally + (.close db))))) + +(defn entity->local-op + [db eid] + (let [ent (d/entity db eid) + m (into {} ent)] + {:db/id (:db/id ent) + :tx-id (:db-sync/tx-id ent) + :created-at (:db-sync/created-at ent) + :pending? (:db-sync/pending? ent) + :failed? (:db-sync/failed? ent) + :outliner-op (:db-sync/outliner-op ent) + :forward-outliner-ops (:db-sync/forward-outliner-ops ent) + :inverse-outliner-ops (:db-sync/inverse-outliner-ops ent) + :inferred-outliner-ops? (:db-sync/inferred-outliner-ops? ent) + :tx (:db-sync/normalized-tx-data ent) + :reversed-tx (:db-sync/reversed-tx-data ent) + :raw (select-keys m local-op-keys)})) + +(defn read-client-ops + [client-ops-db-path] + (let [{:keys [conn sqlite]} (sqlite-cli/open-sqlite-datascript! client-ops-db-path)] + (try + (let [db @conn] + (->> (d/q '[:find ?e ?created-at + :where + [?e :db-sync/tx-id _] + [?e :db-sync/created-at ?created-at]] + db) + (sort-by (fn [[e created-at]] [created-at e])) + (mapv (fn [[e _]] + (entity->local-op db e))))) + (finally + (when sqlite + (.close sqlite)))))) + +(defn parse-tx-id-set + [tx-id-values] + (let [values (if (sequential? tx-id-values) tx-id-values [])] + (->> values + (mapcat (fn [v] + (if (string? v) + (remove string/blank? (string/split v #",")) + []))) + (map parse-uuid) + (remove nil?) + set))) + +(defn filter-local-ops + [ops {:keys [pending-only local-created-at-min local-created-at-max local-tx-id]}] + (let [tx-id-set (parse-tx-id-set local-tx-id)] + (->> ops + (filter (fn [op] + (and + (if pending-only + (true? (:pending? op)) + true) + (if (some? local-created-at-min) + (>= (or (:created-at op) -1) local-created-at-min) + true) + (if (some? local-created-at-max) + (<= (or (:created-at op) js/Number.MAX_SAFE_INTEGER) local-created-at-max) + true) + (if (seq tx-id-set) + (contains? tx-id-set (:tx-id op)) + true)))) + vec))) + +(defn delete-op-uuids + [local-ops] + (->> local-ops + (mapcat :forward-outliner-ops) + (filter (fn [op] (= :delete-blocks (first op)))) + (mapcat (fn [[_ args]] + (let [[ids] args] + (if (sequential? ids) ids [])))) + (map uuid-ref) + (remove nil?) + set)) + +(defn row-referenced-uuids + [row] + (->> (:tx-data row) + (mapcat collect-uuids) + set)) + +(defn infer-conflicts + [server-rows local-ops] + (let [deleted-uuids (delete-op-uuids local-ops) + conflicts (when (seq deleted-uuids) + (->> server-rows + (keep (fn [row] + (let [refs (row-referenced-uuids row) + hit (seq (clojure.set/intersection deleted-uuids refs))] + (when hit + {:t (:t row) + :outliner-op (:outliner-op row) + :hits (vec (take 10 hit))})))) + vec))] + {:deleted-uuids (vec deleted-uuids) + :conflicts conflicts + :first-conflict-t (some-> conflicts first :t)})) + +(defn select-remote-rows + [server-rows from-t to-t remote-limit] + (let [rows (->> server-rows + (filter (fn [{:keys [t]}] + (and (if (some? from-t) (>= t from-t) true) + (if (some? to-t) (<= t to-t) true)))) + vec)] + (if (some? remote-limit) + (vec (take remote-limit rows)) + rows))) + +(defn baseline-rows + [server-rows from-t] + (->> server-rows + (filter (fn [{:keys [t]}] + (if (some? from-t) (< t from-t) true))) + vec)) + +(defn usable-history-ops + [ops] + ops) + +(defn entity-id->block-uuid + [db id] + (or (uuid-ref id) + (some-> (d/entity db id) :block/uuid))) + +(defn delete-block-root-uuids + [db forward-outliner-ops] + (->> (usable-history-ops forward-outliner-ops) + (mapcat (fn [[op args]] + (if (= :delete-blocks op) + (let [[ids] args] + (if (sequential? ids) + (keep #(entity-id->block-uuid db %) ids) + [])) + []))) + distinct + vec)) + +(defn raw-restored-block-uuids + [raw-tx-data] + (->> raw-tx-data + (keep (fn [datom] + (when (and (vector? datom) + (>= (count datom) 4) + (= :db/add (nth datom 0)) + (= :block/uuid (nth datom 2))) + (uuid-ref (nth datom 3))))) + distinct + vec)) + +(defn assert-delete-block-roots-restored! + [conn local-tx] + (let [root-uuids (delete-block-root-uuids @conn (:forward-outliner-ops local-tx))] + (when (seq root-uuids) + (let [missing-root-uuids (->> root-uuids + (remove #(d/entity @conn [:block/uuid %])) + vec)] + (when (seq missing-root-uuids) + (throw (ex-info "incomplete delete-blocks reverse" + {:error :db-sync/incomplete-delete-blocks-reverse + :tx-id (:tx-id local-tx) + :outliner-op (:outliner-op local-tx) + :root-uuids root-uuids + :missing-root-uuids missing-root-uuids}))))))) + +(defn assert-raw-restored-block-uuids! + [conn local-tx raw-tx-data] + (let [restored-uuids (raw-restored-block-uuids raw-tx-data)] + (when (seq restored-uuids) + (let [missing-restored-uuids (->> restored-uuids + (remove #(d/entity @conn [:block/uuid %])) + vec)] + (when (seq missing-restored-uuids) + (throw (ex-info "incomplete raw restored uuids" + {:error :db-sync/incomplete-raw-restored-uuids + :tx-id (:tx-id local-tx) + :outliner-op (:outliner-op local-tx) + :restored-uuids restored-uuids + :missing-restored-uuids missing-restored-uuids}))))))) + +(defn invalid-rebase-op! + [op data] + (throw (ex-info "invalid rebase op" (assoc data :op op)))) + +(defn replay-entity-id-value + [db v] + (cond + (number? v) + v + + (uuid? v) + (some-> (d/entity db [:block/uuid v]) :db/id) + + (or (vector? v) (qualified-keyword? v)) + (some-> (d/entity db v) :db/id) + + :else + v)) + +(defn stable-entity-ref-like? + [v] + (or (qualified-keyword? v) + (and (vector? v) + (or (= :block/uuid (first v)) + (= :db/ident (first v)))))) + +(defn replay-property-value + [db property-id v] + (let [property-type (some-> (d/entity db property-id) :logseq.property/type)] + (if (contains? db-property-type/all-ref-property-types property-type) + (cond + (stable-entity-ref-like? v) + (replay-entity-id-value db v) + + (set? v) + (->> v + (map #(if (stable-entity-ref-like? %) + (replay-entity-id-value db %) + %)) + set) + + (sequential? v) + (mapv #(if (stable-entity-ref-like? %) + (replay-entity-id-value db %) + %) + v) + + :else + v) + v))) + +(defn replay-entity-id-coll + [db ids] + (mapv #(or (replay-entity-id-value db %) %) ids)) + +(defn rebase-find-existing-left-sibling + [current-db target] + (loop [sibling (ldb/get-left-sibling target)] + (if (nil? sibling) + nil + (if-let [current-sibling (and sibling (d/entity current-db [:block/uuid (:block/uuid sibling)]))] + current-sibling + (recur (ldb/get-left-sibling sibling)))))) + +(defn rebase-resolve-target-and-sibling + [current-db rebase-db-before target-id sibling?] + (let [target (d/entity current-db target-id) + target-before (when rebase-db-before + (d/entity rebase-db-before target-id)) + parent-before (when rebase-db-before + (:block/parent (d/entity rebase-db-before target-id)))] + (cond + target + [target sibling?] + + (and target-before parent-before sibling?) + (if-let [left-sibling (rebase-find-existing-left-sibling current-db target-before)] + [left-sibling true] + (when-let [parent (d/entity current-db [:block/uuid (:block/uuid parent-before)])] + [parent false])) + + :else + nil))) + +(defn template-parent-ref + [parent] + (cond + (and (vector? parent) (= :block/uuid (first parent))) + parent + + (uuid? parent) + [:block/uuid parent] + + (and (map? parent) (uuid? (:block/uuid parent))) + [:block/uuid (:block/uuid parent)] + + :else + parent)) + +(defn sanitize-template-block + [current-db rebase-db-before block] + (let [m (into {} block) + block-id (:db/id m) + block-uuid (or (:block/uuid m) + (when (number? block-id) + (or (some-> rebase-db-before (d/entity block-id) :block/uuid) + (some-> (d/entity current-db block-id) :block/uuid))) + (when (and (vector? block-id) + (= :block/uuid (first block-id)) + (uuid? (second block-id))) + (second block-id)))] + (cond-> (-> m + (dissoc :db/id :block/order :block/page :block/tx-id) + (update :block/parent template-parent-ref)) + (uuid? block-uuid) + (assoc :block/uuid block-uuid)))) + +(defn replay-canonical-outliner-op! + [conn [op args] rebase-db-before] + (case op + :save-block + (let [[block opts] args + db @conn + block-uuid (:block/uuid block) + block-ent (when block-uuid + (d/entity db [:block/uuid block-uuid])) + block-base (dissoc block :db/id :block/order) + block' block-base] + (when (nil? block-ent) + (invalid-rebase-op! op {:args args + :reason :missing-block})) + (outliner-core/save-block! conn block' opts)) + + :insert-blocks + (let [[blocks target-id opts] args + db @conn + [target sibling?] (rebase-resolve-target-and-sibling db rebase-db-before target-id (:sibling? opts))] + (when-not (and target (seq blocks)) + (invalid-rebase-op! op {:args args})) + (outliner-core/insert-blocks! conn blocks target (assoc opts :sibling? sibling?))) + + :apply-template + (let [[template-id target-id opts] args + template-id' (replay-entity-id-value @conn template-id) + target-id' (replay-entity-id-value @conn target-id) + [target sibling?] (rebase-resolve-target-and-sibling @conn rebase-db-before target-id' (:sibling? opts))] + (when-not (and template-id' (d/entity @conn template-id') target) + (invalid-rebase-op! op {:args args + :reason :missing-template-or-target-block})) + (let [template-uuid (:block/uuid (d/entity @conn template-id')) + target-uuid (:block/uuid target)] + (when-not (and (uuid? template-uuid) (uuid? target-uuid)) + (invalid-rebase-op! op {:args args + :reason :missing-template-or-target-uuid})) + (let [template-blocks' (some->> (:template-blocks opts) + (mapv #(sanitize-template-block @conn rebase-db-before %)) + (filter :block/uuid) + seq) + opts' (cond-> (-> opts + (assoc :sibling? sibling?) + (dissoc :template-blocks)) + template-blocks' + (assoc :template-blocks template-blocks'))] + (outliner-op/apply-ops! + conn + [[:apply-template [template-uuid + target-uuid + opts']]] + {:gen-undo-ops? false})))) + + :move-blocks + (let [[ids target-id opts] args + ids' (replay-entity-id-coll @conn ids) + target-id' (replay-entity-id-value @conn target-id) + blocks (keep #(d/entity @conn %) ids') + [target sibling?] (rebase-resolve-target-and-sibling @conn rebase-db-before target-id' (:sibling? opts))] + (when (or (empty? blocks) (nil? target)) + (invalid-rebase-op! op {:args args})) + (when (seq blocks) + (outliner-core/move-blocks! conn blocks target (assoc opts :sibling? sibling?)))) + + :move-blocks-up-down + (let [[ids up?] args + ids' (replay-entity-id-coll @conn ids) + blocks (keep #(d/entity @conn %) ids')] + (when (seq blocks) + (outliner-core/move-blocks-up-down! conn blocks up?))) + + :indent-outdent-blocks + (let [[ids indent? opts] args + ids' (replay-entity-id-coll @conn ids) + blocks (keep #(d/entity @conn %) ids')] + (when (empty? blocks) + (invalid-rebase-op! op {:args args})) + (when (seq blocks) + (outliner-core/indent-outdent-blocks! conn blocks indent? opts))) + + :delete-blocks + (let [[ids opts] args + ids' (replay-entity-id-coll @conn ids) + blocks (keep #(d/entity @conn %) ids')] + (when (seq blocks) + (outliner-core/delete-blocks! conn blocks opts))) + + :create-page + (let [[title opts] args] + (outliner-page/create! conn title opts)) + + :delete-page + (let [[page-uuid opts] args] + (outliner-page/delete! conn page-uuid opts)) + + :restore-recycled + (let [[root-id] args + root-ref (cond + (and (vector? root-id) + (= :block/uuid (first root-id))) + root-id + + (uuid? root-id) + [:block/uuid root-id] + + :else + root-id) + root (d/entity @conn root-ref) + tx-data (when root + (seq (outliner-recycle/restore-tx-data @conn root)))] + (when-not tx-data + (invalid-rebase-op! op {:args args + :reason :invalid-restore-target})) + (ldb/transact! conn tx-data + {:outliner-op :restore-recycled})) + + :recycle-delete-permanently + (let [[root-id] args + root-ref (cond + (and (vector? root-id) + (= :block/uuid (first root-id))) + root-id + + (uuid? root-id) + [:block/uuid root-id] + + :else + root-id) + root (d/entity @conn root-ref) + tx-data (when root + (seq (outliner-recycle/permanently-delete-tx-data @conn root)))] + (when (seq tx-data) + (ldb/transact! conn tx-data + {:outliner-op :recycle-delete-permanently}))) + + :set-block-property + (let [[block-eid property-id v] args + block-eid' (or (replay-entity-id-value @conn block-eid) + block-eid) + block (d/entity @conn block-eid') + property (d/entity @conn property-id) + _ (when-not (and block property) + (invalid-rebase-op! op {:args args + :reason :missing-block-or-property})) + v' (replay-property-value @conn property-id v)] + (when (and (stable-entity-ref-like? v) (nil? v')) + (invalid-rebase-op! op {:args args})) + (outliner-property/set-block-property! conn block-eid' property-id v')) + + :remove-block-property + (apply outliner-property/remove-block-property! conn args) + + :batch-set-property + (let [[block-ids property-id v opts] args + block-ids' (replay-entity-id-coll @conn block-ids) + property (d/entity @conn property-id) + _ (when-not (and property + (seq block-ids') + (every? #(some? (d/entity @conn %)) block-ids')) + (invalid-rebase-op! op {:args args + :reason :missing-block-or-property})) + v' (replay-property-value @conn property-id v)] + (when (and (stable-entity-ref-like? v) (nil? v')) + (invalid-rebase-op! op {:args args})) + (outliner-property/batch-set-property! conn block-ids' property-id v' opts)) + + :batch-remove-property + (let [[block-ids property-id] args + block-ids' (replay-entity-id-coll @conn block-ids)] + (outliner-property/batch-remove-property! conn block-ids' property-id)) + + :delete-property-value + (let [[block-eid property-id property-value] args + block (d/entity @conn block-eid) + property (d/entity @conn property-id) + _ (when-not (and block property) + (invalid-rebase-op! op {:args args + :reason :missing-block-or-property})) + property-value' (replay-property-value @conn property-id property-value)] + (when (and (stable-entity-ref-like? property-value) (nil? property-value')) + (invalid-rebase-op! op {:args args})) + (outliner-property/delete-property-value! conn block-eid property-id property-value')) + + :batch-delete-property-value + (let [[block-eids property-id property-value] args + block-eids' (replay-entity-id-coll @conn block-eids) + property (d/entity @conn property-id) + _ (when-not (and property + (seq block-eids') + (every? #(some? (d/entity @conn %)) block-eids')) + (invalid-rebase-op! op {:args args + :reason :missing-block-or-property})) + property-value' (replay-property-value @conn property-id property-value)] + (when (and (stable-entity-ref-like? property-value) (nil? property-value')) + (invalid-rebase-op! op {:args args})) + (outliner-property/batch-delete-property-value! conn block-eids' property-id property-value')) + + :create-property-text-block + (apply outliner-property/create-property-text-block! conn args) + + :upsert-property + (apply outliner-property/upsert-property! conn args) + + :class-add-property + (apply outliner-property/class-add-property! conn args) + + :class-remove-property + (apply outliner-property/class-remove-property! conn args) + + :upsert-closed-value + (apply outliner-property/upsert-closed-value! conn args) + + :add-existing-values-to-closed-values + (apply outliner-property/add-existing-values-to-closed-values! conn args) + + :delete-closed-value + (apply outliner-property/delete-closed-value! conn args) + + (let [tx-data (:tx args)] + (when-let [tx-data (seq tx-data)] + (ldb/transact! conn tx-data {:outliner-op :transact}))))) + +(defn replace-uuid-str-with-eid + [db v] + (if-let [u (and (string? v) (parse-uuid v))] + (if-let [entity (d/entity db [:block/uuid u])] + (:db/id entity) + v) + v)) + +(defn ref-attr? + [db attr] + (= :db.type/ref + (or (get-in (d/schema db) [attr :db/valueType]) + (:db/valueType (d/entity db attr))))) + +(defn resolve-temp-id + [db datom-v] + (if (and (vector? datom-v) + (= (count datom-v) 5) + (= (first datom-v) :db/add)) + (let [[op e a v t] datom-v + e' (replace-uuid-str-with-eid db e) + v' (if (ref-attr? db a) + (replace-uuid-str-with-eid db v) + v)] + [op e' a v' t]) + datom-v)) + +(defn reverse-local-tx! + [conn local-tx] + (let [preserve-during-rebase? (or (= :create-page (:outliner-op local-tx)) + (and (= 1 (count (:forward-outliner-ops local-tx))) + (= :create-page (ffirst (:forward-outliner-ops local-tx))))) + inferred-history? (true? (:inferred-outliner-ops? local-tx)) + inverse-ops (usable-history-ops (:inverse-outliner-ops local-tx)) + raw-tx-data (seq (:reversed-tx local-tx))] + (cond + preserve-during-rebase? + {:tx-id (:tx-id local-tx) + :status :preserved} + + (and inferred-history? raw-tx-data) + (try + (ldb/transact! conn raw-tx-data + {:outliner-op (:outliner-op local-tx) + :reverse? true}) + (catch :default error + (if (seq inverse-ops) + (do + (doseq [op inverse-ops] + (replay-canonical-outliner-op! conn op nil)) + (assert-delete-block-roots-restored! conn local-tx) + (assert-raw-restored-block-uuids! conn local-tx raw-tx-data)) + (throw error)))) + + (seq inverse-ops) + (try + (doseq [op inverse-ops] + (replay-canonical-outliner-op! conn op nil)) + (assert-delete-block-roots-restored! conn local-tx) + (assert-raw-restored-block-uuids! conn local-tx raw-tx-data) + (catch :default error + (if raw-tx-data + (ldb/transact! conn raw-tx-data + {:outliner-op (:outliner-op local-tx) + :reverse? true}) + (throw error)))) + + raw-tx-data + (ldb/transact! conn raw-tx-data + {:outliner-op (:outliner-op local-tx) + :reverse? true}) + + :else + (invalid-rebase-op! :reverse-history-action + {:reason :missing-reversed-tx-data + :tx-id (:tx-id local-tx) + :outliner-op (:outliner-op local-tx)})))) + +(defn rebase-history-ops + [_mode local-tx] + (let [forward-outliner-ops (seq (:forward-outliner-ops local-tx)) + inverse-outliner-ops (seq (:inverse-outliner-ops local-tx)) + forward-ops forward-outliner-ops + inverse-ops inverse-outliner-ops] + {:forward-ops forward-ops + :inverse-ops inverse-ops + :fallback? false})) + +(defn transact-remote-txs! + [conn remote-rows] + (loop [remaining remote-rows + idx 0] + (let [db @conn] + (if-let [row (first remaining)] + (let [tx-data (->> (:tx-data row) + (map (partial resolve-temp-id db)) + seq) + pre-missing-entity-id (when-let [entity-id (some-> tx-data first second)] + (when (and (vector? entity-id) + (every? (fn [datom] + (= entity-id (second datom))) + tx-data) + (nil? (d/entity db entity-id))) + entity-id))] + (if pre-missing-entity-id + nil + (try + (when tx-data + (ldb/transact! conn tx-data {:transact-remote? true})) + (catch :default e + (let [error-data (or (ex-data e) {}) + missing-entity-id (:entity-id error-data) + missing-entity-only-tx? (and (= :entity-id/missing (:error error-data)) + (vector? missing-entity-id) + (seq tx-data) + (every? (fn [datom] + (= missing-entity-id (second datom))) + tx-data))] + (when-not missing-entity-only-tx? + (throw (ex-info "remote transact failed" + {:stage :remote + :index idx + :t (:t row) + :outliner-op (:outliner-op row)} + e))))))) + (recur (next remaining) (inc idx))) + nil)))) + +(defn seed-local-txs! + [conn local-ops] + (doseq [local local-ops] + (let [forward-ops (usable-history-ops (:forward-outliner-ops local)) + raw-tx (seq (:tx local))] + (try + (cond + (seq forward-ops) + (doseq [op forward-ops] + (replay-canonical-outliner-op! conn op nil)) + + raw-tx + (ldb/transact! conn raw-tx {:seed-local? true + :outliner-op (:outliner-op local)}) + + :else + nil) + (catch :default e + (throw (ex-info "seed local tx failed" + {:stage :seed-local + :tx-id (:tx-id local) + :outliner-op (:outliner-op local) + :seed-source (cond + (seq forward-ops) :semantic + raw-tx :raw + :else :none)} + e))))))) + +(defn reapply-local-tx! + [mode conn local-tx rebase-db-before preserved-tx-ids] + (if (contains? preserved-tx-ids (:tx-id local-tx)) + {:tx-id (:tx-id local-tx) + :status :preserved + :fallback? false + :forward-op-count 0 + :inverse-op-count 0} + (let [{:keys [forward-ops inverse-ops fallback?]} (rebase-history-ops mode local-tx)] + (if (seq forward-ops) + (try + (doseq [op forward-ops] + (replay-canonical-outliner-op! conn op rebase-db-before)) + {:tx-id (:tx-id local-tx) + :status :rebased + :fallback? fallback? + :inverse-op-count (count inverse-ops) + :forward-op-count (count forward-ops)} + (catch :default error + {:tx-id (:tx-id local-tx) + :status :failed + :fallback? fallback? + :error (ex-message error) + :error-data (select-keys (or (ex-data error) {}) + [:op :reason :error :entity-id])})) + {:tx-id (:tx-id local-tx) + :status :skipped + :fallback? false + :forward-op-count 0 + :inverse-op-count (count inverse-ops)})))) + +(defn replay-mode! + [{:keys [mode baseline remote local]}] + (let [conn (d/create-conn db-schema/schema) + *stage (atom :baseline) + *current (atom nil)] + (try + ;; Replay can pass through transient intermediate states; disable strict + ;; validation so we can observe end-to-end conflict handling behavior. + (swap! conn assoc :skip-validate-db? true) + (doseq [row baseline] + (when-let [tx-data (seq (:tx-data row))] + (reset! *current {:t (:t row)}) + (ldb/transact! conn tx-data {:server-baseline? true :t (:t row)}))) + (reset! *stage :seed-local) + (seed-local-txs! conn local) + (let [rebase-db-before @conn] + (reset! *stage :reverse-local) + (let [reverse-results (mapv (fn [local-tx] + (reset! *current {:tx-id (:tx-id local-tx)}) + (reverse-local-tx! conn local-tx)) + (reverse local)) + preserved-tx-ids (->> reverse-results + (filter #(= :preserved (:status %))) + (keep :tx-id) + set)] + + (reset! *stage :remote) + (transact-remote-txs! conn remote) + + (reset! *stage :reapply-local) + (let [reapply-results (mapv (fn [local-tx] + (reset! *current {:tx-id (:tx-id local-tx)}) + (reapply-local-tx! mode conn local-tx rebase-db-before preserved-tx-ids)) + local)] + {:mode (name mode) + :ok? true + :baseline-count (count baseline) + :remote-count (count remote) + :local-count (count local) + :reapply-results reapply-results + :reapply-failed-count (count (filter #(= :failed (:status %)) reapply-results)) + :final-datom-count (count (d/datoms @conn :eavt))}))) + (catch :default e + {:mode (name mode) + :ok? false + :stage @*stage + :current @*current + :error (ex-message e) + :error-data (select-keys (or (ex-data e) {}) + [:stage :t :index :tx-id :op :reason :error :entity-id])})))) + +(defn mode-seq + [mode-option] + (case (some-> mode-option string/lower-case) + "legacy" [:legacy] + "fixed" [:fixed] + "both" [:legacy :fixed] + nil [:legacy :fixed] + [:legacy :fixed])) + +(defn ensure-exists! + [label path] + (when-not (.existsSync fs path) + (binding [*print-fn* *print-err-fn*] + (println (str label " does not exist: " path))) + (js/process.exit 1))) + +(defn -main + [argv] + (let [{:keys [opts]} (cli/parse-args argv {:spec cli-spec}) + {:keys [server-db client-ops-db from-t to-t remote-limit + auto-from-conflict inspect-only pretty] :as opts} opts] + (when (:help opts) + (println (usage)) + (js/process.exit 0)) + (when (or (string/blank? server-db) (string/blank? client-ops-db)) + (binding [*print-fn* *print-err-fn*] + (println "Missing required --server-db and/or --client-ops-db")) + (println (usage)) + (js/process.exit 1)) + (let [server-db' (resolve-path server-db) + client-ops-db' (resolve-path client-ops-db)] + (ensure-exists! "server-db" server-db') + (ensure-exists! "client-ops-db" client-ops-db') + (let [server-rows (read-server-tx-log server-db') + all-local-ops (read-client-ops client-ops-db') + local-ops (filter-local-ops all-local-ops opts) + conflicts (infer-conflicts server-rows local-ops) + inferred-from-t (when auto-from-conflict + (:first-conflict-t conflicts)) + effective-from-t (or from-t inferred-from-t)] + (when (and (not inspect-only) (nil? effective-from-t)) + (binding [*print-fn* *print-err-fn*] + (println "Missing --from-t and failed to infer via --auto-from-conflict")) + (js/process.exit 1)) + (if inspect-only + (let [payload {:server-db server-db' + :client-ops-db client-ops-db' + :server-tx-count (count server-rows) + :local-op-count (count local-ops) + :deleted-uuids (mapv str (:deleted-uuids conflicts)) + :first-conflict-t (:first-conflict-t conflicts) + :conflicts (mapv (fn [c] + (update c :hits #(mapv str %))) + (or (:conflicts conflicts) []))}] + (if pretty + (println (js/JSON.stringify (clj->js payload) nil 2)) + (println (js/JSON.stringify (clj->js payload))))) + (let [remote (select-remote-rows server-rows effective-from-t to-t remote-limit) + baseline (baseline-rows server-rows effective-from-t) + results (mapv (fn [mode] + (replay-mode! {:mode mode + :baseline baseline + :remote remote + :local local-ops})) + (mode-seq (:mode opts))) + payload {:server-db server-db' + :client-ops-db client-ops-db' + :input {:from-t from-t + :effective-from-t effective-from-t + :to-t to-t + :remote-limit remote-limit + :mode (:mode opts) + :pending-only (boolean (:pending-only opts)) + :local-created-at-min (:local-created-at-min opts) + :local-created-at-max (:local-created-at-max opts) + :local-tx-id (:local-tx-id opts) + :auto-from-conflict (boolean auto-from-conflict)} + :counts {:server-tx-total (count server-rows) + :baseline (count baseline) + :remote (count remote) + :local-selected (count local-ops) + :local-total (count all-local-ops)} + :conflicts {:deleted-uuids (mapv str (:deleted-uuids conflicts)) + :first-conflict-t (:first-conflict-t conflicts) + :conflict-count (count (:conflicts conflicts)) + :sample (->> (:conflicts conflicts) + (take 10) + (mapv (fn [c] + (update c :hits #(mapv str %)))))} + :results results}] + (if pretty + (println (js/JSON.stringify (clj->js payload) nil 2)) + (println (js/JSON.stringify (clj->js payload)))))))))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/db/src/logseq/db.cljs b/deps/db/src/logseq/db.cljs new file mode 100644 index 0000000..40aaec3 --- /dev/null +++ b/deps/db/src/logseq/db.cljs @@ -0,0 +1,939 @@ +(ns logseq.db + "Main namespace for db fns. All fns are only for DB graphs" + (:require [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.conn :as dc] + [datascript.core :as d] + [datascript.storage :as storage] + [datascript.impl.entity :as de] + [logseq.common.config :as common-config] + [logseq.common.plural :as common-plural] + [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] + [logseq.db.common.delete-blocks :as delete-blocks] ;; Load entity extensions + [logseq.db.common.entity-plus :as entity-plus] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.db :as db-db] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.sqlite.util :as sqlite-util] + [logseq.common.log :as log]) + (:refer-clojure :exclude [object?])) + +(def built-in? entity-util/built-in?) +(def built-in-class-property? db-db/built-in-class-property?) +(def private-built-in-page? db-db/private-built-in-page?) + +(def write-transit-str sqlite-util/write-transit-str) +(def read-transit-str sqlite-util/read-transit-str) +(def build-favorite-tx db-db/build-favorite-tx) + +(defonce *transact-fn (atom nil)) +(defonce *transact-invalid-callback (atom nil)) +(defonce *transact-pipeline-fn (atom nil)) +(defonce *debounce-fn (atom nil)) +(def ^:dynamic *batch-tx-report?* false) + +(defn register-transact-fn! + [f] + (when f (reset! *transact-fn f))) +(defn register-transact-invalid-callback-fn! + [f] + (when f (reset! *transact-invalid-callback f))) +(defn register-transact-pipeline-fn! + [f] + (when f (reset! *transact-pipeline-fn f))) +(defn register-debounce-fn! + [f] + (when f (reset! *debounce-fn f))) + +(defn- remove-temp-block-data + [tx-data] + (let [remove-block-temp-f (fn [m] + (->> (remove (fn [[k _v]] + (= "block.temp" (namespace k))) m) + (into {})))] + (keep (fn [data] + (cond + (map? data) + (cond-> + (remove-block-temp-f data) + (seq (:block/refs data)) + (update :block/refs (fn [refs] + (map (fn [ref] + (if (map? ref) (remove-block-temp-f ref) ref)) refs)))) + (and (vector? data) + (contains? #{:db/add :db/retract} (first data)) + (> (count data) 2) + (keyword? (nth data 2)) + (= "block.temp" (namespace (nth data 2)))) + nil + :else + data)) + tx-data))) + +(defn entity->db-id + [tx-data] + (walk/prewalk + (fn [f] + (if (de/entity? f) + (if-let [id (:db/id f)] + id + (throw (ex-info "ldb/transact! doesn't support Entity" + {:entity f + :tx-data tx-data}))) + f)) + tx-data)) + +(defn- throw-if-page-has-block-parent! + [db tx-data] + (when (some (fn [d] (and (:added d) + (= :block/parent (:a d)) + (let [entity (d/entity db (:e d)) + parent (:block/parent entity)] + (and (entity-util/page? entity) + (= (:v d) (:db/id parent)) + (not (entity-util/page? (d/entity db (:v d)))))))) tx-data) + (throw (ex-info "Page can't have block as parent" + {:tx-data tx-data})))) + +(comment + (defn debounced-store-db + [conn] + (when-some [_storage (storage/storage @conn)] + (when-not (:batch-tx? @conn) + (let [f (if (exists? js/process) + d/store + (or @*debounce-fn d/store))] + (f @conn)))))) + +(defn- should-run-transact-pipeline? + [conn db tx-meta] + (and (entity-plus/db-based-graph? db) + (not + (or (:rtc-download-graph? tx-meta) + (:reset-conn! tx-meta) + (:initial-db? tx-meta) + (:skip-validate-db? tx-meta false) + ;; used by `batch-transact-with-temp-conn!` + (:skip-validate-db? @conn) + (:logseq.graph-parser.exporter/new-graph? tx-meta))))) + +(defn- should-validate-pipeline-result? + [tx-meta] + (not (:fix-db? tx-meta))) + +(defn- tx-report-pipeline-data + [tx-report] + (map (fn [[e a v t]] + [e a v t]) + (:tx-data tx-report))) + +(defn- invalid-tx-debug-data + [tx-meta tx-data errors tx-report] + {:tx-meta tx-meta + :tx-data tx-data + :errors errors + :pipeline-tx-data (tx-report-pipeline-data tx-report)}) + +(defn- throw-invalid-tx! + [tx-meta tx-data errors tx-report] + ;; notify ui + (when-let [f @*transact-invalid-callback] + (f tx-report errors)) + (let [debug-data (invalid-tx-debug-data tx-meta tx-data errors tx-report)] + (prn :debug :invalid-data debug-data) + (prn :debug :errors errors) + (throw (ex-info "DB write failed with invalid data" debug-data)))) + +(defn- transact-sync + [conn tx-data tx-meta] + (try + (loop [] + (let [db @conn + run-pipeline? (should-run-transact-pipeline? conn db tx-meta)] + (if run-pipeline? + (let [tx-report* (d/with db tx-data tx-meta) + pipeline-f @*transact-pipeline-fn + tx-report (if pipeline-f (pipeline-f tx-report*) tx-report*) + _ (throw-if-page-has-block-parent! (:db-after tx-report) (:tx-data tx-report)) + [validate-result errors] (if (should-validate-pipeline-result? tx-meta) + (db-validate/validate-tx-report tx-report nil) + [true nil])] + (cond + (not validate-result) + (if (identical? db @conn) + (throw-invalid-tx! tx-meta tx-data errors tx-report) + (recur)) + + (and tx-report (seq (:tx-data tx-report))) + (if (compare-and-set! conn db (:db-after tx-report)) + (do + (dc/store-after-transact! conn tx-report) + (dc/run-callbacks conn tx-report) + tx-report) + (recur)) + + :else + tx-report)) + (d/transact! conn tx-data tx-meta)))) + (catch :default e + (when-not (or (:db-sync/suppress-transact-failed-log? tx-meta) + (and (:db-sync/suppress-stale-rebase-transact-failed-log? tx-meta) + (= :entity-id/missing (:error (ex-data e))))) + (prn :debug :transact-failed + :tx-meta tx-meta + :tx-data tx-data + :error (str e)) + (js/console.error e)) + (throw e)))) + +(defn transact! + "`repo-or-conn`: repo for UI thread and conn for worker/node" + ([repo-or-conn tx-data] + (transact! repo-or-conn tx-data nil)) + ([repo-or-conn tx-data tx-meta] + (let [tx-data (->> tx-data + entity->db-id + (map (fn [m] + (if (map? m) + (cond-> + (dissoc m :block/children :block/meta :block/top? :block/bottom? :block/anchor + :block/level :block/container :db/other-tx + :block/unordered) + (not @*transact-fn) + (dissoc :block.temp/load-status)) + m))) + (remove-temp-block-data) + (remove (fn [m] (and (map? m) (= (:db/ident m) :block/path-refs)))) + (common-util/fast-remove-nils) + (remove (fn [m] (or ;; db/id + (integer? m) + (empty? m))))) + tx-data (if-not (string? repo-or-conn) + (delete-blocks/expand-delete-blocks-tx @repo-or-conn tx-data tx-meta) + tx-data) + delete-blocks-tx (when-not (string? repo-or-conn) + (delete-blocks/update-refs-history @repo-or-conn tx-data tx-meta)) + tx-data (concat tx-data delete-blocks-tx)] + + ;; Ensure worker can handle the request sequentially (one by one) + ;; Because UI assumes that the in-memory db has all the data except the last one transaction + (when (seq tx-data) + + ;; (prn :debug :transact :sync? (= d/transact! (or @*transact-fn d/transact!)) :tx-meta tx-meta) + ;; (cljs.pprint/pprint tx-data) + ;; (js/console.trace) + + (let [tx-meta (cond-> tx-meta + *batch-tx-report?* + (assoc :batch-tx-report? true))] + (if-let [transact-fn @*transact-fn] + (transact-fn repo-or-conn tx-data tx-meta) + (transact-sync repo-or-conn tx-data tx-meta))))))) + +(defn- make-conn [db opts] + ;; `datascript.conn/->Conn` is not exposed in nbb runtime. + ;; Start from a fresh conn and merge the desired internal state. + (let [conn (d/create-conn (:schema db))] + (swap! (:atom conn) merge opts) + conn)) + +(defn- conn-from-db + "Forked conn-from-db to not invoke `d/store`, it's unsafe to store during nested batch tx." + [db] + (if-some [_storage (storage/storage db)] + (make-conn + db + {:db db + :tx-tail [] + :db-last-stored db}) + (make-conn db {:db db}))) + +(defn temp-conn-from-db + "Create an isolated in-memory conn from `db` that can read storage-backed data without storing writes." + [db] + (doto (conn-from-db db) + (swap! assoc + :skip-store? true + :skip-validate-db? true))) + +(defn batch-transact-with-temp-conn! + "Run batched tx work against a temporary conn, then apply all collected tx-data + to `conn` with a single final `transact!`. + + Semantics: + - Uses `d/conn-from-db` so batch work runs on an isolated in-memory conn. + - Temp conn can still read from storage-backed db state, but cannot write to disk + (`:skip-store? true`). + - Defers db validation during inner batch ops (`:skip-validate-db? true`), then + validates on the final `transact!` to `conn`. + - Supports nested usage. + + Notes: + - `batch-tx-fn` is called as `(batch-tx-fn temp-conn *batch-tx-data)`. + - `listen-db` (if provided) receives each intermediate tx-report from temp conn. + - Do not rely on returned tx-report shape for undo/redo behavior." + [conn tx-meta batch-tx-fn & {:keys [listen-db before-commit]}] + (let [temp-conn (temp-conn-from-db @conn) + *batch-tx-data (volatile! []) + *complete? (volatile! false)] + ;; can read from disk, write is disallowed + (swap! temp-conn assoc + :batch-tx? true + :skip-validate-db? true) + (d/listen! temp-conn ::temp-conn-batch-tx + (fn [{:keys [tx-data] :as tx-report}] + (vswap! *batch-tx-data into tx-data) + (when (fn? listen-db) + (listen-db tx-report)))) + (try + (batch-tx-fn temp-conn *batch-tx-data) + (when (fn? before-commit) + (before-commit)) + (vreset! *complete? true) + (let [tx-data @*batch-tx-data] + (when (and @*complete? (seq tx-data)) + ;; transact tx-data to `conn` and validate db + (transact! conn tx-data tx-meta))) + (finally + (d/unlisten! temp-conn ::temp-conn-batch-tx) + (reset! temp-conn nil) + (vreset! *batch-tx-data nil))))) + +(defn- batch-transact-listen! + [conn *tx-data listen-db] + (d/listen! conn ::batch-tx + (fn [tx-report] + (swap! *tx-data into (:tx-data tx-report)) + (when (fn? listen-db) + (listen-db tx-report))))) + +(defn- batch-transact-cleanup! + [conn] + (d/unlisten! conn ::batch-tx) + (swap! conn dissoc :skip-store? :batch-tx?)) + +(defn batch-transact! + "Run batched tx work on `conn` and persist once at the end. + Not nestable; throws when called inside another `:batch-tx?`." + [conn tx-meta batch-tx-fn & {:keys [listen-db]}] + (let [db-before @conn + *tx-data (atom [])] + (try + (when (:batch-tx? @conn) + (throw (ex-info "batch-transact! can't be nested called" {:tx-meta tx-meta}))) + (batch-transact-listen! conn *tx-data listen-db) + (swap! conn assoc :skip-store? true :batch-tx? true) + (binding [*batch-tx-report?* true] + (batch-tx-fn conn)) + (batch-transact-cleanup! conn) + (when-some [_storage (storage/storage @conn)] + (d/store @conn) + ;; batch-transact! bypasses conn/store-after-transact!, so keep tail bookkeeping in sync + ;; with the just-persisted root snapshot. + (swap! (:atom conn) assoc + :tx-tail [] + :db-last-stored @conn)) + (let [batch-tx-data @*tx-data + _ (reset! *tx-data nil) + tx-report {:db-before db-before + :db-after @conn + :tx-meta (assoc tx-meta :batch-final-tx-report? true) + :tx-data batch-tx-data}] + (dc/run-callbacks conn tx-report) + tx-report) + (catch :default e + (log/error e) + (reset! conn db-before) + (throw e)) + (finally + (batch-transact-cleanup! conn) + (reset! *tx-data nil))))) + +(def page? entity-util/page?) +(def internal-page? entity-util/internal-page?) +(def class? entity-util/class?) +(def property? entity-util/property?) +(def closed-value? entity-util/closed-value?) +(def journal? entity-util/journal?) +(def hidden? entity-util/hidden?) +(def recycled? entity-util/recycled?) +(def object? entity-util/object?) +(def asset? entity-util/asset?) +(def public-built-in-property? db-property/public-built-in-property?) +(def get-entity-types entity-util/get-entity-types) +(def internal-tags db-class/internal-tags) +(def private-tags db-class/private-tags) +(def extends-hidden-tags db-class/extends-hidden-tags) +(def hidden-tags db-class/hidden-tags) + +(defn sort-by-order + [blocks] + (sort-by :block/order blocks)) + +(defn- get-block-and-children-aux + [entity & {:keys [include-property-block?] + :or {include-property-block? false} + :as opts}] + (if-let [children (sort-by-order + (if include-property-block? + (let [children (entity-plus/lookup-kv-then-entity entity :block/_raw-parent)] + (concat children + (keep (fn [c] (:logseq.property/query c)) children))) + (entity-plus/lookup-kv-then-entity entity :block/_parent)))] + (cons entity (mapcat #(get-block-and-children-aux % opts) children)) + [entity])) + +(defn get-block-and-children + [db block-uuid & {:as opts}] + (when-let [e (d/entity db [:block/uuid block-uuid])] + (get-block-and-children-aux e opts))) + +(defn get-page-blocks + "Return blocks of the designated page, without using cache. + page-id - eid" + [db page-id & {:keys [pull-keys] + :or {pull-keys '[*]}}] + (when page-id + (let [datoms (d/datoms db :avet :block/page page-id) + block-eids (mapv :e datoms)] + (d/pull-many db pull-keys block-eids)))) + +(defn get-page-blocks-count + [db page-id] + (count (d/datoms db :avet :block/page page-id))) + +(defn- get-block-children-or-property-children + [block parent] + (let [from-property (:logseq.property/created-from-property block) + closed-property (:block/closed-value-property block)] + (sort-by-order (cond + closed-property + (:block/_closed-value-property closed-property) + + from-property + (filter (fn [e] + (= (:db/id (:logseq.property/created-from-property e)) + (:db/id from-property))) + (:block/_raw-parent parent)) + + :else + (:block/_parent parent))))) + +(defn- get-right-sibling-for-property-children + [block parent] + (assert (or (de/entity? block) (nil? block))) + (let [children (get-block-children-or-property-children block parent) + right (some (fn [child] (when (> (compare (:block/order child) (:block/order block)) 0) child)) children)] + (when (not= (:db/id right) (:db/id block)) + right))) + +(defn- get-ordinary-sibling + [block direction] + (let [db (.-db block) + parent-id (:db/id (:block/parent block)) + block-order (:block/order block) + [eligible? closer?] (case direction + :left [neg? pos?] + :right [pos? neg?]) + [sibling-id _] + (reduce (fn [[_ best-order :as best] datom] + (let [child-id (:e datom) + child-order (some-> (d/datoms db :eavt child-id :block/order) first :v)] + (if (and child-order + (eligible? (compare child-order block-order)) + (not (seq (d/datoms db :avet :logseq.property/created-from-property child-id))) + (not (seq (d/datoms db :avet :block/closed-value-property child-id))) + (or (nil? best-order) + (closer? (compare child-order best-order)))) + [child-id child-order] + best))) + [nil nil] + (d/datoms db :avet :block/parent parent-id))] + (when sibling-id + (d/entity db sibling-id)))) + +(defn get-right-sibling + [block] + (assert (or (de/entity? block) (nil? block))) + (when-let [parent (:block/parent block)] + (cond + (:block/closed-value-property block) + (get-right-sibling-for-property-children block parent) + + (:logseq.property/created-from-property block) + (get-right-sibling-for-property-children block parent) + + :else + (get-ordinary-sibling block :right)))) + +(defn- get-left-sibling-for-property-children + [block parent] + (assert (or (de/entity? block) (nil? block))) + (let [children (reverse (get-block-children-or-property-children block parent)) + left (some (fn [child] (when (< (compare (:block/order child) (:block/order block)) 0) child)) children)] + (when (not= (:db/id left) (:db/id block)) + left))) + +(defn get-left-sibling + [block] + (assert (or (de/entity? block) (nil? block))) + (when-let [parent (:block/parent block)] + (cond + (:block/closed-value-property block) + (get-left-sibling-for-property-children block parent) + + (:logseq.property/created-from-property block) + (get-left-sibling-for-property-children block parent) + + :else + (get-ordinary-sibling block :left)))) + +(defn get-down + [block] + (assert (or (de/entity? block) (nil? block))) + (first (sort-by-order (:block/_parent block)))) + +(defn get-pages + [db] + (->> (d/q + '[:find ?page-title + :where + [?page :block/name ?page-name] + [(get-else $ ?page :block/title ?page-name) ?page-title]] + db) + (map first) + (remove hidden?))) + +(def get-first-page-by-name common-initial-data/get-first-page-by-name) + +(defn page-exists? + "Returns all page db ids that given title and one of the given `tags`." + [db page-name tags] + (when page-name + (let [tags' (if (coll? tags) (set tags) #{tags})] + ;; Classes and properties are case sensitive and can be looked up + ;; as such in case-sensitive contexts e.g. no Page + (if (and (seq tags') (every? #{:logseq.class/Tag :logseq.class/Property} tags')) + (seq + (d/q + '[:find [?p ...] + :in $ ?name [?tag-ident ...] + :where + [?p :block/title ?name] + [?p :block/tags ?tag] + [?tag :db/ident ?tag-ident]] + db + page-name + tags')) + ;; TODO: Decouple db graphs from file specific :block/name + (seq + (d/q + '[:find [?p ...] + :in $ ?name [?tag-ident ...] + :where + [?p :block/name ?name] + [?p :block/tags ?tag] + [?tag :db/ident ?tag-ident]] + db + (common-util/page-name-sanity-lc page-name) + tags')))))) + +(defn get-page + "Get a page given its unsanitized name or uuid" + [db page-id-name-or-uuid] + (when db + (if (number? page-id-name-or-uuid) + (d/entity db page-id-name-or-uuid) + (if-let [id (if (uuid? page-id-name-or-uuid) page-id-name-or-uuid + (parse-uuid page-id-name-or-uuid))] + (d/entity db [:block/uuid id]) + (d/entity db (get-first-page-by-name db (name page-id-name-or-uuid))))))) + +(defn get-journal-page + "Get a journal page given its unsanitized name. + This will be useful for DB graphs later as we can switch to a different lookup + approach for journals e.g. like get-built-in-page" + [db page-name] + (when db + (d/entity db (get-first-page-by-name db page-name)))) + +(defn get-journal-page-by-day + "Get a journal page given its :block/journal-day value." + [db journal-day] + (when (and db journal-day) + (when-let [eid (some-> (first (d/datoms db :avet :block/journal-day journal-day)) + :e)] + (d/entity db eid)))) + +(def get-built-in-page db-db/get-built-in-page) + +(def library? db-db/library?) +(defn get-library-page + [db] + (get-built-in-page db common-config/library-page-name)) + +(defn get-case-page + "Case sensitive version of get-page. For use with DB graphs" + [db page-name-or-uuid] + (when db + (if-let [id (if (uuid? page-name-or-uuid) page-name-or-uuid + (parse-uuid page-name-or-uuid))] + (d/entity db [:block/uuid id]) + (d/entity db (common-initial-data/get-first-page-by-title db page-name-or-uuid))))) + +(defn page-empty? + "Whether a page is empty. Does it has a non-page block? + `page-id` could be either a string or a db/id." + [db page-id] + (let [page-id (if (string? page-id) + (get-first-page-by-name db page-id) + page-id) + page (d/entity db page-id)] + (empty? (:block/_parent page)))) + +(defn get-first-child + [db id] + (first (sort-by-order (:block/_parent (d/entity db id))))) + +(defn get-orphaned-pages + [db {:keys [pages empty-ref-f built-in-pages-names] + :or {empty-ref-f (fn [page] (zero? (count (:block/_refs page)))) + built-in-pages-names #{}}}] + (let [pages (->> (or pages (get-pages db)) + (remove nil?)) + built-in-pages (set (map string/lower-case built-in-pages-names)) + orphaned-pages (->> + (map + (fn [page] + (when-let [page (get-page db page)] + (let [name' (:block/name page)] + (and + (empty-ref-f page) + (or + (page-empty? db (:db/id page)) + (let [first-child (get-first-child db (:db/id page)) + children (:block/_page page)] + (and + first-child + (= 1 (count children)) + (contains? #{"" "-" "*"} (string/trim (:block/title first-child)))))) + (not (contains? built-in-pages name')) + (not (property? page)) + ;; a/b/c might be deleted but a/b/c/d still exists (for backward compatibility) + (not (and (string/includes? name' "/") + (not (journal? page)))) + (not (:block/properties page)) + page)))) + pages) + (remove false?) + (remove nil?) + (remove hidden?))] + orphaned-pages)) + +(defn has-children? + [db block-id] + (let [eid (if (uuid? block-id) [:block/uuid block-id] block-id)] + (some? (:block/_parent (d/entity db eid))))) + +(defn- collapsed-and-has-children? + [db block] + (and (:block/collapsed? block) (has-children? db (:block/uuid block)))) + +(defn get-block-last-direct-child-id + "Notice: if `not-collapsed?` is true, will skip searching for any collapsed block." + ([db db-id] + (get-block-last-direct-child-id db db-id false)) + ([db db-id not-collapsed?] + (when-let [block (d/entity db db-id)] + (when (if not-collapsed? + (not (collapsed-and-has-children? db block)) + true) + (let [children (sort-by :block/order (:block/_parent block))] + (:db/id (last children))))))) + +(defn get-children + "Doesn't include nested children." + ([block-entity] + (get-children nil block-entity)) + ([db block-entity-or-eid] + (when-let [parent (cond + (number? block-entity-or-eid) + (d/entity db block-entity-or-eid) + (uuid? block-entity-or-eid) + (d/entity db [:block/uuid block-entity-or-eid]) + :else + block-entity-or-eid)] + (sort-by-order (:block/_parent parent))))) + +(defn get-block-parents + "Returns parents entities" + [db block-id & {:keys [depth] :or {depth 100}}] + (loop [block-id block-id + parents' (list) + d 1] + (if (> d depth) + parents' + (if-let [parent (:block/parent (d/entity db [:block/uuid block-id]))] + (recur (:block/uuid parent) (conj parents' parent) (inc d)) + parents')))) + +(def get-block-children-ids common-initial-data/get-block-children-ids) +(def get-block-full-children-ids common-initial-data/get-block-full-children-ids) + +(defn- get-sorted-page-block-ids + [db page-id] + (let [root (d/entity db page-id)] + (loop [result [] + children (sort-by-order (:block/_parent root))] + (if (seq children) + (let [child (first children)] + (recur (conj result (:db/id child)) + (concat + (sort-by-order (:block/_parent child)) + (rest children)))) + result)))) + +(defn sort-page-random-blocks + "Blocks could be non consecutive." + [db blocks] + (assert (every? #(= (:block/page %) (:block/page (first blocks))) blocks) "Blocks must to be in a same page.") + (let [page-id (:db/id (:block/page (first blocks))) + ;; TODO: there's no need to sort all the blocks + sorted-ids (get-sorted-page-block-ids db page-id) + blocks-map (zipmap (map :db/id blocks) blocks)] + (keep blocks-map sorted-ids))) + +(defn last-child-block? + "The child block could be collapsed." + [db parent-id child-id] + (when-let [child (d/entity db child-id)] + (cond + (= parent-id child-id) + true + + (get-right-sibling child) + false + + :else + (last-child-block? db parent-id (:db/id (:block/parent child)))))) + +(defn- consecutive-block? + [db block-1 block-2] + (let [aux-fn (fn [block-1 block-2] + (and (= (:block/page block-1) (:block/page block-2)) + (or + ;; sibling or child + (= (:db/id (get-left-sibling block-2)) (:db/id block-1)) + (when-let [prev-sibling (get-left-sibling (d/entity db (:db/id block-2)))] + (last-child-block? db (:db/id prev-sibling) (:db/id block-1))))))] + (or (aux-fn block-1 block-2) (aux-fn block-2 block-1)))) + +(defn get-non-consecutive-blocks + [db blocks] + (vec + (keep-indexed + (fn [i _block] + (when (< (inc i) (count blocks)) + (when-not (consecutive-block? db (nth blocks i) (nth blocks (inc i))) + (nth blocks i)))) + blocks))) + +(defn new-block-id + [] + (common-uuid/gen-uuid)) + +(defn get-alias-source-page + "return the source page of an alias" + [db alias-id] + (when alias-id + (first (:block/_alias (d/entity db alias-id))))) + +(def get-block-alias common-initial-data/get-block-alias) + +(defn page-alias-set + [db page-id] + (->> + (get-block-alias db page-id) + (set) + (set/union #{page-id}))) + +(def get-block-refs-count common-initial-data/get-block-refs-count) + +(defn hidden-or-internal-tag? + [e] + (or (entity-util/hidden? e) (db-class/internal-tags (:db/ident e)))) + +(defn get-all-pages + [db] + (->> + (d/datoms db :avet :block/name) + (keep (fn [d] + (let [e (d/entity db (:e d))] + (when-not (hidden-or-internal-tag? e) + e)))))) + +(defn get-key-value + [db key-ident] + (assert (= "logseq.kv" (namespace key-ident)) key-ident) + (:kv/value (d/entity db key-ident))) + +(def kv sqlite-util/kv) + +(defn get-graph-rtc-uuid + [db] + (when db (get-key-value db :logseq.kv/graph-uuid))) + +(defn get-graph-local-uuid + [db] + (when db (get-key-value db :logseq.kv/local-graph-uuid))) + +(defn get-graph-schema-version + [db] + (when db (get-key-value db :logseq.kv/schema-version))) + +(defn get-graph-remote-schema-version + [db] + (when db (get-key-value db :logseq.kv/remote-schema-version))) + +(defn get-graph-rtc-e2ee? + [db] + (when db (get-key-value db :logseq.kv/graph-rtc-e2ee?))) + +(def get-all-properties db-db/get-all-properties) +(def get-class-extends db-class/get-class-extends) +(def get-classes-parents db-db/get-classes-parents) +(def get-page-parents db-db/get-page-parents) +(def get-title-with-parents db-db/get-title-with-parents) +(def class-instance? db-db/class-instance?) +(def inline-tag? db-db/inline-tag?) +(def node-display-type-classes db-db/node-display-type-classes) +(def get-class-ident-by-display-type db-db/get-class-ident-by-display-type) +(def get-display-type-by-class-ident db-db/get-display-type-by-class-ident) + +(def get-recent-updated-pages common-initial-data/get-recent-updated-pages) + +(def get-latest-journals common-initial-data/get-latest-journals) + +(defn get-pages-relation + [db with-journal?] + (let [q (if with-journal? + '[:find ?p ?ref-page + :where + [?block :block/page ?p] + [?block :block/refs ?ref-page]] + '[:find ?p ?ref-page + :where + [?block :block/page ?p] + [?p :block/tags] + (not [?p :block/tags :logseq.class/Journal]) + [?block :block/refs ?ref-page]])] + (d/q q db))) + +(defn get-all-tagged-pages + [db] + (d/q '[:find ?page ?tag + :where + [?page :block/tags ?tag]] + db)) + +(defn page-in-library? + "Check whether a `page` exists on the Library page" + [db page] + (when (page? page) + (when-let [library-page (get-built-in-page db common-config/library-page-name)] + (loop [parent (:block/parent page)] + (cond + (nil? parent) + false + (= (:db/id parent) (:db/id library-page)) + true + :else + (recur (:block/parent parent))))))) + +(def get-class-title-with-extends db-db/get-class-title-with-extends) + +(defn- bidirectional-property-attr? + [db attr] + (when (qualified-keyword? attr) + (let [attr-ns (namespace attr)] + (and (or (db-property/user-property-namespace? attr-ns) + (db-property/plugin-property? attr)) + (when-let [property (d/entity db attr)] + (and (= :db.type/ref (:db/valueType property)) + (seq (:logseq.property/classes property)))))))) + +(defn- get-bidirectional-property-attrs + [db] + (->> (d/q '[:find [?a ...] + :where + [?property :db/ident ?a] + [?property :db/valueType :db.type/ref] + [?property :logseq.property/classes ?class]] + db) + (filter (partial bidirectional-property-attr? db)))) + +(defn- get-ea-by-v + [db attrs v] + (->> attrs + (mapcat (fn [attr] + (map (fn [datom] + [(:e datom) attr]) + (d/datoms db :avet attr v)))))) + +(defn- add-entity + [acc class-id entity] + (if class-id + (update acc class-id (fnil conj #{}) entity) + acc)) + +(defn- build-bidirectional-property-group + [db [class-id entities]] + (let [class (d/entity db class-id)] + (when (true? (:logseq.property.class/enable-bidirectional? class)) + (let [custom-title (when-let [custom (:logseq.property.class/bidirectional-property-title class)] + (if (string? custom) + custom + (db-property/property-value-content custom))) + title (if (string/blank? custom-title) + (common-plural/plural (:block/title class)) + custom-title)] + {:title title + :class (-> (into {} class) + (assoc :db/id (:db/id class))) + :entities (->> entities + (sort-by :block/created-at))})))) + +(defn get-bidirectional-properties + "Given a target entity id, returns a seq of maps with: + * :class - class entity + * :title - pluralized class title + * :entities - node entities that reference the target via ref properties" + [db target-id] + (when (and db target-id + (not (:logseq.property/created-from-property (d/entity db target-id)))) + (let [bidirectional-attrs (get-bidirectional-property-attrs db)] + (->> (get-ea-by-v db bidirectional-attrs target-id) + (keep (fn [[e _a]] + (when-let [entity (d/entity db e)] + (when (and (not= (:db/id entity) target-id) + (not (entity-util/recycled? entity)) + (not (entity-util/class? entity)) + (not (entity-util/property? entity))) + (let [classes (filter entity-util/class? (:block/tags entity))] + (when (seq classes) + (keep (fn [class-ent] + (when (and (not (built-in? class-ent)) + (not (entity-util/recycled? class-ent))) + [(:db/id class-ent) entity])) + classes))))))) + (mapcat identity) + (reduce (fn [acc [class-ent entity]] + (add-entity acc class-ent entity)) + {}) + (keep (partial build-bidirectional-property-group db)) + (sort-by (comp :block/created-at :class)))))) diff --git a/deps/db/src/logseq/db/common/delete_blocks.cljs b/deps/db/src/logseq/db/common/delete_blocks.cljs new file mode 100644 index 0000000..3a6590c --- /dev/null +++ b/deps/db/src/logseq/db/common/delete_blocks.cljs @@ -0,0 +1,229 @@ +(ns logseq.db.common.delete-blocks + "Provides fn to handle any deletion to occur per ldb/transact!" + (:require [clojure.string :as string] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.common.util.block-ref :as block-ref] + [logseq.common.util.page-ref :as page-ref] + [logseq.db.common.entity-plus :as entity-plus] + [logseq.db.frontend.entity-util :as entity-util])) + +(defn- replace-ref-with-deleted-block-title + [block ref-raw-title] + (let [block-content (if (entity-util/asset? block) + "" + (:block/title block))] + (some-> ref-raw-title + (string/replace (re-pattern (common-util/format "(?i){{embed \\(\\(%s\\)\\)\\s?}}" (str (:block/uuid block)))) + block-content) + + (string/replace (block-ref/->block-ref (str (:block/uuid block))) + block-content) + (string/replace (page-ref/->page-ref (str (:block/uuid block))) + block-content)))) + +(defn- build-retracted-tx + ([retracted-blocks] + (build-retracted-tx retracted-blocks #{})) + ([retracted-blocks extra-retract-ids] + (let [refs (->> (mapcat (fn [block] (:block/_refs block)) retracted-blocks) + (common-util/distinct-by :db/id)) + retract-ids (into (set (map :db/id retracted-blocks)) extra-retract-ids)] + (mapcat + (fn [ref] + (let [id (:db/id ref) + replaced-title (when-not (contains? retract-ids id) + (when-let [raw-title (:block/raw-title ref)] + (reduce + (fn [raw-title block] + (replace-ref-with-deleted-block-title block raw-title)) + raw-title + retracted-blocks))) + tx (cond-> + (mapcat + (fn [block] + [[:db/retract (:db/id ref) :block/refs (:db/id block)]]) retracted-blocks) + replaced-title + (conj [:db/add id :block/title replaced-title]))] + tx)) + refs)))) + +(defn- block-entity? + [entity] + (and (:block/uuid entity) + (:block/page entity) + (not (entity-util/page? entity)))) + +(defn- retracted-entities + [db txs] + (->> txs + (keep (fn [tx] + (when (and (vector? tx) + (contains? #{:db.fn/retractEntity :db/retractEntity} (first tx))) + (d/entity db (second tx))))) + (common-util/distinct-by :db/id))) + +(def ^:private property-history-ref-attrs + #{:logseq.property.history/block + :logseq.property.history/property + :logseq.property.history/ref-value}) + +(defn- property-history-entity? + [entity] + (boolean + (and entity + (or (:logseq.property.history/block entity) + (:logseq.property.history/property entity) + (:logseq.property.history/ref-value entity) + (contains? entity :logseq.property.history/scalar-value))))) + +(defn- property-history-ref-retracted-entities + [db txs] + (->> txs + (keep (fn [tx] + (when (and (vector? tx) + (= :db/retract (first tx)) + (contains? property-history-ref-attrs (nth tx 2 nil))) + (let [entity (d/entity db (second tx))] + (when (property-history-entity? entity) + entity))))) + (common-util/distinct-by :db/id))) + +(defn- tx-entity-id + [db eid] + (cond + (integer? eid) + eid + + (vector? eid) + (some-> (d/entity db eid) :db/id))) + +(defn- vector-adds-by-eid + [txs] + (reduce (fn [result tx] + (if (and (vector? tx) + (= :db/add (first tx)) + (integer? (second tx))) + (update result (second tx) assoc (nth tx 2) (nth tx 3)) + result)) + {} + txs)) + +(defn- property-history-ref-retracted-ids + [txs] + (->> txs + (keep (fn [tx] + (when (and (vector? tx) + (= :db/retract (first tx)) + (integer? (second tx)) + (contains? property-history-ref-attrs (nth tx 2 nil))) + (second tx)))) + set)) + +(defn- new-property-history-retract-tx + [db txs retracted-ids] + (let [referencing-retracted-entity? + (fn [history] + (or (contains? retracted-ids + (tx-entity-id db (:logseq.property.history/block history))) + (contains? retracted-ids + (tx-entity-id db (:logseq.property.history/property history))) + (contains? retracted-ids + (tx-entity-id db (:logseq.property.history/ref-value history))))) + map-retract-tx + (keep (fn [tx] + (when (and (map? tx) + (:block/uuid tx) + (property-history-entity? tx) + (referencing-retracted-entity? tx)) + [:db/retractEntity [:block/uuid (:block/uuid tx)]])) + txs) + retracted-history-ref-ids (property-history-ref-retracted-ids txs) + vector-retract-tx + (->> (vector-adds-by-eid txs) + (keep (fn [[eid tx-entity]] + (when (and (:block/uuid tx-entity) + (property-history-entity? tx-entity) + (or (contains? retracted-history-ref-ids eid) + (referencing-retracted-entity? tx-entity))) + [:db/retractEntity eid]))))] + (distinct (concat map-retract-tx vector-retract-tx)))) + +(defn- block-subtree-entities + [root] + (loop [pending [root] + seen-ids #{} + result []] + (if-let [entity (first pending)] + (if (or (nil? (:db/id entity)) + (contains? seen-ids (:db/id entity))) + (recur (rest pending) seen-ids result) + (recur (concat (rest pending) + (filter block-entity? + (entity-plus/lookup-kv-then-entity entity :block/_raw-parent))) + (conj seen-ids (:db/id entity)) + (conj result entity))) + result))) + +(defn expand-delete-blocks-tx + "Expands delete-blocks retracts to the current DB subtree before transacting." + [db txs {:keys [outliner-op]}] + (if (= :delete-blocks outliner-op) + (let [subtree-tx (->> (retracted-entities db txs) + (filter block-entity?) + (mapcat block-subtree-entities) + (map (fn [entity] [:db/retractEntity (:db/id entity)])))] + (distinct (concat txs subtree-tx))) + txs)) + +(defn- direct-cleanup-tx + [entities] + (let [retracted-blocks (filter block-entity? entities) + retracted-history-self-entities (filter property-history-entity? entities) + retracted-history-self-tx (map (fn [history] [:db/retractEntity (:db/id history)]) + retracted-history-self-entities) + reaction-entities (->> entities + (mapcat :logseq.property.reaction/_target) + (common-util/distinct-by :db/id)) + retract-reactions-tx (map (fn [reaction] [:db/retractEntity (:db/id reaction)]) + reaction-entities) + view-entities (->> entities + (mapcat :logseq.property/_view-for) + (common-util/distinct-by :db/id)) + history-entities (->> entities + (mapcat (fn [entity] + (concat (:logseq.property.history/_block entity) + (:logseq.property.history/_property entity) + (:logseq.property.history/_ref-value entity)))) + (common-util/distinct-by :db/id)) + retract-history-tx (map (fn [history] [:db/retractEntity (:db/id history)]) + history-entities) + cleanup-retract-ids (->> (concat retracted-history-self-entities + reaction-entities + view-entities + history-entities) + (keep :db/id) + set) + retracted-tx (build-retracted-tx retracted-blocks cleanup-retract-ids) + delete-views (map (fn [view] [:db/retractEntity (:db/id view)]) view-entities)] + (vec (concat retracted-tx delete-views retracted-history-self-tx retract-history-tx retract-reactions-tx)))) + +(defn- build-cleanup-tx + [db txs] + (let [initial-entities (concat (retracted-entities db txs) + (property-history-ref-retracted-entities db txs))] + (loop [pending-entities initial-entities + seen-ids #{} + cleanup-tx (new-property-history-retract-tx db txs (set (map :db/id initial-entities)))] + (if-let [entities (seq (remove #(contains? seen-ids (:db/id %)) + pending-entities))] + (let [seen-ids' (into seen-ids (map :db/id) entities) + next-tx (direct-cleanup-tx entities)] + (recur (retracted-entities db next-tx) seen-ids' (into cleanup-tx next-tx))) + (distinct cleanup-tx))))) + +(defn update-refs-history + "When an entity is deleted, related property history, views and reactions + are deleted" + [db txs _opts] + (seq (build-cleanup-tx db txs))) diff --git a/deps/db/src/logseq/db/common/entity_plus.cljc b/deps/db/src/logseq/db/common/entity_plus.cljc new file mode 100644 index 0000000..fd44b0e --- /dev/null +++ b/deps/db/src/logseq/db/common/entity_plus.cljc @@ -0,0 +1,265 @@ +(ns logseq.db.common.entity-plus + "Add map ops such as assoc/dissoc to datascript Entity. + The db-based-graph? checks are still in this ns because it's unclear which of these + lower level fns are still used by the graph-parser. + + NOTE: This doesn't work for nbb/sci yet because of https://github.com/babashka/sci/issues/639" + ;; Disable clj linters since we don't support clj + #?(:clj {:clj-kondo/config {:linters {:unresolved-namespace {:level :off} + :unresolved-symbol {:level :off}}}}) + (:require #?(:org.babashka/nbb [datascript.db]) + [cljs.core] + [clojure.data :as data] + [datascript.core :as d] + [datascript.impl.entity :as entity :refer [Entity]] + [logseq.common.util.date-time :as date-time-util] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property])) + +(def nil-db-ident-entities + "No such entities with these :db/ident, but `(d/entity )` has been called somewhere." + #{:block/tx-id :block/uuid :block/journal-day :block/_refs :block/level :block/heading-level + :block/warning :block/name + :block.temp/ast-title :block.temp/load-status :block.temp/has-children? :block.temp/ast-body + + :db/valueType :db/cardinality :db/ident :db/index + + :logseq.property/_query}) + +(def immutable-db-ident-entities + "These db-ident entities are immutable, + it means `(db/entity :block/title)` always return same result" + #{:block/link :block/updated-at :block/refs :block/closed-value-property + :block/created-at :block/collapsed? :block/tags :block/title + :block/parent :block/order :block/page + + :logseq.property/created-from-property + :logseq.property/icon + :logseq.property.asset/type + :logseq.property.asset/checksum + :logseq.property.node/display-type + + :logseq.kv/db-type}) + +(assert (empty? (last (data/diff immutable-db-ident-entities nil-db-ident-entities)))) + +(def lookup-entity @#'entity/lookup-entity) + +(def ^:private *seen-immutable-entities (volatile! {})) + +(defn reset-immutable-entities-cache! + [] + (vreset! *seen-immutable-entities {})) + +(defonce *reset-cache-background-task-running-f (atom nil)) + +(defn entity-memoized + [db eid] + (if (and (qualified-keyword? eid) (not (exists? js/process))) ; don't memoize on node + (when-not (contains? nil-db-ident-entities eid) ;fast return nil + (let [f @*reset-cache-background-task-running-f] + (if (and (fn? f) + (f :logseq.db.common.entity-plus/reset-immutable-entities-cache!) + (contains? immutable-db-ident-entities eid)) ;return cache entity if possible which isn't nil + (or (get @*seen-immutable-entities eid) + (let [r (d/entity db eid)] + (when r (vswap! *seen-immutable-entities assoc eid r)) + r)) + (d/entity db eid)))) + (d/entity db eid))) + +(defn unsafe->Entity + "Faster version of d/entity without checking e exists. + Only use it in performance-critical areas and where the existence of 'e' is confirmed." + [db e] + {:pre [(pos-int? e)]} + (Entity. db e (volatile! false) (volatile! {}))) + +(defn db-based-graph? + "Whether the current graph is db-only. This should only be used in contexts + where both DB and file graph databases are possible e.g. graph-parser" + [db] + (when db + (identical? "db" (:kv/value (entity-memoized db :logseq.kv/db-type))))) + +(defn- get-journal-title + [db e] + (date-time-util/int->journal-title (:block/journal-day e) + (:logseq.property.journal/title-format (entity-memoized db :logseq.class/Journal)))) + +(defn- get-block-title + [^Entity e k default-value] + (let [db (.-db e) + db-based? (db-based-graph? db)] + (if (and db-based? (entity-util/journal? e)) + (get-journal-title db e) + (or + (get (.-kv e) k) + (if db-based? + (let [result (lookup-entity e k default-value) + ;; Replace title for pages only, otherwise it'll recursively + ;; replace block id refs if there're cycle references of blocks + refs (:block/refs e) + result' (if (and (string? result) refs) + (db-content/id-ref->title-ref result refs + {:db db + :replace-pages-with-same-name? false}) + result)] + (or result' default-value)) + (lookup-entity e k default-value)))))) + +(defn- lookup-kv-with-default-value + [db ^Entity e k default-value] + (or + ;; from kv + (get (.-kv e) k) + ;; from db + (let [result (lookup-entity e k default-value)] + (if (some? result) + result + ;; property default value + (when (qualified-keyword? k) + (when-let [property (entity-memoized db k)] + (let [property-type (lookup-entity property :logseq.property/type nil)] + (if (keyword-identical? :checkbox property-type) + (lookup-entity property :logseq.property/scalar-default-value nil) + (lookup-entity property :logseq.property/default-value nil))))))))) + +(defn- get-property-keys + [^Entity e] + (let [db (.-db e)] + (if (db-based-graph? db) + (->> (map :a (d/datoms db :eavt (.-eid e))) + distinct + (filter db-property/property?)) + (keys (lookup-entity e :block/properties nil))))) + +(defn- get-properties + [^Entity e] + (let [db (.-db e)] + (if (db-based-graph? db) + (lookup-entity e :block/properties + (->> (into {} e) + (filter (fn [[k _]] (db-property/property? k))) + (into {}))) + (lookup-entity e :block/properties nil)))) + +;; (defonce *id->k-frequency (atom {})) +(defn lookup-kv-then-entity + ([e k] (lookup-kv-then-entity e k nil)) + ([^Entity e k default-value] + (try + (when k + ;; (swap! *id->k-frequency update-in [(.-eid e) k] inc) + (let [db (.-db e)] + (case k + :block/raw-title + (if (and (db-based-graph? db) (entity-util/journal? e)) + (get-journal-title db e) + (lookup-entity e :block/title default-value)) + + :block/properties + (get-properties e) + + :block.temp/property-keys + (get-property-keys e) + + :block/title + (get-block-title e k default-value) + + :block/_parent + (->> (lookup-entity e k default-value) + (remove (fn [e] (or (:logseq.property/created-from-property e) + (:block/closed-value-property e)))) + seq) + + :block/_raw-parent + (lookup-entity e :block/_parent default-value) + + :property/closed-values + (some->> (lookup-entity e :block/_closed-value-property default-value) + (remove entity-util/recycled?) + (sort-by :block/order)) + + (lookup-kv-with-default-value db e k default-value)))) + (catch :default e + (js/console.error e))))) + +(defn- cache-with-kv + [^js this] + (let [v @(.-cache this)] + (concat (seq v) + (seq (.-kv this))))) + +(defn- entity-ish? [x] + (instance? Entity x)) + +(defn- ->printable + "Convert values so printing won't recurse forever: + - Entity => {:db/id eid} + - coll of Entity => coll of {:db/id ...} + - maps are walked (rare but safe)" + [x] + (cond + (entity-ish? x) + {:db/id (.-eid ^Entity x)} + + (map? x) + (reduce-kv (fn [m k v] (assoc m k (->printable v))) {} x) + + (sequential? x) + (map ->printable x) + + (set? x) + (into #{} (map ->printable x)) + + :else x)) + +#?(:org.babashka/nbb + nil + :default + (extend-type Entity + cljs.core/IEncodeJS + (-clj->js [_this] nil) ; avoid `clj->js` overhead when entity is passed to React components + (-key->js [_this] nil) + + IAssociative + (-assoc [this k v] + (assert (keyword? k) "attribute must be keyword") + (set! (.-kv this) (assoc (.-kv this) k v)) + this) + (-contains-key? [e k] (not= ::nf (lookup-kv-then-entity e k ::nf))) + + IMap + (-dissoc [this k] + (assert (keyword? k) (str "attribute must be keyword: " k)) + (set! (.-kv this) (dissoc (.-kv this) k)) + this) + + ISeqable + (-seq [this] + (entity/touch this) + (cache-with-kv this)) + + IPrintWithWriter + (-pr-writer [this writer opts] + ;; Touch ONLY this entity, to materialize its forward attrs + (entity/touch this) + (let [m0 (into {} (cache-with-kv this)) + m (-> (reduce-kv (fn [m k v] (assoc m k (->printable v))) {} m0) + (assoc :db/id (.-eid this)))] + (-pr-writer m writer opts))) + + ICollection + (-conj [this entry] + (if (vector? entry) + (let [[k v] entry] + (-assoc this k v)) + (reduce (fn [this [k v]] + (-assoc this k v)) this entry))) + + ILookup + (-lookup + ([this attr] (lookup-kv-then-entity this attr)) + ([this attr not-found] (lookup-kv-then-entity this attr not-found))))) diff --git a/deps/db/src/logseq/db/common/initial_data.cljs b/deps/db/src/logseq/db/common/initial_data.cljs new file mode 100644 index 0000000..dbb9806 --- /dev/null +++ b/deps/db/src/logseq/db/common/initial_data.cljs @@ -0,0 +1,454 @@ +(ns logseq.db.common.initial-data + "Provides db helper fns for graph initialization and lazy loading entities" + (: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.db.common.order :as db-order] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.db :as db-db] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.rules :as rules])) + +;; FIXME: For DB graph built-in pages, look up by name -> uuid like +;; get-built-in-page instead of this approach which is more error prone +(defn get-first-page-by-name + "Return the oldest page's db id for :block/name" + [db page-name] + (when (and db (string? page-name)) + (first (sort (map :e (entity-util/get-pages-by-name db page-name)))))) + +(defn get-first-page-by-title + "Return the oldest page's db id for :block/title" + [db page-name] + {:pre [(string? page-name)]} + (->> (d/datoms db :avet :block/title page-name) + (filter (fn [d] + (let [e (d/entity db (:e d))] + (entity-util/page? e)))) + (map :e) + sort + first)) + +(defn get-block-alias + [db eid] + (->> + (d/q + '[:find [?e ...] + :in $ ?eid % + :where + (alias ?eid ?e)] + db + eid + (:alias rules/rules)) + distinct)) + +(defn- datom-v + [db eid attr] + (some-> (first (d/datoms db :eavt eid attr)) :v)) + +(defn- datom-vs + [db eid attr] + (map :v (d/datoms db :eavt eid attr))) + +(defn- get-block-alias-ids + [db eid] + (distinct + (concat + (datom-vs db eid :block/alias) + (map :e (d/datoms db :avet :block/alias eid))))) + +(defn- hidden-eid-pred + [db] + (let [*cache (volatile! {})] + (letfn [(hidden? [eid seen] + (cond + (nil? eid) + false + + (contains? @*cache eid) + (get @*cache eid) + + (contains? seen eid) + false + + :else + (let [result (boolean + (or (datom-v db eid :logseq.property/hide?) + (datom-v db eid :logseq.property/deleted-at) + (hidden? (datom-v db eid :block/parent) (conj seen eid))))] + (vswap! *cache assoc eid result) + result)))] + (fn [eid] + (hidden? eid #{}))))) + +(defn- hidden-ref-id-pred + ([db id] + (hidden-ref-id-pred db id (hidden-eid-pred db))) + ([db id hidden-eid?] + (let [entity (d/entity db id) + entity-ident (:db/ident entity) + class-ids (when (entity-util/class? entity) + (let [children (db-class/get-structured-children db id)] + (set (conj children id))))] + (fn [ref-eid] + (or + (= ref-eid id) + (= id (datom-v db ref-eid :block/page)) + (= id (datom-v db ref-eid :logseq.property/view-for)) + (hidden-eid? (datom-v db ref-eid :block/page)) + (hidden-eid? ref-eid) + (and class-ids + (some class-ids (datom-vs db ref-eid :block/tags))) + (and entity-ident + (seq (d/datoms db :eavt ref-eid entity-ident)))))))) + +(comment + (defn- get-built-in-files + [db] + (let [files ["logseq/config.edn" + "logseq/custom.css" + "logseq/custom.js"]] + (map #(d/pull db '[*] [:file/path %]) files)))) + +(defn- get-all-files + [db] + (->> (d/datoms db :avet :file/path) + (mapcat (fn [e] (d/datoms db :eavt (:e e)))))) + +(defn- with-block-refs + [db block] + (update block :block/refs (fn [refs] (map (fn [ref] (d/pull db '[*] (:db/id ref))) refs)))) + +(defn with-parent + [db block] + (cond + (:block/page block) + (let [parent (when-let [e (d/entity db (:db/id (:block/parent block)))] + (select-keys e [:db/id :block/uuid]))] + (->> + (assoc block :block/parent parent) + (common-util/remove-nils-non-nested) + (with-block-refs db))) + + :else + block)) + +(comment + (defn- with-block-link + [db block] + (if (:block/link block) + (update block :block/link (fn [link] (d/pull db '[*] (:db/id link)))) + block))) + +(defn get-block-children-ids + "Returns children ids, notice the result doesn't include property value children ids." + [db block-eid & {:keys [include-collapsed-children?] + :or {include-collapsed-children? true}}] + (when-let [eid (:db/id (d/entity db block-eid))] + (let [seen (volatile! #{})] + (loop [eids-to-expand [eid]] + (when (seq eids-to-expand) + (let [children + (mapcat (fn [eid] + (let [e (d/entity db eid)] + (when (or include-collapsed-children? + (not (:block/collapsed? e)) + (entity-util/page? e)) + (:block/_parent e)))) eids-to-expand) + ids-to-add (keep :db/id children)] + (vswap! seen (partial apply conj) ids-to-add) + (recur (keep :db/id children))))) + @seen))) + +(defn get-block-children + "Including nested children, notice the result doesn't include property values." + {:arglists '([db eid & {:keys [include-collapsed-children?]}])} + [db eid & {:as opts}] + (let [ids (get-block-children-ids db eid opts)] + (when (seq ids) + (map (fn [id] (d/entity db id)) ids)))) + +(defn get-block-full-children-ids + "Including nested, collapsed and property value children." + {:arglists '([db block-eid])} + [db block-eid] + (d/q + '[:find [?c ...] + :in $ ?id % + :where + (parent ?id ?c)] + db + block-eid + (:parent rules/rules))) + +(defn- with-raw-title + [m entity] + (if-let [raw-title (:block/raw-title entity)] + (assoc m :block/title raw-title) + m)) + +(defn- entity->map + [entity & {:keys [level properties] + :or {level 0}}] + (let [opts {:level (inc level)} + f (if (> level 0) + identity + (fn [e] + (keep (fn [[k v]] + (when (or (empty? properties) (properties k)) + (let [v' (cond + (= k :block/parent) + (:db/id v) + (= k :block/tags) + (map #(select-keys % [:db/id]) v) + (= k :logseq.property/created-by-ref) + (:db/id v) + (de/entity? v) + (entity->map v opts) + (and (coll? v) (every? de/entity? v)) + (map #(entity->map % opts) v) + :else + v)] + [k v']))) + e))) + m (->> (f entity) + (into {}))] + (-> m + (with-raw-title entity) + (assoc :db/id (:db/id entity))))) + +(defn hidden-ref-pred + "Build a predicate that determines if a ref block should be hidden for `id`. + Reuses cached class/tag context so callers can check many refs efficiently." + [db id] + (let [entity (d/entity db id) + entity-ident (:db/ident entity) + class-ids (when (entity-util/class? entity) + (let [children (db-class/get-structured-children db id)] + (set (conj children id))))] + (fn [ref-block] + (or + (= (:db/id ref-block) id) + (= id (:db/id (:block/page ref-block))) + (= id (:db/id (:logseq.property/view-for ref-block))) + (entity-util/hidden? (:block/page ref-block)) + (entity-util/hidden? ref-block) + (and class-ids + (some class-ids (map :db/id (:block/tags ref-block)))) + (and entity-ident + (some? (get ref-block entity-ident))))))) + +(defn get-block-refs + [db id] + (let [with-alias (->> (get-block-alias db id) + (cons id) + distinct) + hidden-ref?* (hidden-ref-pred db id)] + (some->> with-alias + (map #(d/entity db %)) + (mapcat :block/_refs) + (remove hidden-ref?*)))) + +(defn get-block-refs-count + [db id] + (let [with-alias (->> (get-block-alias-ids db id) + (cons id) + distinct) + hidden-ref?* (hidden-ref-id-pred db id)] + (reduce + (fn [total alias-id] + (+ total + (reduce (fn [n datom] + (if (hidden-ref?* (:e datom)) + n + (inc n))) + 0 + (d/datoms db :avet :block/refs alias-id)))) + 0 + with-alias))) + +(defn ^:large-vars/cleanup-todo get-block-and-children + [db id-or-page-name {:keys [children? properties include-collapsed-children?] + :or {include-collapsed-children? false}}] + (let [block (let [eid (cond (uuid? id-or-page-name) + [:block/uuid id-or-page-name] + (integer? id-or-page-name) + id-or-page-name + :else nil)] + (cond + eid + (d/entity db eid) + (string? id-or-page-name) + (d/entity db (get-first-page-by-name db (name id-or-page-name))) + :else + nil)) + block-refs-count? (some #{:block.temp/refs-count} properties)] + (when block + ;; (prn :debug :get-block (:db/id block) (:block/title block) :children? children? + ;; :include-collapsed-children? include-collapsed-children?) + (let [children (when children? + (let [children-blocks (get-block-children db (:db/id block) {:include-collapsed-children? include-collapsed-children?}) + large-page? (>= (count children-blocks) 100) + children (let [children' (if large-page? + (:block/_parent block) + children-blocks)] + (->> children' + (remove (fn [e] (:block/closed-value-property e))))) + children-ids (set (map :db/id children))] + (map + (fn [block] + (let [collapsed? (:block/collapsed? block)] + (-> block + (entity->map) + (assoc :block.temp/has-children? (some? (:block/_parent block)) + :block.temp/load-status (if (and (not collapsed?) + (or (and large-page? + (every? children-ids (map :db/id (:block/_parent block)))) + (not large-page?))) + :full + :self))))) + children))) + block' (cond-> (entity->map block {:properties (set properties)}) + block-refs-count? + (assoc :block.temp/refs-count (get-block-refs-count db (:db/id block))) + true + (assoc :block.temp/load-status (cond + (and children? include-collapsed-children? (empty? properties)) + :full + (and children? (empty? properties)) + :children + :else + :self) + :block.temp/has-children? (some? (first (d/datoms db :avet :block/parent (:db/id block))))))] + (cond-> + {:block block'} + children? + (assoc :children children)))))) + +(defn get-latest-journals + [db] + (let [today (date-time-util/date->int (js/Date.))] + (->> (d/datoms db :avet :block/journal-day) + vec + rseq + (keep (fn [d] + (when (<= (:v d) today) + (let [e (d/entity db (:e d))] + (when (and (entity-util/journal? e) + (:db/id e) + (not (entity-util/recycled? e))) + e))))) + (common-util/distinct-by :db/id)))) + +(defn- get-structured-datoms + [db] + (let [class-property-id (:db/id (d/entity db :logseq.class/Property))] + (->> (concat + (d/datoms db :avet :block/tags :logseq.class/Tag) + (d/datoms db :avet :block/tags :logseq.class/Property) + (d/datoms db :avet :block/closed-value-property)) + (mapcat (fn [d] + (let [block-datoms (d/datoms db :eavt (:e d)) + property-description-datoms + (when (= (:v d) class-property-id) + (when-let [desc (:logseq.property/description (d/entity db (:e d)))] + (d/datoms db :eavt (:db/id desc))))] + (if (seq property-description-datoms) + (concat block-datoms property-description-datoms) + block-datoms))))))) + +(defn- get-favorites + "Favorites page and its blocks" + [db] + (let [page-id (get-first-page-by-name db common-config/favorites-page-name) + block (d/entity db page-id) + children (:block/_page block)] + (when block + (concat (d/datoms db :eavt (:db/id block)) + (->> (keep :block/link children) + (mapcat (fn [l] + (d/datoms db :eavt (:db/id l))))) + (mapcat (fn [child] + (d/datoms db :eavt (:db/id child))) + children))))) + +(defn get-recent-updated-pages + [db] + (when db + (some->> + (d/datoms db :avet :block/updated-at) + rseq + (keep (fn [datom] + (let [page (first (d/datoms db :eavt (:e datom) :block/page))] + (when-not (or page + (let [title (:v (first (d/datoms db :eavt (:e datom) :block/title)))] + (string/blank? title))) + (let [e (d/entity db (:e datom))] + (when (and + (entity-util/page? e) + (not (entity-util/hidden? e))) + e)))))) + (take 15)))) + +(defn- get-all-user-datoms + [db] + (when (d/entity db :logseq.property.user/email) + (->> (d/datoms db :avet :logseq.property.user/email) + (mapcat + (fn [d] (d/datoms db :eavt (:e d))))))) + +(defn- get-list-style-values + [db] + (->> (d/datoms db :avet :logseq.property/order-list-type) + (map :v) + (distinct) + (mapcat (fn [v] (d/datoms db :eavt v))))) + +(defn get-initial-data + "Returns current database schema and initial data" + [db] + (let [_ (reset! db-order/*max-key (db-order/get-max-order db)) + schema (:schema db) + idents (mapcat (fn [id] + (when-let [e (d/entity db id)] + (d/datoms db :eavt (:db/id e)))) + [:logseq.kv/db-type + :logseq.kv/schema-version + :logseq.kv/graph-uuid + :logseq.kv/local-graph-uuid + :logseq.kv/graph-rtc-e2ee? + :logseq.kv/graph-remote? + :logseq.kv/latest-code-lang + :logseq.kv/graph-backup-folder + :logseq.property/empty-placeholder]) + favorites (get-favorites db) + recent-updated-pages (let [pages (get-recent-updated-pages db)] + (mapcat (fn [p] (d/datoms db :eavt (:db/id p))) pages)) + all-files (get-all-files db) + structured-datoms (get-structured-datoms db) + user-datoms (get-all-user-datoms db) + list-style-datoms (get-list-style-values db) + pages-datoms (let [contents-id (get-first-page-by-title db "Contents") + capture-page-id (:db/id (db-db/get-built-in-page db common-config/quick-add-page-name)) + views-id (get-first-page-by-title db common-config/views-page-name) + recycle-id (get-first-page-by-title db "Recycle")] + (mapcat #(d/datoms db :eavt %) + (remove nil? [contents-id capture-page-id views-id recycle-id]))) + data (->> (concat idents + structured-datoms + user-datoms + list-style-datoms + favorites + recent-updated-pages + all-files + pages-datoms) + distinct + (remove (fn [d] + (contains? #{:block/created-at :block/updated-at + :block/tx-id :logseq.property/created-by-ref} + (:a d)))))] + {:schema schema + :initial-data data})) diff --git a/deps/db/src/logseq/db/common/normalize.cljs b/deps/db/src/logseq/db/common/normalize.cljs new file mode 100644 index 0000000..26633ff --- /dev/null +++ b/deps/db/src/logseq/db/common/normalize.cljs @@ -0,0 +1,196 @@ +(ns logseq.db.common.normalize + "Normalize && denormalize eid for sync" + (:require [datascript.core :as d])) + +(defn remove-retract-entity-ref + [db tx-data] + (let [retracted (-> (keep (fn [[op value]] + (when (and (= op :db/retractEntity) + (nil? (d/entity db value))) + value)) tx-data) + set) + retract-uuids (set (map second retracted)) + retracted-ids (into retract-uuids + ;; handle temp ids + (map str retract-uuids))] + (if (seq retracted) + (remove (fn [item] + (and (= 5 (count item)) + (or + (contains? retracted (second item)) + (contains? retracted-ids (second item)) + (contains? retracted (nth item 3)) + (contains? retracted-ids (nth item 3))))) tx-data) + tx-data))) + +(defn replace-attr-retract-with-retract-entity-v2 + [db normalized-tx-data] + (->> normalized-tx-data + (map (fn [[op eid a v t]] + (cond + (and (= op :db/retract) (= a :block/uuid)) + [:db/retractEntity eid] + (and a (some? v)) + [op eid a v t] + :else + [op eid]))) + (remove-retract-entity-ref db))) + +(defn replace-attr-retract-with-retract-entity + [db-after tx-data] + (let [retract-eids-by-entity + (into {} + (keep (fn [d] + (when (and (= :block/uuid (:a d)) + (false? (:added d))) + (let [entity (d/entity db-after [:block/uuid (:v d)])] + (when (not= (:db/id entity) (:e d)) ; eid changed + [(:e d) (if entity + (:e d) + [:block/uuid (:v d)])]))))) + tx-data)] + (loop [result [] + seen #{} + [d & more] tx-data] + (if-not d + result + (if-let [eid (get retract-eids-by-entity (:e d))] + (if (contains? seen (:e d)) + (recur result seen more) + (recur (conj result [:db/retractEntity eid]) + (conj seen (:e d)) + more)) + (recur (conj result d) seen more)))))) + +(defn eid->lookup + [db e] + (when-let [entity (d/entity db e)] + (if-let [id (:block/uuid entity)] + [:block/uuid id] + (:db/ident entity)))) + +(defn eid->tempid + [db e] + (when-let [entity (d/entity db e)] + (when-let [id (if-let [id (:block/uuid entity)] + id + (:db/ident entity))] + (str id)))) + +(defn- sort-datoms + "Properties first" + [datoms] + (sort-by + (fn [item] + (case (nth item 1) + :db/ident + 0 + :db/valueType + 1 + :db/cardinality + 2 + 3)) + datoms)) + +(defn remove-conflict-datoms + [datoms] + (->> datoms + (group-by (fn [d] (take 4 d))) ; group by '(e a v tx) + (keep (fn [[_eavt same-eavt-datoms]] + (first (rseq same-eavt-datoms)))) + ;; sort by :tx, use nth to make this fn works on both vector and datom + (sort-by #(nth % 3)))) + +(defn- retract-entity-op? + [item] + (and (= 2 (count item)) + (= :db/retractEntity (first item)))) + +(defn- retract-entity-match-keys + [e] + (if (and (vector? e) (= :block/uuid (first e))) + (let [block-uuid (second e)] + #{e block-uuid (str block-uuid)}) + #{e})) + +(defn reorder-retract-entity + [tx-data] + (let [retract-ops (filter retract-entity-op? tx-data) + {recreated-block-retract-ops true + end-retract-ops false} (->> retract-ops + (group-by (fn [[_ [_ id]]] + (boolean + (some (fn [x] + (and + (vector? x) + (>= (count x) 4) + (= (first x) :db/add) + (= (nth x 2) :block/uuid) + (= (nth x 3) id))) tx-data))))) + retract-keys (->> retract-ops + (map second) + (mapcat retract-entity-match-keys) + set) + datom-for-retracted-eid? + (fn [item] + (and (vector? item) + (>= (count item) 4) + (contains? retract-keys (second item)))) + datoms-for-retracted-eids (filter datom-for-retracted-eid? tx-data) + others (remove (fn [item] + (or (retract-entity-op? item) + (datom-for-retracted-eid? item))) + tx-data)] + (concat recreated-block-retract-ops datoms-for-retracted-eids others end-retract-ops))) + +(defn- resolve-eid + [db-before db-after retract? e] + (if retract? + (eid->lookup db-before e) + (or (eid->lookup db-before e) + (eid->tempid db-after e)))) + +(defn- ref-value-type? + [db-after db-before attr] + (or (= :db.type/ref (:db/valueType (d/entity db-after attr))) + (= :db.type/ref (:db/valueType (d/entity db-before attr))))) + +(defn normalize-datom + [db-after db-before [e a v t added]] + (let [retract? (not added) + e' (resolve-eid db-before db-after retract? e) + v' (if (and (integer? v) + (pos? v) + (ref-value-type? db-after db-before a)) + (resolve-eid db-before db-after retract? v) + v)] + (when (and (some? e') (some? v')) + (if added + [:db/add e' a v' t] + [:db/retract e' a v' t])))) + +(defn- normalize-retract-entity-item + [db-before d] + (when-let [[op e] (and (= 2 (count d)) + (= :db/retractEntity (first d)) + d)] + (when-let [e' (eid->lookup db-before e)] + [op e']))) + +(defn- normalize-tx-item + [db-after db-before d] + (case (count d) + 5 (normalize-datom db-after db-before d) + 2 (normalize-retract-entity-item db-before d) + nil)) + +(defn normalize-tx-data + [db-after db-before tx-data] + (->> tx-data + remove-conflict-datoms + (replace-attr-retract-with-retract-entity db-after) + sort-datoms + (keep #(normalize-tx-item db-after db-before %)) + (remove-retract-entity-ref db-after) + reorder-retract-entity + distinct)) diff --git a/deps/db/src/logseq/db/common/order.cljs b/deps/db/src/logseq/db/common/order.cljs new file mode 100644 index 0000000..2ffd48c --- /dev/null +++ b/deps/db/src/logseq/db/common/order.cljs @@ -0,0 +1,78 @@ +(ns logseq.db.common.order + "Use fractional-indexing order for blocks/properties/closed values/etc. + Used by DB and file graphs" + (:require [logseq.clj-fractional-indexing :as index] + [datascript.core :as d])) + +(defonce *max-key (atom nil)) + +(defn reset-max-key! + ([key] + (reset-max-key! *max-key key)) + ([max-key-atom key] + (when (and key (or (nil? @max-key-atom) + (> (compare key @max-key-atom) 0))) + (reset! max-key-atom key)))) + +(defn gen-key + ([] + (gen-key @*max-key nil)) + ([end] + (gen-key @*max-key end)) + ([start end & {:keys [max-key-atom] + :or {max-key-atom *max-key}}] + (let [k (index/generate-key-between start end)] + (reset-max-key! max-key-atom k) + k))) + +(defn get-max-order + [db] + (:v (first (d/rseek-datoms db :avet :block/order)))) + +(defn gen-n-keys + [n start end & {:keys [max-key-atom] + :or {max-key-atom *max-key}}] + (let [ks (index/generate-n-keys-between start end n)] + (reset-max-key! max-key-atom (last ks)) + ks)) + +(defn validate-order-key? + [key] + (index/validate-order-key key index/base-62-digits) + true) + +(defn get-prev-order + [db property value-id] + (let [value (d/entity db value-id)] + (if property + (let [values (->> (:property/closed-values property) + reverse)] + (some (fn [e] + (when (and (< (compare (:block/order e) (:block/order value)) 0) + (not= (:db/id e) (:db/id value))) + (:block/order e))) values)) + (let [properties (->> (d/datoms db :avet :block/tags :logseq.class/Property) + (map (fn [d] (d/entity db (:e d)))) + (sort-by :block/order) + reverse)] + (some (fn [property] + (when (and (< (compare (:block/order property) (:block/order value)) 0) + (not= (:db/id property) (:db/id value))) + (:block/order property))) properties))))) + +(defn get-next-order + [db property value-id] + (let [value (d/entity db value-id)] + (if property + (let [values (:property/closed-values property)] + (some (fn [e] + (when (and (> (compare (:block/order e) (:block/order value)) 0) + (not= (:db/id e) (:db/id value))) + (:block/order e))) values)) + (let [properties (->> (d/datoms db :avet :block/tags :logseq.class/Property) + (map (fn [d] (d/entity db (:e d)))) + (sort-by :block/order))] + (some (fn [property] + (when (and (> (compare (:block/order property) (:block/order value)) 0) + (not= (:db/id property) (:db/id value))) + (:block/order property))) properties))))) diff --git a/deps/db/src/logseq/db/common/reference.cljs b/deps/db/src/logseq/db/common/reference.cljs new file mode 100644 index 0000000..eb125ad --- /dev/null +++ b/deps/db/src/logseq/db/common/reference.cljs @@ -0,0 +1,326 @@ +(ns logseq.db.common.reference + "References" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.entity-util :as entity-util])) + +;; ----------------------------------------------------------------------------- +;; Helpers (fast datoms access) +;; ----------------------------------------------------------------------------- + +(defn- has-datom? [db e a v] + (boolean (seq (d/datoms db :eavt e a v)))) + +(defn- entid + "Normalize a datom value into an entity id. + In DataScript, ref values are often raw entids (numbers), not maps." + [v] + (cond + (nil? v) nil + (number? v) v + (and (map? v) (contains? v :db/id)) (:db/id v) + :else v)) + +(defn- datom-v + "Return the first value for attr a on entity e, normalized to entid." + [db e a] + (some-> (first (d/datoms db :eavt e a)) + :v + entid)) + +(defn- datom-vs + "Return all values for attr a on entity e, normalized to entids, as a set." + [db e a] + (into #{} + (comp (map :v) (map entid)) + (d/datoms db :eavt e a))) + +(defn- get-path-refs + [db entity] + (let [refs (mapcat :block/refs (ldb/get-block-parents db (:block/uuid entity))) + block-page (:block/page entity)] + (->> (cond->> refs (some? block-page) (cons block-page)) + distinct))) + +(defn- get-ref-pages-count + [db id ref-blocks children-ids] + (when (seq ref-blocks) + (let [children (->> children-ids + (map (fn [id] (d/entity db id)))) + hidden-ref?* (common-initial-data/hidden-ref-pred db id)] + (->> (concat (mapcat #(get-path-refs db %) ref-blocks) + (mapcat :block/refs (concat ref-blocks children))) + frequencies + (keep (fn [[ref size]] + (when (and (ldb/page? ref) + (not= (:db/id ref) id) + (not= :block/tags (:db/ident ref)) + (not (hidden-ref?* ref))) + [(:block/title ref) size]))) + (sort-by second #(> %1 %2)))))) + +(defn- child-ids + "Direct children via AVET on :block/parent." + [db parent-eid] + (into #{} (map :e) (d/datoms db :avet :block/parent parent-eid))) + +(defn- own-refs + "Refs contributed by this block itself: + - direct :block/refs + - implicit :block/page as a ref" + [db eid] + (let [direct (datom-vs db eid :block/refs) + page (datom-v db eid :block/page)] + (cond-> direct + page (conj page)))) + +(defn- effective-refs-fn + "effective-refs(eid) = own-refs(eid) ∪ effective-refs(parent(eid))" + [db] + (let [memo (volatile! {})] + (letfn [(eff [eid] + (if (contains? @memo eid) + (get @memo eid) + (let [own (own-refs db eid) + res (if-let [p (datom-v db eid :block/parent)] + (into own (eff p)) + own)] + (vswap! memo assoc eid res) + res)))] + eff))) + +(defn- allowed-subtree-refs-fn + "Like subtree-refs-fn but PRUNES branches that are under an excluded ref. + This implements: + - parent should not be excluded just because a child has an excluded ref + - BUT refs (e.g. [[bar]]) that appear under an excluded block (e.g. [[baz]]) + should NOT satisfy includes for ancestors. + + Concretely: if (effective-refs node) contains any excludes, this node and its + descendants contribute NOTHING to the include reachability set." + [db eff excludes] + (let [memo (volatile! {})] + (letfn [(blocked? [eid] + (and (seq excludes) + (some #(contains? (eff eid) %) excludes))) + (sub [eid] + (if (contains? @memo eid) + (get @memo eid) + (let [res (if (blocked? eid) + #{} + (reduce (fn [acc c] (into acc (sub c))) + (own-refs db eid) + (child-ids db eid)))] + (vswap! memo assoc eid res) + res)))] + sub))) + +(defn- matches-filters? + "Include semantics: AND (must all be present) against include-set. + Exclude semantics: NONE (must not contain any) against exclude-set." + [include-set exclude-set includes excludes] + (and + (or (empty? includes) + (every? #(contains? include-set %) includes)) + (or (empty? excludes) + (not (some #(contains? exclude-set %) excludes))))) + +(defn- filter-matched-ref-blocks + [db top-ref-block-ids includes excludes {:keys [eff class-ok? can-satisfy-includes? allowed-subrefs]}] + (loop [stack (vec top-ref-block-ids) + visited #{} + out #{}] + (if (empty? stack) + out + (let [eid (peek stack) + stack (pop stack)] + (if (contains? visited eid) + (recur stack visited out) + (let [visited (conj visited eid) + eff-refs (eff eid)] + (cond + ;; don't match this node, but still traverse + (not (class-ok? eid)) + (recur (into stack (child-ids db eid)) visited out) + + ;; prune: AND includes cannot be satisfied anywhere below + (not (can-satisfy-includes? eff-refs eid)) + (recur stack visited out) + + :else + (let [include-set (into eff-refs (allowed-subrefs eid)) + exclude-set eff-refs + out' (if (matches-filters? include-set exclude-set includes excludes) + (conj out eid) + out)] + (recur (into stack (child-ids db eid)) visited out'))))))))) + +(defn- matched-ref-block-ids-under-top + "Return the set of block ids (top or child) that match filters. + + Semantics (matches your example): + - Includes (AND) may be satisfied by descendants, BUT only from descendants + that are NOT under an excluded ref. + Example: + - [[foo]] + - [[baz]] + - [[bar]] + With include=bar, exclude=baz => foo should NOT match because bar is under baz. + + - Excludes are checked ONLY against effective-refs(node) (self + parents), + so a parent isn't excluded just because a child mentions an excluded page. + + Pruning: + - If includes cannot be satisfied anywhere below (effective + allowed-subtree), + prune subtree." + [db top-ref-block-ids includes excludes class-ids] + (let [eff (effective-refs-fn db) + ;; allowed subtree refs for includes (prunes excluded branches) + allowed-subrefs (allowed-subtree-refs-fn db eff (->> excludes (remove nil?) vec)) + includes (->> includes (remove nil?) vec) + excludes (->> excludes (remove nil?) vec) + class-ids (when (seq class-ids) (->> class-ids (remove nil?) vec)) + + can-satisfy-includes? (fn [eff-refs node] + (or (empty? includes) + (let [possible (into eff-refs (allowed-subrefs node))] + (every? #(contains? possible %) includes)))) + + class-ok? (fn [eid] + (or (empty? class-ids) + (not (some #(has-datom? db eid :block/tags %) class-ids))))] + + (filter-matched-ref-blocks db top-ref-block-ids includes excludes + {:eff eff + :class-ok? class-ok? + :can-satisfy-includes? can-satisfy-includes? + :allowed-subrefs allowed-subrefs}))) + +;; ----------------------------------------------------------------------------- +;; Expand matched refs up to top refs +;; ----------------------------------------------------------------------------- + +(defn- expand-to-top-refs + "Given matched refs, add ancestors until reaching a top ref in `top-ref-ids`. + Uses cached parent lookups (datoms only)." + [db top-ref-ids matched-ref-ids] + (let [parent-cache (volatile! {}) ;; eid -> parent-eid|nil + result (volatile! #{}) + top? (fn [eid] (contains? top-ref-ids eid)) + parent-of (fn [eid] + (if (contains? @parent-cache eid) + (get @parent-cache eid) + (let [p (some-> (first (d/datoms db :eavt eid :block/parent)) + :v + entid)] + (vswap! parent-cache assoc eid p) + p)))] + (doseq [start matched-ref-ids] + (loop [eid start] + (when eid + (cond + (contains? @result eid) + nil + (top? eid) + (vswap! result conj eid) + :else + (do + (vswap! result conj eid) + (recur (parent-of eid))))))) + @result)) + +;; ----------------------------------------------------------------------------- +;; Public API +;; ----------------------------------------------------------------------------- + +(defn get-filters + [page] + (let [included-pages (:logseq.property.linked-references/includes page) + excluded-pages (:logseq.property.linked-references/excludes page)] + (when (or (seq included-pages) (seq excluded-pages)) + {:included included-pages + :excluded excluded-pages}))) + +(defn- linked-reference-top-block-ids + [db ids class-ids] + (->> ids + (mapcat (fn [pid] (:block/_refs (d/entity db pid)))) + (remove (fn [ref] + (or + (when class-ids + (some class-ids (map :db/id (:block/tags ref)))) + (entity-util/hidden? ref) + (entity-util/hidden? (:block/page ref))))) + (map :db/id) + set)) + +(defn- linked-reference-result + [db id include-ref-pages-count? has-filters? ref-blocks children-ids] + (cond-> + {:ref-blocks ref-blocks + :ref-matched-children-ids (when has-filters? children-ids)} + include-ref-pages-count? + (assoc :ref-pages-count (get-ref-pages-count db id ref-blocks children-ids)))) + +(defn get-linked-references + ([db id] + (get-linked-references db id {:include-ref-pages-count? true})) + ([db id {:keys [include-ref-pages-count?] + :or {include-ref-pages-count? true}}] + (let [entity (d/entity db id) + ids (set (cons id (ldb/get-block-alias db id))) + page-filters (get-filters entity) + excludes (map :db/id (:excluded page-filters)) + includes (map :db/id (:included page-filters)) + has-filters? (or (seq excludes) (seq includes)) + + class-ids (when (ldb/class? entity) + (let [class-children (db-class/get-structured-children db id)] + (set (conj class-children id)))) + ;; Collect all top ref blocks that directly reference the page (or any alias). + full-ref-block-ids (linked-reference-top-block-ids db ids class-ids) + ;; matched can be top or child ids + matched-ref-block-ids + (when has-filters? + (matched-ref-block-ids-under-top db full-ref-block-ids includes excludes class-ids)) + ;; Expand matches up to top refs so we can show parent chains and matched children. + matched-refs-with-children-ids + (when has-filters? + (expand-to-top-refs db full-ref-block-ids matched-ref-block-ids)) + final-ref-ids + (if has-filters? + (set/intersection full-ref-block-ids matched-refs-with-children-ids) + full-ref-block-ids) + ;; Materialize only at the end. + ref-blocks (map #(d/entity db %) final-ref-ids) + children-ids + (if has-filters? + (set (remove full-ref-block-ids matched-refs-with-children-ids)) + (when include-ref-pages-count? + (->> ref-blocks + (mapcat (fn [ref] (ldb/get-block-children-ids db (:db/id ref)))) + set)))] + (linked-reference-result db id include-ref-pages-count? has-filters? ref-blocks children-ids)))) + +(defn get-unlinked-references + [db id] + (let [entity (d/entity db id) + title (string/lower-case (:block/title entity))] + (when-not (string/blank? title) + (let [ids (->> (d/datoms db :avet :block/title) + (keep (fn [d] + (when (and (not= id (:e d)) + (string/includes? (string/lower-case (:v d)) title)) + (:e d)))))] + (keep + (fn [eid] + (let [e (d/entity db eid)] + (when-not (or (some #(= id %) (map :db/id (:block/refs e))) + (:block/link e) + (ldb/built-in? e)) + e))) + ids))))) diff --git a/deps/db/src/logseq/db/common/sqlite.cljs b/deps/db/src/logseq/db/common/sqlite.cljs new file mode 100644 index 0000000..3c658ce --- /dev/null +++ b/deps/db/src/logseq/db/common/sqlite.cljs @@ -0,0 +1,38 @@ +(ns logseq.db.common.sqlite + "Provides common sqlite util fns that work on browser and node" + (:require ["path" :as node-path] + [clojure.string :as string] + [datascript.core :as d] + [logseq.common.graph-dir :as graph-dir] + [logseq.common.path :as path] + [logseq.db.sqlite.util :as sqlite-util])) + +(defn create-kvs-table! + "Creates a sqlite table for use with datascript.storage if one doesn't exist" + [sqlite-db] + (.exec sqlite-db "create table if not exists kvs (addr INTEGER primary key, content TEXT, addresses JSON)")) + +(defn get-storage-conn + "Given a datascript storage, returns a datascript connection for it" + [storage schema] + (or (d/restore-conn storage) + (d/create-conn schema {:storage storage}))) + +(defn sanitize-db-name + [db-name] + (-> db-name + (string/replace sqlite-util/db-version-prefix "") + (string/replace "/" "_") + (string/replace "\\" "_") + (string/replace ":" "_")));; windows + +(defn get-db-full-path + [graphs-dir db-name] + (let [graph-dir-name (graph-dir/repo->encoded-graph-dir-name db-name) + graph-dir (node-path/join graphs-dir graph-dir-name)] + [graph-dir-name (path/path-join graph-dir "db.sqlite")])) + +(defn get-db-backups-path + [graphs-dir db-name] + (let [graph-dir-name (graph-dir/repo->encoded-graph-dir-name db-name)] + (path/path-join graphs-dir graph-dir-name "backups"))) diff --git a/deps/db/src/logseq/db/common/sqlite_cli.cljs b/deps/db/src/logseq/db/common/sqlite_cli.cljs new file mode 100644 index 0000000..42a4806 --- /dev/null +++ b/deps/db/src/logseq/db/common/sqlite_cli.cljs @@ -0,0 +1,103 @@ +(ns ^:node-only logseq.db.common.sqlite-cli + "Primary ns to interact with DB files with node.js based CLIs" + (:require ["better-sqlite3" :as sqlite3] + ["path" :as node-path] + [cljs-bean.core :as bean] + [clojure.string :as string] + [datascript.storage :refer [IStorage]] + [logseq.common.graph :as common-graph] + [logseq.db.common.sqlite :as common-sqlite] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.sqlite.util :as sqlite-util])) + +;; Reference same sqlite default class in cljs + nbb without needing .cljc +(def sqlite (if (find-ns 'nbb.core) (aget sqlite3 "default") sqlite3)) + +(defn query + "Run a sql query against the given better-sqlite3 db" + [^js db sql] + (let [stmt (.prepare db sql)] + (.all ^object stmt))) + +(defn- upsert-addr-content! + "Upsert addr+data-seq. Should be functionally equivalent to db-worker/upsert-addr-content!" + [^js db data] + (assert db ::upsert-addr-content!) + (let [insert (.prepare db "INSERT INTO kvs (addr, content, addresses) values ($addr, $content, $addresses) on conflict(addr) do update set content = $content, addresses = $addresses") + insert-many (.transaction ^object db + (fn [data] + (doseq [item data] + (.run ^object insert item))))] + (insert-many data))) + +(defn- restore-data-from-addr + "Should be functionally equivalent to db-worker/restore-data-from-addr" + [db addr] + (when-let [result (-> (query db (str "select content, addresses from kvs where addr = " addr)) + first)] + (let [{:keys [content addresses]} (bean/->clj result) + addresses (when addresses + (js/JSON.parse addresses)) + data (sqlite-util/read-transit-str content)] + (if (and addresses (map? data)) + (assoc data :addresses addresses) + data)))) + +(defn new-sqlite-storage + "Creates a datascript storage for sqlite. Should be functionally equivalent to db-worker/new-sqlite-storage" + [db] + (reify IStorage + (-store [_ addr+data-seq _delete-addrs] + ;; Only difference from db-worker impl is that js data maps don't start with '$' e.g. :$addr -> :addr + (let [data (map + (fn [[addr data]] + (let [data' (if (map? data) (dissoc data :addresses) data) + addresses (when (map? data) + (when-let [addresses (:addresses data)] + (js/JSON.stringify (bean/->js addresses))))] + #js {:addr addr + :content (sqlite-util/write-transit-str data') + :addresses addresses})) + addr+data-seq)] + (upsert-addr-content! db data))) + (-restore [_ addr] + (restore-data-from-addr db addr)))) + +(defn open-sqlite-datascript! + "Returns a map including `conn` for datascript connection and `sqlite` for sqlite connection" + ([db-full-path] + (open-sqlite-datascript! nil db-full-path)) + ([graphs-dir db-name] + (let [db-full-path + (if (nil? graphs-dir) + db-name + (second (common-sqlite/get-db-full-path graphs-dir db-name))) + db (new sqlite db-full-path nil)] + (common-sqlite/create-kvs-table! db) + (let [storage (new-sqlite-storage db) + conn (common-sqlite/get-storage-conn storage db-schema/schema)] + {:sqlite db + :conn conn})))) + +(defn open-db! + "For a given database name, opens a sqlite db connection for it, creates + needed sqlite tables if not created and returns a datascript connection that's + connected to the sqlite db" + ([db-full-path] + (open-db! nil db-full-path)) + ([graphs-dir db-name] + (:conn (open-sqlite-datascript! graphs-dir db-name)))) + +(defn ->open-db-args + "Creates args for open-db from a graph arg. Works for relative and absolute paths and + defaults to ~/logseq/graphs/ when no '/' present in name" + [graph-dir-or-path] + ;; Pass full path directly to allow for paths that don't have standard graph naming convention + (if (node-path/isAbsolute graph-dir-or-path) + [graph-dir-or-path] + (if (string/includes? graph-dir-or-path "/") + (let [resolve-path' #(if (node-path/isAbsolute %) % + ;; $ORIGINAL_PWD used by bb tasks to correct current dir + (node-path/join (or js/process.env.ORIGINAL_PWD ".") %))] + ((juxt node-path/dirname node-path/basename) (resolve-path' graph-dir-or-path))) + [(common-graph/expand-home (common-graph/get-default-graphs-dir)) graph-dir-or-path]))) diff --git a/deps/db/src/logseq/db/common/view.cljs b/deps/db/src/logseq/db/common/view.cljs new file mode 100644 index 0000000..b609650 --- /dev/null +++ b/deps/db/src/logseq/db/common/view.cljs @@ -0,0 +1,724 @@ +(ns logseq.db.common.view + "Main namespace for view fns." + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.common.util :as common-util] + [logseq.db :as ldb] + [logseq.db.common.entity-plus :as entity-plus] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.common.reference :as db-reference] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.db.frontend.rules :as rules])) + +(def valid-type-for-sort? (some-fn number? string? boolean?)) + +(defn get-property-value-for-search + [block property] + (let [v (get block (:db/ident property))] + (if (valid-type-for-sort? v) ;fast path + v + (let [typ (:logseq.property/type property) + many? (keyword-identical? :db.cardinality/many (get property :db/cardinality)) + number-type? (or (keyword-identical? :number typ) + (keyword-identical? :datetime typ))] + (if many? + (let [col (->> (if (db-property-type/all-ref-property-types typ) (map db-property/property-value-content v) v) + (remove nil?))] + (if number-type? + (reduce + (filter number? col)) + (string/join ", " col))) + (let [v' (if (db-property-type/all-ref-property-types typ) (db-property/property-value-content v) v)] + (cond + (and number-type? (number? v')) v' + :else v'))))))) + +(defn- get-value-for-sort + [property] + (let [db-ident (or (:db/ident property) (:id property)) + closed-values (seq (:property/closed-values property)) + closed-value->sort-number (when closed-values + (->> (zipmap (map :db/id closed-values) + (if (every? :block/order closed-values) + (map :block/order closed-values) + (range 0 (count closed-values)))) + (into {}))) + get-property-value-fn (fn [entity] + (if (de/entity? property) + (if (= :date (:logseq.property/type property)) + (:block/journal-day (get entity db-ident)) + (get-property-value-for-search entity property)) + (get entity db-ident)))] + (fn [entity] + (cond + closed-values + (closed-value->sort-number (:db/id (get entity db-ident))) + :else + (let [v (get-property-value-fn entity)] + (when (valid-type-for-sort? v) + v)))))) + +(defn- by-one-sorting + [{:keys [asc? get-value]}] + (let [cmp (if asc? compare #(compare %2 %1))] + (fn [a b] + (cmp (get-value a) (get-value b))))) + +(defn- sort-ref-entities-by-single-property + "get all entities sorted by `major-sorting`" + [entities {:keys [_id asc?]} get-value-fn] + (let [sorting {:asc? asc? + :get-value get-value-fn} + sort-cmp (by-one-sorting sorting)] + (sort sort-cmp entities))) + +(defn- sort-by-single-property + [db {:keys [id asc?] :as sorting} entities partition?] + (let [property (or (d/entity db id) {:db/ident id}) + get-value-fn (memoize (get-value-for-sort property)) + entities' (if (vector? entities) entities (vec entities)) + datom-sort-supported? (contains? #{:block/updated-at :block/created-at :block/title} + (:db/ident property)) + use-datom-sort? (and datom-sort-supported? + (not= :db.type/ref (:db/valueType property)) + (> (count entities') 10000)) + sorted-entities (->> + (cond + (= id :block.temp/refs-count) + (cond-> (sort-by :block.temp/refs-count entities') + (not asc?) + reverse) + + use-datom-sort? + (let [datoms (cond-> + (->> (d/datoms db :avet id) + (common-util/distinct-by :e) + vec) + (not asc?) + rseq) + row-ids (set (map :db/id entities')) + id->row (zipmap (map :db/id entities') entities')] + (keep + (fn [d] + (when (row-ids (:e d)) + (id->row (:e d)))) + datoms)) + + :else + (sort-ref-entities-by-single-property entities' sorting get-value-fn)) + + distinct)] + (if partition? + (partition-by get-value-fn sorted-entities) + sorted-entities))) + +(defn- sort-entities-by-minor-sorting + "minor-sorting - [{:keys [id asc?]} ...]" + [db partitioned-entities-by-major-sorting minor-sorting] + (let [sorting + (map (fn [{:keys [id asc?]}] + (let [property (d/entity db id)] + {:asc? asc? + :get-value (memoize (get-value-for-sort property))})) + minor-sorting) + sort-cmp (common-util/by-sorting sorting)] + (mapcat (fn [entities] (sort sort-cmp entities)) partitioned-entities-by-major-sorting))) + +(defn sort-entities + [db sorting entities] + (let [major-sorting (or (first sorting) + {:id :block/updated-at :asc? false}) + minor-sorting (seq (rest sorting)) + major-sorted-entities + (sort-by-single-property db major-sorting entities (not-empty minor-sorting))] + (if minor-sorting + (sort-entities-by-minor-sorting db major-sorted-entities minor-sorting) + major-sorted-entities))) + +(defn get-property-value-content + [db value] + (when value + (cond + (uuid? value) + (db-property/property-value-content (d/entity db [:block/uuid value])) + (de/entity? value) + (db-property/property-value-content value) + (keyword? value) + (str value) + :else + value))) + +(defn- match-property-value-as-entity? + "Determines if the property value entity should be treated as an entity. For some property types + like :default, we want match on the entity's content as that is what the user sees and interacts with" + [property-value-entity property-entity] + ;; Allow pvalue entities with :db/ident e.g. closed values like status OR for any type + ;; that aren't text types + (or (:db/ident property-value-entity) + (not (contains? db-property-type/closed-value-property-types (:logseq.property/type property-entity))))) + +(defn- empty-value? + [v] + (or (nil? v) + (= :logseq.property/empty-placeholder v) + (and (string? v) (string/blank? v)) + (and (coll? v) (empty? v)))) + +(defn- ^:large-vars/cleanup-todo row-matched? + [db row filters input] + (let [or? (:or? filters) + check-f (if or? some every?)] + (and + (if (string/blank? input) + true + (string/includes? (string/lower-case (:block/title row)) (string/lower-case input))) + (check-f + (fn [[property-ident operator match]] + (if (nil? match) + true + (boolean + (let [value (get row property-ident) + value' (cond + (set? value) value + (nil? value) nil + :else #{value}) + entity? (de/entity? (first value')) + result + (case operator + :is + (cond + (boolean? match) + (= (boolean (get-property-value-content db (get row property-ident))) match) + (= :empty match) + (empty-value? value) + (empty? match) + true + (and (empty? match) (empty? value')) + true + :else + (if entity? + (let [property (d/entity db property-ident)] + (if (match-property-value-as-entity? (first value') property) + (boolean (some match (map :block/uuid value'))) + (boolean (seq (set/intersection (set (map db-property/property-value-content value')) + (set (map (comp db-property/property-value-content #(d/entity db [:block/uuid %])) + match))))))) + (boolean (some match value')))) + + :is-not + (cond + (boolean? match) + (not= (boolean (get-property-value-content db (get row property-ident))) match) + (= :empty match) + (not (empty-value? value)) + (and (empty? match) (seq value')) + true + (and (seq match) (empty? value')) + true + :else + (if entity? + (let [property (d/entity db property-ident)] + (if (match-property-value-as-entity? (first value') property) + (not (some match (map :block/uuid value'))) + (empty? (set/intersection (set (map db-property/property-value-content value')) + (set (map (comp db-property/property-value-content #(d/entity db [:block/uuid %])) + match)))))) + (not (some match value')))) + + :text-contains + (some (fn [v] + (if-let [property-value (get-property-value-content db v)] + (string/includes? (string/lower-case property-value) (string/lower-case match)) + false)) + value') + + :text-not-contains + (not-any? #(string/includes? (str (get-property-value-content db %)) match) value') + + :number-gt + (when value + (if match (some #(> (get-property-value-content db %) match) value') true)) + :number-gte + (when value + (if match (some #(>= (get-property-value-content db %) match) value') true)) + :number-lt + (when value + (if match (some #(< (get-property-value-content db %) match) value') true)) + :number-lte + (when value + (if match (some #(<= (get-property-value-content db %) match) value') true)) + + :between + (if (seq match) + (some (fn [value-entity] + (let [[start end] match + value (get-property-value-content db value-entity) + conditions [(if start (<= start value) true) + (if end (<= value end) true)]] + (if (seq match) (every? true? conditions) true))) value') + true) + + :date-before + (when value + (if match (some #(< (:block/journal-day %) (:block/journal-day match)) value') true)) + + :date-after + (when value + (if match (some #(> (:block/journal-day %) (:block/journal-day match)) value') true)) + + :before + (when value + (let [search-value (common-util/get-timestamp match)] + (if search-value (<= value search-value) true))) + + :after + (when value + (let [search-value (common-util/get-timestamp match)] + (if search-value (>= value search-value) true))) + + true)] + result)))) + (:filters filters))))) + +(defn- ->filter-match-id + [db v] + (cond + (nil? v) nil + (number? v) v + (uuid? v) (some-> (d/entity db [:block/uuid v]) :db/id) + (de/entity? v) (:db/id v) + (and (map? v) (contains? v :db/id)) (:db/id v) + :else nil)) + +(defn- build-fast-filter-pred + "Build a faster matcher for common filter shapes while preserving semantics. + Currently optimized for a single ref property filter with :is/:is-not." + [db filters input] + (when (and (string/blank? input) + (map? filters) + (not (:or? filters))) + (let [clauses (:filters filters)] + (when (= 1 (count clauses)) + (let [[property-ident operator match] (first clauses) + property (d/entity db property-ident) + ref-property? (= :db.type/ref (:db/valueType property))] + (when (and ref-property? + (#{:is :is-not} operator) + (set? match) + (seq match) + (not (contains? match :empty))) + (let [match-ids (set (keep #(->filter-match-id db %) match))] + (when (seq match-ids) + (fn [row] + (let [v (get row property-ident) + value-col (cond + (set? v) v + (nil? v) nil + :else #{v}) + hit? (boolean (some match-ids (keep #(->filter-match-id db %) value-col)))] + (if (= operator :is) hit? (not hit?)))))))))))) + +(defn- get-exclude-page-ids + [db] + (let [property-tag-id (:db/id (d/entity db :logseq.class/Property))] + (persistent! + (reduce (fn [result d] + (conj! result (:e d))) + (transient #{}) + (concat + (d/datoms db :avet :logseq.property/hide? true) + (d/datoms db :avet :logseq.property/deleted-at) + (d/datoms db :avet :logseq.property/built-in? true) + (d/datoms db :avet :block/tags property-tag-id)))))) + +(defn- get-entities-for-all-pages [db sorting property-ident] + (let [refs-count? (and (coll? sorting) (some (fn [m] (= (:id m) :block.temp/refs-count)) sorting)) + exclude-ids (get-exclude-page-ids db)] + (persistent! + (reduce (fn [result d] + (let [eid (:e d)] + (if (contains? exclude-ids eid) + result + (let [e (entity-plus/unsafe->Entity db eid)] + (if (entity-util/hidden? e) + result + (conj! result + (cond-> e + refs-count? + (assoc :block.temp/refs-count (common-initial-data/get-block-refs-count db eid))))))))) + (transient []) + (d/datoms db :avet property-ident))))) + +(def ^:private fast-all-pages-sort-ids + "Sort ids supported by a datom-order fast path" + #{:block/updated-at :block/created-at :block/title :block/name}) + +(defn- get-all-page-ids-fast + "Fast path for all-pages where only sorted page ids are needed. Avoids + hydrating every page entity by deriving ordering from indexed datoms." + [db sorting] + (let [major-sorting (or (first sorting) + {:id :block/updated-at :asc? false}) + minor-sorting (seq (rest sorting))] + (when (and (empty? minor-sorting) + (contains? fast-all-pages-sort-ids (:id major-sorting))) + (let [exclude-ids (get-exclude-page-ids db) + page-ids (persistent! + (reduce (fn [result datom] + (let [eid (:e datom)] + (if (contains? exclude-ids eid) + result + (conj! result eid)))) + (transient []) + (d/datoms db :avet :block/name))) + sort-id (:id major-sorting) + asc? (:asc? major-sorting) + get-sort-value (memoize + (fn [eid] + (get (entity-plus/unsafe->Entity db eid) sort-id))) + cmp (fn [eid-a eid-b] + (let [va (get-sort-value eid-a) + vb (get-sort-value eid-b) + c (cond + (and (nil? va) (nil? vb)) 0 + (nil? va) 1 + (nil? vb) -1 + :else (compare va vb))] + (if asc? c (- c))))] + (sort cmp page-ids))))) + +(defn- get-entities + [db view feat-type property-ident view-for-id* sorting {:keys [include-ref-pages-count?] + :or {include-ref-pages-count? true}}] + (let [view-for (:logseq.property/view-for view) + view-for-id (or (:db/id view-for) view-for-id*) + non-hidden-e (fn [id] (let [e (d/entity db id)] + (when-not (entity-util/hidden? e) + e)))] + (case feat-type + :all-pages + (get-entities-for-all-pages db sorting property-ident) + + :class-objects + (db-class/get-class-objects db view-for-id) + + :property-objects + (->> + (d/q + '[:find [?b ...] + :in $ % ?prop + :where + (has-property-or-object-property? ?b ?prop)] + db + (rules/extract-rules rules/db-query-dsl-rules [:has-property-or-object-property] + {:deps rules/rules-dependencies}) + property-ident) + (keep (fn [id] (non-hidden-e id)))) + + :linked-references + (db-reference/get-linked-references db view-for-id + {:include-ref-pages-count? + (not (false? include-ref-pages-count?))}) + + :unlinked-references + (db-reference/get-unlinked-references db view-for-id) + + :query-result + nil + + nil))) + +(defn- get-view-entities + [db view-id & {:keys [view-for-id view-feature-type sorting include-ref-pages-count?]}] + (let [view (d/entity db view-id) + feat-type (or view-feature-type (:logseq.property.view/feature-type view)) + sorting (or sorting (:logseq.property.table/sorting view)) + index-attr (case feat-type + :all-pages + :block/name + :class-objects + :block/tags + :property-objects + (let [view-for (:logseq.property/view-for view)] + (:db/ident view-for)) + nil)] + (get-entities db view feat-type index-attr view-for-id sorting + (cond-> {} + (some? include-ref-pages-count?) + (assoc :include-ref-pages-count? include-ref-pages-count?))))) + +(defn- get-view-property-values + [db property-ident {:keys [view-id query-entity-ids]}] + (let [empty-id (:db/id (d/entity db :logseq.property/empty-placeholder)) + entities-result (get-view-entities db view-id) + entities (cond + query-entity-ids + (keep #(d/entity db %) query-entity-ids) + (map? entities-result) + (:ref-blocks entities-result) + :else + entities-result)] + (->> (mapcat (fn [entity] + (let [v (get entity property-ident)] + (if (set? v) v #{v}))) + entities) + (remove nil?) + (keep (fn [e] + (when-not (and (de/entity? e) (entity-util/recycled? e)) + (when-let [label (get-property-value-content db e)] + (when-not (or (string/blank? (str label)) + (= empty-id (:db/id e))) + {:label (str label) + :value (if (de/entity? e) + (select-keys e [:db/id :block/uuid]) + e)}))))) + (common-util/distinct-by :label)))) + +(defn ^:api get-property-values + [db property-ident {:keys [view-id _query-entity-ids] :as option}] + (let [property (d/entity db property-ident) + default-value (:logseq.property/default-value property) + ref-type? (= :db.type/ref (:db/valueType property)) + values (if view-id + (get-view-property-values db property-ident option) + ;; get all values + (->> (d/datoms db :avet property-ident) + (map (fn [d] + (:v d))) + distinct + (keep (fn [v] + (let [e (when ref-type? (d/entity db v))] + (when-not (and ref-type? (entity-util/recycled? e)) + (let [[label value] (cond ref-type? + [(db-property/property-value-content e) + (select-keys e [:db/id :block/uuid])] + ;; FIXME: Move query concerns out of :label as UI labels are usually strings + ;; All non-string values need to be passed to the query builder since non-ref prop values use the actual value + ;; This check is less fragile than listing all the property types to support e.g. :datetime, :checkbox, :keyword, :any + (not (string? v)) + [v v] + :else + [(str v) v])] + {:label label + :value value})))))))] + (->> + (if (and default-value (not (entity-util/recycled? default-value))) + (cons {:label (get-property-value-content db default-value) + :value (select-keys default-value [:db/id :block/uuid])} + values) + values) + (common-util/distinct-by :label)))) + +(defn- get-query-properties + [query entities] + (let [properties (when (and (coll? query) (= :find (first query))) + (let [expr (second query)] + (when (= 'pull (first expr)) + (last expr))))] + (if (and (seq properties) (not= properties ['*])) + properties + (distinct (mapcat keys entities))))) + +(defn- linked-references-page-list-view-data + [view entities-result entities] + (let [groups-sort-by-property-ident (or (:db/ident (:logseq.property.view/sort-groups-by-property view)) + :block/journal-day) + desc? (:logseq.property.view/sort-groups-desc? view) + page-sort-value (fn [page] + (let [v (get page groups-sort-by-property-ident)] + (if (and (= groups-sort-by-property-ident :block/journal-day) + (not desc?) + (nil? (:block/journal-day page))) + js/Number.MAX_SAFE_INTEGER + v))) + page-sorters (cond-> + [{:get-value (fn [[page _blocks]] (page-sort-value page)) + :asc? (not desc?)}] + (not= groups-sort-by-property-ident :block/title) + (conj {:get-value (fn [[page _blocks]] (:block/title page)) + :asc? (not desc?)})) + sorted-page-groups (sort (common-util/by-sorting page-sorters) + (group-by #(or (:block/page %) %) entities)) + block-row (fn [block] + {:db/id (:db/id block) + :block/parent (:block/uuid (:block/parent block))}) + nested-group (fn [[_parent blocks]] + [(:block/uuid (first blocks)) + (map block-row (ldb/sort-by-order blocks))]) + data (map (fn [[page blocks]] + [(select-keys page [:db/id :db/ident :block/uuid :block/title :block/name + :logseq.property/value :logseq.property/icon :block/tags]) + (->> blocks + (group-by :block/parent) + (sort-by (fn [[parent _]] (:block/order parent))) + (map nested-group))]) + sorted-page-groups)] + (merge + {:count (count entities) + :data data} + (select-keys entities-result [:ref-pages-count :ref-matched-children-ids])))) + +(defn ^:api ^:large-vars/cleanup-todo get-view-data + [db view-id {:keys [journals? _view-for-id view-feature-type group-by-property-ident input query-entity-ids query filters sorting] + :as opts}] + ;; TODO: create a view for journals maybe? + (cond + journals? + (let [journals (vec (ldb/get-latest-journals db)) + index (mapv #(select-keys % [:db/id :block/journal-day]) journals)] + {:count (count index) + :data index}) + :else + (let [view (d/entity db view-id) + group-by-property (:logseq.property.view/group-by-property view) + list-view? (= :logseq.property.view/type.list (:db/ident (:logseq.property.view/type view))) + group-by-property-ident (or (:db/ident group-by-property) group-by-property-ident) + group-by-closed-values? (some? (:property/closed-values group-by-property)) + ref-property? (= (:db/valueType group-by-property) :db.type/ref) + filters (or (:logseq.property.table/filters view) filters) + feat-type (or view-feature-type (:logseq.property.view/feature-type view)) + query? (= feat-type :query-result) + query-entity-ids (when (seq query-entity-ids) (set query-entity-ids)) + sorting (let [sorting* (:logseq.property.table/sorting view)] + (if (or (= sorting* :logseq.property/empty-placeholder) (empty? sorting*)) + (or sorting [{:id :block/updated-at :asc? false}]) + sorting*)) + fast-all-pages-ids (when (and (= feat-type :all-pages) + (not query?) + (nil? group-by-property-ident) + (empty? filters) + (string/blank? input)) + (get-all-page-ids-fast db sorting))] + (if fast-all-pages-ids + {:count (count fast-all-pages-ids) + :data fast-all-pages-ids} + (let [entities-result (if query? + (keep (fn [id] + (let [e (d/entity db id)] + (when-not (= :logseq.property/query (:db/ident (:logseq.property/created-from-property e))) + e))) + query-entity-ids) + (get-view-entities db view-id opts)) + entities (if (= feat-type :linked-references) + (:ref-blocks entities-result) + entities-result) + filtered-entities (if (or (seq filters) (not (string/blank? input))) + (let [filter-pred (or (build-fast-filter-pred db filters input) + (fn [row] (row-matched? db row filters input)))] + (into [] (filter filter-pred) entities)) + entities) + nested-list-view? (and list-view? + (some :block/page filtered-entities)) + group-by-page? (= group-by-property-ident :block/page) + linked-references-page-list-fast-path? + (and (= feat-type :linked-references) + group-by-page? + list-view? + (empty? filters) + (string/blank? input)) + group-values + (fn group-values [ent] + (let [pvalue (get ent group-by-property-ident) + values (if (and (not (de/entity? pvalue)) + (coll? pvalue) + (not (map? pvalue))) + (seq pvalue) + [pvalue])] + (or + (seq + (map + (fn [value] + (if (de/entity? value) + (if (match-property-value-as-entity? value group-by-property) + value + (db-property/property-value-content value)) + value)) + values)) + [nil]))) + result (if linked-references-page-list-fast-path? + nil + (if group-by-property-ident + (let [groups-sort-by-property-ident (or (:db/ident (:logseq.property.view/sort-groups-by-property view)) + :block/journal-day) + desc? (:logseq.property.view/sort-groups-desc? view) + result (->> filtered-entities + (reduce (fn [groups ent] + (reduce + (fn [groups value] + (update groups value (fnil conj []) ent)) + groups + (group-values ent))) + {}) + (seq)) + keyfn (fn [groups-sort-by-property-ident] + (fn [[by-value _]] + (cond + group-by-page? + (let [v (get by-value groups-sort-by-property-ident)] + (if (and (= groups-sort-by-property-ident :block/journal-day) (not desc?) + (nil? (:block/journal-day by-value))) + ;; Use MAX_SAFE_INTEGER so non-journal pages (without :block/journal-day) are sorted + ;; after all journal pages when sorting by journal date. + js/Number.MAX_SAFE_INTEGER + v)) + group-by-closed-values? + (:block/order by-value) + ref-property? + ;; For value-ref types (e.g. :number), group-values has already + ;; extracted the scalar content, so by-value is no longer an entity. + ;; Only re-extract for entity group keys (e.g. :node/:class). + (if (de/entity? by-value) + (db-property/property-value-content by-value) + by-value) + :else + by-value)))] + (sort (common-util/by-sorting + (cond-> + [{:get-value (keyfn groups-sort-by-property-ident) + :asc? (not desc?)}] + (not= groups-sort-by-property-ident :block/title) + (conj {:get-value (keyfn :block/title) + :asc? (not desc?)}))) + result)) + (sort-entities db sorting filtered-entities))) + data' (if linked-references-page-list-fast-path? + nil + (if group-by-property-ident + (map + (fn [[by-value entities]] + (let [by-value' (if (de/entity? by-value) + (select-keys by-value [:db/id :db/ident :block/uuid :block/title :block/name :logseq.property/value :logseq.property/icon :block/tags]) + by-value) + group (if nested-list-view? + (let [parent-groups (->> entities + (group-by :block/parent) + (sort-by (fn [[parent _]] (:block/order parent))))] + (map + (fn [[_parent blocks]] + [(:block/uuid (first blocks)) + (map (fn [b] + {:db/id (:db/id b) + :block/parent (:block/uuid (:block/parent b))}) + (ldb/sort-by-order blocks))]) + parent-groups)) + (->> (sort-entities db sorting entities) + (map :db/id)))] + [by-value' group])) + result) + (map :db/id result))) + dedupe-data? (or (= feat-type :property-objects) query?)] + (if linked-references-page-list-fast-path? + (linked-references-page-list-view-data view entities-result entities) + (cond-> + {:count (count filtered-entities) + :data (if dedupe-data? + (distinct data') + data')} + (= feat-type :linked-references) + (merge (select-keys entities-result [:ref-pages-count :ref-matched-children-ids])) + query? + (assoc :properties (get-query-properties query entities-result))))))))) diff --git a/deps/db/src/logseq/db/frontend/asset.cljs b/deps/db/src/logseq/db/frontend/asset.cljs new file mode 100644 index 0000000..3ca6c62 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/asset.cljs @@ -0,0 +1,29 @@ +(ns logseq.db.frontend.asset + "Asset fns used in node and browser contexts" + (:require ["path" :as node-path] + [clojure.string :as string])) + +(defn- decode-digest + [^js/Uint8Array digest] + (.. (js/Array.from digest) + (map (fn [s] (.. s (toString 16) (padStart 2 "0")))) + (join ""))) + +(defn (js/crypto.subtle.digest "SHA-256" array-buffer) + (.then (fn [dig] (js/Uint8Array. dig))) + (.then decode-digest)))) + +(defn asset-path->type + "Create asset type given asset path" + [path] + (string/lower-case (.substr (node-path/extname path) 1))) + +(defn asset-name->title + "Create asset title given asset path's basename" + [path-basename] + (.-name (node-path/parse path-basename))) \ No newline at end of file diff --git a/deps/db/src/logseq/db/frontend/block_title.cljs b/deps/db/src/logseq/db/frontend/block_title.cljs new file mode 100644 index 0000000..6962c96 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/block_title.cljs @@ -0,0 +1,118 @@ +(ns logseq.db.frontend.block-title + "Shared block title formatting for DB graph entities." + (:require [clojure.string :as string] + [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.common.util.namespace :as ns-util] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.db :as db-db] + [logseq.db.frontend.entity-util :as entity-util])) + +(defn- resolve-entity + [db lookup-ref] + (when db + (d/entity db lookup-ref))) + +(defn- needs-resolved-block? + [block] + (or (nil? (:block/title block)) + (entity-util/class? block) + (some number? (:block/tags block)))) + +(defn- resolve-block + [db block] + (cond + (de/entity? block) + block + + (and (needs-resolved-block? block) + (number? (:db/id block))) + (or (resolve-entity db (:db/id block)) block) + + (and (needs-resolved-block? block) + (uuid? (:block/uuid block))) + (or (resolve-entity db [:block/uuid (:block/uuid block)]) block) + + :else + block)) + +(defn- class-title-conflicts? + [db class-entity] + (let [class-title (:block/title class-entity) + class-id (:db/id class-entity)] + (when (and db class-title class-id) + (boolean + (d/q '[:find ?other . + :in $ ?class-title ?class-id + :where + [?other :block/title ?class-title] + [?other :block/tags :logseq.class/Tag] + [(not= ?other ?class-id)] + (not [?other :logseq.property/deleted-at])] + db class-title class-id))))) + +(defn- class-title-with-extends + [class-entity title] + (let [extends (some->> (:logseq.property.class/extends class-entity) + (remove (fn [extend] + (or (:logseq.property/built-in? extend) + (= (:block/title class-entity) (:block/title extend))))) + vec)] + (if (seq extends) + (str (if (= 1 (count extends)) + (:block/title (first extends)) + (->> (take 2 extends) + (map :block/title) + (string/join " | "))) + ns-util/parent-char + title) + title))) + +(defn- resolve-tag + [db tag] + (if (number? tag) + (resolve-entity db tag) + tag)) + +(defn block-unique-title + "Return a display title that disambiguates duplicate class titles and appends + user-visible tags. + + `title` may be supplied when a caller has already prepared a display title, + such as a search snippet with highlight markers." + [db block {:keys [with-tags? alias truncate? title] + :or {with-tags? true + truncate? true}}] + (let [block-e (resolve-block db block)] + (if (entity-util/built-in? block-e) + (:block/title block-e) + (let [class? (entity-util/class? block-e) + tags (when (and with-tags? (not class?)) + (remove (fn [tag] + (or (some-> (:block/raw-title block-e) (db-db/inline-tag? tag)) + (db-class/private-tags (:db/ident tag)))) + (map #(resolve-tag db %) (or (:block/tags block) + (:block/tags block-e))))) + base-title (if class? + (let [display-title (or title (:block/title block-e))] + (if (class-title-conflicts? db block-e) + (class-title-with-extends block-e display-title) + display-title)) + (or title (:block/title block-e))) + trunc-title (if (and truncate? base-title (> (count base-title) 256)) + (subs base-title 0 256) + base-title) + title (if (seq tags) + (str (or trunc-title "") + " " + (string/join + ", " + (keep (fn [tag] + (when-let [title (:block/title tag)] + (str "#" title))) + tags))) + trunc-title)] + (when title + (str title + (when alias + (str " -> alias: " alias)))))))) diff --git a/deps/db/src/logseq/db/frontend/class.cljs b/deps/db/src/logseq/db/frontend/class.cljs new file mode 100644 index 0000000..6e9ad43 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/class.cljs @@ -0,0 +1,234 @@ +(ns logseq.db.frontend.class + "Class related fns for DB graphs and frontend/datascript usage" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [datascript.impl.entity :as de] + [flatland.ordered.map :refer [ordered-map]] + [logseq.common.defkeywords :refer [defkeywords]] + [logseq.db.frontend.db-ident :as db-ident] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.rules :as rules] + [logseq.db.sqlite.util :as sqlite-util])) + +;; Main class vars +;; =============== + +(def ^:large-vars/data-var built-in-classes + "Map of built-in classes for db graphs with their :db/ident as keys" + (apply + ordered-map + (defkeywords + :logseq.class/Root {:title "Root Tag"} + + :logseq.class/Tag {:title "Tag"} + + :logseq.class/Property {:title "Property"} + + :logseq.class/Page {:title "Page"} + + :logseq.class/Journal + {:title "Journal" + :properties {:logseq.property.class/extends :logseq.class/Page + :logseq.property.journal/title-format "MMM do, yyyy"}} + + ;; TODO: Remove deprecated + :logseq.class/Whiteboard + {:title "Whiteboard" + :properties {:logseq.property.class/extends :logseq.class/Page}} + + :logseq.class/Task + {:title "Task" + :schema {:properties [:logseq.property/status :logseq.property/priority :logseq.property/deadline :logseq.property/scheduled]}} + + :logseq.class/Comments + {:title "Comments" + :properties {:logseq.property.class/hide-from-node true + :logseq.property/icon {:type :tabler-icon, :id "message-circle"}} + :schema {:properties [:logseq.property.comments/blocks]}} + + :logseq.class/Comment + {:title "Comment" + :properties {:logseq.property.class/hide-from-node true}} + + :logseq.class/Query + {:title "Query" + :properties {:logseq.property/icon {:type :tabler-icon :id "search"}} + :schema {:properties [:logseq.property/query]}} + + :logseq.class/Card + {:title "Card" + :schema {:properties [:logseq.property.fsrs/state :logseq.property.fsrs/due]}} + + :logseq.class/Cards + {:title "Cards" + :properties {:logseq.property/icon {:type :tabler-icon :id "search"} + :logseq.property.class/extends :logseq.class/Query}} + + :logseq.class/Asset + {:title "Asset" + :properties {;; :logseq.property/icon {:type :tabler-icon :id "file"} + :logseq.property.class/hide-from-node true + :logseq.property.view/type :logseq.property.view/type.gallery} + :schema {:properties [:logseq.property.asset/type :logseq.property.asset/size :logseq.property.asset/checksum] + :required-properties [:logseq.property.asset/type :logseq.property.asset/size :logseq.property.asset/checksum]}} + + :logseq.class/Code-block + {:title "Code" + :properties {:logseq.property.class/hide-from-node true} + :schema {:properties [:logseq.property.node/display-type :logseq.property.code/lang]}} + + :logseq.class/Quote-block + {:title "Quote" + :properties {:logseq.property.class/hide-from-node true} + :schema {:properties [:logseq.property.node/display-type]}} + + :logseq.class/Math-block + {:title "Math" + :properties {:logseq.property.class/hide-from-node true} + :schema {:properties [:logseq.property.node/display-type]}} + + :logseq.class/Pdf-annotation + {:title "PDF Annotation" + :properties {:logseq.property.class/hide-from-node true} + :schema {:properties [:logseq.property/ls-type :logseq.property.pdf/hl-color :logseq.property/asset + :logseq.property.pdf/hl-page :logseq.property.pdf/hl-value + :logseq.property.pdf/hl-type :logseq.property.pdf/hl-image] + :required-properties [:logseq.property/ls-type :logseq.property.pdf/hl-color :logseq.property/asset + :logseq.property.pdf/hl-page :logseq.property.pdf/hl-value]}} + + :logseq.class/Template + {:title "Template" + :schema {:properties [:logseq.property/template-applied-to]}} + + ;; TODO: Add more classes such as :book, :paper, :movie, :music, :project) + ))) + +(def page-children-classes + "Children of :logseq.class/Page" + (set + (keep (fn [[class-ident m]] + (when (= (get-in m [:properties :logseq.property.class/extends]) :logseq.class/Page) class-ident)) + built-in-classes))) + +(def page-classes + "Built-in classes that behave like a page. Classes should match entity-util/page?" + (into #{:logseq.class/Page :logseq.class/Tag :logseq.class/Property} + page-children-classes)) + +(def internal-tags + "Built-in classes that are hidden on a node and all pages view" + #{:logseq.class/Page :logseq.class/Property :logseq.class/Tag :logseq.class/Root + :logseq.class/Asset}) + +(def private-tags + "Built-in classes that are private and should not be used by a user directly." + (set/union (disj internal-tags :logseq.class/Root) + #{:logseq.class/Journal :logseq.class/Whiteboard + :logseq.class/Pdf-annotation})) + +(def block-kind-tags + #{:logseq.class/Cards :logseq.class/Code-block + :logseq.class/Math-block :logseq.class/Quote-block + :logseq.class/Query :logseq.class/Pdf-annotation + :logseq.class/Template}) + +(def disallowed-inline-tags + "Classes that should be removed from inline tags" + (set/union page-classes + private-tags + block-kind-tags)) + +(def extends-hidden-tags + "Built-in classes that are hidden when choosing extends" + (set/union + private-tags + block-kind-tags)) + +(def hidden-tags + "Built-in classes that are hidden in a few contexts like property values" + #{:logseq.class/Page :logseq.class/Root :logseq.class/Asset}) + +;; Helper fns +;; ========== +(defn get-structured-children + "Returns all children of a class" + [db eid] + (->> + (d/q '[:find [?c ...] + :in $ ?p % + :where + (class-extends ?p ?c)] + db + eid + (:class-extends rules/rules)) + (remove #{eid}))) + +(defn get-class-extends + "Returns all extends of a class" + [class] + (assert (de/entity? class) "get-class-extends `class` should be an entity") + (loop [extends (:logseq.property.class/extends class) + result []] + (if (seq extends) + (recur (mapcat :logseq.property.class/extends extends) + (into result extends)) + (reverse (distinct result))))) + +(defn create-user-class-ident-from-name + "Creates a class :db/ident for a default user namespace. + NOTE: Only use this when creating a db-ident for a new class." + [db class-name & {:keys [ident-namespace]}] + (let [ident-ns (or ident-namespace "user.class") + db-ident (db-ident/create-db-ident-from-name ident-ns class-name)] + (if db + (db-ident/ensure-unique-db-ident db db-ident) + db-ident))) + +(defn build-new-class + "Builds a new class with a unique :db/ident. Also throws exception for user + facing messages when name is invalid" + [db page-m & {:as option}] + {:pre [(string? (:block/title page-m))]} + (let [db-ident (create-user-class-ident-from-name db (:block/title page-m) option)] + (sqlite-util/build-new-class (assoc page-m :db/ident db-ident)))) + +(defonce logseq-class "logseq.class") + +(defn logseq-class? + "Determines if keyword is a logseq class" + [kw] + (= logseq-class (namespace kw))) + +(defn user-class-namespace? + "Determines if namespace string is a user class" + [s] + (string/includes? s ".class")) + +(defn- hidden-class-object? + [e] + (if (entity-util/property? e) + (or (:logseq.property/deleted-at e) + (and (entity-util/built-in? e) + (not (:logseq.property/public? e)))) + (entity-util/hidden? e))) + +(defn get-class-objects + "Get class objects including children classes'" + [db class-id] + (let [class-children (get-structured-children db class-id) + class-ids (distinct (conj class-children class-id))] + (->> class-ids + (mapcat (fn [id] (d/datoms db :avet :block/tags id))) + (reduce (fn [[seen result] d] + (let [eid (:e d)] + (if (contains? seen eid) + [seen result] + (let [e (d/entity db eid) + seen' (conj seen eid)] + (if (hidden-class-object? e) + [seen' result] + [seen' (conj! result e)]))))) + [#{} (transient [])]) + second + persistent!))) diff --git a/deps/db/src/logseq/db/frontend/content.cljs b/deps/db/src/logseq/db/frontend/content.cljs new file mode 100644 index 0000000..94c2b4b --- /dev/null +++ b/deps/db/src/logseq/db/frontend/content.cljs @@ -0,0 +1,265 @@ +(ns logseq.db.frontend.content + "Fns to handle block content e.g. internal ids" + (:require [clojure.string :as string] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.common.util.page-ref :as page-ref] + [logseq.db.frontend.entity-util :as entity-util])) + +;; [[uuid]] +(def id-ref-pattern + (re-pattern + (str + "\\[\\[" + "(" + common-util/uuid-pattern + ")" + "\\]\\]"))) + +(def ^:private id-or-tag-ref-pattern + (re-pattern + (str + "(#?)" + "\\[\\[" + "(" + common-util/uuid-pattern + ")" + "\\]\\]"))) + +(defn content-id-ref->page + "Convert id ref backs to page name using refs." + [content refs] + (reduce + (fn [content ref] + (if (:block/title ref) + (string/replace content (page-ref/->page-ref (:block/uuid ref)) (:block/title ref)) + content)) + content + refs)) + +(defn- sort-refs + "Nested pages first" + [refs] + (sort-by + (fn [ref] + (when-let [title (and (map? ref) (:block/title ref))] + [(boolean (re-find page-ref/page-ref-without-nested-re (:block/title ref))) + title])) + > + refs)) + +(defn id-ref->title-ref + "Convert id ref backs to page name refs using refs." + [content* refs* & {:keys [db replace-block-id? replace-pages-with-same-name?] + :or {replace-block-id? false + replace-pages-with-same-name? true}}] + (when (string? content*) + (let [refs (if replace-block-id? + ;; The caller need to handle situations including + ;; mutual references and circle references. + refs* + (cond->> (filter entity-util/page? refs*) + (and db (false? replace-pages-with-same-name?)) + (remove (fn [e] + (> (count (entity-util/get-pages-by-name db (:block/title e))) 1))))) + content (str content*)] + (if (re-find id-ref-pattern content) + (reduce + (fn [content ref] + (if (:block/title ref) + (let [content' (if (not (string/includes? (:block/title ref) " ")) + (string/replace content + (str "#" (page-ref/->page-ref (:block/uuid ref))) + (str "#" (:block/title ref))) + content)] + (string/replace content' (page-ref/->page-ref (:block/uuid ref)) + (page-ref/->page-ref (:block/title ref)))) + content)) + content + (sort-refs refs)) + content)))) + +(defn get-matched-ids + [content] + (->> (re-seq id-ref-pattern content) + (distinct) + (map second) + (map uuid))) + +(defn- replace-tag-ref + [content page-name id] + (let [page (if (string/includes? page-name " ") (page-ref/->page-ref page-name) page-name) + wrapped-id (page-ref/->page-ref id) + page-name (common-util/format "#%s" page) + r (common-util/format "#%s" wrapped-id)] + ;; hash tag parsing rules https://github.com/logseq/mldoc/blob/701243eaf9b4157348f235670718f6ad19ebe7f8/test/test_markdown.ml#L631 + ;; Safari doesn't support look behind, don't use + ;; TODO: parse via mldoc + (string/replace content + (re-pattern (str "(?i)(^|\\s|\\()(" (common-util/escape-regex-chars page-name) ")(?=[,\\.\\)]*($|\\s|\\)))")) + ;; case_insense^ ^lhs ^_grp2 look_ahead^ ^_grp3 + (fn [[_match lhs _grp2 _grp3]] + (str lhs r))))) + +(defn- replace-page-ref + [content page-name id] + (let [[page wrapped-id] (map page-ref/->page-ref [page-name id])] + (string/replace content + ;; Don't replace #[[]] as that is a tag and is handled separately in replace-tag-ref + (re-pattern (str "(?i)" "(^|[^#])" + (common-util/escape-regex-chars page))) (str "$1" wrapped-id)))) + +(defn- replace-page-ref-with-id + [content page-name id replace-tag?] + (let [page-name (-> (str page-name) + (string/replace "HashTag-" "#")) + id (str id) + content' (replace-page-ref content page-name id)] + (if replace-tag? + (replace-tag-ref content' page-name id) + content'))) + +(defn- ref-replacement-title + [{:block/keys [title] original-page-name :block.temp/original-page-name}] + (or (when (and (string? original-page-name) + (not (common-util/uuid-string? original-page-name))) + original-page-name) + title)) + +(defn title-ref->id-ref + "Convert ref to id refs e.g. `[[page name]] -> [[uuid]]." + [title refs & {:keys [replace-tag?] + :or {replace-tag? true}}] + (assert (string? title)) + (let [refs' (->> refs + (map (fn [ref] + ;; remove uuid references since they're introduced to detect multiple pages + ;; that have the same name + (if (and (map? ref) (some-> (:block.temp/original-page-name ref) common-util/uuid-string?)) + (dissoc ref :block.temp/original-page-name) + ref))) + (map + (fn [ref] + (if (and (vector? ref) (= :block/uuid (first ref))) + {:block/uuid (second ref) + :block/title (str (first ref))} + ref))) + sort-refs)] + (reduce + (fn [content {uuid' :block/uuid :as ref}] + (replace-page-ref-with-id content (ref-replacement-title ref) uuid' replace-tag?)) + title + (filter #(and (:block/title %) (:block/uuid %)) refs')))) + +(defn update-block-content + "Replace `[[internal-id]]` with `[[page name]]`" + [db item eid] + (if-let [content (:block/title item)] + (let [refs (:block/refs (d/entity db eid))] + (assoc item :block/title (id-ref->title-ref content refs))) + item)) + +(defn replace-tags-with-id-refs + "Replace tag names in content with page-ref ids e.g. #TAG -> [[UUID]]. + Ignore case because tags in content can have any case and still have a valid ref" + [content tags] + (->> + (reduce + (fn [content tag] + (let [id-ref (page-ref/->page-ref (:block/uuid tag))] + (-> content + ;; #[[favorite book]] + (common-util/replace-ignore-case + (str "#" (page-ref/->page-ref (:block/title tag))) + id-ref) + ;; #book + (common-util/replace-ignore-case (str "#" (:block/title tag)) id-ref)))) + content + (sort-refs tags)) + (string/trim))) + +(defn replace-tag-refs-with-page-refs + "Replace tag refs in content with page refs e.g. #[[UUID]] -> [[UUID]]" + [content tags] + (->> + (reduce + (fn [content tag] + (let [id-ref (page-ref/->page-ref (:block/uuid tag))] + (-> content + ;; #[[favorite book]] + (common-util/replace-ignore-case + (str "#" id-ref) + id-ref) + ;; #book + (common-util/replace-ignore-case (str "#" id-ref) id-ref)))) + content + (sort-refs tags)) + (string/trim))) + +(defn- title-ref-replacement + [id->title matched hash-prefix id] + (if-let [ref-title (get id->title id)] + (if (and (= "#" hash-prefix) + (not (string/includes? ref-title " "))) + (str "#" ref-title) + (str hash-prefix (page-ref/->page-ref ref-title))) + matched)) + +(defn- replace-title-refs-once + [content id->title] + (string/replace content id-or-tag-ref-pattern + #(apply title-ref-replacement id->title %))) + +(defn- ref->title-entry + [replace-block-refs? {:block/keys [title] + block-uuid :block/uuid + :as ref}] + (when (and block-uuid + (string? title) + (or replace-block-refs? + (entity-util/page? ref))) + [(str block-uuid) title])) + +(defn- block-ref-id->title + [ent max-depth replace-block-refs?] + (loop [frontier (set (:block/refs ent)) + seen-ids #{} + id->title {} + depth 0] + (if (or (>= depth max-depth) + (empty? frontier)) + id->title + (let [refs (filter map? frontier) + new-refs (remove (fn [ref] + (contains? seen-ids (:block/uuid ref))) + refs) + seen-ids' (into seen-ids (keep :block/uuid) new-refs) + id->title' (into id->title + (keep #(ref->title-entry replace-block-refs? %)) + new-refs) + next-frontier (->> new-refs + (mapcat :block/refs) + (filter map?) + set)] + (recur next-frontier seen-ids' id->title' (inc depth)))))) + +(defn recur-replace-uuid-in-block-title + "Convert id ref (recursively) backs to page name refs, returns replaced title" + ([ent] + (recur-replace-uuid-in-block-title ent 10)) + ([ent max-depth] + (recur-replace-uuid-in-block-title ent max-depth {})) + ([ent max-depth {:keys [replace-block-refs?] + :or {replace-block-refs? true}}] + (let [title (:block/title ent)] + (if (some->> title (re-find id-ref-pattern)) + (let [id->title (block-ref-id->title ent max-depth replace-block-refs?)] + (loop [result title depth 0] + (if (or (>= depth max-depth) + (not (re-find id-ref-pattern result))) + result + (let [next-result (replace-title-refs-once result id->title)] + (if (= result next-result) + result + (recur next-result (inc depth))))))) + title)))) diff --git a/deps/db/src/logseq/db/frontend/datalog.cljc b/deps/db/src/logseq/db/frontend/datalog.cljc new file mode 100644 index 0000000..3618784 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/datalog.cljc @@ -0,0 +1,35 @@ +(ns logseq.db.frontend.datalog + "Utility fns related to datalog queries and rules") + +(defn find-rules-in-where + "Given where clauses and a set of valid rules, returns rules found in where + clause as keywords. A more advanced version of this would use a datalog parser +and not require valid-rules" + [where valid-rules] + (->> where + flatten + distinct + (filter #(and (symbol? %) (contains? valid-rules (keyword %)))) + (map keyword))) + +(defn query-vec->map + "Converts query vec to query map. Modified version of + datascript.parser/query->map which preserves insertion order in case map is + converted back to vec" + [query-vec] + (loop [parsed (array-map) key nil qs query-vec] + (if-let [q (first qs)] + (if (keyword? q) + (recur parsed q (next qs)) + (recur (update-in parsed [key] (fnil conj []) q) key (next qs))) + parsed))) + +(defn add-to-end-of-query-section + "Adds vec of elements to end of a query section e.g. :find or :in" + [query-vec query-kw elems] + (let [query-map (query-vec->map query-vec)] + (vec + (reduce (fn [acc [k v]] + (concat acc [k] v (when (= k query-kw) elems))) + '() + query-map)))) diff --git a/deps/db/src/logseq/db/frontend/db.cljs b/deps/db/src/logseq/db/frontend/db.cljs new file mode 100644 index 0000000..b67461e --- /dev/null +++ b/deps/db/src/logseq/db/frontend/db.cljs @@ -0,0 +1,141 @@ +(ns logseq.db.frontend.db + "DB graph fns commonly used outside db dep" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [logseq.common.config :as common-config] + [logseq.common.util.namespace :as ns-util] + [logseq.common.util.page-ref :as page-ref] + [logseq.common.uuid :as common-uuid] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property])) + +(defn built-in-class-property? + "Whether property a built-in property for the specific class" + [class-entity property-entity] + (and (entity-util/built-in? class-entity) + (entity-util/class? class-entity) + (entity-util/built-in? property-entity) + (contains? (set (get-in (db-class/built-in-classes (:db/ident class-entity)) [:schema :properties])) + (:db/ident property-entity)))) + +(defn private-built-in-page? + "Private built-in pages should not be navigable, searchable or editable by users. Later it + could be useful to use this for the All Pages view" + [page] + (cond (entity-util/property? page) + (not (db-property/public-built-in-property? page)) + (or (entity-util/class? page) (entity-util/internal-page? page)) + false + ;; Default to true for closed value and future internal types. + ;; Other types are not considered because they aren't built-in + :else + true)) + +(defn build-favorite-tx + "Builds tx for a favorite block in favorite page" + [favorite-uuid] + {:block/link [:block/uuid favorite-uuid] + :block/title ""}) + +(defn get-all-properties + [db] + (->> (d/datoms db :avet :block/tags :logseq.class/Property) + (map (fn [d] (d/entity db (:e d)))))) + +(defn get-page-parents + [node] + (when-let [parent (:block/parent node)] + (loop [current-parent parent + parents' []] + (if (and current-parent + (not (contains? parents' current-parent))) + (recur (:block/parent current-parent) + (conj parents' current-parent)) + (vec (reverse parents')))))) + +(defn get-class-title-with-extends + [entity] + (let [extends (some->> (:logseq.property.class/extends entity) + (remove (fn [extend] + (or (:logseq.property/built-in? extend) + (= (:block/title entity) (:block/title extend))))) + vec)] + (if (seq extends) + (str (if (= 1 (count extends)) + (:block/title (first extends)) + (->> (take 2 extends) + (map :block/title) + (string/join " | "))) + ns-util/parent-char (:block/title entity)) + (:block/title entity)))) + +(defn get-title-with-parents + [entity] + (cond + (entity-util/class? entity) + (get-class-title-with-extends entity) + + (entity-util/page? entity) + (let [parents' (->> (get-page-parents entity) + (remove (fn [e] + (and (:logseq.property/built-in? e) (= common-config/library-page-name (:block/title e))))))] + (string/join + ns-util/parent-char + (map :block/title (conj (vec parents') entity)))) + + :else + (:block/title entity))) + +(defn get-classes-parents + "Returns all parents of all classes. Like get-class-extends but for multiple classes" + [tags] + (let [tags' (filter entity-util/class? tags) + result (mapcat db-class/get-class-extends tags')] + (set result))) + +(defn class-instance? + "Whether `object` is an instance of `class`" + [class object] + (let [tags (:block/tags object) + tags-ids (set (map :db/id tags))] + (or + (contains? tags-ids (:db/id class)) + (let [class-parent-ids (set (map :db/id (get-classes-parents tags)))] + (contains? (set/union class-parent-ids tags-ids) (:db/id class)))))) + +(defn inline-tag? + [block-raw-title tag] + (assert (string? block-raw-title) "block-raw-title should be a string") + (string/includes? block-raw-title (str "#" (page-ref/->page-ref (:block/uuid tag))))) + +(defonce node-display-type-classes + #{:logseq.class/Code-block :logseq.class/Math-block :logseq.class/Quote-block}) + +(defn get-class-ident-by-display-type + [display-type] + (case display-type + :code :logseq.class/Code-block + :math :logseq.class/Math-block + :quote :logseq.class/Quote-block + nil)) + +(defn get-display-type-by-class-ident + [class-ident] + (case class-ident + :logseq.class/Code-block :code + :logseq.class/Math-block :math + :logseq.class/Quote-block :quote + nil)) + +(defn get-built-in-page + [db title] + (when db + (let [id (common-uuid/gen-uuid :builtin-block-uuid title)] + (d/entity db [:block/uuid id])))) + +(defn library? + [page] + (and (entity-util/built-in? page) + (= common-config/library-page-name (:block/title page)))) diff --git a/deps/db/src/logseq/db/frontend/db_ident.cljc b/deps/db/src/logseq/db/frontend/db_ident.cljc new file mode 100644 index 0000000..03e33e9 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/db_ident.cljc @@ -0,0 +1,97 @@ +(ns logseq.db.frontend.db-ident + "Helper fns for class and property :db/ident" + (:require [clojure.string :as string] + [datascript.core :as d])) + +(defn ensure-unique-db-ident + "Ensures the given db-ident is unique. If a db-ident conflicts, it is made + unique by adding a suffix with a unique number e.g. :db-ident-1 :db-ident-2" + [db db-ident] + (if (d/entity db db-ident) + (let [existing-idents + (d/q '[:find [?ident ...] + :in $ ?ident-name + :where + [?b :db/ident ?ident] + [(str ?ident) ?str-ident] + [(clojure.string/starts-with? ?str-ident ?ident-name)]] + db + (str db-ident "-")) + new-ident (if-let [max-num (->> existing-idents + (keep #(parse-long (string/replace-first (str %) (str db-ident "-") ""))) + (apply max))] + (keyword (namespace db-ident) (str (name db-ident) "-" (inc max-num))) + (keyword (namespace db-ident) (str (name db-ident) "-1")))] + new-ident) + db-ident)) + +(def ^:private non-int-char-range "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +(def alphabet + (mapv str "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) + +#_:clj-kondo/ignore +(defn- random-bytes + [size] + #?(:org.babashka/nbb + nil + :default + (let [seed (js/Uint8Array. size)] + (.getRandomValues js/crypto seed) + (array-seq seed)))) + +(defn nano-id + "Random id generator" + ([] + (nano-id 21)) + ([size] + (let [mask' 0x3f] + (loop [bs (random-bytes size) + id ""] + (if bs + (recur (next bs) + (->> (first bs) + (bit-and mask') + alphabet + (str id))) + id))))) + +(defn normalize-ident-name-part + [name-string] + (->> (string/replace-first name-string #"^(\d)" "NUM-$1") + ;; '-' must go last in char class + (filter #(re-find #"[0-9a-zA-Z*+!_'?<>=-]{1}" %)) + (apply str))) + +(defn create-db-ident-from-name + "Creates a :db/ident for a class or property by sanitizing the given name. + The created ident must obey clojure's rules for keywords i.e. + be a valid symbol per https://clojure.org/reference/reader#_symbols + + NOTE: Only use this when creating a db-ident for a new class/property. Using + this in read-only contexts like querying can result in db-ident conflicts" + [user-namespace name-string] + {:pre [(or (keyword? user-namespace) (string? user-namespace)) (string? name-string)]} + (assert (not (re-find #"^(logseq|block)(\.|$)" (name user-namespace))) + "New ident is not allowed to use an internal namespace") + (if #?(:org.babashka/nbb true + ;; Use $LOGSEQ_STABLE_IDENTS when we want stable idents e.g. tests + :cljs (and (exists? js/process) js/process.env.LOGSEQ_STABLE_IDENTS) + :default false) + (keyword user-namespace (normalize-ident-name-part name-string)) + (let [plugin? (string/starts-with? user-namespace "plugin.class.") + suffix (str "-" + (rand-nth non-int-char-range) + (nano-id 7))] + (keyword user-namespace + (str + (normalize-ident-name-part name-string) + (when-not plugin? + suffix)))))) + +(defn replace-db-ident-random-suffix + [db-ident-kw new-suffix] + (assert (keyword? db-ident-kw)) + (assert (and (string? new-suffix) (= 8 (count new-suffix)))) + (keyword (namespace db-ident-kw) + (string/replace-first (name db-ident-kw) #"-.{8}$" (str "-" new-suffix)))) diff --git a/deps/db/src/logseq/db/frontend/entity_util.cljs b/deps/db/src/logseq/db/frontend/entity_util.cljs new file mode 100644 index 0000000..6300286 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/entity_util.cljs @@ -0,0 +1,115 @@ +(ns logseq.db.frontend.entity-util + "Lower level entity util fns for DB graphs" + (:require [clojure.string :as string] + [datascript.core :as d] + [datascript.db] + [datascript.impl.entity :as de] + [logseq.common.util :as common-util]) + (:refer-clojure :exclude [object?])) + +(defn- has-tag? + [entity tag-ident] + (when (or (map? entity) (de/entity? entity)) + (some (fn [t] + (or (keyword-identical? (:db/ident t) tag-ident) + (keyword-identical? t tag-ident))) + (:block/tags entity)))) + +(comment + (require '[logseq.common.profile :as c.p]) + (do (vreset! c.p/*key->call-count {}) + (vreset! c.p/*key->time-sum {})) + (c.p/profile-fn! has-tag? :print-on-call? false)) + +(defn internal-page? + [entity] + (has-tag? entity :logseq.class/Page)) + +(defn class? + [entity] + (has-tag? entity :logseq.class/Tag)) + +(defn property? + [entity] + (has-tag? entity :logseq.class/Property)) + +(defn closed-value? + [entity] + (some? (:block/closed-value-property entity))) + +(defn journal? + "Given a page entity or map, check if it is a journal page" + [entity] + (has-tag? entity :logseq.class/Journal)) + +(defn page? + [entity] + (or (internal-page? entity) + (journal? entity) + (class? entity) + (property? entity))) + +(defn asset? + "Given an entity or map, check if it is an asset block" + [entity] + ;; Can't use :block/tags because this is used in some perf sensitive fns like ldb/transact! + (some? (:logseq.property.asset/type entity))) + +(defn hidden? + [page] + (letfn [(hidden-parent? [entity seen] + (when (and entity + (:db/id entity) + (not (contains? seen (:db/id entity)))) + (or (:logseq.property/hide? entity) + (:logseq.property/deleted-at entity) + (hidden-parent? (:block/parent entity) (conj seen (:db/id entity))))))] + (boolean + (when page + (if (string? page) + (string/starts-with? page "$$$") + (when (or (map? page) (de/entity? page)) + (or (:logseq.property/hide? page) + (:logseq.property/deleted-at page) + (hidden-parent? (:block/parent page) #{})))))))) + +(defn recycled? + [entity] + (letfn [(recycled-parent? [parent seen] + (when (and parent + (:db/id parent) + (not (contains? seen (:db/id parent)))) + (or (:logseq.property/deleted-at parent) + (recycled-parent? (:block/parent parent) (conj seen (:db/id parent))))))] + (boolean + (when (or (map? entity) (de/entity? entity)) + (or (:logseq.property/deleted-at entity) + (recycled-parent? (:block/parent entity) #{})))))) + +(defn object? + [node] + (seq (:block/tags node))) + +(defn get-entity-types + "Get entity types from :block/tags" + [entity] + (let [ident->type {:logseq.class/Tag :class + :logseq.class/Property :property + :logseq.class/Journal :journal + :logseq.class/Page :page}] + (set (map #(ident->type (:db/ident %)) (:block/tags entity))))) + +(defn built-in? + "Built-in page or block" + [entity] + (:logseq.property/built-in? entity)) + +(defn get-pages-by-name + [db page-name] + (d/datoms db :avet :block/name (common-util/page-name-sanity-lc page-name))) + +(defn entity->map + "Convert a db Entity to a map" + [e] + (assert (de/entity? e)) + (assoc (into {} e) :db/id (:db/id e))) diff --git a/deps/db/src/logseq/db/frontend/inputs.cljs b/deps/db/src/logseq/db/frontend/inputs.cljs new file mode 100644 index 0000000..ee2c421 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/inputs.cljs @@ -0,0 +1,162 @@ +(ns logseq.db.frontend.inputs + "Handles :inputs in queries" + (:require [cljs-time.core :as t] + [logseq.common.util.date-time :as date-time-util] + [logseq.common.util :as common-util] + [logseq.common.util.page-ref :as page-ref] + [datascript.core :as d] + [clojure.string :as string])) + +(defn date-at-local-ms + "Returns the milliseconds representation of the provided time, in the local timezone. +For example, if you run this function at 10pm EDT in the EDT timezone on May 31st, +it will return 1622433600000, which is equivalent to Mon May 31 2021 00 :00:00." + ([hours mins secs millisecs] + (date-at-local-ms (.now js/Date) hours mins secs millisecs)) + ([date hours mins secs millisecs] + (.setHours (js/Date. date) hours mins secs millisecs))) + +(defn- old->new-relative-date-format [input] + (let [count' (re-find #"^\d+" (name input)) + plus-minus (if (re-find #"after" (name input)) "+" "-") + ms? (string/ends-with? (name input) "-ms")] + (keyword :today (str plus-minus count' "d" (if ms? "-ms" ""))))) + +(comment + (old->new-relative-date-format "1d") + (old->new-relative-date-format "1d-before") + (old->new-relative-date-format "1d-after") + (old->new-relative-date-format "1d-before-ms") + (old->new-relative-date-format "1d-after-ms") + (old->new-relative-date-format "1w-after-ms")) + +(defn- get-relative-date [input] + (case (or (namespace input) "today") + "today" (t/today))) + +(defn- get-offset-date [relative-date direction amount unit] + (let [offset-fn (case direction "+" t/plus "-" t/minus) + offset-amount (parse-long amount) + offset-unit-fn (case unit + "d" t/days + "w" t/weeks + "m" t/months + "y" t/years)] + (offset-fn (offset-fn relative-date (offset-unit-fn offset-amount))))) + +(defn- get-ts-units + "There are currently several time suffixes being used in inputs: + - ms: milliseconds, will return a time relative to the direction the date is being adjusted + - start: will return the time at the start of the day [00:00:00.000] + - end: will return the time at the end of the day [23:59:59.999] + - HHMM: will return the specified time at the turn of the minute [HH:MM:00.000] + - HHMMSS: will return the specified time at the turm of the second [HH:MM:SS.000] + - HHMMSSmmm: will return the specified time at the turn of the millisecond [HH:MM:SS.mmm] + + The latter three will be capped to the maximum allowed for each unit so they will always be valid times" + [offset-direction offset-time] + (case offset-time + "ms" (if (= offset-direction "+") [23 59 59 999] [0 0 0 0]) + "start" [0 0 0 0] + "end" [23 59 59 999] + ;; if it's not a matching string, then assume it is HHMM + (let [[h1 h2 m1 m2 s1 s2 ms1 ms2 ms3] (str offset-time "000000000")] + [(min 23 (parse-long (str h1 h2))) + (min 59 (parse-long (str m1 m2))) + (min 59 (parse-long (str s1 s2))) + (min 999 (parse-long (str ms1 ms2 ms3)))]))) + +(defn- keyword-input-dispatch [input] + (cond + (#{:current-page :query-page :current-block :parent-block :today :yesterday :tomorrow :right-now-ms} input) input + + (re-find #"^[+-]\d+[dwmy]?$" (name input)) :relative-date + (re-find #"^[+-]\d+[dwmy]-(ms|start|end|\d{2}|\d{4}|\d{6}|\d{9})?$" (name input)) :relative-date-time + + (= :start-of-today-ms input) :today-time + (= :end-of-today-ms input) :today-time + (re-find #"^today-(start|end|\d{2}|\d{4}|\d{6}|\d{9})$" (name input)) :today-time + + (re-find #"^\d+d(-before|-after|-before-ms|-after-ms)?$" (name input)) :DEPRECATED-relative-date)) + +(defmulti resolve-keyword-input (fn [_db input _opts] (keyword-input-dispatch input))) + +(defmethod resolve-keyword-input :current-page [_ _ {:keys [current-page-fn]}] + (when current-page-fn + (some-> (current-page-fn) string/lower-case))) + +(defmethod resolve-keyword-input :query-page [db _ {:keys [current-block-uuid]}] + (when-let [current-block (and current-block-uuid (d/entity db [:block/uuid current-block-uuid]))] + (get-in current-block [:block/page :block/name]))) + +(defmethod resolve-keyword-input :current-block [db _ {:keys [current-block-uuid]}] + (when current-block-uuid + (:db/id (d/entity db [:block/uuid current-block-uuid])))) + +(defmethod resolve-keyword-input :parent-block [db _ {:keys [current-block-uuid]}] + (when current-block-uuid + (:db/id (:block/parent (d/entity db [:block/uuid current-block-uuid]))))) + +(defmethod resolve-keyword-input :today [_ _ _] + (date-time-util/date->int (t/today))) + +(defmethod resolve-keyword-input :yesterday [_ _ _] + (date-time-util/date->int (t/minus (t/today) (t/days 1)))) + +(defmethod resolve-keyword-input :tomorrow [_ _ _] + (date-time-util/date->int (t/plus (t/today) (t/days 1)))) + +(defmethod resolve-keyword-input :right-now-ms [_ _ _] + (common-util/time-ms)) + +;; today-time returns an epoch int +(defmethod resolve-keyword-input :today-time [_db input _opts] + (let [[hh mm ss ms] (case input + :start-of-today-ms [0 0 0 0] + :end-of-today-ms [23 59 59 999] + (get-ts-units nil (subs (name input) 6)))] + (date-at-local-ms (t/today) hh mm ss ms))) + +;; relative-date returns a YYYMMDD string +(defmethod resolve-keyword-input :relative-date [_ input _] + (let [relative-to (get-relative-date input) + [_ offset-direction offset offset-unit] (re-find #"^([+-])(\d+)([dwmy])$" (name input)) + offset-date (get-offset-date relative-to offset-direction offset offset-unit)] + (date-time-util/date->int offset-date))) + +;; relative-date-time returns an epoch int +(defmethod resolve-keyword-input :relative-date-time [_ input _] + (let [relative-to (get-relative-date input) + [_ offset-direction offset offset-unit ts] (re-find #"^([+-])(\d+)([dwmy])-(ms|start|end|\d{2,9})$" (name input)) + offset-date (get-offset-date relative-to offset-direction offset offset-unit) + [hh mm ss ms] (get-ts-units offset-direction ts)] + (date-at-local-ms offset-date hh mm ss ms))) + +(defmethod resolve-keyword-input :DEPRECATED-relative-date [db input opts] + ;; This handles all of the cases covered by the following: + ;; :Xd, :Xd-before, :Xd-before-ms, :Xd-after, :Xd-after-ms + (resolve-keyword-input db (old->new-relative-date-format input) opts)) + +(defmethod resolve-keyword-input :default [_ _ _] + nil) + +(defn resolve-input + "Main fn for resolving advanced query :inputs" + [db input {:keys [current-block-uuid current-page-fn] + :or {current-page-fn (constantly nil)}}] + (cond + (keyword? input) + (or + (resolve-keyword-input db input {:current-block-uuid current-block-uuid + :current-page-fn current-page-fn}) + ;; The input is returned back unresolved if a resolver communicates it + ;; can't resolve it by returning nil. We may want to error if this is too + ;; subtle for the user + input) + + (and (string? input) (page-ref/page-ref? input)) + (-> (page-ref/get-page-name input) + (string/lower-case)) + + :else + input)) diff --git a/deps/db/src/logseq/db/frontend/kv_entity.cljs b/deps/db/src/logseq/db/frontend/kv_entity.cljs new file mode 100644 index 0000000..21d38e8 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/kv_entity.cljs @@ -0,0 +1,33 @@ +(ns logseq.db.frontend.kv-entity + "kv entities used by logseq db" + (:require [logseq.common.defkeywords :refer [defkeywords]])) + +(def kv-entities + (apply + hash-map + (defkeywords + :logseq.kv/db-type {:doc "Set to \"db\" if it's a db-graph"} + :logseq.kv/graph-uuid {:doc "Store graph-uuid if it's a rtc enabled graph" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/local-graph-uuid {:doc "Store graph-uuid if it's a local graph" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/import-type {:doc "If graph is imported, identifies how a graph is imported including which UI or CLI import process. CLI scripts can set this to a custom value. + UI values include :file-graph and :sqlite-db and CLI values start with :cli e.g. :cli/default."} + :logseq.kv/imported-at {:doc "Time if graph is imported"} + :logseq.kv/graph-local-tx {:doc "local rtc tx-id" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/schema-version {:doc "Graph's current schema version"} + :logseq.kv/remote-schema-version {:doc "Graph's remote schema version. +RTC won't start when major-schema-versions don't match" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/graph-created-at {:doc "Graph's created at time"} + :logseq.kv/latest-code-lang {:doc "Latest lang used by a #Code-block" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/graph-backup-folder {:doc "Backup folder for automated backup feature" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/graph-initial-schema-version {:doc "Graph's schema version when created"} + + :logseq.kv/graph-last-gc-at {:doc "Last time graph gc at" + :rtc {:rtc/ignore-entity-when-init-upload true}} + :logseq.kv/graph-rtc-e2ee? {:doc "true if it's a rtc graph with E2EE enabled"} + :logseq.kv/graph-remote? {:doc "true if it's a remote graph"}))) diff --git a/deps/db/src/logseq/db/frontend/malli_schema.cljs b/deps/db/src/logseq/db/frontend/malli_schema.cljs new file mode 100644 index 0000000..cae0fe7 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/malli_schema.cljs @@ -0,0 +1,647 @@ +(ns logseq.db.frontend.malli-schema + "Malli schemas and fns for logseq.db.frontend.*" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [datascript.impl.entity :as de] + [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.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.db.frontend.schema :as db-schema])) + +;; :db/ident malli schemas +;; ======================= + +(def db-attribute-ident + (into [:enum] db-property/db-attribute-properties)) + +(def logseq-property-ident + [:and :keyword [:fn + {:error/message "should be a valid logseq property namespace"} + db-property/logseq-property?]]) + +(def block-order + [:and :string [:fn + {:error/message "should be a valid fractional index"} + db-order/validate-order-key?]]) + +(def internal-property-ident + [:or logseq-property-ident db-attribute-ident]) + +(defn user-property? + "Determines if keyword/ident is a user property" + [kw] + (db-property/user-property-namespace? (namespace kw))) + +(def user-property-ident + [:and :qualified-keyword [:fn + {:error/message "should be a valid user property namespace"} + user-property?]]) + +(def plugin-property-ident + [:and :qualified-keyword [:fn + {:error/message "should be a valid plugin property namespace"} + db-property/plugin-property?]]) + +(def logseq-ident-namespaces + "Set of all namespaces Logseq uses for :db/ident except for + db-attribute-ident. It's important to grow this list purposefully and have it + start with 'logseq' to allow for users and 3rd party plugins to provide their + own namespaces to core concepts." + (into db-property/logseq-property-namespaces #{db-class/logseq-class "logseq.kv"})) + +(def logseq-ident + [:and :keyword [:fn + {:error/message "should be a valid :db/ident namespace"} + (fn logseq-namespace? [k] + (contains? logseq-ident-namespaces (namespace k)))]]) + +(defn class? + "Determines if keyword/ident is a logseq or user class" + [kw] + (string/includes? (namespace kw) ".class")) + +(def class-ident + [:and :qualified-keyword [:fn + {:error/message "should be a valid class namespace"} + class?]]) +;; Helper fns +;; ========== +(defn- empty-placeholder-value? [db property property-val] + (if (= :db.type/ref (:db/valueType property)) + (and (integer? property-val) + (= :logseq.property/empty-placeholder (:db/ident (d/entity db property-val)))) + (= :logseq.property/empty-placeholder property-val))) + +(defn internal-ident? + "Determines if given ident is created by Logseq. All Logseq internal idents + must start with 'block' or 'logseq' to keep Logseq internals from leaking + across namespaces and to allow for users and 3rd party plugins to choose + any other namespace" + [ident] + (or (contains? db-property/db-attribute-properties ident) + (contains? logseq-ident-namespaces (namespace ident)))) + +(defn validate-property-value + "Validates the property value in a property tuple. The property value is + expected to be a coll if the property has a :many cardinality. validate-fn is + a fn that is called directly on each value to return a truthy value. + validate-fn varies by property type" + [db validate-fn [property property-val] & {:keys [new-closed-value? :closed-values-validate? _skip-strict-url-validate?] + :as validate-options}] + ;; For debugging + ;; (when (not (internal-ident? (:db/ident property))) (prn :validate-val (dissoc property :property/closed-values) property-val)) + (let [validate-fn' (if (db-property-type/property-types-with-db (:logseq.property/type property)) + (fn [value] + (validate-fn db value validate-options)) + validate-fn) + validate-fn'' (if (and closed-values-validate? + (db-property-type/closed-value-property-types (:logseq.property/type property)) + ;; new closed values aren't associated with the property yet + (not new-closed-value?) + (seq (:property/closed-values property))) + (fn closed-value-valid? [val] + (and (validate-fn' val) + (let [ids (set (map :db/id (:property/closed-values property))) + result (contains? ids val)] + (when-not result + (js/console.error (str "Error: not a closed value, id: " val ", existing choices: " ids ", property: " (:db/ident property)))) + result))) + validate-fn')] + (if (db-property/many? property) + (or (every? validate-fn'' property-val) + (empty-placeholder-value? db property (first property-val))) + (or (validate-fn'' property-val) + ;; also valid if value is empty-placeholder + (empty-placeholder-value? db property property-val))))) + +(def required-properties + "Set of properties required by a schema and that are validated directly in a schema instead + of validate-property-value" + (set/union + (set (get-in db-class/built-in-classes [:logseq.class/Asset :schema :required-properties])) + #{:logseq.property/created-from-property :logseq.property/value + :logseq.property.history/scalar-value :logseq.property.history/block + :logseq.property.history/property :logseq.property.history/ref-value + :logseq.property.class/extends + :logseq.property.reaction/emoji-id + :logseq.property.reaction/target})) + +(defn- property-entity->map + "Provide the minimal number of property attributes to validate the property + and to reduce noise in error messages. The resulting map should be the same as + what the frontend property since they both call validate-property-value" + [property] + ;; use explicit call to be nbb compatible + (let [closed-values (entity-plus/lookup-kv-then-entity property :property/closed-values)] + (cond-> (select-keys property [:db/ident :db/valueType :db/cardinality :logseq.property/type]) + (seq closed-values) + (assoc :property/closed-values closed-values)))) + +(defn update-properties-in-ents + "Prepares properties in entities to be validated by DB schema" + [db ents] + ;; required-properties allows schemas like property-value-block to require + ;; properties in their schema that they depend on + (let [exceptions-to-block-properties (-> required-properties + (into db-property/schema-properties) + (conj :block/tags)) + page-class-id (:db/id (d/entity db :logseq.class/Page)) + all-page-class-ids (set (map #(:db/id (d/entity db %)) db-class/page-classes))] + (mapv + (fn [ent] + (reduce (fn [m [k v]] + (if-let [property (and (db-property/property? k) + (not (contains? exceptions-to-block-properties k)) + (d/entity db k))] + (update m :block/properties (fnil conj []) + [(property-entity->map property) v]) + (if (= :block/tags k) + ;; Provides additional options map to validation for data about current entity being tagged + (let [property (d/entity db :block/tags)] + (assoc m k [(property-entity->map property) + v + (merge (select-keys ent [:logseq.property/built-in?]) + {:page-class-id page-class-id + :all-page-class-ids all-page-class-ids})])) + (assoc m k v)))) + {} + ent)) + ents))) + +(defn datoms->entity-maps + "Returns entity maps for given :eavt datoms indexed by db/id. Optional keys: + * :entity-fn - Optional fn that given an entity id, returns entity. Defaults + to just doing a lookup based on existing entity-maps to be as performant as possible" + [datoms & {:keys [entity-fn]}] + (let [ent-maps + (reduce (fn [acc {:keys [a e v]}] + (if (contains? db-schema/card-many-attributes a) + (update acc e update a (fnil conj #{}) v) + ;; If there's already a val, don't clobber it and automatically start collecting it as a :many + (if-let [existing-val (get-in acc [e a])] + (if (set? existing-val) + (update acc e assoc a (conj existing-val v)) + (update acc e assoc a #{existing-val v})) + (update acc e assoc a v)))) + {} + datoms) + entity-fn' (or entity-fn + (let [db-ident-maps (dissoc (into {} (map (juxt :db/ident identity) (vals ent-maps))) nil)] + #(get db-ident-maps %)))] + (-> ent-maps + (update-vals + (fn [m] + (->> m + (map (fn [[k v]] + (if-let [property (and (db-property/property? k) + (entity-fn' k))] + (if (and (db-property/many? property) + (not (set? v))) + ;; Fix :many property values that only had one value + [k #{v}] + [k v]) + [k v]))) + (into {}))))))) + +(defn datoms->entities + "Returns a vec of entity maps given :eavt datoms" + [datoms] + (mapv (fn [[db-id m]] (assoc m :db/id db-id)) + (datoms->entity-maps datoms))) + +(assert (every? #(re-find #"^(block|logseq\.)" (namespace %)) db-property/db-attribute-properties) + "All db-attribute idents start with an internal namespace") +(assert (every? #(re-find #"^logseq\." %) logseq-ident-namespaces) + "All logseq idents start with an internal namespace") + +;; Main malli schemas +;; ================== +;; These schemas should be data vars to remain as simple and reusable as possible + +(def ^:dynamic *db-for-validate-fns* + "Used by validate-fns which need db as input" + nil) + +(def ^:dynamic *skip-strict-url-validate?* + "`true` allows updating a block's other property when it has invalid URL value" + false) + +(def ^:dynamic *closed-values-validate?* + "By default this is false because we can't ensure this when merging updates from server. + `true` allows for non RTC graphs to have higher data quality and avoid + possible UX bugs related to closed values." + false) + +(def property-tuple + "A tuple of a property map and a property value" + (into + [:multi {:dispatch #(-> % first :logseq.property/type)}] + (map (fn [[prop-type value-schema]] + [prop-type + (let [schema-fn (if (vector? value-schema) (last value-schema) value-schema) + error-message (when (vector? value-schema) + (and (map? (second value-schema)) + (:error/message (second value-schema))))] + [:fn + (when error-message + {:error/message error-message}) + (fn [tuple] + (validate-property-value *db-for-validate-fns* schema-fn tuple + {:skip-strict-url-validate? *skip-strict-url-validate?* + :closed-values-validate? *closed-values-validate?*}))])]) + db-property-type/built-in-validation-schemas))) + +(def block-properties + "Validates a block's properties as property pairs. Properties are + a vector of tuples instead of a map in order to validate each + property with its property value that is valid for its type" + [:sequential property-tuple]) + +(def block-tags + [:and + property-tuple + ;; Important to keep data integrity of built-in entities. Ensure UI doesn't accidentally modify them + [:fn {:error/message "should only have one tag for a built-in entity"} + (fn [[_k v opts]] + (if (:logseq.property/built-in? opts) + (= 1 (count v)) + true))] + ;; Ensure use of :logseq.class/Page is consistent and simple. Doing so reduces complexity elsewhere + ;; and allows for Page to exist as its own public concept later + [:fn {:error/message "should not have other built-in page tags when tagged with #Page"} + (fn [[_k v {:keys [page-class-id all-page-class-ids]}]] + (if (contains? v page-class-id) + (empty? (set/intersection (disj v page-class-id) all-page-class-ids)) + true))]]) + +(def page-or-block-attrs + "Common attributes for page and normal blocks" + [[:block/uuid :uuid] + [:block/created-at :int] + [:block/updated-at :int] + [:logseq.property/deleted-at {:optional true} :int] + ;; Injected by update-properties-in-ents + [:block/properties {:optional true} block-properties] + [:block/tags {:optional true} block-tags] + [:block/refs {:optional true} [:set :int]] + [:block/tx-id {:optional true} :int] + [:block/collapsed? {:optional true} :boolean] + [:block/warning {:optional true} [:keyword]] + [:logseq.property/created-by-ref {:optional true} :int]]) + +(def page-attrs + "Common attributes for pages" + [[:block/name :string] + [:block/title :string]]) + +(def property-attrs + "Common attributes for properties" + [[:db/index {:optional true} :boolean] + [:db/valueType {:optional true} [:enum :db.type/ref]] + [:db/cardinality {:optional true} [:enum :db.cardinality/many :db.cardinality/one]] + [:block/order {:optional true} block-order] + [:logseq.property/classes {:optional true} [:set :int]]]) + +(def normal-page + (vec + (concat + [:map {:error/path ["normal-page"]} + ;; journal-day is only set for journal pages + [:block/journal-day {:optional true} :int] + [:block/parent {:optional true} :int] + [:block/order {:optional true} block-order]] + page-attrs + page-or-block-attrs))) + +(def class-page + [:or + (vec + (concat + [:map {:error/path ["class-page"]} + [:db/ident class-ident] + [:logseq.property.class/extends [:set :int]]] + page-attrs + page-or-block-attrs)) + (vec + (concat + [:map {:error/path ["class-page"]} + [:db/ident [:= :logseq.class/Root]]] + page-attrs + page-or-block-attrs))]) + +(def property-common-schema-attrs + "Property :schema attributes common to all properties" + [[:logseq.property/hide? {:optional true} :boolean] + [:logseq.property/public? {:optional true} :boolean] + [:logseq.property/ui-position {:optional true} [:enum :properties :block-left :block-right :block-below]]]) + +(def internal-property + (vec + (concat + [:map {:error/path ["internal-property"]} + [:db/ident internal-property-ident] + [:logseq.property/type (apply vector :enum (into db-property-type/internal-built-in-property-types + db-property-type/user-built-in-property-types))] + [:logseq.property/view-context {:optional true} [:enum :page :block :class :property :never]]] + property-common-schema-attrs + property-attrs + page-attrs + page-or-block-attrs))) + +(def user-property + (vec + (concat + [:map {:error/path ["user-property"]} + [:db/ident user-property-ident] + [:logseq.property/type (apply vector :enum (into db-property-type/user-allowed-internal-property-types + db-property-type/user-built-in-property-types))]] + property-common-schema-attrs + property-attrs + page-attrs + page-or-block-attrs))) + +(def plugin-property + (vec + (concat + [:map {:error/path ["plugin-property"]} + [:db/ident plugin-property-ident] + [:logseq.property/type (apply vector :enum (concat db-property-type/user-built-in-property-types [:json :string :page]))]] + property-common-schema-attrs + property-attrs + page-attrs + page-or-block-attrs))) + +(def property-page + [:multi {:dispatch (fn [m] + (let [ident (:db/ident m)] + + (cond + (or (and ident (db-property/logseq-property? ident)) + (contains? db-property/db-attribute-properties (:db/ident m))) + :internal + + (and ident (db-property/plugin-property? ident)) + :plugin + + :else + :user)))} + [:internal internal-property] + [:plugin plugin-property] + [:malli.core/default user-property]]) + +(def hidden-page + (vec + (concat + [:map {:error/path ["hidden-page"]} + ;; pages from :default property uses this but closed-value pages don't + [:block/order {:optional true} block-order] + [:logseq.property/hide? [:enum true]]] + page-attrs + page-or-block-attrs))) + +(def block-attrs + "Common attributes for normal blocks" + [[:block/title :string] + [:block/parent :int] + [:block/order block-order] + ;; refs + [:block/page :int] + [:block/link {:optional true} :int] + [:logseq.property/created-from-property {:optional true} :int]]) + +;; TODO: Remove deprecated +(def whiteboard-block + "A (shape) block for whiteboard" + (vec + (concat + [:map {:error/path ["whiteboard-block"]}] + [[:block/title :string] + [:block/parent :int] + ;; These blocks only associate with pages of type "whiteboard" + [:block/page :int]] + page-or-block-attrs))) + +(def property-value-block + "A common property value for user properties" + (vec + (concat + [:map {:error/path "property-value-block"}] + [[:logseq.property/value [:or :string :double :boolean]] + [:logseq.property/created-from-property :int]] + (remove #(#{:block/title :logseq.property/created-from-property} (first %)) block-attrs) + page-or-block-attrs))) + +(def reaction-entity + "A reaction entity referencing a target node" + (vec + [:map {:error/path ["reaction-entity"]} + [:block/uuid :uuid] + [:logseq.property.reaction/emoji-id :string] + [:logseq.property.reaction/target :int] + [:block/created-at :int] + [:block/tx-id {:optional true} :int] + [:block/properties {:optional true} block-properties] + [:block/refs {:optional true} [:set :int]]])) + +(def property-history-block* + [:map {:error/path ["property-history-block"]} + [:block/uuid :uuid] + [:block/created-at :int] + [:block/updated-at {:optional true} :int] + [:logseq.property.history/block :int] + [:logseq.property.history/property :int] + [:logseq.property.history/ref-value {:optional true} :int] + [:logseq.property.history/scalar-value {:optional true} :any] + [:block/properties {:optional true} block-properties] + [:block/tx-id {:optional true} :int]]) + +(def property-history-block + "A closed value for a property with closed/allowed values" + [:and property-history-block* + [:fn {:error/message ":logseq.property.history/ref-value or :logseq.property.history/scalar-value required" + :error/path [:logseq.property.history/ref-value]} + (fn [m] + (or (:logseq.property.history/ref-value m) + (some? (:logseq.property.history/scalar-value m))))]]) + +(def closed-value-block* + (vec + (concat + [:map] + [;; for built-in properties + [:db/ident {:optional true} logseq-property-ident] + [:block/title {:optional true} :string] + [:logseq.property/value {:optional true} [:or :string :double]] + [:logseq.property/created-from-property :int] + [:block/closed-value-property {:optional true} [:set :int]]] + (remove #(#{:block/title :logseq.property/created-from-property} (first %)) block-attrs) + page-or-block-attrs))) + +(def closed-value-block + "A closed value for a property with closed/allowed values" + [:and closed-value-block* + [:fn {:error/message ":block/title or :logseq.property/value required" + :error/path [:logseq.property/value]} + (fn [m] + (or (:block/title m) (:logseq.property/value m)))]]) + +(def normal-block + "A block with content and no special type or tag behavior" + (vec + (concat + [:map {:error/path ["normal-block"]}] + block-attrs + page-or-block-attrs))) + +(def block + "A block has content and a page" + [:or + normal-block + whiteboard-block]) + +(def asset-block + "A block tagged with #Asset" + (vec + (concat + [:map {:error/path ["asset-block"]}] + ;; TODO: Derive required property types from existing schema in frontend.property + [[:logseq.property.asset/type :string] + [:logseq.property.asset/checksum :string] + [:logseq.property.asset/size :int] + [:logseq.property.asset/width {:optional true} :int] + [:logseq.property.asset/height {:optional true} :int] + [:logseq.property.asset/align {:optional true} :keyword]] + block-attrs + page-or-block-attrs))) + +(def file-block + [:map {:error/path ["file-block"]} + [:block/uuid :uuid] + [:block/tx-id {:optional true} :int] + ;; App doesn't use timestamps but migrations may + [:block/created-at {:optional true} :int] + [:block/updated-at {:optional true} :int] + [:file/content :string] + [:file/path :string] + [:file/size {:optional true} :int] + [:file/created-at inst?] + [:file/last-modified-at inst?]]) + +(def db-ident-key-val + "A key value map with :db/ident and :kv/value" + [:map {:error/path ["db-ident-key-val"]} + [:db/ident logseq-ident] + [:kv/value :any] + [:block/tx-id {:optional true} :int]]) + +(def property-value-placeholder + [:map {:error/path ["property-value-placeholder"]} + [:db/ident [:= :logseq.property/empty-placeholder]] + [:block/uuid :uuid] + [:block/tx-id {:optional true} :int] + [:block/created-at {:optional true} :int] + [:block/updated-at {:optional true} :int]]) + +(defn- whiteboard? + [entity] + (when (or (map? entity) (de/entity? entity)) + (some (fn [t] + (or (keyword-identical? (:db/ident t) :logseq.class/Whiteboard) + (keyword-identical? t :logseq.class/Whiteboard))) + (:block/tags entity)))) + +(defn entity-dispatch-key [db ent] + (let [d (if (:block/uuid ent) (d/entity db [:block/uuid (:block/uuid ent)]) ent) + ;; order matters as some block types are a subset of others e.g. :whiteboard + dispatch-key (cond + (:logseq.property.reaction/target d) + :reaction-entity + (entity-util/property? d) + :property + (entity-util/class? d) + :class + (and (entity-util/page? d) + (true? (:logseq.property/hide? d))) + :hidden + ;; TODO: Remove deprecated + (whiteboard? d) + :normal-page + (entity-util/page? d) + :normal-page + (entity-util/asset? d) + :asset-block + (:file/path d) + :file-block + (:logseq.property.history/block d) + :property-history-block + (:block/closed-value-property d) + :closed-value-block + (and (:logseq.property/created-from-property d) (:logseq.property/value d)) + :property-value-block + (= (:db/ident d) :logseq.property/empty-placeholder) + :property-value-placeholder + (:block/uuid d) + :block + (:db/ident d) + :db-ident-key-value)] + dispatch-key)) + +(def Data + (into + [:multi {:dispatch (fn [d] (entity-dispatch-key *db-for-validate-fns* d))}] + {:property property-page + :class class-page + :hidden hidden-page + :normal-page normal-page + :reaction-entity reaction-entity + :property-history-block property-history-block + :closed-value-block closed-value-block + :property-value-block property-value-block + :block block + :asset-block asset-block + :file-block file-block + :db-ident-key-value db-ident-key-val + :property-value-placeholder property-value-placeholder})) + +(def DB + "Malli schema for entities from db-schema/schema. In order to + thoroughly validate properties, the entities and this schema should be + prepared with update-properties-in-ents and update-properties-in-schema + respectively" + [:sequential Data]) + +;; Keep malli schema in sync with db schema +;; ======================================== +(let [malli-many-ref-attrs (->> (concat property-attrs page-attrs block-attrs page-or-block-attrs (rest closed-value-block*)) + (filter #(= (last %) [:set :int])) + (map first) + (into db-property/public-db-attribute-properties) + set)] + (when-let [undeclared-ref-attrs (seq (remove malli-many-ref-attrs db-schema/card-many-ref-type-attributes))] + (throw (ex-info (str "The malli DB schema is missing the following cardinality-many ref attributes from datascript's schema: " + (string/join ", " undeclared-ref-attrs)) + {})))) + +(let [malli-one-ref-attrs (->> (concat property-attrs page-attrs block-attrs page-or-block-attrs (rest normal-page)) + (filter #(= (last %) :int)) + (map first) + set)] + (when-let [undeclared-ref-attrs (seq (remove malli-one-ref-attrs db-schema/card-one-ref-type-attributes))] + (throw (ex-info (str "The malli DB schema is missing the following cardinality-one ref attributes from datascript's schema: " + (string/join ", " undeclared-ref-attrs)) + {})))) + +(let [malli-non-ref-attrs (->> (concat property-attrs page-attrs block-attrs page-or-block-attrs (rest normal-page)) + (concat (rest file-block) (rest property-value-block) + (rest db-ident-key-val) (rest internal-property)) + (remove #(= (last %) [:set :int])) + (map first) + set)] + (when-let [undeclared-attrs (seq (remove malli-non-ref-attrs db-schema/db-non-ref-attributes))] + (throw (ex-info (str "The malli DB schema is missing the following non ref attributes from datascript's schema: " + (string/join ", " undeclared-attrs)) + {})))) diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs new file mode 100644 index 0000000..35e5cbb --- /dev/null +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -0,0 +1,1024 @@ +(ns logseq.db.frontend.property + "Property related fns for DB graphs and frontend/datascript usage" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [flatland.ordered.map :refer [ordered-map]] + [logseq.common.defkeywords :refer [defkeywords]] + [logseq.common.uuid :as common-uuid] + [logseq.db.common.order :as db-order] + [logseq.db.frontend.db-ident :as db-ident] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property.type :as db-property-type])) + +(def ^:private property-ignore-rtc + {:rtc/ignore-attr-when-syncing true}) + +;; Main property vars +;; ================== + +(def ^:large-vars/data-var built-in-properties + "Map of built in properties for db graphs with their :db/ident as keys. + Each property has a config map with the following keys: + TODO: Move some of these keys to :properties since :schema is a deprecated concept + * :schema - Property's schema. Required key. Has the following common keys: + * :type - Property type + * :cardinality - property cardinality. Default to one/single cardinality if not set + * :hide? - Boolean which hides property when set on a block or exported e.g. slides + * :public? - Boolean which allows property to be used by user: add and remove property to blocks/pages + and queryable via property and has-property rules. When it's not set, it's the same as false + * :view-context - Keyword to indicate which view contexts a property can be + seen in when :public? is set. Valid values are :page, :block and :never. Property can + be viewed in any context if not set + * :title - Property's :block/title + * :attribute - Property keyword that is saved to a datascript attribute outside of :block/properties + * :queryable? - Boolean for whether property can be queried in the query builder + * :closed-values - Vec of closed-value maps for properties with choices. Map + has keys :value, :db-ident, :uuid and :icon + * :rtc - submap for RTC configs. view docs by jumping to keyword definitions. + " + (apply + ordered-map + (defkeywords + :logseq.property/type {:title "Property type" + :schema {:type :keyword + :hide? true}} + :logseq.property/hide? {:title "Hide this property or page" + :schema {:type :checkbox + :hide? true}} + :logseq.property/public? {:title "Property public?" + :schema {:type :checkbox + :hide? true}} + :logseq.property/view-context {:title "Property view context" + :schema {:type :keyword + :hide? true}} + :logseq.property/ui-position {:title "Property position" + :schema {:type :keyword + :hide? true}} + :logseq.property/classes {:title "Property classes" + :schema {:type :entity + :cardinality :many + :public? false + :hide? true}} + :logseq.property/value {:title "Property value" + :schema {:type :any + :public? false + :hide? true}} + + :block/alias {:title "Alias" + :attribute :block/alias + :schema {:type :page + :cardinality :many + :view-context :page + :public? true} + :queryable? true} + :block/tags {:title "Tags" + :attribute :block/tags + :schema {:type :class + :cardinality :many + :public? true} + :queryable? true} + :block/parent {:title "Node parent" + :attribute :block/parent + :schema {:type :entity + :public? false + :hide? true}} + :block/order {:title "Node order" + :attribute :block/order + :schema {:type :string + :public? false + :hide? true}} + :block/collapsed? {:title "Node collapsed?" + :attribute :block/collapsed? + :schema {:type :checkbox + :public? false + :hide? true}} + :block/page {:title "Node page" + :attribute :block/page + :schema {:type :entity + :public? false + :hide? true}} + :block/refs {:title "Node references" + :attribute :block/refs + :schema {:type :entity + :cardinality :many + :public? false + :hide? true}} + :block/link {:title "Node links to" + :attribute :block/link + :schema {:type :entity + :public? false + :hide? true}} + :block/title {:title "Node title" + :attribute :block/title + :schema {:type :string + :public? false + :hide? true}} + :block/closed-value-property {:title "Closed value property" + :attribute :block/closed-value-property + :schema {:type :entity + :public? false + :hide? true}} + :block/journal-day {:title "Journal date" + :attribute :block/journal-day + :schema {:type :raw-number + :public? false + :hide? true} + :queryable? true} + :block/created-at {:title "Node created at" + :attribute :block/created-at + :schema {:type :datetime + :public? false + :hide? true}} + :block/updated-at {:title "Node updated at" + :attribute :block/updated-at + :schema {:type :datetime + :public? false + :hide? true}} + :logseq.property.node/display-type {:title "Node Display Type" + :schema {:type :keyword + :public? false + :hide? true + :view-context :block} + :queryable? true} + :logseq.property/description {:title "Description" + :schema + {:type :default + :public? true}} + :logseq.property.code/lang {:title "Code Mode" + :schema {:type :string + :public? false + :hide? true + :view-context :block} + :queryable? true} + :logseq.property/default-value {:title "Default value" + :schema {:type :entity + :public? false + :hide? true + :view-context :property}} + :logseq.property/scalar-default-value {:title "Non ref type default value" + :schema {:type :any + :public? false + :hide? true + :view-context :property}} + :logseq.property.class/extends {:title "Extends" + :schema {:type :class + :cardinality :many + :public? true + :view-context :class} + :queryable? true + :properties + {:logseq.property/description "This enables tags to inherit properties from other tags"}} + :logseq.property.class/properties {:title "Tag Properties" + :schema {:type :property + :cardinality :many + :public? true + :view-context :never}} + :logseq.property.class/bidirectional-property-title {:title "Bidirectional property title" + :schema {:type :string + :public? true + :view-context :class}} + :logseq.property.class/enable-bidirectional? {:title "Enable bidirectional properties" + :schema {:type :checkbox + :public? true + :view-context :class} + :properties + {:logseq.property/description "When enabled, this tag will show reverse nodes that link to the current node via properties."}} + :logseq.property/hide-empty-value {:title "Hide empty value" + :schema {:type :checkbox + :public? true + :view-context :property} + :properties + {:logseq.property/description "Hides a property's value on any node when empty e.g. when a property appears on a node through a tag."}} + :logseq.property.class/hide-from-node {:title "Hide from Node" + :schema {:type :checkbox + :public? true + :view-context :class}} + :logseq.property/query {:title "Query" + :schema {:type :default + :public? true + :hide? true + :view-context :block}} + :logseq.property/page-tags {:title "Page Tags" + :schema {:type :page + :public? true + :view-context :page + :cardinality :many} + :properties + {:logseq.property/description "Provides a way for a page to associate to another page i.e. backward compatible tagging."}} + :logseq.property/background-color {:title "Background color" + :schema {:type :default :hide? true}} + ;; number (1-6) or boolean for auto heading + :logseq.property/heading {:title "Heading" + :schema {:type :any :hide? true} + :queryable? true} + :logseq.property/created-from-property {:title "Created from property" + :schema {:type :entity + :hide? true}} + :logseq.property/built-in? {:title "Built in?" + :schema {:type :checkbox + :hide? true}} + :logseq.property/asset {:title "Asset" + :schema {:type :entity + :hide? true}} + ;; used by pdf + ;; TODO: remove ls-type + :logseq.property/ls-type {:schema {:type :keyword + :hide? true}} + + :logseq.property.pdf/hl-type {:title "Annotation type" + :schema {:type :keyword :hide? true}} + :logseq.property.pdf/hl-color {:title "Annotation color" + :schema {:type :default :hide? true} + :closed-values + (mapv (fn [[db-ident value]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)}) + [[:logseq.property/color.yellow "yellow"] + [:logseq.property/color.red "red"] + [:logseq.property/color.green "green"] + [:logseq.property/color.blue "blue"] + [:logseq.property/color.purple "purple"]])} + :logseq.property.pdf/hl-page {:title "Annotation page" + :schema {:type :raw-number :hide? true}} + :logseq.property.pdf/hl-image {:title "Annotation image" + :schema {:type :entity :hide? true}} + :logseq.property.pdf/hl-value {:title "Annotation data" + :schema {:type :map :hide? true}} + ;; FIXME: :logseq.property/order-list-type should updated to closed values + :logseq.property/order-list-type {:title "List type" + :schema {:type :default + :hide? true}} + :logseq.property.linked-references/includes {:title "Included references" + :schema {; could be :entity to support blocks(objects) in the future + :type :node + :cardinality :many + :hide? true}} + :logseq.property.linked-references/excludes {:title "Excluded references" + :schema {:type :node + :cardinality :many + :hide? true}} + :logseq.property.comments/blocks {:title "Commented blocks" + :schema {:type :node + :cardinality :many + :public? false + :hide? true}} + + ;; Journal props + :logseq.property.journal/title-format {:title "Title Format" + :schema + {:type :string + :public? false}} + + :logseq.property/choice-checkbox-state {:title "Choice checkbox state" + :schema {:type :checkbox + :hide? true} + :queryable? false} + ;; tag-scoped choice, a choice can be specified locally for specified tags + :logseq.property/choice-classes {:title "Choice classes" + :schema {:type :class + :cardinality :many + :public? false + :hide? true + :view-context :never} + :queryable? false} + ;; tag can define which global choices are hidden for its objects + :logseq.property/choice-exclusions {:title "Choice exclusions" + :schema {:type :node + :cardinality :many + :public? false + :hide? true + :view-context :never} + :queryable? false} + :logseq.property/checkbox-display-properties {:title "Properties displayed as checkbox" + :schema {:type :property + :cardinality :many + :hide? true} + :queryable? false} + ;; Task props + :logseq.property/status {:title "Status" + :schema + {:type :default + :public? true + :ui-position :block-left} + :closed-values + (mapv (fn [[db-ident value icon checkbox-state]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident) + :icon {:type :tabler-icon :id icon} + :properties (when (some? checkbox-state) + {:logseq.property/choice-checkbox-state checkbox-state})}) + [[:logseq.property/status.backlog "Backlog" "Backlog"] + [:logseq.property/status.todo "Todo" "Todo" false] + [:logseq.property/status.doing "Doing" "InProgress50"] + [:logseq.property/status.in-review "In Review" "InReview"] + [:logseq.property/status.done "Done" "Done" true] + [:logseq.property/status.canceled "Canceled" "Cancelled"]]) + :properties {:logseq.property/hide-empty-value true + :logseq.property/default-value :logseq.property/status.todo + :logseq.property/enable-history? true} + :queryable? true} + :logseq.property/priority {:title "Priority" + :schema + {:type :default + :public? true + :ui-position :block-left} + :closed-values + (mapv (fn [[db-ident value icon]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident) + :icon {:type :tabler-icon :id icon}}) + [[:logseq.property/priority.low "Low" "priorityLvlLow"] + [:logseq.property/priority.medium "Medium" "priorityLvlMedium"] + [:logseq.property/priority.high "High" "priorityLvlHigh"] + [:logseq.property/priority.urgent "Urgent" "priorityLvlUrgent"]]) + :properties {:logseq.property/hide-empty-value true + :logseq.property/enable-history? true}} + :logseq.property/deadline {:title "Deadline" + :schema {:type :datetime + :public? true + :ui-position :block-below} + :properties {:logseq.property/hide-empty-value true + :logseq.property/description "Use it to finish something at a specific date(time)."} + :queryable? true} + :logseq.property/scheduled {:title "Scheduled" + :schema {:type :datetime + :public? true + :ui-position :block-below} + :properties {:logseq.property/hide-empty-value true + :logseq.property/description "Use it to plan something to start at a specific date(time)."} + :queryable? true} + :logseq.property.repeat/recur-frequency (let [schema {:type :number + :public? false}] + {:title "Repeating recur frequency" + :schema schema + :properties {:logseq.property/hide-empty-value true + :logseq.property/default-value 1} + :queryable? true}) + :logseq.property.repeat/recur-unit {:title "Repeating recur unit" + :schema {:type :default + :public? false} + :closed-values (mapv (fn [[db-ident value]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)}) + [[:logseq.property.repeat/recur-unit.minute "Minute"] + [:logseq.property.repeat/recur-unit.hour "Hour"] + [:logseq.property.repeat/recur-unit.day "Day"] + [:logseq.property.repeat/recur-unit.week "Week"] + [:logseq.property.repeat/recur-unit.month "Month"] + [:logseq.property.repeat/recur-unit.year "Year"]]) + :properties {:logseq.property/hide-empty-value true + :logseq.property/default-value :logseq.property.repeat/recur-unit.day} + :queryable? true} + :logseq.property.repeat/repeated? {:title "Node Repeats?" + :schema {:type :checkbox + :hide? true} + :queryable? true} + :logseq.property.repeat/repeat-type {:title "Repeating type" + :schema {:type :default + :public? false} + :closed-values (mapv (fn [[db-ident value]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)}) + [[:logseq.property.repeat/repeat-type.dotted-plus "Advance from completion"] + [:logseq.property.repeat/repeat-type.plus "Advance from scheduled"] + [:logseq.property.repeat/repeat-type.double-plus "Advance from scheduled, skip to future"]]) + :properties {:logseq.property/hide-empty-value true + :logseq.property/default-value :logseq.property.repeat/repeat-type.double-plus} + :queryable? true} + :logseq.property.repeat/temporal-property {:title "Repeating Temporal Property" + :schema {:type :property + :hide? true}} + :logseq.property.repeat/checked-property {:title "Repeating Checked Property" + :schema {:type :property + :hide? true}} + + :logseq.property/assignee {:title "Assignee" + :schema {:type :node + :cardinality :many + :public? true + :ui-position :block-below + :classes #{:logseq.class/Page}} + :properties {:logseq.property/hide-empty-value true} + :queryable? true} + + ;; TODO: Add more props :Estimate, :Cycle, :Project + + :logseq.property/icon {:title "Icon" + :schema {:type :map}} + :logseq.property/publishing-public? {:title "Publishing Public?" + :schema + {:type :checkbox + :hide? true + :view-context :page + :public? true}} + :logseq.property.publish/published-url {:title "Published URL" + :schema + {:type :url + :view-context :page + :public? true}} + :logseq.property/exclude-from-graph-view {:title "Excluded from Graph view?" + :schema + {:type :checkbox + :hide? true + :view-context :page + :public? true}} + + :logseq.property.view/type {:title "View Type" + :schema + {:type :default + :public? false + :hide? true} + :closed-values + (mapv (fn [[db-ident value icon]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident) + :icon {:type :tabler-icon :id icon}}) + [[:logseq.property.view/type.table "Table View" "table"] + [:logseq.property.view/type.list "List View" "list"] + [:logseq.property.view/type.gallery "Gallery View" "layout-grid"]]) + :properties {:logseq.property/default-value :logseq.property.view/type.table} + :queryable? true} + + :logseq.property.view/feature-type {:title "View Feature Type" + :schema + {:type :keyword + :public? false + :hide? true} + :queryable? false} + + :logseq.property.view/group-by-property {:title "View group by property" + :schema + {:type :property + :public? false + :hide? true} + :queryable? true} + + :logseq.property.view/gallery-asset-property {:title "Gallery asset property" + :schema + {:type :property + :hide? true + :public? false}} + + :logseq.property.view/gallery-display-properties {:title "Gallery display properties" + :schema + {:type :property + :cardinality :many + :hide? true + :public? false}} + + :logseq.property.view/gallery-card-size {:title "Gallery card size" + :schema + {:type :keyword + :hide? true + :public? false} + :properties {:logseq.property/scalar-default-value :default} + :rtc property-ignore-rtc} + + :logseq.property.view/gallery-card-width {:title "Gallery card width" + :schema + {:type :raw-number + :hide? true + :public? false} + :rtc property-ignore-rtc} + + :logseq.property.view/gallery-card-height {:title "Gallery card height" + :schema + {:type :raw-number + :hide? true + :public? false} + :rtc property-ignore-rtc} + + :logseq.property.view/sort-groups-by-property {:title "View sort groups by" + :schema + {:type :property + :hide? true + :public? false} + :rtc property-ignore-rtc} + :logseq.property.view/sort-groups-desc? {:title "View sort groups DESC" + :schema + {:type :checkbox + :hide? true + :public? false} + :properties {:logseq.property/scalar-default-value true} + :rtc property-ignore-rtc} + + :logseq.property.table/sorting {:title "View sorting" + :schema + {:type :coll + :hide? true + :public? false} + :rtc property-ignore-rtc} + + :logseq.property.table/filters {:title "View filters" + :schema + {:type :map + :hide? true + :public? false}} + + :logseq.property.table/hidden-columns {:title "View hidden columns" + :schema + {:type :keyword + :cardinality :many + :hide? true + :public? false}} + + :logseq.property.table/ordered-columns {:title "View ordered columns" + :schema + {:type :coll + :hide? true + :public? false}} + + :logseq.property.table/sized-columns {:title "View columns settings" + :schema + {:type :map + :hide? true + :public? false}} + :logseq.property.table/pinned-columns {:title "Table view pinned columns" + :schema + {:type :property + :cardinality :many + :hide? true + :public? false}} + :logseq.property/view-for {:title "This view belongs to" + :schema + {:type :node + :hide? true + :public? false}} + :logseq.property.asset/type {:title "File Type" + :schema {:type :string + :hide? true + :public? false} + :queryable? true} + :logseq.property.asset/external-url {:title "External URL" + :schema {:type :string + :hide? false + :public? true} + :queryable? true} + ;; need to rename for better alignment with practical purposes + :logseq.property.asset/external-file-name {:title "External file name" + :schema {:type :string + :hide? true + :public? false} + :queryable? false} + :logseq.property.asset/size {:title "File Size" + :schema {:type :raw-number + :hide? true + :public? false} + :queryable? true} + :logseq.property.asset/width {:title "Image width" + :schema {:type :raw-number + :hide? true + :public? false} + :queryable? true} + :logseq.property.asset/height {:title "Image height" + :schema {:type :raw-number + :hide? true + :public? false} + :queryable? true} + :logseq.property.asset/checksum {:title "File checksum" + :schema {:type :string + :hide? true + :public? false}} + :logseq.property.asset/last-visit-page {:title "Last visit page" + :schema {:type :raw-number + :hide? true + :public? false} + :rtc property-ignore-rtc} + :logseq.property.asset/remote-metadata {:title "File remote metadata" + :schema + {:type :map + :hide? true + :public? false} + :properties + {:logseq.property/description "Metadata of asset in remote storage"}} + :logseq.property.asset/resize-metadata {:title "Asset resize metadata" + :schema {:type :map + :hide? true + :public? false}} + :logseq.property.asset/align {:title "Asset alignment" + :schema {:type :keyword + :hide? true + :public? false} + :queryable? false} + :logseq.property.fsrs/due {:title "Due" + :schema + {:type :datetime + :hide? false + :public? false}} + :logseq.property.fsrs/state {:title "State" + :schema + {:type :map + :hide? false ; TODO: show for debug now, hide it later + :public? false}} + :logseq.property.user/name {:title "User Name" + :schema + {:type :string + :hide? false + :public? true}} + :logseq.property.user/email {:title "User Email" + :schema + {:type :string + :hide? false + :public? true}} + :logseq.property.user/avatar {:title "User Avatar" + :schema + {:type :string + :hide? false + :public? true}} + :logseq.property/enable-history? {:title "Enable property history" + :schema {:type :checkbox + :public? true + :view-context :property} + :properties + {:logseq.property/description "Records history anytime a property's value changes on a node."}} + :logseq.property.history/block {:title "History block" + :schema {:type :entity + :hide? true}} + :logseq.property.history/property {:title "History property" + :schema {:type :property + :hide? true}} + :logseq.property.history/ref-value {:title "History value" + :schema {:type :entity + :hide? true}} + :logseq.property.history/scalar-value {:title "History scalar value" + :schema {:type :any + :hide? true}} + :logseq.property/created-by-ref {:title "Node created by" + :schema {:type :entity + :hide? true} + :queryable? true} + :logseq.property/deleted-at {:title "Deleted at" + :schema {:type :datetime + :hide? true + :public? false}} + :logseq.property/deleted-by-ref {:title "Deleted by" + :schema {:type :entity + :hide? true + :public? false}} + :logseq.property.recycle/original-parent {:title "Recycle original parent" + :schema {:type :node + :hide? true + :public? false}} + :logseq.property.recycle/original-page {:title "Recycle original page" + :schema {:type :node + :hide? true + :public? false}} + :logseq.property.recycle/original-order {:title "Recycle original order" + :schema {:type :string + :hide? true + :public? false}} + :logseq.property.reaction/emoji-id {:title "Reaction emoji" + :schema {:type :string + :public? false + :hide? true}} + :logseq.property.reaction/target {:title "Reaction target" + :schema {:type :node + :public? false + :hide? true}} + :logseq.property.agent/session-id {:title "Agent Session ID" + :schema {:type :string + :public? true + :hide? true} + :properties + {:logseq.property/description "Stores the AgentBridge session ID for a routed task."}} + :logseq.property/used-template {:title "Used template" + :schema {:type :node + :public? false + :hide? true + :classes #{:logseq.class/Template}}} + :logseq.property/template-applied-to {:title "Apply template to tags" + :schema {:type :class + :cardinality :many + :public? true} + :queryable? true} + :logseq.property.sync/large-title-object {:title "Reference to large block title stored in remote object storage" + :schema {:type :map + :public? false + :hide? true}}))) + +(def public-built-in-properties + (->> built-in-properties + (keep (fn [[k v]] (when (get-in v [:schema :public?]) k))) + set)) + +(def db-attribute-properties + "Internal properties that are also db schema attributes" + #{:block/alias :block/tags :block/parent + :block/order :block/collapsed? :block/page + :block/refs :block/link + :block/title :block/closed-value-property :block/journal-day + :block/created-at :block/updated-at}) + +(assert (= db-attribute-properties + (set (keep (fn [[_k {:keys [attribute]}]] (when attribute attribute)) + built-in-properties))) + "All db attribute properties are configured in built-in-properties") + +(def private-db-attribute-properties + "db-attribute properties that are not visible to user" + (->> db-attribute-properties + (remove #(get-in built-in-properties [% :schema :public?])) + set)) + +(def public-db-attribute-properties + "db-attribute properties that are visible to user" + (set/difference db-attribute-properties private-db-attribute-properties)) + +(def read-only-properties + "Property values that shouldn't be updated" + #{:logseq.property/built-in?}) + +(def schema-properties-map + "Maps schema unqualified keywords to their qualified keywords. + The qualified keywords are all properties except for :db/cardinality + which is a datascript attribute" + {:cardinality :db/cardinality + :type :logseq.property/type + :hide? :logseq.property/hide? + :public? :logseq.property/public? + :ui-position :logseq.property/ui-position + :view-context :logseq.property/view-context + :classes :logseq.property/classes}) + +(def schema-properties + "Properties that used to be in block/schema. Schema originally referred to just type and cardinality + but expanded to include a property's core configuration because it was easy to add to the schema map. + We should move some of these out since they are just like any other properties e.g. :view-context" + (set (vals schema-properties-map))) + +(def logseq-property-namespaces + #{"logseq.property" "logseq.property.tldraw" "logseq.property.pdf" "logseq.property.fsrs" + "logseq.property.linked-references" "logseq.property.asset" "logseq.property.table" "logseq.property.node" + "logseq.property.code" "logseq.property.repeat" + "logseq.property.journal" "logseq.property.class" "logseq.property.view" + "logseq.property.user" "logseq.property.history" + "logseq.property.reaction" "logseq.property.sync" "logseq.property.publish" + "logseq.property.recycle" "logseq.property.comments" "logseq.property.agent"}) + +(defn logseq-property? + "Determines if keyword is a logseq property" + [kw] + (contains? logseq-property-namespaces (namespace kw))) + +(defn user-property-namespace? + "Determines if namespace string is a user property" + [s] + (string/includes? s ".property")) + +(defn plugin-property? + "Determines if keyword is a plugin property" + [kw] + (string/starts-with? (namespace kw) "plugin.property.")) + +(defn internal-property? + "Determines if ident kw is an internal property. This includes db-attribute properties + unlike logseq-property? and doesn't include non-property idents unlike internal-ident?" + [k] + (let [k-name (namespace k)] + (and k-name + (or (contains? logseq-property-namespaces k-name) + (contains? public-db-attribute-properties k))))) + +(defn property? + "Determines if ident kw is a property visible to user" + [k] + (let [k-name (namespace k)] + (and k-name + (or (contains? logseq-property-namespaces k-name) + (user-property-namespace? k-name) + ;; disallow private db-attribute-properties as they cause unwanted refs + ;; and appear noisily in debugging contexts + (and (keyword? k) (contains? public-db-attribute-properties k)))))) + +;; Helper fns +;; ========== + +(defn properties + "Returns a block's properties as a map indexed by property's db-ident. + Use this in deps because nbb can't use :block/properties from entity-plus" + [e] + (->> (into {} e) + (filter (fn [[k _]] (property? k))) + (into {}))) + +(defn valid-property-name? + [s] + {:pre [(string? s)]} + ;; Disallow tags or page refs as they would create unreferenceable page names + (not (re-find #"^(#|\[\[)" s))) + +(defn built-in-closed-values + "Gets :closed-values for given built-in property ident" + [ident] + (get-in built-in-properties [ident :closed-values])) + +(defn get-closed-property-values + [db property-id] + (when db + (when-let [property (d/entity db property-id)] + (some->> (:block/_closed-value-property property) + (remove entity-util/recycled?) + (sort-by :block/order))))) + +(defn closed-value-content + "Gets content/value of a given closed value ent/map. Works for all closed value types" + [ent] + (or (:block/title ent) + (:logseq.property/value ent))) + +(defn property-value-content + "Given an entity, gets the content for the property value of a ref type + property i.e. what the user sees. For page types the content is the page name" + [ent] + (or (:block/title ent) + (:logseq.property/value ent))) + +(defn get-closed-value-entity-by-name + "Given a property, finds one of its closed values by name or nil if none + found. Works for all closed value types" + [db db-ident value-content] + (let [values (get-closed-property-values db db-ident)] + (some (fn [e] + (when (= (closed-value-content e) value-content) + e)) values))) + +(def default-user-namespace "user.property") + +(defn create-user-property-ident-from-name + "Creates a property :db/ident for a default user namespace. + NOTE: Only use this when creating a db-ident for a new property." + ([property-name] (create-user-property-ident-from-name property-name default-user-namespace)) + ([property-name user-namespace] + (db-ident/create-db-ident-from-name user-namespace property-name))) + +(defn normalize-sorted-entities-block-order + "Return tx-data. + Generate appropriate :block/order values for sorted-blocks with :block/order value = nil or duplicated" + [sorted-entities] + (let [parts (partition-by :block/order sorted-entities) + [_ tx-data] + (reduce (fn [[start-order tx-data] ents] + (let [n (count ents)] + (if (> n 1) + (let [orders (db-order/gen-n-keys n start-order (:block/order (first ents))) + tx-data* (apply conj tx-data (map + (fn [order ent] + {:db/id (:db/id ent) + :block/order order}) + orders ents))] + [(last orders) tx-data*]) + [(:block/order (first ents)) tx-data]))) + [nil []] parts)] + tx-data)) + +(defn sort-properties + "Sort by :block/order and :block/uuid. + - nil is greater than non-nil + - When block/order is equal, sort by block/uuid" + [prop-entities] + (sort + (fn [a b] + (let [order-a (:block/order a) + order-b (:block/order b)] + (cond + (and (nil? order-a) (nil? order-b)) + (compare (:block/uuid a) (:block/uuid b)) + + (nil? order-a) 1 + (nil? order-b) -1 + + (= order-a order-b) + (compare (:block/uuid a) (:block/uuid b)) + + :else + (compare order-a order-b)))) + prop-entities)) + +(defn get-class-ordered-properties + [class-entity] + (sort-properties (:logseq.property.class/properties class-entity))) + +(defn property-created-block? + "`block` has been created in a property and it's not a closed value." + [block] + (and (map? block) + (:logseq.property/created-from-property block) + (:block/page block) + (not (some? (closed-value-content block))))) + +(defn many? + [property] + (= (:db/cardinality property) :db.cardinality/many)) + +(defn public-built-in-property? + "Indicates whether built-in property can be seen and edited by users" + [entity] + ;; No need to do :built-in? check yet since user properties can't set this + (:logseq.property/public? entity)) + +(defn get-property-schema + [property-m] + (select-keys property-m schema-properties)) + +(defn built-in-has-ref-value? + "Given a built-in's db-ident, determine if its property value is a ref" + [db-ident] + (contains? db-property-type/value-ref-property-types + (get-in built-in-properties [db-ident :schema :type]))) + +(defn- normalize-choice-ids + [values] + (set (keep :db/id values))) + +(defn scoped-closed-values + "Get scoped closed values for a given block" + [property block & {:keys [values]}] + (let [values (or values (:property/closed-values property)) + classes (:block/tags block) + class-ids* (set (keep :db/id classes)) + class-ids (if (entity-util/class? block) + (conj class-ids* (:db/id block)) + class-ids*) + excluded-ids (normalize-choice-ids + (mapcat :logseq.property/choice-exclusions classes))] + (filter (fn [value] + (let [scope-ids (set (keep :db/id (:logseq.property/choice-classes value)))] + (cond + (empty? scope-ids) + (not (contains? excluded-ids (:db/id value))) + + (seq class-ids) + (seq (set/intersection scope-ids class-ids)) + + :else + false))) + (remove entity-util/recycled? values)))) + +(defn lookup + "Get the property value by a built-in property's db-ident from coll" + [block db-ident] + (let [val (get block db-ident)] + (if (built-in-has-ref-value? db-ident) (property-value-content val) val))) + +(defn get-block-property-value + "Get the value of built-in block's property by its db-ident" + [db block db-ident] + (when db + (let [block (or (d/entity db (:db/id block)) block)] + (lookup block db-ident)))) + +(defn built-in-ident->i18n-key + "Derives an i18n key from a built-in db-ident. + Returns nil for non-built-in idents. + Examples: + :block/alias -> :property.built-in/alias + :logseq.property/status -> :property.built-in/status + :logseq.property.code/lang -> :property.built-in/code-lang + :logseq.class/Task -> :class.built-in/task + :logseq.property/status.backlog -> :property.status/backlog" + [db-ident] + (let [ns-str (namespace db-ident) + n (name db-ident)] + (cond + (= ns-str "logseq.class") + (keyword "class.built-in" (string/lower-case n)) + + (or (= ns-str "logseq.property") + (string/starts-with? ns-str "logseq.property.")) + (let [sub-ns (when (not= ns-str "logseq.property") + (subs ns-str (count "logseq.property."))) + dot-idx (string/index-of n ".") + clean-n (string/replace n #"\?$" "")] + (if dot-idx + ;; Closed value: logseq.property/status.backlog -> :property.status/backlog + (let [prop-part (subs clean-n 0 dot-idx) + choice-part (subs clean-n (inc dot-idx)) + subdomain (if sub-ns (str sub-ns "-" prop-part) prop-part)] + (keyword (str "property." subdomain) choice-part)) + ;; Property definition + (if sub-ns + (keyword "property.built-in" (str sub-ns "-" clean-n)) + (keyword "property.built-in" clean-n)))) + + (= ns-str "block") + (keyword "property.built-in" (string/replace n #"\?$" "")) + + :else nil))) + +(defn built-in-display-title + "Returns the display title for a built-in entity (property or class). + `translate-fn` takes an i18n keyword and returns the translated string. + Falls back to (:block/title entity) when no translation is available." + [entity translate-fn] + (or (when-let [i18n-key (some-> (:db/ident entity) built-in-ident->i18n-key)] + (let [s (translate-fn i18n-key)] + (when-not (string/starts-with? (str s) "{Missing") + s))) + (:block/title entity))) diff --git a/deps/db/src/logseq/db/frontend/property/build.cljs b/deps/db/src/logseq/db/frontend/property/build.cljs new file mode 100644 index 0000000..12a1dd5 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/property/build.cljs @@ -0,0 +1,163 @@ +(ns logseq.db.frontend.property.build + "Builds core property concepts" + (:require [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] + [logseq.db.common.order :as db-order] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.db.sqlite.util :as sqlite-util])) + +(defn- closed-value-new-block + [block-id block-type value property] + (let [property-id (:db/ident property)] + (merge {:block/uuid block-id + :block/page property-id + :block/closed-value-property property-id + :logseq.property/created-from-property (if (= property-id :logseq.property/default-value) + [:block/uuid block-id] + property-id) + :block/parent property-id} + (if (db-property-type/property-value-content? block-type property) + {:logseq.property/value value} + {:block/title value})))) + +(defn build-closed-value-block + "Builds a closed value block to be transacted" + [block-uuid block-type block-value property {:keys [db-ident icon]}] + (assert block-uuid (uuid? block-uuid)) + (cond-> + (closed-value-new-block block-uuid block-type block-value property) + (and db-ident (keyword? db-ident)) + (assoc :db/ident db-ident) + + icon + (assoc :logseq.property/icon icon) + + true + sqlite-util/block-with-timestamps)) + +(defn closed-values->blocks + [property] + (map (fn [{uuid' :uuid :keys [db-ident value icon schema properties]}] + (cond-> + (build-closed-value-block + uuid' + (:type schema) + value + property + {:db-ident db-ident :icon icon}) + (seq properties) + (merge properties) + true + (assoc :block/order (db-order/gen-key)))) + (:closed-values property))) + +(defn build-closed-values + "Builds all the tx needed for property with closed values including + the hidden page and closed value blocks as needed" + [db-ident prop-name property {:keys [property-attributes properties]}] + (let [property-schema (or (:schema property) + (db-property/get-property-schema property)) + property-tx (merge (sqlite-util/build-new-property db-ident property-schema {:title prop-name + :ref-type? true + :properties properties}) + property-attributes)] + (into [property-tx] + (closed-values->blocks property)))) + +(defn build-property-value-block + "Builds a property value entity given a block map/entity, a property entity or + ident and its property value. Takes the following options: + * :block-uuid - :block/uuid for property value entity + * :properties - Additional properties and attributes to add to entity" + [block property value & {:keys [block-uuid properties]}] + (let [block-id (or (:db/id block) (:db/ident block))] + (cond-> + (merge + {:block/uuid (or block-uuid (common-uuid/gen-uuid)) + :block/page (if (:block/page block) + (:db/id (:block/page block)) + ;; page block + block-id) + :block/parent block-id + :logseq.property/created-from-property (if (= (:db/ident property) :logseq.property/default-value) + block-id + (or (:db/id property) {:db/ident (:db/ident property)})) + :block/order (db-order/gen-key)} + (if (db-property-type/property-value-content? (:logseq.property/type property) property) + {:logseq.property/value value} + {:block/title value})) + true + common-util/block-with-timestamps + properties + (merge properties)))) + +(defn build-property-values-tx-m + "Builds a map of property names to their property value blocks to be + transacted, given a block and a properties map with raw property values. The + properties map can have keys that are db-idents or they can be maps. If a map, + it should have :original-property-id and :db/ident keys. See + ->property-value-tx-m for such an example. Options: + + * :pure? - ensure this fn is a pure function + * :pvalue-map? - When set, property value is passed as a map with keys :value and :attributes. This + allows property values to have additional attributes" + [block properties & {:keys [pure? pvalue-map?]}] + ;; Build :db/id out of uuid if block doesn't have one for tx purposes + (let [block' (if (:db/id block) block (assoc block :db/id [:block/uuid (:block/uuid block)]))] + (->> properties + (map (fn [[k v*]] + (let [property-map (if (map? k) k {:db/ident k}) + gen-uuid-value-prefix (when pure? + (or (:db/ident block) (:block/uuid block))) + ->pvalue #(if pvalue-map? (:value %) %) + v (if (set? v*) + (set (map ->pvalue v*)) + (->pvalue v*)) + ->value-block-opts + (fn ->value-block-opts [v'] + (cond-> {} + (and pvalue-map? (seq (:attributes v'))) + (assoc :properties (:attributes v')) + pure? + (assoc :block-uuid + (common-uuid/gen-uuid :builtin-block-uuid + (str gen-uuid-value-prefix "-" (->pvalue v'))))))] + (assert (:db/ident property-map) "Key in map must have a :db/ident") + (when pure? (assert (some? gen-uuid-value-prefix) block)) + [(or (:original-property-id property-map) (:db/ident property-map)) + (cond + (and (set? v) (every? uuid? v)) + (set (map #(vector :block/uuid %) v)) + (set? v*) + (set + (map #(build-property-value-block block' property-map (->pvalue %) (->value-block-opts %)) + v*)) + (uuid? v) + [:block/uuid v] + :else + (build-property-value-block block' property-map v (->value-block-opts v*)))]))) + (into {})))) + +(defn- lookup-id? + [v] + (and (vector? v) + (= 2 (count v)) + (= :block/uuid (first v)) + (uuid? (second v)))) + +(defn build-properties-with-ref-values + "Given a properties map with property values to be transacted e.g. from + build-property-values-tx-m, build a properties map to be transacted with the block" + [prop-vals-tx-m] + (update-vals prop-vals-tx-m + (fn [v] + (cond + (and (set? v) (every? lookup-id? v)) + v + (set? v) + (set (map #(vector :block/uuid (:block/uuid %)) v)) + (lookup-id? v) + v + :else + (vector :block/uuid (:block/uuid v)))))) diff --git a/deps/db/src/logseq/db/frontend/property/type.cljs b/deps/db/src/logseq/db/frontend/property/type.cljs new file mode 100644 index 0000000..3615116 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/property/type.cljs @@ -0,0 +1,253 @@ +(ns logseq.db.frontend.property.type + "Provides property types and related helper fns e.g. property value validation + fns and their allowed schema attributes" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [logseq.common.util.macro :as macro-util] + [logseq.db.frontend.entity-util :as entity-util])) + +;; Config vars +;; =========== +;; These vars enumerate all known property types and their associated behaviors +;; except for validation which is in its own section + +(def internal-built-in-property-types + "Valid property types only for use by internal built-in-properties" + #{:keyword :map :coll :any :entity :class :page :property :string :json :raw-number}) + +(def user-built-in-property-types + "Valid property types for users in order they appear in the UI" + [:default :number :date :datetime :checkbox :url :node :asset]) + +(def user-allowed-internal-property-types + "Internal property types that users are allowed to store. These aren't available in the UI + so these would normally be created via EDN or the API." + #{:map :json :string}) + +(assert (set/subset? user-allowed-internal-property-types internal-built-in-property-types)) + +(def closed-value-property-types + "Valid property :type for closed values" + #{:default :number :url}) + +(def cardinality-property-types + "Valid property types that can change cardinality" + #{:default :number :url :date :node :asset}) + +(def default-value-ref-property-types + "Valid ref property :type for default value support" + #{:default :number :checkbox}) + +(def text-ref-property-types + "Valid ref property :types that support text" + #{:default :url :entity}) + +(assert (set/subset? cardinality-property-types (set user-built-in-property-types)) + "All closed value types are valid property types") + +(assert (set/subset? closed-value-property-types (set user-built-in-property-types)) + "All closed value types are valid property types") + +(def original-value-ref-property-types + "Property value ref types where the refed entity stores its value in + :logseq.property/value e.g. :number is stored as a number. new value-ref-property-types + should default to this as it allows for more querying power" + #{:number}) + +(def value-ref-property-types + "Property value ref types where the refed entities either store their value in + :logseq.property/value or :block/title (for :default)" + (into #{:default :url} original-value-ref-property-types)) + +(def user-ref-property-types + "User ref types. Property values that users see are stored in either + :logseq.property/value or :block/title. :block/title is for all the page related types" + (into #{:date :node :asset} value-ref-property-types)) + +(assert (set/subset? user-ref-property-types + (set user-built-in-property-types)) + "All ref types are valid property types") + +(def all-ref-property-types + "All ref types - user and internal" + (into #{:entity :class :page :property} user-ref-property-types)) + +(assert (set/subset? all-ref-property-types + (set/union (set user-built-in-property-types) internal-built-in-property-types)) + "All ref types are valid property types") + +;; Property value validation +;; ========================= +;; TODO: +;; Validate && list fixes for non-validated values when updating property schema + +(defn url? + "Test if it is a `protocol://`-style URL. Allows custom protocol such as `zotero`. + Originally from common-util/url? but does not need to be the same" + [s] + (and (string? s) + (try + (not (contains? #{nil} (.-origin (js/URL. s)))) + (catch :default _e + false)))) + +(defn macro-url? + [s] + ;; TODO: Confirm that macro expanded value is url when it's easier to pass data into validations + (macro-util/macro? s)) + +(defn- url-entity? + "Empty string, url or macro url" + [db val {:keys [new-closed-value? skip-strict-url-validate?]}] + (if new-closed-value? + (or (url? val) (macro-url? val)) + (when-let [ent (d/entity db val)] + (let [title (:block/title ent)] + (if skip-strict-url-validate? + (string? title) + (or (string/blank? title) (url? title) (macro-url? title))))))) + +(defn- entity? + [db id] + (some? (d/entity db id))) + +(defn- class-entity? + [db id] + (entity-util/class? (d/entity db id))) + +(defn- property-entity? + [db id] + (entity-util/property? (d/entity db id))) + +(defn- page-entity? + [db id] + (entity-util/page? (d/entity db id))) + +(defn- number-entity? + [db id-or-value {:keys [new-closed-value?]}] + (if new-closed-value? + (number? id-or-value) + (when-let [entity (d/entity db id-or-value)] + (number? (:logseq.property/value entity))))) + +(defn- text-entity? + [db s {:keys [new-closed-value?]}] + (if new-closed-value? + (string? s) + (when-let [ent (d/entity db s)] + (and (string? (:block/title ent)) + (some? (:block/page ent)))))) + +(defn- node-entity? + [db val] + (when-let [ent (d/entity db val)] + (some? (:block/title ent)))) + +;; :logseq.class/Asset is in ldb/private-tags, so users can't type `#Asset` +;; inline — we use the class programmatically here to scope :asset property +;; values to uploaded asset blocks. +(defn- asset-entity? + [db val] + (when-let [ent (d/entity db val)] + (and (some? (:block/title ent)) + (contains? (set (map :db/ident (:block/tags ent))) + :logseq.class/Asset)))) + +(defn- date? + [db val] + (when-let [ent (d/entity db val)] + (and (some? (:block/title ent)) + (entity-util/journal? ent)))) + +;; Internal usage +(def internal-validation-schemas + {:string [:fn + {:error/message "should be a string"} + string?] + :json [:fn + {:error/message "should be JSON string"} + string?] + :raw-number [:fn + {:error/message "should be a raw number"} + number?] + :entity [:fn + {:error/message "should be an Entity"} + entity?] + :class [:fn + {:error/message "should be a Class"} + class-entity?] + :property [:fn + {:error/message "should be a Property"} + property-entity?] + :page [:fn + {:error/message "should be a Page"} + page-entity?] + :keyword [:fn + {:error/message "should be a Clojure keyword"} + keyword?] + :map [:fn + {:error/message "should be a Clojure map"} + map?] + ;; coll elements are ordered as it's saved as a vec + :coll [:fn + {:error/message "should be a collection"} + coll?] + :any some?}) + +(def built-in-validation-schemas + "Map of types to malli validation schemas that validate a property value for that type" + (into + {:default [:fn + {:error/message "should be a text block"} + text-entity?] + :number [:fn + {:error/message "should be a number"} + number-entity?] + :date [:fn + {:error/message "should be a journal date"} + date?] + :datetime [:fn + {:error/message "should be a datetime"} + number?] + :checkbox [:fn + {:error/message "should be a boolean"} + boolean?] + :url [:fn + {:error/message "should be a URL"} + url-entity?] + :node [:fn + {:error/message "should be a node with a title"} + node-entity?] + :asset [:fn + {:error/message "should be an asset node"} + asset-entity?]} + internal-validation-schemas)) + +(assert (= (set (keys built-in-validation-schemas)) + (into internal-built-in-property-types + user-built-in-property-types)) + "Built-in property types must be equal") + +(def property-types-with-db + "Property types whose validation fn requires a datascript db" + #{:default :url :number :date :node :asset :entity :class :property :page}) + +;; Helper fns +;; ========== +(defn infer-property-type-from-value + "Infers a user defined built-in :type from property value(s)" + [val] + (cond + (number? val) :number + (url? val) :url + (contains? #{true false} val) :checkbox + :else :default)) + +(defn property-value-content? + "Whether property value should be stored in :logseq.property/value" + [block-type property] + (or + (original-value-ref-property-types (:logseq.property/type property)) + (and (= (:db/ident property) :logseq.property/default-value) + (original-value-ref-property-types block-type)))) diff --git a/deps/db/src/logseq/db/frontend/query_dsl.cljs b/deps/db/src/logseq/db/frontend/query_dsl.cljs new file mode 100644 index 0000000..7975e97 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/query_dsl.cljs @@ -0,0 +1,72 @@ +(ns logseq.db.frontend.query-dsl + "Pure parsing helpers shared by renderer and worker query DSL code." + (:require [clojure.string :as string] + [clojure.walk :as walk] + [logseq.common.util :as common-util] + [logseq.common.util.page-ref :as page-ref] + [logseq.db.frontend.property :as db-property])) + +(def ^:private tag-placeholder "~~~tag-placeholder~~~") + +(defn pre-transform + [s] + (if (common-util/wrapped-by-quotes? s) + s + (let [quoted-page-ref + (fn [matches] + (let [match' (string/replace (second matches) "#" tag-placeholder)] + (str "\"" page-ref/left-brackets match' page-ref/right-brackets "\"")))] + (some-> s + (string/replace #"\"?\[\[(.*?)\]\]\"?" quoted-page-ref) + (string/replace #"\(between ([^\)]+)\)" + (fn [[_ x]] + (->> (string/split x #" ") + (remove string/blank?) + (map (fn [value] + (if (or (contains? #{"+" "-"} (first value)) + (and (common-util/safe-re-find #"\d" (first value)) + (some #(string/ends-with? value %) + ["y" "m" "d" "h" "min"]))) + (keyword (name value)) + value))) + (string/join " ") + (common-util/format "(between %s)")))) + (string/replace #"\"[^\"]+\"" + #(string/replace % "#" tag-placeholder)) + (string/replace " #" " #tag ") + (string/replace #"^#" "#tag ") + (string/replace tag-placeholder "#"))))) + +(defn simplify-query + [query] + (if (string? query) + query + (walk/postwalk + (fn [form] + (if (and (coll? form) + (contains? #{'and 'or} (first form)) + (= 2 (count form))) + (second form) + form)) + query))) + +(defn get-timestamp-property + [form] + (let [property-name (second form)] + (when (or (keyword? property-name) + (symbol? property-name) + (string? property-name)) + (let [property (-> property-name + name + string/lower-case + (string/replace "_" "-") + keyword)] + (if (db-property/property? property) + property + (case property + :created-at :block/created-at + :updated-at :block/updated-at + nil)))))) + +(def custom-readers + {:readers {'tag page-ref/->page-ref}}) diff --git a/deps/db/src/logseq/db/frontend/rules.cljc b/deps/db/src/logseq/db/frontend/rules.cljc new file mode 100644 index 0000000..66f9e44 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/rules.cljc @@ -0,0 +1,360 @@ +(ns ^:bb-compatible logseq.db.frontend.rules + "Datalog rules for DB graphs") + +(def ^:large-vars/data-var rules + "Rules used mainly in frontend.db.model" + ;; rule "parent" is optimized for parent node -> child node nesting queries + {:parent + '[[(parent ?p ?c) + [?c :block/parent ?p]] + [(parent ?p ?c) + [?t :block/parent ?p] + (parent ?t ?c)]] + + :class-extends + '[[(class-extends ?p ?c) + [?c :logseq.property.class/extends ?p]] + [(class-extends ?p ?c) + [?t :logseq.property.class/extends ?p] + (class-extends ?t ?c)]] + + :alias + '[[(alias ?e2 ?e1) + [?e2 :block/alias ?e1]] + [(alias ?e2 ?e1) + [?e1 :block/alias ?e2]]] + + :self-ref + '[(self-ref ?b ?ref) + [?b :block/refs ?ref]] + + :has-ref + '[[(has-ref ?b ?r) + [?b :block/refs ?r]] + [(has-ref ?b ?r) + (parent ?p ?b) + [?p :block/refs ?r]]]}) + +;; Rules writing advice +;; ==================== +;; Select rules carefully, as it is critical for performance. +;; The rules have different clause order and resolving directions. +;; Clause order Reference: +;; https://docs.datomic.com/on-prem/query/query-executing.html#clause-order +;; Recursive optimization Reference: +;; https://stackoverflow.com/questions/42457136/recursive-datalog-queries-for-datomic-really-slow +;; Should optimize for query the descendents of a block +;; Quote: +;; My theory is that your rules are not written in a way that Datalog can optimize for this read pattern - probably resulting in a traversal of all the entities. I suggest to rewrite them as follows: +;; [[(ubersymbol ?c ?p) +;; (?c :ml/parent ?p)] +;; [(ubersymbol ?c ?p) +;; ;; we bind a child of the ancestor, instead of a parent of the descendant +;; (?c1 :ml/parent ?p) +;; (ubersymbol ?c ?c1)]] + +;; This way of writing the ruleset is optimized to find the descendants of some node. The way you originally wrote it is optimized to find the anscestors of some node. + +;; from https://stackoverflow.com/questions/43784258/find-entities-whose-ref-to-many-attribute-contains-all-elements-of-input +;; Quote: +;; You're tackling the general problem of 'dynamic conjunction' in Datomic's Datalog. +;; Write a dynamic Datalog query which uses 2 negations and 1 disjunction or a recursive rule +;; Datalog has no direct way of expressing dynamic conjunction (logical AND / 'for all ...' / set intersection). +;; However, you can achieve it in pure Datalog by combining one disjunction +;; (logical OR / 'exists ...' / set union) and two negations, i.e +;; (For all ?g in ?Gs p(?e,?g)) <=> NOT(Exists ?g in ?Gs, such that NOT(p(?e, ?g))) + +;; [(matches-all ?e ?a ?vs) +;; [(first ?vs) ?v0] +;; [?e ?a ?v0] +;; (not-join [?e ?vs] +;; [(identity ?vs) [?v ...]] +;; (not-join [?e ?v] +;; [?e ?a ?v]))] + +(def ^:large-vars/data-var db-query-dsl-rules + "Rules used by frontend.query.dsl for DB graphs" + (merge + rules + + {:page-ref + '[(page-ref ?b ?ref) + (has-ref ?b ?ref)] + + :block-content + '[(block-content ?b ?query) + [?b :block/title ?content] + [(clojure.string/includes? ?content ?query)]] + + :page + '[(page ?b ?page-name) + [?b :block/page ?bp] + [?bp :block/name ?page-name]] + + :between + '[(between ?b ?start ?end) + [?b :block/page ?p] + [?p :block/tags :logseq.class/Journal] + [?p :block/journal-day ?d] + [(>= ?d ?start)] + [(<= ?d ?end)]] + + :ref->val + '[[(ref->val ?pv ?val) + [?pv :block/title ?val]] + [(ref->val ?pv ?val) + [?pv :logseq.property/value ?val]]] + + :property-missing-value + '[(property-missing-value ?b ?prop-e ?default-p ?default-v) + [?t :logseq.property.class/properties ?prop-e] + [?prop-e :db/ident ?prop] + (object-has-class-property? ?b ?prop) + ;; Notice: `(missing? )` doesn't work here because `de/entity` + ;; returns the default value if there's no value yet. + [(get-else $ ?b ?prop "N/A") ?prop-v] + [(= ?prop-v "N/A")] + [?prop-e ?default-p ?default-v]] + + :scalar-property-value + '[[(scalar-property-value ?b ?prop-e ?val) + [?prop-e :db/ident ?prop] + [?b ?prop ?val]]] + + :scalar-property-value-with-default + '[[(scalar-property-value-with-default ?b ?prop-e ?val) + (scalar-property-value ?b ?prop-e ?val)] + + [(scalar-property-value-with-default ?b ?prop-e ?val) + (property-missing-value ?b ?prop-e :logseq.property/scalar-default-value ?val)]] + + :ref-property-value + '[[(ref-property-value ?b ?prop-e ?val) + [?prop-e :db/ident ?prop] + [?b ?prop ?pv] + (ref->val ?pv ?val)]] + + :ref-property-value-with-default + '[[(ref-property-value-with-default ?b ?prop-e ?val) + (ref-property-value ?b ?prop-e ?val)] + [(ref-property-value-with-default ?b ?prop-e ?val) + (property-missing-value ?b ?prop-e :logseq.property/default-value ?pv) + (ref->val ?pv ?val)]] + + :object-has-class-property + '[(object-has-class-property? ?b ?prop) + [?prop-e :db/ident ?prop] + [?t :logseq.property.class/properties ?prop-e] + [?b :block/tags ?tc] + (or + [(= ?t ?tc)] + (class-extends ?t ?tc))] + + :has-property-or-object-property + '[(has-property-or-object-property? ?b ?prop) + [?prop-e :db/ident ?prop] + (or + [?b ?prop _] + (object-has-class-property? ?b ?prop))] + + ;; Checks if a property exists for simple queries. Supports default values + :has-simple-query-property + '[(has-simple-query-property ?b ?prop) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (has-property-or-object-property? ?b ?prop) + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true])] + + ;; Same as has-simple-query-property except it returns public and private properties like :block/title + :has-private-simple-query-property + '[(has-private-simple-query-property ?b ?prop) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (has-property-or-object-property? ?b ?prop)] + + ;; Checks if a property exists for any features that are not simple queries + :has-property + '[(has-property ?b ?prop) + [?b ?prop _] + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true])] + + ;; Checks if a property has a value for simple queries. Supports default values + :scalar-property + '[(scalar-property ?b ?prop ?val) + [?prop-e :db/ident ?prop] + (scalar-property-value ?b ?prop-e ?val) + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true])] + + :scalar-property-with-default + '[(scalar-property-with-default ?b ?prop ?val) + [?prop-e :db/ident ?prop] + (scalar-property-value-with-default ?b ?prop-e ?val) + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true])] + + :ref-property + '[(ref-property ?b ?prop ?val) + [?prop-e :db/ident ?prop] + (ref-property-value ?b ?prop-e ?val) + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true])] + + :ref-property-with-default + '[(ref-property-with-default ?b ?prop ?val) + [?prop-e :db/ident ?prop] + (ref-property-value-with-default ?b ?prop-e ?val) + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true])] + + ;; Same as ref-property/scalar-property except it returns public and private properties like :block/title + :private-scalar-property + '[(private-scalar-property ?b ?prop ?val) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (scalar-property-value ?b ?prop-e ?val)] + + :private-scalar-property-with-default + '[(private-scalar-property-with-default ?b ?prop ?val) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (scalar-property-value-with-default ?b ?prop-e ?val)] + + :private-ref-property + '[(private-ref-property ?b ?prop ?val) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (ref-property-value ?b ?prop-e ?val)] + + :private-ref-property-with-default + '[(private-ref-property-with-default ?b ?prop ?val) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (ref-property-value-with-default ?b ?prop-e ?val)] + + ;; `property` is slow so don't use it for user-facing queries. + ;; However this is still useful as it is the only self-contained way to query properties + :property + '[(property ?b ?prop ?val) + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property] + (or + [(missing? $ ?prop-e :logseq.property/public?)] + [?prop-e :logseq.property/public? true]) + [?b ?prop ?pv] + (or + ;; non-ref value + (and + [(missing? $ ?prop-e :db/valueType)] + [?b ?prop ?val]) + ;; ref value + (and + [?prop-e :db/valueType :db.type/ref] + (or [?pv :block/title ?val] + [?pv :logseq.property/value ?val])))] + + :tags + '[;; Case 1: input is number entity id + [(tag-spec->tag ?tag ?spec) + [(number? ?spec)] + [(identity ?spec) ?tag]] + + ;; Case 2: input is :block/title + [(tag-spec->tag ?tag ?spec) + [?tag :block/title ?spec]] + + ;; Case 3: input is db/ident + [(tag-spec->tag ?tag ?spec) + [?tag :db/ident ?spec]] + + ;; --- Main rule ----------------------------------------------------------- + + [(tags ?b ?tags) + ;; enumerate user input set + [(identity ?tags) [?spec ...]] + (tag-spec->tag ?tag ?spec) + + ;; tag/class attached to block + [?b :block/tags ?tc] + + ;; direct or descendant + (or + [(= ?tag ?tc)] + (class-extends ?tag ?tc)) + + [(missing? $ ?b :block/link)]]] + + :task + '[(task ?b ?statuses) + (ref-property-with-default ?b :logseq.property/status ?val) + [(contains? ?statuses ?val)]] + + :priority + '[(priority ?b ?priorities) + (ref-property-with-default ?b :logseq.property/priority ?priority) + [(contains? ?priorities ?priority)]]})) + +(def rules-dependencies + "For db graphs, a map of rule names and the rules they depend on. If this map + becomes long or brittle, we could do scan rules for their deps with something + like find-rules-in-where" + {:has-ref #{:parent} + :page-ref #{:has-ref} + + ;; simple query helpers + :task #{:ref-property-with-default} + :priority #{:ref-property-with-default} + :tags #{:class-extends} + + :has-property-or-object-property #{:object-has-class-property} + :object-has-class-property #{:class-extends} + :has-simple-query-property #{:has-property-or-object-property} + :has-private-simple-query-property #{:has-property-or-object-property} + :property-missing-value #{:object-has-class-property} + :ref-property-value #{:ref->val} + :scalar-property #{:scalar-property-value} + :scalar-property-with-default #{:scalar-property-value-with-default} + :scalar-property-value-with-default #{:scalar-property-value :property-missing-value} + :ref-property #{:ref-property-value} + :ref-property-value-with-default #{:ref-property-value :property-missing-value} + :ref-property-with-default #{:ref-property-value-with-default} + :private-scalar-property #{:scalar-property-value} + :private-scalar-property-with-default #{:scalar-property-value-with-default} + :private-ref-property #{:ref-property-value} + :private-ref-property-with-default #{:ref-property-value-with-default}}) + +(defn- get-full-deps + [deps rules-deps] + (loop [deps' deps + result #{}] + (if (seq deps') + (recur (mapcat rules-deps deps') + (into result deps')) + result))) + +(defn extract-rules + "Given a rules map and the rule names to extract, returns a vector of rules to + be passed to datascript.core/q. Can handle rules with multiple or single clauses. + Takes following options: + * :deps - A map of rule names to their dependencies. Only one-level of dependencies are resolved. + No dependencies are detected by default though we could add it later e.g. find-rules-in-where" + ([rules-m] (extract-rules rules-m (keys rules-m))) + ([rules-m rules' & {:keys [deps]}] + (let [rules-with-deps (if (map? deps) + (get-full-deps rules' deps) + rules')] + (vec + (mapcat #(let [val (rules-m %)] + ;; if vector?, rule has multiple clauses + (if (vector? (first val)) val [val])) + rules-with-deps))))) diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs new file mode 100644 index 0000000..27bf22a --- /dev/null +++ b/deps/db/src/logseq/db/frontend/schema.cljs @@ -0,0 +1,142 @@ +(ns logseq.db.frontend.schema + "Schema related fns" + (:require [clojure.set :as set] + [clojure.string :as string])) + +(def schema-version? (every-pred map? :major)) + +(defn parse-schema-version + "Return schema-version({:major :minor }). + supported input: 10, \"10.1\", [10 1]" + [string-or-compatible-number] + (cond + (schema-version? string-or-compatible-number) string-or-compatible-number + (and (sequential? string-or-compatible-number) + (first string-or-compatible-number)) + {:major (first string-or-compatible-number) + :minor (second string-or-compatible-number)} + + (int? string-or-compatible-number) {:major string-or-compatible-number :minor nil} + (string? string-or-compatible-number) + (let [[major minor] (map parse-long (string/split string-or-compatible-number #"\."))] + (assert (some? major)) + {:major major :minor minor}) + :else + (throw (ex-info (str "Bad schema version: " string-or-compatible-number) {:data string-or-compatible-number})))) + +(defn compare-schema-version + [x y] + (apply compare + (map (juxt :major :minor) + [(parse-schema-version x) (parse-schema-version y)]))) + +(def version (parse-schema-version "65.33")) + +(defn major-version + "Return a number. + Compatible with current schema-version number. + schema-version-old: 10, a number + schema-version-new: \"12.34\", string, ." + [schema-version] + (if (schema-version? schema-version) + (:major schema-version) + (:major (parse-schema-version schema-version)))) + +(defn schema-version->string + [schema-version] + (cond + (string? schema-version) schema-version + (int? schema-version) (str schema-version) + (schema-version? schema-version) + (if-let [minor (:minor schema-version)] + (str (:major schema-version) "." minor) + (str (:major schema-version))) + :else (throw (ex-info "Not a schema-version" {:data schema-version})))) + +(def ^:large-vars/data-var schema + "Schema for DB graphs" + {:db/ident {:db/unique :db.unique/identity} + :kv/value {} + + :block/uuid {:db/unique :db.unique/identity} + :block/parent {:db/valueType :db.type/ref + :db/index true} + :block/order {:db/index true} + :block/collapsed? {} + + ;; belongs to which page + :block/page {:db/valueType :db.type/ref + :db/index true} + ;; reference blocks + :block/refs {:db/valueType :db.type/ref + :db/cardinality :db.cardinality/many} + :block/tags {:db/valueType :db.type/ref + :db/cardinality :db.cardinality/many} + + ;; which block this block links to, used for tag, embeds + :block/link {:db/valueType :db.type/ref + :db/index true} + + ;; for pages + :block/alias {:db/valueType :db.type/ref + :db/cardinality :db.cardinality/many + :db/index true} + + :block/created-at {:db/index true} + :block/updated-at {:db/index true} + + ;; page additional attributes + ;; page's name, lowercase + :block/name {:db/index true} ; remove db/unique for :block/name + + ;; page's original name + :block/title {:db/index true} + + ;; page's journal day + :block/journal-day {:db/index true} + + ;; latest tx that affected the block + :block/tx-id {} + :block/closed-value-property {:db/valueType :db.type/ref + :db/cardinality :db.cardinality/many} + + ;; file + :file/path {:db/unique :db.unique/identity} + :file/content {} + :file/created-at {} + :file/last-modified-at {} + :file/size {}}) + +;; If only block/title changes +(def retract-attributes + "Retract attributes for DB graphs" + #{:block/warning}) + +;; DB graph helpers +;; ================ +(def ref-type-attributes + (into #{} + (keep (fn [[attr-name attr-body-map]] + (when (= :db.type/ref (:db/valueType attr-body-map)) + attr-name))) + schema)) + +(def card-many-attributes + (into #{} + (keep (fn [[attr-name attr-body-map]] + (when (= :db.cardinality/many (:db/cardinality attr-body-map)) + attr-name))) + schema)) + +(def card-many-ref-type-attributes + (set/intersection card-many-attributes ref-type-attributes)) + +(def card-one-ref-type-attributes + (set/difference ref-type-attributes card-many-attributes)) + +(def db-non-ref-attributes + (->> schema + (keep (fn [[k v]] + (when (not (:db/valueType v)) + k))) + set)) diff --git a/deps/db/src/logseq/db/frontend/validate.cljs b/deps/db/src/logseq/db/frontend/validate.cljs new file mode 100644 index 0000000..26325f9 --- /dev/null +++ b/deps/db/src/logseq/db/frontend/validate.cljs @@ -0,0 +1,153 @@ +(ns logseq.db.frontend.validate + "Validate frontend db for DB graphs" + (:require [clojure.pprint :as pprint] + [datascript.core :as d] + [logseq.db.frontend.malli-schema :as db-malli-schema] + [logseq.db.frontend.property :as db-property] + [malli.core :as m] + [malli.error :as me] + [malli.util :as mu])) + +(def ^:private db-schema-validator (m/validator db-malli-schema/DB)) +(def ^:private db-schema-explainer (m/explainer db-malli-schema/DB)) +(def ^:private closed-db-schema-validator (m/validator (mu/closed-schema db-malli-schema/DB))) +(def ^:private closed-db-schema-explainer (m/explainer (mu/closed-schema db-malli-schema/DB))) + +(defn get-schema-validator + [closed-schema?] + (if closed-schema? closed-db-schema-validator db-schema-validator)) + +(defn get-schema-explainer + [closed-schema?] + (if closed-schema? closed-db-schema-explainer db-schema-explainer)) + +(defn validate-tx-report + "Validates the datascript tx-report for entities that have changed. Returns + boolean indicating if db is valid" + [{:keys [db-after tx-data tx-meta]} {:keys [closed-schema?]}] + (binding [db-malli-schema/*skip-strict-url-validate?* true] + (let [changed-ids (->> tx-data (keep :e) distinct) + tx-datoms (mapcat (fn [id] + (d/datoms db-after :eavt id)) + changed-ids) + ent-maps* (map (fn [[db-id m]] + ;; Add :db/id for debugging + (assoc m :db/id db-id)) + (db-malli-schema/datoms->entity-maps tx-datoms {:entity-fn #(d/entity db-after %)})) + ent-maps (db-malli-schema/update-properties-in-ents db-after ent-maps*) + validator (get-schema-validator closed-schema?)] + (binding [db-malli-schema/*db-for-validate-fns* db-after] + (let [invalid-ent-maps (remove + ;; remove :db/id as it adds needless declarations to schema + #(validator [(dissoc % :db/id)]) + ent-maps) + schema-errors + (when (seq invalid-ent-maps) + (prn "Invalid datascript entities detected amongst changed entity ids:" changed-ids :tx-meta tx-meta) + (let [explainer (get-schema-explainer closed-schema?)] + (doall + (map + (fn [m] + (let [m' (update m :block/properties (fn [properties] + (map (fn [[p v]] + [(:db/ident p) v]) + properties))) + data {:entity-map m' + :errors (me/humanize (explainer [(dissoc m :db/id)]))}] + (try + (pprint/pprint data) + (catch :default _e + (prn data))) + data)) + invalid-ent-maps)))) + errors (->> schema-errors + (remove nil?) + vec)] + (if (seq errors) + [false errors] + [true nil])))))) + +(defn- group-errors-by-entity + "Groups malli errors by entities. db is used for providing more debugging info" + [db ent-maps errors] + (assert (vector? ent-maps) "Must be a vec for grouping to work") + (->> errors + (group-by #(-> % :in first)) + (map (fn [[idx errors']] + (let [ent (get ent-maps idx)] + {:entity (cond-> ent + ;; Provide additional page info for debugging + (:block/page ent) + (update :block/page + (fn [id] + (let [page-ent (d/entity db id)] + (cond-> (select-keys page-ent [:block/name :db/id :block/created-at]) + (:block/tags page-ent) + (assoc :block/tags (mapv :db/ident (:block/tags page-ent)))))))) + :dispatch-key (->> (dissoc ent :db/id) (db-malli-schema/entity-dispatch-key db)) + :errors errors'}))))) + +(defn validate-db + "Validates all the entities of the given db using :eavt datoms. Returns a map + with info about db being validated. If there are errors, they are placed on + :errors and grouped by entity" + [db] + (let [datoms (d/datoms db :eavt) + ent-maps* (db-malli-schema/datoms->entities datoms) + ent-maps (mapv + ;; Remove some UI interactions adding this e.g. import + #(dissoc % :block.temp/load-status :block.temp/has-children?) + (db-malli-schema/update-properties-in-ents db ent-maps*)) + errors (binding [db-malli-schema/*db-for-validate-fns* db] + (-> (map (fn [e] + (dissoc e :db/id)) + ent-maps) closed-db-schema-explainer :errors))] + (cond-> {:datom-count (count datoms) + :entities ent-maps*} + (some? errors) + (assoc :errors (map #(update % :errors (fn [errs] (me/humanize {:errors errs}))) + (group-errors-by-entity db ent-maps errors)))))) + +(defn graph-counts + "Calculates graph-wide counts given a graph's db and its entities from :eavt datoms" + [db entities] + (let [classes-count (count (d/datoms db :avet :block/tags :logseq.class/Tag)) + properties-count (count (d/datoms db :avet :block/tags :logseq.class/Property))] + {:entities (count entities) + :pages (count (filter :block/name entities)) + ;; Nodes that aren't pages + :blocks (count (filter :block/page entities)) + :classes classes-count + :properties properties-count + ;; Objects that aren't classes or properties + :objects (- (count (d/datoms db :avet :block/tags)) classes-count properties-count) + :property-pairs (count (mapcat #(-> % db-property/properties (dissoc :block/tags)) entities))})) + +(defn validate-local-db! + "Validates a local (non-RTC) DB like validate-db! but with default behavior, + options and logging specific to a local DB. Used by CLI, importer and tests" + [db & {:keys [db-name open-schema verbose]}] + (let [datoms (d/datoms db :eavt) + ent-maps* (db-malli-schema/datoms->entities datoms) + _ (when verbose + (println "Read graph" (str db-name " with counts: " + (pr-str (assoc (graph-counts db ent-maps*) + :datoms (count datoms)))))) + ent-maps (mapv + ;; Remove some UI interactions adding this e.g. import + #(dissoc % :block.temp/load-status :block.temp/has-children?) + (db-malli-schema/update-properties-in-ents db ent-maps*)) + explainer (get-schema-explainer (not open-schema))] + (when-let [explanation (binding [db-malli-schema/*db-for-validate-fns* db + db-malli-schema/*closed-values-validate?* true] + (->> (map (fn [e] (dissoc e :db/id)) ent-maps) explainer not-empty))] + (let [ent-errors + (->> (group-errors-by-entity db ent-maps (:errors explanation)) + (map #(update % :errors + (fn [errs] + ;; errs looks like: {178 {:logseq.property/hide? ["disallowed key"]}} + ;; map is indexed by :in which is unused since all errors are for the same map + (->> (me/humanize {:errors errs}) + vals + (apply merge-with into))))))] + {:errors ent-errors})))) diff --git a/deps/db/src/logseq/db/sqlite/backup.cljs b/deps/db/src/logseq/db/sqlite/backup.cljs new file mode 100644 index 0000000..6dda407 --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/backup.cljs @@ -0,0 +1,58 @@ +(ns logseq.db.sqlite.backup + "Shared SQLite backup utilities for Node runtimes." + (:require ["node:fs" :as fs] + ["node:sqlite" :as node-sqlite] + [clojure.string :as string] + [goog.object :as gobj] + [promesa.core :as p])) + +(defn- resolve-database-sync-ctor + [] + (or (gobj/get node-sqlite "DatabaseSync") + (some-> (gobj/get node-sqlite "default") + (gobj/get "DatabaseSync")) + (let [default-export (gobj/get node-sqlite "default")] + (when (fn? default-export) + default-export)) + (throw (ex-info "node:sqlite DatabaseSync constructor missing" + {:module-keys (js->clj (js/Object.keys node-sqlite))})))) + +(defn- resolve-sqlite-backup-fn + [] + (or (gobj/get node-sqlite "backup") + (some-> (gobj/get node-sqlite "default") + (gobj/get "backup")) + (throw (ex-info "node:sqlite backup function missing" + {:module-keys (js->clj (js/Object.keys node-sqlite))})))) + +(def ^:private sqlite-backup-fn + (resolve-sqlite-backup-fn)) + +(defn- remove-file-if-exists! + [path] + (fs/rmSync path #js {:force true})) + +(defn backup-connection! + [^js db path] + (try + (-> (sqlite-backup-fn db path) + (p/catch (fn [error] + (remove-file-if-exists! path) + (throw error)))) + (catch :default error + (remove-file-if-exists! path) + (p/rejected error)))) + +(defn backup-db-file! + ([src-path dst-path] + (let [DatabaseSync (resolve-database-sync-ctor) + db (new DatabaseSync src-path)] + (-> (backup-connection! db dst-path) + (p/finally (fn [] + (try + (.close db) + (catch :default error + (when-not (string/includes? (str error) "database is not open") + (throw error))))))))) + ([db _src-path dst-path] + (backup-connection! db dst-path))) diff --git a/deps/db/src/logseq/db/sqlite/build.cljs b/deps/db/src/logseq/db/sqlite/build.cljs new file mode 100644 index 0000000..45c530a --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/build.cljs @@ -0,0 +1,1067 @@ +(ns logseq.db.sqlite.build + "This ns provides a concise and readable EDN format to build DB graph tx-data. + All core concepts including pages, blocks, properties and classes can be + generated and related to each other without needing to juggle uuids or + temporary db ids. The generated tx-data is used to create DB graphs that + persist to sqlite or for testing with in-memory databases. See `Options` for + the EDN format and `build-blocks-tx` which is the main fn to build tx data" + (:require [cljs.pprint :as pprint] + [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d] + [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.common.order :as db-order] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.db-ident :as db-ident] + [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.db.frontend.property.type :as db-property-type] + [logseq.db.sqlite.util :as sqlite-util] + [malli.core :as m] + [malli.error :as me])) + +(defn block-property-value? [%] + (and (map? %) (:build/property-value %))) + +;; should match definition in translate-property-value +(defn page-prop-value? + [prop-value] + (and (vector? prop-value) (= :build/page (first prop-value)))) + +(defn- translate-property-value + "Translates a property value for create-graph edn. A value wrapped in vector + may indicate a reference type e.g. [:build/page {:block/title \"some page\"}]" + [val page-uuids] + (if (vector? val) + (case (first val) + ;; Converts a page or journal name to block/uuid + :build/page + (let [page-name (if-let [journal-day (:build/journal (second val))] + ;; TODO: Make lookup more efficient than build name each time + (date-time-util/int->journal-title journal-day "MMM do, yyyy") + (:block/title (second val)))] + (if-let [page-uuid (page-uuids page-name)] + [:block/uuid page-uuid] + (throw (ex-info (str "No uuid for page '" (second val) "'") {:name (second val)})))) + :block/uuid + val + ;; Allow through :coll properties like + val) + val)) + +(defn- block-with-timestamps + "Only adds timestamps to block if they don't exist" + [block] + (let [updated-at (common-util/time-ms) + block (cond-> block + (nil? (:block/updated-at block)) + (assoc :block/updated-at updated-at) + (nil? (:block/created-at block)) + (assoc :block/created-at updated-at))] + block)) + +(defn- get-ident [all-idents kw] + (if (and (qualified-keyword? kw) + ;; Loosen checks to any property or class for build-existing-tx? + (or (db-property/property? kw) + (db-class/user-class-namespace? (namespace kw)))) + kw + (or (get all-idents kw) + (throw (ex-info (str "No ident found for " (pr-str kw)) {}))))) + +(defn- ->block-properties [properties page-uuids all-idents {:keys [translate-property-values?]}] + (let [translate-property-values (if translate-property-values? + (fn translate-property-values [val] + ;; set indicates a :many value + (if (set? val) + (set (map #(translate-property-value % page-uuids) val)) + (translate-property-value val page-uuids))) + identity)] + (->> (map (fn [[prop-name val]] + [(get-ident all-idents prop-name) + (translate-property-values val)]) + properties) + (into {})))) + +(defn- create-page-uuids + "Creates maps of unique page names, block contents and property names to their uuids. Used to + provide user references for translate-property-value" + [pages-and-blocks] + (->> pages-and-blocks + (map :page) + (map (juxt :block/title :block/uuid)) + (into {}))) + +(def current-db-id (atom 0)) +(def new-db-id + "Provides the next temp :db/id to use in a create-graph transact!" + #(swap! current-db-id dec)) + +(defn- build-property-map-for-pvalue-tx + "Returns a property map if the given property pair should have a property value entity constructured + or nil if it should not. Property maps must at least contain the :db/ident and :logseq.property/type keys" + [k v new-block properties-config all-idents] + (if-let [built-in-type (get-in db-property/built-in-properties [k :schema :type])] + (if (and (db-property-type/value-ref-property-types built-in-type) + ;; closed values are referenced by their :db/ident so no need to create values + (not (get-in db-property/built-in-properties [k :closed-values]))) + {:db/ident k + :logseq.property/type built-in-type} + (when-let [built-in-type' (get (or (:build/properties-ref-types new-block) + ;; Reasonable default for properties like logseq.property/default-value + {:entity :number}) + built-in-type)] + ;; Don't build property value entity if values are :block/uuid refs + (when (if (set? v) (not (vector? (first v))) (not (vector? v))) + {:db/ident k + :logseq.property/type built-in-type'}))) + (when (and (db-property-type/value-ref-property-types (get-in properties-config [k :logseq.property/type])) + ;; Don't build property value entity if values are :block/uuid refs + (if (set? v) (not (vector? (first v))) (not (vector? v)))) + (let [prop-type (get-in properties-config [k :logseq.property/type])] + {:db/ident (get-ident all-idents k) + :original-property-id k + :logseq.property/type prop-type})))) + +;; build-pvalue and ->property-value-tx-m depend on each other +(declare ->property-value-tx-m) + +(defn- build-pvalue [properties-config all-idents closed-value-id v] + (let [pvalue-uuid (or (:block/uuid v) (random-uuid)) + nested-pvalue-tx-m + (when (seq (:build/properties v)) + (some-> (->property-value-tx-m {:block/uuid pvalue-uuid} + (:build/properties v) + properties-config + all-idents) + ;; add :db/id to ensure datascript consistently creates this new tx + (update-vals (fn [prop-val] + (cond + (map? prop-val) + (assoc prop-val :db/id (new-db-id)) + (set? prop-val) + (set (map #(if (map? %) + (assoc % :db/id (new-db-id)) + %) + prop-val)) + :else + prop-val)))))] + {:attributes + (when (:build/property-value v) + (merge (:build/properties v) + nested-pvalue-tx-m + {:block/tags (mapv #(hash-map :db/ident (get-ident all-idents %)) + (:build/tags v))} + (select-keys v [:block/created-at :block/updated-at :build/children]) + {:block/uuid pvalue-uuid})) + :value + (cond + closed-value-id + closed-value-id + (:build/property-value v) + (or (:logseq.property/value v) (:block/title v)) + :else + v)})) + +(defn- ->property-value-tx-m + "Given a new block and its properties, creates a map of properties which have values of property value tx. + This map is used for both creating the new property values and then adding them to a block. + This fn is similar to sqlite-create-graph/->property-value-tx-m and we may want to reuse it from here later." + [new-block properties properties-config all-idents] + (->> properties + (keep (fn [[k v]] + (when-let [property-map (build-property-map-for-pvalue-tx k v new-block properties-config all-idents)] + [property-map + (let [property (when (keyword? k) (get properties-config k)) + closed-value-id (when property (some (fn [item] + (when (= (:value item) v) + (:uuid item))) + (get property :build/closed-values))) + build-pvalue' #(build-pvalue properties-config all-idents closed-value-id %)] + (if (set? v) (set (map build-pvalue' v)) (build-pvalue' v)))]))) + ((fn [x] + (db-property-build/build-property-values-tx-m new-block x {:pvalue-map? true}))))) + +(defn- extract-basic-content-refs + "Extracts basic refs from :block/title like `[[foo]]` or `[[UUID]]`. Can't + use db-content/get-matched-ids because of named ref support. Adding more ref + support would require parsing each block with mldoc and extracting with + text/extract-refs-from-mldoc-ast" + [s] + ;; FIXME: Better way to ignore refs inside a macro + (if (string/starts-with? s "{{") + [] + (map second (re-seq page-ref/page-ref-re s)))) + +(defn- expand-build-children + "Expands any blocks with :build/children to return a flattened vec with + children having correct :block/parent. Also ensures all blocks have a :block/uuid" + ([data] (expand-build-children data nil)) + ([data parent-id] + (vec + (mapcat + (fn [block] + (let [block' (if (:block/uuid block) + (with-meta block {::existing-block? true}) + (assoc block :block/uuid (random-uuid))) + block'' (cond-> block' + true + (dissoc :build/children) + parent-id + (assoc :block/parent {:db/id [:block/uuid parent-id]})) + children (:build/children block) + child-maps (when children (expand-build-children children (:block/uuid block'')))] + (cons block'' child-maps))) + data)))) + +;; pvalue-tx->txs and ->block-tx depend on each other +(declare ->block-tx) + +(defn- pvalue-tx->txs + "Builds tx maps from property value tx maps and handles nested property value children." + [pvalue-tx-m page-uuids all-idents options] + (mapcat (fn [pvalue] + (if (map? pvalue) + (let [children-tx + (when-let [children (seq (:build/children pvalue))] + (let [children' (expand-build-children children (:block/uuid pvalue))] + (mapcat #(->block-tx % page-uuids all-idents (:block/page pvalue) options) + children')))] + (cond-> [(dissoc pvalue :build/children)] + (seq children-tx) (into children-tx))) + [pvalue])) + (mapcat #(if (set? %) % [%]) (vals pvalue-tx-m)))) + +(defn- ->block-tx [{:keys [build/properties] :as m} page-uuids all-idents page-id + {properties-config :properties :keys [build-existing-tx? extract-content-refs?] :as options}] + (let [build-existing-tx?' (and build-existing-tx? (::existing-block? (meta m)) (not (:build/keep-uuid? m))) + block (if build-existing-tx?' + (select-keys m [:block/uuid]) + {:db/id (new-db-id) + :block/page {:db/id page-id} + :block/order (db-order/gen-key nil) + :block/parent (or (:block/parent m) {:db/id page-id})}) + pvalue-tx-m (->property-value-tx-m block properties properties-config all-idents) + ref-strings (when extract-content-refs? (extract-basic-content-refs (:block/title m)))] + (cond-> [] + ;; Place property values first since they are referenced by block + (seq pvalue-tx-m) + (into (pvalue-tx->txs pvalue-tx-m page-uuids all-idents options)) + true + (conj (merge (if build-existing-tx?' {:block/updated-at (common-util/time-ms)} (block-with-timestamps block)) + (dissoc m :build/properties :build/tags :build/keep-uuid?) + (when (seq properties) + (->block-properties (merge properties (db-property-build/build-properties-with-ref-values pvalue-tx-m)) + page-uuids all-idents options)) + (when-let [tags (:build/tags m)] + {:block/tags (mapv #(hash-map :db/ident (get-ident all-idents %)) + tags)}) + (when (seq ref-strings) + ;; Use maps for uuids to avoid out of order tx issues + (let [block-refs (mapv #(if-let [uuid' (parse-uuid %)] + (hash-map :block/uuid uuid') + (hash-map :block/uuid + (or + (page-uuids %) + (throw (ex-info (str "No uuid for page ref name" (pr-str %)) {}))) + :block/title %)) + ref-strings)] + {:block/title (db-content/title-ref->id-ref (:block/title m) block-refs {:replace-tag? false}) + :block/refs block-refs}))))))) + +(defn- build-property-tx + [properties page-uuids all-idents property-db-ids class-property-orders options + [prop-name {:build/keys [property-classes] :as prop-m}]] + (let [class-property-order (get class-property-orders prop-name) + [new-block & additional-tx] + (if-let [closed-values (seq (map #(merge {:uuid (random-uuid)} %) (:build/closed-values prop-m)))] + (let [db-ident (get-ident all-idents prop-name)] + (db-property-build/build-closed-values + db-ident + (:block/title prop-m) + (assoc prop-m :db/ident db-ident :closed-values closed-values) + {:property-attributes + (merge {:db/id (or (property-db-ids prop-name) + (throw (ex-info "No :db/id for property" {:property prop-name})))} + (when class-property-order + {:block/order class-property-order}) + (select-keys prop-m [:build/properties-ref-types :block/created-at :block/updated-at :block/collapsed? :block/alias]))})) + [(cond-> (merge (sqlite-util/build-new-property (get-ident all-idents prop-name) + (db-property/get-property-schema prop-m) + {:block-uuid (:block/uuid prop-m) + :title (:block/title prop-m)}) + {:db/id (or (property-db-ids prop-name) + (throw (ex-info "No :db/id for property" {:property prop-name})))} + (select-keys prop-m [:build/properties-ref-types :block/created-at :block/updated-at :block/collapsed? :block/alias])) + class-property-order + (assoc :block/order class-property-order))]) + pvalue-tx-m + (->property-value-tx-m new-block (:build/properties prop-m) properties all-idents)] + (cond-> [] + (seq pvalue-tx-m) + (into (pvalue-tx->txs pvalue-tx-m page-uuids all-idents options)) + true + (conj + (merge + (dissoc new-block :build/properties-ref-types) + (when-let [props (not-empty (:build/properties prop-m))] + (->block-properties (merge props (db-property-build/build-properties-with-ref-values pvalue-tx-m)) + page-uuids all-idents options)) + (when (seq property-classes) + {:logseq.property/classes + (mapv #(hash-map :db/ident (get-ident all-idents %)) + property-classes)}))) + true + (into additional-tx)))) + +(defn- class-properties->ordered-properties + "Returns a deterministic property order inferred from :build/class-properties, using topological sorting" + [classes] + (let [class-properties (->> (vals classes) + (map :build/class-properties) + (filter seq)) + ;; Create first-seen unique property ids for use as a stable tie-break order + all-properties (vec (distinct (mapcat identity class-properties))) + property-index (zipmap all-properties (range)) + sort-by-input-order #(sort-by property-index %) + ;; Adjacent pairs encode ordering e.g. [:p2 :p1 :p3]: #{[:p2 :p1] [:p1 :p3]} + edges (->> class-properties + (mapcat #(partition 2 1 %)) + (remove (fn [[left right]] (= left right))) + set) + ;; Adjacency list by source node + ;; Example: #{[:p2 :p1] [:p2 :p3] [:p1 :p3]} -> {:p2 [[:p2 :p1] [:p2 :p3]], :p1 [[:p1 :p3]]} + outgoing (group-by first edges) + ;; Count inbound edges for each property e.g. {:p2 0, :p1 1, :p3 2} + incoming-counts (reduce (fn [m [_left right]] + (update m right inc)) + (zipmap all-properties (repeat 0)) + edges)] + (loop [ordered-properties [] + ;; Kahn queue: nodes with zero incoming edges, stably sorted + queue (->> all-properties + (filter #(zero? (incoming-counts %))) + sort-by-input-order + vec) + remaining-incoming incoming-counts] + (if-let [property (first queue)] + ;; Consume one zero-incoming node, then decrement incoming counts for its neighbors + (let [[next-incoming unlocked] + (reduce (fn [[incoming unlocked*] [_left next-property]] + (let [next-count (dec (incoming next-property))] + [(assoc incoming next-property next-count) + (if (zero? next-count) (conj unlocked* next-property) unlocked*)])) + [remaining-incoming []] + (get outgoing property)) + ;; Merge newly unlocked nodes into queue with deterministic ordering + next-queue (->> (concat (rest queue) unlocked) + sort-by-input-order + vec)] + (recur (conj ordered-properties property) next-queue next-incoming)) + (do + (assert (= (count ordered-properties) (count all-properties)) + (str "Cycle detected in :build/class-properties constraints. Ordered " + (count ordered-properties) " of " (count all-properties) " properties.")) + ordered-properties))))) + +(defn- build-properties-tx [properties classes page-uuids all-idents {:keys [build-existing-tx?] :as options}] + (let [properties' (if build-existing-tx? + (->> properties + (remove (fn [[_ v]] (and (:block/uuid v) (not (:build/keep-uuid? v))))) + (into {})) + properties) + class-property-orders (->> classes + class-properties->ordered-properties + (#(zipmap % (db-order/gen-n-keys (count %) nil nil)))) + property-db-ids (->> (keys properties') + (map #(vector % (new-db-id))) + (into {})) + ;; build-property-tx needs the full properties map (not properties') for + ;; type lookup in :build/properties — otherwise a surviving property def + ;; that references a filtered-out property's value can't resolve the value + ;; to a property-value block and the raw value falls through to the + ;; transaction as an unresolved string tempid (e.g. URL strings). + new-properties-tx (vec + (mapcat (partial build-property-tx properties page-uuids all-idents property-db-ids class-property-orders options) + properties')) + ;; Apply the topological :block/order to properties that already exist in + ;; the target DB (e.g. built-ins) so per-class property order survives a + ;; round-trip alongside the user-defined properties built above. + existing-property-orders-tx + (->> class-property-orders + (keep (fn [[ident order]] + (when-not (contains? properties' ident) + {:db/ident ident :block/order order}))))] + (into new-properties-tx existing-property-orders-tx))) + +(defn- effective-class-extends + [{:build/keys [class-parent class-extends]}] + (if class-parent [class-parent] class-extends)) + +(defn- build-class-extends [class-config class-db-ids] + (when (:build/class-parent class-config) + (println "Warning: :build/class-parent is deprecated and will be removed soon.")) + (when-let [class-extends' (effective-class-extends class-config)] + (mapv (fn [c] + (or (class-db-ids c) + (if (db-malli-schema/class? c) + c + (throw (ex-info (str "No :db/id for " c) {}))))) + class-extends'))) + +(defn- validate-class-extends-acyclic! + [classes all-idents] + (when (some (fn [[_ class-config]] + (seq (effective-class-extends class-config))) + classes) + (let [class-idents (set (map #(get-ident all-idents %) (keys classes))) + edges (->> classes + (mapcat (fn [[class-name class-config]] + (let [class-ident (get-ident all-idents class-name)] + (keep (fn [parent] + (let [parent-ident (get-ident all-idents parent)] + (when (contains? class-idents parent-ident) + [class-ident parent-ident]))) + (effective-class-extends class-config)))))) + [outgoing incoming-counts] + (reduce (fn [[outgoing counts] [class-ident parent-ident]] + [(update outgoing class-ident (fnil conj []) parent-ident) + (update counts parent-ident inc)]) + [{} (zipmap class-idents (repeat 0))] + edges) + initial-queue (into [] (filter #(zero? (incoming-counts %))) class-idents)] + (loop [queue initial-queue + queue-index 0 + remaining-incoming incoming-counts] + (if (< queue-index (count queue)) + (let [class-ident (nth queue queue-index) + [next-incoming unlocked] + (reduce (fn [[counts unlocked-idents] parent-ident] + (let [next-count (dec (counts parent-ident))] + [(assoc counts parent-ident next-count) + (cond-> unlocked-idents + (zero? next-count) (conj parent-ident))])) + [remaining-incoming []] + (get outgoing class-ident))] + (recur (into queue unlocked) + (inc queue-index) + next-incoming)) + (when-not (= queue-index (count class-idents)) + (throw (ex-info "Cycle detected in :build/class-extends" {})))))))) + +(defn- build-classes-tx [classes properties-config uuid-maps all-idents {:keys [build-existing-tx?] :as options}] + (let [classes' (if build-existing-tx? + (->> classes + (remove (fn [[_ v]] (and (:block/uuid v) (not (:build/keep-uuid? v))))) + (into {})) + classes) + class-db-ids (->> (keys classes') + (map #(vector % (new-db-id))) + (into {})) + classes-tx (vec + (mapcat + (fn [[class-name {:build/keys [class-properties] :as class-m}]] + (let [db-ident (get-ident all-idents class-name) + new-block + (sqlite-util/build-new-class + {:block/name (common-util/page-name-sanity-lc (name class-name)) + :block/title (name class-name) + :block/uuid (or (:block/uuid class-m) + (common-uuid/gen-uuid :db-ident-block-uuid db-ident)) + :db/ident db-ident + :db/id (or (class-db-ids class-name) + (throw (ex-info "No :db/id for class" {:class class-name})))}) + pvalue-tx-m (->property-value-tx-m new-block (:build/properties class-m) properties-config all-idents)] + (cond-> [] + (seq pvalue-tx-m) + (into (pvalue-tx->txs pvalue-tx-m uuid-maps all-idents options)) + true + (conj + (merge + new-block + (dissoc class-m :build/properties :build/class-extends :build/class-parent :build/class-properties :build/keep-uuid?) + (when-let [props (not-empty (:build/properties class-m))] + (->block-properties (merge props (db-property-build/build-properties-with-ref-values pvalue-tx-m)) + uuid-maps all-idents options)) + (when-let [class-extends (build-class-extends class-m class-db-ids)] + {:logseq.property.class/extends class-extends}) + (when class-properties + {:logseq.property.class/properties + (mapv #(hash-map :db/ident (get-ident all-idents %)) + class-properties)})))))) + classes'))] + classes-tx)) + +(def Class :keyword) +(def Property :keyword) +(def Page + [:and + [:map + [:block/uuid {:optional true} :uuid] + [:block/title {:optional true} :string] + [:build/journal {:optional true} :int] + [:build/properties {:optional true} [:ref ::user-properties]] + [:build/tags {:optional true} [:or [:set Class] [:vector Class]]] + [:build/keep-uuid? {:optional true} :boolean]] + [:fn {:error/message ":block/title, :block/uuid or :build/journal required" + :error/path [:block/title]} + (fn [m] + (or (:block/title m) (:block/uuid m) (:build/journal m)))]]) + +(def Build-schema-registry + "This registry contains block and properties related definitions which reference each other" + {::block + [:map + [:block/title :string] + [:build/children {:optional true} [:vector [:ref ::block]]] + [:build/properties {:optional true} [:ref ::user-properties]] + [:build/tags {:optional true} [:or [:set Class] [:vector Class]]] + [:build/keep-uuid? {:optional true} :boolean]] + ;; Used primarily by text-ref property values like :default + ::block-property-value + [:and + [:ref ::block] + [:map + [:build/property-value [:= :block]]]] + ;; Used for any other ref property value + ::block-uuid-property-value + [:tuple [:= :block/uuid] :uuid] + ;; Used as a convenient way to embed pages in non :graph type exports + ::build-page-property-value + [:tuple [:= :build/page] Page] + ::property-value + [:or + ;; All ref property values + [:ref ::build-page-property-value] + [:ref ::block-uuid-property-value] + [:ref ::block-property-value] + ;; All scalar property values as enumerated in logseq.db.frontend.property.type + :any] + ::property-values + [:or [:ref ::property-value] [:set [:ref ::property-value]]] + ::user-properties + [:map-of Property [:ref ::property-values]]}) + +;; Having the schema here instead of Options allows the schemas below to be public facing +(def User-properties + [:schema + {:registry Build-schema-registry} + [:ref ::user-properties]]) + +(def Page-blocks + [:schema + {:registry Build-schema-registry} + [:map + {:closed true} + [:page Page] + [:blocks {:optional true} [:vector [:ref ::block]]]]]) + +(def Properties + [:map-of + Property + [:map + [:build/properties {:optional true} User-properties] + [:build/properties-ref-types {:optional true} + [:map-of :keyword :keyword]] + [:build/closed-values + {:optional true} + [:vector [:map + [:value [:or :string :double]] + [:uuid {:optional true} :uuid] + [:icon {:optional true} :map]]]] + [:build/property-classes {:optional true} [:or [:set Class] [:vector Class]]] + [:build/keep-uuid? {:optional true} :boolean]]]) + +(def Classes + [:map-of + Class + [:map + [:build/properties {:optional true} User-properties] + [:build/class-extends {:optional true} [:or [:set Class] [:vector Class]]] + [:build/class-properties {:optional true} [:vector Property]] + [:build/keep-uuid? {:optional true} :boolean]]]) + +(def Options + "Main malli schema that validates a sqlite.build EDN map. If an inner schema + uses :vector e.g. :blocks, it's to preserve :block/order-ing for that node's + attribute. If an inner schema uses :vector or :set e.g. :build/class-extends, + it's to indicate it is order-less and also allow users to write the more + familiar vector syntax" + [:map + {:closed true} + ;; TODO: Make this respect :block/order or allow :set + [:pages-and-blocks {:optional true} [:vector Page-blocks]] + [:properties {:optional true} Properties] + [:classes {:optional true} Classes] + [:graph-namespace {:optional true} :keyword] + [:page-id-fn {:optional true} :any] + [:auto-create-ontology? {:optional true} :boolean] + [:build-existing-tx? {:optional true} :boolean] + [:extract-content-refs? {:optional true} :boolean] + [:translate-property-values? {:optional true} :boolean]]) + +(defn get-used-properties-from-options + "Extracts all used properties as a map of properties to their property values. Looks at properties + from :build/properties and :build/class-properties. Properties from :build/class-properties have + a ::no-value value" + [{:keys [pages-and-blocks properties classes]}] + (let [page-block-properties (->> pages-and-blocks + (map #(-> (:blocks %) vec (conj (:page %)))) + (mapcat (fn build-node-props-vec [nodes] + (mapcat (fn [m] + (let [nested-pvalue-pages + (->> (vals (:build/properties m)) + (mapcat #(if (set? %) % [%])) + (keep #(cond + (page-prop-value? %) + (second %) + (block-property-value? %) + % + :else + nil)) + seq)] + (if nested-pvalue-pages + (into (vec (:build/properties m)) + (build-node-props-vec nested-pvalue-pages)) + (:build/properties m)))) + nodes))) + set) + property-properties (->> (vals properties) + (mapcat #(into [] (:build/properties %)))) + class-properties (->> (vals classes) + (mapcat #(concat (map (fn [p] [p ::no-value]) (:build/class-properties %)) + (into [] (:build/properties %)))) + set) + props-to-values (->> (set/union class-properties page-block-properties property-properties) + (group-by first) + ((fn [x] (update-vals x #(mapv second %)))))] + props-to-values)) + +;; TODO: How to detect these idents don't conflict with existing? :db/add? +(defn- create-all-idents + [properties classes {:keys [graph-namespace]}] + (let [create-property-ident (if graph-namespace + (fn create-property-ident [kw] + (db-ident/create-db-ident-from-name (str (name graph-namespace) ".property") + (name kw))) + (fn create-property-ident [kw] + (if (qualified-keyword? kw) + (do + (assert (db-property/user-property-namespace? (namespace kw)) + "Property ident must have valid namespace") + (db-ident/create-db-ident-from-name (namespace kw) (name kw))) + (db-property/create-user-property-ident-from-name (name kw))))) + property-idents (->> (keys properties) + (map #(vector % (create-property-ident %))) + (into {})) + _ (assert (= (count (set (vals property-idents))) (count properties)) + (str "All property db-idents must be unique but the following are duplicates: " + (->> property-idents vals frequencies (keep (fn [[k v]] (when (> v 1) k)))))) + create-class-ident (if graph-namespace + (fn create-class-ident [kw] + (db-ident/create-db-ident-from-name (str (name graph-namespace) ".class") + (name kw))) + (fn create-class-ident [kw] + (if (qualified-keyword? kw) + (do + (assert (db-class/user-class-namespace? (namespace kw)) + "Class ident must have valid namespace") + (db-ident/create-db-ident-from-name (namespace kw) (name kw))) + (db-class/create-user-class-ident-from-name nil (name kw))))) + class-idents (->> (keys classes) + (map #(vector % (create-class-ident %))) + (into {})) + _ (assert (= (count (set (vals class-idents))) (count classes)) "All class db-idents must be unique") + all-idents (merge property-idents class-idents)] + (assert (= (count all-idents) (+ (count property-idents) (count class-idents))) + "Class and property db-idents are unique and do not overlap") + all-idents)) + +(defn- build-page-tx [page all-idents page-uuids properties {:keys [build-existing-tx?] :as options}] + (let [page' (dissoc page :build/tags :build/properties :build/keep-uuid?) + pvalue-tx-m (->property-value-tx-m page' (:build/properties page) properties all-idents)] + (cond-> [] + (seq pvalue-tx-m) + (into (pvalue-tx->txs pvalue-tx-m page-uuids all-idents options)) + true + (conj + (merge + (if build-existing-tx? + {:block/updated-at (common-util/time-ms)} + (select-keys (block-with-timestamps page') [:block/created-at :block/updated-at])) + page' + (when (seq (:build/properties page)) + (->block-properties (merge (:build/properties page) (db-property-build/build-properties-with-ref-values pvalue-tx-m)) + page-uuids all-idents options)) + (when-let [tag-idents (->> (:build/tags page) (map #(get-ident all-idents %)) seq)] + {:block/tags (cond-> (mapv #(hash-map :db/ident %) tag-idents) + (empty? (set/intersection (set tag-idents) db-class/page-classes)) + (conj :logseq.class/Page))})))))) + +(defn- build-pages-and-blocks-tx + [pages-and-blocks all-idents page-uuids {:keys [page-id-fn properties build-existing-tx?] + :or {page-id-fn :db/id} + :as options}] + (vec + (mapcat + (fn [{:keys [page blocks]}] + (let [build-existing-tx?' (and build-existing-tx? (not (::new-page? (meta page))) (not (:build/keep-uuid? page))) + page' (if build-existing-tx?' + page + (merge + ;; TODO: Use sqlite-util/build-new-page + {:db/id (or (:db/id page) (new-db-id)) + :block/title (or (:block/title page) (string/capitalize (:block/name page))) + :block/name (or (:block/name page) (common-util/page-name-sanity-lc (:block/title page))) + :block/tags #{:logseq.class/Page}} + (dissoc page :db/id :block/name :block/title))) + page-id-fn' (if (and build-existing-tx? (not (::new-page? (meta page)))) + #(vector :block/uuid (:block/uuid %)) + page-id-fn)] + (into + ;; page tx + (if (and build-existing-tx?' (not (:build/properties page')) (not (:build/tags page'))) + ;; Minimally update existing unless there is useful data to update e.g. properties and tags + [(select-keys page [:block/uuid :block/created-at :block/updated-at])] + (build-page-tx page' all-idents page-uuids properties (assoc options :build-existing-tx? build-existing-tx?'))) + ;; blocks tx + (reduce (fn [acc m] + (into acc + (->block-tx m page-uuids all-idents (page-id-fn' page') options))) + [] + blocks)))) + pages-and-blocks))) + +(defn- split-blocks-tx + "Splits a vec of maps tx into maps that can immediately be transacted, + :init-tx, and maps that need to be transacted after :init-tx, :block-props-tx, in order to use + the correct schema e.g. user properties with :db/cardinality" + [blocks-tx properties] + (let [property-idents (concat (keep #(when (:db/cardinality %) (:db/ident %)) blocks-tx) + ;; add properties for :build-existing-tx? since they aren't in blocks-tx + (keys properties)) + [init-tx block-props-tx] + (reduce (fn [[init-tx* block-props-tx*] m] + (let [props (select-keys m property-idents)] + [(if (map? m) + (conj init-tx* (apply dissoc m property-idents)) + init-tx*) + (if (seq props) + (conj block-props-tx* + (merge {:block/uuid (or (:block/uuid m) + (throw (ex-info "No :block/uuid for block" {:block m})))} + props)) + block-props-tx*)])) + [[] []] + blocks-tx)] + {:init-tx init-tx + :block-props-tx block-props-tx})) + +(defn- add-new-pages-from-refs + "This allows top-level page blocks to contain [[named]] refs and auto create + those pages. This is for convenience. For robust EDN it's recommended + to use [[UUID]] refs and handle page creation with initial build-blocks-tx options" + [pages-and-blocks] + (let [existing-pages (->> pages-and-blocks (keep #(get-in % [:page :block/title])) set) + new-pages-from-refs + (->> pages-and-blocks + (mapcat + (fn [{:keys [blocks]}] + (->> blocks + (mapcat #(extract-basic-content-refs (:block/title %))) + (remove common-util/uuid-string?) + (remove existing-pages)))) + distinct + (map #(hash-map :page {:block/title %})))] + ;; (when (seq new-pages-from-refs) + ;; (prn :debug "Building additional pages from content refs:" (pr-str (mapv #(get-in % [:page :block/title]) new-pages-from-refs)))) + (concat new-pages-from-refs pages-and-blocks))) + +(defn- add-new-pages-from-properties + [properties pages-and-blocks] + (let [used-properties (get-used-properties-from-options {:pages-and-blocks pages-and-blocks :properties properties}) + existing-pages (->> pages-and-blocks (keep #(select-keys (:page %) [:build/journal :block/title])) set) + new-pages (->> (mapcat val used-properties) + (mapcat (fn [val-or-vals] + (keep #(when (page-prop-value? %) (second %)) + (if (set? val-or-vals) val-or-vals [val-or-vals])))) + distinct + (remove existing-pages) + (map #(hash-map :page %)))] + ;; (when (seq new-pages) + ;; (prn :debug "Building additional pages from property values:" + ;; (pr-str (mapv #(or (get-in % [:page :block/title]) (get-in % [:page :build/journal])) new-pages)))) + ;; new-pages must come first because they are referenced by pages-and-blocks + (concat new-pages pages-and-blocks))) + +(defn- pre-build-pages-and-blocks + "Pre builds :pages-and-blocks before any indexes like page-uuids are made" + [pages-and-blocks properties {:keys [:extract-content-refs?]}] + (let [ensure-page-uuids (fn [m] + (if (get-in m [:page :block/uuid]) + m + (-> (assoc-in m [:page :block/uuid] (random-uuid)) + (update :page #(with-meta % {::new-page? true}))))) + expand-block-children (fn [m] + (if (:blocks m) + (update m :blocks expand-build-children) + m)) + expand-journal (fn [m] + (if-let [date-int (get-in m [:page :build/journal])] + (update m :page + (fn [page] + (let [page-name (date-time-util/int->journal-title date-int "MMM do, yyyy")] + (-> (dissoc page :build/journal) + (merge {:block/journal-day date-int + :block/title page-name + :block/uuid + (or (:block/uuid page) (common-uuid/gen-uuid :journal-page-uuid date-int)) + :block/tags :logseq.class/Journal}) + (with-meta {::new-page? (not (:block/uuid page))}))))) + m)) + ;; Order matters as some steps depend on previous step having prepared blocks or pages in a certain way + pages (->> pages-and-blocks + (add-new-pages-from-properties properties) + (map expand-journal) + (map expand-block-children))] + (cond->> pages + extract-content-refs? + add-new-pages-from-refs + true + ;; This needs to be last to ensure page metadata + (map ensure-page-uuids) + true + vec))) + +(defn- infer-property-schema + "Infers a property schema given a collection of its a property pair values" + [property-pair-values] + ;; Infer from first property pair is good enough for now + (let [prop-value (some #(when (not= ::no-value %) %) property-pair-values) + prop-value' (if (set? prop-value) (first prop-value) prop-value) + prop-type (if prop-value' + (if (page-prop-value? prop-value') + (if (:build/journal (second prop-value)) :date :node) + (db-property-type/infer-property-type-from-value prop-value')) + :default)] + (cond-> {:logseq.property/type prop-type} + (set? prop-value) + (assoc :db/cardinality :many)))) + +(defn- auto-create-ontology + "Auto creates properties and classes from uses of options. Creates properties + from any uses of :build/properties and :build/schema.properties. Creates classes from any uses of + :build/tags" + [{:keys [pages-and-blocks properties classes] :as options}] + (let [new-classes (-> (remove + #(and (keyword? %) (db-class/logseq-class? %)) + (concat + (mapcat #(mapcat :build/tags (:blocks %)) pages-and-blocks) + (mapcat #(get-in % [:page :build/tags]) pages-and-blocks))) + set + (set/difference (set (keys classes))) + (zipmap (repeat {}))) + classes' (merge new-classes classes) + used-properties (get-used-properties-from-options options) + new-properties (->> (set/difference (set (keys used-properties)) (set (keys properties))) + (remove db-property/internal-property?) + (map (fn [prop] + [prop (infer-property-schema (get used-properties prop))])) + (into {})) + properties' (merge new-properties properties)] + ;; (when (seq new-properties) (prn :new-properties new-properties)) + ;; (when (seq new-classes) (prn :new-classes new-classes)) + {:classes classes' :properties properties'})) + +(defn- get-possible-referenced-uuids + "Gets all possible ref uuids from either [:block/uuid X] or {:build/journal X}. Uuid scraping + is aggressive so some uuids may not be referenced" + [input-map] + (let [uuids (atom #{}) + _ (walk/postwalk (fn [f] + ;; This does get a few uuids that aren't :build/keep-uuid? but + ;; that's ok because it consistently gets pvalue uuids + (when (and (vector? f) (= :block/uuid (first f))) + (swap! uuids conj (second f))) + ;; All journals that don't have uuid and could be referenced + (when (and (map? f) (:build/journal f) (not (:block/uuid f))) + (swap! uuids conj (common-uuid/gen-uuid :journal-page-uuid (:build/journal f)))) + f) + input-map)] + @uuids)) + +(defn- build-blocks-tx* + [{:keys [pages-and-blocks properties auto-create-ontology? build-existing-tx?] + :as options}] + (let [pages-and-blocks' (pre-build-pages-and-blocks pages-and-blocks properties (dissoc options :pages-and-blocks :properties)) + page-uuids (create-page-uuids pages-and-blocks') + {:keys [classes properties]} (if auto-create-ontology? (auto-create-ontology options) options) + all-idents (create-all-idents properties classes options) + _ (validate-class-extends-acyclic! classes all-idents) + properties-tx (build-properties-tx properties classes page-uuids all-idents options) + classes-tx (build-classes-tx classes properties page-uuids all-idents options) + class-ident->id (->> classes-tx (map (juxt :db/ident :db/id)) (into {})) + ;; Replace idents with db-ids to avoid any upsert issues + properties-tx' (mapv (fn [m] + (if (:logseq.property/classes m) + (update m :logseq.property/classes + (fn [cs] + (mapv #(if (db-class/logseq-class? (:db/ident %)) + % + (or (some->> (:db/ident %) class-ident->id (hash-map :db/id)) + ;; Allow existing user classes to be specified as idents + (when (and build-existing-tx? (some->> (:db/ident %) (get classes))) + (:db/ident %)) + (throw (ex-info (str "No :db/id found for :db/ident " (pr-str %)) {})))) + cs))) + m)) + properties-tx) + pages-and-blocks-tx (build-pages-and-blocks-tx pages-and-blocks' all-idents page-uuids + (assoc options :properties properties)) + ;; Properties first b/c they have schema and are referenced by all. Then + ;; classes b/c they can be referenced by pages. Then pages + split-txs (split-blocks-tx (concat properties-tx' classes-tx pages-and-blocks-tx) + properties)] + (cond-> split-txs + ;; Just add indices option as there are too many out of order uuid cases with importing user content + (:build-existing-tx? options) + (update :init-tx + (fn [init-tx] + (let [indices + (mapv #(hash-map :block/uuid %) + (get-possible-referenced-uuids {:classes classes :properties properties :pages-and-blocks pages-and-blocks}))] + (into indices init-tx))))))) + +;; Public API +;; ========== + +(defn extract-from-blocks + "Given a vec of blocks and a fn which applied to a block returns a coll, this + returns the coll produced by applying f to all blocks including :build/children blocks" + [blocks f] + (let [apply-to-block-and-all-children + (fn apply-to-block-and-all-children [m f] + (into (f m) + (when-let [children (seq (:build/children m))] + (mapcat #(apply-to-block-and-all-children % f) children))))] + (mapcat #(apply-to-block-and-all-children % f) blocks))) + +(defn update-each-block + "Calls fn f on each block including all children under :build/children" + [blocks f] + (mapv (fn [m] + (let [updated-m (f m)] + (if (:build/children m) + (assoc updated-m :build/children (update-each-block (:build/children m) f)) + updated-m))) + blocks)) + +(defn validate-options + [{:keys [properties] :as options}] + (when-let [errors (m/explain Options options)] + (println "The build-blocks-tx has the following options errors:") + (pprint/pprint (me/humanize errors)) + (println "Invalid data for options errors:") + (pprint/pprint (reduce (fn [m e] + (assoc-in m + (:in e) + (get-in options (:in e)))) + {} + (:errors errors))) + (throw (ex-info "Options validation failed" {:errors (me/humanize errors)}))) + (when-not (:auto-create-ontology? options) + (let [used-properties (get-used-properties-from-options options) + undeclared-properties (-> (set (keys used-properties)) + (set/difference (set (keys properties))) + ((fn [x] (remove db-property/internal-property? x))))] + (when (seq undeclared-properties) + (throw (ex-info (str "The following properties used in EDN were not declared in :properties: " undeclared-properties) + {:used-properties (select-keys used-properties undeclared-properties)})))))) + +(defn ^:large-vars/doc-var build-blocks-tx + "Given an EDN map for defining pages, blocks and properties, this creates a map + with two keys of transactable data for use with d/transact!. The :init-tx key + must be transacted first and the :block-props-tx can be transacted after. + The blocks that can be created have the following limitations: + + * Only top level blocks can be easily defined. Other level blocks can be + defined but they require explicit setting of :block/parent + + The EDN map has the following keys: + + * :pages-and-blocks - This is a vector of maps containing a :page key and optionally a :blocks + key when defining a page's blocks. More about each key: + * :page - This is a datascript attribute map for pages with + :block/title required e.g. `{:block/title \"foo\"}`. Additional keys available: + * :build/journal - Define a journal pages as an integer e.g. 20240101 is Jan 1, 2024. :block/title + is not required if using this since it generates one + * :build/properties - Defines properties on a page + * :build/tags - Defines tags on a page + * :build/keep-uuid? - Keeps :block/uuid because another block depends on it + * :blocks - This is a vec of datascript attribute maps for blocks with + :block/title required. e.g. `{:block/title \"bar\"}`. Additional keys available: + * :build/children - A vec of blocks that are nested (indented) under the current block. + Allows for outlines to be expressed to whatever depth + * :build/properties - Defines properties on a block + * :build/tags - Defines tags on a block + * :build/keep-uuid? - Keeps :block/uuid because another block depends on it + * :properties - This is a map to configure properties where the keys are property name keywords + and the values are maps of datascript attributes e.g. `{:logseq.property/type :checkbox}`. + Additional keys available: + * :build/properties - Define properties on a property page. + * :build/closed-values - Define closed values with a vec of maps. A map contains keys :uuid, :value and :icon. + * :build/property-classes - Vec of class name keywords. Defines a property's range classes + * :build/properties-ref-types - Map of internal ref types to public ref types that are valid only for this property. + Useful when remapping value ref types e.g. for :logseq.property/default-value. + Default is `{:entity :number}` + * :build/keep-uuid? - Keeps :block/uuid because another block depends on it + * :classes - This is a map to configure classes where the keys are class name keywords + and the values are maps of datascript attributes e.g. `{:block/title \"Foo\"}`. + Additional keys available: + * :build/properties - Define properties on a class page + * :build/class-extends - Vec of class name keywords which extend a class. + * :build/class-properties - Vec of property name keywords. Defines properties that a class gives to its objects + * :build/keep-uuid? - Keeps :block/uuid because another block depends on it + * :graph-namespace - namespace to use for db-ident creation. Useful when importing an ontology + * :auto-create-ontology? - When set to true, creates properties and classes from their use. + See auto-create-ontology for more details + * :build-existing-tx? - When set to true, blocks, pages, properties and classes with :block/uuid are treated as + existing in DB and are skipped for creation. This is useful for building tx on existing DBs e.g. for importing. + Blocks and pages are updated with any attributes passed to it while all other node types are ignored for update + unless :build/keep-uuid? is set. + * :extract-content-refs? - When set to true, plain text refs e.g. `[[foo]]` are automatically extracted to create pages + and to create refs in blocks. This is useful for testing but since it only partially works, not useful for exporting. + Default is true + * :translate-property-values? - When set to true, property values support special interpretation e.g. `[:build/page ..]`. + Default is true + * :page-id-fn - custom fn that returns ent lookup id for page refs e.g. `[:block/uuid X]` + Default is :db/id + + The :build/properties in :pages-and-blocks, :properties and :classes is a map of + property name keywords to property values. Multiple property values for a many + cardinality property are defined as a set. The following property types are + supported: :default, :url, :checkbox, :number, :node and :date. :checkbox and + :number values are written as booleans and integers/floats. :node references + are written as vectors e.g. `[:build/page {:block/title \"PAGE NAME\"}]`" + [options*] + (let [options (merge {:extract-content-refs? true :translate-property-values? true} options*)] + (validate-options options) + (build-blocks-tx* options))) + +(defn create-blocks + "Builds txs with build-blocks-tx and transacts them. Also provides a shorthand + version of options that are useful for testing" + [conn options] + (let [options' (merge {:auto-create-ontology? true} + (if (vector? options) {:pages-and-blocks options} options)) + {:keys [init-tx block-props-tx] :as _txs} (build-blocks-tx options')] + ;; (cljs.pprint/pprint _txs) + (d/transact! conn init-tx) + (when (seq block-props-tx) + (d/transact! conn block-props-tx)))) diff --git a/deps/db/src/logseq/db/sqlite/create_graph.cljs b/deps/db/src/logseq/db/sqlite/create_graph.cljs new file mode 100644 index 0000000..baea292 --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/create_graph.cljs @@ -0,0 +1,275 @@ +(ns logseq.db.sqlite.create-graph + "Helper fns for creating a DB graph" + (:require [clojure.string :as string] + [logseq.common.config :as common-config] + [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] + [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] + [logseq.db.frontend.property.build :as db-property-build] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.sqlite.util :as sqlite-util])) + +(defn mark-block-as-built-in [block] + (assoc block :logseq.property/built-in? true)) + +(defn- schema->qualified-property-keyword + [prop-schema] + (reduce-kv + (fn [r k v] + (if-let [new-k (and (simple-keyword? k) (db-property/schema-properties-map k))] + (assoc r new-k v) + (assoc r k v))) + {} + prop-schema)) + +(defn- ->property-value-tx-m + "Given a new block and its properties, creates a map of properties which have values of property value tx. + This map is used for both creating the new property values and then adding them to a block" + [new-block properties] + (db-property-build/build-property-values-tx-m + new-block + (->> properties + (keep (fn [[k v]] + (when-let [built-in-type (get-in db-property/built-in-properties [k :schema :type])] + (if (and (db-property-type/value-ref-property-types built-in-type) + ;; closed values are referenced by their :db/ident so no need to create values + (not (get-in db-property/built-in-properties [k :closed-values]))) + (let [property-map {:db/ident k + :logseq.property/type built-in-type}] + [property-map v]) + (when-let [built-in-type' (get (:build/properties-ref-types new-block) built-in-type)] + (let [property-map {:db/ident k + :logseq.property/type built-in-type'}] + [property-map v]))))))) + :pure? true)) + +(defn build-properties + "Given a properties map in the format of db-property/built-in-properties, builds their properties tx" + [built-in-properties] + (mapcat + (fn [[db-ident {:keys [attribute schema title closed-values properties]}]] + (let [db-ident (or attribute db-ident) + schema' (schema->qualified-property-keyword schema) + [property & others] (if closed-values + (db-property-build/build-closed-values + db-ident + title + {:db/ident db-ident :schema schema' :closed-values closed-values} + {}) + [(sqlite-util/build-new-property + db-ident + schema' + {:title title})]) + pvalue-tx-m (->property-value-tx-m + (merge property + ;; This config is for :logseq.property/default-value and may need to + ;; move to built-in-properties + {:build/properties-ref-types {:entity :number}}) + (->> properties + ;; No need to create property value if it's an internal ident + (remove (fn [[_k v]] + (and (keyword? v) (db-malli-schema/internal-ident? v)))) + (into {}))) + ;; _ (when (seq pvalue-tx-m) (prn :pvalue-tx-m db-ident pvalue-tx-m)) + + ;; The order of tx matters. property and others must come first as + ;; they may contain idents and uuids that are referenced by properties + tx + (cond-> [property] + (seq others) + (into others) + (seq pvalue-tx-m) + (into (mapcat #(if (set? %) % [%]) (vals pvalue-tx-m))) + (seq properties) + (conj + (merge {:block/uuid (:block/uuid property)} + properties + (db-property-build/build-properties-with-ref-values pvalue-tx-m))))] + tx)) + built-in-properties)) + +(defn- build-bootstrap-property + [db-ident] + (sqlite-util/build-new-property + db-ident + (schema->qualified-property-keyword (get-in db-property/built-in-properties [db-ident :schema])) + {:title (get-in db-property/built-in-properties [db-ident :title])})) + +(defn- build-initial-properties + "Builds initial properties and their closed values and marks them + as built-in?. Returns their tx data as well as data needed for subsequent build steps" + [] + ;; bootstrap-idents must either be necessary to define a property or be used on every property + (let [bootstrap-idents #{:logseq.property/type :logseq.property/hide? :logseq.property/built-in? + ;; Required to define :properties on a property + :logseq.property/created-from-property} + bootstrap-properties (map build-bootstrap-property bootstrap-idents) + ;; First tx bootstrap properties so they can take affect. Then tx the bootstrap properties on themselves + bootstrap-properties-tx (into (mapv #(apply dissoc % bootstrap-idents) bootstrap-properties) + (mapv #(select-keys % (into [:block/uuid] bootstrap-idents)) + bootstrap-properties)) + properties-tx (build-properties (apply dissoc db-property/built-in-properties bootstrap-idents)) + mark-block-as-built-in' (fn [b] (mark-block-as-built-in {:block/uuid (:block/uuid b)})) + ;; Tx order matters + tx (concat bootstrap-properties-tx + properties-tx + ;; Adding built-ins must come after its properties are defined + (map mark-block-as-built-in' bootstrap-properties) + (map mark-block-as-built-in' properties-tx))] + (doseq [m tx] + (when-let [block-uuid (and (:db/ident m) (:block/uuid m))] + (assert (string/starts-with? (str block-uuid) "00000002") m))) + + {:tx tx + :properties (filter entity-util/property? properties-tx)})) + +(def built-in-pages-names + #{common-config/library-page-name + common-config/quick-add-page-name + "Contents"}) + +(defn- validate-tx-for-duplicate-idents [tx] + (when-let [conflicting-idents + (->> (keep :db/ident tx) + frequencies + (keep (fn [[k v]] (when (> v 1) k))) + seq)] + (throw (ex-info (str "The following :db/idents are not unique and clobbered each other: " + (vec conflicting-idents)) + {:idents conflicting-idents})))) + +(defn build-initial-classes* + [built-in-classes db-ident->properties] + (map + (fn [[db-ident {:keys [properties schema title]}]] + (let [title' (or title (name db-ident))] + (mark-block-as-built-in + (sqlite-util/build-new-class + (let [class-properties (mapv + (fn [db-ident] + (let [property (get db-ident->properties db-ident)] + (assert property (str "Built-in property " db-ident " is not defined yet")) + db-ident)) + (:properties schema))] + (cond-> + {:block/title title' + :block/name (common-util/page-name-sanity-lc title') + :db/ident db-ident + :block/uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)} + (seq class-properties) + (assoc :logseq.property.class/properties class-properties) + (seq properties) + (merge properties))))))) + built-in-classes)) + +(defn- build-initial-classes + [db-ident->properties] + (build-initial-classes* db-class/built-in-classes db-ident->properties)) + +(defn build-initial-views + "Builds initial blocks used for storing views" + [] + (let [page-id (common-uuid/gen-uuid :builtin-block-uuid common-config/views-page-name)] + [(sqlite-util/block-with-timestamps + {:block/uuid page-id + :block/name common-config/views-page-name + :block/title common-config/views-page-name + :block/tags [:logseq.class/Page] + :logseq.property/hide? true + :logseq.property/built-in? true})])) + +(defn- build-recycle-page + [] + [(sqlite-util/block-with-timestamps + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "Recycle") + :block/name (common-util/page-name-sanity-lc "Recycle") + :block/title "Recycle" + :block/tags [:logseq.class/Page] + :logseq.property/hide? true + :logseq.property/built-in? true})]) + +(defn- build-favorites-page + [] + [(sqlite-util/block-with-timestamps + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid common-config/favorites-page-name) + :block/name common-config/favorites-page-name + :block/title common-config/favorites-page-name + :block/tags [:logseq.class/Page] + :logseq.property/hide? true + :logseq.property/built-in? true})]) + +(defn- build-initial-files [config-content] + [{:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/config.edn") + :file/path (str "logseq/" "config.edn") + :file/content config-content + :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)} + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/custom.css") + :file/path (str "logseq/" "custom.css") + :file/content "" + :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)} + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/custom.js") + :file/path (str "logseq/" "custom.js") + :file/content "" + :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)} + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/publish.css") + :file/path (str "logseq/" "publish.css") + :file/content "" + :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)} + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/publish.js") + :file/path (str "logseq/" "publish.js") + :file/content "" + :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)}]) + +(defn build-db-initial-data + "Builds tx of initial data for a new graph including key values, initial files, + built-in properties and built-in classes" + [config-content & {:keys [import-type graph-git-sha creating-remote-graph?]}] + (assert (string? config-content)) + (let [initial-data (cond-> + [(sqlite-util/kv :logseq.kv/db-type "db") + (sqlite-util/kv :logseq.kv/schema-version db-schema/version) + (sqlite-util/kv :logseq.kv/graph-initial-schema-version db-schema/version) + (sqlite-util/kv :logseq.kv/graph-created-at (common-util/time-ms)) + ;; Empty property value used by db.type/ref properties + {:db/ident :logseq.property/empty-placeholder + :block/uuid (common-uuid/gen-uuid :builtin-block-uuid :logseq.property/empty-placeholder)}] + import-type + (into (sqlite-util/import-tx import-type)) + graph-git-sha + (conj (sqlite-util/kv :logseq.kv/graph-git-sha graph-git-sha)) + creating-remote-graph? + (conj (sqlite-util/kv :logseq.kv/graph-remote? creating-remote-graph?)) + true + (conj (sqlite-util/kv :logseq.kv/local-graph-uuid + (uuid (str "00000000" (subs (str (common-uuid/gen-uuid)) 8)))))) + initial-files (build-initial-files config-content) + {properties-tx :tx :keys [properties]} (build-initial-properties) + db-ident->properties (zipmap (map :db/ident properties) properties) + default-classes (build-initial-classes db-ident->properties) + default-pages (->> (map sqlite-util/build-new-page built-in-pages-names) + (map mark-block-as-built-in)) + hidden-pages (concat (build-initial-views) (build-favorites-page) (build-recycle-page)) + ;; These classes bootstrap our tags and properties as they depend on each other e.g. + ;; Root <-> Tag, classes-tx depends on logseq.property.class/extends, properties-tx depends on Property + bootstrap-class? (fn [c] (contains? #{:logseq.class/Root :logseq.class/Property :logseq.class/Tag + :logseq.class/Page :logseq.class/Template} (:db/ident c))) + bootstrap-classes (filter bootstrap-class? default-classes) + bootstrap-class-ids (map #(select-keys % [:db/ident :block/uuid]) bootstrap-classes) + classes-tx (concat (map #(dissoc % :db/ident) bootstrap-classes) + (remove bootstrap-class? default-classes)) + ;; Order of tx is critical. bootstrap-class-ids bootstraps properties-tx and classes-tx + ;; bootstrap-class-ids coming first is useful as Root, Tag and Property have stable :db/id's of 1, 2 and 3 + tx (vec (concat bootstrap-class-ids + initial-data properties-tx classes-tx + initial-files default-pages hidden-pages))] + (validate-tx-for-duplicate-idents tx) + tx)) diff --git a/deps/db/src/logseq/db/sqlite/debug.cljs b/deps/db/src/logseq/db/sqlite/debug.cljs new file mode 100644 index 0000000..3973e5f --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/debug.cljs @@ -0,0 +1,37 @@ +(ns logseq.db.sqlite.debug + "SQLite debug fns" + (:require [cljs-bean.core :as bean] + [clojure.set] + [logseq.db.sqlite.util :as sqlite-util])) + +(defn find-missing-addresses + "WASM version to find missing addresses from the kvs table" + [^Object db] + (let [schema (some->> (.exec db #js {:sql "select content from kvs where addr = 0" + :rowMode "array"}) + bean/->clj + ffirst + sqlite-util/read-transit-str) + result (->> (.exec db #js {:sql "select addr, addresses from kvs" + :rowMode "array"}) + bean/->clj + (keep (fn [[addr addresses]] + [addr (bean/->clj (js/JSON.parse addresses))]))) + used-addresses (set (concat (mapcat second result) + [0 1 (:eavt schema) (:avet schema) (:aevt schema)]))] + (clojure.set/difference used-addresses (set (map first result))))) + +(defn find-missing-addresses-node-version + "Node version to find missing addresses from the kvs table" + [^Object db] + (let [schema (let [stmt (.prepare db "select content from kvs where addr = ?") + content (.-content (.get stmt 0))] + (sqlite-util/read-transit-str content)) + stmt (.prepare db "select addr, addresses from kvs") + result (->> (.all ^Object stmt) + bean/->clj + (keep (fn [{:keys [addr addresses]}] + [addr (bean/->clj (js/JSON.parse addresses))]))) + used-addresses (set (concat (mapcat second result) + [0 1 (:eavt schema) (:avet schema) (:aevt schema)]))] + (clojure.set/difference used-addresses (set (map first result))))) diff --git a/deps/db/src/logseq/db/sqlite/export.cljs b/deps/db/src/logseq/db/sqlite/export.cljs new file mode 100644 index 0000000..136a8d8 --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/export.cljs @@ -0,0 +1,1455 @@ +(ns logseq.db.sqlite.export + "Builds sqlite.build EDN to represent nodes in a graph-agnostic way. + Useful for exporting and importing across DB graphs" + (:require [cljs.pprint :as pprint] + [clojure.data :as data] + [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.db :as ldb] + [logseq.db.common.entity-plus :as entity-plus] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.db :as db-db] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.sqlite.build :as sqlite-build] + [logseq.db.sqlite.create-graph :as sqlite-create-graph])) + +;; Export fns +;; ========== +(defn- ->build-tags [block-tags] + (->> (map :db/ident block-tags) + ;; These classes are redundant as :build/journal is enough for Journal and Page + ;; is implied by being in :pages-and-blocks + (remove #{:logseq.class/Page :logseq.class/Journal}) + set)) + +(defn- block-title + "Get an entity's original title" + [ent] + (or (:block/raw-title ent) (:block/title ent))) + +;; nbb-compatible version of db-property/property-value-content +(defn- property-value-content [pvalue] + (or (block-title pvalue) + (:logseq.property/value pvalue))) + +(defn- referenced-property-value-contents + [db property] + (if (= :db.type/ref (:db/valueType property)) + (->> (d/datoms db :avet (:db/ident property)) + (keep (fn [datom] + (some->> (:v datom) + (d/entity db) + property-value-content))) + set) + #{})) + +(defn- closed-values-for-export + [db property] + (let [referenced-contents (referenced-property-value-contents db property)] + (->> (concat (entity-plus/lookup-kv-then-entity property :property/closed-values) + (filter #(contains? referenced-contents (property-value-content %)) + (:block/_closed-value-property property))) + (reduce (fn [closed-values value] + (assoc closed-values (:db/id value) value)) + {}) + vals + (sort-by :block/order)))) + +(defn- shallow-copy-page + "Given a page or journal entity, shallow copies it e.g. no properties or tags info included. + Pages that are shallow copied are at the edges of export and help keep the export size reasonable and + avoid exporting unexpected info" + [page-entity] + (if (entity-util/journal? page-entity) + {:build/journal (:block/journal-day page-entity)} + {:block/title (block-title page-entity)})) + +(defn- build-pvalue-entity-for-build-page + [pvalue] + (cond (entity-util/internal-page? pvalue) + ;; Should page properties be pulled here? + [:build/page (cond-> (shallow-copy-page pvalue) + (seq (:block/tags pvalue)) + (assoc :build/tags (->build-tags (:block/tags pvalue))))] + (entity-util/journal? pvalue) + [:build/page {:build/journal (:block/journal-day pvalue)}])) + +(defn- build-pvalue-entity-default [ent-properties build-children pvalue + {:keys [include-pvalue-uuid-fn] + :or {include-pvalue-uuid-fn (constantly false)} + :as options}] + (let [property-value-content' (property-value-content pvalue)] + (if (or (seq ent-properties) + (seq build-children) + (seq (:block/tags pvalue)) + (include-pvalue-uuid-fn (:block/uuid pvalue))) + (cond-> {:build/property-value :block + :block/title property-value-content'} + (seq build-children) + (assoc :build/children build-children) + + (seq (:block/tags pvalue)) + (assoc :build/tags (->build-tags (:block/tags pvalue))) + + (seq ent-properties) + (assoc :build/properties ent-properties) + + (include-pvalue-uuid-fn (:block/uuid pvalue)) + (assoc :block/uuid (:block/uuid pvalue) :build/keep-uuid? true) + + (:include-timestamps? options) + (merge (select-keys pvalue [:block/created-at :block/updated-at]))) + property-value-content'))) + +(defonce ignored-properties [:logseq.property/created-by-ref :logseq.property.embedding/hnsw-label-updated-at]) +;; buildable-properties and build-blocks-export depend on each other +(declare build-blocks-export) + +(defn- buildable-properties + "Originally copied from db-test/readable-properties. Modified so that property values are + valid sqlite.build EDN" + [db ent-properties properties-config options] + (letfn [(build-pvalue-entity + [db' property-ent pvalue properties-config' {:keys [property-value-uuids?] :as options'}] + (if-let [build-page (and (not property-value-uuids?) (build-pvalue-entity-for-build-page pvalue))] + build-page + (if (and (contains? #{:node :date :entity} (:logseq.property/type property-ent)) + (not= :logseq.property/default-value (:db/ident property-ent))) + ;; Idents take precedence over uuid because they keep data graph-agnostic + (if (:db/ident pvalue) + (:db/ident pvalue) + ;; Use metadata to distinguish from block references that don't exist like closed values + ^::existing-property-value? [:block/uuid (:block/uuid pvalue)]) + (or (:db/ident pvalue) + (let [ent-properties* (apply dissoc (db-property/properties pvalue) :logseq.property/value :logseq.property/created-from-property + db-property/public-db-attribute-properties) + child-blocks (rest (ldb/get-block-and-children db' (:block/uuid pvalue))) + build-children (when (seq child-blocks) + ;; TODO: Handle new properties and classes for non :graph exports + (:blocks (build-blocks-export db' child-blocks + (dissoc options' :include-uuid-fn :include-pvalue-uuid-fn)))) + ent-properties (when (and (not (:block/closed-value-property pvalue)) (seq ent-properties*)) + (buildable-properties db' ent-properties* properties-config' options'))] + (build-pvalue-entity-default ent-properties build-children pvalue options'))))))] + (->> (apply dissoc ent-properties ignored-properties) + (map (fn [[k v]] + [k + ;; handle user closed value properties. built-ins have idents and shouldn't be handled here + (if (and (not (db-property/logseq-property? k)) + (or (:block/closed-value-property v) + (and (set? v) (:block/closed-value-property (first v))))) + (let [find-closed-uuid (fn [val] + (or (some #(when (= (:value %) (db-property/property-value-content val)) + (:uuid %)) + (get-in properties-config [k :build/closed-values])) + (throw (ex-info (str "No closed value found for content: " (pr-str (db-property/property-value-content val))) {:properties properties-config}))))] + (if (set? v) + (set (map #(vector :block/uuid (find-closed-uuid %)) v)) + [:block/uuid (find-closed-uuid v)])) + (cond + (de/entity? v) + (build-pvalue-entity db (d/entity db k) v properties-config options) + (and (set? v) (every? de/entity? v)) + (let [property-ent (d/entity db k)] + (set (map #(build-pvalue-entity db property-ent % properties-config options) v))) + :else + v))])) + (into {})))) + +(defn- build-export-properties + "The caller of this fn is responsible for building :build/:property-classes unless shallow-copy?" + [db user-property-idents {:keys [include-properties? include-timestamps? include-uuid? shallow-copy? include-alias?] :as options}] + (let [properties-config-by-ent + (->> user-property-idents + (map (fn [ident] + (let [property (d/entity db ident) + closed-values (closed-values-for-export db property)] + [property + (cond-> (select-keys property + (-> (disj db-property/schema-properties :logseq.property/classes) + (into [:block/title :block/collapsed?]))) + include-uuid? + (assoc :block/uuid (:block/uuid property) :build/keep-uuid? true) + include-timestamps? + (merge (select-keys property [:block/created-at :block/updated-at])) + (and (not shallow-copy?) include-alias? (:block/alias property)) + (assoc :block/alias (set (map #(vector :block/uuid (:block/uuid %)) (:block/alias property)))) + (and (not shallow-copy?) (:logseq.property/classes property)) + (assoc :build/property-classes (set (map :db/ident (:logseq.property/classes property)))) + (seq closed-values) + (assoc :build/closed-values + (mapv #(cond-> {:value (db-property/property-value-content %) + :uuid (:block/uuid %)} + (:logseq.property/icon %) + (assoc :icon (:logseq.property/icon %))) + closed-values)))]))) + + (into {})) + properties-config (update-keys properties-config-by-ent :db/ident)] + ;; By default properties aren't included as build and page exports do not include all user-defined-properties + ;; e.g. properties that are on property pages + (if include-properties? + (->> properties-config-by-ent + (map (fn [[ent build-property]] + (let [ent-properties (apply dissoc (db-property/properties ent) + (into db-property/schema-properties db-property/public-db-attribute-properties)) + build-properties (buildable-properties db ent-properties properties-config options)] + [(:db/ident ent) + (cond-> build-property + (seq build-properties) + (assoc :build/properties build-properties))]))) + (into {})) + properties-config))) + +(defn- build-export-class + "The caller of this fn is responsible for building any classes or properties from this fn + unless shallow-copy?" + [class-ent {:keys [include-uuid? shallow-copy? include-timestamps? include-alias?]}] + (let [class-properties (->> (:logseq.property.class/properties class-ent) + (sort-by :block/order) + (map :db/ident) + (remove (set ignored-properties)) + vec)] + (cond-> (select-keys class-ent [:block/title :block/collapsed?]) + include-uuid? + (assoc :block/uuid (:block/uuid class-ent) :build/keep-uuid? true) + include-timestamps? + (merge (select-keys class-ent [:block/created-at :block/updated-at])) + (and (seq class-properties) (not shallow-copy?)) + (assoc :build/class-properties class-properties) + (and (not shallow-copy?) include-alias? (:block/alias class-ent)) + (assoc :block/alias (set (map #(vector :block/uuid (:block/uuid %)) (:block/alias class-ent)))) + ;; It's caller's responsibility to ensure parent is included in final export + (and (not shallow-copy?) + (:logseq.property.class/extends class-ent) + (not= [:logseq.class/Root] (mapv :db/ident (:logseq.property.class/extends class-ent)))) + (assoc :build/class-extends + (set (map :db/ident (:logseq.property.class/extends class-ent))))))) + +(defn- build-node-classes + [db build-block block-tags properties] + (let [pvalue-classes (->> (:build/properties build-block) + vals + (mapcat (fn [val-or-vals] + (mapcat #(cond (sqlite-build/page-prop-value? %) + (:build/tags (second %)) + (sqlite-build/block-property-value? %) + (:build/tags %)) + (if (set? val-or-vals) val-or-vals [val-or-vals])))) + (remove db-class/logseq-class?)) + property-classes (->> (mapcat :build/property-classes (vals properties)) + (remove db-class/logseq-class?) + set) + new-class-ents (remove #(db-class/logseq-class? (:db/ident %)) block-tags) + shallow-classes (set/difference (into property-classes pvalue-classes) + (set (map :db/ident new-class-ents)))] + (merge + ;; These are shallow b/c properties have already been built + (when (seq shallow-classes) + (->> shallow-classes + (map #(d/entity db %)) + (map #(vector (:db/ident %) (build-export-class % {:shallow-copy? true}))) + (into {}))) + (->> new-class-ents + ;; Properties from here are built in build-node-properties + ;; Classes from here are built in build-class-parents-export + (map #(vector (:db/ident %) (build-export-class % {}))) + (into {}))))) + +(defn- build-node-properties + [db entity ent-properties {:keys [properties] :as options}] + (let [collect-nested-property-ids + (fn collect-nested-property-ids [v] + (cond + (and (de/entity? v) (:logseq.property/created-from-property v)) + (let [pvalue-properties (apply dissoc (db-property/properties v) db-property/public-db-attribute-properties)] + (concat (keys pvalue-properties) + (mapcat collect-nested-property-ids (vals pvalue-properties)))) + (set? v) + (mapcat collect-nested-property-ids v) + :else + [])) + new-user-property-ids (->> (keys ent-properties) + (concat (->> (:block/tags entity) + (mapcat :logseq.property.class/properties) + (map :db/ident))) + (concat (mapcat collect-nested-property-ids (vals ent-properties))) + ;; Built-in properties and any possible modifications are not exported + (remove db-property/logseq-property?) + (remove #(get properties %)))] + ;; Classes from here are built in build-node-classes + (build-export-properties db new-user-property-ids options))) + +(defn- build-node-export + "Given a block/page entity and optional existing properties, build an export map of its + tags and properties" + [db entity {:keys [properties include-uuid-fn shallow-copy? include-timestamps? exclude-ontology?] + :or {include-uuid-fn (constantly false)} + :as options}] + (let [ent-properties (apply dissoc (db-property/properties entity) db-property/public-db-attribute-properties) + build-tags (when (seq (:block/tags entity)) (->build-tags (:block/tags entity))) + new-properties (when-not (or shallow-copy? exclude-ontology?) + (build-node-properties db entity ent-properties (dissoc options :shallow-copy? :include-uuid-fn))) + build-properties (when (and (not shallow-copy?) (seq ent-properties)) + (buildable-properties db ent-properties (merge properties new-properties) options)) + build-node (cond-> {:block/title (property-value-content entity)} + (some? (:block/collapsed? entity)) + (assoc :block/collapsed? (:block/collapsed? entity)) + (:block/link entity) + (assoc :block/link [:block/uuid (:block/uuid (:block/link entity))]) + (include-uuid-fn (:block/uuid entity)) + (assoc :block/uuid (:block/uuid entity) :build/keep-uuid? true) + include-timestamps? + (merge (select-keys entity [:block/created-at :block/updated-at])) + (and (not shallow-copy?) (seq build-tags)) + (assoc :build/tags build-tags) + (seq build-properties) + (assoc :build/properties build-properties)) + new-classes (when-not (or shallow-copy? exclude-ontology?) + (build-node-classes db build-node (:block/tags entity) new-properties))] + (cond-> {:node build-node} + (seq new-classes) + (assoc :classes new-classes) + (seq new-properties) + (assoc :properties new-properties)))) + +(defn- get-pvalue-uuids + "Extracts block reference uuids from a block's property values" + [build-block] + (->> (:build/properties build-block) + vals + (mapcat (fn [val-or-vals] + (keep #(when (and (vector? %) + (= :block/uuid (first %)) + (::existing-property-value? (meta %))) + (second %)) + (if (set? val-or-vals) val-or-vals [val-or-vals])))) + set)) + +(defn- merge-export-maps + "Merge export maps for partial graph exports. *Do not* use for a full graph + export because it makes assumptions about page identity" + [& export-maps] + (let [pages-and-blocks + (->> (mapcat :pages-and-blocks export-maps) + ;; TODO: Group by more correct identity for title, same as check-for-existing-entities + (group-by #(select-keys (:page %) [:block/title :build/journal])) + (mapv #(apply merge-with (fn [e1 e2] + ;; merge :page and add :blocks + (if (and (map? e1) (map e2)) + (merge e1 e2) + (into e1 e2))) + (second %)))) + ;; Use merge-with to preserve new-property? and to allow full copies to overwrite shallow ones + properties (apply merge-with merge (keep :properties export-maps)) + classes (apply merge-with merge (keep :classes export-maps))] + (cond-> {:pages-and-blocks pages-and-blocks} + (seq properties) + (assoc :properties properties) + (seq classes) + (assoc :classes classes)))) + +(defn- build-mixed-properties-and-classes-export + "Builds an export of properties and classes from a mixed group of nodes that may both" + [db ents export-opts] + (let [properties + (when-let [prop-ids (seq (map :db/ident (filter entity-util/property? ents)))] + (build-export-properties db prop-ids export-opts)) + classes + (when-let [class-ents (seq (filter entity-util/class? ents))] + (->> class-ents + (map #(vector (:db/ident %) (build-export-class % export-opts))) + (into {})))] + (cond-> {} + properties (assoc :properties properties) + classes (assoc :classes classes)))) + +(defn- build-content-ref-export + "Builds an export config (and additional info) for refs in the given blocks. Refs are detected + if they are a :block/link or if a `[[UUID]]` ref in the content. All the exported + entities found in block refs include their uuid in order to preserve the relationship to the blocks" + [db blocks*] + (let [;; Remove property value blocks that can't have content refs + blocks (remove :logseq.property/value blocks*) + block-links (->> (filter :block/link blocks) + (map #(:block/uuid (:block/link %)))) + content-ref-uuids (into (set (mapcat (comp db-content/get-matched-ids block-title) blocks)) + block-links) + content-ref-ents (map #(d/entity db [:block/uuid %]) content-ref-uuids) + content-ref-pages (filter #(or (entity-util/internal-page? %) (entity-util/journal? %)) content-ref-ents) + {:keys [properties classes]} + (build-mixed-properties-and-classes-export db content-ref-ents {:include-uuid? true :shallow-copy? true})] + {:content-ref-uuids content-ref-uuids + :content-ref-ents content-ref-ents + :properties (update-vals properties #(merge % {:build/keep-uuid? true})) + :classes (update-vals classes #(merge % {:build/keep-uuid? true})) + :pages-and-blocks (mapv #(hash-map :page (merge (shallow-copy-page %) + {:block/uuid (:block/uuid %) :build/keep-uuid? true})) + content-ref-pages)})) + +(defn- build-class-parents-export [db classes-config] + (let [class-parent-ents (->> classes-config + (filter #(:build/class-extends (val %))) + (map #(d/entity db (key %))) + db-db/get-classes-parents) + classes + (->> class-parent-ents + (remove #(db-class/logseq-class? (:db/ident %))) + ;; No new parents come from here as they are found above + (map #(vector (:db/ident %) (build-export-class % {}))) + (into {})) + class-parent-properties + (->> class-parent-ents + (mapcat :logseq.property.class/properties) + (map :db/ident) + (remove db-property/logseq-property?)) + properties (build-export-properties db class-parent-properties {:shallow-copy? true})] + {:classes classes + :properties properties})) + +(defn- build-blocks-export + "Given a vec of block entities, returns the blocks in a sqlite.build EDN format + and all properties and classes used in these blocks" + [db blocks {:keys [include-children?] :or {include-children? true} :as opts}] + (let [*properties (atom (or (get-in opts [:graph-ontology :properties]) {})) + *classes (atom (or (get-in opts [:graph-ontology :classes]) {})) + *pvalue-uuids (atom #{}) + id-map (into {} (map (juxt :db/id identity)) blocks) + children (if include-children? (group-by #(get-in % [:block/parent :db/id]) blocks) {}) + build-block (fn build-block [block*] + (let [child-nodes (mapv build-block (get children (:db/id block*) [])) + {:keys [node properties classes]} + (build-node-export db block* (-> (dissoc opts :graph-ontology) + (assoc :properties @*properties))) + new-pvalue-uuids (get-pvalue-uuids node)] + (when (seq properties) (swap! *properties merge properties)) + (when (seq classes) (swap! *classes merge classes)) + (when (seq new-pvalue-uuids) (swap! *pvalue-uuids into new-pvalue-uuids)) + (cond-> node + (seq child-nodes) (assoc :build/children child-nodes)))) + roots (remove #(contains? id-map (get-in % [:block/parent :db/id])) blocks) + exported-blocks (mapv build-block roots)] + (cond-> {:blocks exported-blocks + :pvalue-uuids @*pvalue-uuids} + (not= @*properties (get-in opts [:graph-ontology :properties])) + (assoc :properties @*properties) + (not= @*classes (get-in opts [:graph-ontology :classes])) + (assoc :classes @*classes)))) + +(defn- build-uuid-block-export [db pvalue-uuids content-ref-ents {:keys [page-entity]}] + (let [content-ref-blocks (set (remove entity-util/page? content-ref-ents)) + uuid-block-ents-to-export (concat (map #(d/entity db [:block/uuid %]) pvalue-uuids) + content-ref-blocks) + uuid-block-pages + (when (seq uuid-block-ents-to-export) + (->> uuid-block-ents-to-export + (group-by :block/page) + ;; Remove page-entity because it's already been built for content-ref-ents + ;; and it's unlikely and complex to handle for pvalue-uuids + ((fn [m] (dissoc m page-entity))) + (map (fn [[parent-page-ent blocks]] + (merge (build-blocks-export db + (sort-by :block/order blocks) + {:include-uuid-fn (constantly true) + ;; shallow copy to disallow failing pvalues + :shallow-copy? true}) + {:page (shallow-copy-page parent-page-ent)})))))] + {:properties (apply merge (map :properties uuid-block-pages)) + :classes (apply merge (map :classes uuid-block-pages)) + :pages-and-blocks (mapv #(select-keys % [:page :blocks]) uuid-block-pages)})) + +(defn sort-pages-and-blocks + "Provide a reliable sort order since this tends to be large. Helps with diffing + and readability" + [pages-and-blocks] + (vec + (sort-by #(or (get-in % [:page :block/title]) + (some-> (get-in % [:page :build/journal]) str) + (str (get-in % [:page :block/uuid]))) + pages-and-blocks))) + +(defn- finalize-export-maps + "Given final export maps, merges them, adds any missing class parents and merges those in. + If :pages-and-blocks exist, sorts them in order to have reliable sort order" + [db & export-maps] + (let [final-export* (apply merge-export-maps export-maps) + class-parents-export (some->> (:classes final-export*) (build-class-parents-export db)) + merged-map (merge-export-maps final-export* class-parents-export)] + (cond-> merged-map + (:pages-and-blocks merged-map) + (update :pages-and-blocks sort-pages-and-blocks)))) + +(defn- build-block-export + "Exports block for given block eid" + [db eid] + (let [block-entity (d/entity db eid) + property-value-ents (->> (dissoc (db-property/properties block-entity) :block/tags) + vals + (filter de/entity?)) + {:keys [content-ref-uuids content-ref-ents] :as content-ref-export} + (build-content-ref-export db (into [block-entity] property-value-ents)) + node-export (build-node-export db block-entity {:include-uuid-fn content-ref-uuids}) + pvalue-uuids (get-pvalue-uuids (:node node-export)) + uuid-block-export (build-uuid-block-export db pvalue-uuids content-ref-ents {}) + block-export (finalize-export-maps db node-export uuid-block-export content-ref-export)] + (merge {::block (:node node-export)} + block-export))) + +(defn- build-page-blocks-export + "If :include-alias? option is set, the caller fn is responsible for defining alias pages" + [db page-entity {:keys [properties classes blocks ontology-page? include-alias?] :as options}] + (let [options' (cond-> (dissoc options :classes :blocks :graph-ontology) + (:exclude-ontology? options) + (assoc :properties (get-in options [:graph-ontology :properties]))) + page-ent-export (if ontology-page? + ;; Ontology pages are already built in build-graph-ontology-export + {:node (select-keys page-entity [:block/uuid])} + (build-node-export db page-entity options')) + page-pvalue-uuids (get-pvalue-uuids (:node page-ent-export)) + page (if ontology-page? + (:node page-ent-export) + (merge (dissoc (:node page-ent-export) :block/title) + (shallow-copy-page page-entity) + (when (and include-alias? (:block/alias page-entity)) + {:block/alias (set (map #(vector :block/uuid (:block/uuid %)) (:block/alias page-entity)))}))) + page-blocks-export {:pages-and-blocks [{:page page :blocks (or blocks [])}] + :properties properties + :classes classes}] + (assoc (merge-export-maps page-blocks-export page-ent-export) + :pvalue-uuids page-pvalue-uuids))) + +(defn- get-page-blocks [db eid] + (->> (d/datoms db :avet :block/page eid) + (map :e) + (map #(d/entity db %)))) + +(defn- remove-uuid-if-not-ref-given-uuids + "Cleans up blocks that have uuids that are not referenced elsewhere. + Handles a block map and its properties' value blocks (one level deep). For property + value blocks also handles reverting the value back to its concise form as needed" + [ref-uuids m] + (cond-> m + (not (contains? ref-uuids (:block/uuid m))) + (dissoc :block/uuid :build/keep-uuid?) + (:build/properties m) + (update :build/properties + (fn [props] + (let [shrink-property-value + (fn shrink-property-value [v] + (if (sqlite-build/block-property-value? v) + ;; Keep property value as map if uuid is referenced or it has unique attributes + (if (or (contains? ref-uuids (:block/uuid v)) + ;; Keep this in sync with build-pvalue-entity-default + ((some-fn :build/tags :build/properties :build/children) v)) + v + (:block/title v)) + v))] + (update-vals props + (fn [v] + (if (set? v) + (set (map shrink-property-value v)) + (shrink-property-value v))))))))) + +(defn- pvalue-descendant? [block] + (loop [parent (:block/parent block)] + (if-not parent + false + (if (:logseq.property/created-from-property parent) + true + (recur (:block/parent parent)))))) + +(defn- build-page-export* + "When given the :handle-block-uuids option, handle uuid references between + blocks including property value blocks" + [db eid page-blocks* {:keys [handle-block-uuids?] :as options}] + (let [page-entity (d/entity db eid) + page-blocks (->> page-blocks* + (sort-by :block/order) + ;; Remove property value blocks and their children as they are exported in :build/properties + (remove #(or (:logseq.property/created-from-property %) + (pvalue-descendant? %)))) + {:keys [pvalue-uuids] :as blocks-export*} + (build-blocks-export db page-blocks (cond-> options + handle-block-uuids? + (assoc :include-uuid-fn (constantly true)))) + blocks-export (if handle-block-uuids? + (let [remove-uuid-if-not-ref + (partial remove-uuid-if-not-ref-given-uuids + (set/union (set pvalue-uuids) + (when (set? (:include-uuid-fn options)) (:include-uuid-fn options))))] + (update blocks-export* :blocks #(sqlite-build/update-each-block % remove-uuid-if-not-ref))) + blocks-export*) + ontology-page-export + (when (and (not (:ontology-page? options)) + (or (entity-util/class? page-entity) (entity-util/property? page-entity))) + (build-mixed-properties-and-classes-export db [page-entity] {:include-uuid? true})) + class-page-properties-export + (when-let [props + (and (not (:ontology-page? options)) + (entity-util/class? page-entity) + (->> (:logseq.property.class/properties page-entity) + (map :db/ident) + seq))] + {:properties (build-export-properties db props {:shallow-copy? true})}) + page-block-options (cond-> blocks-export + ontology-page-export + (merge-export-maps ontology-page-export class-page-properties-export) + true + (merge options + {:blocks (:blocks blocks-export)} + (when ontology-page-export {:ontology-page? true}))) + page-blocks-export (build-page-blocks-export db page-entity page-block-options) + page-block-uuids (set/union pvalue-uuids (:pvalue-uuids page-blocks-export)) + page-export (assoc page-blocks-export :pvalue-uuids page-block-uuids)] + page-export)) + +(defn- build-page-export + "Exports page for given page eid" + [db eid] + (let [page-blocks* (get-page-blocks db eid) + {:keys [content-ref-ents] :as content-ref-export} (build-content-ref-export db page-blocks*) + {:keys [pvalue-uuids] :as page-export*} + (build-page-export* db eid page-blocks* {:include-uuid-fn (:content-ref-uuids content-ref-export) + :include-pvalue-uuid-fn (:content-ref-uuids content-ref-export) + :handle-block-uuids? true + :include-alias? true}) + page-entity (d/entity db eid) + uuid-block-export (build-uuid-block-export db pvalue-uuids content-ref-ents {:page-entity page-entity}) + alias-export (when (:block/alias page-entity) + {:pages-and-blocks (mapv #(hash-map :page (merge (shallow-copy-page %) + {:block/uuid (:block/uuid %) :build/keep-uuid? true})) + (:block/alias page-entity))}) + page-export (finalize-export-maps db page-export* uuid-block-export content-ref-export alias-export)] + page-export)) + +(defn- build-nodes-export + "Export a mix of pages and blocks" + [db nodes opts] + (let [node-pages (filter entity-util/page? nodes) + pages-export + (merge + (build-mixed-properties-and-classes-export db node-pages {:shallow-copy? true}) + {:pages-and-blocks (mapv #(hash-map :page (shallow-copy-page %)) + (filter #(or (entity-util/internal-page? %) (entity-util/journal? %)) node-pages))}) + node-blocks (remove entity-util/page? nodes) + ;; Similar to build-uuid-block-export + pages-to-blocks + (->> node-blocks + (group-by :block/page) + (map (fn [[parent-page-ent blocks]] + (merge (build-blocks-export db (sort-by :block/order blocks) opts) + {:page (shallow-copy-page parent-page-ent)})))) + pages-to-blocks-export + {:properties (apply merge (map :properties pages-to-blocks)) + :classes (apply merge (map :classes pages-to-blocks)) + :pages-and-blocks (mapv #(select-keys % [:page :blocks]) pages-to-blocks)}] + (merge (merge-export-maps pages-export pages-to-blocks-export) + {:pvalue-uuids (apply set/union (map :pvalue-uuids pages-to-blocks))}))) + +(defn- build-view-nodes-export + "Exports given nodes from a view. Nodes are a random mix of blocks and pages" + [db rows {:keys [group-by?]}] + (let [eids (if group-by? (mapcat second rows) rows) + nodes (map #(d/entity db (if (uuid? %) [:block/uuid %] %)) eids) + property-value-ents (mapcat #(->> (apply dissoc (db-property/properties %) db-property/public-db-attribute-properties) + vals + (filter de/entity?)) + nodes) + {:keys [content-ref-uuids content-ref-ents] :as content-ref-export} + (build-content-ref-export db (into nodes property-value-ents)) + {:keys [pvalue-uuids] :as nodes-export} + (build-nodes-export db nodes {:include-uuid-fn content-ref-uuids :include-children? false}) + uuid-block-export (build-uuid-block-export db pvalue-uuids content-ref-ents {}) + view-nodes-export (finalize-export-maps db nodes-export uuid-block-export content-ref-export)] + view-nodes-export)) + +(defn- build-selected-nodes-export + "Exports given nodes selected by a user. Nodes can be a mix of blocks and pages" + [db eids] + (let [top-level-nodes (keep #(d/entity db %) eids) + children-nodes (->> top-level-nodes + ;; Remove pages b/c when selected their children are not highlighted + (remove entity-util/page?) + (mapcat #(rest (ldb/get-block-and-children db (:block/uuid %)))) + (remove :logseq.property/created-from-property)) + nodes (concat top-level-nodes children-nodes) + property-value-ents (mapcat #(->> (apply dissoc (db-property/properties %) db-property/public-db-attribute-properties) + vals + (filter de/entity?)) + nodes) + {:keys [content-ref-uuids content-ref-ents] :as content-ref-export} + (build-content-ref-export db (into nodes property-value-ents)) + {:keys [pvalue-uuids] :as nodes-export} + (build-nodes-export db nodes {:include-uuid-fn content-ref-uuids :include-children? true}) + uuid-block-export (build-uuid-block-export db pvalue-uuids content-ref-ents {}) + view-nodes-export (finalize-export-maps db nodes-export uuid-block-export content-ref-export)] + view-nodes-export)) + +(defn- build-graph-ontology-export + "Exports a graph's tags and properties" + [db {:keys [exclude-namespaces] :as options}] + (let [exclude-regex (when (seq exclude-namespaces) + (re-pattern (str "^(" + (string/join "|" (map name exclude-namespaces)) + ")(\\.|$)"))) + user-property-idents (d/q '[:find [?db-ident ...] + :where [?p :db/ident ?db-ident] + [?p :block/tags :logseq.class/Property] + (not [?p :logseq.property/built-in?])] + db) + user-property-idents' (if (seq exclude-namespaces) + (remove #(re-find exclude-regex (namespace %)) user-property-idents) + user-property-idents) + properties (build-export-properties db user-property-idents' (merge options {:include-properties? true})) + class-ents (->> (d/q '[:find [?class ...] + :where [?class :block/tags :logseq.class/Tag] + (not [?class :logseq.property/built-in?])] + db) + (map #(d/entity db %)) + (remove #(and (seq exclude-namespaces) (re-find exclude-regex (namespace (:db/ident %)))))) + classes + (->> class-ents + (map (fn [ent] + (let [ent-properties (apply dissoc (db-property/properties ent) :logseq.property.class/extends db-property/public-db-attribute-properties)] + (vector (:db/ident ent) + (let [build-properties + (-> (buildable-properties db ent-properties properties options) + (dissoc :logseq.property.class/properties))] + (cond-> (build-export-class ent options) + (seq build-properties) + (assoc :build/properties build-properties))))))) + (into {}))] + (cond-> {} + (seq properties) + (assoc :properties properties) + (seq classes) + (assoc :classes classes)))) + +;; :graph-human only fns +;; ===================== +(defn- get-graph-content-ref-uuids + [db {:keys [:exclude-built-in-pages?]}] + (let [;; Add support for exclude-built-in-pages? and block-titles as needed + block-titles (map :v (d/datoms db :avet :block/title)) + block-links (if exclude-built-in-pages? + (->> (d/datoms db :avet :block/link) + (keep #(when-not (:logseq.property/built-in? (:block/page (d/entity db (:e %)))) + (:block/uuid (d/entity db (:v %)))))) + (->> (d/datoms db :avet :block/link) + (map #(:block/uuid (d/entity db (:v %)))))) + content-ref-uuids (concat (->> block-titles + (filter string?) + (mapcat db-content/get-matched-ids)) + block-links)] + (set content-ref-uuids))) + +(defn- build-graph-pages-export + "Handles pages, journals and their blocks" + [db graph-ontology options*] + (let [options (merge options* + {:graph-ontology graph-ontology} + ;; dont exclude when ontology is incomplete because :closed values can fail so have to build ontology + (when (empty? (:exclude-namespaces options*)) + {:exclude-ontology? true})) + page-ids (concat (map :e (d/datoms db :avet :block/tags :logseq.class/Page)) + (map :e (d/datoms db :avet :block/tags :logseq.class/Journal))) + ontology-ids (set/union (set (map :e (d/datoms db :avet :block/tags :logseq.class/Tag))) + (set (map :e (d/datoms db :avet :block/tags :logseq.class/Property)))) + page-exports (mapv (fn [eid] + (let [page-blocks (get-page-blocks db eid)] + (build-page-export* db eid page-blocks (merge options {:include-uuid-fn (constantly true) + :include-pvalue-uuid-fn (constantly true)})))) + page-ids) + ontology-page-exports + (vec + (keep (fn [eid] + (when-let [page-blocks (seq (remove :logseq.property/created-from-property (get-page-blocks db eid)))] + (build-page-export* db eid page-blocks (merge options {:include-uuid-fn (constantly true) + :include-pvalue-uuid-fn (constantly true) + :ontology-page? true})))) + ontology-ids)) + page-exports' (remove (fn [page-export] + (and (:exclude-built-in-pages? options) + (get-in page-export [:pages-and-blocks 0 :page :build/properties :logseq.property/built-in?]))) + (concat page-exports ontology-page-exports)) + alias-uuids (concat (mapcat (fn [{:keys [pages-and-blocks]}] + (mapcat #(map second (get-in % [:page :block/alias])) + pages-and-blocks)) + page-exports') + (mapcat #(map second (:block/alias %)) + (vals (:classes graph-ontology))) + (mapcat #(map second (:block/alias %)) + (vals (:properties graph-ontology)))) + uuids-to-keep (set/union (set (mapcat :pvalue-uuids page-exports')) + (set alias-uuids) + (set (map #(get-in % [:pages-and-blocks 0 :page :block/uuid]) ontology-page-exports))) + pages-export {:pages-and-blocks (vec (mapcat :pages-and-blocks page-exports')) + ;; :pvalue-uuids is a misleading name here but using it to keep uuid key consistent across exports + :pvalue-uuids uuids-to-keep}] + pages-export)) + +(defn- build-graph-files + [db {:keys [include-timestamps?]}] + (->> (d/q '[:find [(pull ?b [:file/path :file/content :file/created-at :file/last-modified-at]) ...] + :where [?b :file/path]] db) + ;; Sort so the export is deterministic across DB backends and import order + (sort-by :file/path) + (mapv #(if include-timestamps? + (select-keys % [:file/path :file/content :file/created-at :file/last-modified-at]) + (select-keys % [:file/path :file/content]))))) + +(defn- build-kv-values + [db] + (->> (d/q '[:find [(pull ?b [:db/ident :kv/value]) ...] + :where [?b :kv/value]] db) + ;; Don't export schema-version as frontend sets this and shouldn't be overridden + (remove #(= :logseq.kv/schema-version (:db/ident %))) + vec)) + +(defn- build-property-history + "Builds property history. Always include timestamps regardless of :include-timestamps? because + timestamps are a necessary part of history" + [db] + (->> (d/q '[:find [(pull ?b [:block/uuid + :block/created-at + {:logseq.property.history/block [:block/uuid]} + {:logseq.property.history/property [:db/ident]} + {:logseq.property.history/ref-value [:db/ident :block/uuid]} + :logseq.property.history/scalar-value]) ...] + :where [?b :logseq.property.history/block]] db) + (map (fn [history] + (cond-> (-> history + (update :logseq.property.history/block + (fn [m] [:block/uuid (:block/uuid m)])) + (update :logseq.property.history/property :db/ident) + (update :logseq.property.history/ref-value + (fn [m] + (if (:db/ident m) + (:db/ident m) + [:block/uuid (:block/uuid m)])))) + (nil? (:logseq.property.history/ref-value history)) + (dissoc :logseq.property.history/ref-value) + (not (contains? history :logseq.property.history/scalar-value)) + (dissoc :logseq.property.history/scalar-value)))) + set)) + +(defn remove-uuids-if-not-ref [export-map all-ref-uuids] + (let [remove-uuid-if-not-ref (partial remove-uuid-if-not-ref-given-uuids all-ref-uuids)] + (-> export-map + (update :classes update-vals remove-uuid-if-not-ref) + (update :properties update-vals remove-uuid-if-not-ref) + (update :pages-and-blocks + (fn [pages-and-blocks] + (mapv (fn [{:keys [page blocks]}] + (let [page-map {:page (remove-uuid-if-not-ref page) + :blocks (sqlite-build/update-each-block blocks remove-uuid-if-not-ref)} + ;; TODO: Walk data structure via :build/properties instead of slower walk + page-map' + (walk/postwalk (fn [f] + (if (sqlite-build/block-property-value? f) + (remove-uuid-if-not-ref f) + f)) + page-map)] + page-map')) + pages-and-blocks)))))) +;; end of :graph-human only fns +;; ============================ + +(defn- add-ontology-for-include-namespaces + "Adds :properties to export for given namespace parents. Current use case is for :exclude-namespaces + so no need to add :classes yet" + [db {::keys [auto-include-namespaces] :as graph-export}] + (let [include-regex (re-pattern (str "^(" + (string/join "|" (map name auto-include-namespaces)) + ")(\\.|$)")) + used-properties + (->> (sqlite-build/get-used-properties-from-options graph-export) + keys + (remove db-property/internal-property?) + (filter #(re-find include-regex (namespace %))) + (map #(vector % (select-keys (d/entity db %) [:logseq.property/type :db/cardinality]))) + (into {}))] + (-> (merge-export-maps (select-keys graph-export [:properties]) + {:properties used-properties}) + (select-keys [:properties])))) + +(defn- datom-export? [export-map] + (= :datoms (::graph-format export-map))) + +(def ^:private graph-datom-export-excluded-kvs + #{:logseq.kv/local-graph-uuid + :logseq.kv/graph-uuid + :logseq.kv/graph-local-tx + :logseq.kv/remote-schema-version + :logseq.kv/graph-rtc-e2ee? + :logseq.kv/graph-remote? + :logseq.kv/import-type + :logseq.kv/imported-at + :logseq.kv/graph-backup-folder + :logseq.kv/graph-last-gc-at + :logseq.kv/graph-git-sha}) + +(def ^:private graph-datom-export-excluded-attrs + #{:block/tx-id + :logseq.property.embedding/hnsw-label + :logseq.property.embedding/hnsw-label-updated-at + :logseq.property/created-by-ref + :logseq.property.user/email + :logseq.property.user/name + :logseq.property.user/avatar}) + +(defn- graph-datom-export-excluded-eids + [db] + (into #{} + (keep #(some-> (d/entity db %) :db/id)) + graph-datom-export-excluded-kvs)) + +(defn- exportable-graph-datom? + [excluded-eids datom] + (and (not (contains? excluded-eids (:e datom))) + (not (contains? graph-datom-export-excluded-attrs (:a datom))))) + +(defn- lookup-ref? + [v] + (and (vector? v) + (= 2 (count v)) + (keyword? (first v)))) + +(defn- export-datom [db datom] + [(:e datom) + (:a datom) + (let [v (:v datom)] + (if (lookup-ref? v) + (or (:db/id (d/entity db v)) v) + v))]) + +(defn- build-graph-datoms-export + [db] + (let [excluded-eids (graph-datom-export-excluded-eids db)] + {::schema-version db-schema/version + ::graph-format :datoms + :datoms (->> (d/datoms db :eavt) + (filter #(exportable-graph-datom? excluded-eids %)) + (map #(export-datom db %)) + (sort-by first) + vec)})) + +(defn- build-graph-export + "Exports whole graph. Has the following options: + * :include-timestamps? - When set, timestamps are included on all blocks except for property value blocks + * :exclude-namespaces - A set of parent namespaces to exclude from properties and classes. + This is useful for graphs seeded with an ontology e.g. schema.org as it eliminates noisy and needless + export+import + * :exclude-built-in-pages? - When set, built-in pages are excluded from export + * :exclude-files? - When set, files are excluded from export" + [db {:keys [exclude-files?] :as options*}] + (let [options (merge options* {:property-value-uuids? true + :include-alias? true}) + content-ref-uuids (get-graph-content-ref-uuids db options) + ontology-options (merge options {:include-uuid? true}) + ontology-export (build-graph-ontology-export db ontology-options) + ontology-pvalue-uuids (set (concat (mapcat get-pvalue-uuids (vals (:properties ontology-export))) + (mapcat get-pvalue-uuids (vals (:classes ontology-export))))) + pages-export (build-graph-pages-export db ontology-export (assoc options :include-pvalue-uuid-fn content-ref-uuids)) + graph-export* (-> (merge ontology-export pages-export) (dissoc :pvalue-uuids)) + graph-export (if (seq (:exclude-namespaces options)) + (assoc graph-export* ::auto-include-namespaces (:exclude-namespaces options)) + graph-export*) + property-history (build-property-history db) + property-history-ref-uuids + (->> property-history + (mapcat (fn [history] + (keep #(when (vector? %) (second %)) + [(:logseq.property.history/block history) + (:logseq.property.history/ref-value history)]))) + set) + all-ref-uuids (set/union content-ref-uuids ontology-pvalue-uuids (:pvalue-uuids pages-export) + property-history-ref-uuids) + files (when-not exclude-files? (build-graph-files db options)) + kv-values (build-kv-values db) + ;; Remove all non-ref uuids after all nodes are built. + ;; Only way to ensure all pvalue uuids present across block types + graph-export' (-> (remove-uuids-if-not-ref graph-export all-ref-uuids) + (update :pages-and-blocks sort-pages-and-blocks) + (assoc ::schema-version db-schema/version))] + (cond-> graph-export' + (not exclude-files?) + (assoc ::graph-files files) + true + (assoc ::kv-values kv-values) + true + (assoc ::property-history property-history)))) + +(defn- find-undefined-classes-and-properties [{:keys [classes properties pages-and-blocks]}] + (let [referenced-classes + (->> (concat (mapcat :build/property-classes (vals properties)) + (keep :class/parent (vals classes)) + (mapcat (comp :block/tags :page) pages-and-blocks) + (mapcat #(sqlite-build/extract-from-blocks (:blocks %) :build/tags) pages-and-blocks)) + (remove db-class/logseq-class?) + set) + undefined-classes (set/difference referenced-classes (set (keys classes))) + referenced-properties + (->> (concat (mapcat :build/class-properties (vals classes)) + (mapcat (comp keys :build/properties :page) pages-and-blocks) + (mapcat #(sqlite-build/extract-from-blocks (:blocks %) (comp keys :build/properties)) pages-and-blocks)) + (remove db-property/internal-property?) + set) + undefined-properties (set/difference referenced-properties (set (keys properties))) + undefined (cond-> {} + (seq undefined-classes) (assoc :classes undefined-classes) + (seq undefined-properties) (assoc :properties undefined-properties))] + ;; (prn :rclasses referenced-classes) + ;; (prn :rproperties referenced-properties) + undefined)) + +(defn- find-undefined-uuids [db {:keys [classes properties pages-and-blocks]}] + (let [pvalue-known-uuids (atom #{}) + _ (walk/postwalk (fn [f] + (if (and (sqlite-build/block-property-value? f) (:block/uuid f)) + (swap! pvalue-known-uuids conj (:block/uuid f)) + f)) + pages-and-blocks) + known-uuids + (->> (concat (keep :block/uuid (vals classes)) + (keep :block/uuid (vals properties)) + (keep #(get-in % [:page :block/uuid]) pages-and-blocks) + (mapcat #(sqlite-build/extract-from-blocks (:blocks %) (fn [m] (some-> m :block/uuid vector))) + pages-and-blocks) + @pvalue-known-uuids) + set) + ;; Only looks one-level deep in properties e.g. not inside :build/page + ;; Doesn't find :block/link refs + ref-uuids + (->> (concat (mapcat #(map second (:block/alias %)) (vals classes)) + (mapcat #(map second (:block/alias %)) (vals properties)) + (mapcat #(map second (:block/alias (:page %))) pages-and-blocks) + (mapcat get-pvalue-uuids (vals classes)) + (mapcat get-pvalue-uuids (vals properties)) + (mapcat (comp get-pvalue-uuids :page) pages-and-blocks) + (mapcat #(sqlite-build/extract-from-blocks (:blocks %) get-pvalue-uuids) pages-and-blocks)) + (remove (fn [id] + (let [eid (when id [:block/uuid id])] + (some->> eid + (d/entity db) + :logseq.property/created-from-property)))) + set)] + (set/difference ref-uuids known-uuids))) + +(defn- remove-namespaced-keys + "Removes keys from this ns for maps passed sqlite.build fns as they don't need to validate or use them" + [m] + (->> m + (remove (fn [[k _v]] (= "logseq.db.sqlite.export" (namespace k)))) + (into {}))) + +;; Remove if DB graphs are migrated to no longer have invalid keywords +(defn- patch-invalid-keywords + "Fixes invalids keywords whose name start with a number e.g. :user.property/2ndsomething" + [m] + (let [initial-version (:kv/value (first (filter #(= :logseq.kv/graph-initial-schema-version (:db/ident %)) + (::kv-values m))))] + ;; Only ignore patch if initial version is > 64.8 since this fix started with 64.9 + (if (some-> initial-version (db-schema/compare-schema-version {:major 64 :minor 8}) pos?) + m + (walk/postwalk + (fn [e] + (if (and (keyword? e) (some-> (namespace e) (string/starts-with? "user."))) + ;; Copied from create-db-ident-from-name since this may be shortlived + (let [sanitized-kw (keyword (namespace e) + (->> (string/replace-first (name e) #"^(\d)" "NUM-$1") + (filter #(re-find #"[0-9a-zA-Z*+!_'?<>=-]{1}" %)) + (apply str)))] + ;; (when (not= sanitized-kw e) (prn :sanitize e :-> sanitized-kw)) + (if (not= sanitized-kw e) sanitized-kw e)) + e)) + m)))) + +(defn- basic-validate-export + "Checks that export map is usable by sqlite.build including checking that + all referenced properties and classes are defined. This validation is not as robust + as validate-export. Checks related to properties and + classes are disabled when :exclude-namespaces is set because those checks can't be done" + [db export-map* {:keys [graph-options]}] + (when-not (datom-export? export-map*) + (let [export-map (remove-namespaced-keys export-map*)] + (when-not (seq (:exclude-namespaces graph-options)) (sqlite-build/validate-options export-map)) + (let [undefined-uuids (find-undefined-uuids db export-map) + undefined (cond-> {} + (empty? (:exclude-namespaces graph-options)) + (merge (find-undefined-classes-and-properties export-map)) + (seq undefined-uuids) + (assoc :uuids undefined-uuids))] + (when (seq undefined) + (throw (ex-info (str "The following classes, uuids and properties are not defined: " (pr-str undefined)) + undefined))))))) + +(defn build-export + "Handles exporting db by given export-type. Most export options are meant for + a human to edit and read except for :graph with :datoms. There are two + graph-wide exports, :graph and :graph-human. :graph is designed to be simple, + reliable and for machines. :graph-human is designed for humans to read, edit + and instill confidence that their full graph's data is accessible to them. + :graph-human comes with a number of options to customize the export's + granularity including toggling export of timestamps, files and certain + namespaces. See build-graph-export for more" + [db {:keys [export-type] :as options}] + (let [export-map* + (case export-type + :block + (build-block-export db (:block-id options)) + :page + (build-page-export db (:page-id options)) + :view-nodes + (build-view-nodes-export db (:rows options) (select-keys options [:group-by?])) + :selected-nodes + (build-selected-nodes-export db (:node-ids options)) + :graph-ontology + (build-graph-ontology-export db {}) + :graph + (build-graph-datoms-export db) + :graph-human + (build-graph-export db (:graph-options options)) + (throw (ex-info (str (pr-str export-type) " is an invalid export-type") {}))) + export-map (patch-invalid-keywords export-map*)] + (if (get-in options [:graph-options :catch-validation-errors?]) + (try + (basic-validate-export db export-map options) + (catch ExceptionInfo e + (println "Caught error:" e))) + (basic-validate-export db export-map options)) + (assoc export-map ::export-type export-type))) + +;; Import fns +;; ========== +(defn- add-uuid-to-page-if-exists + [db import-to-existing-page-uuids {:keys [existing-pages-keep-properties?]} m] + (if-let [ent (if (:build/journal m) + (some->> (:build/journal m) + (d/datoms db :avet :block/journal-day) + first + :e + (d/entity db)) + ;; TODO: For now only check page uniqueness by title. Could handle more uniqueness checks later + (some->> (:block/title m) (ldb/get-case-page db)))] + (do + (swap! import-to-existing-page-uuids assoc (:block/uuid m) (:block/uuid ent)) + (cond-> (assoc m :block/uuid (:block/uuid ent)) + (and (:build/properties m) existing-pages-keep-properties?) + (update :build/properties (fn [props] + (->> props + (remove (fn [[k _v]] (get ent k))) + (into {})))))) + m)) + +(defn- update-existing-properties + "Updates existing properties by ident. Also check imported and existing properties have + the same cardinality and type to avoid failure after import" + [db property-conflicts properties] + (->> properties + (map (fn [[k v]] + (if-let [ent (d/entity db k)] + (do + (when (not= (select-keys ent [:logseq.property/type :db/cardinality]) + (select-keys v [:logseq.property/type :db/cardinality])) + (swap! property-conflicts conj + {:property-id k + :actual (select-keys v [:logseq.property/type :db/cardinality]) + :expected (select-keys ent [:logseq.property/type :db/cardinality])})) + [k (assoc v :block/uuid (:block/uuid ent))]) + [k v]))) + (into {}))) + +(defn- check-for-existing-entities + "Checks export map for existing entities and adds :block/uuid to them if they exist in graph to import. + Also checks for property conflicts between existing properties and properties to be imported" + [db {:keys [pages-and-blocks classes properties] ::keys [export-type import-options] :as export-map} property-conflicts] + (let [import-to-existing-page-uuids (atom {}) + export-map + (cond-> {:build-existing-tx? true + :extract-content-refs? false} + (seq pages-and-blocks) + (assoc :pages-and-blocks + (mapv (fn [m] + (update m :page (partial add-uuid-to-page-if-exists db import-to-existing-page-uuids import-options))) + pages-and-blocks)) + (seq classes) + (assoc :classes + (->> classes + (map (fn [[k v]] + (if-let [ent (d/entity db k)] + [k (assoc v :block/uuid (:block/uuid ent))] + [k v]))) + (into {}))) + (seq properties) + (assoc :properties (update-existing-properties db property-conflicts properties)) + ;; Graph exports don't use :build/page so this speeds up build + (#{:graph :graph-human} export-type) + (assoc :translate-property-values? false) + (#{:graph :graph-human} export-type) + ;; Currently all graph-files are created by app so no need to distinguish between user and built-in ones yet + (merge (dissoc export-map :pages-and-blocks :classes :properties))) + export-map' (if (#{:graph :graph-human} export-type) + export-map + (walk/postwalk (fn [f] + (if (and (vector? f) (= :build/page (first f))) + [:build/page + (add-uuid-to-page-if-exists db import-to-existing-page-uuids import-options (second f))] + f)) + export-map)) + ;; Update uuid references of all pages that had their uuids updated to reference an existing page + export-map'' + (walk/postwalk (fn [f] + (if-let [new-uuid (and (vector? f) (= :block/uuid (first f)) + (get @import-to-existing-page-uuids (second f)))] + [:block/uuid new-uuid] + f)) + export-map')] + export-map'')) + +(defn- build-block-import-options + "Builds options for sqlite-build to import into current-block" + [current-block export-map] + (let [block (merge (::block export-map) + {:block/uuid (:block/uuid current-block) + :block/page (select-keys (:block/page current-block) [:block/uuid])}) + pages-and-blocks + [{:page (select-keys (:block/page block) [:block/uuid]) + :blocks [(dissoc block :block/page)]}]] + (merge-export-maps export-map {:pages-and-blocks pages-and-blocks}))) + +(defn- current-db-retract-tx + [db] + (->> (d/datoms db :eavt) + (map :e) + distinct + (mapv (fn [e] [:db/retractEntity e])))) + +(def ^:private datom-schema-attrs + #{:db/ident + :db/cardinality + :db/valueType + :db/unique + :db/index}) + +(defn- schema-datom-eids + [datoms] + (let [ident-eids (into #{} (keep (fn [[e a _v]] + (when (= :db/ident a) e))) + datoms) + schema-eids (into #{} (keep (fn [[e a _v]] + (when (and (not= :db/ident a) + (datom-schema-attrs a)) + e))) + datoms)] + (set/intersection ident-eids schema-eids))) + +(defn- resolve-lookup-refs + [datoms] + (let [lookup-ref->eid (reduce (fn [m [e a v]] + (assoc m [a v] e)) + {} + datoms)] + (map (fn [[e a v]] + [e a (if (lookup-ref? v) + (get lookup-ref->eid v v) + v)]) + datoms))) + +(defn- datoms-for-import + [datoms] + (let [datoms (resolve-lookup-refs datoms) + schema-eids (schema-datom-eids datoms) + schema-datoms (filter (fn [[e a _v]] + (and (schema-eids e) + (datom-schema-attrs a))) + datoms) + schema-datoms' (set schema-datoms)] + (concat schema-datoms (remove schema-datoms' datoms)))) + +(defn- build-datom-import + [export-map db] + {:init-tx (into (current-db-retract-tx db) + (map (fn [[e a v]] [:db/add e a v])) + (datoms-for-import (:datoms export-map))) + :block-props-tx [] + :misc-tx []}) + +(defn build-import + "Given an export map, build the import tx to create it. In addition to standard sqlite.build keys, + an export map can have the following namespaced keys: + * ::export-type - Keyword indicating export type + * ::block - Block map for a :block export + * :datoms - Vec of [e a v] datom tuples for a :graph export + * ::graph-files - Vec of files for a :graph-human export + * ::kv-values - Vec of :kv/value maps for a :graph-human export + * ::property-history - Set of property history blocks for a :graph-human export + * ::auto-include-namespaces - A set of parent namespaces to include from properties and classes + for a :graph-human export. See :exclude-namespaces in build-graph-export for a similar option + * ::import-options - A map of options that alters importing behavior. Has the following keys: + * :existing-pages-keep-properties? - Boolean which allows existing pages to keep existing properties + + This fn then returns a map of txs to transact with the following keys: + * :init-tx - Txs that must be transacted first, usually because they define new properties + * :block-props-tx - Txs to transact after :init-tx, usually because they use newly defined properties + * :misc-tx - Txs to transact unrelated to other txs" + [export-map* db {:keys [current-block]}] + (cond + (datom-export? export-map*) + (build-datom-import export-map* db) + + :else + (let [export-map (if (and (::block export-map*) current-block) + (build-block-import-options current-block export-map*) + export-map*) + export-map' (if (and (#{:graph :graph-human} (::export-type export-map*)) (seq (::auto-include-namespaces export-map*))) + (merge (dissoc export-map :properties ::auto-include-namespaces) + (add-ontology-for-include-namespaces db export-map)) + export-map) + property-conflicts (atom []) + export-map'' (check-for-existing-entities db export-map' property-conflicts)] + (if (seq @property-conflicts) + (do + (js/console.error :property-conflicts @property-conflicts) + {:error (str "The following imported properties conflict with the current graph: " + (pr-str (mapv :property-id @property-conflicts)))}) + (if (#{:graph :graph-human} (::export-type export-map'')) + (-> (sqlite-build/build-blocks-tx (remove-namespaced-keys export-map'')) + (assoc :misc-tx (vec (concat (::graph-files export-map'') + (::kv-values export-map'') + (::property-history export-map''))))) + (sqlite-build/build-blocks-tx (remove-namespaced-keys export-map''))))))) + +(defn import-tx-data + [{:keys [init-tx block-props-tx misc-tx]}] + (vec (concat init-tx block-props-tx misc-tx))) + +(defn- disallowed-key-error? + [error] + (some #(= "disallowed key" %) + (tree-seq coll? seq error))) + +(defn- disallowed-key-attrs + [errors] + (into {} + (keep (fn [{:keys [entity errors]}] + (let [attrs (->> errors + (keep (fn [[attr error]] + (when (disallowed-key-error? error) + attr))) + set)] + (when (seq attrs) + [(:db/id entity) attrs])))) + errors)) + +(defn- all-disallowed-key-errors? + [errors] + (every? (fn [{:keys [errors]}] + (and (seq errors) + (every? disallowed-key-error? (vals errors)))) + errors)) + +(defn- remove-disallowed-key-datoms + [tx-data eid->attrs] + (remove (fn [tx] + (and (vector? tx) + (= :db/add (first tx)) + (contains? (get eid->attrs (second tx)) (nth tx 2)))) + tx-data)) + +(defn- validate-import-tx-data + [txs db edn-label] + (loop [tx-data (import-tx-data txs)] + (let [db-after (:db-after (d/with db tx-data)) + validation (db-validate/validate-local-db! db-after)] + (if-let [errors (seq (:errors validation))] + (let [eid->attrs (disallowed-key-attrs errors) + tx-data' (remove-disallowed-key-datoms tx-data eid->attrs)] + (if (and (all-disallowed-key-errors? errors) + (seq eid->attrs) + (not= (count tx-data) (count tx-data'))) + (recur (vec tx-data')) + {:error (str "The " edn-label " has " (count errors) " validation error(s)") + :errors errors})) + {:db db-after + :tx-data tx-data})))) + +(defn validate-import-txs + "Dry-runs import txs against db and validates the resulting local DB. + Returns {:db db-after :tx-data tx-data} when valid or {:error string} when invalid." + ([txs db] + (validate-import-txs txs db {:edn-label "Imported EDN"})) + ([txs db {:keys [edn-label] + :or {edn-label "Imported EDN"}}] + (if-let [error (:error txs)] + {:error error} + (try + (let [result (validate-import-tx-data txs db edn-label)] + (if-let [errors (seq (:errors result))] + (do + (js/console.error (str edn-label " has " (count errors) " validation error(s)")) + (pprint/pprint + (mapv + (fn [{:keys [entity dispatch-key] entity-errors :errors}] + {:entity (select-keys entity + [:db/id + :db/ident + :block/uuid + :block/title]) + :dispatch-key dispatch-key + :errors entity-errors}) + errors)) + (dissoc result :errors)) + result)) + (catch :default e + (js/console.error (str "Unexpected " edn-label " validation error:") e) + {:error (str "The " edn-label " is unexpectedly invalid: " (pr-str (ex-message e)))}))))) + +(defn create-conn + "Create a conn for a DB graph seeded with initial data" + [] + (let [conn (d/create-conn db-schema/schema) + _ (d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))] + (entity-plus/reset-immutable-entities-cache!) + conn)) + +(defn validate-export + "Validates an export by creating an in-memory DB graph, importing the EDN and validating the graph. + Returns a map with a readable :error key if any error occurs" + [export-edn] + (try + (let [import-conn (create-conn) + txs (build-import export-edn @import-conn {})] + (validate-import-txs txs @import-conn {:edn-label "Exported EDN"})) + (catch :default e + (js/console.error "Unexpected export-edn validation error:" e) + {:error (str "The exported EDN is unexpectedly invalid: " (pr-str (ex-message e)))}))) + +(defn- prepare-export-to-diff + "Prepare a graph's exported edn to be diffed with another" + [m] + (-> m + (update ::kv-values + (fn [kvs] + (->> kvs + ;; This varies per copied graph so ignore it + (remove #(#{:logseq.kv/import-type :logseq.kv/imported-at :logseq.kv/local-graph-uuid} + (:db/ident %))) + (sort-by :db/ident) + vec))))) + +(defn diff-exports + "Given two graph export edns, return a vector of diffs when there is a diff and nil when there is + no diff between the two" + [export-map export-map2] + (let [diff (->> (data/diff (prepare-export-to-diff export-map) (prepare-export-to-diff export-map2)) + butlast)] + (when-not (= [nil nil] diff) + diff))) diff --git a/deps/db/src/logseq/db/sqlite/gc.cljs b/deps/db/src/logseq/db/sqlite/gc.cljs new file mode 100644 index 0000000..996603e --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/gc.cljs @@ -0,0 +1,120 @@ +(ns logseq.db.sqlite.gc + "GC unused addresses from `kvs` table" + (:require [cljs-bean.core :as bean] + [clojure.set :as set] + [logseq.db.sqlite.util :as sqlite-util])) + +(defn- walk-addresses + "Given a map of parent address to children addresses and a root address, + returns a set of all used addresses including the root and its descendants." + [root addr->children] + (println :debug :walk-addresses :root root) + (time + (letfn [(collect-addresses [addr] + (let [children (addr->children addr)] + (into #{addr} (mapcat collect-addresses children))))] + (collect-addresses root)))) + +(defonce get-non-refed-addrs-sql + "WITH all_referenced AS ( + SELECT CAST(value AS INTEGER) AS addr + FROM kvs, json_each(kvs.addresses) + ) + SELECT kvs.addr + FROM kvs + WHERE kvs.addr NOT IN (SELECT addr FROM all_referenced)") + +(defn- get-unused-addresses + [db] + (let [schema (some->> (.exec db #js {:sql "select content from kvs where addr = 0" + :rowMode "array"}) + bean/->clj + ffirst + sqlite-util/read-transit-str) + ;; 0: Datascript sets 0 as the address to store the db's meta, including addresses for :eavt, :avet, and aevt index. + ;; 1: Datascript sets 1 for tail, to improve the performance + internal-addrs (set [0 1 (:eavt schema) (:avet schema) (:aevt schema)]) + non-refed-addrs (->> (.exec db #js {:sql get-non-refed-addrs-sql + :rowMode "array"}) + (map first) + set)] + (set/difference non-refed-addrs internal-addrs))) + +(defn gc-kvs-table! + "WASM version to GC kvs table to remove unused addresses" + [^Object db {:keys [full-gc?] :as opts}] + (when db + (let [unused-addresses (get-unused-addresses db)] + (if (seq unused-addresses) + (do + (println :debug :db-gc :unused-addresses unused-addresses) + (.transaction db (fn [tx] + (doseq [addr unused-addresses] + (.exec tx #js {:sql "Delete from kvs where addr = ?" + :bind #js [addr]})))) + (when full-gc? + (gc-kvs-table! db opts))) + (println :debug :db-gc "There's no garbage data that's need to be collected."))))) + +(defn- get-unused-addresses-node-version + [^object db] + (let [schema (let [^object stmt (.prepare db "select content from kvs where addr = ?") + content (.-content (.get ^object stmt 0))] + (sqlite-util/read-transit-str content)) + internal-addrs (set [0 1 (:eavt schema) (:avet schema) (:aevt schema)]) + non-refed-addrs (let [^object stmt (.prepare db get-non-refed-addrs-sql)] + (->> (.all stmt) + bean/->clj + (map :addr) + (set)))] + (set/difference non-refed-addrs internal-addrs))) + +(defn- get-unused-addresses-node-walk-version + [^object db] + (let [schema (let [^object stmt (.prepare db "select content from kvs where addr = ?") + content (.-content (.get ^object stmt 0))] + (sqlite-util/read-transit-str content)) + set-addresses #{(:eavt schema) (:avet schema) (:aevt schema)} + internal-addresses (conj set-addresses 0 1) + parent->children (let [^object stmt (.prepare db "select addr, addresses from kvs")] + (->> (.all stmt) + bean/->clj + (map (fn [{:keys [addr addresses]}] + [addr (bean/->clj (js/JSON.parse addresses))])) + (into {}))) + used-addresses (->> (mapcat (fn [set-root-addr] + (walk-addresses set-root-addr parent->children)) set-addresses) + set + (set/union internal-addresses))] + (set/difference (set (keys parent->children)) used-addresses))) + +(defn gc-kvs-table-node-version! + "Node version to GC kvs table to remove unused addresses + `walk?` - `true`: walk all used addresses, `false`: gc recursively" + [^Object db walk?] + (let [unused-addresses (if walk? + (get-unused-addresses-node-walk-version db) + (get-unused-addresses-node-version db)) + addrs-count (let [^object stmt (.prepare db "select count(*) as c from kvs")] + (.-c (.get stmt)))] + (println :debug "addrs total count: " addrs-count) + (if (seq unused-addresses) + (do + (println :debug :db-gc :unused-addresses-count (count unused-addresses)) + (let [^object stmt (.prepare db "Delete from kvs where addr = ?") + delete (.transaction + db + (fn [addrs] + (doseq [addr addrs] + (.run ^object stmt addr))))] + (delete (bean/->js unused-addresses)) + (when-not walk? + (gc-kvs-table-node-version! db false)))) + (println :debug :db-gc "There's no garbage data that's need to be collected.")))) + +(defn ensure-no-garbage + [^Object db] + (let [unused-addresses (get-unused-addresses-node-version db)] + ;; (println :debug :db-gc :unused-addresses-count (count unused-addresses)) + ;; (println :debug :unused-addresses unused-addresses) + (empty? unused-addresses))) diff --git a/deps/db/src/logseq/db/sqlite/util.cljs b/deps/db/src/logseq/db/sqlite/util.cljs new file mode 100644 index 0000000..db8a163 --- /dev/null +++ b/deps/db/src/logseq/db/sqlite/util.cljs @@ -0,0 +1,134 @@ +(ns logseq.db.sqlite.util + "Utils fns for backend sqlite db" + (:require [cljs-bean.transit] + [clojure.string :as string] + [cognitect.transit :as transit] + [datascript.core] + [datascript.impl.entity :as de] + [datascript.transit :as dt] + [logseq.common.config :as common-config] + [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] + [logseq.db.common.order :as db-order] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.type :as db-property-type])) + +(defonce db-version-prefix common-config/db-version-prefix) + +(def ^:private write-handlers (cljs-bean.transit/writer-handlers)) +(def ^:private read-handlers {}) +(def write-transit-str + (let [write-handlers* (->> (assoc dt/write-handlers + de/Entity (transit/write-handler (constantly "datascript/Entity") + (fn [^de/entity entity] + (assert (some? (:db/id entity))) + (assoc (.-kv entity) + :db/id (:db/id entity)))) + ExceptionInfo (transit/write-handler (constantly "error") + (fn [e] + {:message (ex-message e) + :data (ex-data e)})) + js/Error (transit/write-handler (constantly "js/Error") + (fn [e] {:message (ex-message e)}))) + (merge write-handlers)) + writer (transit/writer :json {:handlers write-handlers*})] + (fn write-transit-str* [o] + (try (transit/write writer o) + (catch :default e + (prn ::write-transit-str (type o) o) + (js/console.trace) + (throw e)))))) + +(def read-transit-str + (let [read-handlers* (->> (assoc dt/read-handlers + "datascript/Entity" identity + "error" (fn [m] (ex-info (:message m) (:data m))) + "js/Error" (fn [m] (js/Error. (:message m)))) + (merge read-handlers)) + reader (transit/reader :json {:handlers read-handlers*})] + (fn read-transit-str* [s] + (transit/read reader s)))) + +(defn db-based-graph? + [graph-name] + (when graph-name + (string/starts-with? graph-name db-version-prefix))) + +(def block-with-timestamps common-util/block-with-timestamps) + +(defn build-new-property + "Build a standard new property so that it is is consistent across contexts. Takes + an optional map with following keys: + * :title - Case sensitive property name. Defaults to deriving this from db-ident + * :block-uuid - :block/uuid for property" + ([db-ident prop-schema] (build-new-property db-ident prop-schema {})) + ([db-ident prop-schema {:keys [title block-uuid ref-type? properties]}] + (let [db-ident' (if (qualified-keyword? db-ident) + db-ident + (db-property/create-user-property-ident-from-name (name db-ident))) + prop-name (or title (name db-ident')) + prop-type (get prop-schema :logseq.property/type :default)] + (merge + (dissoc prop-schema :db/cardinality) + (block-with-timestamps + (cond-> + {:db/ident db-ident' + :block/tags #{:logseq.class/Property} + :logseq.property/type prop-type + :block/name (common-util/page-name-sanity-lc (name prop-name)) + :block/uuid (or block-uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident')) + :block/title (name prop-name) + :db/index true + :db/cardinality (if (#{:many :db.cardinality/many} (:db/cardinality prop-schema)) + :db.cardinality/many + :db.cardinality/one) + :block/order (db-order/gen-key)} + (or ref-type? (contains? db-property-type/all-ref-property-types prop-type)) + (assoc :db/valueType :db.type/ref) + (seq properties) + (merge properties))))))) + +(defn build-new-class + "Build a standard new class so that it is consistent across contexts" + [block] + {:pre [(qualified-keyword? (:db/ident block))]} + (block-with-timestamps + (cond-> (merge block + {:block/tags (set (conj (:block/tags block) :logseq.class/Tag))}) + (and (not= (:db/ident block) :logseq.class/Root) + (nil? (:logseq.property.class/extends block))) + (assoc :logseq.property.class/extends :logseq.class/Root)))) + +(defn build-new-page + "Builds a basic page to be transacted. A minimal version of gp-block/page-name->map" + [title] + (block-with-timestamps + (cond-> + {:block/name (common-util/page-name-sanity-lc title) + :block/title title + :block/uuid (common-uuid/gen-uuid :builtin-block-uuid title) + :block/tags #{:logseq.class/Page}} + (contains? #{common-config/quick-add-page-name} title) + (assoc :logseq.property/hide? true)))) + +(defn kv + "Creates a key-value pair tx with the key and value respectively stored under + :db/ident and :kv/value. The key must be under the namespace :logseq.kv" + [k value] + {:pre [(= "logseq.kv" (namespace k))]} + {:db/ident k + :kv/value value}) + +(defn import-tx + "Creates tx for an import given an import-type" + [import-type] + (concat [(kv :logseq.kv/import-type import-type) + ;; Timestamp is useful as this can occur much later than :logseq.kv/graph-created-at + (kv :logseq.kv/imported-at (common-util/time-ms))] + (mapv + (fn [db-ident] [:db/retractEntity db-ident]) + [:logseq.kv/graph-uuid ;rtc related + :logseq.kv/graph-local-tx ;rtc related + :logseq.kv/remote-schema-version ;rtc related + :logseq.kv/graph-rtc-e2ee? ;rtc related + ]))) diff --git a/deps/db/src/logseq/db/test/helper.cljs b/deps/db/src/logseq/db/test/helper.cljs new file mode 100644 index 0000000..158260b --- /dev/null +++ b/deps/db/src/logseq/db/test/helper.cljs @@ -0,0 +1,105 @@ +(ns ^:node-only logseq.db.test.helper + "Main ns for providing test fns for DB graphs" + (:require [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.db.frontend.property :as db-property] + [logseq.db.sqlite.build :as sqlite-build] + [logseq.db.sqlite.export :as sqlite-export])) + +(defn find-block-by-content + "Find first block by exact block string or by fuzzier regex" + [db content] + (if (instance? js/RegExp content) + (->> content + (d/q '[:find [?b ...] + :in $ ?pattern + :where + [?b :block/title ?content] + [?b :block/page] + [(re-find ?pattern ?content)]] + db) + first + (d/entity db)) + (->> content + (d/q '[:find [?b ...] + :in $ ?content + :where + [?b :block/title ?content] + [?b :block/page]] + db) + first + (d/entity db)))) + +(defn find-page-by-title + "Find first page by its title" + [db title] + (->> title + (d/q '[:find [?b ...] + :in $ ?title + :where [?b :block/title ?title]] + db) + first + (d/entity db))) + +(defn find-journal-by-journal-day + [db journal-day] + (->> journal-day + (d/q + '[:find [?page ...] + :in $ ?journal-day + :where + [?page :block/journal-day ?journal-day]] + db) + first + (d/entity db))) + +(defn readable-properties + "Returns an entity's properties and tags in readable form for assertions. + tags are included here since they behave like properties on an ent" + [ent] + (->> (db-property/properties ent) + (mapv (fn [[k v]] + [k + (cond + (#{:block/tags :logseq.property.class/extends} k) + (mapv :db/ident v) + (and (set? v) (every? de/entity? v)) + (set (map db-property/property-value-content v)) + (de/entity? v) + (or (:db/ident v) (db-property/property-value-content v)) + :else + v)])) + (into {}))) + +(def create-conn sqlite-export/create-conn) + +(defn create-conn-with-blocks + "Create a conn with create-conn and then create blocks using sqlite-build" + [opts] + (let [conn (create-conn) + _ (sqlite-build/create-blocks conn opts)] + conn)) + +(defn create-conn-with-import-map + "Create a conn with create-conn and then create/upsert entities using sqlite-export/build-import. + Unlike create-conn-with-blocks, this fn can upsert existing entities" + [export-map] + (let [conn (create-conn) + {:keys [init-tx block-props-tx misc-tx]} + (sqlite-export/build-import (dissoc export-map ::sqlite-export/graph-files) @conn {}) + _ (d/transact! conn (concat init-tx block-props-tx misc-tx)) + ;; Handle graph-files separately b/c build-import can't upsert them + _ (when (::sqlite-export/graph-files export-map) + (d/transact! conn (::sqlite-export/graph-files export-map)))] + conn)) + +(defmacro silence-stderr + "Silence stderr as successful tests should print long stderr messages" + [& body] + `(let [orig-write# (.-write js/process.stderr)] + (set! (.-write js/process.stderr) + (fn [& _] true)) + (try + ~@body + (finally + (set! (.-write js/process.stderr) orig-write#))))) diff --git a/deps/db/test/logseq/db/common/delete_blocks_test.cljs b/deps/db/test/logseq/db/common/delete_blocks_test.cljs new file mode 100644 index 0000000..508eb75 --- /dev/null +++ b/deps/db/test/logseq/db/common/delete_blocks_test.cljs @@ -0,0 +1,312 @@ +(ns logseq.db.common.delete-blocks-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.db :as ldb] + [logseq.db.common.delete-blocks :as delete-blocks] + [logseq.db.test.helper :as db-test])) + +(deftest delete-blocks-removes-reactions + (testing "reactions targeting deleted blocks are retracted" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Block"}]}]}) + block (db-test/find-block-by-content @conn "Block") + now (common-util/time-ms) + reaction {:block/uuid (random-uuid) + :block/created-at now + :block/updated-at now + :logseq.property.reaction/emoji-id "+1" + :logseq.property.reaction/target (:db/id block)} + _ (d/transact! conn [reaction]) + reaction-entity (first (:logseq.property.reaction/_target (d/entity @conn (:db/id block)))) + retracts [[:db/retractEntity (:db/id block)]] + extra (delete-blocks/update-refs-history @conn retracts {})] + (d/transact! conn (concat retracts extra)) + (is (nil? (d/entity @conn (:db/id reaction-entity))))))) + +(deftest delete-blocks-expands-property-value-children + (testing "delete-blocks retracts generated property value children" + (let [property-value-uuid (random-uuid) + conn (db-test/create-conn-with-blocks + {:properties {:user.property/cli-http-prop {:logseq.property/type :default}} + :pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Parent" + :build/properties + {:user.property/cli-http-prop + {:build/property-value :block + :block/title "Property value" + :block/uuid property-value-uuid + :build/keep-uuid? true}}}]}]}) + parent (db-test/find-block-by-content @conn "Parent") + property-value (d/entity @conn [:block/uuid property-value-uuid]) + txs [[:db/retractEntity (:db/id parent)]] + expanded (delete-blocks/expand-delete-blocks-tx @conn txs {:outliner-op :delete-blocks})] + (is (= (:db/id parent) (:db/id (:block/parent property-value)))) + (is (some #(= [:db/retractEntity (:db/id property-value)] %) expanded))))) + +(deftest delete-blocks-removes-history-with-ref-value + (testing "property history entries referencing a deleted block are retracted" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"} + {:block/title "Choice value"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + choice-value-block (db-test/find-block-by-content @conn "Choice value") + history-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block (:db/id target-block) + :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status)) + :logseq.property.history/ref-value (:db/id choice-value-block)}]) + history-entity (d/entity @conn [:block/uuid history-uuid]) + retracts [[:db/retractEntity (:db/id choice-value-block)]] + extra (delete-blocks/update-refs-history @conn retracts {})] + (d/transact! conn (concat retracts extra)) + (is (nil? (d/entity @conn (:db/id history-entity))))))) + +(deftest property-history-block-updates-are-kept + (testing "history block updates do not delete the history entity" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"} + {:block/title "Choice value"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + choice-value-block (db-test/find-block-by-content @conn "Choice value") + history-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid history-uuid + :block/title "History entry" + :block/page (:db/id (:block/page target-block)) + :block/parent (:db/id (:block/page target-block)) + :block/order "a0" + :block/created-at now + :block/updated-at now + :logseq.property.history/block (:db/id target-block) + :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status)) + :logseq.property.history/ref-value (:db/id choice-value-block)}]) + history-entity (d/entity @conn [:block/uuid history-uuid]) + txs [[:db/retract (:db/id history-entity) :block/title "History entry"] + [:db/add (:db/id history-entity) :block/title "Updated history entry"] + [:db/retract (:db/id history-entity) :block/parent (:db/id (:block/page history-entity))] + [:db/add (:db/id history-entity) :block/parent (:db/id target-block)] + [:db/retract (:db/id history-entity) :block/order "a0"] + [:db/add (:db/id history-entity) :block/order "b0"]] + extra (delete-blocks/update-refs-history @conn txs {})] + (d/transact! conn (concat txs extra)) + (let [updated-history (d/entity @conn (:db/id history-entity))] + (is (= {:block/title "Updated history entry" + :block/parent (:db/id target-block) + :block/order "b0"} + {:block/title (:block/title updated-history) + :block/parent (:db/id (:block/parent updated-history)) + :block/order (:block/order updated-history)})))))) + +(deftest remote-delete-blocks-removes-history-when-owner-ref-retracted + (testing "remote delete-blocks datoms fully retract property history blocks" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"} + {:block/title "Choice value"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + choice-value-block (db-test/find-block-by-content @conn "Choice value") + history-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block (:db/id target-block) + :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status)) + :logseq.property.history/ref-value (:db/id choice-value-block)}]) + history-entity (d/entity @conn [:block/uuid history-uuid]) + txs [[:db/retract (:db/id history-entity) :logseq.property.history/block (:db/id target-block)]] + extra (delete-blocks/update-refs-history @conn txs {:outliner-op :delete-blocks})] + (d/transact! conn (concat txs extra)) + (is (nil? (d/entity @conn (:db/id history-entity))))))) + +(deftest delete-blocks-removes-new-history-for-deleted-block + (testing "history blocks created in the same tx as their deleted owner are retracted" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"} + {:block/title "Choice value"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + choice-value-block (db-test/find-block-by-content @conn "Choice value") + history-uuid (random-uuid) + now (common-util/time-ms) + txs [{:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block (:db/id target-block) + :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status)) + :logseq.property.history/ref-value (:db/id choice-value-block)} + [:db/retractEntity (:db/id target-block)]] + extra (delete-blocks/update-refs-history @conn txs {:outliner-op :delete-blocks})] + (d/transact! conn (concat txs extra)) + (is (nil? (d/entity @conn [:block/uuid history-uuid])))))) + +(deftest remote-delete-blocks-removes-new-normalized-history-when-owner-ref-retracted + (testing "remote normalized delete-blocks datoms fully retract new property history blocks" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"} + {:block/title "Choice value"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + choice-value-block (db-test/find-block-by-content @conn "Choice value") + history-uuid (random-uuid) + history-eid 1000000 + now (common-util/time-ms) + txs [[:db/add history-eid :block/uuid history-uuid] + [:db/add history-eid :block/created-at now] + [:db/add history-eid :block/updated-at now] + [:db/add history-eid :logseq.property.history/block (:db/id target-block)] + [:db/add history-eid :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status))] + [:db/add history-eid :logseq.property.history/ref-value (:db/id choice-value-block)] + [:db/retract history-eid :logseq.property.history/block (:db/id target-block)]] + extra (delete-blocks/update-refs-history @conn txs {:outliner-op :delete-blocks})] + (d/transact! conn (concat txs extra)) + (is (nil? (d/entity @conn [:block/uuid history-uuid])))))) + +(deftest delete-page-removes-history-with-ref-value + (testing "deleting a page retracts property history entries that referenced that page" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Journal"} + :blocks [{:block/title "b1"}]} + {:page {:block/title "Page to delete"}}]}) + block (db-test/find-block-by-content @conn "b1") + page (db-test/find-page-by-title @conn "Page to delete") + history-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block (:db/id block) + :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status)) + :logseq.property.history/ref-value (:db/id page)}]) + history-entity (d/entity @conn [:block/uuid history-uuid])] + (ldb/transact! conn [[:db/retractEntity (:db/id page)]]) + (is (nil? (d/entity @conn (:db/id history-entity))))))) + +(deftest delete-property-removes-history-for-property + (testing "property history entries for a deleted property are retracted" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"}]}] + :properties {:obsolete {:logseq.property/type :string}}}) + block (db-test/find-block-by-content @conn "Target block") + property (d/entity @conn :user.property/obsolete) + history-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid history-uuid + :block/created-at now + :block/updated-at now + :logseq.property.history/block (:db/id block) + :logseq.property.history/property (:db/id property) + :logseq.property.history/scalar-value "old"}]) + history-entity (d/entity @conn [:block/uuid history-uuid]) + retracts [[:db/retractEntity (:db/id property)]] + extra (delete-blocks/update-refs-history @conn retracts {})] + (d/transact! conn (concat retracts extra)) + (is (nil? (d/entity @conn (:db/id history-entity))))))) + +(deftest delete-blocks-removes-history-for-corresponding-views + (testing "property history entries attached to a deleted block's view are retracted" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + 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-block) + :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-block) + :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 [[:db/retractEntity (:db/id target-block)]]) + (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])))))) + +(deftest delete-blocks-does-not-rewrite-title-for-deleted-view + (testing "reference title rewrite should skip generated views deleted by the same cleanup" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Asset target"}]}]}) + target-block (db-test/find-block-by-content @conn "Asset target") + page (:block/page target-block) + view-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid view-uuid + :block/title "Unlinked references" + :block/raw-title "Unlinked references" + :block/created-at now + :block/updated-at now + :block/page (:db/id page) + :block/parent (:db/id page) + :block/order "cD66" + :block/refs (:db/id target-block) + :logseq.property/view-for (:db/id target-block) + :logseq.property.view/type :logseq.property.view/type.list + :logseq.property.view/feature-type :unlinked-references}]) + view (d/entity @conn [:block/uuid view-uuid]) + retracts [[:db/retractEntity (:db/id target-block)]] + extra (delete-blocks/update-refs-history @conn retracts {})] + (is (some #(= [:db/retractEntity (:db/id view)] %) extra)) + (is (not-any? #(= [:db/add (:db/id view) :block/title "Unlinked references"] %) extra))))) + +(deftest delete-blocks-does-not-rewrite-title-for-deleted-history + (testing "reference title rewrite should skip history entities deleted by the same cleanup" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Target block"}]}]}) + target-block (db-test/find-block-by-content @conn "Target block") + page (:block/page target-block) + history-uuid (random-uuid) + now (common-util/time-ms) + _ (d/transact! conn [{:block/uuid history-uuid + :block/title "History entry" + :block/raw-title "History entry" + :block/created-at now + :block/updated-at now + :block/page (:db/id page) + :block/parent (:db/id page) + :block/order "a0" + :block/refs (:db/id target-block) + :logseq.property.history/block (:db/id target-block) + :logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status)) + :logseq.property.history/scalar-value "Todo"}]) + history (d/entity @conn [:block/uuid history-uuid]) + retracts [[:db/retractEntity (:db/id target-block)]] + extra (delete-blocks/update-refs-history @conn retracts {})] + (is (some #(= [:db/retractEntity (:db/id history)] %) extra)) + (is (not-any? #(= [:db/add (:db/id history) :block/title "History entry"] %) extra))))) diff --git a/deps/db/test/logseq/db/common/initial_data_refs_test.cljs b/deps/db/test/logseq/db/common/initial_data_refs_test.cljs new file mode 100644 index 0000000..1080543 --- /dev/null +++ b/deps/db/test/logseq/db/common/initial_data_refs_test.cljs @@ -0,0 +1,49 @@ +(ns logseq.db.common.initial-data-refs-test + (:require [cljs.test :refer [deftest is]] + [datascript.core :as d] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.test.helper :as db-test])) + +(deftest get-block-refs-count-matches-get-block-refs-for-class-page-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :pages-and-blocks + [{:page {:block/title "Ref A"}} + {:page {:block/title "Ref B"}}]}) + topic-id (:db/id (d/entity @conn :user.class/Topic)) + ref-a-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Ref A") + ref-b-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Ref B") + _ (d/transact! conn [[:db/add ref-a-id :block/refs topic-id] + [:db/add ref-b-id :block/refs topic-id] + [:db/add ref-b-id :logseq.property/hide? true]]) + refs (common-initial-data/get-block-refs @conn topic-id) + count* (common-initial-data/get-block-refs-count @conn topic-id)] + (is (= (count refs) count*)) + (is (= 1 count*)))) + +(deftest get-block-refs-count-page-without-db-ident-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Foo"}} + {:page {:block/title "Bar"}}]}) + foo-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Foo") + bar-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Bar") + _ (d/transact! conn [[:db/add bar-id :block/refs foo-id]]) + count* (common-initial-data/get-block-refs-count @conn foo-id)] + (is (= 1 count*)))) diff --git a/deps/db/test/logseq/db/common/initial_data_test.cljs b/deps/db/test/logseq/db/common/initial_data_test.cljs new file mode 100644 index 0000000..deaa9c0 --- /dev/null +++ b/deps/db/test/logseq/db/common/initial_data_test.cljs @@ -0,0 +1,99 @@ +(ns logseq.db.common.initial-data-test + "This ns is the only one to test against file based datascript connections. + These are useful integration tests" + (:require ["fs" :as fs] + ["path" :as node-path] + [cljs.test :refer [deftest async use-fixtures is testing]] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.frontend.property :as db-property] + [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.db.test.helper :as db-test])) + +(use-fixtures + :each + ;; Cleaning tmp/ before leaves last tmp/ after a test run for dev and debugging + {:before + #(async done + (if (fs/existsSync "tmp") + (fs/rm "tmp" #js {:recursive true} (fn [err] + (when err (js/console.log err)) + (done))) + (done)))}) + +(defn- create-graph-dir + [dir db-name] + (fs/mkdirSync (node-path/join dir db-name) #js {:recursive true})) + +(deftest get-initial-data + (testing "Fetches a defined block" + (create-graph-dir "tmp/graphs" "test-db") + + (let [conn* (sqlite-cli/open-db! "tmp/graphs" "test-db") + _ (d/transact! conn* (sqlite-create-graph/build-db-initial-data "{}")) + blocks [{:file/path "logseq/config.edn" + :file/content "{:foo :bar}"}] + _ (d/transact! conn* blocks) + ;; Simulate getting data from sqlite and restoring it for frontend + {:keys [schema initial-data]} (common-initial-data/get-initial-data @conn*) + conn (d/conn-from-datoms initial-data schema)] + (is (= blocks + (->> @conn + (d/q '[:find (pull ?b [:file/path :file/content]) + :where [?b :file/content] [?b :file/path "logseq/config.edn"]]) + (map first))) + "Correct file with content is found")))) + +(deftest get-initial-data-includes-property-description-datoms + (create-graph-dir "tmp/graphs" "test-db") + (let [conn* (sqlite-cli/open-db! "tmp/graphs" "test-db") + _ (d/transact! conn* (sqlite-create-graph/build-db-initial-data "{}")) + {:keys [schema initial-data]} (common-initial-data/get-initial-data @conn*) + conn (d/conn-from-datoms initial-data schema) + description (-> (d/entity @conn :logseq.property/deadline) + :logseq.property/description)] + (is (string/includes? (str (db-property/property-value-content description)) + "finish something")))) + +(deftest restore-initial-data + (create-graph-dir "tmp/graphs" "test-db") + (let [conn* (sqlite-cli/open-db! "tmp/graphs" "test-db") + _ (d/transact! conn* (sqlite-create-graph/build-db-initial-data "{}")) + {:keys [init-tx]} + (sqlite-build/build-blocks-tx + {:pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b1"}]}]}) + _ (d/transact! conn* init-tx) + ;; Simulate getting data from sqlite and restoring it for frontend + {:keys [schema initial-data]} (common-initial-data/get-initial-data @conn*) + conn (d/conn-from-datoms initial-data schema)] + (is (some? (db-test/find-page-by-title @conn "page1")) + "Restores recently updated page"))) + +(deftest get-block-and-children-has-children-flag + (testing "Top-level block with children has :block.temp/has-children? true" + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "test-page"} + :blocks [{:block/title "parent" + :build/children + [{:block/title "child1"} + {:block/title "child2"}]}]}]) + parent-block (db-test/find-block-by-content @conn "parent") + result (common-initial-data/get-block-and-children + @conn (:block/uuid parent-block) {:children? false})] + (is (true? (:block.temp/has-children? (:block result))) + "Top-level block with children should have :block.temp/has-children? true"))) + + (testing "Top-level block without children has :block.temp/has-children? false" + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "test-page2"} + :blocks [{:block/title "leaf-block"}]}]) + leaf-block (db-test/find-block-by-content @conn "leaf-block") + result (common-initial-data/get-block-and-children + @conn (:block/uuid leaf-block) {:children? false})] + (is (false? (:block.temp/has-children? (:block result))) + "Top-level block without children should have :block.temp/has-children? false")))) diff --git a/deps/db/test/logseq/db/common/view_test.cljs b/deps/db/test/logseq/db/common/view_test.cljs new file mode 100644 index 0000000..c14bb40 --- /dev/null +++ b/deps/db/test/logseq/db/common/view_test.cljs @@ -0,0 +1,303 @@ +(ns logseq.db.common.view-test + (:require [cljs.test :refer [deftest is]] + [datascript.core :as d] + [logseq.db.common.view :as db-view] + [logseq.db.test.helper :as db-test])) + +(defn- create-view-id + [conn feature-type & {:keys [view-for-id]}] + (let [tx (d/transact! conn [(cond-> {:db/id -100 + :block/title "Test view" + :block/uuid (random-uuid) + :logseq.property.view/feature-type feature-type + :logseq.property.view/type :logseq.property.view/type.table} + view-for-id + (assoc :logseq.property/view-for view-for-id))])] + (get-in tx [:tempids -100]))) + +(deftest get-view-data-journals-returns-ordered-compact-index-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + (mapv (fn [journal-day] + {:page {:build/journal journal-day} + :blocks [{:block/title (str "Block " journal-day)}]}) + [20260716 20260715 20260714 20260713 20260712])}) + result (db-view/get-view-data @conn nil {:journals? true}) + index (:data result)] + (is (= 5 (:count result))) + (is (= [20260716 20260715 20260714 20260713 20260712] + (mapv :block/journal-day index))) + (is (every? map? index)) + (when (every? map? index) + (is (every? #(= #{:db/id :block/journal-day} (set (keys %))) index) + "The journal index should include only the identity and placeholder title data.")) + (is (not (contains? result :selection-block-ids)) + "Blocks are loaded only for visible journals."))) + +(deftest get-view-data-all-pages-sorts-and-filters-hidden-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Alpha" :block/updated-at 10}} + {:page {:block/title "Beta" :block/updated-at 20}} + {:page {:block/title "Hidden" :block/updated-at 30 :logseq.property/hide? true}} + {:page {:block/title "Deleted" :block/updated-at 40 :logseq.property/deleted-at 1}}]}) + view-id (create-view-id conn :all-pages) + result (db-view/get-view-data @conn view-id {:view-feature-type :all-pages + :sorting [{:id :block/updated-at :asc? false}]}) + ids (:data result) + titles (map (fn [id] (:block/title (d/entity @conn id))) ids)] + (is (= 2 (:count result))) + (is (= ["Beta" "Alpha"] titles)))) + +(deftest get-view-data-all-pages-title-sort-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "gamma" :block/updated-at 1}} + {:page {:block/title "alpha" :block/updated-at 2}} + {:page {:block/title "beta" :block/updated-at 3}}]}) + view-id (create-view-id conn :all-pages) + result (db-view/get-view-data @conn view-id {:view-feature-type :all-pages + :sorting [{:id :block/title :asc? true}]}) + ids (:data result) + titles (map (fn [id] (:block/title (d/entity @conn id))) ids)] + (is (= ["alpha" "beta" "gamma"] titles)))) + +(deftest get-view-data-class-objects-sort-keeps-rows-with-missing-sort-value-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :pages-and-blocks + [{:page {:block/title "With timestamp" + :block/updated-at 20 + :build/tags [:Topic]}} + {:page {:block/title "Without timestamp" + :block/updated-at 10 + :build/tags [:Topic]}}]}) + class-id (:db/id (d/entity @conn :user.class/Topic)) + without-ts-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Without timestamp") + without-ts-value (:block/updated-at (d/entity @conn without-ts-id)) + _ (d/transact! conn [[:db/retract without-ts-id :block/updated-at without-ts-value]]) + view-id (create-view-id conn :class-objects :view-for-id class-id) + result (db-view/get-view-data @conn view-id {:view-feature-type :class-objects + :view-for-id class-id + :sorting [{:id :block/updated-at :asc? false}]}) + titles (map (fn [id] (:block/title (d/entity @conn id))) (:data result))] + (is (= 2 (:count result))) + (is (= #{"With timestamp" "Without timestamp"} (set titles))))) + +(deftest get-view-data-class-objects-simple-is-filter-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :pages-and-blocks + [{:page {:block/title "A" :build/tags [:Topic]}} + {:page {:block/title "B" :build/tags [:Topic]}} + {:page {:block/title "C" :build/tags [:Topic]}}]}) + class-id (:db/id (d/entity @conn :user.class/Topic)) + view-id (create-view-id conn :class-objects :view-for-id class-id) + result (db-view/get-view-data @conn view-id {:view-feature-type :class-objects + :view-for-id class-id + :filters {:or? false + :filters [[:block/title :is #{"B"}]]}}) + titles (map (fn [id] (:block/title (d/entity @conn id))) (:data result))] + (is (= 1 (:count result))) + (is (= ["B"] titles)))) + +(deftest get-view-data-class-objects-groups-by-title-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :pages-and-blocks + [{:page {:block/title "A" :build/tags [:Topic]}} + {:page {:block/title "B" :build/tags [:Topic]}}]}) + class-id (:db/id (d/entity @conn :user.class/Topic)) + view-id (create-view-id conn :class-objects :view-for-id class-id) + _ (d/transact! conn [[:db/add view-id :logseq.property.view/group-by-property :block/title]]) + result (db-view/get-view-data @conn view-id {:view-feature-type :class-objects + :view-for-id class-id}) + group-titles (map first (:data result))] + (is (= ["A" "B"] group-titles)))) + +(deftest get-view-data-class-objects-groups-by-many-values-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"} + :SciFi {:block/title "Sci-Fi"} + :Drama {:block/title "Drama"}} + :pages-and-blocks + [{:page {:block/title "Movie A" :build/tags [:Topic :SciFi :Drama]}} + {:page {:block/title "Movie B" :build/tags [:Topic :SciFi]}}]}) + class-id (:db/id (d/entity @conn :user.class/Topic)) + view-id (create-view-id conn :class-objects :view-for-id class-id) + _ (d/transact! conn [[:db/add view-id :logseq.property.view/group-by-property :block/tags]]) + result (db-view/get-view-data @conn view-id {:view-feature-type :class-objects + :view-for-id class-id}) + group->titles (into {} + (map (fn [[group rows]] + [(:block/title group) + (set (map (fn [id] (:block/title (d/entity @conn id))) rows))])) + (:data result))] + (is (= #{"Movie A" "Movie B"} (get group->titles "Sci-Fi"))) + (is (= #{"Movie A"} (get group->titles "Drama"))))) + +(deftest get-view-data-list-view-keeps-one-row-shape-for-pages-and-blocks-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :pages-and-blocks + [{:page {:block/title "Tagged page" + :build/tags [:Topic]}} + {:page {:block/title "Block page"} + :blocks [{:block/title "Tagged block" + :build/tags [:Topic]}]}]}) + class-id (:db/id (d/entity @conn :user.class/Topic)) + view-id (create-view-id conn :class-objects :view-for-id class-id) + _ (d/transact! conn [[:db/add view-id + :logseq.property.view/group-by-property + :block/page] + [:db/add view-id + :logseq.property.view/type + :logseq.property.view/type.list]]) + result (db-view/get-view-data @conn view-id + {:view-feature-type :class-objects + :view-for-id class-id})] + (is (= 2 (:count result))) + (is (every? (fn [[_group partitions]] + (every? (fn [[breadcrumb-uuid rows]] + (and (uuid? breadcrumb-uuid) + (sequential? rows) + (every? map? rows))) + partitions)) + (:data result)) + "A list view must not mix flat row IDs with nested partitions."))) + +(deftest get-view-data-linked-references-page-view-does-not-crash-on-missing-db-ident-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Foo"}} + {:page {:block/title "Bar"}}]}) + foo-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Foo") + bar-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Bar") + _ (d/transact! conn [[:db/add bar-id :block/refs foo-id]]) + view-id (create-view-id conn :linked-references :view-for-id foo-id) + result (db-view/get-view-data @conn view-id {:view-feature-type :linked-references + :view-for-id foo-id})] + (is (number? (:count result))) + (is (contains? (set (:data result)) bar-id)))) + +(deftest get-view-data-groups-page-level-linked-references-under-the-referring-page-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Target"}} + {:page {:block/title "Referring page"}}]}) + target-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Target") + referring-page-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Referring page") + target (d/entity @conn target-id) + referring-page (d/entity @conn referring-page-id) + view-id (create-view-id conn :linked-references + :view-for-id (:db/id target)) + _ (d/transact! conn + [[:db/add (:db/id referring-page) + :block/refs + (:db/id target)] + [:db/add view-id + :logseq.property.view/type + :logseq.property.view/type.list] + [:db/add view-id + :logseq.property.view/group-by-property + :block/page]]) + result (db-view/get-view-data + @conn view-id + {:view-feature-type :linked-references + :view-for-id (:db/id target)}) + [[group partitions]] (:data result)] + (is (= (:block/uuid referring-page) (:block/uuid group))) + (is (= [[(:block/uuid referring-page) + [{:db/id (:db/id referring-page) + :block/parent nil}]]] + (mapv (fn [[breadcrumb rows]] + [breadcrumb (vec rows)]) + partitions))))) + +(deftest get-view-data-class-objects-ref-filter-fast-path-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :pages-and-blocks + [{:page {:block/title "Page A"} + :blocks [{:block/title "Obj A" + :build/tags [:Topic]}]} + {:page {:block/title "Page B"} + :blocks [{:block/title "Obj B" + :build/tags [:Topic]}]}]}) + obj-a-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Obj A") + obj-b-id (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Obj B") + page-a-uuid (:block/uuid (d/entity @conn (d/q '[:find ?e . + :in $ ?title + :where [?e :block/title ?title]] + @conn + "Page A"))) + class-id (:db/id (d/entity @conn :user.class/Topic)) + view-id (create-view-id conn :class-objects :view-for-id class-id) + is-result (db-view/get-view-data @conn view-id {:view-feature-type :class-objects + :view-for-id class-id + :filters {:or? false + :filters [[:block/page :is #{page-a-uuid}]]}}) + is-titles (map (fn [id] (:block/title (d/entity @conn id))) (:data is-result)) + is-not-result (db-view/get-view-data @conn view-id {:view-feature-type :class-objects + :view-for-id class-id + :filters {:or? false + :filters [[:block/page :is-not #{page-a-uuid}]]}}) + is-not-titles (set (map (fn [id] (:block/title (d/entity @conn id))) (:data is-not-result)))] + (is (= #{"Obj A"} (set is-titles))) + (is (= #{"Obj B"} is-not-titles)) + (is (= #{obj-a-id} (set (:data is-result)))) + (is (= #{obj-b-id} (set (:data is-not-result)))))) + +(deftest get-view-data-class-objects-groups-by-number-property-sorts-numerically-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Topic {:block/title "Topic"}} + :properties {:user.property/score {:logseq.property/type :number}} + :pages-and-blocks + [{:page {:block/title "A" :build/tags [:Topic] + :build/properties {:user.property/score 2}}} + {:page {:block/title "B" :build/tags [:Topic] + :build/properties {:user.property/score 10}}} + {:page {:block/title "C" :build/tags [:Topic] + :build/properties {:user.property/score 1}}}]}) + class-id (:db/id (d/entity @conn :user.class/Topic)) + view-id (create-view-id conn :class-objects :view-for-id class-id) + _ (d/transact! conn [[:db/add view-id :logseq.property.view/group-by-property :user.property/score]]) + asc-groups (map first (:data (db-view/get-view-data @conn view-id + {:view-feature-type :class-objects + :view-for-id class-id}))) + _ (d/transact! conn [[:db/add view-id :logseq.property.view/sort-groups-desc? true]]) + desc-groups (map first (:data (db-view/get-view-data @conn view-id + {:view-feature-type :class-objects + :view-for-id class-id})))] + ;; Number groups must sort numerically (1 2 10), not lexicographically (1 10 2) + (is (= [1 2 10] asc-groups)) + ;; "Sort groups order" (desc?) must reverse the numeric order + (is (= [10 2 1] desc-groups)))) diff --git a/deps/db/test/logseq/db/frontend/class_test.cljs b/deps/db/test/logseq/db/frontend/class_test.cljs new file mode 100644 index 0000000..e16b9fc --- /dev/null +++ b/deps/db/test/logseq/db/frontend/class_test.cljs @@ -0,0 +1,59 @@ +(ns logseq.db.frontend.class-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.db.frontend.class :as db-class] + [logseq.db.test.helper :as db-test])) + +(defn- parent-class-id [db] + (:db/id (d/entity db :user.class/Parent))) + +(deftest get-class-objects-dedupes-inherited-tags-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Parent {:block/title "Parent"} + :Child {:block/title "Child" + :build/class-extends [:Parent]}} + :pages-and-blocks [{:page {:block/title "Object1" + :build/tags [:Parent :Child]}}]}) + objects (db-class/get-class-objects @conn (parent-class-id @conn)) + ids (map :db/id objects)] + (testing "an object tagged by both parent and child class is returned once" + (is (= 1 (count ids))) + (is (= 1 (count (distinct ids))))))) + +(deftest get-class-objects-filters-hidden-objects-test + (let [conn (db-test/create-conn-with-blocks + {:classes {:Parent {:block/title "Parent"} + :Child {:block/title "Child" + :build/class-extends [:Parent]}} + :pages-and-blocks [{:page {:block/title "Visible" + :build/tags [:Child]}} + {:page {:block/title "Deleted" + :build/tags [:Child] + :logseq.property/deleted-at 1}} + {:page {:block/title "Hidden" + :build/tags [:Child] + :logseq.property/hide? true}}]}) + titles (->> (db-class/get-class-objects @conn (parent-class-id @conn)) + (map :block/title) + set)] + (is (= #{"Visible"} titles)))) + +(deftest get-class-objects-includes-hide-by-default-properties-test + (let [conn (db-test/create-conn-with-blocks + {:properties {:keywords {:logseq.property/type :default + :logseq.property/hide? true} + :author {:logseq.property/type :default} + :deleted-prop {:logseq.property/type :default}}}) + deleted (d/entity @conn :user.property/deleted-prop) + _ (d/transact! conn [[:db/add (:db/id deleted) :logseq.property/deleted-at 1]]) + property-class-id (:db/id (d/entity @conn :logseq.class/Property)) + titles (->> (db-class/get-class-objects @conn property-class-id) + (map :block/title) + set)] + (testing "hide-by-default user properties still appear in the Property table" + (is (contains? titles "keywords")) + (is (contains? titles "author"))) + (testing "deleted properties stay out of the Property table" + (is (not (contains? titles "deleted-prop")))) + (testing "private built-in properties stay out of the Property table" + (is (not (contains? titles "Property type")))))) diff --git a/deps/db/test/logseq/db/frontend/content_test.cljs b/deps/db/test/logseq/db/frontend/content_test.cljs new file mode 100644 index 0000000..f45cfc4 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/content_test.cljs @@ -0,0 +1,104 @@ +(ns logseq.db.frontend.content-test + (:require [cljs.test :refer [deftest is testing]] + [clojure.string :as string] + [logseq.db.frontend.content :as db-content] + [logseq.common.util.page-ref :as page-ref])) + +(deftest replace-tags-with-page-refs + (testing "tags with overlapping names get replaced correctly" + (is (= "string [[foo]] string2 [[foo-bar]]" + (db-content/replace-tags-with-id-refs + "string #foo string2 #foo-bar" + [{:block/title "foo" :block/uuid "foo"} + {:block/title "foo-bar" :block/uuid "foo-bar"}]))))) + +(deftest title-ref->id-ref + (let [page-uuid (random-uuid)] + (is (= (str "some page ref " (page-ref/->page-ref page-uuid)) + (db-content/title-ref->id-ref "some page ref [[page1]]" [{:block/title "page1" :block/uuid page-uuid}])) + "Replaces page ref name with uuid")) + + (testing "uses original page name when journal title is normalized" + (let [journal-uuid #uuid "00000001-2026-0615-0000-000000000000"] + (is (= (str "some page ref " (page-ref/->page-ref journal-uuid)) + (db-content/title-ref->id-ref + "some page ref [[2026-06-15]]" + [{:block/title "Jun 15th, 2026" + :block.temp/original-page-name "2026-06-15" + :block/uuid journal-uuid}]))))) + + (testing "ignores refs without uuid" + (is (= "some page ref [[2026-06-15]]" + (db-content/title-ref->id-ref + "some page ref [[2026-06-15]]" + [{:block/title "2026-06-15"}])))) + + (testing "does replace tag with :replace-tag? true" + (is (= "some #[[5c6cd067-c602-4955-96b8-74b62e08113c]] tag" + (db-content/title-ref->id-ref "some #test tag" + [{:block/title "test" :block/uuid #uuid "5c6cd067-c602-4955-96b8-74b62e08113c"}] + {:replace-tag? true}))) + (is (= "some #[[5c6cd067-c602-4955-96b8-74b62e08113c]] tag" + (db-content/title-ref->id-ref "some #[[another test]] tag" + [{:block/title "another test" :block/uuid #uuid "5c6cd067-c602-4955-96b8-74b62e08113c"}] + {:replace-tag? true})))) + + (testing "does not replace tag with :replace-tag? false" + (is (= "some #test tag" + (db-content/title-ref->id-ref "some #test tag" + [{:block/title "test" :block/uuid (random-uuid)}] + {:replace-tag? false}))) + (is (= "some #[[another test]] tag" + (db-content/title-ref->id-ref "some #[[another test]] tag" + [{:block/title "another test" :block/uuid (random-uuid)}] + {:replace-tag? false}))))) + +(deftest recur-replace-uuid-in-block-title-test + (let [uuid-a #uuid "11111111-1111-1111-1111-111111111111" + uuid-b #uuid "22222222-2222-2222-2222-222222222222" + uuid-c #uuid "33333333-3333-3333-3333-333333333333" + id-ref-a (page-ref/->page-ref uuid-a) + id-ref-b (page-ref/->page-ref uuid-b) + id-ref-c (page-ref/->page-ref uuid-c)] + (testing "replaces direct id-ref title" + (is (= "[[Direct Page]]" + (db-content/recur-replace-uuid-in-block-title + {:block/title id-ref-a + :block/refs [{:block/uuid uuid-a + :block/title "Direct Page"}]})))) + + (testing "replaces recursively through nested refs" + (is (= "[[[[Leaf Page]]]]" + (db-content/recur-replace-uuid-in-block-title + {:block/title id-ref-a + :block/refs [{:block/uuid uuid-a + :block/title id-ref-b + :block/refs [{:block/uuid uuid-b + :block/title "Leaf Page"}]}]})))) + + (testing "keeps hashtag style for simple page title" + (is (= "#simple-tag" + (db-content/recur-replace-uuid-in-block-title + {:block/title (str "#" id-ref-a) + :block/refs [{:block/uuid uuid-a + :block/title "simple-tag"}]})))) + + (testing "keeps page-ref style hashtag when title contains spaces" + (is (= "#[[tag with space]]" + (db-content/recur-replace-uuid-in-block-title + {:block/title (str "#" id-ref-a) + :block/refs [{:block/uuid uuid-a + :block/title "tag with space"}]})))) + + (testing "stops recursion when max-depth reached" + (let [result (db-content/recur-replace-uuid-in-block-title + {:block/title id-ref-a + :block/refs [{:block/uuid uuid-a + :block/title id-ref-b + :block/refs [{:block/uuid uuid-b + :block/title id-ref-c + :block/refs [{:block/uuid uuid-c + :block/title "Too Deep"}]}]}]} + 1)] + (is (re-find db-content/id-ref-pattern result)) + (is (not (string/includes? result "Too Deep"))))))) diff --git a/deps/db/test/logseq/db/frontend/db_ident_test.cljc b/deps/db/test/logseq/db/frontend/db_ident_test.cljc new file mode 100644 index 0000000..08812d5 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/db_ident_test.cljc @@ -0,0 +1,44 @@ +(ns logseq.db.frontend.db-ident-test + (:require [cljs.test :refer [deftest is testing]] + [clojure.edn :as edn] + [clojure.string :as string] + [logseq.db.frontend.db-ident :as db-ident])) + +(defn- valid-edn-keyword? + "Like common-util/valid-edn-keyword? but with kw as argument" + [kw] + (try + (boolean (edn/read-string (str "{" kw " nil}"))) + (catch :default _ + false))) + +;; These tests are copied to and should be kept in sync with logseq.db.misc-test +;; Tests edge cases as described in https://clojure.org/reference/reader#_symbols +(deftest create-db-ident-from-name + (testing "Symbols begin with a non-numeric character and can contain alphanumeric characters and *, +, !, -, _, ', ?, <, > and =" + (is (valid-edn-keyword? (db-ident/create-db-ident-from-name "user.property" "f@!{h[#")) + "Kw created from name containing invalid special characters is valid edn") + (is (valid-edn-keyword? (db-ident/create-db-ident-from-name "user.property" "foo*+!_'?<>=-")) + "Kw created from name with all valid special characters is valid edn") + (is (string/includes? (name (db-ident/create-db-ident-from-name "user.property" "foo*+!_'?<>=-")) + "*+!_'?<>=-") + "Kw created from name with all valid special characters contains all special characters") + (is (valid-edn-keyword? (db-ident/create-db-ident-from-name "user.property" "2ndCity")) + "Kw created from name starting with number is valid edn") + (is (string/starts-with? (name (db-ident/create-db-ident-from-name "user.property" "2ndCity")) + "NUM-2nd") + "Kw created from name starting with number does not start with that number")) + + (testing "All other special characters" + (is (valid-edn-keyword? (db-ident/create-db-ident-from-name "user.property" ":name")) + "Kw created from name with ':' is valid edn") + (is (not (string/includes? (name (db-ident/create-db-ident-from-name "user.property" ":name")) ":")) + "Kw created from name with ':' doesn't contain ':'") + (is (valid-edn-keyword? (db-ident/create-db-ident-from-name "user.property" "foo/bar")) + "Kw created from name with '/' is valid edn") + (is (not (string/includes? (name (db-ident/create-db-ident-from-name "user.property" "foo/bar")) "/")) + "Kw created from name with '/' doesn't contain '/'") + (is (valid-edn-keyword? (db-ident/create-db-ident-from-name "user.property" "foo.bar")) + "Kw created from name with '.' is valid edn") + (is (not (string/includes? (name (db-ident/create-db-ident-from-name "user.property" "foo.bar")) ".")) + "Kw created from name with '.' doesn't contain '.'"))) \ No newline at end of file diff --git a/deps/db/test/logseq/db/frontend/inputs_test.cljs b/deps/db/test/logseq/db/frontend/inputs_test.cljs new file mode 100644 index 0000000..5ef7e81 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/inputs_test.cljs @@ -0,0 +1,207 @@ +(ns logseq.db.frontend.inputs-test + (:require [cljs-time.core :as t] + [cljs.test :refer [deftest is]] + [datascript.core :as d] + [logseq.common.util.date-time :as date-time-util] + [logseq.db.frontend.inputs :as db-inputs] + [logseq.db.frontend.rules :as rules] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.sqlite.build :as sqlite-build] + [logseq.db.test.helper :as db-test])) + +(defn- custom-query [db {:keys [inputs query input-options]}] + (let [q-args (cond-> (mapv #(db-inputs/resolve-input db % input-options) inputs) + (contains? (set query) '%) + (conj (rules/extract-rules rules/db-query-dsl-rules [:between])))] + (->> (apply d/q query db q-args) + (map first)))) + +(deftest resolve-input-for-page-and-block-inputs + (let [conn (d/create-conn db-schema/schema) + _ (d/transact! conn [{:db/ident :logseq.class/Page}]) + _ (sqlite-build/create-blocks + conn + [{:page {:block/title "page1"} + :blocks [{:block/title "parent" + :build/children + [{:block/title "child 1"} + {:block/title "child 2"}]}]}])] + (is (= ["child 2" "child 1" "parent"] + (map :block/title + (custom-query @conn + {:inputs [:current-page] + :query '[:find (pull ?b [*]) + :in $ ?current-page + :where [?b :block/page ?bp] + [?bp :block/name ?current-page]] + :input-options {:current-page-fn (constantly "page1")}}))) + ":current-page input resolves to current page name") + + (is (= [] + (map :block/title + (custom-query @conn + {:inputs [:current-page] + :query '[:find (pull ?b [*]) + :in $ ?current-page + :where [?b :block/page ?bp] + [?bp :block/name ?current-page]]}))) + ":current-page input doesn't resolve when :current-page-fn not provided") + + (is (= ["child 1" "child 2"] + (let [block-uuid (:block/uuid (db-test/find-block-by-content @conn "parent"))] + (map :block/title + (custom-query @conn + {:inputs [:current-block] + :query '[:find (pull ?b [*]) + :in $ ?current-block + :where [?b :block/parent ?current-block]] + :input-options {:current-block-uuid block-uuid}})))) + ":current-block input resolves to current block's :db/id") + + (is (thrown-with-msg? + js/Error + #"Nothing found for entity" + (custom-query @conn + {:inputs [:current-block] + :query '[:find (pull ?b [*]) + :in $ ?current-block + :where [?b :block/parent ?current-block]] + :input-options {:current-block-uuid nil}})) + ":current-block input doesn't resolve and fails when :current-block-uuid is not provided") + + (is (thrown-with-msg? + js/Error + #"Nothing found for entity" + (custom-query @conn + {:inputs [:current-block] + :query '[:find (pull ?b [*]) + :in $ ?current-block + :where [?b :block/parent ?current-block]] + :input-options {:current-block-uuid :magic}})) + ":current-block input doesn't resolve and fails when :current-block-uuid is invalid") + + (is (= ["parent"] + (let [block-uuid (:block/uuid (db-test/find-block-by-content @conn "child 1"))] + (map :block/title + (custom-query @conn + {:inputs [:parent-block] + :query '[:find (pull ?parent-block [*]) + :in $ ?parent-block + :where [?parent-block :block/parent]] + :input-options {:current-block-uuid block-uuid}})))) + ":parent-block input resolves to parent of current blocks's :db/id"))) + +(deftest resolve-input-for-journal-date-inputs + (let [conn (db-test/create-conn-with-blocks + [{:page {:build/journal 20230101} + :blocks [{:block/title "b1"}]} + {:page {:build/journal 20230107} + :blocks [{:block/title "b2"}]}])] + (is (= ["b2"] + (with-redefs [t/today (constantly (t/date-time 2023 1 7))] + (map :block/title + (custom-query @conn + {:inputs [:3d-before :today] + :query '[:find (pull ?b [*]) + :in $ ?start ?end % + :where (between ?b ?start ?end)]})))) + ":Xd-before and :today resolve to correct journal range") + + (is (= ["b1"] + (with-redefs [t/today (constantly (t/date-time 2022 12 31))] + (map :block/title + (custom-query @conn + {:inputs [:tomorrow :4d-after] + :query '[:find (pull ?b [*]) + :in $ ?start ?end % + :where (between ?b ?start ?end)]})))) + ":tomorrow and :Xd-after resolve to correct journal range"))) + +(defn- blocks-on-journal-page-from-block-with-content [db page-input block-content current-page-date] + (map :block/title + (custom-query db + {:inputs [page-input] + :query '[:find (pull ?b [*]) + :in $ ?page + :where [?b :block/page ?e] + [?e :block/name ?page]] + :input-options + {:current-block-uuid (:block/uuid (db-test/find-block-by-content db block-content)) + :current-page-fn (constantly + (date-time-util/int->journal-title (date-time-util/date->int current-page-date) + "MMM do, yyyy"))}}))) + +(deftest resolve-input-for-query-page + (let [current-date (t/date-time 2023 1 1) + conn (db-test/create-conn-with-blocks + [{:page {:build/journal 20221231} :blocks [{:block/title "-1d"}]} + {:page {:build/journal 20230101} :blocks [{:block/title "now"}]} + {:page {:build/journal 20230102} :blocks [{:block/title "+1d"}]}]) + db @conn] + (is (= ["now"] (blocks-on-journal-page-from-block-with-content db :current-page "now" current-date)) + ":current-page resolves to the stateful page when called from a block on the stateful page") + + (is (= ["now"] (blocks-on-journal-page-from-block-with-content db :query-page "now" current-date)) + ":query-page resolves to the stateful page when called from a block on the stateful page") + + (is (= ["now"] (blocks-on-journal-page-from-block-with-content db :current-page "+1d" current-date)) + ":current-page resolves to the stateful page when called from a block on another page") + + (is (= ["+1d"] (blocks-on-journal-page-from-block-with-content db :query-page "+1d" current-date)) + ":query-page resolves to the parent page when called from another page"))) + +(defn- blocks-journaled-between-inputs [db a b] + ;; reverse is for sort order and may be brittle + (reverse + (map :block/title + (custom-query db + {:inputs [a b] + :query '[:find (pull ?b [*]) + :in $ ?start ?end % + :where (between ?b ?start ?end)]})))) + +(deftest resolve-input-for-relative-date-queries + (let [conn (db-test/create-conn-with-blocks + [{:page {:build/journal 20220101} :blocks [{:block/title "-1y"}]} + {:page {:build/journal 20221201} :blocks [{:block/title "-1m"}]} + {:page {:build/journal 20221225} :blocks [{:block/title "-1w"}]} + {:page {:build/journal 20221231} :blocks [{:block/title "-1d"}]} + {:page {:build/journal 20230101} :blocks [{:block/title "now"}]} + {:page {:build/journal 20230102} :blocks [{:block/title "+1d"}]} + {:page {:build/journal 20230108} :blocks [{:block/title "+1w"}]} + {:page {:build/journal 20230201} :blocks [{:block/title "+1m"}]} + {:page {:build/journal 20240101} :blocks [{:block/title "+1y"}]}]) + db @conn] + (with-redefs [t/today (constantly (t/date-time 2023 1 1))] + (is (= ["now" "-1d" "-1w" "-1m" "-1y"] (blocks-journaled-between-inputs db :-365d :today)) + ":-365d and today resolve to correct journal range") + + (is (= ["now" "-1d" "-1w" "-1m" "-1y"] (blocks-journaled-between-inputs db :-1y :today)) + ":-1y and today resolve to correct journal range") + + (is (= ["now" "-1d" "-1w" "-1m"] (blocks-journaled-between-inputs db :-1m :today)) + ":-1m and today resolve to correct journal range") + + (is (= ["now" "-1d" "-1w"] (blocks-journaled-between-inputs db :-1w :today)) + ":-1w and today resolve to correct journal range") + + (is (= ["now" "-1d"] (blocks-journaled-between-inputs db :-1d :today)) + ":-1d and today resolve to correct journal range") + + (is (= ["+1y" "+1m" "+1w" "+1d" "now"] (blocks-journaled-between-inputs db :today :+365d)) + ":+365d and today resolve to correct journal range") + + (is (= ["+1y" "+1m" "+1w" "+1d" "now"] (blocks-journaled-between-inputs db :today :+1y)) + ":+1y and today resolve to correct journal range") + + (is (= ["+1m" "+1w" "+1d" "now"] (blocks-journaled-between-inputs db :today :+1m)) + ":+1m and today resolve to correct journal range") + + (is (= ["+1w" "+1d" "now"] (blocks-journaled-between-inputs db :today :+1w)) + ":+1w and today resolve to correct journal range") + + (is (= ["+1d" "now"] (blocks-journaled-between-inputs db :today :+1d)) + ":+1d and today resolve to correct journal range") + + (is (= ["+1d" "now"] (blocks-journaled-between-inputs db :today :today/+1d)) + ":today/+1d and today resolve to correct journal range")))) diff --git a/deps/db/test/logseq/db/frontend/property/type_test.cljs b/deps/db/test/logseq/db/frontend/property/type_test.cljs new file mode 100644 index 0000000..ed41199 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/property/type_test.cljs @@ -0,0 +1,42 @@ +(ns logseq.db.frontend.property.type-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.db.test.helper :as db-test])) + +(deftest asset-property-type-registered + (testing ":asset is exposed to users" + (is (some #{:asset} db-property-type/user-built-in-property-types))) + + (testing ":asset is treated as a ref type" + (is (contains? db-property-type/user-ref-property-types :asset)) + (is (contains? db-property-type/all-ref-property-types :asset))) + + (testing ":asset validator requires a datascript db" + (is (contains? db-property-type/property-types-with-db :asset))) + + (testing ":asset can be configured as multiple values" + (is (contains? db-property-type/cardinality-property-types :asset)) + (is (not (contains? db-property-type/closed-value-property-types :asset)) + ":asset should not support closed values"))) + +(deftest asset-entity-validator + (testing "asset-entity? accepts blocks tagged :logseq.class/Asset and rejects others" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "plain-block"} + {:block/title "my-asset" + :build/tags [:logseq.class/Asset]}]}]}) + db @conn + asset-block (db-test/find-block-by-content db "my-asset") + plain-block (db-test/find-block-by-content db "plain-block") + ;; asset-entity? is private; reach through the registered schema + asset-fn (last (:asset db-property-type/built-in-validation-schemas))] + (is (some? asset-block)) + (is (some? plain-block)) + (testing "asset block validates" + (is (true? (boolean (asset-fn db (:db/id asset-block)))))) + (testing "non-asset block does not validate" + (is (not (asset-fn db (:db/id plain-block))))) + (testing "nonexistent id does not validate" + (is (not (asset-fn db 9999999))))))) diff --git a/deps/db/test/logseq/db/frontend/property_test.cljs b/deps/db/test/logseq/db/frontend/property_test.cljs new file mode 100644 index 0000000..b6b76a4 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/property_test.cljs @@ -0,0 +1,89 @@ +(ns logseq.db.frontend.property-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.db.frontend.property :as db-property])) + +(deftest sort-properties + (let [p1 {:db/id 1, :block/order "a", :block/uuid "uuid-a"} + p2 {:db/id 2, :block/order "b", :block/uuid "uuid-b"} + p3 {:db/id 3, :block/order nil, :block/uuid "uuid-d"} + p4 {:db/id 4, :block/order "b", :block/uuid "uuid-c"} + p5 {:db/id 5, :block/order nil, :block/uuid "uuid-e"}] + (is (= [p1 p2 p4 p3 p5] + (db-property/sort-properties [p3 p1 p5 p2 p4]))))) + +(deftest normalize-block-order-tx-data-test + (testing "Generate appropriate :block/order values for sorted-blocks with :block/order value = nil or duplicated" + (let [p1 {:db/id 1, :block/order "a0"} + p2 {:db/id 2, :block/order "bbb"} + p3 {:db/id 3, :block/order "bbb"} + p4 {:db/id 4, :block/order nil} + p5 {:db/id 5, :block/order nil} + sorted-entities [p1 p2 p3 p4 p5] + tx-data (db-property/normalize-sorted-entities-block-order sorted-entities) + ;; apply tx-data to entities + tx-map (into {} (map (juxt :db/id identity) tx-data)) + updated-entities (map (fn [ent] + (if-let [tx (get tx-map (:db/id ent))] + (merge ent tx) + ent)) + sorted-entities) + ;; sort again and test + final-sorted (db-property/sort-properties updated-entities)] + (is (= 5 (count final-sorted))) + ;; Check that all orders are now strings + (is (every? string? (map :block/order final-sorted))) + ;; Check that all orders are unique + (is (= 5 (count (set (map :block/order final-sorted))))) + ;; Check that the final list is sorted correctly by the new orders + (is (= final-sorted (sort-by :block/order final-sorted))))) + + (testing "No changes needed for already valid orders" + (let [p1 {:db/id 1, :block/order "b00"} + p2 {:db/id 2, :block/order "b01"} + sorted-entities [p1 p2] + tx-data (db-property/normalize-sorted-entities-block-order sorted-entities)] + (is (empty? tx-data))))) + +(deftest reaction-built-in-properties + (let [props db-property/built-in-properties] + (testing "entries exist" + (is (contains? props :logseq.property.reaction/emoji-id)) + (is (contains? props :logseq.property.reaction/target))) + + (testing "schema types" + (is (= :string (get-in props [:logseq.property.reaction/emoji-id :schema :type]))) + (is (= :node (get-in props [:logseq.property.reaction/target :schema :type])))) + + (testing "internal visibility" + (is (= false (get-in props [:logseq.property.reaction/emoji-id :schema :public?]))) + (is (= false (get-in props [:logseq.property.reaction/target :schema :public?]))) + (is (= true (get-in props [:logseq.property.reaction/emoji-id :schema :hide?]))) + (is (= true (get-in props [:logseq.property.reaction/target :schema :hide?])))) + + (testing "logseq property namespace" + (is (db-property/logseq-property? :logseq.property.reaction/emoji-id)) + (is (db-property/logseq-property? :logseq.property.reaction/target))))) + +(deftest comments-built-in-properties + (let [props db-property/built-in-properties + property :logseq.property.comments/blocks] + (is (contains? props property)) + (is (= "Commented blocks" (get-in props [property :title]))) + (is (= :node (get-in props [property :schema :type]))) + (is (= :many (get-in props [property :schema :cardinality]))) + (is (= false (get-in props [property :schema :public?]))) + (is (= true (get-in props [property :schema :hide?]))) + (is (db-property/logseq-property? property)))) + +(deftest assignee-built-in-property + (let [property (get db-property/built-in-properties :logseq.property/assignee)] + (testing "schema" + (is (= "Assignee" (:title property))) + (is (= :node (get-in property [:schema :type]))) + (is (= :many (get-in property [:schema :cardinality]))) + (is (= true (get-in property [:schema :public?])))) + + (testing "queryable built-in logseq property" + (is (= true (:queryable? property))) + (is (contains? db-property/public-built-in-properties :logseq.property/assignee)) + (is (db-property/logseq-property? :logseq.property/assignee))))) diff --git a/deps/db/test/logseq/db/frontend/reaction_test.cljs b/deps/db/test/logseq/db/frontend/reaction_test.cljs new file mode 100644 index 0000000..7e33951 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/reaction_test.cljs @@ -0,0 +1,21 @@ +(ns logseq.db.frontend.reaction-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.test.helper :as db-test])) + +(deftest reaction-entity-valid + (testing "reaction entity passes db validation" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Block"}]}]}) + block (db-test/find-block-by-content @conn "Block") + now (common-util/time-ms) + reaction {:block/uuid (random-uuid) + :block/created-at now + :logseq.property.reaction/emoji-id "+1" + :logseq.property.reaction/target (:db/id block)}] + (d/transact! conn [reaction]) + (is (empty? (:errors (db-validate/validate-local-db! @conn))))))) diff --git a/deps/db/test/logseq/db/frontend/rules_test.cljs b/deps/db/test/logseq/db/frontend/rules_test.cljs new file mode 100644 index 0000000..5e153b2 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/rules_test.cljs @@ -0,0 +1,266 @@ +(ns logseq.db.frontend.rules-test + (:require [cljs.test :refer [deftest is testing are]] + [datascript.core :as d] + [logseq.db.frontend.rules :as rules] + [logseq.db.test.helper :as db-test])) + +(defn q-with-rules [query db] + ;; query assumes no :in given + (d/q (into query [:in '$ '%]) + db + (rules/extract-rules rules/db-query-dsl-rules))) + +(deftest get-full-deps + (let [property-value-deps #{:ref->val :class-extends :object-has-class-property :property-missing-value :ref-property-value :ref-property-value-with-default} + property-deps (conj property-value-deps :ref-property-with-default) + task-deps (conj property-deps :task) + priority-deps (conj property-deps :priority) + task-priority-deps (into priority-deps task-deps)] + (are [x y] (= y (#'rules/get-full-deps x rules/rules-dependencies)) + [:ref-property-value-with-default] property-value-deps + [:ref-property-with-default] property-deps + [:task] task-deps + [:priority] priority-deps + [:task :priority] task-priority-deps))) + +(deftest has-property-rule + (let [conn (db-test/create-conn-with-blocks + {:properties {:foo {:logseq.property/type :default} + :foo2 {:logseq.property/type :default}} + :pages-and-blocks + [{:page {:block/title "Page1" + :build/properties {:foo "bar"}}}]})] + + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (has-property ?b :user.property/foo)] + @conn) + (map (comp :block/title first)))) + "has-property returns result when block has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (has-property ?b :user.property/foo2)] + @conn) + (map (comp :block/title first)))) + "has-property returns no result when block doesn't have property") + (is (= [:block/tags :user.property/foo] + (q-with-rules '[:find [?p ...] + :where (has-property ?b ?p) [?b :block/title "Page1"]] + @conn)) + "has-property can bind to property arg"))) + +(deftest ref-property-rule + (let [conn (db-test/create-conn-with-blocks + {:properties {:foo {:logseq.property/type :default} + :foo2 {:logseq.property/type :default} + :number-many {:logseq.property/type :number + :db/cardinality :many} + :page-many {:logseq.property/type :node + :db/cardinality :many}} + :pages-and-blocks + [{:page {:block/title "Page1" + :build/properties {:foo "bar" :number-many #{5 10} :page-many #{[:build/page {:block/title "Page A"}]}}}} + {:page {:block/title "Page A" + :build/properties {:foo "bar A"}}}]})] + (testing "cardinality :one property" + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (ref-property ?b :user.property/foo "bar")] + @conn) + (map (comp :block/title first)))) + "property returns result when page has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (ref-property ?b :user.property/foo "baz")] + @conn) + (map (comp :block/title first)))) + "property returns no result when page doesn't have property value") + (is (= #{:user.property/foo} + (->> (q-with-rules '[:find [?p ...] + :where (ref-property ?b ?p "bar") [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property arg with bound property value")) + + (testing "cardinality :many property" + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (ref-property ?b :user.property/number-many 5)] + @conn) + (map (comp :block/title first)))) + "property returns result when page has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (ref-property ?b :user.property/number-many 20)] + @conn) + (map (comp :block/title first)))) + "property returns no result when page doesn't have property value") + (is (= #{:user.property/number-many} + (->> (q-with-rules '[:find [?p ...] + :where (ref-property ?b ?p 5) [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property arg with bound property value")) + + ;; NOTE: Querying a ref's name is different than before and requires more than just the rule + (testing ":ref property" + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) + :where (ref-property ?b :user.property/page-many "Page A")] + @conn) + (map (comp :block/title first)))) + "property returns result when page has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) + :where [?b :user.property/page-many ?pv] [?pv :block/title "Page B"]] + @conn) + (map (comp :block/title first)))) + "property returns no result when page doesn't have property value")) + + (testing "bindings with property value" + (is (= #{:user.property/foo :user.property/number-many :user.property/page-many :block/tags} + (->> (q-with-rules '[:find [?p ...] + :where (ref-property ?b ?p _) [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property arg with unbound property value") + (is (= #{[:user.property/number-many 10] + [:user.property/number-many 5] + [:user.property/foo "bar"] + [:user.property/page-many "Page A"] + [:block/tags "Page"]} + (->> (q-with-rules '[:find ?p ?val + :where (ref-property ?b ?p ?val) [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property and property value args") + (is (= #{"Page1"} + (->> (q-with-rules '[:find (pull ?b [:block/title]) + :where + [?b :user.property/page-many ?pv] + (ref-property ?pv :user.property/foo "bar A")] + @conn) + (map (comp :block/title first)) + set)) + "property can be used multiple times to query a property value's property")))) + +(deftest property-rule + (let [conn (db-test/create-conn-with-blocks + {:properties {:foo {:logseq.property/type :default} + :foo2 {:logseq.property/type :default} + :number-many {:logseq.property/type :number + :db/cardinality :many} + :page-many {:logseq.property/type :node + :db/cardinality :many}} + :pages-and-blocks + [{:page {:block/title "Page1" + :build/properties {:foo "bar" :number-many #{5 10} :page-many #{[:build/page {:block/title "Page A"}]}}}} + {:page {:block/title "Page A" + :build/properties {:foo "bar A"}}}]})] + (testing "cardinality :one property" + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (property ?b :user.property/foo "bar")] + @conn) + (map (comp :block/title first)))) + "property returns result when page has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (property ?b :user.property/foo "baz")] + @conn) + (map (comp :block/title first)))) + "property returns no result when page doesn't have property value") + (is (= #{:user.property/foo} + (->> (q-with-rules '[:find [?p ...] + :where (property ?b ?p "bar") [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property arg with bound property value")) + + (testing "cardinality :many property" + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (property ?b :user.property/number-many 5)] + @conn) + (map (comp :block/title first)))) + "property returns result when page has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (property ?b :user.property/number-many 20)] + @conn) + (map (comp :block/title first)))) + "property returns no result when page doesn't have property value") + (is (= #{:user.property/number-many} + (->> (q-with-rules '[:find [?p ...] + :where (property ?b ?p 5) [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property arg with bound property value")) + + ;; NOTE: Querying a ref's name is different than before and requires more than just the rule + (testing ":ref property" + (is (= ["Page1"] + (->> (q-with-rules '[:find (pull ?b [:block/title]) + :where (property ?b :user.property/page-many "Page A")] + @conn) + (map (comp :block/title first)))) + "property returns result when page has property") + (is (= [] + (->> (q-with-rules '[:find (pull ?b [:block/title]) + :where [?b :user.property/page-many ?pv] [?pv :block/title "Page B"]] + @conn) + (map (comp :block/title first)))) + "property returns no result when page doesn't have property value")) + + (testing "bindings with property value" + (is (= #{:user.property/foo :user.property/number-many :user.property/page-many :block/tags} + (->> (q-with-rules '[:find [?p ...] + :where (property ?b ?p _) [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property arg with unbound property value") + (is (= #{[:user.property/number-many 10] + [:user.property/number-many 5] + [:user.property/foo "bar"] + [:user.property/page-many "Page A"] + [:block/tags "Page"]} + (->> (q-with-rules '[:find ?p ?val + :where (property ?b ?p ?val) [?b :block/title "Page1"]] + @conn) + set)) + "property can bind to property and property value args") + (is (= #{"Page1"} + (->> (q-with-rules '[:find (pull ?b [:block/title]) + :where + [?b :user.property/page-many ?pv] + (property ?pv :user.property/foo "bar A")] + @conn) + (map (comp :block/title first)) + set)) + "property can be used multiple times to query a property value's property")))) + +(deftest tags-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page1" + :build/tags [:Person]}} + {:page {:block/title "Page2" + :build/tags [:Person]}} + {:page {:block/title "Page3" + :build/tags [:Employee]}}]}) + person-eid (:db/id (d/entity @conn :user.class/Person))] + (d/transact! conn [{:db/ident :user.class/Employee + :logseq.property.class/extends :user.class/Person}]) + (testing "tags query with eid" + (is (= #{"Page1" "Page2" "Page3"} + (->> (d/q + '[:find (pull ?b [:block/title]) + :in $ % ?tag-ids + :where (tags ?b ?tag-ids)] + @conn + (rules/extract-rules rules/db-query-dsl-rules) + #{person-eid}) + (map (comp :block/title first)) + set)))) + (testing "tags query with db/ident" + (is (= #{"Page1" "Page2" "Page3"} + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (tags ?b #{:user.class/Person})] + @conn) + (map (comp :block/title first)) + set)))) + (testing "tags query with block/title" + (is (= #{"Page1" "Page2" "Page3"} + (->> (q-with-rules '[:find (pull ?b [:block/title]) :where (tags ?b #{"Person"})] + @conn) + (map (comp :block/title first)) + set)))))) diff --git a/deps/db/test/logseq/db/sqlite/build_test.cljs b/deps/db/test/logseq/db/sqlite/build_test.cljs new file mode 100644 index 0000000..b5f7524 --- /dev/null +++ b/deps/db/test/logseq/db/sqlite/build_test.cljs @@ -0,0 +1,342 @@ +(ns logseq.db.sqlite.build-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.common.util.page-ref :as page-ref] + [logseq.db :as ldb] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.sqlite.build :as sqlite-build] + [logseq.db.sqlite.export :as sqlite-export] + [logseq.db.test.helper :as db-test])) + +(deftest build-tags + (let [conn (db-test/create-conn) + _ (sqlite-build/create-blocks + conn + [{:page {:block/title "page1"} + :blocks [{:block/title "Jrue Holiday" :build/tags [:Person]} + {:block/title "some task" :build/tags [:logseq.class/Task]}]} + {:page {:block/title "Jayson Tatum" :build/tags [:Person]}}])] + (is (= [:user.class/Person] + (mapv :db/ident (:block/tags (db-test/find-block-by-content @conn "Jrue Holiday")))) + "Person class is created and correctly associated to a block") + + (is (contains? + (set (map :db/ident (:block/tags (db-test/find-page-by-title @conn "Jayson Tatum")))) + :user.class/Person) + "Person class is created and correctly associated to a page") + + (is (= [:logseq.class/Task] + (mapv :db/ident (:block/tags (db-test/find-block-by-content @conn "some task")))) + "Built-in class is associatedly correctly"))) + +(deftest build-properties-user + (let [conn (db-test/create-conn) + _ (sqlite-build/create-blocks + conn + [{:page {:block/title "page1"} + :blocks [{:block/title "Jrue Holiday" :build/properties {:description "Clutch defense"}}]} + {:page {:block/title "Jayson Tatum" :build/properties {:description "Awesome selfless basketball"}}}])] + (is (= "Clutch defense" + (->> (db-test/find-block-by-content @conn "Jrue Holiday") + :user.property/description + db-property/property-value-content)) + "description property is created and correctly associated to a block") + + (is (= "Awesome selfless basketball" + (->> (db-test/find-page-by-title @conn "Jayson Tatum") + :user.property/description + db-property/property-value-content)) + "description property is created and correctly associated to a page"))) + +(deftest build-properties-built-in + (let [conn (db-test/create-conn) + _ (sqlite-build/create-blocks + conn + [{:page {:block/title "page1"} + :blocks [{:block/title "some todo" + :build/properties {:logseq.property/status :logseq.property/status.doing}} + {:block/title "rojo" + :build/properties {:logseq.property/background-color "red"}}]}])] + (is (= :logseq.property/status.doing + (->> (db-test/find-block-by-content @conn "some todo") + :logseq.property/status + :db/ident)) + "built-in property with closed value is created and correctly associated to a block") + + (is (= "red" + (->> (db-test/find-block-by-content @conn "rojo") + :logseq.property/background-color + db-property/property-value-content)) + "built-in :default property is created and correctly associated to a block"))) + +(deftest build-properties-with-build-page + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "page1" + :build/properties + {:date [:build/page {:build/journal 20250223}] + :page #{[:build/page {:block/title "page object" + :build/properties {:p1 "foo" + :date [:build/page {:build/journal 20250224}]}}]}}}}])] + (is (= "foo" + (->> (db-test/find-page-by-title @conn "page object") + :user.property/p1 + db-property/property-value-content)) + ":build/page page can have a :default property") + (is (= 20250224 + (->> (db-test/find-page-by-title @conn "page object") + :user.property/date + :block/journal-day)) + ":build/page page can have a :date property defined by another :build/page"))) + +(deftest build-for-existing-blocks + (let [conn (db-test/create-conn) + _ (sqlite-build/create-blocks + conn + {:properties {:p1 {}} + :classes {:MyClass {}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "block 1"} + {:block/title "block 2"}]}]}) + block (db-test/find-block-by-content @conn "block 1") + block2 (db-test/find-block-by-content @conn "block 2") + page1 (db-test/find-page-by-title @conn "page1") + built-in-page (db-test/find-page-by-title @conn "Quick add") + {:keys [init-tx block-props-tx]} + (sqlite-build/build-blocks-tx + {:pages-and-blocks [{:page + {:block/uuid (:block/uuid built-in-page) + :build/keep-uuid? true + :build/properties {:logseq.property/description "foo"} + :block/title "Quick add"} + :blocks []} + {:page (select-keys (:block/page block) [:block/uuid]) + :blocks [(merge {:block/title "imported task" :block/uuid (:block/uuid block)} + {:build/properties {:logseq.property/status :logseq.property/status.todo} + :build/tags [:logseq.class/Task]})]}] + :build-existing-tx? true}) + _ (d/transact! conn init-tx) + _ (d/transact! conn block-props-tx) + updated-block (d/entity @conn [:block/uuid (:block/uuid block)]) + {init-tx2 :init-tx block-props-tx2 :block-props-tx :as _tx} + (sqlite-build/build-blocks-tx + {:pages-and-blocks [{:page (select-keys (:block/page block2) [:block/uuid]) + :blocks [(merge {:block/title "imported block" :block/uuid (:block/uuid block2)} + {:build/properties {:user.property/p1 "foo"} + :build/tags [:user.class/MyClass]})]}] + :properties {:user.property/p1 (select-keys (d/entity @conn :user.property/p1) + [:logseq.property/type :block/uuid])} + :build-existing-tx? true}) + _ (d/transact! conn init-tx2) + _ (d/transact! conn block-props-tx2) + updated-block2 (d/entity @conn [:block/uuid (:block/uuid block2)])] +;; (cljs.pprint/pprint _tx) + (testing "existing page cases" + (is (= (:block/updated-at page1) + (:block/updated-at (db-test/find-page-by-title @conn "page1"))) + "Existing page with no property changes didn't get updated") + (is (not= (:block/updated-at built-in-page) + (:block/updated-at (db-test/find-page-by-title @conn "Quick add"))) + "Existing page with property changes does get updated")) + + (testing "block with built-in properties and tags" + (is (= [] + (filter #(and (not= (:block/uuid %) (:block/uuid built-in-page)) + (or (:db/id %) (:db/ident %))) + (concat init-tx block-props-tx))) + "Tx doesn't try to create new blocks or modify existing idents") + (is (= "imported task" (:block/title updated-block))) + (is (= {:block/tags [:logseq.class/Task] + :logseq.property/status :logseq.property/status.todo} + (db-test/readable-properties updated-block)) + "Block's properties and tags are updated")) + + (testing "block with existing user properties and tags" + (is (= "imported block" (:block/title updated-block2))) + (is (= {:block/tags [:user.class/MyClass] + :user.property/p1 "foo"} + (db-test/readable-properties updated-block2)) + "Block's properties and tags are updated")))) + +(deftest build-blocks-with-refs + (let [block-uuid (random-uuid) + class-uuid (random-uuid) + page-uuid (random-uuid) + property-uuid (random-uuid) + conn (db-test/create-conn-with-blocks + {:classes {:C1 {:block/uuid class-uuid :build/keep-uuid? true}} + :properties {:p1 {:block/uuid property-uuid :build/keep-uuid? true}} + :build-existing-tx? true + :pages-and-blocks + [{:page {:block/title "page 1"} + :blocks [{:block/title "named page ref to [[named page]]"} + {:block/title (str "page ref to " (page-ref/->page-ref page-uuid))} + {:block/title (str "block ref to " (page-ref/->page-ref block-uuid))} + {:block/title (str "class ref to " (page-ref/->page-ref class-uuid))} + {:block/title (str "inline class ref to #" (page-ref/->page-ref class-uuid))} + {:block/title (str "property ref to " (page-ref/->page-ref property-uuid))} + {:block/title "hi" :block/uuid block-uuid :build/keep-uuid? true}]} + {:page {:block/title "another page" :block/uuid page-uuid :build/keep-uuid? true}}]}) + block-with-named-page-ref (db-test/find-block-by-content @conn #"^named page ref") + block-with-page-ref (db-test/find-block-by-content @conn #"^page ref") + block-with-class-ref (db-test/find-block-by-content @conn #"^class ref") + block-with-inline-class-ref (db-test/find-block-by-content @conn #"^inline class ref") + block-with-property-ref (db-test/find-block-by-content @conn #"^property ref") + block-with-block-ref (db-test/find-block-by-content @conn #"^block ref")] + (is (ldb/internal-page? (db-test/find-page-by-title @conn "named page"))) + (is (contains? (:block/refs block-with-named-page-ref) (db-test/find-page-by-title @conn "named page"))) + + (is (= page-uuid (:block/uuid (db-test/find-page-by-title @conn "another page")))) + (is (contains? (:block/refs block-with-page-ref) (db-test/find-page-by-title @conn "another page"))) + + (is (= class-uuid (:block/uuid (d/entity @conn :user.class/C1)))) + (is (contains? (:block/refs block-with-class-ref) (d/entity @conn :user.class/C1))) + (is (contains? (:block/refs block-with-inline-class-ref) (d/entity @conn :user.class/C1))) + + (is (= property-uuid (:block/uuid (d/entity @conn :user.property/p1)))) + (is (contains? (:block/refs block-with-property-ref) (d/entity @conn :user.property/p1))) + + (is (= block-uuid (:block/uuid (db-test/find-block-by-content @conn "hi")))) + (is (contains? (:block/refs block-with-block-ref) (db-test/find-block-by-content @conn "hi"))))) + +(deftest build-class-and-property-pages + (let [class-uuid (random-uuid) + property-uuid (random-uuid) + conn (db-test/create-conn-with-blocks + {:classes {:C1 {:block/uuid class-uuid :build/keep-uuid? true}} + :properties {:p1 {:block/uuid property-uuid :build/keep-uuid? true}} + :pages-and-blocks + [{:page {:block/uuid class-uuid} + :blocks [{:block/title "b1" + :build/children [{:block/title "b2"}]}]} + {:page {:block/uuid property-uuid} + :blocks [{:block/title "b3" + :build/children [{:block/title "b4"}]}]}] + :build-existing-tx? true})] + (is (= ["b1" "b2"] + (->> (d/q '[:find [?b ...] :in $ ?page-id :where [?b :block/page ?page-id]] + @conn [:block/uuid class-uuid]) + (map #(:block/title (d/entity @conn %))))) + "Class page has correct blocks") + + (is (= ["b3" "b4"] + (->> (d/q '[:find [?b ...] :in $ ?page-id :where [?b :block/page ?page-id]] + @conn [:block/uuid property-uuid]) + (map #(:block/title (d/entity @conn %))))) + "Property page has correct blocks"))) + +(deftest build-class-extends-rejects-cycles + (testing "self cycle" + (is (thrown-with-msg? + js/Error + #"Cycle detected in :build/class-extends" + (sqlite-build/build-blocks-tx + {:classes {:user.class/A {:build/class-extends [:user.class/A]}}})))) + (testing "deprecated class parent self cycle" + (is (thrown-with-msg? + js/Error + #"Cycle detected in :build/class-extends" + (sqlite-build/build-blocks-tx + {:classes {:user.class/A {:build/class-parent :user.class/A}}})))) + (testing "deprecated class parent takes precedence" + (let [txs (sqlite-build/build-blocks-tx + {:classes {:user.class/A {:build/class-parent :user.class/B + :build/class-extends [:user.class/A]} + :user.class/B {}}}) + class-a (some #(when (= :user.class/A (:db/ident %)) %) (:init-tx txs)) + class-b (some #(when (= :user.class/B (:db/ident %)) %) (:init-tx txs))] + (is (= [(:db/id class-b)] + (:logseq.property.class/extends class-a))))) + (testing "multi-class cycle" + (is (thrown-with-msg? + js/Error + #"Cycle detected in :build/class-extends" + (sqlite-build/build-blocks-tx + {:classes {:user.class/A {:build/class-extends [:user.class/B]} + :user.class/B {:build/class-extends [:user.class/C]} + :user.class/C {:build/class-extends [:user.class/A]}}})))) + (testing "diamond inheritance without a cycle" + (is (map? + (sqlite-build/build-blocks-tx + {:classes {:user.class/A {} + :user.class/B {:build/class-extends [:user.class/A]} + :user.class/C {:build/class-extends [:user.class/A]} + :user.class/D {:build/class-extends [:user.class/B :user.class/C]}}}))))) + +(deftest property-value-with-properties-and-tags + (let [conn (db-test/create-conn-with-blocks + {:properties {:p1 {:logseq.property/type :default}} + :classes {:C1 {}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "block has pvalue with built-in tag" + :build/properties + {:p1 {:build/property-value :block + :block/title "t1" + :build/tags [:logseq.class/Task]}}} + {:block/title "block has pvalue with user tag" + :build/properties + {:p1 {:build/property-value :block + :block/title "u1" + :build/tags [:C1]}}} + {:block/title "Todo query", + :build/tags [:logseq.class/Query], + :build/properties + {:logseq.property/query + {:build/property-value :block + :block/title "{:query (task Todo)}" + :build/properties + {:logseq.property.code/lang "clojure" + :logseq.property.node/display-type :code}}}}]}]})] + (is (= {:logseq.property.node/display-type :code + :logseq.property.code/lang "clojure"} + (-> (db-test/find-block-by-content @conn "{:query (task Todo)}") + db-test/readable-properties + (dissoc :logseq.property/created-from-property)))) + (is (= {:block/tags [:logseq.class/Task]} + (-> (db-test/find-block-by-content @conn "t1") + db-test/readable-properties + (dissoc :logseq.property/created-from-property)))) + (is (= {:block/tags [:user.class/C1]} + (-> (db-test/find-block-by-content @conn "u1") + db-test/readable-properties + (dissoc :logseq.property/created-from-property)))))) + +(deftest build-ontology-with-multiple-namespaces + (let [conn (db-test/create-conn-with-blocks + {:properties {:user.property/p1 {:logseq.property/type :default} + :other.property/p1 {:logseq.property/type :default}} + :classes {:user.class/C1 {} + :other.class/C1 {}}})] + (is (entity-util/property? (d/entity @conn :user.property/p1))) + (is (entity-util/property? (d/entity @conn :other.property/p1))) + (is (entity-util/class? (d/entity @conn :user.class/C1))) + (is (entity-util/class? (d/entity @conn :other.class/C1))))) + +(deftest build-preserves-class-property-ordering-for-export + (let [class-properties-c1 [:user.property/p2 :user.property/p1 :user.property/p3] + class-properties-c2 [:user.property/p4 :user.property/p2 :user.property/p3] + another-class-properties-c1 [:user.property/p5] + another-class-properties-c2 [:user.property/p6] + another-class-properties-c3 [:user.property/p6 :user.property/p5] + conn (db-test/create-conn-with-blocks + {:properties {:user.property/p1 {:logseq.property/type :default} + :user.property/p2 {:logseq.property/type :default} + :user.property/p3 {:logseq.property/type :default} + :user.property/p4 {:logseq.property/type :default} + :user.property/p5 {:logseq.property/type :default} + :user.property/p6 {:logseq.property/type :default}} + :classes {:user.class/C1 {:build/class-properties class-properties-c1} + :user.class/C2 {:build/class-properties class-properties-c2} + :user.class/AnotherC1 {:build/class-properties another-class-properties-c1} + :user.class/AnotherC2 {:build/class-properties another-class-properties-c2} + :user.class/AnotherC3 {:build/class-properties another-class-properties-c3}}}) + export-map (sqlite-export/build-export @conn {:export-type :graph-ontology})] + (is (= class-properties-c1 + (get-in export-map [:classes :user.class/C1 :build/class-properties]))) + (is (= class-properties-c2 + (get-in export-map [:classes :user.class/C2 :build/class-properties]))) + (is (= another-class-properties-c3 + (get-in export-map [:classes :user.class/AnotherC3 :build/class-properties])) + "Later class-level ordering constraint :p6 before :p5 is preserved"))) diff --git a/deps/db/test/logseq/db/sqlite/create_graph_test.cljs b/deps/db/test/logseq/db/sqlite/create_graph_test.cljs new file mode 100644 index 0000000..c92d25c --- /dev/null +++ b/deps/db/test/logseq/db/sqlite/create_graph_test.cljs @@ -0,0 +1,173 @@ +(ns logseq.db.sqlite.create-graph-test + (:require [cljs.test :refer [deftest is testing]] + [clojure.data :as data] + [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.sqlite.build :as sqlite-build] + [logseq.db.sqlite.create-graph :as sqlite-create-graph] + [logseq.db.test.helper :as db-test])) + +(deftest new-graph-db-idents + (testing "a new graph follows :db/ident conventions for" + (let [conn (db-test/create-conn) + ident-ents (->> (d/q '[:find [?b ...] + :where [?b :db/ident]] + @conn) + (map (fn [id] (d/entity @conn id)))) + default-idents (map :db/ident ident-ents)] + (is (> (count default-idents) 45) + "Approximate number of default idents is correct") + + (testing "namespaces" + (is (= '() (remove namespace default-idents)) + "All default :db/ident's have namespaces") + (is (= [] + (->> (remove db-property/db-attribute-properties default-idents) + (keep namespace) + (remove #(string/starts-with? % "logseq")))) + "All default :db/ident namespaces start with logseq.")) + + (testing "closed values" + (let [closed-value-ents (filter #(string/includes? (name (:db/ident %)) ".") ident-ents) + closed-value-properties (->> closed-value-ents + (map :db/ident) + (map #(keyword (namespace %) (string/replace (name %) #".[^.]+$" ""))) + set)] + (is (= [] + (remove ldb/closed-value? closed-value-ents)) + "All property names that contain a '.' are closed values") + (is (= #{} + (set/difference + (set (remove #{:logseq.property/color} closed-value-properties)) + (set default-idents))) + "All closed values start with a prefix that is a property name")))))) + +(deftest new-graph-marks-built-ins + (let [conn (db-test/create-conn) + idents (->> (d/q '[:find [(pull ?b [:db/ident :logseq.property/built-in?]) ...] + :where [?b :db/ident]] + @conn) + ;; only kv's and empty property value aren't marked because + ;; they aren't user facing + (remove #(or (= "logseq.kv" (namespace (:db/ident %))) + (= :logseq.property/empty-placeholder (:db/ident %))))) + pages (d/q '[:find [(pull ?b [:logseq.property/built-in? :block/title]) ...] + :where [?b :block/tags :logseq.class/Page]] + @conn)] + (is (= [] (remove :logseq.property/built-in? idents)) + "All entities with :db/ident have built-in property (except for kv idents)") + (is (= [] (remove :logseq.property/built-in? pages)) + "All default internal pages should have built-in property"))) + +(deftest new-graph-creates-class + (let [conn (db-test/create-conn) + task (d/entity @conn :logseq.class/Task)] + (is (ldb/class? task) + "Task class has correct type") + (is (= 4 (count (:logseq.property.class/properties task))) + "Has correct number of task properties") + (is (every? ldb/property? (:logseq.property.class/properties task)) + "Each task property has correct type"))) + +(deftest new-graph-initializes-default-classes-correctly + (let [conn (db-test/create-conn)] + (is (= (set (keys db-class/built-in-classes)) + (set (map #(:db/ident (d/entity @conn (:e %))) (d/datoms @conn :avet :block/tags :logseq.class/Tag)))) + "All built-in classes are indexed by :block/tags with :logseq.class/Tag") + + (is (= (count (dissoc db-class/built-in-classes :logseq.class/Root)) + (count (->> (d/datoms @conn :avet :block/tags :logseq.class/Tag) + (map #(d/entity @conn (:e %))) + (mapcat :logseq.property.class/_extends) + set))) + "Reverse lookup of :logseq.property.class/extends correctly fetches number of child classes"))) + +(deftest new-graph-initializes-default-properties-correctly + (let [conn (db-test/create-conn)] + (is (= (set (keys db-property/built-in-properties)) + (set (map #(:db/ident (d/entity @conn (:e %))) (d/datoms @conn :avet :block/tags :logseq.class/Property)))) + "All built-in properties are indexed by :block/tags with :logseq.class/Property") + (is (= (set (keys db-property/built-in-properties)) + (set (map #(:db/ident (d/entity @conn (:e %))) (d/datoms @conn :avet :logseq.property/type)))) + "All built-in properties have and are indexed by :logseq.property/type") + (is (= #{} + (set/difference + (set (keys db-property/built-in-properties)) + (set (map #(:db/ident (d/entity @conn (:e %))) (d/datoms @conn :avet :logseq.property/built-in?))))) + "All built-in properties have and are indexed by :logseq.property/built-in?") + + ;; testing :properties config + (testing "A built-in property that has" + (is (= :logseq.property/status.todo + (-> (d/entity @conn :logseq.property/status) + :logseq.property/default-value + :db/ident)) + "A property with a :db/ident property value is created correctly") + (is (-> (d/entity @conn :logseq.property/deadline) + :logseq.property/description + db-property/property-value-content + str + (string/includes? "finish something")) + "A :default property is created correctly") + (is (= true + (-> (d/entity @conn :logseq.property/status) + :logseq.property/enable-history?)) + "A :checkbox property is created correctly") + (is (= 1 + (-> (d/entity @conn :logseq.property.repeat/recur-frequency) + :logseq.property/default-value + db-property/property-value-content)) + "A numeric property is created correctly")))) + +(deftest new-graph-is-valid + (let [conn (db-test/create-conn) + validation (db-validate/validate-local-db! @conn)] + ;; For debugging + ;; (println (count (:errors validation)) "errors of" (count (:entities validation))) + ;; (cljs.pprint/pprint (:errors validation)) + (is (empty? (map :entity (:errors validation))) + "New graph has no validation errors"))) + +(deftest property-types + (let [conn (d/create-conn db-schema/schema) + _ (d/transact! conn (sqlite-create-graph/build-db-initial-data + (pr-str {:macros {"docs-base-url" "https://docs.logseq.com/#/page/$1"}})))] + + (testing ":url property" + (sqlite-build/create-blocks + conn + {:properties {:url {:logseq.property/type :url}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b1" :build/properties {:url "https://logseq.com"}} + ;; :url macros are used for consistently building urls with the same hostname e.g. docs graph + {:block/title "b2" :build/properties {:url "{{docs-base-url test}}"}}]}]}) + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Graph with different :url blocks has no validation errors")))) + +(deftest build-db-initial-data-test + (testing "idempotent initial-data" + (letfn [(remove-ignored-attrs&entities [init-data] + (let [ignored-idents #{:logseq.kv/graph-created-at :logseq.kv/graph-uuid :logseq.kv/local-graph-uuid} + init-data* (remove (fn [ent] + (contains? ignored-idents (:db/ident ent))) + init-data)] + (map (fn [ent] (dissoc ent + :block/created-at :block/updated-at + :file/last-modified-at :file/created-at + :block/order ;; TODO: block/order should be same as well + )) + init-data*)))] + (let [[first-only second-only _common] + (data/diff (remove-ignored-attrs&entities (sqlite-create-graph/build-db-initial-data "")) + (remove-ignored-attrs&entities (sqlite-create-graph/build-db-initial-data "")))] + ;; (pr-str [first-only second-only _common]) + (is (and (every? nil? first-only) + (every? nil? second-only))))))) diff --git a/deps/db/test/logseq/db/sqlite/export_test.cljs b/deps/db/test/logseq/db/sqlite/export_test.cljs new file mode 100644 index 0000000..0ef310c --- /dev/null +++ b/deps/db/test/logseq/db/sqlite/export_test.cljs @@ -0,0 +1,1469 @@ +(ns logseq.db.sqlite.export-test + (:require [cljs.pprint] + [cljs.test :refer [deftest is testing]] + [clojure.walk :as walk] + [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.frontend.schema :as db-schema] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.sqlite.create-graph :as sqlite-create-graph] + [logseq.db.sqlite.export :as sqlite-export] + [logseq.db.test.helper :as db-test] + [medley.core :as medley])) + +;; When LOGSEQ_EXPORT_HUMAN=1 is set, all generic :graph export tests run against the +;; human-readable :graph-human export-type instead. Datom-specific tests still +;; reference :graph directly since :graph-human does not produce a datom format. +(def ^:private graph-export-type + (if (= "1" (some-> js/process .-env .-LOGSEQ_EXPORT_HUMAN)) + :graph-human + :graph)) + +;; Test helpers +;; ============ +(defn- validate-db + "Validate db, usually after transacting an import" + [db] + (let [validation (db-validate/validate-local-db! db)] + (when (seq (:errors validation)) (cljs.pprint/pprint {:validate (:errors validation)})) + (is (empty? (map :entity (:errors validation))) "Imported graph has no validation errors"))) + +(defn- has-datom? [datoms e a v] + (some #(= [e a v] %) datoms)) + +(defn- has-datom-attr? [datoms a] + (some #(= a (second %)) datoms)) + +(defn- export-block-and-import-to-another-block + "Exports given block from one graph/conn, imports it to a 2nd block and then + exports the 2nd block. The two blocks do not have to be in the same graph" + [export-conn import-conn export-block-content import-block-content] + (let [export-block (db-test/find-block-by-content @export-conn export-block-content) + import-block (db-test/find-block-by-content @import-conn import-block-content) + {:keys [init-tx block-props-tx] :as _txs} + (-> (sqlite-export/build-export @export-conn {:export-type :block + :block-id [:block/uuid (:block/uuid export-block)]}) + (sqlite-export/build-import @import-conn {:current-block import-block})) + ;; _ (cljs.pprint/pprint _txs) + _ (d/transact! import-conn (concat init-tx block-props-tx))] + (validate-db @import-conn) + (sqlite-export/build-export @import-conn {:export-type :block + :block-id (:db/id import-block)}))) + +(defn- export-page-and-import-to-another-graph + "Exports given page from one graph/conn, imports it to a 2nd graph, validates + it and then exports the page from the 2nd graph" + [export-conn import-conn page-title] + (let [page (db-test/find-page-by-title @export-conn page-title) + {:keys [init-tx block-props-tx] :as _txs} + (-> (sqlite-export/build-export @export-conn {:export-type :page :page-id (:db/id page)}) + ;; ((fn [x] (cljs.pprint/pprint {:export x}) x)) + (sqlite-export/build-import @import-conn {})) + ;; _ (cljs.pprint/pprint _txs) + _ (d/transact! import-conn (concat init-tx block-props-tx)) + _ (validate-db @import-conn) + page2 (db-test/find-page-by-title @import-conn page-title)] + (sqlite-export/build-export @import-conn {:export-type :page :page-id (:db/id page2)}))) + +(defn- import-second-time-assertions [conn conn2 page-title original-data + & {:keys [transform-expected-blocks build-journal skip-updated-at?] + :or {transform-expected-blocks (fn [bs] (into bs bs))}}] + (let [page (db-test/find-page-by-title @conn2 page-title) + imported-page (export-page-and-import-to-another-graph conn conn2 page-title) + updated-page (db-test/find-page-by-title @conn2 page-title) + expected-page-and-blocks + (-> (:pages-and-blocks original-data) + (update-in [0 :blocks] transform-expected-blocks)) + filter-imported-page (if build-journal + #(= build-journal (get-in % [:page :build/journal])) + #(= (get-in % [:page :block/title]) page-title))] + + (assert (first expected-page-and-blocks)) + ;; Assume first page is one being imported for now + (is (= (first expected-page-and-blocks) + (first (filter filter-imported-page (:pages-and-blocks imported-page)))) + "Blocks are appended to existing page") + (is (= (:block/created-at page) (:block/created-at updated-page)) + "Existing page didn't get re-created") + (when-not skip-updated-at? + (is (= (:block/updated-at page) (:block/updated-at updated-page)) + "Existing page didn't get updated")))) + +(defn- export-graph-and-import-to-another-graph + "Exports graph and imports it to a 2nd graph, validates it and then exports the 2nd graph. + This is similar to create-conn-with-import-map but works with existing conn" + [export-conn import-conn export-options] + (let [{:keys [init-tx block-props-tx misc-tx] :as _txs} + (-> (sqlite-export/build-export @export-conn {:export-type graph-export-type :graph-options export-options}) + (sqlite-export/build-import @import-conn {})) + ;; _ (cljs.pprint/pprint _txs) + _ (d/transact! import-conn (concat init-tx block-props-tx misc-tx)) + _ (validate-db @import-conn) + imported-graph (sqlite-export/build-export @import-conn {:export-type graph-export-type :graph-options export-options})] + imported-graph)) + +(defn- expand-properties + "Modify given properties so that they match properties exported from the imported graph" + [properties] + (->> properties + (map (fn [[k m]] + [k + (cond-> + (merge {:db/cardinality :db.cardinality/one} + m) + (:build/property-classes m) + (update :build/property-classes set) + (not (:block/title m)) + (assoc :block/title (name k)))])) + (into {}))) + +(defn- expand-classes + "Modify given classes so that they match classes exported from the imported graph" + [classes] + (->> classes + (map (fn [[k m]] + [k + (cond-> m + (not (:block/title m)) + (assoc :block/title (name k)) + (:build/class-extends m) + (update :build/class-extends set))])) + (into {}))) + +(def sort-pages-and-blocks sqlite-export/sort-pages-and-blocks) + +;; Tests +;; ===== + +(deftest merge-export-maps + (is (= {:pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b1"} + {:block/title "b2"}]} + {:page {:block/title "page2"}}]} + (#'sqlite-export/merge-export-maps + {:pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b1"}]}]} + {:pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b2"}]} + {:page {:block/title "page2"}}]})) + "In :pages-and-blocks, identical pages and their :blocks are merged") + + (is (= {:pages-and-blocks + [{:page {:build/journal 20250220} + :blocks [{:block/title "b1"}]} + {:page {:build/journal 20250221}}]} + (#'sqlite-export/merge-export-maps + {:pages-and-blocks + [{:page {:build/journal 20250220} + :blocks [{:block/title "b1"}]}]} + {:pages-and-blocks + [{:page {:build/journal 20250220}} + {:page {:build/journal 20250221}}]})) + "In :pages-and-blocks, identical journals and their :blocks are merged")) + +(deftest import-block-in-same-graph + (let [original-data + {:properties {:user.property/default-many {:logseq.property/type :default :db/cardinality :db.cardinality/many}} + :classes {:user.class/MyClass + {:build/class-properties [:user.property/default-many]}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "export" + :build/properties {:user.property/default-many #{"foo" "bar" "baz"}} + :build/tags #{:user.class/MyClass}} + {:block/title "import"}]}]} + conn (db-test/create-conn-with-blocks original-data) + imported-block (export-block-and-import-to-another-block conn conn "export" "import")] + + (is (= (get-in original-data [:pages-and-blocks 0 :blocks 0]) + (::sqlite-export/block imported-block)) + "Imported block equals exported block") + (is (= (expand-properties (:properties original-data)) (:properties imported-block))) + (is (= (expand-classes (:classes original-data)) (:classes imported-block))))) + +(deftest import-block-in-different-graph + (let [original-data + {:properties {:user.property/num-many + {:logseq.property/type :number + :db/cardinality :db.cardinality/many + :block/title "Num Many" + :logseq.property/hide? true} + :user.property/p1 {:logseq.property/type :default}} + :classes {:user.class/MyClass + {:build/class-properties [:user.property/num-many :user.property/p1]}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "export" + :build/properties {:user.property/num-many #{3 6 9}} + :build/tags #{:user.class/MyClass}}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page2"} + :blocks [{:block/title "import"} + {:block/title "import2"}]}]}) + imported-block (export-block-and-import-to-another-block conn conn2 "export" "import")] + + (is (= (get-in original-data [:pages-and-blocks 0 :blocks 0]) + (::sqlite-export/block imported-block)) + "Imported block equals exported block") + (is (= (expand-properties (:properties original-data)) (:properties imported-block))) + (is (= (expand-classes (:classes original-data)) (:classes imported-block))) + + (testing "same import in another block" + (let [imported-block (export-block-and-import-to-another-block conn conn2 "export" "import2")] + (is (= (get-in original-data [:pages-and-blocks 0 :blocks 0]) + (::sqlite-export/block imported-block)) + "Imported block equals exported block") + (is (= (expand-properties (:properties original-data)) (:properties imported-block))) + (is (= (expand-classes (:classes original-data)) (:classes imported-block))))))) + +(deftest import-block-with-different-ref-types + (let [page-uuid (random-uuid) + block-uuid (random-uuid) + original-data + {:properties {:user.property/p1 {:logseq.property/type :default}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title (str "page ref to " (page-ref/->page-ref page-uuid)) + :build/properties {:user.property/p1 (str "block ref to " (page-ref/->page-ref block-uuid))}}]} + {:page {:block/title "another page" :block/uuid page-uuid :build/keep-uuid? true} + :blocks [{:block/title "b1" :block/uuid block-uuid :build/keep-uuid? true}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page2"} + :blocks [{:block/title "import"}]}]}) + imported-block (export-block-and-import-to-another-block conn conn2 #"page ref" "import")] + + (is (= (get-in original-data [:pages-and-blocks 0 :blocks 0]) + (::sqlite-export/block imported-block)) + "Imported block equals exported block") + (is (= (second (:pages-and-blocks original-data)) + (first (:pages-and-blocks imported-block))) + "Imported page equals exported page of page ref"))) + +;; Tests a variety of blocks including block children with new properties, blocks with users classes +;; and blocks with built-in properties and classes +(deftest import-page-with-different-blocks + (let [original-data + {:properties {:user.property/default {:logseq.property/type :default + :block/title "Default"} + :user.property/num {:logseq.property/type :number}} + :classes {:user.class/MyClass {:block/title "My Class"}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/properties {:user.property/default "woot"} + :build/children [{:block/title "b1a" + :build/children + [{:block/title "b1aa" + :build/properties {:user.property/num 2}} + {:block/title "b1ab"}]} + {:block/title "b1b"}]} + {:block/title "b2" + :build/tags #{:user.class/MyClass}} + {:block/title "some task" + :build/properties {:logseq.property/status :logseq.property/status.doing} + :build/tags #{:logseq.class/Task}}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn) + imported-page (export-page-and-import-to-another-graph conn conn2 "page1")] + + (is (= (expand-properties (:properties original-data)) (:properties imported-page)) + "Page's properties are imported") + (is (= (:classes original-data) (:classes imported-page)) + "Page's classes are imported") + (is (= (:pages-and-blocks original-data) (:pages-and-blocks imported-page)) + "Page's blocks are imported") + + (import-second-time-assertions conn conn2 "page1" original-data))) + +(deftest import-page-with-different-ref-types + (let [block-uuid (random-uuid) + internal-block-uuid (random-uuid) + class-uuid (random-uuid) + page-uuid (random-uuid) + pvalue-page-uuid (random-uuid) + pvalue-block-uuid (random-uuid) + property-uuid (random-uuid) + journal-uuid (random-uuid) + block-object-uuid (random-uuid) + original-data + {:classes {:user.class/C1 {:block/uuid class-uuid :build/keep-uuid? true} + :user.class/NodeClass {}} + :properties {:user.property/p1 + {:logseq.property/type :node + :block/uuid property-uuid + :build/keep-uuid? true + :build/property-classes [:user.class/NodeClass]} + :user.property/p2 + {:logseq.property/type :default} + :user.property/p3 + {:logseq.property/type :default}} + :extract-content-refs? false + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title (str "page ref to " (page-ref/->page-ref page-uuid))} + {:block/title (str "not a page ref `" (page-ref/->page-ref "foo") "`")} + {:block/title (str "block ref to " (page-ref/->page-ref block-uuid))} + {:block/title "ref in properties" + :build/properties {:user.property/p2 (str "pvalue ref to " (page-ref/->page-ref pvalue-page-uuid))}} + {:block/title "hola" :block/uuid internal-block-uuid :build/keep-uuid? true} + {:block/title (str "internal block ref to " (page-ref/->page-ref internal-block-uuid))} + {:block/title (str "class ref to " (page-ref/->page-ref class-uuid))} + {:block/title (str "inline class ref to #" (page-ref/->page-ref class-uuid))} + {:block/title (str "property ref to " (page-ref/->page-ref property-uuid))} + {:block/title (str "journal ref to " (page-ref/->page-ref journal-uuid))} + {:block/title (str "property block value ref to " (page-ref/->page-ref pvalue-block-uuid))} + {:block/title "block with a pvalue that has a :block/uuid" + :build/properties {:user.property/p2 {:build/property-value :block + :block/title "property value block" + :build/properties {:user.property/p3 "woot"} + :block/uuid pvalue-block-uuid + :build/keep-uuid? true}}}]} + {:page {:block/title "page with block ref"} + :blocks [{:block/title "hi" :block/uuid block-uuid :build/keep-uuid? true + :build/properties {:user.property/p1 [:block/uuid block-object-uuid]}}]} + {:page {:block/title "page ref page" :block/uuid page-uuid :build/keep-uuid? true}} + {:page {:block/title "pvalue ref page" :block/uuid pvalue-page-uuid :build/keep-uuid? true}} + {:page {:build/journal 20250207 :block/uuid journal-uuid :build/keep-uuid? true}} + {:page {:block/title "Blocks"} + :blocks [{:block/title "myclass object" + :build/tags [:user.class/MyClass] + :block/uuid block-object-uuid + :build/keep-uuid? true}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn) + imported-page (export-page-and-import-to-another-graph conn conn2 "page1")] + + (is (= (-> (expand-properties (:properties original-data)) + (medley/dissoc-in [:user.property/p1 :build/property-classes])) + (:properties imported-page)) + "Page's properties are imported") + (is (= (-> (expand-classes (:classes original-data)) + (dissoc :user.class/NodeClass)) + (:classes imported-page)) + "Page's classes are imported except for shallow property's class") + (is (= (-> (:pages-and-blocks original-data) + (medley/dissoc-in [1 :blocks 0 :build/properties]) + ;; shallow block means this page doesn't get included + butlast + sort-pages-and-blocks) + (:pages-and-blocks imported-page)) + "Page's blocks are imported") + + (import-second-time-assertions conn conn2 "page1" original-data + {:transform-expected-blocks + (fn [bs] + ;; internal referenced block doesn't get copied b/c it already exists + (into (vec (remove #(= "hola" (:block/title %)) bs)) + bs))}))) + +(deftest import-page-with-block-links + (let [block-uuid (random-uuid) + original-data + {:pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "b1" :block/uuid block-uuid :build/keep-uuid? true} + {:block/title "" :block/link [:block/uuid block-uuid]}]}]} + ;; add option to test out of order uuids + conn (db-test/create-conn-with-blocks (assoc original-data :build-existing-tx? true)) + conn2 (db-test/create-conn) + imported-page (export-page-and-import-to-another-graph conn conn2 "page1")] + (is (= (:pages-and-blocks original-data) (:pages-and-blocks imported-page)) + "Page's blocks are imported") + + (import-second-time-assertions conn conn2 "page1" original-data + {:transform-expected-blocks + (fn [bs] + ;; internal referenced block doesn't get copied b/c it already exists + (into (vec (remove #(= "b1" (:block/title %)) bs)) + bs))}))) + +(deftest import-page-with-different-page-and-classes + (let [original-data + {:properties {:user.property/p1 {:logseq.property/type :default} + ;; shallow property b/c it's a property for a class' parent + :user.property/p2 {:logseq.property/type :node + :build/property-classes [:user.class/NodeClass2]} + :user.property/p3 {:logseq.property/type :node + :build/property-classes [:user.class/NodeClass]} + :user.property/node-p1 {:logseq.property/type :default}} + :classes {:user.class/MyClass {:build/class-properties [:user.property/p1 :user.property/p2]} + :user.class/MyClass2 {:build/class-properties [:user.property/p2]} + :user.class/ChildClass {:build/class-extends [:user.class/MyClass] + :build/class-properties [:user.property/p3]} + :user.class/ChildClass2 {:build/class-extends [:user.class/MyClass2]} + ;; shallow class b/c it's a property's class property + :user.class/NodeClass {:build/class-properties [:user.property/node-p1]} + :user.class/NodeClass2 {}} + :pages-and-blocks + [{:page {:block/title "page1" + :build/properties {:user.property/p1 "woot"} + :build/tags #{:user.class/ChildClass}} + :blocks [{:block/title "child object" + :build/tags #{:user.class/ChildClass2}}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn) + imported-page (export-page-and-import-to-another-graph conn conn2 "page1")] + + (is (= (-> (expand-properties (:properties original-data)) + (dissoc :user.property/node-p1) + ;; Shallow property doesn't have class + (medley/dissoc-in [:user.property/p2 :build/property-classes])) + (:properties imported-page)) + "Page's properties are imported except for shallow class' property") + (is (= (-> (expand-classes (:classes original-data)) + ;; Shallow class doesn't have properties + (medley/dissoc-in [:user.class/NodeClass :build/class-properties]) + (dissoc :user.class/NodeClass2)) + (:classes imported-page)) + "Page's classes are imported except for shallow property's class") + (is (= (:pages-and-blocks original-data) (:pages-and-blocks imported-page)) + "Page's blocks are imported") + + (import-second-time-assertions conn conn2 "page1" original-data {:skip-updated-at? true}))) + +(deftest import-journal-page + (let [original-data + {:pages-and-blocks + [{:page {:build/journal 20250210} + :blocks [{:block/title "b1"} {:block/title "b2"}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn) + journal-title (date-time-util/int->journal-title 20250210 "MMM do, yyyy") + imported-page (export-page-and-import-to-another-graph conn conn2 journal-title)] + + (is (= (:pages-and-blocks original-data) (:pages-and-blocks imported-page)) + "Page's blocks are imported") + + (import-second-time-assertions conn conn2 journal-title original-data {:build-journal 20250210}))) + +(deftest import-class-page + (let [class-uuid (random-uuid) + original-data + {:classes {:user.class/C0 {} + :user.class/C1 {:build/class-extends [:user.class/C0] + :build/class-properties [:user.property/p1] + :block/uuid class-uuid + :build/keep-uuid? true}} + :properties {:user.property/p1 {:logseq.property/type :default}} + :pages-and-blocks [{:page {:block/uuid class-uuid} + :blocks [{:block/title "class block"}]}]} + conn (db-test/create-conn-with-blocks (assoc original-data :build-existing-tx? true)) + conn2 (db-test/create-conn) + imported-page (export-page-and-import-to-another-graph conn conn2 "C1")] + + (is (= (expand-classes (:classes original-data)) (:classes imported-page)) + "Class page is imported") + (is (= (expand-properties (:properties original-data)) (:properties imported-page)) + "Class page's properties are imported") + (is (= (:pages-and-blocks original-data) (:pages-and-blocks imported-page)) + "Page's blocks are imported"))) + +(deftest import-page-with-different-property-types + (let [block-object-uuid (random-uuid) + original-data + {:properties {:user.property/num {:logseq.property/type :number} + :user.property/checkbox {:logseq.property/type :checkbox} + :user.property/date {:logseq.property/type :date} + :user.property/node {:logseq.property/type :node + :db/cardinality :db.cardinality/many + :build/property-classes [:user.class/MyClass]} + :user.property/p1 {:logseq.property/type :default} + :user.property/map {:logseq.property/type :map}} + :classes {:user.class/MyClass {:build/class-properties [:user.property/p1]}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "num block" + :build/properties {:user.property/num 2}} + {:block/title "checkbox block" + :build/properties {:user.property/checkbox false}} + {:block/title "date block" + :build/properties {:user.property/date [:build/page {:build/journal 20250203}]}} + {:block/title "node block" + :build/properties {:user.property/node #{[:build/page {:block/title "page object" + :build/tags #{:user.class/MyClass}}] + [:block/uuid block-object-uuid] + :logseq.class/Task}}} + {:block/title "map block" + :build/properties {:user.property/map {:foo :bar :num 2}}}]} + {:page {:block/title "Blocks"} + :blocks [{:block/title "myclass object" + :build/tags #{:user.class/MyClass} + :block/uuid block-object-uuid + :build/keep-uuid? true}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn) + imported-page (export-page-and-import-to-another-graph conn conn2 "page1")] + + (is (= (-> (expand-properties (:properties original-data)) + ;; Don't include shallow page object's property + (dissoc :user.property/p1)) + (:properties imported-page)) + "Page's properties are imported") + (is (= (-> (expand-classes (:classes original-data)) + (medley/dissoc-in [:user.class/MyClass :build/class-properties])) + (:classes imported-page)) + "Page's classes are imported") + (is (= (-> (:pages-and-blocks original-data) + ;; adjust shallow block + (medley/dissoc-in [1 :blocks 0 :build/tags]) + sort-pages-and-blocks) + (:pages-and-blocks imported-page)) + "Page's blocks are imported") + + (import-second-time-assertions conn conn2 "page1" original-data) + (is (= 1 (count (d/datoms @conn2 :avet :block/title "page object"))) + "Page property value is only created first time") + (is (= 1 (count (d/datoms @conn2 :avet :block/journal-day 20250203))) + "Journal property value is only created first time"))) + +(deftest import-graph-ontology + (let [original-data + {:properties + {:user.property/num {:logseq.property/type :number} + :user.property/checkbox {:logseq.property/type :checkbox} + :user.property/url {:logseq.property/type :url + :build/properties {:logseq.property/description "desc for url"}} + :user.property/node {:logseq.property/type :node + :db/cardinality :db.cardinality/many + :build/property-classes [:user.class/MyClass]}} + :classes + {:user.class/MyClass {:build/properties {:user.property/url "https://example.com/MyClass"}} + :user.class/MyClass2 {:build/class-extends [:user.class/MyClass] + :build/properties {:logseq.property/description "tests child class"}}}} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn-with-import-map + (sqlite-export/build-export @conn {:export-type :graph-ontology})) + _ (validate-db @conn2) + imported-ontology (sqlite-export/build-export @conn2 {:export-type :graph-ontology})] + + (is (= (expand-properties (:properties original-data)) (:properties imported-ontology))) + (is (= (expand-classes (:classes original-data)) (:classes imported-ontology))))) + +(deftest import-with-url-property-should-be-idempotent + (let [about-uuid (random-uuid) + export-edn + {:properties + {:user.property/url {:db/cardinality :db.cardinality/one + :logseq.property/type :url + :block/title "url"} + :user.property/about {:db/cardinality :db.cardinality/one + :logseq.property/type :node + :block/title "about" + :block/uuid about-uuid + :build/keep-uuid? true + :build/properties {:user.property/url "https://example.com/about"}}}} + conn (db-test/create-conn-with-blocks {:properties (:properties export-edn)}) + {:keys [init-tx block-props-tx misc-tx]} + (sqlite-export/build-import export-edn @conn {})] + (d/transact! conn (concat init-tx block-props-tx misc-tx)) + (validate-db @conn) + (is (= "https://example.com/about" + (some-> (d/entity @conn :user.property/about) + :user.property/url + :block/title)) + "URL value materializes as a property-value block"))) + +(deftest export-graph-ontology-ignores-legacy-internal-class-properties + (let [legacy-property :logseq.property.embedding/hnsw-label-updated-at + original-data + {:properties {:user.property/p1 {:logseq.property/type :default}} + :classes {:user.class/MyClass {:build/class-properties [:user.property/p1]}}} + conn (db-test/create-conn-with-blocks original-data) + _ (d/transact! conn + [{:db/ident legacy-property + :block/uuid (random-uuid) + :block/title "HNSW label updated-at" + :block/tags :logseq.class/Property + :logseq.property/built-in? true + :logseq.property/type :number} + {:db/id (:db/id (d/entity @conn :user.class/MyClass)) + :logseq.property.class/properties [legacy-property]}]) + export-edn (sqlite-export/build-export @conn {:export-type :graph-ontology})] + (is (= [:user.property/p1] + (get-in export-edn [:classes :user.class/MyClass :build/class-properties]))) + (is (not (contains? (:properties export-edn) legacy-property))))) + +(deftest graph-datom-import-drops-legacy-plugin-property-schema-attrs + (let [plugin-property :plugin.property.degrande-colors/mugpet_degrande_colors_controls + conn (db-test/create-conn-with-import-map + {:properties {plugin-property {:logseq.property/type :json}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1"}]}]}) + plugin-property-ent (d/entity @conn plugin-property) + _ (d/transact! conn [{:db/id (:db/id plugin-property-ent) + :hide? true + :public? false}]) + export-edn (sqlite-export/build-export @conn {:export-type :graph}) + import-conn (sqlite-export/create-conn) + validation (sqlite-export/validate-import-txs + (sqlite-export/build-import export-edn @import-conn {}) + @import-conn + {:edn-label "exported EDN"}) + tx-data (:tx-data validation)] + (is (nil? (:error validation))) + (is (has-datom? (:datoms export-edn) (:db/id plugin-property-ent) :hide? true)) + (is (has-datom? (:datoms export-edn) (:db/id plugin-property-ent) :public? false)) + (is (not (some #{[:db/add (:db/id plugin-property-ent) :hide? true]} tx-data))) + (is (not (some #{[:db/add (:db/id plugin-property-ent) :public? false]} tx-data))))) + +(deftest graph-export-keeps-referenced-recycled-closed-value-config + (let [property-id :plugin.property.degrande-colors/tldraw + closed-value-uuid (random-uuid) + conn (db-test/create-conn-with-import-map + {:properties {property-id {:logseq.property/type :default + :build/closed-values [{:value "tldraw" + :uuid closed-value-uuid}]}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/properties {property-id [:block/uuid closed-value-uuid]}}]}]}) + closed-value (d/entity @conn [:block/uuid closed-value-uuid]) + _ (d/transact! conn [{:db/id (:db/id closed-value) + :logseq.property/deleted-at 1}]) + export-edn (sqlite-export/build-export @conn {:export-type :graph}) + validation (sqlite-export/validate-export export-edn)] + (is (nil? (:error validation))) + (is (has-datom? (:datoms export-edn) (:db/id closed-value) :logseq.property/deleted-at 1)))) + +(deftest graph-export-ignores-scalar-values-when-finding-referenced-closed-values + (let [property-id :user.property/datetime + conn (db-test/create-conn-with-import-map + {:properties {property-id {:logseq.property/type :datetime}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/properties {property-id 1779841453610}}]}]}) + export-edn (sqlite-export/build-export @conn {:export-type :graph}) + validation (sqlite-export/validate-export export-edn)] + (is (nil? (:error validation))) + (is (has-datom? (:datoms export-edn) (:db/id (d/entity @conn property-id)) :logseq.property/type :datetime)))) + +(deftest graph-export-uses-db-id-sorted-datoms + (let [conn (db-test/create-conn-with-import-map + {:properties {:user.property/p1 {:logseq.property/type :default}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/properties {:user.property/p1 "ok"}}]}]}) + export-edn (sqlite-export/build-export @conn {:export-type :graph}) + datoms (:datoms export-edn)] + (is (= :graph (::sqlite-export/export-type export-edn))) + (is (= :datoms (::sqlite-export/graph-format export-edn))) + (is (vector? datoms)) + (is (seq datoms)) + (is (every? #(and (vector? %) (= 3 (count %))) datoms)) + (is (not (contains? export-edn :pages-and-blocks))) + (is (= (sort (map first datoms)) + (map first datoms)) + "Graph EDN datoms should be sorted by db id") + (is (some #(= [(:db/id (db-test/find-block-by-content @conn "b1")) :block/title "b1"] %) datoms)))) + +(deftest graph-export-omits-local-metadata-datoms + (let [excluded-kvs #{:logseq.kv/local-graph-uuid + :logseq.kv/graph-uuid + :logseq.kv/graph-local-tx + :logseq.kv/remote-schema-version + :logseq.kv/graph-rtc-e2ee? + :logseq.kv/graph-remote? + :logseq.kv/import-type + :logseq.kv/imported-at + :logseq.kv/graph-backup-folder + :logseq.kv/graph-last-gc-at + :logseq.kv/graph-git-sha} + excluded-attrs #{:block/tx-id + :logseq.property.embedding/hnsw-label + :logseq.property.embedding/hnsw-label-updated-at + :logseq.property/created-by-ref + :logseq.property.user/email + :logseq.property.user/name + :logseq.property.user/avatar} + conn (db-test/create-conn-with-import-map + {:pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1"}]}]}) + block (db-test/find-block-by-content @conn "b1") + user-uuid (random-uuid) + _ (d/transact! conn (concat + (map (fn [kv-ident] {:db/ident kv-ident :kv/value (str kv-ident)}) + excluded-kvs) + [{:block/uuid user-uuid + :block/title "Alice" + :logseq.property.user/email "alice@example.com" + :logseq.property.user/name "Alice" + :logseq.property.user/avatar "avatar.png"} + {:db/id (:db/id block) + :block/tx-id 7 + :logseq.property.embedding/hnsw-label "label" + :logseq.property.embedding/hnsw-label-updated-at 8 + :logseq.property/created-by-ref [:block/uuid user-uuid]}])) + datoms (:datoms (sqlite-export/build-export @conn {:export-type :graph}))] + (is (has-datom? datoms (:db/id block) :block/title "b1")) + (doseq [kv-ident excluded-kvs + :let [kv-eid (:db/id (d/entity @conn kv-ident))]] + (is (not-any? #(= kv-eid (first %)) datoms) + (str kv-ident " entity datoms should not be exported"))) + (doseq [attr excluded-attrs] + (is (not (has-datom-attr? datoms attr)) + (str attr " datoms should not be exported"))))) + +(deftest graph-datom-export-import-is-idempotent + (let [closed-value-uuid (random-uuid) + original-data + {:properties {:user.property/closed {:logseq.property/type :default + :build/closed-values [{:value "closed" + :uuid closed-value-uuid}]}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/properties {:user.property/closed [:block/uuid closed-value-uuid]}}]}]} + conn (db-test/create-conn-with-import-map original-data) + export-edn (sqlite-export/build-export @conn {:export-type :graph}) + valid-result (sqlite-export/validate-export export-edn) + _ (assert (not (:error valid-result)) "No error when importing export-edn into a new graph") + _ (validate-db (:db valid-result)) + export-edn2 (sqlite-export/build-export (:db valid-result) {:export-type :graph})] + (is (= :datoms (::sqlite-export/graph-format export-edn))) + (is (= nil + (sqlite-export/diff-exports export-edn export-edn2)) + "No diff between original datom export and export after importing into a new graph"))) + +(deftest graph-datom-import-replaces-seeded-data + (let [source-conn (d/create-conn db-schema/schema) + ;; Shift subsequent built-in eids without leaving invalid datoms in the export. + _ (d/transact! source-conn [{:db/id 1 :block/uuid (random-uuid)}]) + _ (d/transact! source-conn [[:db/retractEntity 1]]) + _ (d/transact! source-conn (sqlite-create-graph/build-db-initial-data "{}")) + export-edn (sqlite-export/build-export @source-conn {:export-type :graph}) + valid-result (sqlite-export/validate-export export-edn) + export-edn2 (some-> (:db valid-result) + (sqlite-export/build-export {:export-type :graph}))] + (is (nil? (:error valid-result)) + "Datom import should replace seeded graph data before importing graph datoms") + (is (= nil + (sqlite-export/diff-exports export-edn export-edn2)) + "No diff after importing datoms with built-in entities at different db ids"))) + +(deftest graph-datom-import-applies-schema-datoms-before-values + (let [conn (db-test/create-conn) + export-edn {::sqlite-export/export-type :graph + ::sqlite-export/graph-format :datoms + :datoms [[1 :user.property/many "a"] + [1 :user.property/many "b"] + [2 :db/ident :user.property/many] + [2 :db/cardinality :db.cardinality/many]]} + {:keys [init-tx]} (sqlite-export/build-import export-edn @conn {}) + _ (d/transact! conn init-tx) + values (->> (d/datoms @conn :eavt 1 :user.property/many) + (map :v) + set)] + (is (= #{"a" "b"} values) + "Datom import should apply dynamic schema datoms before values that use them"))) + +(deftest graph-datom-import-applies-lookup-ref-targets-before-values + (let [conn (db-test/create-conn) + target-uuid (random-uuid) + export-edn {::sqlite-export/export-type :graph + ::sqlite-export/graph-format :datoms + :datoms [[1 :block/refs [:block/uuid target-uuid]] + [2 :block/uuid target-uuid]]} + {:keys [init-tx]} (sqlite-export/build-import export-edn @conn {})] + (d/transact! conn init-tx) + (is (has-datom? (mapv (juxt :e :a :v) (d/datoms @conn :eavt)) + 1 :block/refs 2) + "Datom import should apply lookup-ref targets before values that use them"))) + +(deftest validate-export-rejects-invalid-graph-datoms + (let [validation (sqlite-export/validate-export + {::sqlite-export/export-type :graph + ::sqlite-export/graph-format :datoms + :datoms [[1 :block/title "Orphan Page"] + [1 :block/name "orphan page"] + [1 :block/uuid #uuid "33333333-3333-4333-8333-000000000001"] + [1 :block/tags 2] + [2 :block/title "Page"] + [2 :block/name "page"] + [2 :db/ident :logseq.class/Page] + [2 :block/uuid #uuid "33333333-3333-4333-8333-000000000002"]]})] + (is (string? (:error validation)) + "Datom import validation should reject invalid graph datoms") + (is (re-find #"Exported EDN" (:error validation)) + "Export validation error should describe exported EDN") + (is (not (contains? validation :db)) + "Invalid export validation should not return a transient DB snapshot"))) + +(deftest graph-datom-export-resolves-lookup-ref-values + (let [conn (d/create-conn db-schema/schema) + target-uuid (random-uuid) + _ (d/transact! conn [[:db/add 1 :user.property/ref [:block/uuid target-uuid]] + [:db/add 2 :block/uuid target-uuid]]) + export-edn (sqlite-export/build-export @conn {:export-type :graph})] + (is (has-datom? (:datoms export-edn) 1 :user.property/ref 2) + "Graph datom export should normalize lookup-ref values to entity ids") + (is (not (has-datom? (:datoms export-edn) 1 :user.property/ref [:block/uuid target-uuid])) + "Graph datom export should not keep lookup-ref values when the entity exists"))) + +(deftest import-supports-legacy-structured-graph-edn + (let [conn (db-test/create-conn) + legacy-graph-export {::sqlite-export/export-type :graph + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1"}]}] + ::sqlite-export/graph-files [{:file/path "logseq/config.edn" + :file/content "{:foo :bar}"}] + ::sqlite-export/kv-values [{:db/ident :logseq.kv/test-import + :kv/value "ok"}]} + {:keys [init-tx block-props-tx misc-tx]} (sqlite-export/build-import legacy-graph-export @conn {})] + (d/transact! conn (concat init-tx block-props-tx misc-tx)) + (validate-db @conn) + (is (some? (db-test/find-page-by-title @conn "page1"))) + (is (some? (db-test/find-block-by-content @conn "b1"))) + (is (= "{:foo :bar}" (:file/content (first (d/q '[:find [(pull ?f [:file/content]) ...] + :where [?f :file/path "logseq/config.edn"]] + @conn))))) + (is (= "ok" (:kv/value (d/entity @conn :logseq.kv/test-import)))))) + +(deftest import-view-blocks + (let [original-data + ;; Test a mix of page and block types + {:properties {:user.property/p1 {:logseq.property/type :default} + :user.property/p2 {:logseq.property/type :default}} + :classes {:user.class/class1 {}} + :pages-and-blocks [{:page {:block/title "page1"}} + {:page {:build/journal 20250226}} + {:page {:block/title "page2"} + :blocks [{:block/title "b1" + :build/properties {:user.property/p2 "ok"}}]}]} + conn (db-test/create-conn-with-blocks original-data) + get-node-ids (fn [db] + (->> [(d/entity db :user.property/p1) + (d/entity db :user.class/class1) + (db-test/find-page-by-title db "page1") + (db-test/find-journal-by-journal-day db 20250226) + (db-test/find-block-by-content db "b1")] + (remove nil?) + (mapv #(vector :block/uuid (:block/uuid %))))) + conn2 (db-test/create-conn-with-import-map + (sqlite-export/build-export @conn {:export-type :view-nodes :rows (get-node-ids @conn)})) + _ (validate-db @conn2) + imported-nodes (sqlite-export/build-export @conn2 {:export-type :view-nodes + :rows (get-node-ids @conn2)})] + + (is (= (sort-pages-and-blocks (:pages-and-blocks original-data)) (:pages-and-blocks imported-nodes))) + (is (= (expand-properties (:properties original-data)) (:properties imported-nodes))) + (is (= (expand-classes (:classes original-data)) (:classes imported-nodes))))) + +(deftest export-grouped-view-nodes-by-uuid + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "page"} + :blocks [{:block/title "Alpha"} + {:block/title "Beta"}]}]}) + block-uuids (mapv :block/uuid + [(db-test/find-block-by-content @conn "Alpha") + (db-test/find-block-by-content @conn "Beta")]) + exported (sqlite-export/build-export + @conn + {:export-type :view-nodes + :rows [["group" block-uuids]] + :group-by? true})] + (is (= #{"Alpha" "Beta"} + (->> (:pages-and-blocks exported) + (mapcat :blocks) + (map :block/title) + set))))) + +(deftest import-selected-nodes + (let [original-data + ;; Test a mix of pages and blocks + {:properties {:user.property/p1 {:logseq.property/type :default}} + :classes {:user.class/class1 {}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/properties {:user.property/p1 "ok"} + :build/children [{:block/title "b2"}]} + {:block/title "b3" + :build/tags #{:user.class/class1} + :build/children [{:block/title "b4"}]}]} + {:page {:block/title "page2"} + :blocks [{:block/title "dont export"}]}]} + conn (db-test/create-conn-with-blocks original-data) + get-node-ids (fn [db] + (->> [(db-test/find-block-by-content db "b1") + (db-test/find-page-by-title db "b3") + (db-test/find-page-by-title db "page2")] + (remove nil?) + (mapv #(vector :block/uuid (:block/uuid %))))) + conn2 (db-test/create-conn-with-import-map + (sqlite-export/build-export @conn {:export-type :selected-nodes :node-ids (get-node-ids @conn)})) + _ (validate-db @conn2) + imported-nodes (sqlite-export/build-export @conn2 {:export-type :selected-nodes :node-ids (get-node-ids @conn2)})] + + (is (= (->> (:pages-and-blocks original-data) + (map #(if (= (get-in % [:page :block/title]) "page2") (dissoc % :blocks) %))) + (:pages-and-blocks imported-nodes))) + (is (= (expand-properties (:properties original-data)) (:properties imported-nodes))) + (is (= (expand-classes (:classes original-data)) (:classes imported-nodes))))) + +(deftest export-selected-nodes-with-missing-node + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1"}]}]}) + block (db-test/find-block-by-content @conn "b1") + result (try + {:export (sqlite-export/build-export + @conn + {:export-type :selected-nodes + :node-ids [[:block/uuid (:block/uuid block)] + [:block/uuid (random-uuid)]]})} + (catch :default e + {:error (ex-message e)}))] + (is (nil? (:error result)) (:error result)) + (is (some? (:export result)) "Selected nodes export is present") + (when-let [export (:export result)] + (is (= ["b1"] + (mapv :block/title (mapcat :blocks (:pages-and-blocks export))))) + (is (nil? (:error (sqlite-export/validate-export export))))))) + +(defn- build-original-graph-data + [& {:keys [exclude-namespaces? add-built-in-pages?] + :or {add-built-in-pages? true}}] + (let [internal-block-uuid (random-uuid) + favorited-uuid (random-uuid) + block-pvalue-uuid (random-uuid) + property-pvalue-uuid (random-uuid) + page-pvalue-uuid (random-uuid) + page-object-uuid (random-uuid) + page-alias-uuid (random-uuid) + closed-value-uuid (random-uuid) + property-uuid (random-uuid) + class-uuid (random-uuid) + class-alias-uuid (random-uuid) + class2-uuid (random-uuid) + journal-uuid (common-uuid/gen-uuid :journal-page-uuid 19650201) + original-data + {:properties + {:user.property/num {:logseq.property/type :number + :block/uuid property-uuid + :build/keep-uuid? true + :block/collapsed? true + :build/properties (if exclude-namespaces? + {} + {:user.property/node #{[:block/uuid property-pvalue-uuid]} + :logseq.property/default-value 42})} + :user.property/default-closed + {:logseq.property/type :default + :db/cardinality :db.cardinality/many + :build/closed-values [{:value "joy" :uuid closed-value-uuid} + {:value "sad" :uuid (random-uuid)}]} + :user.property/checkbox {:logseq.property/type :checkbox} + :user.property/date {:logseq.property/type :date} + :user.property/url {:logseq.property/type :url + :build/properties {:logseq.property/description "desc for url"}} + :user.property/node {:logseq.property/type :node + :db/cardinality :db.cardinality/many + :build/property-classes [:user.class/MyClass]}} + :classes + {:user.class/MyClass (cond-> {:build/properties {:user.property/url "https://example.com/MyClass"} + :block/uuid class-uuid + :build/keep-uuid? true} + (not exclude-namespaces?) + (assoc :block/alias #{[:block/uuid class-alias-uuid]})) + :user.class/MyClassAlias {:block/uuid class-alias-uuid + :build/keep-uuid? true} + :user.class/MyClass2 {:build/class-extends [:user.class/MyClass] + :block/collapsed? true + :block/uuid class2-uuid + :build/keep-uuid? true + :build/properties {:logseq.property/description "tests child class"}}} + :pages-and-blocks + [{:page {:block/title "page1" + :block/uuid favorited-uuid :build/keep-uuid? true + :build/properties {:user.property/checkbox false + :user.property/node #{[:block/uuid page-pvalue-uuid]}}} + :blocks [{:block/title "b1" + :build/properties {:user.property/num 1 + :user.property/default-closed #{[:block/uuid closed-value-uuid]} + :user.property/date [:block/uuid journal-uuid]}} + {:block/title "b2" :build/properties {:user.property/node #{[:block/uuid page-object-uuid]}}} + {:block/title "b3" :build/properties {:user.property/node #{[:block/uuid page-object-uuid]}}} + {:block/title "Example advanced query", + :build/tags #{:logseq.class/Query}, + :build/properties + {:logseq.property/query + {:build/property-value :block + :block/title "{:query (task Todo)}" + :build/properties + {:logseq.property.code/lang "clojure" + :logseq.property.node/display-type :code}}}} + {:block/title "block has property value with tags and properties" + :build/properties + {:user.property/url + {:build/property-value :block + :block/title "https://example.com" + :build/tags #{:user.class/MyClass}}}}]} + {:page {:block/title "page object" + :block/uuid page-object-uuid + :build/keep-uuid? true} + :blocks []} + {:page {:block/title "page2" :build/tags #{:user.class/MyClass2}} + :blocks [{:block/title "hola" :block/uuid internal-block-uuid :build/keep-uuid? true} + {:block/title "myclass object 1" + :build/tags #{:user.class/MyClass} + :block/uuid block-pvalue-uuid + :build/keep-uuid? true} + (cond-> {:block/title "myclass object 2" + :build/tags #{:user.class/MyClass}} + (not exclude-namespaces?) + (merge {:block/uuid property-pvalue-uuid + :build/keep-uuid? true})) + {:block/title "myclass object 3" + :build/tags #{:user.class/MyClass} + :block/uuid page-pvalue-uuid + :build/keep-uuid? true} + {:block/title "ref blocks" + :block/collapsed? true + :build/children + [{:block/title (str "internal block ref to " (page-ref/->page-ref internal-block-uuid))} + {:block/title "node block" + :build/properties {:user.property/node #{[:block/uuid block-pvalue-uuid]}}} + {:block/title (str "property ref to " (page-ref/->page-ref property-uuid))} + {:block/title (str "class ref to " (page-ref/->page-ref class-uuid))}]}]} + {:page {:block/title "Alias for 2/28" :block/uuid page-alias-uuid :build/keep-uuid? true} + :blocks []} + {:page {:build/journal 20250228 + :block/alias #{[:block/uuid page-alias-uuid]} + :build/properties {:user.property/num 1}} + :blocks [{:block/title "journal block"}]} + {:page {:build/journal 19650201 + :block/uuid journal-uuid + :build/keep-uuid? true} + :blocks []} + {:page {:block/uuid class-uuid} + :blocks [{:block/title "class block1" + :build/children [{:block/title "class block2"}]}]} + {:page {:block/uuid class2-uuid} + :blocks [{:block/title "class2 block1"}]} + {:page {:block/uuid property-uuid} + :blocks [{:block/title "property block1"}]}] + ::sqlite-export/graph-files + [{:file/path "logseq/config.edn" + :file/content "{:foo :bar}"} + {:file/path "logseq/custom.css" + :file/content ".foo {background-color: blue}"} + {:file/path "logseq/custom.js" + :file/content "// comment"} + {:file/path "logseq/publish.css" + :file/content ""} + {:file/path "logseq/publish.js" + :file/content ""}] + :build-existing-tx? true} + ;; Some of these built-ins are only here to make assertions pass + built-in-pages + [{:page {:block/title "Library" :build/properties {:logseq.property/built-in? true}} + :blocks []} + {:page {:block/title "Quick add" :build/properties {:logseq.property/built-in? true + :logseq.property/hide? true}}, :blocks []} + {:page {:block/title "Recycle" + :build/properties {:logseq.property/built-in? true + :logseq.property/hide? true}} + :blocks []} + {:page {:block/title "Contents" :build/properties {:logseq.property/built-in? true}} + :blocks [{:block/title "right sidebar"}]} + {:page {:block/title common-config/favorites-page-name + :build/properties {:logseq.property/built-in? true, :logseq.property/hide? true}} + :blocks [(ldb/build-favorite-tx favorited-uuid)]} + {:page {:block/title common-config/views-page-name + :build/properties {:logseq.property/built-in? true, :logseq.property/hide? true}} + :blocks [{:block/title "All" + :build/properties {:logseq.property/view-for :logseq.class/Task + :logseq.property.view/feature-type :class-objects}} + {:block/title "All" + :build/properties {:logseq.property/view-for :user.class/MyClass + :logseq.property.view/feature-type :class-objects}} + {:block/title "Linked references", + :build/properties + {:logseq.property.view/type :logseq.property.view/type.list, + :logseq.property.view/feature-type :linked-references, + :logseq.property/view-for [:block/uuid journal-uuid]}}]}]] + (cond-> original-data + add-built-in-pages? + (update :pages-and-blocks into built-in-pages)))) + +(deftest ^:long import-graph + (let [original-data (build-original-graph-data) + conn (db-test/create-conn-with-import-map original-data) + ;; set to an unobtainable version to test this ident + _ (d/transact! conn [{:db/ident :logseq.kv/schema-version :kv/value {:major 1 :minor 0}}]) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type}) + conn2 (db-test/create-conn) + imported-graph (export-graph-and-import-to-another-graph conn conn2 {})] + + (is (= nil + (sqlite-export/diff-exports export-map imported-graph)) + "No diff between original datom export and export after importing into a new graph") + (is (= 1 (count (d/datoms @conn2 :avet :block/title "page object"))) + "No duplicate pages for pvalue uuids used more than once") + ;; :graph-human intentionally strips :logseq.kv/schema-version from exports so the frontend can set it + (when (= :graph graph-export-type) + (is (= (:kv/value (d/entity @conn :logseq.kv/schema-version)) + (:kv/value (d/entity @conn2 :logseq.kv/schema-version))) + "Raw datom import preserves kv values exactly")))) + +(deftest ^:long import-graph-with-timestamps + (let [original-data* (build-original-graph-data) + original-data (-> original-data* + (update :pages-and-blocks + (fn [pages-and-blocks] + (walk/postwalk (fn [e] + (if (and (map? e) (or (:block/title e) (:build/journal e))) + (common-util/block-with-timestamps e) + e)) + pages-and-blocks))) + (update :classes update-vals common-util/block-with-timestamps) + (update :properties update-vals common-util/block-with-timestamps) + (update ::sqlite-export/graph-files + (fn [files] + (mapv #(let [now (js/Date.)] + (merge % {:file/created-at now :file/last-modified-at now})) + files)))) + conn (db-test/create-conn-with-import-map original-data) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type + :graph-options {:include-timestamps? true}}) + conn2 (db-test/create-conn) + imported-graph (export-graph-and-import-to-another-graph conn conn2 {:include-timestamps? true})] + + (is (= nil + (sqlite-export/diff-exports export-map imported-graph)) + "No diff between original datom export and export after importing into a new graph"))) + +(deftest ^:long import-graph-with-exclude-namespaces + (let [original-data (build-original-graph-data {:exclude-namespaces? true}) + conn (db-test/create-conn-with-import-map original-data) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type + :graph-options {:exclude-namespaces #{:user}}}) + ;; :graph-human drops the excluded namespace from the export, so the import target + ;; must already define the user-namespace ontology that the pages-and-blocks reference + conn2 (if (= :graph-human graph-export-type) + (db-test/create-conn-with-blocks + {:properties (update-vals (:properties original-data) #(dissoc % :build/properties)) + :classes (update-vals (:classes original-data) #(dissoc % :build/properties))}) + (db-test/create-conn)) + imported-graph (export-graph-and-import-to-another-graph conn conn2 {:exclude-namespaces #{:user}})] + + (is (= nil + (sqlite-export/diff-exports export-map imported-graph)) + "Graph export with :exclude-namespaces roundtrips exactly"))) + +(deftest ^:long graph-is-idempotent-across-import-and-export + (let [original-data (build-original-graph-data) + conn (db-test/create-conn-with-import-map original-data) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type}) + valid-result (sqlite-export/validate-export export-map) + _ (assert (not (:error valid-result)) "No error when importing export-map into new graph") + _ (validate-db (:db valid-result)) + export-map2 (sqlite-export/build-export (:db valid-result) {:export-type graph-export-type})] + ;; (cljs.pprint/pprint (sqlite-export/diff-exports export-map export-map2)) + (is (= nil + (sqlite-export/diff-exports export-map export-map2)) + "No diff between original export and export after importing into a new graph"))) + +(deftest graph-with-property-alias-is-idempotent + (let [property-alias-uuid (random-uuid) + original-data + {:properties + {:user.property/referrerURL + {:logseq.property/type :default + :block/alias #{[:block/uuid property-alias-uuid]}}} + :pages-and-blocks + [{:page {:block/title "Referrer" + :block/uuid property-alias-uuid + :build/keep-uuid? true} + :blocks []}]} + conn (db-test/create-conn-with-import-map original-data) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type}) + valid-result (sqlite-export/validate-export export-map) + _ (assert (not (:error valid-result)) "No error when importing export-map into new graph") + _ (validate-db (:db valid-result)) + export-map2 (sqlite-export/build-export (:db valid-result) {:export-type graph-export-type})] + (is (some? (:block/alias (d/entity @conn :user.property/referrerURL))) + "Property's :block/alias is preserved after import") + (is (some? (:block/alias (d/entity (:db valid-result) :user.property/referrerURL))) + "Property's :block/alias is present after datom import") + (is (= nil + (sqlite-export/diff-exports export-map export-map2)) + "No diff between original export and export after importing into a new graph"))) + +(deftest ^:long import-graph-preserves-property-history + (let [now (common-util/time-ms) + original-data + {:properties {:user.property/num {:logseq.property/type :number} + :user.property/node {:logseq.property/type :node + :db/cardinality :db.cardinality/many}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "num block" + :build/properties {:user.property/num 44}} + {:block/title "status block" + :build/properties {:logseq.property/status :logseq.property/status.doing}} + {:block/title "node block"} + {:block/title "object 1"} + {:block/title "object 2"}]}]} + conn (db-test/create-conn-with-import-map original-data) + num-block (db-test/find-block-by-content @conn "num block") + status-block (db-test/find-block-by-content @conn "status block") + node-block (db-test/find-block-by-content @conn "node block") + original-property-history + [{:block/uuid (random-uuid) + :block/created-at now + :logseq.property.history/block [:block/uuid (:block/uuid num-block)] + :logseq.property.history/property :user.property/num + :logseq.property.history/scalar-value 42} + {:block/uuid (random-uuid) + :block/created-at (+ now 1000) + :logseq.property.history/block [:block/uuid (:block/uuid num-block)] + :logseq.property.history/property :user.property/num + :logseq.property.history/scalar-value 44} + {:block/uuid (random-uuid) + :block/created-at now + :logseq.property.history/block [:block/uuid (:block/uuid node-block)] + :logseq.property.history/property :user.property/node + :logseq.property.history/ref-value [:block/uuid (:block/uuid (db-test/find-block-by-content @conn "object 1"))]} + {:block/uuid (random-uuid) + :block/created-at (+ now 1000) + :logseq.property.history/block [:block/uuid (:block/uuid node-block)] + :logseq.property.history/property :user.property/node + :logseq.property.history/ref-value [:block/uuid (:block/uuid (db-test/find-block-by-content @conn "object 2"))]} + {:block/uuid (random-uuid) + :block/created-at now + :logseq.property.history/block [:block/uuid (:block/uuid status-block)] + :logseq.property.history/property :logseq.property/status + :logseq.property.history/ref-value :logseq.property/status.todo} + {:block/uuid (random-uuid) + :block/created-at (+ now 1000) + :logseq.property.history/block [:block/uuid (:block/uuid status-block)] + :logseq.property.history/property :logseq.property/status + :logseq.property.history/ref-value :logseq.property/status.doing}] + _ (d/transact! conn original-property-history) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type}) + valid-result (sqlite-export/validate-export export-map) + _ (assert (not (:error valid-result)) "No error when importing export-map into new graph") + _ (validate-db (:db valid-result)) + export-map2 (sqlite-export/build-export (:db valid-result) {:export-type graph-export-type})] + (is (= nil + (sqlite-export/diff-exports export-map export-map2)) + "No diff between original export and export after importing into a new graph") + (is (= (count original-property-history) + (count (d/datoms (:db valid-result) :avet :logseq.property.history/block))) + "Original property history datoms are imported"))) + +;; When a built-in property appears in :build/class-properties alongside +;; user-defined properties, the per-class order must survive a round-trip +(deftest import-graph-preserves-class-properties-order-with-built-in + (let [original-data + {:properties {:user.property/url {:logseq.property/type :default} + :user.property/about {:logseq.property/type :default}} + :classes {:user.class/UrlFirst {:build/class-properties [:user.property/url :logseq.property/status]} + :user.class/StatusFirst {:build/class-properties [:logseq.property/status :user.property/about]}}} + conn (db-test/create-conn-with-import-map original-data) + ;; Simulate a UI-built graph where the user positioned :logseq.property/status + ;; ahead of the user-defined properties referenced from the same classes. + _ (d/transact! conn [{:db/ident :logseq.property/status :block/order "a0"}]) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type}) + valid-result (sqlite-export/validate-export export-map) + _ (assert (not (:error valid-result)) "No error when importing export-map into new graph") + export-map2 (sqlite-export/build-export (:db valid-result) {:export-type graph-export-type})] + (is (= nil + (sqlite-export/diff-exports export-map export-map2)) + "No diff between original export and export after importing into a new graph"))) + +(deftest import-graph-preserves-graph-files-order + (let [conn (db-test/create-conn-with-import-map {}) + ;; Transact files one at a time in non-alphabetical order to ensure + ;; the test isn't satisfied just by happening to query in input order. + _ (doseq [file [{:file/path "logseq/publish.js" :file/content ""} + {:file/path "logseq/custom.css" :file/content ".foo {}"} + {:file/path "logseq/publish.css" :file/content ""} + {:file/path "logseq/custom.js" :file/content "// hi"} + {:file/path "logseq/config.edn" :file/content "{:foo :bar}"}]] + (d/transact! conn [file])) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type}) + valid-result (sqlite-export/validate-export export-map) + _ (assert (not (:error valid-result)) "No error when importing export-map into new graph") + export-map2 (sqlite-export/build-export (:db valid-result) {:export-type graph-export-type})] + (is (= nil + (sqlite-export/diff-exports export-map export-map2)) + "No diff between original export and export after importing into a new graph"))) + +(deftest import-graph-with-different-property-value-cases + (let [pvalue-uuid1 (random-uuid) + original-data + {:classes {:user.class/C1 {}} + :properties + {:user.property/default {:logseq.property/type :default} + :user.property/default-many {:logseq.property/type :default + :db/cardinality :db.cardinality/many}} + :pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "block with pvalue that has :build/tags" + :build/properties {:user.property/default + {:build/property-value :block + :block/title "tags pvalue" + :build/tags #{:user.class/C1}}}} + {:block/title "block with pvalue that has a view" + :build/properties {:user.property/default {:build/property-value :block + :block/title "view pvalue" + :block/uuid pvalue-uuid1 + :build/keep-uuid? true}}} + {:block/title "block with pvalue that has children" + :build/properties {:user.property/default + {:build/property-value :block + :block/title "children pvalue" + :build/children + [{:block/title "c1" :build/tags #{:user.class/C1}} + {:block/title "c2" :build/properties {:user.property/default "c21"}}]}}} + {:block/title "block with pvalue map in a :many property" + :build/properties + {:user.property/default-many + #{"yep" + {:build/property-value :block + :block/title ":many pvalue" + :build/tags #{:user.class/C1}}}}}]} + {:page {:block/title "$$$views2"} + :blocks [{:block/title "Unlinked references", + :build/properties + {:logseq.property.view/type :logseq.property.view/type.list, + :logseq.property.view/group-by-property :block/page, + :logseq.property.view/feature-type :unlinked-references, + :logseq.property/view-for + [:block/uuid pvalue-uuid1]}}]}]} + conn (db-test/create-conn-with-blocks (assoc original-data :build-existing-tx? true)) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type + :graph-options {:exclude-built-in-pages? true}}) + conn2 (db-test/create-conn) + imported-graph (export-graph-and-import-to-another-graph conn conn2 {:exclude-built-in-pages? true})] + + (is (= nil + (sqlite-export/diff-exports export-map imported-graph)) + "Property value entities roundtrip through graph datoms"))) + +(defn- test-import-existing-page [import-options expected-page-properties] + (let [original-data + {:properties {:user.property/node {:logseq.property/type :node + :db/cardinality :db.cardinality/many}} + :pages-and-blocks + [{:page {:block/title "page1" + :build/properties {:user.property/node + #{[:build/page {:block/title "existing page" + :build/properties {:logseq.property/description "first description"}}]}}}}]} + conn (db-test/create-conn-with-blocks original-data) + page-uuid (:block/uuid (db-test/find-page-by-title @conn "existing page")) + _ (validate-db @conn) + ;; This is just a temp uuid used to link to the page during import + temp-uuid (random-uuid) + import-data + {:properties {:user.property/node {:logseq.property/type :node + :db/cardinality :db.cardinality/many}} + :pages-and-blocks + [{:page {:block/title "existing page" + :block/uuid temp-uuid + :build/keep-uuid? true + :build/properties {:logseq.property/description "second description" + :logseq.property/exclude-from-graph-view true}}} + {:page {:block/title "page2" + :build/properties {:user.property/node #{[:block/uuid temp-uuid]}}}}] + ::sqlite-export/import-options import-options} + {:keys [init-tx block-props-tx] :as _txs} + (sqlite-export/build-import import-data @conn {}) + ;; _ (cljs.pprint/pprint _txs) + _ (d/transact! conn (concat init-tx block-props-tx)) + _ (validate-db @conn) + existing-page (db-test/find-page-by-title @conn "existing page") + page1 (db-test/find-page-by-title @conn "page1") + page2 (db-test/find-page-by-title @conn "page2")] + (is (= page-uuid (:block/uuid existing-page))) + (is (= expected-page-properties + (select-keys (db-test/readable-properties existing-page) + (keys expected-page-properties)))) + (is (= #{page-uuid} + (set (map :block/uuid (:user.property/node page1))))) + (is (= #{page-uuid} + (set (map :block/uuid (:user.property/node page2)))) + "page uuid of 'existing page' is preserved across imports even when its assigned a temporary + uuid to relate it to other nodes"))) + +(deftest build-import-can-import-existing-page-with-different-uuid + (testing "By default any properties passed to an existing page are upserted" + (test-import-existing-page {} + {:logseq.property/description "second description" + :logseq.property/exclude-from-graph-view true})) + (testing "With ::existing-pages-keep-properties?, existing properties on existing pages are not overwritten by imported data" + (test-import-existing-page {:existing-pages-keep-properties? true} + {:logseq.property/description "first description" + :logseq.property/exclude-from-graph-view true}))) + +(deftest build-export-omits-empty-build-properties + (let [conn (db-test/create-conn-with-blocks + {:properties {:user.property/p1 {:logseq.property/type :default}} + :classes {:user.class/C1 {:build/class-properties [:user.property/p1]}} + :pages-and-blocks [{:page {:block/title "page1"} + :blocks [{:block/title "b1" + :build/tags [:user.class/C1]}]}]}) + page (db-test/find-page-by-title @conn "page1") + export-edn (sqlite-export/build-export @conn {:export-type :page :page-id (:db/id page)}) + empty-build-properties (atom [])] + (walk/postwalk (fn [e] + (when (and (map? e) (= {} (:build/properties e))) + (swap! empty-build-properties conj e)) + e) + export-edn) + (is (empty? @empty-build-properties) + "Export should omit :build/properties when it would otherwise be an empty map"))) + +(deftest import-graph-with-assets + (let [asset-uuid (random-uuid) + asset2-uuid (random-uuid) + original-data + {:pages-and-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "asset block" + :block/uuid asset-uuid + :build/keep-uuid? true + :build/tags #{:logseq.class/Asset} + :build/properties {:logseq.property.asset/type "pdf" + :logseq.property.asset/checksum "abc" + :logseq.property.asset/size 42}} + {:block/title "annotation block" + :build/tags #{:logseq.class/Pdf-annotation} + :build/properties {:logseq.property/asset [:block/uuid asset-uuid]}}]} + {:page {:block/title "page2"} + :blocks [{:block/title "asset image block" + :block/uuid asset2-uuid + :build/keep-uuid? true + :build/tags #{:logseq.class/Asset} + :build/properties {:logseq.property.asset/type "png" + :logseq.property.asset/checksum "img-checksum" + :logseq.property.asset/width 100 + :logseq.property.asset/height 200 + :logseq.property.asset/size 300}} + {:block/title "annotation with image" + :build/tags #{:logseq.class/Pdf-annotation} + :build/properties {:logseq.property.pdf/hl-image [:block/uuid asset2-uuid]}}]}]} + conn (db-test/create-conn-with-blocks original-data) + conn2 (db-test/create-conn) + imported-graph (export-graph-and-import-to-another-graph conn conn2 {:exclude-built-in-pages? true}) + export-map (sqlite-export/build-export @conn {:export-type graph-export-type + :graph-options {:exclude-built-in-pages? true}}) + annotation-block (db-test/find-block-by-content @conn2 "annotation block") + annotation-image (db-test/find-block-by-content @conn2 "annotation with image")] + (is (= nil + (sqlite-export/diff-exports export-map imported-graph)) + "Asset graph datoms roundtrip exactly") + (is (= [:block/uuid asset-uuid] + [:block/uuid (:block/uuid (:logseq.property/asset annotation-block))]) + ":logseq.property/asset should preserve the asset ref") + (is (= [:block/uuid asset2-uuid] + [:block/uuid (:block/uuid (:logseq.property.pdf/hl-image annotation-image))]) + ":logseq.property.pdf/hl-image should preserve the asset ref"))) diff --git a/deps/db/test/logseq/db/sqlite/gc_test.cljs b/deps/db/test/logseq/db/sqlite/gc_test.cljs new file mode 100644 index 0000000..fc68812 --- /dev/null +++ b/deps/db/test/logseq/db/sqlite/gc_test.cljs @@ -0,0 +1,49 @@ +(ns logseq.db.sqlite.gc-test + (:require ["fs" :as fs] + ["path" :as node-path] + [cljs.test :refer [deftest async use-fixtures is testing]] + [datascript.core :as d] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [logseq.db.sqlite.debug :as sqlite-debug] + [logseq.db.sqlite.gc :as sqlite-gc])) + +(use-fixtures + :each + ;; Cleaning tmp/ before leaves last tmp/ after a test run for dev and debugging + {:before + #(async done + (if (fs/existsSync "tmp") + (fs/rm "tmp" #js {:recursive true} (fn [err] + (when err (js/console.log err)) + (done))) + (done)))}) + +(defn- create-graph-dir + [dir db-name] + (fs/mkdirSync (node-path/join dir db-name) #js {:recursive true})) + +(deftest ^:long gc-kvs-table-test + (testing "Create a datascript db, gc it and ensure there's no missing addrs and garbage addrs" + (create-graph-dir "tmp/graphs" "test-db") + + (let [{:keys [conn sqlite]} (sqlite-cli/open-sqlite-datascript! "tmp/graphs" "test-db") + tx-data (map (fn [i] {:block/uuid (random-uuid) + :block/title (str "title " i)}) + (range 0 500000))] + (println "DB start transacting") + (d/transact! conn tx-data) + (println "DB transacted") + (let [non-ordered-tx (->> (shuffle tx-data) + (take 100000) + (map (fn [block] [:db/retractEntity [:block/uuid (:block/uuid block)]])))] + (d/transact! conn non-ordered-tx)) + (println "gc time") + ;; `true` to walk addresses and `false` to recursively run gc + (time (sqlite-gc/gc-kvs-table-node-version! sqlite false)) + + ;; ensure there's no missing address (broken db) + (is (empty? (sqlite-debug/find-missing-addresses-node-version sqlite)) + "Found missing addresses!") + + (is (true? (sqlite-gc/ensure-no-garbage sqlite)) + "Found garbage addresses!")))) diff --git a/deps/db/test/logseq/db/test_runner.cljs b/deps/db/test/logseq/db/test_runner.cljs new file mode 100644 index 0000000..5ef5f7a --- /dev/null +++ b/deps/db/test/logseq/db/test_runner.cljs @@ -0,0 +1,6 @@ +(ns logseq.db.test-runner + "Test runner which enables https://github.com/PEZ/baldr by default" + (:require [nextjournal.test-runner :as next-runner] + [pez.baldr])) + +(def -main next-runner/-main) diff --git a/deps/db/test/logseq/db_test.cljs b/deps/db/test/logseq/db_test.cljs new file mode 100644 index 0000000..f24a487 --- /dev/null +++ b/deps/db/test/logseq/db_test.cljs @@ -0,0 +1,376 @@ +(ns logseq.db-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [datascript.storage :as ds-storage :refer [IStorage]] + [logseq.db :as ldb] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.test.helper :as db-test])) + +(defrecord InMemoryStorage [*disk] + IStorage + (-store [_ addr+data-seq _delete-addrs] + (doseq [[addr data] addr+data-seq] + (vswap! *disk assoc addr data))) + (-restore [_ addr] + (get @*disk addr))) + +(defn- make-storage [] + (->InMemoryStorage (volatile! {}))) + +;;; datoms +;;; - 1 <----+ +;;; - 2 | +;;; - 3 -+ +(comment + ;; TODO: throw error or fix broken data when cycle detected + (def broken-outliner-data-with-cycle + [{:db/id 1 + :block/uuid #uuid"e538d319-48d4-4a6d-ae70-c03bb55b6fe4" + :block/parent 3} + {:db/id 2 + :block/uuid #uuid"c46664c0-ea45-4998-adf0-4c36486bb2e5" + :block/parent 1} + {:db/id 3 + :block/uuid #uuid"2b736ac4-fd49-4e04-b00f-48997d2c61a2" + :block/parent 2}])) + +(def class-parents-data + [{:block/tags :logseq.class/Tag + :block/title "x" + :block/name "x" + :block/uuid #uuid "6c353967-f79b-4785-b804-a39b81d72461"} + {:block/tags :logseq.class/Tag + :block/title "y" + :block/name "y" + :block/uuid #uuid "7008db08-ba0c-4aa9-afc6-7e4783e40a99" + :logseq.property.class/extends [:block/uuid #uuid "6c353967-f79b-4785-b804-a39b81d72461"]} + {:block/tags :logseq.class/Tag + :block/title "z" + :block/name "z" + :block/uuid #uuid "d95f2912-a7af-41b9-8ed5-28861f7fc0be" + :logseq.property.class/extends [:block/uuid #uuid "7008db08-ba0c-4aa9-afc6-7e4783e40a99"]}]) + +(deftest get-class-extends + (let [conn (db-test/create-conn)] + (d/transact! conn class-parents-data) + (is (= #{"x" "y"} + (->> (ldb/get-class-extends (ldb/get-page @conn "z")) + (map :block/title) + set))))) + +(deftest get-case-page + (let [conn (db-test/create-conn-with-blocks + {:properties + {:foo {:logseq.property/type :default} + :Foo {:logseq.property/type :default}} + :classes {:movie {} :Movie {}}})] + ;; Case sensitive properties + (is (= "foo" (:block/title (ldb/get-case-page @conn "foo")))) + (is (= "Foo" (:block/title (ldb/get-case-page @conn "Foo")))) + ;; Case sensitive classes + (is (= "movie" (:block/title (ldb/get-case-page @conn "movie")))) + (is (= "Movie" (:block/title (ldb/get-case-page @conn "Movie")))))) + +(deftest get-journal-page-by-day + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:build/journal 20260410}} + {:page {:build/journal 20260411}}]})] + (is (= "Apr 10th, 2026" + (:block/title (ldb/get-journal-page-by-day @conn 20260410)))) + (is (= "Apr 11th, 2026" + (:block/title (ldb/get-journal-page-by-day @conn 20260411)))))) + +(deftest page-exists + (let [conn (db-test/create-conn-with-blocks + {:properties + {:foo {:logseq.property/type :default} + :Foo {:logseq.property/type :default}} + :classes {:movie {} :Movie {}}})] + (is (= ["foo"] + (map #(:block/title (d/entity @conn %)) (ldb/page-exists? @conn "foo" #{:logseq.class/Property}))) + "Property pages correctly found for given class") + (is (= nil + (ldb/page-exists? @conn "foo" #{:logseq.class/Tag})) + "Property pages correctly not found for given class") + (is (= ["movie"] + (map #(:block/title (d/entity @conn %)) (ldb/page-exists? @conn "movie" #{:logseq.class/Tag}))) + "Class pages correctly found for given class") + (is (= nil + (ldb/page-exists? @conn "movie" #{:logseq.class/Property})) + "Class pages correctly not found for given class"))) + +(deftest test-transact-with-multiple-tx-datoms + (testing "last write wins with same tx" + (let [conn (d/create-conn)] + (d/transact! conn [[:db/add -1 :property :v1]]) + (let [tx (:max-tx @conn)] + (ldb/transact! conn + [(d/datom 1 :property :v1 (inc tx) false) + (d/datom 1 :property :v1 (inc tx) true)])) + (is (= :v1 (:property (d/entity @conn 1)))))) + (testing "last write wins with different tx" + (let [conn (d/create-conn)] + (d/transact! conn [[:db/add -1 :property :v1]]) + (let [tx (:max-tx @conn)] + (ldb/transact! conn + [(d/datom 1 :property :v1 (inc tx) false) + (d/datom 1 :property :v1 (+ tx 2) true)])) + (is (= :v1 (:property (d/entity @conn 1))))))) + +(deftest test-batch-transact! + (testing "DB validation should be running after the whole transaction" + (let [conn (db-test/create-conn)] + (testing "#Task shouldn't be converted to property" + (is (thrown? js/Error + (with-out-str + (db-test/silence-stderr + (ldb/transact! conn [{:db/ident :logseq.class/Task + :block/tags :logseq.class/Property}])))))) + (ldb/batch-transact-with-temp-conn! + conn + {} + (fn [temp-conn] + (ldb/transact! temp-conn [{:db/ident :logseq.class/Task + :block/tags :logseq.class/Property}]) + (ldb/transact! temp-conn [[:db/retract :logseq.class/Task :block/tags :logseq.class/Property]])))))) + +(deftest batch-transact-with-temp-conn-preserves-retracts-test + (testing "temp conn batch replays retractions instead of treating every datom as an add" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page 1"} + :blocks [{:block/title "old"}]}]}) + block (db-test/find-block-by-content @conn "old") + block-id (:db/id block)] + (ldb/batch-transact-with-temp-conn! + conn + {} + (fn [temp-conn] + (ldb/transact! temp-conn [[:db/retract [:block/uuid (:block/uuid block)] :block/title "old"]]) + (ldb/transact! temp-conn [[:db/add [:block/uuid (:block/uuid block)] :block/title "new"]]))) + (is (= "new" (:block/title (d/entity @conn block-id))))))) + +(deftest batch-transact-with-temp-conn-preserves-cardinality-one-schema-test + (testing "temp conn emits replacement retractions for cardinality-one attrs" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page 1"} + :blocks [{:block/title "old"}]}]}) + block (db-test/find-block-by-content @conn "old") + block-id (:db/id block)] + (ldb/transact! conn [[:db/add block-id :block/order "a0"]]) + (ldb/batch-transact-with-temp-conn! + conn + {} + (fn [temp-conn] + (ldb/transact! temp-conn [[:db/add [:block/uuid (:block/uuid block)] :block/order "a1"]]))) + (is (= "a1" (:block/order (d/entity @conn block-id))))))) + +(deftest batch-transact-with-temp-conn-before-commit-can-abort-live-commit-test + (testing "before-commit runs after temp work and before the live conn is modified" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page 1"} + :blocks [{:block/title "old"}]}]}) + block (db-test/find-block-by-content @conn "old") + block-id (:db/id block)] + (is (thrown? js/Error + (ldb/batch-transact-with-temp-conn! + conn + {} + (fn [temp-conn] + (ldb/transact! temp-conn [[:db/add [:block/uuid (:block/uuid block)] :block/title "new"]])) + {:before-commit #(throw (js/Error. "abort before commit"))}))) + (is (= "old" (:block/title (d/entity @conn block-id))))))) + +(deftest validated-transact-retries-when-live-conn-changes-before-commit-test + (testing "validated transact does not overwrite a concurrent live commit" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page 1"} + :blocks [{:block/title "first"} + {:block/title "second"}]}]}) + first-block (db-test/find-block-by-content @conn "first") + second-block (db-test/find-block-by-content @conn "second") + injected? (atom false) + original-pipeline @ldb/*transact-pipeline-fn] + (try + (ldb/register-transact-pipeline-fn! + (fn [tx-report] + (when (and (not @injected?) + (some (fn [datom] + (and (= :block/title (:a datom)) + (= "first updated" (:v datom)))) + (:tx-data tx-report))) + (reset! injected? true) + (ldb/transact! conn [[:db/add (:db/id second-block) :block/title "second updated"]])) + tx-report)) + (ldb/transact! conn [[:db/add (:db/id first-block) :block/title "first updated"]]) + (is (= "first updated" (:block/title (d/entity @conn (:db/id first-block))))) + (is (= "second updated" (:block/title (d/entity @conn (:db/id second-block))))) + (finally + (reset! ldb/*transact-pipeline-fn original-pipeline)))))) + +(deftest fix-db-transact-runs-pipeline-without-recursive-validation-test + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:block/title "page 1"} + :blocks [{:block/title "before"}]}]}) + block (db-test/find-block-by-content @conn "before") + block-id (:db/id block) + original-pipeline @ldb/*transact-pipeline-fn] + (try + (ldb/register-transact-pipeline-fn! + (fn [{:keys [db-before db-after tx-data tx-meta]}] + (let [stamp-report (d/with db-after + [{:db/id block-id + :block/tx-id 42}])] + (assoc stamp-report + :db-before db-before + :tx-data (concat tx-data (:tx-data stamp-report)) + :tx-meta tx-meta)))) + (with-redefs [db-validate/validate-tx-report + (fn [& _] + (throw (js/Error. "Repair transactions must not recursively validate")))] + (ldb/transact! conn + [{:db/id block-id + :block/title "after"}] + {:fix-db? true})) + (is (= "after" (:block/title (d/entity @conn block-id)))) + (is (= 42 (:block/tx-id (d/entity @conn block-id))) + "Repair transactions commit canonical data produced by the pipeline.") + (finally + (reset! ldb/*transact-pipeline-fn original-pipeline))))) + +(deftest test-batch-transact-clears-stale-tx-tail-before-next-store-tail + (let [block-uuid #uuid "00000001-2026-0421-0000-000000000000" + schema {:block/uuid {:db/unique :db.unique/identity}} + storage (make-storage) + conn (d/create-conn schema {:storage storage})] + ;; Valid history: unique value moves to another entity. + (d/transact! conn [[:db/add 28446 :block/uuid block-uuid]]) + (ldb/batch-transact! + conn + {} + (fn [batch-conn] + (d/transact! batch-conn [[:db/retract 28446 :block/uuid block-uuid]]) + (d/transact! batch-conn [[:db/add 28447 :block/uuid block-uuid]]))) + ;; Next tx uses tail-only persistence. + (d/transact! conn [[:db/add 1 :filler "x"]]) + (let [tail (get @(:*disk storage) @#'ds-storage/tail-addr) + stale? (some (fn [[e a v _tx]] + (and (= 28446 e) + (= :block/uuid a) + (= block-uuid v))) + (apply concat tail)) + restored (d/restore-conn storage)] + (is (nil? stale?) + "stale pre-batch datoms should not leak into tail after batch-transact!") + (is (= [28447] + (mapv :e (d/datoms @restored :avet :block/uuid block-uuid))))))) + +(deftest get-bidirectional-properties + (testing "disabled by default" + (let [conn (db-test/create-conn-with-blocks + {:properties {:friend {:logseq.property/type :node + :build/property-classes [:Person]}} + :classes {:Person {} + :Project {}} + :pages-and-blocks + [{:page {:block/title "Alice" + :build/tags [:Person] + :build/properties {:friend [:build/page {:block/title "Bob"}]}}} + {:page {:block/title "Bob"}} + {:page {:block/title "Charlie" + :build/tags [:Project] + :build/properties {:friend [:build/page {:block/title "Bob"}]}}}]}) + target (db-test/find-page-by-title @conn "Bob")] + (is (empty? (ldb/get-bidirectional-properties @conn (:db/id target)))))) + + (testing "enabled per class" + (let [conn (db-test/create-conn-with-blocks + {:properties {:friend {:logseq.property/type :node + :build/property-classes [:Person]}} + :classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}} + :Project {}} + :pages-and-blocks + [{:page {:block/title "Alice" + :build/tags [:Person] + :build/properties {:friend [:build/page {:block/title "Bob"}]}}} + {:page {:block/title "Bob"}} + {:page {:block/title "Charlie" + :build/tags [:Project] + :build/properties {:friend [:build/page {:block/title "Bob"}]}}}]}) + target (db-test/find-page-by-title @conn "Bob") + results (ldb/get-bidirectional-properties @conn (:db/id target))] + (is (= 1 (count results))) + (is (= "People" (:title (first results)))) + (is (= ["Alice"] + (map :block/title (:entities (first results)))))))) + +(deftest get-bidirectional-properties-ignores-recycled-entities + (let [conn (db-test/create-conn-with-blocks + {:properties {:friend {:logseq.property/type :node + :build/property-classes [:Person]}} + :classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}}} + :pages-and-blocks + [{:page {:block/title "Alice" + :build/tags [:Person] + :build/properties {:friend [:build/page {:block/title "Bob"}]}}} + {:page {:block/title "Bob"}}]}) + alice (db-test/find-page-by-title @conn "Alice") + target (db-test/find-page-by-title @conn "Bob")] + (d/transact! conn [{:db/id (:db/id alice) + :logseq.property/deleted-at 1}]) + (is (empty? (ldb/get-bidirectional-properties @conn (:db/id target)))))) + +(defn- bidirectional-perf-conn + [n property-titles] + (let [target-page {:page {:block/title "Target"}} + properties (into {} + (map (fn [property-title] + [property-title {:logseq.property/type :node + :build/property-classes [:Person]}])) + property-titles) + person-properties (into {} + (map (fn [property-title] + [property-title [:build/page {:block/title "Target"}]])) + property-titles) + pages (vec (concat [target-page] + (map (fn [i] + {:page {:block/title (str "Person " i) + :build/tags [:Person] + :build/properties person-properties}}) + (range n))))] + (db-test/create-conn-with-blocks + {:properties properties + :classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}}} + :pages-and-blocks pages}))) + +(deftest ^:long get-bidirectional-properties-performance-single-property + (testing "attribute lookups scale with unique properties, not entities" + (let [conn (bidirectional-perf-conn 400 [:friend]) + target-id (:db/id (db-test/find-page-by-title @conn "Target")) + original-entity d/entity + attr-lookups (atom 0) + results (with-redefs [d/entity (fn [db eid] + (when (keyword? eid) + (swap! attr-lookups inc)) + (original-entity db eid))] + (ldb/get-bidirectional-properties @conn target-id))] + (is (= 1 (count results))) + (is (= 400 (count (:entities (first results))))) + (is (<= @attr-lookups 8) + (str "expected bounded attr lookups, got " @attr-lookups))))) + +(deftest ^:long get-bidirectional-properties-performance-multi-property + (testing "attribute lookups stay bounded with multiple matching properties" + (let [conn (bidirectional-perf-conn 300 [:friend :colleague]) + target-id (:db/id (db-test/find-page-by-title @conn "Target")) + original-entity d/entity + attr-lookups (atom 0) + results (with-redefs [d/entity (fn [db eid] + (when (keyword? eid) + (swap! attr-lookups inc)) + (original-entity db eid))] + (ldb/get-bidirectional-properties @conn target-id))] + (is (= 1 (count results))) + (is (= 300 (count (:entities (first results))))) + (is (<= @attr-lookups 12) + (str "expected bounded attr lookups, got " @attr-lookups))))) diff --git a/deps/graph-parser/.carve/config.edn b/deps/graph-parser/.carve/config.edn new file mode 100644 index 0000000..a7525e3 --- /dev/null +++ b/deps/graph-parser/.carve/config.edn @@ -0,0 +1,4 @@ +{:paths ["src"] + :api-namespaces [logseq.graph-parser.exporter + logseq.graph-parser.schema.mldoc] + :report {:format :ignore}} diff --git a/deps/graph-parser/.carve/ignore b/deps/graph-parser/.carve/ignore new file mode 100644 index 0000000..8f41c96 --- /dev/null +++ b/deps/graph-parser/.carve/ignore @@ -0,0 +1,13 @@ +;; For CLI +logseq.graph-parser.mldoc/ast-export-markdown +;; API +logseq.graph-parser.mldoc/block-with-title? +;; API +logseq.graph-parser.mldoc/->db-edn +;; API +logseq.graph-parser.text/get-file-basename +;; API +logseq.graph-parser.mldoc/mldoc-link? +;; API +logseq.graph-parser.text/get-page-name +logseq.graph-parser.text/get-namespace-last-part \ No newline at end of file diff --git a/deps/graph-parser/.clj-kondo/config.edn b/deps/graph-parser/.clj-kondo/config.edn new file mode 100644 index 0000000..480b5c2 --- /dev/null +++ b/deps/graph-parser/.clj-kondo/config.edn @@ -0,0 +1,27 @@ +{:linters + {:aliased-namespace-symbol {:level :warning} + :namespace-name-mismatch {:level :warning} + :used-underscored-binding {:level :warning} + :shadowed-var {:level :warning + ;; FIXME: Remove these as shadowing core fns isn't a good practice + :exclude [val format key name alias type parents exists?]} + + :consistent-alias + {:aliases {clojure.string string + datascript.core d + logseq.graph-parser.text text + logseq.graph-parser.block gp-block + logseq.graph-parser.mldoc gp-mldoc + logseq.common.util common-util + logseq.graph-parser.property gp-property + logseq.common.config common-config + logseq.graph-parser.date-time-util date-time-util + logseq.common.util.page-ref page-ref + logseq.common.util.block-ref block-ref}}} + + :lint-as {promesa.core/let clojure.core/let + promesa.core/loop clojure.core/loop + promesa.core/recur clojure.core/recur + logseq.graph-parser.test.helper/deftest-async clojure.test/deftest} + :skip-comments true + :output {:progress true}} diff --git a/deps/graph-parser/.gitignore b/deps/graph-parser/.gitignore new file mode 100644 index 0000000..ddf8821 --- /dev/null +++ b/deps/graph-parser/.gitignore @@ -0,0 +1,3 @@ +/.clj-kondo/.cache +cljs-test-runner-out +/test/resources/docs* diff --git a/deps/graph-parser/README.md b/deps/graph-parser/README.md new file mode 100644 index 0000000..f67565c --- /dev/null +++ b/deps/graph-parser/README.md @@ -0,0 +1,59 @@ +## Description + +This library parses a file graph directory and returns it as a datascript +database connection. This library mainly exists to convert file graphs to DB graphs. +This library is compatible with ClojureScript and with +[nbb-logseq](https://github.com/logseq/nbb-logseq) to respectively provide +frontend and commandline functionality. + +## API + +This library is under the parent namespace `logseq.graph-parser`. This library +provides two main namespaces, `logseq.graph-parser.exporter` and +`logseq.graph-parser.extract`. + +## Usage + +See [nbb-logseq example +scripts](https://github.com/logseq/nbb-logseq/tree/main/examples) for example +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-graph-parser.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 + +The package.json dependencies are just for testing and should be updated if there is +new behavior to test. + +The deps.edn dependencies are used by both ClojureScript and nbb-logseq. Their +versions should be backwards compatible with each other with priority given to +the frontend. _No new dependency_ should be introduced to this library without +an understanding of the tradeoffs of adding this to nbb-logseq. diff --git a/deps/graph-parser/bb.edn b/deps/graph-parser/bb.edn new file mode 100644 index 0000000..08b40ff --- /dev/null +++ b/deps/graph-parser/bb.edn @@ -0,0 +1,29 @@ +{:min-bb-version "0.8.156" + :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 75}}} diff --git a/deps/graph-parser/deps.edn b/deps/graph-parser/deps.edn new file mode 100644 index 0000000..8615456 --- /dev/null +++ b/deps/graph-parser/deps.edn @@ -0,0 +1,28 @@ +{:paths ["src"] + :deps + ;; These nbb-logseq deps are kept in sync with https://github.com/logseq/nbb-logseq/blob/main/bb.edn + {com.andrewmcveigh/cljs-time {:git/url "https://github.com/logseq/cljs-time" ;; fork + :sha "5704fbf48d3478eedcf24d458c8964b3c2fd59a9"} + funcool/promesa {:mvn/version "11.0.678"} + cljs-bean/cljs-bean {:mvn/version "1.9.0"} + + ;; Any other deps should be added here and to nbb.edn + borkdude/rewrite-edn {:mvn/version "0.5.9"} + logseq/db {:local/root "../db"} + logseq/common {:local/root "../common"} + ;; stubbed via logseq.common.log + com.lambdaisland/glogi {:git/url "https://github.com/lambdaisland/glogi" + :git/sha "30328a045141717aadbbb693465aed55f0904976"}} + + :aliases + ;; This runs tests with nodejs. Would be nice to run this with in a browser env + ;; since this is how its normally run in the app but this requires more setup + ;; with karma, shadow-cljs.edn and headless mode on CI + {:test {:extra-paths ["test"] + :extra-deps {olical/cljs-test-runner {:mvn/version "3.8.1"} + org.clojure/clojurescript {:mvn/version "1.12.134"} + logseq/outliner {:local/root "../outliner"}} + :main-opts ["-m" "cljs-test-runner.main"]} + + :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/graph-parser/nbb.edn b/deps/graph-parser/nbb.edn new file mode 100644 index 0000000..aa0cf2f --- /dev/null +++ b/deps/graph-parser/nbb.edn @@ -0,0 +1,12 @@ +{:paths ["src"] + :deps + {logseq/common + {:local/root "../common"} + logseq/db + {:local/root "../db"} + logseq/outliner + {:local/root "../outliner"} + io.github.nextjournal/nbb-test-runner + {:git/sha "b379325cfa5a3306180649da5de3bf5166414e71"} + borkdude/rewrite-edn {:mvn/version "0.5.9"} + io.github.pez/baldr {:mvn/version "1.0.9"}}} \ No newline at end of file diff --git a/deps/graph-parser/package.json b/deps/graph-parser/package.json new file mode 100644 index 0000000..0115d73 --- /dev/null +++ b/deps/graph-parser/package.json @@ -0,0 +1,18 @@ +{ + "name": "@logseq/graph-parser", + "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", + "better-sqlite3": "^12.8.0" + }, + "dependencies": { + "mldoc": "^1.5.9", + "sanitize-filename": "1.6.4" + }, + "scripts": { + "test": "pnpm exec nbb-logseq -cp test -m nextjournal.test-runner", + "test-v": "pnpm exec nbb-logseq -cp test -m logseq.graph-parser.test-runner" + } +} diff --git a/deps/graph-parser/pnpm-lock.yaml b/deps/graph-parser/pnpm-lock.yaml new file mode 100644 index 0000000..60c3b88 --- /dev/null +++ b/deps/graph-parser/pnpm-lock.yaml @@ -0,0 +1,713 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + mldoc: + specifier: ^1.5.9 + version: 1.5.9 + sanitize-filename: + specifier: 1.6.4 + version: 1.6.4 + 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 + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 + +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==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + 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'} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + 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: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + 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 + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + utf8-byte-length@1.0.5: {} + + 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 diff --git a/deps/graph-parser/script/db_import.cljs b/deps/graph-parser/script/db_import.cljs new file mode 100644 index 0000000..851665c --- /dev/null +++ b/deps/graph-parser/script/db_import.cljs @@ -0,0 +1,244 @@ +(ns db-import + "Imports given file(s) to a db graph. This script is primarily for + developing the import feature and for engineers who want to customize + the import process" + (:require ["fs" :as fs] + ["fs/promises" :as fsp] + ["path" :as node-path] + [babashka.cli :as cli] + [cljs.pprint :as pprint] + [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [logseq.common.config :as common-config] + [logseq.common.graph :as common-graph] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [logseq.db.frontend.asset :as db-asset] + [logseq.db.frontend.validate :as db-validate] + [logseq.graph-parser.exporter :as gp-exporter] + [logseq.outliner.cli :as outliner-cli] + [nbb.classpath :as cp] + [nbb.core :as nbb] + [promesa.core :as p])) + +(def tx-queue (atom cljs.core/PersistentQueue.EMPTY)) +;; This is a lower-level dev hook to inspect txs and shouldn't hook into ldb/transact! +(def original-transact! d/transact!) +(defn dev-transact! [conn tx-data tx-meta] + (swap! tx-queue (fn [queue] + (let [new-queue (conj queue {:tx-data tx-data :tx-meta tx-meta})] + ;; Only care about last few so vary 10 as needed + (if (> (count new-queue) 10) + (pop new-queue) + new-queue)))) + (original-transact! conn tx-data tx-meta)) + +(defn- build-graph-files + "Given a file graph directory, return all files including assets and adds relative paths + on ::rpath since paths are absolute by default and exporter needs relative paths for + some operations" + [dir*] + (let [dir (node-path/resolve dir*)] + (->> (common-graph/get-files dir) + (concat (when (fs/existsSync (node-path/join dir* "assets")) + (common-graph/readdir (node-path/join dir* "assets")))) + (mapv #(hash-map :path % + ::rpath (node-path/relative dir* %)))))) + +(defn- (.-length buffer) (* 100 1024 1024))) + +(defn- name (:path file)) + asset-type (db-asset/asset-path->type (:path file))] + (if (exceed-limit-size? buffer) + (js/console.log (str "Skipped copying asset " (pr-str (:path file)) " because it is larger than the 100M max.")) + (p/let [parent-dir (node-path/join db-graph-dir common-config/local-assets-dir) + {:keys [with-edn-content pdf-annotation?]} (buffer-handler buffer)] + (fsp/mkdir parent-dir #js {:recursive true}) + (swap! assets assoc asset-name + (with-edn-content + {:size (.-length buffer) + :type asset-type + :path (:path file) + :checksum checksum + :asset-id asset-id})) + (when-not pdf-annotation? + (fsp/copyFile (:path file) (node-path/join parent-dir (str asset-id "." asset-type)))))))) + +(defn- notify-user [{:keys [continue debug]} m] + (println (:msg m)) + (when (:ex-data m) + (println "Ex-data:" (pr-str (merge (dissoc (:ex-data m) :error) + (when-let [err (get-in m [:ex-data :error])] + {:original-error (ex-data (.-cause err))})))) + (println "\nStacktrace:") + (if-let [stack (some-> (get-in m [:ex-data :error]) ex-data :sci.impl/callstack deref)] + (println (string/join + "\n" + (map + #(str (:file %) + (when (:line %) (str ":" (:line %))) + (when (:sci.impl/f-meta %) + (str " calls #'" (get-in % [:sci.impl/f-meta :ns]) "/" (get-in % [:sci.impl/f-meta :name])))) + (reverse stack)))) + (println (some-> (get-in m [:ex-data :error]) .-stack))) + (when debug + (when-let [matching-tx (seq (filter #(and (get-in m [:ex-data :path]) + (= (get-in % [:tx-meta ::gp-exporter/path]) (get-in m [:ex-data :path]))) + @tx-queue))] + (println (str "\n" (count matching-tx)) "Tx Maps for failing path:") + (pprint/pprint matching-tx)))) + (when (and (= :error (:level m)) (not continue)) + (js/process.exit 1))) + +(defn default-export-options + [file-graph-dir options] + {;; common options + :rpath-key ::rpath + :notify-user (partial notify-user options) + :open-db-args db-graph-dir) + db-name (if (= 1 (count init-conn-args)) (first init-conn-args) (second init-conn-args)) + db-full-dir (if (= 1 (count init-conn-args)) + (node-path/dirname (first init-conn-args)) + (apply node-path/join init-conn-args)) + file-graph' (resolve-path file-graph) + conn (apply outliner-cli/init-conn (conj init-conn-args {:classpath (cp/get-classpath) + :import-type :cli/db-import})) + directory? (.isDirectory (fs/statSync file-graph')) + user-options (cond-> (merge {:all-tags false} (dissoc options :verbose :files :help :continue)) + ;; coerce option collection into strings + (:tag-classes options) + (update :tag-classes (partial mapv str)) + true + (set/rename-keys {:all-tags :convert-all-tags? :remove-inline-tags :remove-inline-tags?})) + _ (when (:verbose options) (prn :options user-options)) + options' (merge {:user-options user-options} + (select-keys options [:files :verbose :continue :debug :validate]))] + (p/let [{:keys [import-state]} + (if directory? + (import-file-graph-to-db file-graph' db-full-dir conn options') + (import-files-to-db file-graph' conn options'))] + + (when-let [ignored-props (seq @(:ignored-properties import-state))] + (println "Ignored properties:" (pr-str ignored-props))) + (when-let [ignored-assets (seq @(:ignored-assets import-state))] + (println "Ignored assets:" (pr-str ignored-assets))) + (when-let [ignored-files (seq @(:ignored-files import-state))] + (println (count ignored-files) "ignored file(s):" (pr-str (vec ignored-files)))) + (when (:verbose options') (println "Transacted" (count (d/datoms @conn :eavt)) "datoms")) + (println "Created graph" (str db-name "!")) + (when (:validate options') (validate-db @conn db-name {}))))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) diff --git a/deps/graph-parser/src/logseq/graph_parser/block.cljs b/deps/graph-parser/src/logseq/graph_parser/block.cljs new file mode 100644 index 0000000..293625e --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/block.cljs @@ -0,0 +1,906 @@ +(ns logseq.graph-parser.block + "Given mldoc ast, prepares block data in preparation for db transaction. + Used by file and DB graphs" + (:require [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d] + [logseq.common.config :as common-config] + [logseq.common.date :as common-date] + [logseq.common.util :as common-util] + [logseq.common.util.block-ref :as block-ref] + [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.entity-plus :as entity-plus] + [logseq.db.common.order :as db-order] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.graph-parser.mldoc :as gp-mldoc] + [logseq.graph-parser.property :as gp-property] + [logseq.graph-parser.text :as text] + [logseq.graph-parser.utf8 :as utf8])) + +(defn heading-block? + [block] + (and + (vector? block) + (= "Heading" (first block)))) + +(defn get-tag + [block] + (when-let [tag-value (and (vector? block) + (= "Tag" (first block)) + (second block))] + (->> tag-value + (map (fn [[elem value]] + (case elem + "Plain" value + "Link" (:full_text value) + "Nested_link" (:content value) + ""))) + (string/join)))) + +(defn get-page-reference + [block format] + (let [page (cond + (and (vector? block) (= "Link" (first block))) + (let [url-type (first (:url (second block))) + value (second (:url (second block)))] + ;; {:url ["File" "file:../pages/hello_world.org"], :label [["Plain" "hello world"]], :title nil} + (or + (and + (= url-type "Page_ref") + (and (string? value) + (not (common-config/local-relative-asset? value))) + value) + + (and + (= url-type "Search") + (page-ref/page-ref? value) + (text/page-ref-un-brackets! value)) + + (and (= url-type "Search") + (= format :org) + (not (common-config/local-relative-asset? value)) + value) + + (and + (= url-type "File") + (second (first (:label (second block))))))) + + (and (vector? block) (= "Nested_link" (first block))) + (let [content (:content (last block))] + (subs content 2 (- (count content) 2))) + + (and (vector? block) + (= "Macro" (first block))) + (let [{:keys [name arguments]} (second block) + argument (string/join ", " arguments)] + (if (= name "embed") + (when (page-ref/page-ref? argument) + (text/page-ref-un-brackets! argument)) + {:type "macro" + :name name + :arguments arguments})) + + (and (vector? block) + (= "Tag" (first block))) + (let [text (get-tag block)] + (text/page-ref-un-brackets! text)) + + :else + nil)] + (when page (or (when (string? page) + (block-ref/get-block-ref-id page)) + page)))) + +(defn get-block-reference + [block] + (when-let [block-id (cond + (and (vector? block) + (= "Block_reference" (first block))) + (last block) + + (and (vector? block) + (= "Link" (first block)) + (map? (second block)) + (= "Block_ref" (first (:url (second block))))) + (second (:url (second block))) + + (and (vector? block) + (= "Macro" (first block))) + (let [{:keys [name arguments]} (second block)] + (when (and (= name "embed") + (string? (first arguments)) + (block-ref/string-block-ref? (first arguments))) + (block-ref/get-string-block-ref-id (first arguments)))) + + (and (vector? block) + (= "Link" (first block)) + (map? (second block))) + (if (= "id" (:protocol (second (:url (second block))))) + (:link (second (:url (second block)))) + (let [id (second (:url (second block)))] + ;; these can be maps + (when (string? id) + (or (block-ref/get-block-ref-id id) id)))) + + :else + nil)] + (when (some-> block-id parse-uuid) + block-id))) + +(defn paragraph-block? + [block] + (and + (vector? block) + (= "Paragraph" (first block)))) + +(defn timestamp-block? + [block] + (and + (vector? block) + (= "Timestamp" (first block)))) + +(defn- get-page-refs-from-property-names + [properties {:property-pages/keys [enabled? excludelist]}] + (if (contains? #{true nil} enabled?) + (sequence + (comp (map (comp name first)) + (remove string/blank?) + (remove (set (map name excludelist))) + ;; Remove built-in properties as we don't want pages + ;; created for them by default + (remove (into #{} + (map name) + (apply conj + (apply disj + (gp-property/editable-built-in-properties) + gp-property/editable-linkable-built-in-properties) + (gp-property/hidden-built-in-properties)))) + (distinct)) + properties) + [])) + +(defn- extract-refs-from-property-value + [value format] + (cond + (coll? value) + (filter (fn [v] (and (string? v) (not (string/blank? v)))) value) + (and (string? value) (= \" (first value) (last value))) + nil + (string? value) + (let [ast (gp-mldoc/inline->edn value (gp-mldoc/default-config format))] + (text/extract-refs-from-mldoc-ast ast)) + :else + nil)) + +(defn- get-page-ref-names-from-properties + [properties user-config] + (let [page-refs (->> + properties + (remove (fn [[k _]] + (contains? + (set/union (apply disj + (gp-property/editable-built-in-properties) + gp-property/editable-linkable-built-in-properties) + (gp-property/hidden-built-in-properties)) + (keyword k)))) + ;; get links ast + (map last) + (mapcat (fn [value] + (extract-refs-from-property-value value (get user-config :format :markdown)))) + ;; comma separated collections + (concat (->> (map second properties) + (filter coll?) + (apply concat)))) + page-refs-from-property-names (get-page-refs-from-property-names properties user-config)] + (->> (concat page-refs page-refs-from-property-names) + (remove string/blank?) + distinct))) + +(defn- extract-block-refs + [nodes] + (let [ref-blocks (atom nil)] + (walk/postwalk + (fn [form] + (when-let [block (get-block-reference form)] + (swap! ref-blocks conj block)) + form) + nodes) + (keep (fn [block] + (when-let [id (parse-uuid block)] + [:block/uuid id])) + @ref-blocks))) + +(defn extract-properties + [properties user-config] + (when (seq properties) + (let [properties (seq properties) + *invalid-properties (atom #{}) + properties (->> properties + (map (fn [[k v mldoc-ast]] + (let [k (if (or (keyword? k) (symbol? k)) + (subs (str k) 1) + k) + k (-> (string/lower-case k) + (string/replace "/" "-") + (string/replace " " "-") + (string/replace "_" "-"))] + (if (gp-property/valid-property-name? (str ":" k)) + (let [k' (keyword + (if (contains? #{"custom_id" "custom-id"} k) + "id" + k)) + v' (text/parse-property k v mldoc-ast user-config)] + [k' v' mldoc-ast v]) + (do (swap! *invalid-properties conj k) + nil))))) + (remove #(nil? (second %)))) + page-refs (get-page-ref-names-from-properties properties user-config) + block-refs (extract-block-refs properties) + properties-text-values (->> (map (fn [[k _v _refs original-text]] [k original-text]) properties) + (into {})) + properties (map (fn [[k v _]] [k v]) properties) + properties' (into {} properties)] + {:properties properties' + :properties-order (map first properties) + :properties-text-values properties-text-values + :invalid-properties @*invalid-properties + :page-refs page-refs + :block-refs block-refs}))) + +(defn- paragraph-timestamp-block? + [block] + (and (paragraph-block? block) + (or (timestamp-block? (first (second block))) + (timestamp-block? (second (second block)))))) + +(defn- extract-timestamps + [block] + (some->> + (second block) + (filter timestamp-block?) + (map last) + (into {}))) + +(defn- timestamp->scheduled-or-deadline-value + [{:keys [date repetition] timestamp-time :time}] + (let [{:keys [year month day]} date + day (js/parseInt (str year (common-util/zero-pad month) (common-util/zero-pad day)))] + (if (or timestamp-time repetition) + (cond-> {:date-int day} + timestamp-time + (assoc :time timestamp-time) + repetition + (assoc :repetition repetition)) + day))) + +;; {"Deadline" {:date {:year 2020, :month 10, :day 20}, :wday "Tue", :time {:hour 8, :min 0}, :repetition [["DoublePlus"] ["Day"] 1], :active true}} +(defn timestamps->scheduled-and-deadline + [timestamps] + (let [timestamps (update-keys timestamps (comp keyword string/lower-case)) + m (some->> (select-keys timestamps [:scheduled :deadline]) + (map (fn [[k v]] + (let [value (timestamp->scheduled-or-deadline-value v)] + (case k + :scheduled + {:scheduled value} + :deadline + {:deadline value})))))] + (apply merge m))) + +(defn- convert-page-if-journal-impl + "Convert journal file name to user' custom date format" + [original-page-name date-formatter & {:keys [export-to-db-graph?]}] + (when original-page-name + (let [page-name (common-util/page-name-sanity-lc original-page-name) + day (when date-formatter + (date-time-util/journal-title->int + page-name + ;; When exporting, only use the configured date-formatter. Allowing for other date formatters allows + ;; for page names to change which breaks looking up journal refs for unconfigured journal pages + (if export-to-db-graph? [date-formatter] (date-time-util/safe-journal-title-formatters date-formatter))))] + (if day + (let [original-page-name' (date-time-util/int->journal-title day date-formatter) + default-journal-page-name (date-time-util/int->journal-title day date-time-util/default-journal-title-formatter)] + [original-page-name' (common-util/page-name-sanity-lc default-journal-page-name) day]) + [original-page-name page-name day])))) + +(def convert-page-if-journal (memoize convert-page-if-journal-impl)) + +;; Hack to detect export as some fns are too deeply nested to be refactored to get explicit option +(def *export-to-db-graph? (atom false)) + +(defn- get-page + "Similar to get-page but only for file graphs" + [db page-name] + (when (and db (string? page-name)) + (d/entity db + (first (sort (map :e (entity-util/get-pages-by-name db page-name))))))) + +(defn- page-name-string->map + [original-page-name db date-formatter + {:keys [with-timestamp? page-uuid from-page class? skip-existing-page-check? skip-journal?]}] + (let [db-based? (entity-plus/db-based-graph? db) + original-page-name (common-util/remove-boundary-slashes original-page-name) + [original-page-name' page-name journal-day] + (if skip-journal? + [original-page-name (common-util/page-name-sanity-lc original-page-name) nil] + (convert-page-if-journal original-page-name date-formatter {:export-to-db-graph? @*export-to-db-graph?})) + namespace? (and (or (not db-based?) @*export-to-db-graph?) + (not journal-day) + (not (boolean (text/get-nested-page-name original-page-name'))) + (text/namespace-page? original-page-name')) + page-entity (when (and db (not skip-existing-page-check?)) + (cond + journal-day + (ldb/get-journal-page-by-day db journal-day) + + (and class? db-based?) + (some->> (ldb/page-exists? db original-page-name' #{:logseq.class/Tag}) + first + (d/entity db)) + + :else + (get-page db original-page-name'))) + original-page-name' (or from-page (:block/title page-entity) original-page-name') + page-name' (if (and journal-day page-entity) + (:block/name page-entity) + page-name) + page (merge + {:block/name page-name' + :block/title original-page-name'} + (when (and original-page-name + (not= (string/lower-case original-page-name) + (string/lower-case original-page-name')) + (not @*export-to-db-graph?)) + {:block.temp/original-page-name original-page-name}) + (if (and class? page-entity (:db/ident page-entity)) + {:block/uuid (:block/uuid page-entity) + :db/ident (:db/ident page-entity)} + (let [new-uuid* (if (uuid? page-uuid) + page-uuid + (if journal-day + (common-uuid/gen-uuid :journal-page-uuid journal-day) + (common-uuid/gen-uuid))) + new-uuid (if skip-existing-page-check? + new-uuid* + (or + (cond page-entity (:block/uuid page-entity) + (uuid? page-uuid) page-uuid) + new-uuid*))] + {:block/uuid new-uuid})) + (when namespace? + (let [namespace' (first (common-util/split-last "/" original-page-name))] + (when-not (string/blank? namespace') + {:block/namespace {:block/name (string/trim (common-util/page-name-sanity-lc namespace'))}}))) + (when (and with-timestamp? (or skip-existing-page-check? (not page-entity))) ;; Only assign timestamp on creating new entity + (let [current-ms (common-util/time-ms)] + {:block/created-at current-ms + :block/updated-at current-ms})) + (if journal-day + (cond-> {:block/journal-day journal-day} + db-based? + (assoc :block/tags [:logseq.class/Journal]) + (not db-based?) + (assoc :block/type "journal")) + {}))] + [page page-entity])) + +(defn sanitize-hashtag-name + "This must be kept in sync with its reverse operation in logseq.db.frontend.content" + [s] + (string/replace s "#" "HashTag-")) + +(defn- page-entity? + "Support DB or file graphs because of exporter" + [entity] + (or (entity-util/page? entity) + (contains? #{"page" "journal"} (:block/type entity)))) + +;; TODO: refactor +(defn page-name->map + "Create a page's map structure given a original page name (string). + map as input is supported for legacy compatibility. + `with-timestamp?`: assign timestampes to the map structure. + Useful when creating new pages from references or namespaces, + as there's no chance to introduce timestamps via editing in page + `skip-existing-page-check?`: if true, allows pages to have the same name" + [original-page-name db with-timestamp? date-formatter + & {:keys [page-uuid class?] :as options}] + (when-not (and db (common-util/uuid-string? original-page-name) + (not (page-entity? (d/entity db [:block/uuid (uuid original-page-name)])))) + (let [db-based? (entity-plus/db-based-graph? db) + original-page-name (cond-> (string/trim original-page-name) + db-based? + sanitize-hashtag-name) + [page _page-entity] (cond + (and original-page-name (string? original-page-name)) + (page-name-string->map original-page-name db date-formatter + (assoc options :with-timestamp? with-timestamp?)) + :else + (let [page (cond (and (map? original-page-name) (:block/uuid original-page-name)) + original-page-name + + (map? original-page-name) + (assoc original-page-name :block/uuid (or page-uuid (d/squuid))) + + :else + nil)] + [page nil]))] + (when page + (if db-based? + (let [tags (if class? [:logseq.class/Tag] + (or (:block/tags page) + [:logseq.class/Page]))] + (assoc page :block/tags tags)) + (assoc page :block/type (or (:block/type page) "page"))))))) + +(defn- db-namespace-page? + "Namespace page that're not journal pages" + [db-based? page] + (and db-based? + (text/namespace-page? page) + (not (common-date/valid-journal-title-with-slash? page)))) + +(defn- ref->map + [db *col {:keys [date-formatter *name->id tag? db-based? structured-tags]}] + (let [col (distinct (remove string/blank? @*col)) + children-pages (->> (mapcat (fn [p] + (let [p (if (map? p) + (:block/title p) + p)] + (when (string? p) + (let [p (or (text/get-nested-page-name p) p)] + (if (and (text/namespace-page? p) + (not (common-date/valid-journal-title-with-slash? p)) + (not tag?)) + (common-util/split-namespace-pages p) + [p]))))) + col) + (remove string/blank?) + (distinct)) + col (->> (distinct (concat col children-pages)) + (remove nil?)) + export-to-db-graph? @*export-to-db-graph?] + (map + (fn [item] + (let [macro? (and (map? item) + (= "macro" (:type item))) + tag? (if export-to-db-graph? + tag? + (or (contains? structured-tags item) tag?))] + (when-not macro? + (let [m (page-name->map item db true date-formatter {:class? tag?}) + result (cond->> m + (and db-based? tag? (not (:db/ident m))) + (db-class/build-new-class db)) + page-name (if db-based? (:block/title result) (:block/name result)) + id (get @*name->id page-name)] + (when (nil? id) + (swap! *name->id assoc page-name (:block/uuid result))) + ;; Changing a :block/uuid should be done cautiously here as it can break + ;; the identity of built-in concepts in db graphs + (if (and id + (or (when-let [ident (:db/ident result)] + (nil? (d/entity db ident))) + export-to-db-graph?)) + (assoc result :block/uuid id) + result))))) col))) + +(defn- with-page-refs-and-tags + [{:keys [title body tags refs marker priority] :as block} db date-formatter {:keys [structured-tags] + :or {structured-tags #{}}}] + (let [db-based? (and (entity-plus/db-based-graph? db) (not @*export-to-db-graph?)) + refs (->> (concat tags refs (when-not db-based? [marker priority])) + (remove string/blank?) + (distinct)) + *refs (atom refs) + *structured-tags (atom (set structured-tags))] + (walk/prewalk + (fn [form] + ;; skip custom queries + (when-not (and (vector? form) + (= (first form) "Custom") + (= (second form) "query")) + (when-let [page (get-page-reference form (get block :format :markdown))] + (when-let [page' (when-not (db-namespace-page? db-based? page) + page)] + (swap! *refs conj page'))) + (when-let [tag (get-tag form)] + (let [tag (text/page-ref-un-brackets! tag)] + (when-let [tag' (when-not (db-namespace-page? db-based? tag) + tag)] + (when (common-util/tag-valid? tag') + (swap! *refs conj tag') + (swap! *structured-tags conj tag'))))) + form)) + (concat title body)) + (swap! *refs #(remove string/blank? %)) + (let [*name->id (atom {}) + ref->map-options {:db-based? db-based? + :date-formatter date-formatter + :*name->id *name->id + :structured-tags (set @*structured-tags)} + refs (->> (ref->map db *refs ref->map-options) + (remove nil?) + (map (fn [ref] + (cond-> ref + (:block.temp/original-page-name ref) + (assoc :block.temp/original-page-name (:block.temp/original-page-name ref)))))) + tags (ref->map db *structured-tags (assoc ref->map-options :tag? true))] + (assoc block + :refs refs + :tags tags)))) + +(defn- with-block-refs + [{:keys [title body] :as block}] + (let [ref-blocks (extract-block-refs (concat title body)) + refs (distinct (concat (:refs block) ref-blocks))] + (assoc block :refs refs))) + +(defn block-keywordize + [block] + (update-keys + block + (fn [k] + (if (namespace k) + k + (keyword "block" k))))) + +(defn- sanity-blocks-data + "Clean up blocks data and add `block` ns to all keys" + [blocks] + (map (fn [block] + (if (map? block) + (block-keywordize (common-util/remove-nils-non-nested block)) + block)) + blocks)) + +(defn get-block-content + [utf8-content block format meta' block-pattern] + (let [content (if-let [end-pos (:end_pos meta')] + (utf8/substring utf8-content + (:start_pos meta') + end-pos) + (utf8/substring utf8-content + (:start_pos meta'))) + content (when content + (let [content (text/remove-level-spaces content format block-pattern)] + (if (or (:pre-block? block) + (= (get block :format :markdown) :org)) + content + (gp-mldoc/remove-indentation-spaces content (inc (:level block)) false))))] + (if (= format :org) + content + (gp-property/->new-properties content)))) + +(defn get-custom-id-or-new-id + [properties] + (or (when-let [custom-id (or (get-in properties [:properties :custom-id]) + (get-in properties [:properties :custom_id]) + (get-in properties [:properties :id]))] + ;; guard against non-string custom-ids + (when-let [custom-id (and (string? custom-id) (string/trim custom-id))] + (some-> custom-id parse-uuid))) + (d/squuid))) + +(defn get-page-refs-from-properties + [properties db date-formatter user-config] + (let [page-refs (get-page-ref-names-from-properties properties user-config)] + (map (fn [page] (page-name->map page db true date-formatter)) page-refs))) + +(defn- with-page-block-refs + [block db date-formatter opts] + (some-> block + (with-page-refs-and-tags db date-formatter opts) + with-block-refs + (update :refs (fn [col] (remove nil? col))))) + +(defn- macro->block + "macro: {:name \"\" arguments [\"\"]}" + [macro] + {:block/uuid (common-uuid/gen-uuid) + :block/type "macro" + :block/properties {:logseq.macro-name (:name macro) + :logseq.macro-arguments (:arguments macro)}}) + +(defn extract-macros-from-ast + [ast] + (let [*result (atom #{})] + (walk/postwalk + (fn [f] + (if (and (vector? f) (= (first f) "Macro")) + (do + (swap! *result conj (second f)) + nil) + f)) + ast) + (mapv macro->block @*result))) + +(defn with-pre-block-if-exists + [blocks body pre-block-properties encoded-content {:keys [db date-formatter user-config]}] + (let [first-block (first blocks) + first-block-start-pos (get-in first-block [:block/meta :start_pos]) + + ;; Add pre-block + blocks (if (or (> first-block-start-pos 0) + (empty? blocks)) + (cons + (merge + (let [content (utf8/substring encoded-content 0 first-block-start-pos) + {:keys [properties properties-order properties-text-values invalid-properties]} pre-block-properties + id (get-custom-id-or-new-id {:properties properties}) + property-refs (->> (get-page-refs-from-properties + properties db date-formatter + user-config) + (map :block/title)) + pre-block? (if (:heading properties) false true) + block {:block/uuid id + :block/title content + :block/level 1 + :block/properties properties + :block/properties-order (vec properties-order) + :block/properties-text-values properties-text-values + :block/invalid-properties invalid-properties + :block/pre-block? pre-block? + :block/macros (extract-macros-from-ast body) + :block.temp/ast-body body} + {:keys [tags refs]} + (with-page-block-refs {:body body :refs property-refs} db date-formatter {})] + (cond-> block + tags + (assoc :block/tags tags) + true + (assoc :block/refs (concat refs (:block-refs pre-block-properties))))) + (select-keys first-block [:block/format :block/page])) + blocks) + blocks)] + blocks)) + +(defn- with-heading-property + [properties markdown-heading? size] + (if markdown-heading? + (assoc properties :heading size) + properties)) + +(defn- construct-block + [block properties* timestamps body encoded-content format pos-meta {:keys [block-pattern db date-formatter remove-properties? remove-logbook? remove-deadline-scheduled? db-graph-mode? export-to-db-graph?]}] + (let [id (get-custom-id-or-new-id properties*) + block-tags (and export-to-db-graph? (get-in properties* [:properties :tags])) + ;; For export, remove tags from properties as they are being converted to classes + properties (if (seq block-tags) + (-> properties* + (update :properties #(dissoc % :tags)) + (update :properties-text-values #(dissoc % :tags)) + (update :properties-order (fn [v] (remove #(= :tags %) v))) + (update :page-refs (fn [v] (remove #(= "tags" %) v)))) + properties*) + ref-pages-in-properties (->> (:page-refs properties) + (remove string/blank?)) + block (second block) + unordered? (:unordered block) + markdown-heading? (and (:size block) (= :markdown format)) + block (if markdown-heading? + (assoc block + :level (if unordered? (:level block) 1)) + block) + block (cond-> + (-> (assoc block + :uuid id + :refs ref-pages-in-properties + :format format + :meta pos-meta) + (dissoc :size :unordered)) + (or (seq (:properties properties)) markdown-heading?) + (assoc :properties (with-heading-property (:properties properties) markdown-heading? (:size block)) + :properties-text-values (:properties-text-values properties) + :properties-order (vec (:properties-order properties))) + + (seq (:invalid-properties properties)) + (assoc :invalid-properties (:invalid-properties properties))) + block (if (get-in block [:properties :collapsed]) + (-> (assoc block :collapsed? true) + (update :properties (fn [m] (dissoc m :collapsed))) + (update :properties-text-values dissoc :collapsed) + (update :properties-order (fn [keys'] (vec (remove #{:collapsed} keys'))))) + block) + title (cond->> (get-block-content encoded-content block format pos-meta block-pattern) + remove-properties? + (gp-property/remove-properties (get block :format :markdown)) + remove-logbook? + (gp-property/remove-logbook) + remove-deadline-scheduled? + (gp-property/remove-deadline-scheduled)) + block (assoc block :block/title title) + block (if (seq timestamps) + (merge block (timestamps->scheduled-and-deadline timestamps)) + block) + db-based? (or db-graph-mode? export-to-db-graph?) + block (-> block + (assoc :body body) + (with-page-block-refs db date-formatter + (cond-> {} (seq block-tags) (assoc :structured-tags block-tags)))) + block (if db-based? block + (-> block + (update :tags (fn [tags] (map #(assoc % :block/format format) tags))) + (update :refs (fn [refs] (map #(if (map? %) (assoc % :block/format format) %) refs))))) + block (update block :refs concat (:block-refs properties)) + {:keys [created-at updated-at]} (:properties properties) + block (cond-> block + (and created-at (integer? created-at)) + (assoc :block/created-at created-at) + + (and updated-at (integer? updated-at)) + (assoc :block/updated-at updated-at))] + (dissoc block :title :body :anchor))) + +(defn fix-duplicate-id + [block] + (println "Logseq will assign a new id for block with content:" (pr-str (:block/title block))) + (-> block + (assoc :block/uuid (d/squuid)) + (update :block/properties dissoc :id) + (update :block/properties-text-values dissoc :id) + (update :block/properties-order #(vec (remove #{:id} %))) + (update :block/title (fn [c] + (let [replace-str (re-pattern + (str + "\n*\\s*" + (if (= :markdown (get block :block/format :markdown)) + (str "id" gp-property/colons " " (:block/uuid block)) + (str (gp-property/colons-org "id") " " (:block/uuid block)))))] + (string/replace-first c replace-str "")))))) + +(defn fix-block-id-if-duplicated! + "If the block exists in another page or the current page, we need to fix it" + [db page-name *extracted-block-ids block] + (let [block-page-name (:block/name (:block/page (d/entity db [:block/uuid (:block/uuid block)]))) + block (if (or (and block-page-name (not= block-page-name page-name)) + (contains? @*extracted-block-ids (:block/uuid block))) + (fix-duplicate-id block) + block)] + (swap! *extracted-block-ids conj (:block/uuid block)) + block)) + +(defn extract-blocks + "Extract headings from mldoc ast. Args: + * `ast`: mldoc ast. + * `content`: markdown or org-mode text. + * `format`: content's format, it could be either :markdown or :org-mode. + * `options`: Options are :user-config, :block-pattern, :date-formatter, :db and + * :db-graph-mode? : Set when a db graph in the frontend + * :export-to-db-graph? : Set when exporting to a db graph" + [ast content format {:keys [user-config db-graph-mode? export-to-db-graph?] :as options}] + {:pre [(seq ast) (string? content) (contains? #{:markdown :org} format)]} + (let [encoded-content (utf8/encode content) + all-blocks (vec (reverse ast)) + [blocks body pre-block-properties] + (loop [headings [] + ast-blocks (reverse ast) + block-idx 0 + timestamps {} + properties {} + body [] + prev-block-num 0] + (if (seq ast-blocks) + (let [[ast-block pos-meta] (first ast-blocks)] + (cond + (paragraph-timestamp-block? ast-block) + (let [ts (extract-timestamps ast-block) + timestamps' (merge timestamps ts)] + (recur headings (rest ast-blocks) (inc block-idx) timestamps' properties body (inc prev-block-num))) + + (gp-property/properties-ast? ast-block) + (let [properties (extract-properties (second ast-block) (assoc user-config :format format))] + (recur headings (rest ast-blocks) (inc block-idx) timestamps properties body (inc prev-block-num))) + + (heading-block? ast-block) + (let [cut-multiline? (and export-to-db-graph? (= prev-block-num 0)) + prev-blocks (map first (subvec all-blocks (max 0 (- block-idx prev-block-num)) block-idx)) + pos-meta' (if cut-multiline? + pos-meta + ;; fix start_pos + (assoc pos-meta :end_pos + (if (seq headings) + (get-in (last headings) [:meta :start_pos]) + nil))) + ;; Remove properties, deadline/scheduled and logbook text from title in db graphs + options' (assoc options + :remove-properties? + (and export-to-db-graph? (some gp-property/properties-ast? prev-blocks)) + :remove-logbook? + (and export-to-db-graph? (some #(= ["Drawer" "logbook"] (take 2 %)) prev-blocks)) + :remove-deadline-scheduled? + (and export-to-db-graph? (some #(seq (set/intersection (set (flatten %)) #{"Deadline" "Scheduled"})) prev-blocks))) + block' (construct-block ast-block properties timestamps body encoded-content format pos-meta' options') + block'' (cond + db-graph-mode? + block' + export-to-db-graph? + (assoc block' :block.temp/ast-blocks (cons ast-block body)) + :else + (assoc block' :macros (extract-macros-from-ast (cons ast-block body))))] + (recur (conj headings block'') (rest ast-blocks) (inc block-idx) {} {} [] 0)) + + :else + (recur headings (rest ast-blocks) (inc block-idx) timestamps properties (conj body ast-block) (inc prev-block-num)))) + [(-> (reverse headings) + sanity-blocks-data) + body + properties])) + result (with-pre-block-if-exists blocks body pre-block-properties encoded-content options)] + (map #(dissoc % :block/meta) result))) + +(defn with-parent-and-order + [page-id blocks] + (let [[blocks other-blocks] (split-with + (fn [b] + (not= "macro" (:block/type b))) + blocks) + result (loop [blocks (map (fn [block] (assoc block :block/level-spaces (:block/level block))) blocks) + parents [{:page/id page-id ; db id or a map {:block/name "xxx"} + :block/level 0 + :block/level-spaces 0}] + result []] + (if (empty? blocks) + (map #(dissoc % :block/level-spaces) result) + (let [[block & others] blocks + level-spaces (:block/level-spaces block) + {uuid' :block/uuid :block/keys [level parent] :as last-parent} (last parents) + parent-spaces (:block/level-spaces last-parent) + [blocks parents result] + (cond + (= level-spaces parent-spaces) ; sibling + (let [block (assoc block + :block/parent parent + :block/level level) + parents' (conj (vec (butlast parents)) block) + result' (conj result block)] + [others parents' result']) + + (> level-spaces parent-spaces) ; child + (let [parent (if uuid' [:block/uuid uuid'] (:page/id last-parent)) + block (cond-> + (assoc block + :block/parent parent) + ;; fix block levels with wrong order + ;; For example: + ;; - a + ;; - b + ;; What if the input indentation is two spaces instead of 4 spaces + (>= (- level-spaces parent-spaces) 1) + (assoc :block/level (inc level))) + parents' (conj parents block) + result' (conj result block)] + [others parents' result']) + + (< level-spaces parent-spaces) + (cond + (some #(= (:block/level-spaces %) (:block/level-spaces block)) parents) ; outdent + (let [parents' (vec (filter (fn [p] (<= (:block/level-spaces p) level-spaces)) parents)) + blocks (cons (assoc (first blocks) + :block/level (dec level)) + (rest blocks))] + [blocks parents' result]) + + :else + (let [[f r] (split-with (fn [p] (<= (:block/level-spaces p) level-spaces)) parents) + left (first r) + parent-id (if-let [block-id (:block/uuid (last f))] + [:block/uuid block-id] + page-id) + block (assoc block + :block/parent parent-id + :block/level (:block/level left) + :block/level-spaces (:block/level-spaces left)) + + parents' (->> (concat f [block]) vec) + result' (conj result block)] + [others parents' result'])))] + (recur blocks parents result)))) + result' (map (fn [block] (assoc block :block/order (db-order/gen-key))) result)] + (concat result' other-blocks))) diff --git a/deps/graph-parser/src/logseq/graph_parser/exporter.cljs b/deps/graph-parser/src/logseq/graph_parser/exporter.cljs new file mode 100644 index 0000000..74f818f --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/exporter.cljs @@ -0,0 +1,3169 @@ +(ns logseq.graph-parser.exporter + "Exports a file graph to DB graph. Used by the File to DB graph importer and + by nbb-logseq CLIs" + (:require ["path" :as node-path] + ["sanitize-filename" :as sanitizeFilename] + [borkdude.rewrite-edn :as rewrite] + [cljs-time.coerce :as tc] + [cljs.pprint] + [clojure.edn :as edn] + [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d] + [logseq.common.config :as common-config] + [logseq.common.path :as path] + [logseq.common.util :as common-util] + [logseq.common.util.block-ref :as block-ref] + [logseq.common.util.date-time :as date-time-util] + [logseq.common.util.macro :as macro-util] + [logseq.common.util.namespace :as ns-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.asset :as db-asset] + [logseq.db.frontend.class :as db-class] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.db-ident :as db-ident] + [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.db.frontend.property.type :as db-property-type] + [logseq.db.frontend.rules :as rules] + [logseq.db.sqlite.create-graph :as sqlite-create-graph] + [logseq.db.sqlite.util :as sqlite-util] + [logseq.graph-parser.block :as gp-block] + [logseq.graph-parser.extract :as extract] + [logseq.graph-parser.text :as text] + [logseq.graph-parser.utf8 :as utf8] + [promesa.core :as p])) + +(defn- add-missing-timestamps + "Add updated-at or created-at timestamps if they doesn't exist" + ([block] + (add-missing-timestamps block nil)) + ([block {:keys [file-created-at file-updated-at]}] + (let [updated-at (or file-updated-at (common-util/time-ms)) + created-at (or file-created-at updated-at) + block (cond-> block + (nil? (:block/updated-at block)) + (assoc :block/updated-at updated-at) + (nil? (:block/created-at block)) + (assoc :block/created-at created-at))] + block))) + +(defn- build-new-namespace-page [block] + (let [new-title (ns-util/get-last-part (:block/title block))] + (merge block + {;; DB graphs only have child name of namespace + :block/title new-title + :block/name (common-util/page-name-sanity-lc new-title)}))) + +(def template-file-property-names #{:template :template-including-parent}) + +(defn- get-template-name + [block] + (let [template-name (get-in block [:block/properties :template])] + (when (string? template-name) + (not-empty (string/trim template-name))))) + +(defn- template-including-parent? + [block] + (not= false (get-in block [:block/properties :template-including-parent]))) + +(defn- remove-template-property-lines + [title] + (if (string? title) + (->> (string/split-lines title) + (remove (fn [line] + (let [trimmed-line (string/triml line)] + (or (string/starts-with? trimmed-line "template::") + (string/starts-with? trimmed-line "template-including-parent::"))))) + (string/join "\n")) + title)) + +(defn- group-block-children-by-parent + [blocks] + (reduce (fn [result {parent :block/parent + child-uuid :block/uuid}] + (if (and (vector? parent) + (= :block/uuid (first parent))) + (update result (second parent) (fnil conj []) child-uuid) + result)) + {} + blocks)) + +(defn- get-block-subtree-uuids + [block-children root-uuid] + (loop [queue [root-uuid] + result []] + (if-let [current-uuid (first queue)] + (recur (into (vec (rest queue)) (get block-children current-uuid)) + (conj result current-uuid)) + result))) + +(defn- get-parent-uuid [parent] + (cond + (and (vector? parent) (= :block/uuid (first parent))) + (second parent) + (and (map? parent) (:block/uuid parent)) + (:block/uuid parent) + :else + nil)) + +(defn- handle-template-blocks + "Handles creating #Template blocks and their children and calculates + :preserve-empty-properties-uuids for use later" + [blocks*] + (let [include-parent-template-uuids (->> blocks* + (filter (fn [block] + (and (get-template-name block) + (template-including-parent? block)))) + (map :block/uuid) + set) + content-uuids-by-template (into {} + (map (fn [template-uuid] + [template-uuid (common-uuid/gen-uuid)])) + include-parent-template-uuids)] + (reduce + (fn [{:keys [blocks preserve-empty-properties-uuids]} block] + (if-let [template-name (get-template-name block)] + (let [block-children (group-block-children-by-parent blocks*) + parent-uuid (get-parent-uuid (:block/parent block)) + content-uuid (when parent-uuid + (get content-uuids-by-template parent-uuid)) + cleaned-block' (if content-uuid + (assoc block :block/parent [:block/uuid content-uuid]) + block) + source-preserve-empty-properties-uuids (set (get-block-subtree-uuids block-children (:block/uuid block))) + template-root-block (-> cleaned-block' + (assoc :block/title template-name) + (update :block/tags (fnil conj []) :logseq.class/Template) + (dissoc :block/properties)) + template-content-block (when (template-including-parent? block) + (-> (cond-> block + (seq (:block/properties block)) + (update :block/properties #(apply dissoc % template-file-property-names))) + (update :block/title remove-template-property-lines) + (assoc :block/uuid (get content-uuids-by-template (:block/uuid block)) + :block/parent [:block/uuid (:block/uuid block)] + :block/order (db-order/gen-key)) + (dissoc :db/id)))] + {:blocks (cond-> blocks + true + (conj template-root-block) + template-content-block + (conj template-content-block)) + :preserve-empty-properties-uuids (set/union preserve-empty-properties-uuids + source-preserve-empty-properties-uuids + (cond-> #{} + template-content-block + (conj (:block/uuid template-content-block))))}) + {:blocks (if-let [content-uuid (some->> (get-parent-uuid (:block/parent block)) + (get content-uuids-by-template))] + (conj blocks + (assoc block :block/parent [:block/uuid content-uuid])) + (conj blocks block)) + :preserve-empty-properties-uuids preserve-empty-properties-uuids})) + {:blocks [] + :preserve-empty-properties-uuids #{}} + blocks*))) + +(defn- get-page-uuid [page-names-to-uuids page-name ex-data'] + (or (get @page-names-to-uuids (some-> (if (string/includes? (str page-name) "#") + (string/lower-case (gp-block/sanitize-hashtag-name page-name)) + page-name) + string/trimr)) + (throw (ex-info (str "No uuid found for page name " (pr-str page-name)) + (merge ex-data' {:page-name page-name + :page-names (sort (keys @page-names-to-uuids))}))))) + +(defn- replace-namespace-with-parent [block page-names-to-uuids parent-k] + (if (and (:block/namespace block) + (not (:block/journal-day block))) + (-> (dissoc block :block/namespace) + (assoc parent-k + {:block/uuid (get-page-uuid page-names-to-uuids + (get-in block [:block/namespace :block/name]) + {:block block :block/namespace (:block/namespace block)})})) + block)) + +(defn- build-class-ident-name + [class-name] + (string/replace class-name "/" "___")) + +(defn- find-or-create-class + ([db class-name all-idents] + (find-or-create-class db class-name all-idents {})) + ([db class-name all-idents class-block] + (let [ident (keyword class-name)] + (if-let [db-ident (get @all-idents ident)] + {:db/ident db-ident} + (let [m + (if (:block/namespace class-block) + ;; Give namespaced tags a unique ident so they don't conflict with other tags + (-> (db-class/build-new-class db (merge {:block/title (build-class-ident-name class-name)} + (select-keys class-block [:block/tags]))) + (merge {:block/title class-name + :block/name (common-util/page-name-sanity-lc class-name)}) + (build-new-namespace-page)) + (db-class/build-new-class db + (assoc {:block/title class-name + :block/name (common-util/page-name-sanity-lc class-name)} + :block/tags (:block/tags class-block))))] + (swap! all-idents assoc ident (:db/ident m)) + (with-meta m {:new-class? true})))))) + +(defn- find-or-gen-class-uuid [page-names-to-uuids page-name db-ident & {:keys [temp-new-class?]}] + (or (if temp-new-class? + ;; First lookup by possible parent b/c page-names-to-uuids erroneously has the child name + ;; and full name. To not guess at the parent name we would need to save all properties-from-classes + (or (some #(when (string/ends-with? (key %) (str ns-util/parent-char page-name)) + (val %)) + @page-names-to-uuids) + (get @page-names-to-uuids page-name)) + (get @page-names-to-uuids page-name)) + (let [new-uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)] + (swap! page-names-to-uuids assoc page-name new-uuid) + new-uuid))) + +(defn- convert-tag? [tag-name {:keys [convert-all-tags? tag-classes]}] + (and (or convert-all-tags? + (contains? tag-classes tag-name) + ;; built-in tags that always convert + (contains? #{"card"} tag-name)) + ;; Disallow tags as it breaks :block/tags + (not (contains? #{"tags"} tag-name)))) + +(defn- find-existing-class + "Finds a class entity by unique name and parents and returns its :block/uuid if found. + db is searched because there is no in-memory index only for created classes by unique name" + [db {full-name :block/name block-ns :block/namespace}] + (if block-ns + (->> (d/q '[:find [?b ...] + :in $ ?name + :where [?b :block/uuid ?uuid] [?b :block/tags :logseq.class/Tag] [?b :block/name ?name]] + db + (ns-util/get-last-part full-name)) + (map #(d/entity db %)) + (some #(let [parent (->> (ldb/get-class-extends %) + (remove (fn [e] (= :logseq.class/Root (:db/ident e)))) + first) + parent-ancestors (when parent (ldb/get-page-parents parent)) + parents (cond-> (or parent-ancestors []) + parent (conj parent))] + (when (= full-name (string/join ns-util/namespace-char (map :block/name (conj parents %)))) + (:block/uuid %))))) + (first + (d/q '[:find [?uuid ...] + :in $ ?name + :where [?b :block/uuid ?uuid] [?b :block/tags :logseq.class/Tag] [?b :block/name ?name]] + db + full-name)))) + +(defn- convert-tag-to-class + "Converts a tag block with class or returns nil if this tag should be removed + because it has been moved" + [db tag-block {:keys [page-names-to-uuids classes-tx]} user-options all-idents] + (if-let [new-class (:block.temp/new-class tag-block)] + (let [class-m (find-or-create-class db new-class all-idents) + class-m' (merge class-m + {:block/uuid + (find-or-gen-class-uuid page-names-to-uuids (common-util/page-name-sanity-lc new-class) (:db/ident class-m) {:temp-new-class? true})})] + (when (:new-class? (meta class-m)) (swap! classes-tx conj class-m')) + (assert (:block/uuid class-m') "Class must have a :block/uuid") + [:block/uuid (:block/uuid class-m')]) + (when (convert-tag? (:block/name tag-block) user-options) + (let [existing-tag-uuid (find-existing-class db tag-block) + internal-tag-conflict? (contains? #{"tag" "property" "page" "journal" "asset"} (:block/name tag-block))] + (cond + ;; Don't overwrite internal tags + (and existing-tag-uuid (not internal-tag-conflict?)) + [:block/uuid existing-tag-uuid] + + :else + ;; Creates or updates page within same tx + (let [class-m (find-or-create-class db (:block/title tag-block) all-idents tag-block) + class-m' (-> (merge tag-block class-m + (if internal-tag-conflict? + {:block/uuid (common-uuid/gen-uuid :db-ident-block-uuid (:db/ident class-m))} + (when-not (:block/uuid tag-block) + (let [id (find-or-gen-class-uuid page-names-to-uuids (:block/name tag-block) (:db/ident class-m))] + {:block/uuid id})))) + ;; override with imported timestamps + (dissoc :block/created-at :block/updated-at) + (merge (add-missing-timestamps + (select-keys tag-block [:block/created-at :block/updated-at]))) + (replace-namespace-with-parent page-names-to-uuids :logseq.property.class/extends))] + (when (:new-class? (meta class-m)) (swap! classes-tx conj class-m')) + (assert (:block/uuid class-m') "Class must have a :block/uuid") + [:block/uuid (:block/uuid class-m')])))))) + +(defn- logseq-class-ident? + [k] + (and (qualified-keyword? k) (db-class/logseq-class? k))) + +(defn- convert-tags-to-classes + "Handles converting tags to classes and any post processing of it e.g. + cleaning :block/tags when a block is tagged with a namespace page" + [tags db per-file-state user-options all-idents] + ;; vec needed is needed so that tags are built in order + (let [tags' (vec (keep #(if (logseq-class-ident? %) + % + (convert-tag-to-class db % per-file-state user-options all-idents)) + tags))] + ;; Only associate leaf child tag with block as other tags are only used to define tag parents. + ;; This assumes that extract/extract returns :block/tags with their leaf child first and then its parents + (if-let [child-tag (and (some :block/namespace tags) (first tags'))] + [child-tag] + tags'))) + +(defn- update-page-tags + [block db user-options per-file-state all-idents] + (if (seq (:block/tags block)) + (let [page-tags (->> (:block/tags block) + (remove #(or (:block.temp/new-class %) + (convert-tag? (:block/name %) user-options) + ;; Ignore new class tags from extract e.g. :logseq.class/Journal + (logseq-class-ident? %))) + (map #(vector :block/uuid (get-page-uuid (:page-names-to-uuids per-file-state) (:block/name %) {:block %}))) + set)] + (cond-> block + true + (update :block/tags convert-tags-to-classes db per-file-state user-options all-idents) + true + (update :block/tags (fn [tags] + (cond-> (set tags) + ;; ensure pages at least have a Page + true + (conj :logseq.class/Page) + ;; Remove Page if another Page-like class is already present + (seq (set/intersection (disj (set tags) :logseq.class/Page) + db-class/page-classes)) + (disj :logseq.class/Page)))) + (seq page-tags) + (merge {:logseq.property/page-tags page-tags}))) + ;; ensure page at least have a Page tag + (assoc block :block/tags [:logseq.class/Page]))) + +(defn- add-uuid-to-page-map [m page-names-to-uuids] + (assoc m :block/uuid (get-page-uuid page-names-to-uuids (:block/name m) {:block m}))) + +(defn- content-without-tags-ignore-case + "Ignore case because tags in content can have any case and still have a valid ref" + [content tags] + (-> + (reduce + (fn [content tag] + (-> content + (common-util/replace-ignore-case (str "#" tag) "") + (common-util/replace-ignore-case (str "#" page-ref/left-brackets tag page-ref/right-brackets) ""))) + content + (sort > tags)) + (string/trim))) + +(defn- replace-namespaced-tags-with-id-refs + [content tags] + (->> + (reduce + (fn [content tag] + (if (ns-util/namespace-page? (:block/name tag)) + (let [id-ref (page-ref/->page-ref (:block/uuid tag))] + (-> content + (common-util/replace-ignore-case + (str "#" (:block/name tag)) + (str "#" id-ref)) + (common-util/replace-ignore-case + (str "#" (page-ref/->page-ref (:block/name tag))) + (str "#" id-ref)))) + content)) + content + (sort-by (comp count :block/name) > tags)) + (string/trim))) + +(defn- update-block-tags + [block db {:keys [remove-inline-tags?] :as user-options} per-file-state all-idents] + (let [block' + (if (seq (:block/tags block)) + (let [original-tags (remove #(or (:block.temp/new-class %) + ;; Filter out new classes already set on a block e.g. :logseq.class/Query + (logseq-class-ident? %)) + (:block/tags block)) + convert-tag?' #(convert-tag? (:block/name %) user-options)] + (cond-> block + remove-inline-tags? + (update :block/title + content-without-tags-ignore-case + (->> original-tags + (filter convert-tag?') + (map :block/title))) + (not remove-inline-tags?) + (update :block/title + replace-namespaced-tags-with-id-refs + (filter convert-tag?' original-tags)) + true + (update :block/title + db-content/replace-tags-with-id-refs + (->> original-tags + (remove convert-tag?') + (map #(add-uuid-to-page-map % (:page-names-to-uuids per-file-state))))) + true + (update :block/tags convert-tags-to-classes db per-file-state user-options all-idents))) + block)] + block')) + +(def ^:private built-in-status-markers + {"TODO" :logseq.property/status.todo + "LATER" :logseq.property/status.todo + "NOW" :logseq.property/status.doing + "DOING" :logseq.property/status.doing + "DONE" :logseq.property/status.done + "CANCELED" :logseq.property/status.canceled + "CANCELLED" :logseq.property/status.canceled}) + +(def ^:private custom-status-marker? + #{"WAIT" "WAITING" "IN-PROGRESS"}) + +(def ^:private status-markers + (set/union (set (keys built-in-status-markers)) custom-status-marker?)) + +(defn- marker-only-block-title + [block] + (when-let [title (:block/title block)] + (let [title' (string/trim title)] + (when (contains? status-markers title') + title')))) + +(defn- find-status-choice-by-content + [db marker] + (some #(when (= marker (db-property/closed-value-content %)) %) + (db-property/get-closed-property-values db :logseq.property/status))) + +(defn- build-status-choice-tx + [marker block-uuid] + (assoc (db-property-build/build-closed-value-block + block-uuid + :default + marker + {:db/ident :logseq.property/status} + {}) + :block/order (db-order/gen-key))) + +(defn- custom-marker-status-ref + [db marker {:keys [import-state custom-status-tx]}] + (or (get @(:custom-status-markers import-state) marker) + (let [status-ref (if-let [status (find-status-choice-by-content db marker)] + (:db/id status) + (let [block-uuid (common-uuid/gen-uuid)] + (swap! custom-status-tx conj (build-status-choice-tx marker block-uuid)) + [:block/uuid block-uuid]))] + (swap! (:custom-status-markers import-state) assoc marker status-ref) + status-ref))) + +(defn- update-block-marker + "If a block has a marker, convert it to a task object" + [block db {:keys [log-fn] :as options}] + (if-let [marker (or (:block/marker block) + (marker-only-block-title block))] + (let [status-ident (cond + (contains? built-in-status-markers marker) + (built-in-status-markers marker) + + (custom-status-marker? marker) + (custom-marker-status-ref db marker options) + + :else + (do + (log-fn :invalid-todo (str (pr-str marker) " is not a valid marker so setting it to TODO")) + :logseq.property/status.todo))] + (-> block + (assoc :logseq.property/status status-ident) + (update :block/title string/replace-first (re-pattern (str marker "\\s*")) "") + (update :block/tags (fnil conj []) :logseq.class/Task) + (dissoc :block/marker))) + block)) + +(defn- update-block-priority + [block {:keys [log-fn]}] + (if-let [priority (:block/priority block)] + (let [old-to-new {"A" :logseq.property/priority.high + "B" :logseq.property/priority.medium + "C" :logseq.property/priority.low} + priority-value (or (old-to-new priority) + (do + (log-fn :invalid-priority (str (pr-str priority) " is not a valid priority so setting it to low")) + :logseq.property/priority.low))] + (-> block + (assoc :logseq.property/priority priority-value) + (update :block/title string/replace-first (re-pattern (str "\\[#" priority "\\]" "\\s*")) "") + (dissoc :block/priority))) + block)) + +(defn- get-date-formatter + [config] + (or + (:journal/page-title-format config) + ;; for compatibility + (:date-formatter config) + "MMM do, yyyy")) + +(defn- journal-entity? + [entity] + (or (entity-util/journal? entity) + (identical? "journal" (:block/type entity)))) + +(defn- page-entity? + [entity] + (or (entity-util/page? entity) + (contains? #{"page" "journal"} (:block/type entity)))) + +(declare ->property-value-tx-m) + +(defn- deadline-scheduled-date-int + [value] + (if (map? value) (:date-int value) value)) + +(defn- deadline-scheduled-time-ms + [value] + (let [date-int (deadline-scheduled-date-int value) + date (date-time-util/int->local-date date-int) + {:keys [hour] timestamp-min :min} (when (map? value) (:time value))] + (when hour + (.setHours date hour (or timestamp-min 0) 0 0)) + (tc/to-long date))) + +(def ^:private repeat-recur-units + {"Minute" :logseq.property.repeat/recur-unit.minute + "Hour" :logseq.property.repeat/recur-unit.hour + "Day" :logseq.property.repeat/recur-unit.day + "Week" :logseq.property.repeat/recur-unit.week + "Month" :logseq.property.repeat/recur-unit.month + "Year" :logseq.property.repeat/recur-unit.year}) + +(def ^:private repeat-types + "Maps the mldoc repetition kind to the corresponding `:repeat-type` + closed-value db-ident. `:double-plus` matches the scheduler's fallback for + unknown values." + {"Dotted" :logseq.property.repeat/repeat-type.dotted-plus + "Plus" :logseq.property.repeat/repeat-type.plus + "DoublePlus" :logseq.property.repeat/repeat-type.double-plus}) + +(defn- repeat-properties + [temporal-property value] + (when-let [[kind unit frequency] (and (map? value) (:repetition value))] + (let [unit-ident (get repeat-recur-units (first unit)) + repeat-type-ident (get repeat-types (first kind) + :logseq.property.repeat/repeat-type.double-plus)] + (assert unit-ident (str "Unknown repeat unit: " (pr-str unit))) + {:logseq.property.repeat/repeated? true + :logseq.property.repeat/temporal-property temporal-property + :logseq.property.repeat/repeat-type repeat-type-ident + :logseq.property.repeat/recur-frequency frequency + :logseq.property.repeat/recur-unit unit-ident}))) + +(defn- build-repeat-properties + [block properties] + (let [pvalue-tx-m (->property-value-tx-m (dissoc block :block/page) properties (constantly nil) {}) + pvalues-tx (into (mapcat #(if (set? %) % [%]) (vals pvalue-tx-m)))] + {:block-properties (merge properties + (db-property-build/build-properties-with-ref-values pvalue-tx-m)) + :properties-tx pvalues-tx})) + +(def ^:private fallback-repeat-type-property + {:title "Repeating type" + :schema {:type :default + :public? false} + :closed-values [{:db-ident :logseq.property.repeat/repeat-type.dotted-plus + :value "Advance from completion" + :uuid (common-uuid/gen-uuid :db-ident-block-uuid :logseq.property.repeat/repeat-type.dotted-plus)} + {:db-ident :logseq.property.repeat/repeat-type.plus + :value "Advance from scheduled" + :uuid (common-uuid/gen-uuid :db-ident-block-uuid :logseq.property.repeat/repeat-type.plus)} + {:db-ident :logseq.property.repeat/repeat-type.double-plus + :value "Advance from scheduled, skip to future" + :uuid (common-uuid/gen-uuid :db-ident-block-uuid :logseq.property.repeat/repeat-type.double-plus)}] + :properties {:logseq.property/hide-empty-value true + :logseq.property/default-value :logseq.property.repeat/repeat-type.double-plus}}) + +(defn- missing-repeat-type-property-tx + [db repeat-property-values] + (when (and (:logseq.property.repeat/repeat-type repeat-property-values) + (nil? (d/entity db :logseq.property.repeat/repeat-type))) + (->> (or (not-empty (select-keys db-property/built-in-properties [:logseq.property.repeat/repeat-type])) + {:logseq.property.repeat/repeat-type fallback-repeat-type-property}) + sqlite-create-graph/build-properties + (map #(assoc % :logseq.property/built-in? true))))) + +(defn- find-or-create-deadline-scheduled-value + "Given a :block/scheduled or :block/deadline value, creates the datetime property value + and any optional journal tx associated with that value" + [value page-names-to-uuids user-config] + (let [date-int (deadline-scheduled-date-int value) + title (date-time-util/int->journal-title date-int (get-date-formatter user-config)) + existing-journal-page (some->> title + common-util/page-name-sanity-lc + (get @page-names-to-uuids) + (hash-map :block/uuid)) + journal-page (-> (or existing-journal-page + (let [page-m (sqlite-util/build-new-page title)] + (assoc page-m + :block/uuid (common-uuid/gen-uuid :journal-page-uuid date-int) + :block/journal-day date-int))) + (assoc :block/tags #{:logseq.class/Journal})) + time-long (deadline-scheduled-time-ms value)] + {:property-value time-long + :journal-tx (when-not existing-journal-page [journal-page])})) + +(defn- update-block-deadline-and-scheduled + "Converts :block/deadline and :block/scheduled to their new logseq properties." + [db block page-names-to-uuids {:keys [user-config]}] + (let [deadline (:block/deadline block) + scheduled (:block/scheduled block) + {deadline-value :property-value deadline-tx :journal-tx} + (when deadline + (find-or-create-deadline-scheduled-value deadline page-names-to-uuids user-config)) + {scheduled-value :property-value scheduled-tx :journal-tx} + (when scheduled + (find-or-create-deadline-scheduled-value scheduled page-names-to-uuids user-config)) + repeat-properties' (merge (repeat-properties :logseq.property/deadline deadline) + (repeat-properties :logseq.property/scheduled scheduled)) + {repeat-block-properties :block-properties repeat-properties-tx :properties-tx} + (build-repeat-properties block repeat-properties')] + {:block + (cond-> (dissoc block :block/deadline :block/scheduled :block/repeated?) + (some? deadline-value) + (assoc :logseq.property/deadline deadline-value) + (some? scheduled-value) + (assoc :logseq.property/scheduled scheduled-value) + (seq repeat-block-properties) + (merge repeat-block-properties)) + :properties-tx (distinct (concat (missing-repeat-type-property-tx db repeat-properties') + deadline-tx scheduled-tx repeat-properties-tx))})) + +(defn- text-with-refs? + "Detects if a property value has text with refs e.g. `#Logseq is #awesome` + instead of `#Logseq #awesome`. If so the property type is :default instead of :page" + [prop-vals val-text] + (let [replace-regex (re-pattern + ;; Regex removes all characters of a tag or page-ref + ;; so that only ref chars are left + (str "([#[])" + "(" + ;; Sorts ref names in descending order so that longer names + ;; come first. Order matters since (foo-bar|foo) correctly replaces + ;; "foo-bar" whereas (foo|foo-bar) does not + (->> prop-vals (sort >) (map common-util/escape-regex-chars) (string/join "|")) + ")")) + remaining-text (string/replace val-text replace-regex "$1") + non-ref-char (some #(if (or (string/blank? %) (#{"[" "]" "," "#"} %)) + false + %) + remaining-text)] + (some? non-ref-char))) + +(defn- create-property-ident [db all-idents property-name] + (let [db-ident (->> (db-property/create-user-property-ident-from-name (name property-name)) + ;; TODO: Detect new ident conflicts within same page + (db-ident/ensure-unique-db-ident db))] + (swap! all-idents assoc property-name db-ident))) + +(defn- get-ident [all-idents kw] + (if (and (qualified-keyword? kw) (db-property/logseq-property? kw)) + kw + (or (get all-idents kw) + (throw (ex-info (str "No ident found for " (pr-str kw)) {}))))) + +(defn- get-property-schema [property-schemas kw] + (or (get property-schemas kw) + (throw (ex-info (str "No property schema found for " (pr-str kw)) {})))) + +(defn- infer-property-schema-and-get-property-change + "Infers a property's schema from the given _user_ property value and adds new ones to + the property-schemas atom. If a property's :logseq.property/type changes, returns a map of + the schema attribute changed and how it changed e.g. `{:type {:from :default :to :url}}`" + [db prop-val prop prop-val-text refs {:keys [property-schemas all-idents]} macros] + ;; Explicitly fail an unexpected case rather than cause silent downstream failures + (when (and (coll? prop-val) (not (every? string? prop-val))) + (throw (ex-info (str "Import cannot infer schema of unknown property value " (pr-str prop-val)) + {:value prop-val :property prop}))) + (let [prop-type (cond (and (coll? prop-val) + (seq prop-val) + (set/subset? prop-val + (set (keep #(when (journal-entity? %) + (:block/title %)) refs)))) + :date + (and (coll? prop-val) (seq prop-val) (text-with-refs? prop-val prop-val-text)) + :default + (coll? prop-val) + :node + :else + (db-property-type/infer-property-type-from-value + (macro-util/expand-value-if-macro prop-val macros))) + prev-type (get-in @property-schemas [prop :logseq.property/type])] + ;; Create new property + (when-not (get @property-schemas prop) + (create-property-ident db all-idents prop) + (let [schema (cond-> {:logseq.property/type prop-type} + (#{:node :date} prop-type) + ;; Assume :many for now as detecting that detecting property values across files are consistent + ;; isn't possible yet + (assoc :db/cardinality :many))] + (swap! property-schemas assoc prop schema))) + (when (and prev-type (not= prev-type prop-type)) + {:type {:from prev-type :to prop-type}}))) + +(defn- get-file-pid + "Gets file graph property id given the db graph ident" + [db-ident] + ;; Map of unique cases where the db graph keyword name is different than the file graph id + (let [unique-file-ids {:logseq.property/order-list-type :logseq.order-list-type + :logseq.property/publishing-public? :public}] + (or (get unique-file-ids db-ident) + (keyword (name db-ident))))) + +(def built-in-property-file-to-db-idents + "Map of built-in property file ids to their db graph idents" + (->> (keys db-property/built-in-properties) + (map (fn [k] + [(get-file-pid k) k])) + (into {}))) + +(def all-built-in-property-file-ids + "All built-in property file ids as a set of keywords" + (-> built-in-property-file-to-db-idents keys set + ;; built-in-properties that map to new properties + (set/union #{:filters :query-table :query-properties :query-sort-by :query-sort-desc :hl-stamp :file :file-path}) + (set/union template-file-property-names))) + +;; TODO: Review whether this should be using :block/title instead of file graph ids +(def all-built-in-names + "All built-in properties and classes as a set of keywords" + (set/union all-built-in-property-file-ids + ;; This should list all new pages introduced with db graph + (set (->> db-class/built-in-classes + vals + (map :title) + (concat [common-config/library-page-name]) + (map #(-> % string/lower-case keyword)))))) + +(def file-built-in-property-names + "File-graph built-in property names that are supported. Expressed as set of keywords" + #{:alias :tags :background-color :heading + :query-table :query-properties :query-sort-by :query-sort-desc + :ls-type :hl-type :hl-color :hl-page :hl-stamp :hl-value :file :file-path + :logseq.order-list-type :icon :public :exclude-from-graph-view :filters + :template :template-including-parent}) + +(assert (set/subset? file-built-in-property-names all-built-in-property-file-ids) + "All file-built-in properties are used in db graph") + +(def query-table-special-keys + "Special keywords in previous query table" + {:page :block/title + :block :block/title + :tags :block/tags + :alias :block/alias + :created-at :block/created-at + :updated-at :block/updated-at}) + +(defn- translate-query-properties [prop-value all-idents options] + (let [property-classes (set (map keyword (:property-classes options)))] + (try + (->> (edn/read-string prop-value) + (keep #(cond (get query-table-special-keys %) + (get query-table-special-keys %) + (property-classes %) + :block/tags + (= :tags %) + ;; This could also be :logseq.property/page-tags + :block/tags + :else + (get-ident @all-idents %))) + distinct + vec) + (catch :default e + (js/console.error "Translating query properties failed with:" e) + [])))) + +(defn- translate-linked-ref-filters + [prop-value page-names-to-uuids] + (try + (let [filters (edn/read-string prop-value) + filter-by (group-by val filters) + includes (->> (filter-by true) + (map first) + (keep #(or (get @page-names-to-uuids %) + (js/console.error (str "No uuid found for linked reference filter page " (pr-str %))))) + (mapv #(vector :block/uuid %))) + excludes (->> (filter-by false) + (map first) + (keep #(or (get @page-names-to-uuids %) + (js/console.error (str "No uuid found for linked reference filter page " (pr-str %))))) + (mapv #(vector :block/uuid %)))] + (cond-> [] + (seq includes) + (conj [:logseq.property.linked-references/includes includes]) + (seq excludes) + (conj [:logseq.property.linked-references/excludes excludes]))) + (catch :default e + (js/console.error "Translating linked reference filters failed with: " e)))) + +(defn- update-built-in-property-values + [props page-names-to-uuids {:keys [ignored-properties all-idents]} {:block/keys [title name]} options] + (let [m + (->> props + (mapcat (fn [[prop prop-value]] + (if (#{:icon :file :file-path :hl-stamp} prop) + (do (swap! ignored-properties + conj + {:property prop :value prop-value :location (if name {:page name} {:block title})}) + nil) + (case prop + :query-properties + (when-let [cols (not-empty (translate-query-properties prop-value all-idents options))] + [[:logseq.property.table/ordered-columns cols]]) + :query-table + [[:logseq.property.view/type + (if prop-value :logseq.property.view/type.table :logseq.property.view/type.list)]] + :query-sort-by + [[:logseq.property.table/sorting + [{:id (or (query-table-special-keys (keyword prop-value)) + (get-ident @all-idents (keyword prop-value))) + :asc? true}]]] + ;; ignore to handle below + :query-sort-desc + nil + :filters + (translate-linked-ref-filters prop-value page-names-to-uuids) + :ls-type + [[:logseq.property/ls-type (keyword prop-value)]] + :hl-color + (let [color-text-idents + (->> (get-in db-property/built-in-properties [:logseq.property.pdf/hl-color :closed-values]) + (map (juxt :value :db-ident)) + (into {}))] + [[:logseq.property.pdf/hl-color (get color-text-idents prop-value)]]) + ;; else + [[(built-in-property-file-to-db-idents prop) prop-value]])))) + (into {}))] + (cond-> m + (and (contains? props :query-sort-desc) (:query-sort-by props)) + (update :logseq.property.table/sorting + (fn [v] + (assoc-in v [0 :asc?] (not (:query-sort-desc props)))))))) + +(defn- property-ref-name->page-name + [page-name user-config] + (let [[_ page-name' _journal-day] + (gp-block/convert-page-if-journal page-name (get-date-formatter user-config) {:export-to-db-graph? true})] + page-name')) + +(defn- update-page-or-date-values + "Converts :node or :date names to entity values" + [page-names-to-uuids property-values user-config] + (set (map #(vector :block/uuid + (get-page-uuid page-names-to-uuids (property-ref-name->page-name % user-config) {:original-name %})) + property-values))) + +(defn- handle-changed-property + "Handles a property's schema changing across blocks. Handling usually means + converting a property value to a new changed value or nil if the property is + to be ignored. Sometimes handling a property change results in changing a + property's previous usages instead of its current value e.g. when changing to + a :default type. This is done by adding an entry to upstream-properties and + building the additional tx to ensure this happens" + [val prop page-names-to-uuids properties-text-values + {:keys [ignored-properties property-schemas]} + {:keys [property-changes log-fn upstream-properties user-config]}] + (let [type-change (get-in property-changes [prop :type])] + (cond + ;; ignore :to as any property value gets stringified + (= :default (:from type-change)) + (or (get properties-text-values prop) (str val)) + + ;; treat it the same as a :node + (= {:from :node :to :date} type-change) + (update-page-or-date-values page-names-to-uuids val user-config) + + ;; Change to :node as dates can be pages but pages can't be dates + (= {:from :date :to :node} type-change) + (do + (swap! upstream-properties assoc prop {:schema {:logseq.property/type :node} + :from-type :date}) + (swap! property-schemas assoc-in [prop :logseq.property/type] :node) + (update-page-or-date-values page-names-to-uuids val user-config)) + + ;; Unlike the other property changes, this one changes all the previous values of a property + ;; in order to accommodate the change + (= :default (:to type-change)) + (if (get @upstream-properties prop) + ;; Ignore more than one property schema change per file to keep it simple + (do + (log-fn :prop-to-change-ignored {:property prop :val val :change type-change}) + (swap! ignored-properties conj {:property prop :value val :schema (get property-changes prop)}) + nil) + (do + (swap! upstream-properties assoc prop {:schema {:logseq.property/type :default} + :from-type (:from type-change)}) + (swap! property-schemas assoc prop {:logseq.property/type :default}) + (get properties-text-values prop))) + + :else + (do + (log-fn :prop-change-ignored {:property prop :val val :change type-change}) + (swap! ignored-properties conj {:property prop :value val :schema (get property-changes prop)}) + nil)))) + +(defn- update-user-property-values + [props page-names-to-uuids properties-text-values + {:keys [property-schemas] :as import-state} + {:keys [property-changes user-config] :as options}] + (->> props + (keep (fn [[prop val]] + (if (get-in property-changes [prop :type]) + (when-let [val' (handle-changed-property val prop page-names-to-uuids properties-text-values import-state options)] + [prop val']) + [prop + (if (set? val) + (if (= :default (:logseq.property/type (get @property-schemas prop))) + (get properties-text-values prop) + (update-page-or-date-values page-names-to-uuids val user-config)) + val)]))) + (into {}))) + +(defn- ->property-value-tx-m + "Given a new block and its properties, creates a map of properties which have values of property value tx. + Similar to sqlite.build/->property-value-tx-m" + [new-block properties get-schema-fn all-idents] + (->> properties + (keep (fn [[k v]] + (if-let [built-in-type (get-in db-property/built-in-properties [k :schema :type])] + (when (and (db-property-type/value-ref-property-types built-in-type) + ;; closed values are referenced by their :db/ident so no need to create values + (not (get-in db-property/built-in-properties [k :closed-values]))) + (let [property-map {:db/ident k + :logseq.property/type built-in-type}] + [property-map v])) + (when (db-property-type/value-ref-property-types (:logseq.property/type (get-schema-fn k))) + (let [property-map (merge + {:db/ident (get-ident all-idents k) + :original-property-id k} + (get-schema-fn k))] + [property-map v]))))) + (db-property-build/build-property-values-tx-m new-block))) + +(defn- build-properties-and-values + "For given block properties, builds property values tx and returns a map with + updated properties in :block-properties and any property values tx in :pvalues-tx" + [props _db page-names-to-uuids + {:block/keys [properties-text-values] :as block} + {:keys [import-state user-options] :as options}] + (let [{:keys [all-idents property-schemas]} import-state + get-ident' #(get-ident @all-idents %) + user-properties (apply dissoc props file-built-in-property-names)] + (when (seq user-properties) + (swap! (:block-properties-text-values import-state) + assoc + ;; For pages, valid uuid is in page-names-to-uuids, not in block + (if (:block/name block) + (get-page-uuid page-names-to-uuids ((some-fn ::original-name :block/name) block) {:block block}) + (:block/uuid block)) + properties-text-values)) + (let [props' (-> (update-built-in-property-values + (select-keys props file-built-in-property-names) + page-names-to-uuids + (select-keys import-state [:ignored-properties :all-idents]) + (select-keys block [:block/name :block/title]) + (select-keys user-options [:property-classes])) + (merge (update-user-property-values user-properties page-names-to-uuids properties-text-values import-state options))) + pvalue-tx-m (->property-value-tx-m block props' #(get-property-schema @property-schemas %) @all-idents) + block-properties (-> (merge props' (db-property-build/build-properties-with-ref-values pvalue-tx-m)) + (update-keys get-ident'))] + {:block-properties block-properties + :pvalues-tx (into (mapcat #(if (set? %) % [%]) (vals pvalue-tx-m)))}))) + +(def ignored-built-in-properties + "Ignore built-in properties that are already imported or not supported in db graphs" + ;; Already imported via a datascript attribute i.e. have :attribute on property config + [:tags :alias :collapsed + ;; Supported + :id + ;; Not supported as they have been ignored for a long time and cause invalid built-in pages + :now :later :doing :done :canceled :cancelled :in-progress :todo :wait :waiting + ;; deprecated in db graphs + :background-image :macros :logseq.query/nlp-date + :card-last-interval :card-repeats :card-last-reviewed :card-next-schedule + :card-ease-factor :card-last-score + :logseq.color :logseq.table.borders :logseq.table.stripes :logseq.table.max-width + :logseq.table.version :logseq.table.compact :logseq.table.headers :logseq.table.hover]) + +(defn- pre-update-properties + "Updates page and block properties before their property types are inferred" + [properties class-related-properties {:keys [preserve-empty-properties?]}] + (let [dissoced-props (concat ignored-built-in-properties + ;; TODO: Deal with these dissoced built-in properties + [:title :created-at :updated-at] + class-related-properties)] + (->> (apply dissoc properties dissoced-props) + (keep (fn [[prop val]] + (if (not (contains? file-built-in-property-names prop)) + ;; only update user properties + (if (string? val) + ;; Ignore blank values outside template-related blocks to preserve existing import behavior + (when (or preserve-empty-properties? + (not (string/blank? val))) + [prop + ;; handle float strings b/c graph-parser doesn't + (or (parse-double val) val)]) + [prop val]) + [prop val]))) + (into {})))) + +(defn- handle-page-and-block-properties + "Returns a map of :block with updated block and :properties-tx with any properties tx. + Handles modifying block properties, updating classes from property-classes + and removing any deprecated property related attributes. Before updating most + block properties, their property schemas are inferred as that can affect how + a property is updated. Only infers property schemas on user properties as + built-in ones must not change" + [{:block/keys [properties] :as block} db page-names-to-uuids refs + {{:keys [property-classes property-parent-classes]} :user-options + :keys [import-state macros] + :as options}] + (-> (if (seq properties) + (let [preserve-empty-properties? (contains? (or (:preserve-empty-property-block-uuids options) #{}) + (:block/uuid block)) + classes-from-properties (->> (select-keys properties property-classes) + (mapcat (fn [[_k v]] (if (coll? v) v [v]))) + distinct) + properties' (pre-update-properties properties + (into property-classes property-parent-classes) + {:preserve-empty-properties? preserve-empty-properties?}) + properties-to-infer (apply dissoc properties' file-built-in-property-names) + property-changes + (->> properties-to-infer + (keep (fn [[prop val]] + (when-let [property-change + (infer-property-schema-and-get-property-change db val prop (get (:block/properties-text-values block) prop) refs import-state macros)] + [prop property-change]))) + (into {})) + ;; _ (when (seq property-changes) (prn :prop-changes property-changes)) + options' (assoc options :property-changes property-changes) + {:keys [block-properties pvalues-tx]} + (build-properties-and-values properties' db page-names-to-uuids + (select-keys block [:block/properties-text-values :block/name :block/title :block/uuid ::original-name]) + options')] + {:block + (cond-> block + true + (merge block-properties) + (seq classes-from-properties) + ;; Add a map of {:block.temp/new-class TAG} to be processed later + (update :block/tags + (fn [tags] + (let [tags' (if (sequential? tags) tags (set tags))] + (into tags' (map #(hash-map :block.temp/new-class %) classes-from-properties)))))) + :properties-tx pvalues-tx}) + {:block block :properties-tx []}) + (update :block dissoc :block/properties :block/properties-text-values :block/properties-order :block/invalid-properties))) + +(defn- handle-page-properties + "Adds page properties including special handling for :logseq.property.class/extends or :block/parent" + [{:block/keys [properties] :as block*} db {:keys [page-names-to-uuids classes-tx]} refs + {:keys [user-options log-fn import-state] :as options}] + (let [{:keys [block properties-tx]} (handle-page-and-block-properties block* db page-names-to-uuids refs options) + block' + (if-let [parent-classes-from-properties (->> (select-keys properties (:property-parent-classes user-options)) + (mapcat (fn [[_k v]] (if (coll? v) v [v]))) + distinct + seq)] + (let [_ (swap! (:classes-from-property-parents import-state) conj (:block/title block*)) + class-m (find-or-create-class db ((some-fn ::original-title :block/title) block) (:all-idents import-state) block) + class-m' (-> block + (merge class-m) + (dissoc :block/namespace) + (assoc :logseq.property.class/extends + (let [new-class (first parent-classes-from-properties) + class-m (find-or-create-class db new-class (:all-idents import-state)) + class-m' (merge class-m + {:block/uuid (find-or-gen-class-uuid page-names-to-uuids (common-util/page-name-sanity-lc new-class) (:db/ident class-m))})] + (when (> (count parent-classes-from-properties) 1) + (log-fn :skipped-parent-classes "Only one parent class is allowed so skipped ones after the first one" :classes parent-classes-from-properties)) + (when (:new-class? (meta class-m)) (swap! classes-tx conj class-m')) + [:block/uuid (:block/uuid class-m')])))] + class-m') + (replace-namespace-with-parent block page-names-to-uuids :block/parent))] + {:block block' :properties-tx properties-tx})) + +(defn- pretty-print-dissoc + "Remove list of keys from a given map string while preserving whitespace" + [s dissoc-keys] + (-> (reduce rewrite/dissoc + (rewrite/parse-string s) + dissoc-keys) + str)) + +(defn- migrate-advanced-query-string [query-str] + (try + (pretty-print-dissoc query-str [:title :group-by-page? :collapsed?]) + (catch :default _e + ;; rewrite/parse-string can fail on some queries in Advanced Queries in docs graph + (js/console.error "Failed to parse advanced query string. Falling back to full query string: " (pr-str query-str)) + (if-let [query-map (not-empty (common-util/safe-read-map-string query-str))] + (pr-str (dissoc query-map :title :group-by-page? :collapsed?)) + query-str)))) + +(declare extract-block-list ast->text) +(defn- extract-block-list-item + [{:keys [content items number checkbox]}] + (let [content* (mapcat #(ast->text % {}) content) + number* (if number + (str number ". ") + "* ") + checkbox* (if (some? checkbox) + (if (boolean checkbox) + "[X]" "[ ]") + "") + items* (extract-block-list items :in-list? true)] + (concat [number* checkbox* " "] + content* + ["\n"] + items* + (when (seq items*) ["\n"])))) + +(defn- extract-block-list + [l & {:keys [in-list?]}] + (vec (concat (when-not in-list? ["\n"]) + (mapcat extract-block-list-item l) + (when (and (pos? (count l)) + (not in-list?)) + ["\n\n"])))) + +(defn- ast->text + "Given an ast block, convert it to text for use as a block title. This is a + slimmer version of handler.export.text/export-blocks-as-markdown" + [ast-block {:keys [log-fn] + :or {log-fn prn}}] + (let [extract + (fn extract [node] + (let [extract-emphasis + (fn extract-emphasis [node] + (let [[[type'] coll'] node] + (case type' + "Bold" + (vec (concat ["**"] (mapcat extract coll') ["**"])) + "Italic" + (vec (concat ["*"] (mapcat extract coll') ["*"])) + "Strike_through" + (vec (concat ["~~"] (mapcat extract coll') ["~~"])) + "Highlight" + (vec (concat ["^^"] (mapcat extract coll') ["^^"])) + (throw (ex-info (str "Failed to wrap Emphasis AST block of type " (pr-str type')) {})))))] + (cond + (and (vector? node) (#{"Inline_Html" "Plain" "Inline_Hiccup"} (first node))) + [(second node)] + (and (vector? node) (#{"Break_Line" "Hard_Break_Line"} (first node))) + ["\n"] + (and (vector? node) (= (first node) "Link")) + [(:full_text (second node))] + (and (vector? node) (#{"Paragraph" "Quote"} (first node))) + (mapcat extract (second node)) + (and (vector? node) (= (first node) "Tag")) + (into ["#"] (mapcat extract (second node))) + (and (vector? node) (= (first node) "Emphasis")) + (extract-emphasis (second node)) + (and (vector? node) (= ["Custom" "query"] (take 2 node))) + [(get node 4)] + (and (vector? node) (= (first node) "Code")) + ["`" (second node) "`"] + (and (vector? node) (= (first node) "Email")) + (let [{:keys [local_part domain]} (second node)] + [(str "<" local_part "@" domain ">")]) + (and (vector? node) (= "Macro" (first node)) (= "query" (:name (second node)))) + (:arguments (second node)) + (and (vector? node) (= (first node) "Example")) + (second node) + (and (vector? node) (= (first node) "Latex_Fragment")) + (let [[type' content] (second node) + wrapper (case type' "Inline" "$" "Displayed" "$$")] + [wrapper content wrapper]) + (and (vector? node) (= (first node) "Src")) + [(str "\n```" (when-let [lang (:language (second node))] lang)) + (apply str "\n" (:lines (second node))) + "```"] + (and (vector? node) (= (first node) "Displayed_Math")) + ["$$" (second node) "$$"] + (and (vector? node) (= (first node) "List")) + (extract-block-list (second node)) + :else + (do + (log-fn :ast->text "Ignored ast node" :node node) + []))))] + (->> (extract ast-block) + ;; ((fn [x] (prn :X x) x)) + (apply str) + string/trim))) + +;; {:url ["Complex" {:protocol "zotero", :link "select/library/items/6VCW9QFJ"}], :label [["Plain" "Dechow and Struppa - 2015 - Intertwingled.pdf"]], :full_text "[Dechow and Struppa - 2015 - Intertwingled.pdf](zotero://select/library/items/6VCW9QFJ)", :metadata ""} +(defn- get-zotero-local-pdf-path + [config m] + (when (= "zotero" (:protocol (second (:url m)))) + (let [link (:link (second (:url m))) + label (second (first (:label m))) + id (last (string/split link #"/"))] + (when (and link id label) + (when-let [zotero-data-dir (get-in config [:zotero/settings-v2 "default" :zotero-data-directory])] + {:link (str "zotero://" link) + :path (path/path-join zotero-data-dir "storage" id label) + :base label}))))) + +(defn- file-link-map->url + [m] + (when (and (map? m) (= "file" (:protocol m)) (string? (:link m))) + (str "file://" (:link m)))) + +(defn- file-url->path + [file-url] + (try + (js/decodeURI (path/url-to-path file-url)) + (catch :default _ + (path/url-to-path file-url)))) + +(defn- walk-ast-blocks + "Walks each ast block in order to its full depth. Saves multiple ast types for + use in build-block-tx. This walk is only done once for perf reasons" + [config ast-blocks] + (let [results (atom {:simple-queries [] + :cards [] + :asset-links [] + :embeds [] + :zotero-imported-files {} + :zotero-linked-files []})] + (walk/prewalk + (fn [x] + (cond + (and (vector? x) + (= "Link" (first x)) + (let [path-or-map (second (:url (second x)))] + (cond + (string? path-or-map) + (or (common-config/local-relative-asset? path-or-map) + (string/ends-with? path-or-map ".pdf")) + (and (map? path-or-map) (= "zotero" (:protocol path-or-map)) (string? (:link path-or-map))) + (:link (get-zotero-local-pdf-path config (second x))) + (file-link-map->url path-or-map) + (= "pdf" (path/file-ext (file-link-map->url path-or-map))) + :else + nil))) + (swap! results update :asset-links conj x) + (and (vector? x) + (= "Macro" (first x)) + (= "embed" (:name (second x)))) + (swap! results update :embeds conj x) + (and (vector? x) + (= "Macro" (first x)) + (= "zotero-imported-file" (:name (second x)))) + (let [[item-key filename] (:arguments (second x))] + (when (and item-key filename) + (swap! results update :zotero-imported-files assoc item-key (common-util/safe-read-string filename)))) + (and (vector? x) + (= "Macro" (first x)) + (= "zotero-linked-file" (:name (second x)))) + (let [[relative-path] (:arguments (second x)) + parsed-path (common-util/safe-read-string relative-path)] + (when (string? parsed-path) + (swap! results update :zotero-linked-files conj parsed-path))) + (and (vector? x) + (= "Macro" (first x)) + (= "cards" (:name (second x)))) + (swap! results update :cards conj x) + (and (vector? x) + (= "Macro" (first x)) + (= "query" (:name (second x)))) + (swap! results update :simple-queries conj x)) + x) + ast-blocks) + @results)) + +(defn- handle-queries + "If a block contains a simple or advanced queries, converts block to a #Query node. If a block + contains a cards query converts to a #Cards node" + [{:block/keys [title] :as block} db page-names-to-uuids walked-ast-blocks options] + (if-let [query (some-> (first (:simple-queries walked-ast-blocks)) + (ast->text (select-keys options [:log-fn])) + string/trim)] + (let [props {:logseq.property/query query} + {:keys [block-properties pvalues-tx]} + (build-properties-and-values props db page-names-to-uuids + (select-keys block [:block/properties-text-values :block/name :block/title :block/uuid]) + options) + block' + (-> (update block :block/tags (fnil conj []) :logseq.class/Query) + (merge block-properties + {:block/title (string/trim (string/replace-first title #"\{\{query(.*)\}\}" ""))}))] + {:block block' + :pvalues-tx pvalues-tx}) + (if-let [advanced-query (some-> (first (filter #(= ["Custom" "query"] (take 2 %)) (:block.temp/ast-blocks block))) + (ast->text (select-keys options [:log-fn])) + string/trim)] + (let [props {:logseq.property/query (migrate-advanced-query-string advanced-query)} + {:keys [block-properties pvalues-tx]} + (build-properties-and-values props db page-names-to-uuids + (select-keys block [:block/properties-text-values :block/name :block/title :block/uuid]) + options) + pvalues-tx' + (concat pvalues-tx [{:block/uuid (second (:logseq.property/query block-properties)) + :logseq.property.code/lang "clojure" + :logseq.property.node/display-type :code}]) + block' + (let [query-map (common-util/safe-read-map-string advanced-query)] + (cond-> (update block :block/tags (fnil conj []) :logseq.class/Query) + true + (merge block-properties) + true + (assoc :block/title + (or (when-let [title' (:title query-map)] + (if (string? title') title' (pr-str title'))) + ;; Put all non-query content in title for now + (string/trim (string/replace-first title #"(?s)#\+BEGIN_QUERY(.*)#\+END_QUERY" "")))) + (:collapsed? query-map) + (assoc :block/collapsed? true)))] + {:block block' + :pvalues-tx pvalues-tx'}) + (if-let [cards-macro (first (:cards walked-ast-blocks))] + (if-let [query (some-> cards-macro second :arguments first string/trim not-empty)] + (let [props {:logseq.property/query query} + {:keys [block-properties pvalues-tx]} + (build-properties-and-values props db page-names-to-uuids + (select-keys block [:block/properties-text-values :block/name :block/title :block/uuid]) + options) + block' + (-> (update block :block/tags (fnil conj []) :logseq.class/Cards) + (merge block-properties + {:block/title (string/trim (string/replace-first title #"\{\{cards(.*)\}\}" ""))}))] + {:block block' + :pvalues-tx pvalues-tx}) + {:block block}) + {:block block})))) + +(defn- handle-block-properties + "Does everything page properties does and updates a couple of block specific attributes" + [block* db page-names-to-uuids refs walked-ast-blocks + {{:keys [property-classes]} :user-options :as options}] + (let [{:keys [block properties-tx]} (handle-page-and-block-properties block* db page-names-to-uuids refs options) + {block' :block :keys [pvalues-tx]} (handle-queries block db page-names-to-uuids walked-ast-blocks options)] + {:block + (cond-> block' + (and (seq property-classes) (seq (:block/refs block*))) + ;; remove unused, nonexistent property page + (update :block/refs (fn [refs] (remove #(property-classes (keyword (:block/name %))) refs)))) + :properties-tx (concat properties-tx (when pvalues-tx pvalues-tx))})) + +(defn- convert-block-refs-to-page-refs + "Converts ((uuid)) block-ref syntax to [[uuid]] page-ref syntax in a title string. + DB graphs use [[uuid]] for all node references." + [title] + (string/replace title block-ref/block-ref-re + (fn [[_ id]] (page-ref/->page-ref id)))) + +(defn- update-block-refs + "Updates the attributes of a block ref as this is where a new page is defined. Also + updates block content effected by refs" + [block page-names-to-uuids] + (if (seq (:block/refs block)) + (cond-> block + true + (update + :block/refs + (fn [refs] + (mapv (fn [ref] + ;; Only keep :block/uuid as we don't want to re-transact page refs + (if (map? ref) + ;; a new page's uuid can change across blocks so rely on consistent one from pages-tx + (if-let [existing-uuid (some->> (:block/name ref) (get @page-names-to-uuids))] + [:block/uuid existing-uuid] + [:block/uuid (:block/uuid ref)]) + ref)) + refs))) + (:block/title block) + (assoc :block/title + (let [refs (->> (:block/refs block) + (remove #(or (and (vector? %) (= :block/uuid (first %))) + ;; ignore deadline related refs that don't affect content + (and (keyword? %) (db-malli-schema/internal-ident? %)))) + (map #(add-uuid-to-page-map % page-names-to-uuids)))] + (-> (db-content/title-ref->id-ref (:block/title block) refs {:replace-tag? false}) + convert-block-refs-to-page-refs)))) + block)) + +(defn- fix-pre-block-references + "Point pre-block children to parents since pre blocks don't exist in db graphs" + [{:block/keys [parent] :as block} pre-blocks page-names-to-uuids] + (cond-> block + (and (vector? parent) (contains? pre-blocks (second parent))) + (assoc :block/parent [:block/uuid (get-page-uuid page-names-to-uuids (second (:block/page block)) {:block block :block/page (:block/page block)})]))) + +(defn- fix-block-name-lookup-ref + "Some graph-parser attributes return :block/name as a lookup ref. This fixes + those to use uuids since block/name is not unique for db graphs" + [block page-names-to-uuids] + (cond-> block + (= :block/name (first (:block/page block))) + (assoc :block/page [:block/uuid (get-page-uuid page-names-to-uuids (second (:block/page block)) {:block block :block/page (:block/page block)})]) + (:block/name (:block/parent block)) + (assoc :block/parent {:block/uuid (get-page-uuid page-names-to-uuids (:block/name (:block/parent block)) {:block block :block/parent (:block/parent block)})}))) + +(defn- pdf-file? + [path] + (= "pdf" (some-> path path/file-ext string/lower-case))) + +(defn asset-path->name + "Given an asset's relative or full path, create a unique name for identifying an asset. + Must handle to paths as ../assets/*, assets/* and with subdirectories" + [path] + (when (string? path) + (or (re-find #"assets/.*$" path) + ;; pdf outside logseq graphs + (when (pdf-file? path) + path)))) + +(defn- update-asset-links-in-block-title [block-title asset-name-to-uuids ignored-assets] + (reduce (fn [acc [asset-name asset-uuid]] + (let [new-title (string/replace acc + (re-pattern (str "!?\\[[^\\]]*?\\]\\([^\\)]*?" + (common-util/escape-regex-chars asset-name) + "\\)(\\{[^}]*\\})?")) + (page-ref/->page-ref asset-uuid))] + (when (string/includes? new-title asset-name) + (swap! ignored-assets conj + {:reason "Some asset links were not updated to block references" + :path asset-name + :location {:block new-title}})) + new-title)) + block-title + asset-name-to-uuids)) + +(defn find-annotation-children-blocks + "Given a list of blocks and a set of parent uuids, return all blocks that are + descendants via :block/parent of given parent uuids" + [blocks parent-uuids] + (let [get-descendant-uuids + (fn get-descendant-uuids [acc-uuids seen] + (let [new-blocks (filter #(contains? acc-uuids (second (:block/parent %))) blocks) + new-uuids (set (map :block/uuid new-blocks)) + unseen (set/difference new-uuids seen)] + (if (empty? unseen) + seen + (recur unseen (set/union seen unseen))))) + parent-and-descendant-uuids (get-descendant-uuids parent-uuids parent-uuids) + only-descendants (set/difference parent-and-descendant-uuids parent-uuids)] + (filter #(contains? only-descendants (:block/uuid %)) blocks))) + +(defn- build-annotation-block + [m color-text-idents parent-asset image-asset-name-to-uuids md-blocks {:keys [log-fn] :or {log-fn prn}}] + (let [user-attributes + {:logseq.property.pdf/hl-color (get color-text-idents (get-in m [:properties :color])) + :logseq.property.pdf/hl-page (:page m) + :block/title (get-in m [:content :text])} + _ (when (some (comp nil? val) user-attributes) + (log-fn :missing-annotation-attributes "Annotation is missing some attributes so set reasonable defaults for them" + {:annotation user-attributes :asset (:block/title parent-asset)})) + asset-image-uuid (some (fn [[asset-name image-uuid]] + (when (string/includes? asset-name + (str (:id m) + (when (get-in m [:content :image]) + (str "_" (get-in m [:content :image]))))) + image-uuid)) + image-asset-name-to-uuids) + md-block (get md-blocks (:id m)) + annotation (merge + ;; Reasonable defaults for user attributes + {:logseq.property.pdf/hl-color :logseq.property/color.yellow + :logseq.property.pdf/hl-page 1 + :block/title ""} + (common-util/remove-nils-non-nested user-attributes) + {:block/uuid (:id m) + :block/order (db-order/gen-key) + :logseq.property/ls-type :annotation + :logseq.property.pdf/hl-value m + :logseq.property/asset [:block/uuid (:block/uuid parent-asset)] + :block/tags [:logseq.class/Pdf-annotation] + :block/parent [:block/uuid (:block/uuid parent-asset)] + :block/page :logseq.class/Asset} + (when asset-image-uuid + {:logseq.property.pdf/hl-image [:block/uuid asset-image-uuid] + :logseq.property.pdf/hl-type :area}) + (when-some [title (:block/title md-block)] + {:block/title title}))] + (sqlite-util/block-with-timestamps annotation))) + +(defn- build-pdf-annotations-tx* + "Creates annotations for a pdf asset given the asset's edn map and parsed markdown file" + [asset-edn-map parsed-md parent-asset image-asset-name-to-uuids opts] + (let [color-text-idents + (->> (get-in db-property/built-in-properties [:logseq.property.pdf/hl-color :closed-values]) + (map (juxt :value :db-ident)) + (into {})) + md-blocks + (->> parsed-md + :blocks + ;; Currently we can only import text of any md annotation blocks. No tags or properties + (map #(vector (:block/uuid %) + (select-keys % [:block/title :block/order :block/parent :block/uuid]))) + (into {})) + annotation-blocks + (mapv #(build-annotation-block % color-text-idents parent-asset image-asset-name-to-uuids md-blocks opts) + (get-in asset-edn-map [:edn-content :highlights])) + md-children-blocks* + (find-annotation-children-blocks (vals md-blocks) (set (map :id (get-in asset-edn-map [:edn-content :highlights])))) + md-children-blocks (keep #(sqlite-util/block-with-timestamps (merge % {:block/page :logseq.class/Asset})) + md-children-blocks*)] + (into annotation-blocks md-children-blocks))) + +(defn- build-new-asset [asset-data] + (merge (sqlite-util/block-with-timestamps + {:block/order (db-order/gen-key) + :block/page :logseq.class/Asset + :block/parent :logseq.class/Asset}) + {:block/tags [:logseq.class/Asset] + :logseq.property.asset/type (:type asset-data) + :logseq.property.asset/checksum (:checksum asset-data) + :logseq.property.asset/size (:size asset-data)} + (when-let [external-url (:external-url asset-data)] + (cond-> {:logseq.property.asset/external-url external-url} + (:external-file-name asset-data) + (assoc :logseq.property.asset/external-file-name (:external-file-name asset-data)))))) + +(defn- get-asset-block-id + [assets path] + (get-in @assets [path :asset-id])) + +(defn- build-annotation-images + "Builds tx for annotation images and provides a map for mapping image asset names + to their new uuids" + [parent-asset-paths assets {:keys [notify-user]}] + (let [image-dirs (set (map #(string/replace-first % #"(?i)\.pdf$" "") parent-asset-paths)) + image-paths (filter #(contains? image-dirs (node-path/dirname %)) (keys @assets)) + txs (keep #(let [asset-id (get-asset-block-id assets %)] + (if-not asset-id + (notify-user {:msg (str "Skipped creating asset " (pr-str %) " because it has no asset id") + :level :error}) + (let [new-asset (merge (build-new-asset (get @assets %)) + {:block/title "pdf area highlight" + :block/uuid asset-id})] + (swap! assets assoc-in [% :asset-created?] true) + new-asset))) + image-paths)] + {:txs txs + :image-asset-name-to-uuids + (->> (map (fn [image-path tx] + [(node-path/basename image-path) (:block/uuid tx)]) image-paths txs) + (into {}))})) + +;; Reference same default class in cljs + nbb without needing .cljc +(def sanitizeFilename' (if (find-ns 'nbb.core) (aget sanitizeFilename "default") sanitizeFilename)) + +(defn safe-sanitize-file-name + "Sanitizes filenames for pdf assets" + [s] + (sanitizeFilename' (str s))) + +(defn- pdf-annotation-edn-path + [parent-asset-path] + (path/path-join + common-config/local-assets-dir + (safe-sanitize-file-name + (node-path/basename (string/replace-first parent-asset-path #"(?i)\.pdf$" ".edn"))))) + +(defn- pdf-annotation-md-name + [parent-asset-path] + (str "hls__" (safe-sanitize-file-name + (node-path/basename (string/replace-first parent-asset-path #"(?i)\.pdf$" ".md"))))) + +(defn- build-pdf-annotations-tx + "Builds tx for pdf annotations when a pdf has an annotations EDN file under assets/" + [parent-asset-paths assets parent-asset pdf-annotation-pages opts] + (let [parent-asset-paths (if (sequential? parent-asset-paths) + (distinct parent-asset-paths) + [parent-asset-paths]) + asset-edn-entry (some (fn [parent-asset-path] + (let [asset-edn-path (pdf-annotation-edn-path parent-asset-path)] + (when-let [asset-edn-map (get @assets asset-edn-path)] + {:asset-edn-map asset-edn-map + :asset-md-name (pdf-annotation-md-name parent-asset-path)}))) + parent-asset-paths)] + (when-let [asset-edn-map (:asset-edn-map asset-edn-entry)] + (let [{:keys [txs image-asset-name-to-uuids]} (build-annotation-images parent-asset-paths assets opts)] + (concat txs + (build-pdf-annotations-tx* asset-edn-map (get @pdf-annotation-pages (:asset-md-name asset-edn-entry)) parent-asset image-asset-name-to-uuids opts)))))) + +(defn- resolve-asset-data + [asset-link user-config linked-files linked-base-dir zotero-imported-files] + (let [link-map (second asset-link) + path* (-> link-map :url second) + file-url (file-link-map->url path*) + zotero-path-data (when (map? path*) + (get-zotero-local-pdf-path user-config link-map)) + zotero-asset? (some? zotero-path-data) + linked-relative (when (and linked-files zotero-asset? (seq @linked-files)) + (let [value (first @linked-files)] + (swap! linked-files rest) + (string/replace-first value "attachments:" ""))) + linked-base (when (string? linked-relative) + (node-path/basename linked-relative)) + linked-path (when (and (string? linked-relative) + (string? linked-base-dir) + (not (string/blank? linked-base-dir))) + (node-path/join linked-base-dir linked-relative)) + {:keys [path link base]} (cond + linked-path {:path linked-path + :link (:link zotero-path-data) + :base linked-base} + zotero-asset? zotero-path-data + file-url {:path (file-url->path file-url) + :link file-url + :base (path/filename file-url)} + :else {:path path*}) + asset-name (cond + linked-path base + zotero-asset? (or (get zotero-imported-files (last (string/split link #"/"))) base) + :else (some-> path asset-path->name)) + path (cond + linked-path path + (and zotero-asset? asset-name) (string/replace path #"[^/]+$" asset-name) + :else path) + asset-link-or-name (or link asset-name) + asset-path (when zotero-asset? + (if linked-path + (str "zotero-link://" linked-relative) + (str "zotero-path://" (string/join ns-util/namespace-char [(last (string/split link #"/")) asset-name]))))] + {:asset-link-or-name asset-link-or-name + :asset-name asset-name + :asset-path asset-path + :path path + :zotero-asset? zotero-asset?})) + +(defn- ensure-asset-data! + [assets asset-link-or-name path asset-path (p/let [stat ( stat .-size) 0) + :external-url (or asset-link-or-name path) + :external-file-name asset-path})) + (p/catch (constantly nil))))) + +(defn- build-asset-tx + [asset-data asset-name asset-link-or-name asset-link pdf-annotation-pages opts assets zotero-asset?] + (let [new-asset (merge (build-new-asset asset-data) + {:block/title (db-asset/asset-name->title (node-path/basename asset-name)) + :block/uuid (get-asset-block-id assets asset-link-or-name)} + (when-let [metadata (not-empty (common-util/safe-read-map-string (:metadata (second asset-link))))] + {:logseq.property.asset/resize-metadata metadata})) + external-file-asset? (and (string? asset-name) + (not= asset-name asset-link-or-name)) + pdf-annotations-paths (if (and (or zotero-asset? external-file-asset?) + (string? asset-name)) + [(path/path-join common-config/local-assets-dir (node-path/basename asset-name)) + (path/path-join common-config/local-assets-dir asset-name)] + [(or asset-name asset-link-or-name)]) + pdf-annotations-tx (when (some pdf-file? pdf-annotations-paths) + (build-pdf-annotations-tx pdf-annotations-paths assets new-asset pdf-annotation-pages opts)) + asset-tx (concat [new-asset] pdf-annotations-tx)] + ;; (prn :asset-added! (node-path/basename asset-name)) + ;; (cljs.pprint/pprint asset-link) + ;; (prn :debug :asset-tx asset-tx) + (swap! assets assoc-in [asset-link-or-name :asset-created?] true) + {:asset-name-uuid [asset-link-or-name (:block/uuid new-asset)] + :asset-tx asset-tx})) + +(defn- asset-link second :url second) + :location {:block (:block/title block)}}) + nil)))) + asset-links)) + asset-maps (remove nil? asset-maps*) + asset-blocks (mapcat :asset-tx asset-maps) + asset-names-to-uuids + (into {} (map :asset-name-uuid asset-maps))] + (cond-> {:block + (update block :block/title update-asset-links-in-block-title asset-names-to-uuids ignored-assets)} + (seq asset-blocks) + (assoc :asset-blocks-tx asset-blocks))) + (p/resolved {:block block})))) + +(defn- quote-node->markdown + "Converts a Quote AST node to markdown, preserving nested quote structure. + Content at depth=0 (used for Quote-block titles) has no outer prefix — the + Quote-block display-type provides the outermost blockquote styling. + Content at depth>=1 (used for mixed blocks) gets '> ' prefix at each level." + [quote-node opts depth] + (let [inner-elements (second quote-node) + parts (for [el inner-elements + :let [el-type (first el)]] + (cond + (= "Quote" el-type) + ;; Nested quote: recurse one level deeper + (quote-node->markdown el opts (inc depth)) + :else + ;; Other content (Paragraph, Src, etc.): extract as text + (let [text (ast->text el opts)] + (if (pos? depth) + (->> (string/split-lines text) + (map #(str "> " %)) + (string/join "\n")) + text))))] + (string/join "\n" (remove string/blank? parts)))) + +(defn- handle-quotes + "Handles blocks containing markdown blockquotes or #+BEGIN_QUOTE: + - Pure quote blocks (empty heading + all body elements are Quote nodes): + convert to #Quote-block with quote content as title. Nested quotes are + preserved using '> ' markdown prefix at each nesting level. + - Blocks where the title contains #+BEGIN_QUOTE and has Quote body elements + but are not pure quote blocks: retain as regular block with '> ' prefix + added to quote content so it renders as a blockquote in the DB graph." + [block opts] + (let [ast-blocks (:block.temp/ast-blocks block) + heading (first (filter #(= "Heading" (first %)) ast-blocks)) + heading-title (when heading (get-in (second heading) [:title])) + heading-empty? (or (nil? heading) (empty? heading-title)) + body-elements (remove #(= "Heading" (first %)) ast-blocks) + all-body-quotes? (and (seq body-elements) (every? #(= "Quote" (first %)) body-elements)) + has-quote-body? (some #(= "Quote" (first %)) body-elements) + org-quote? (boolean (re-find #"(?i)#\+BEGIN_QUOTE" (str (:block/title block))))] + (cond + (and heading-empty? all-body-quotes?) + ;; Pure quote block: convert to Quote-block with depth=0. + ;; The Quote-block display-type provides the visual blockquote styling, so + ;; no '> ' prefix is added to content. Nested '>>' gets '> ' prefix via + ;; recursion into depth=1 inside quote-node->markdown. + (let [combined-title (->> body-elements + (map #(quote-node->markdown % opts 0)) + (remove string/blank?) + (string/join "\n"))] + (merge block + {:block/title combined-title + :logseq.property.node/display-type :quote + :block/tags [:logseq.class/Quote-block]})) + + (and has-quote-body? org-quote?) + ;; Mixed block with #+BEGIN_QUOTE: keep as regular block but add '> ' prefix + ;; to quote content so it renders as a blockquote in the DB graph. + ;; ast-blocks body is accumulated in reverse file order by extract-blocks + ;; (due to (reverse ast) + conj). Restore file order: keep the Heading + ;; first, then reverse the rest back to original file position order. + ;; After a Quote element, use a blank line as separator before any following + ;; content: unlike #+BEGIN_QUOTE/#+END_QUOTE delimiters, the markdown '>' + ;; blockquote ends at the first blank line, so a blank line is required to + ;; prevent subsequent non-quote text from being absorbed into the blockquote. + (let [ordered-blocks (into [(first ast-blocks)] (reverse (rest ast-blocks))) + tagged-parts (->> ordered-blocks + (map (fn [el] + {:quote? (= "Quote" (first el)) + :text (case (first el) + "Heading" (let [title (get-in (second el) [:title])] + (when (seq title) + (ast->text ["Paragraph" title] opts))) + "Quote" (quote-node->markdown el opts 1) + (ast->text el opts))})) + (remove #(string/blank? (:text %)))) + combined (first (reduce (fn [[result prev-quote?] {:keys [quote? text]}] + [(if (string/blank? result) + text + (str result (if prev-quote? "\n\n" "\n") text)) + quote?]) + ["" false] + tagged-parts))] + (assoc block :block/title combined)) + + :else + block))) + +(defn- handle-math + "If a block's entire content is a single displayed math formula, convert to #Math node. + Detects blocks whose title is entirely delimited by $$ markers." + [block] + (let [title (string/trim (:block/title block))] + (if (and (string/starts-with? title "$$") + (string/ends-with? title "$$") + (> (count title) 4) + ;; ensure there's no nested $$ pair (i.e. not two separate inline formulas) + (not (string/includes? (subs title 2 (- (count title) 2)) "$$"))) + (let [math-content (string/trim (subs title 2 (- (count title) 2)))] + (merge block + {:block/title math-content + :logseq.property.node/display-type :math + :block/tags [:logseq.class/Math-block]})) + block))) + +(defn- split-title-by-code-fences + "Parses a block title string line-by-line, splitting into non-code text parts + and code fence segments. All code fences are extracted regardless of whether + they have a language tag; :lang is nil when not specified. + Returns {:text-parts [...] :code-segs [{:text ... :lang ...}]}." + [title] + (let [lines (string/split-lines title)] + (loop [remaining lines + in-code? false + lang nil + current [] + text-parts [] + code-segs []] + (if (empty? remaining) + {:text-parts (if (seq current) + (conj text-parts (string/join "\n" current)) + text-parts) + :code-segs code-segs} + (let [line (first remaining) + trimmed-line (string/trim line) + fence-start? (and (not in-code?) (re-matches #"```.*" trimmed-line)) + fence-end? (and in-code? (= trimmed-line "```"))] + (cond + fence-start? + (recur (rest remaining) true (not-empty (subs trimmed-line 3)) [] + (if (seq current) + (conj text-parts (string/join "\n" current)) + text-parts) + code-segs) + fence-end? + (recur (rest remaining) false nil [] + text-parts + (conj code-segs {:text (string/join "\n" current) :lang lang})) + :else + (recur (rest remaining) in-code? lang (conj current line) + text-parts code-segs))))))) + +(defn- build-code-snippet-child-blocks + "Builds child block tx maps for extracted code snippets, tagging each as a + Code-block with its detected language." + [parent-block code-segs] + (mapv (fn [{:keys [text lang]}] + (cond-> (sqlite-util/block-with-timestamps + {:block/uuid (d/squuid) + :block/title text + :block/parent [:block/uuid (:block/uuid parent-block)] + :block/page (:block/page parent-block) + :block/order (db-order/gen-key) + :block/tags [:logseq.class/Code-block] + :logseq.property.node/display-type :code}) + lang + (assoc :logseq.property.code/lang lang))) + code-segs)) + +(defn- handle-embeds + "If a block contains page or block embeds, converts block to a :block/link based embed" + [block page-names-to-uuids {:keys [embeds]} {:keys [log-fn] :or {log-fn prn}}] + (if-let [embed-node (first embeds)] + (cond + (page-ref/page-ref? (str (first (:arguments (second embed-node))))) + (let [page-uuid (get-page-uuid page-names-to-uuids + (some-> (text/get-page-name (first (:arguments (second embed-node)))) + common-util/page-name-sanity-lc) + {:block block})] + (merge block + {:block/title "" + :block/link [:block/uuid page-uuid]})) + (block-ref/block-ref? (str (first (:arguments (second embed-node))))) + (let [block-uuid (uuid (block-ref/get-block-ref-id (first (:arguments (second embed-node)))))] + (merge block + {:block/title "" + :block/link [:block/uuid block-uuid]})) + :else + (do + (log-fn :invalid-embed-arguments "Ignore embed because of invalid arguments" :args (:arguments (second embed-node))) + block)) + block)) + +(defn- dissoc-nil-block-refs + [block] + (cond-> block + (nil? (:block/refs block)) (dissoc :block/refs))) + +(defn- at-least-two? + [s substr] + (if (empty? substr) + false + (loop [start 0 cnt 0] + (let [idx (string/index-of s substr start)] + (cond + (>= cnt 2) true + (nil? idx) false + :else (recur (+ idx (count substr)) (inc cnt))))))) + +(defn- handle-code-blocks + "Returns a vector of block and optional block children tx. If a block + contains code fence(s) i.e. ```, converts block to a #Code node. If user + enables :extract-code-snippets? option, multiple code fences are extracted out + of text and put into children blocks in the order they appear" + [block' options] + (let [title (:block/title block') + has-fence? (and (string? title) (at-least-two? title "```")) + extract? (get-in options [:user-options :extract-code-snippets?]) + [final-block code-children-tx] + (if has-fence? + (let [{:keys [text-parts code-segs]} (split-title-by-code-fences title) + pure-single-code? (and (= 1 (count code-segs)) + (every? string/blank? text-parts)) + has-mixed-content? (and extract? + (seq code-segs) + (some #(not (string/blank? %)) text-parts))] + (cond + pure-single-code? + (let [{:keys [text lang]} (first code-segs)] + [(cond-> (assoc block' + :block/title text + :block/tags [:logseq.class/Code-block] + :logseq.property.node/display-type :code) + lang (assoc :logseq.property.code/lang lang)) + []]) + has-mixed-content? + (let [remaining-title (-> (string/join "\n" text-parts) + (string/replace #"\n{2,}" "\n") + string/trim) + updated-block (assoc block' :block/title remaining-title) + code-children (build-code-snippet-child-blocks updated-block code-segs)] + [updated-block code-children]) + :else + [block' []])) + [block' []])] + [final-block code-children-tx])) + +(defn- (:block/page block*) second journal-created-ats) + prepared-block (cond-> block-after-assets + journal-page-created-at + (assoc :block/created-at journal-page-created-at)) + block' (-> prepared-block + (fix-pre-block-references pre-blocks page-names-to-uuids) + (fix-block-name-lookup-ref page-names-to-uuids) + (update-block-refs page-names-to-uuids) + dissoc-nil-block-refs + (update-block-tags db (:user-options options) per-file-state (:all-idents import-state)) + (handle-embeds page-names-to-uuids walked-ast-blocks (select-keys options [:log-fn])) + (handle-quotes (select-keys options [:log-fn])) + (handle-math) + (update-block-marker db options) + (update-block-priority options) + (add-missing-timestamps options) + (dissoc :block/format :block.temp/ast-blocks) + ;; ((fn [x] (prn ::block-out x) x)) + )] + (let [[final-block code-children-tx] (handle-code-blocks block' options)] + ;; Order matters as previous txs are referenced in block + (concat properties-tx deadline-properties-tx asset-blocks-tx [final-block] code-children-tx)))) + +(defn- update-page-alias + [m page-names-to-uuids] + (update m :block/alias (fn [aliases] + (map #(vector :block/uuid (get-page-uuid page-names-to-uuids (:block/name %) {:block %})) + aliases)))) + +(defn- build-new-page-or-class + [m db per-file-state all-idents {:keys [user-options journal-created-ats] :as options}] + (-> (cond-> m + ;; Fix pages missing :block/title. Shouldn't happen + (not (:block/title m)) + (assoc :block/title (:block/name m)) + (seq (:block/alias m)) + (update-page-alias (:page-names-to-uuids per-file-state)) + (journal-created-ats (:block/name m)) + (assoc :block/created-at (journal-created-ats (:block/name m)))) + (add-missing-timestamps options) + (update-page-tags db user-options per-file-state all-idents))) + +(defn- get-page-parents + "Like ldb/get-page-parents but using all-existing-page-uuids" + [node all-existing-page-uuids] + (let [get-parent (fn get-parent [n] + (let [parent (or (:logseq.property.class/extends n) (:block/parent n))] + (when-let [parent-id (:block/uuid parent)] + (or (get all-existing-page-uuids parent-id) + (throw (ex-info (str "No parent page found for " (pr-str (:block/uuid parent))) + {:node n}))))))] + (when-let [parent (get-parent node)] + (loop [current-parent parent + parents' []] + (if (and current-parent (not (contains? parents' current-parent))) + (recur (get-parent current-parent) + (conj parents' current-parent)) + (vec (reverse parents'))))))) + +(defn- get-all-existing-page-uuids + "Returns a map of unique page names mapped to their uuids. The page names + are in a format that is compatible with extract/extract e.g. namespace pages have + their full hierarchy in the name" + [classes-from-property-parents all-existing-page-uuids] + (->> all-existing-page-uuids + (map (fn [[_ p]] + (vector + (if-let [parents (and (or (contains? (:block/tags p) :logseq.class/Tag) + (contains? (:block/tags p) :logseq.class/Page)) + ;; These classes have parents now but don't in file graphs (and in extract) + (not (contains? classes-from-property-parents (:block/title p))) + (get-page-parents p all-existing-page-uuids))] + ;; Build a :block/name for namespace pages that matches data from extract/extract + (string/join ns-util/namespace-char (map :block/name (conj (vec parents) p))) + (:block/name p)) + (or (:block/uuid p) + (throw (ex-info (str "No uuid for existing page " (pr-str (:block/name p))) + (select-keys p [:block/name :block/tags]))))))) + (into {}))) + +(defn- journal-file-title + [path] + (let [normalized-path (some-> path str (string/replace "\\" "/") string/lower-case)] + (second (re-find #"(?:^|/)journals/(\d{4}_\d{2}_\d{2})\.(?:md|markdown|org)$" + normalized-path)))) + +(defn- journal-page-name-uuid-entries + [{:keys [path]}] + (when-let [journal-title (journal-file-title path)] + (when-let [journal-day (date-time-util/journal-title->int journal-title ["yyyy_MM_dd"])] + (let [journal-uuid (common-uuid/gen-uuid :journal-page-uuid journal-day) + canonical-page-name (-> journal-day + (date-time-util/int->journal-title date-time-util/default-journal-title-formatter) + common-util/page-name-sanity-lc)] + [[journal-title journal-uuid] + [canonical-page-name journal-uuid]])))) + +(defn- index-journal-page-name-uuids! + [doc-files import-state] + (swap! (:journal-page-name-uuids import-state) + merge + (into {} (mapcat journal-page-name-uuid-entries) doc-files))) + +(defn- build-existing-page + [m db page-uuid {:keys [page-names-to-uuids] :as per-file-state} {:keys [notify-user import-state] :as options}] + (let [;; These attributes are not allowed to be transacted because they must not change across files + disallowed-attributes [:block/name :block/uuid :block/format :block/title :block/journal-day + :block/created-at :block/updated-at] + allowed-attributes (into [:block/tags :block/alias :block/parent :logseq.property.class/extends :db/ident] + (keep #(when (db-malli-schema/user-property? (key %)) (key %)) + m)) + block-changes (select-keys m allowed-attributes)] + (when-let [ignored-attrs (not-empty (apply dissoc m (into disallowed-attributes allowed-attributes)))] + (notify-user {:msg (str "Import ignored the following attributes on page " (pr-str (:block/title m)) ": " + ignored-attrs)})) + (when (seq block-changes) + (cond-> (merge block-changes {:block/uuid page-uuid}) + (seq (:block/alias m)) + (update-page-alias page-names-to-uuids) + (:block/tags m) + (update-page-tags db (:user-options options) per-file-state (:all-idents import-state)))))) + +(defn- modify-page-tx + "Modifies page tx from graph-parser for use with DB graphs. Currently modifies + namespaces and blocks with built-in page names" + [page all-existing-page-uuids] + (let [page' + (if (contains? all-existing-page-uuids (:block/name page)) + (cond-> page + (:block/namespace page) + ;; Fix uuid for existing pages as graph-parser's :block/name is different than + ;; the DB graph's version e.g. 'b/c/d' vs 'd' + (assoc :block/uuid + (or (all-existing-page-uuids (:block/name page)) + (throw (ex-info (str "No uuid found for existing namespace page " (pr-str (:block/name page))) + (select-keys page [:block/name :block/namespace])))))) + (let [built-in-name? (and (contains? all-built-in-names (keyword (:block/name page))) + ;; Don't create new card page + (not (contains? #{:card} (keyword (:block/name page)))))] + (cond-> page + ;; fix extract incorrectly assigning new user pages built-in uuids + built-in-name? + (assoc :block/uuid (d/squuid)) + ;; only happens for few file built-ins like tags and alias + (and built-in-name? (not (:block/tags page))) + (assoc :block/tags [:logseq.class/Page]))))] + (cond-> page' + true + (dissoc :block/format) + (and (:block/namespace page) (not (:block/journal-day page))) + ((fn [block'] + (merge (build-new-namespace-page block') + {;; save original name b/c it's still used for a few name lookups + ::original-name (:block/name block') + ::original-title (:block/title block')})))))) + +(defn sanitize-page-aliases-for-import! + "Removes alias declarations that violate ownership invariants across imported files. + Mutates alias-owners-atom (alias-name -> canonical-name map) and ignored-properties-atom. + Violations detected: duplicate owner (two pages claiming same alias), + alias-of-alias (a page used as alias that itself owns aliases), + cross-file source-is-alias (a page that is already an alias across files trying to own aliases), and + cross-file alias-owns-aliases (an alias target that already owns aliases in a previous file). + Returns a vec of pages with conflicting aliases stripped." + [pages alias-owners-atom ignored-properties-atom] + (let [;; Within this batch: first-declared owner wins for duplicate detection + batch-alias->owner + (reduce (fn [acc page] + (reduce (fn [acc2 alias-m] + (let [n (:block/name alias-m)] + (if (contains? acc2 n) acc2 (assoc acc2 n (:block/name page))))) + acc (:block/alias page))) + {} pages) + ;; Names of pages in this batch that themselves declare aliases + batch-pages-with-aliases + (->> pages (filter #(seq (:block/alias %))) (map :block/name) set) + ;; Pages that already own aliases across previously imported files (values of alias-owners-atom) + ;; Computed once per call for O(1) per-alias lookup + cross-file-alias-owners + (set (vals @alias-owners-atom))] + (mapv + (fn [page] + (if-let [aliases (seq (:block/alias page))] + (let [canonical (:block/name page)] + ;; Cross-file source-is-alias: this canonical page is already registered as someone else's alias + (if (contains? @alias-owners-atom canonical) + (do (doseq [alias-m aliases] + (swap! ignored-properties-atom conj + {:property :block/alias :value (:block/name alias-m) + :location canonical :reason :alias/source-is-alias})) + (dissoc page :block/alias)) + (let [valid (filterv + (fn [alias-m] + (let [aname (:block/name alias-m) + cross-owner (get @alias-owners-atom aname) + batch-owner (get batch-alias->owner aname)] + (cond + (= aname canonical) + (do (swap! ignored-properties-atom conj + {:property :block/alias :value aname + :location canonical :reason :alias/self}) + false) + (and cross-owner (not= cross-owner canonical)) + (do (swap! ignored-properties-atom conj + {:property :block/alias :value aname + :location canonical :reason :alias/duplicate-owner}) + false) + (and batch-owner (not= batch-owner canonical)) + (do (swap! ignored-properties-atom conj + {:property :block/alias :value aname + :location canonical :reason :alias/duplicate-owner}) + false) + ;; Cross-file alias-owns-aliases: aname already owns aliases from a previous file + (contains? cross-file-alias-owners aname) + (do (swap! ignored-properties-atom conj + {:property :block/alias :value aname + :location canonical :reason :alias/alias-owns-aliases}) + false) + (contains? batch-pages-with-aliases aname) + (do (swap! ignored-properties-atom conj + {:property :block/alias :value aname + :location canonical :reason :alias/alias-owns-aliases}) + false) + :else true))) + aliases)] + (doseq [alias-m valid] + (swap! alias-owners-atom assoc (:block/name alias-m) canonical)) + (if (seq valid) (assoc page :block/alias valid) (dissoc page :block/alias))))) + page)) + pages))) + +(defn- build-pages-tx + "Given all the pages and blocks parsed from a file, return a map containing + all pages to be transacted, pages' properties and additional + data for subsequent steps" + [conn pages blocks {:keys [import-state user-options] + :as options}] + (let [journal-page-name-uuids @(:journal-page-name-uuids import-state) + all-pages* (-> (->> (extract/with-ref-pages pages blocks) + (remove #(and (not (:block/file %)) + (contains? journal-page-name-uuids (:block/name %)))) + ;; remove unused property pages unless the page has content + (remove #(and (contains? (into (:property-classes user-options) (:property-parent-classes user-options)) + (keyword (:block/name %))) + (not (:block/file %)))) + ;; remove file path relative + (map #(dissoc % :block/file))) + ;; sanitize alias declarations before transacting + (sanitize-page-aliases-for-import! (:alias-owners import-state) + (:ignored-properties import-state))) + ;; Build all named ents once per import file to speed up named lookups + all-existing-page-uuids (get-all-existing-page-uuids @(:classes-from-property-parents import-state) + @(:all-existing-page-uuids import-state)) + all-pages (map #(modify-page-tx % all-existing-page-uuids) all-pages*) + all-new-page-uuids (->> all-pages + (remove #(all-existing-page-uuids (or (::original-name %) (:block/name %)))) + (map (juxt (some-fn ::original-name :block/name) :block/uuid)) + (into {})) + ;; Stateful because new page uuids can occur via tags + page-names-to-uuids (atom (merge all-existing-page-uuids all-new-page-uuids journal-page-name-uuids)) + per-file-state {:page-names-to-uuids page-names-to-uuids + :classes-tx (:classes-tx options)} + all-pages-m (mapv #(handle-page-properties % @conn per-file-state all-pages options) + all-pages) + pages-tx (keep (fn [{m :block _properties-tx :properties-tx}] + (let [page (if-let [page-uuid (if (::original-name m) + (all-existing-page-uuids (::original-name m)) + (all-existing-page-uuids (:block/name m)))] + (build-existing-page (dissoc m ::original-name ::original-title) @conn page-uuid per-file-state options) + (when (or (ldb/class? m) + ;; Don't build a new page if it overwrites an existing class + (not (some-> (get @(:all-idents import-state) + (some-> (or (::original-title m) (:block/title m)) + build-class-ident-name + keyword)) + db-malli-schema/class?)) + ;; TODO: Enable this when it's valid for all test graphs because + ;; pages with properties must be built or else properties-tx is invalid + #_(seq properties-tx)) + (build-new-page-or-class (dissoc m ::original-name ::original-title) + @conn per-file-state (:all-idents import-state) options)))] + ;; (when-not ret (println "Skipped page tx for" (pr-str (:block/title m)))) + page)) + all-pages-m)] + {:pages-tx pages-tx + :page-properties-tx (mapcat :properties-tx all-pages-m) + :existing-pages (select-keys all-existing-page-uuids (map :block/name all-pages*)) + :per-file-state per-file-state})) + +(defn- build-upstream-properties-tx-for-default + "Builds upstream-properties-tx for properties that change to :default type" + [db prop property-ident from-prop-type block-properties-text-values] + (let [get-pvalue-content (fn get-pvalue-content [block-uuid prop'] + (or (get-in block-properties-text-values [block-uuid prop']) + (throw (ex-info (str "No :block/text-properties-values found when changing property values: " (pr-str block-uuid)) + {:property prop' + :block/uuid block-uuid})))) + existing-blocks + (map first + (d/q '[:find (pull ?b [*]) + :in $ ?p % + :where (has-property ?b ?p)] + db + property-ident + (rules/extract-rules rules/db-query-dsl-rules))) + existing-blocks-tx + (mapcat (fn [m] + (let [prop-value (get m property-ident) + ;; Don't delete property values from these types b/c those pages are needed + ;; for refs and may have content + retract-tx (if (#{:node :date} from-prop-type) + [[:db/retract (:db/id m) property-ident]] + (mapv #(vector :db/retractEntity (:db/id %)) + (if (sequential? prop-value) prop-value [prop-value]))) + prop-value-content (get-pvalue-content (:block/uuid m) prop) + new-value (db-property-build/build-property-value-block + m {:db/ident property-ident} prop-value-content)] + (into retract-tx + [new-value + {:block/uuid (:block/uuid m) + property-ident [:block/uuid (:block/uuid new-value)]}]))) + existing-blocks)] + existing-blocks-tx)) + +(defn- build-upstream-properties-tx + "Builds tx for upstream properties that have changed and any instances of its + use in db or in given blocks-tx. Upstream properties can be properties that + already exist in the DB from another file or from earlier uses of a property + in the same file" + [db upstream-properties import-state log-fn] + (if (seq upstream-properties) + (let [block-properties-text-values @(:block-properties-text-values import-state) + all-idents @(:all-idents import-state) + _ (log-fn :props-upstream-to-change upstream-properties) + txs + (mapcat + (fn [[prop {:keys [schema from-type]}]] + (let [prop-ident (get-ident all-idents prop) + upstream-tx + (when (= :default (:logseq.property/type schema)) + (build-upstream-properties-tx-for-default db prop prop-ident from-type block-properties-text-values)) + property-pages-tx [(merge {:db/ident prop-ident} schema)]] + ;; If we handle cardinality changes we would need to return these separately + ;; as property-pages would need to be transacted separately + (concat property-pages-tx upstream-tx))) + upstream-properties)] + txs) + [])) + +(defn new-import-state + "New import state that is used for import of one graph. State is atom per + key to make code more readable and encourage local mutations" + [] + {;; Vec of maps with keys :property, :value, :schema and :location. + ;; Properties are ignored to keep graph valid and notify users of ignored properties. + ;; Properties with :schema are ignored due to property schema changes + :ignored-properties (atom []) + ;; Vec of maps with keys :path and :reason + :ignored-files (atom []) + ;; Vec of maps with keys :path, :reason and :location (optional). + :ignored-assets (atom []) + ;; Map of annotation page paths and their parsed contents + :pdf-annotation-pages (atom {}) + ;; Map of property names (keyword) and their current schemas (map of qualified properties). + ;; Used for adding schemas to properties and detecting changes across a property's usage + :property-schemas (atom {}) + ;; Indexes all created pages by uuid. Index is used to fetch all parents of a page + :all-existing-page-uuids (atom {}) + ;; Map of stable journal file names and canonical page names to their standard journal page uuids. + :journal-page-name-uuids (atom {}) + ;; Map of property or class names (keyword) to db-ident keywords + :all-idents (atom {}) + ;; Set of children pages turned into classes by :property-parent-classes option + :classes-from-property-parents (atom #{}) + ;; Map of imported legacy task markers to their Status closed value lookup refs. + :custom-status-markers (atom {}) + ;; Map of block uuids to their :block/properties-text-values value. + ;; Used if a property value changes to :default + :block-properties-text-values (atom {}) + ;; Track asset data for use across asset and doc import steps + :assets (atom {}) + ;; Map from alias-page-name (string) to canonical-page-name (string) for duplicate-owner detection. + ;; Populated as files are imported and used to drop conflicting alias declarations. + :alias-owners (atom {})}) + +(defn- build-tx-options [{:keys [user-options] :as options}] + (merge + (dissoc options :extract-options :user-options) + {:import-state (or (:import-state options) (new-import-state)) + ;; Track per file changes to make to existing properties + ;; Map of property names (keyword) and their changes (map) + :upstream-properties (atom {}) + ;; Track per file class tx so that their tx isn't embedded in individual :block/tags and can be post processed + :classes-tx (atom []) + ;; Track per file Status closed values required by imported legacy task markers. + :custom-status-tx (atom []) + :user-options + (merge user-options + {:tag-classes (set (map string/lower-case (:tag-classes user-options))) + :property-classes (set/difference + (set (map (comp keyword string/lower-case) (:property-classes user-options))) + file-built-in-property-names) + :property-parent-classes (set/difference + (set (map (comp keyword string/lower-case) (:property-parent-classes user-options))) + file-built-in-property-names)})})) + +(defn- retract-parent-and-page-tag + [col] + (vec + (mapcat (fn [b] + (let [eid [:block/uuid (:block/uuid b)]] + [[:db/retract eid :block/parent] + [:db/retract eid :block/tags :logseq.class/Page]])) + col))) + +(defn- split-pages-and-properties-tx + "Separates new pages from new properties tx in preparation for properties to + be transacted separately. Also builds property pages tx and converts existing + pages that are now properties" + [pages-tx old-properties existing-pages import-state upstream-properties] + (let [new-properties (set/difference (set (keys @(:property-schemas import-state))) (set old-properties)) + ;; _ (when (seq new-properties) (prn :new-properties new-properties)) + [properties-tx pages-tx'] ((juxt filter remove) + #(contains? new-properties (keyword (:block/name %))) pages-tx) + property-pages-tx (map (fn [{block-uuid :block/uuid :block/keys [title]}] + (let [property-name (keyword (string/lower-case title)) + db-ident (get-ident @(:all-idents import-state) property-name) + upstream-property (get upstream-properties property-name)] + (sqlite-util/build-new-property + db-ident + ;; Tweak new properties that have upstream changes in flight to behave like + ;; existing properties i.e. they should be defined by the upstream property + (if (and upstream-property + (#{:date :node} (:from-type upstream-property)) + (= :default (get-in upstream-property [:schema :logseq.property/type]))) + ;; Assumes :many for :date and :node like infer-property-schema-and-get-property-change + {:logseq.property/type (:from-type upstream-property) :db/cardinality :many} + (get-property-schema @(:property-schemas import-state) property-name)) + {:title title :block-uuid block-uuid}))) + properties-tx) + converted-property-pages-tx + (map (fn [kw-name] + (let [existing-page-uuid (get existing-pages (name kw-name)) + db-ident (get-ident @(:all-idents import-state) kw-name) + new-prop (sqlite-util/build-new-property db-ident + (get-property-schema @(:property-schemas import-state) kw-name) + {:title (name kw-name)})] + (assert existing-page-uuid) + (merge (select-keys new-prop [:block/tags :db/ident :logseq.property/type :db/index :db/cardinality :db/valueType]) + {:block/uuid existing-page-uuid}))) + (set/intersection new-properties (set (map keyword (keys existing-pages))))) + ;; Could do this only for existing pages but the added complexity isn't worth reducing the tx noise + retract-page-tag-from-properties-tx (retract-parent-and-page-tag (concat property-pages-tx converted-property-pages-tx)) + ;; Save properties on new property pages separately as they can contain new properties and thus need to be + ;; transacted separately the property pages + property-page-properties-tx (keep (fn [b] + (when-let [page-properties (not-empty (db-property/properties b))] + (merge page-properties {:block/uuid (:block/uuid b) + :block/tags (-> (remove #(= :logseq.class/Page %) (:block/tags page-properties)) + (conj :logseq.class/Property))}))) + properties-tx)] + {:pages-tx pages-tx' + :property-pages-tx (concat property-pages-tx converted-property-pages-tx retract-page-tag-from-properties-tx) + :property-page-properties-tx property-page-properties-tx})) + +(defn- fix-extracted-block-tags-and-refs + "A tag or ref can have different :block/uuid's across extracted blocks. This makes + sense for most in-app uses but not for importing where we want consistent identity. + This fn fixes that issue. This fn also ensures that tags and pages have the same uuid" + [blocks] + (let [name-uuids (atom {}) + fix-block-uuids + (fn fix-block-uuids [tags-or-refs {:keys [ref? properties]}] + ;; mapv to determinastically process in order + (mapv (fn [b] + (if (and ref? (get properties (keyword (:block/name b)))) + ;; don't change uuid if property since properties and tags have different uuids + b + (if-let [existing-uuid (some->> (:block/name b) (get @name-uuids))] + (if (not= existing-uuid (:block/uuid b)) + ;; fix unequal uuids for same name + (assoc b :block/uuid existing-uuid) + b) + (if (vector? b) + ;; ignore [:block/uuid] refs + b + (do + (assert (and (:block/name b) (:block/uuid b)) + (str "Extracted block tag/ref must have a name and uuid: " (pr-str b))) + (swap! name-uuids assoc (:block/name b) (:block/uuid b)) + b))))) + tags-or-refs))] + (map (fn [b] + (cond-> b + (seq (:block/tags b)) + (update :block/tags fix-block-uuids {}) + (seq (:block/refs b)) + (update :block/refs fix-block-uuids {:ref? true :properties (:block/properties b)}))) + blocks))) + +(defn- get-block-pattern + [format] + (let [format' (keyword format)] + (if (= format' :org) "*" "-"))) + +(defn- extract-pages-and-blocks + "Main fn which calls graph-parser to convert markdown into data" + [db file content {:keys [extract-options import-state file-created-at file-updated-at]}] + (let [format (common-util/get-format file) + journal-file? (some? (journal-file-title file)) + with-file-timestamps (fn [node] + (cond-> node + file-created-at (assoc :block/created-at file-created-at) + file-updated-at (assoc :block/updated-at file-updated-at))) + extract-options' (merge {:block-pattern (get-block-pattern format) + :date-formatter "MMM do, yyyy" + :uri-encoded? false + ;; Alters behavior in gp-block + :export-to-db-graph? true + :filename-format :legacy} + extract-options + {:db db + ;; File graph journals have a fixed path and filename independent of their display format. + :skip-journal? (not journal-file?)}) + extracted + (cond (contains? #{:org :markdown :md} format) + (-> (extract/extract file content extract-options') + (update :pages (fn [pages] + (map #(-> % + (dissoc :block.temp/original-page-name) + with-file-timestamps) + pages))) + (update :blocks (fn [blocks] + (fix-extracted-block-tags-and-refs + (map with-file-timestamps blocks))))) + + :else + (when-not (re-find #"whiteboards/.*\.edn$" (str file)) + (swap! (:ignored-files import-state) conj + {:path file :reason :unsupported-file-format})))] + ;; Annotation markdown pages are saved for later as they are dependant on the asset being annotated + (if (string/starts-with? (str (path/basename file)) "hls__") + (do + (swap! (:pdf-annotation-pages import-state) assoc (node-path/basename file) extracted) + nil) + extracted))) + +(defn- build-journal-created-ats + "Calculate created-at timestamps for journals" + [pages] + (->> pages + (map #(when-let [journal-day (:block/journal-day %)] + [(:block/name %) (date-time-util/journal-day->ms journal-day)])) + (into {}))) + +(defn- clean-extra-invalid-tags + "If a page/class tx is an existing property or a new or existing class, ensure that + it only has one tag by removing :logseq.class/Page from its tx" + [db pages-tx' classes-tx existing-pages] + ;; TODO: Improve perf if we tracked all created classes in atom + (let [existing-classes (->> (d/datoms db :avet :block/tags :logseq.class/Tag) + (map #(d/entity db (:e %))) + (map :block/uuid) + set) + classes (set/union existing-classes + (set (map :block/uuid classes-tx))) + existing-properties (->> (d/datoms db :avet :block/tags :logseq.class/Property) + (map #(d/entity db (:e %))) + (map :block/uuid) + set) + existing-pages' (set/map-invert existing-pages) + retract-page-tag-from-existing-pages + (->> pages-tx' + ;; Existing pages that have converted to property or class + (filter #(and (:db/ident %) (get existing-pages' (:block/uuid %)))) + retract-parent-and-page-tag)] + {:pages-tx + (mapv (fn [page] + (if (or (contains? classes (:block/uuid page)) + (contains? existing-properties (:block/uuid page))) + (-> page + (update :block/tags (fn [tags] (vec (remove #(= % :logseq.class/Page) tags)))) + (dissoc :block/parent)) + page)) + pages-tx') + :retract-page-tags-tx + (into (retract-parent-and-page-tag classes-tx) + retract-page-tag-from-existing-pages)})) + +(defn- save-from-tx + "Save importer state from given txs" + [txs {:keys [import-state] :as _opts}] + ;; (when (string/includes? (:file _opts) "some-file.md") (cljs.pprint/pprint txs)) + (when-let [nodes (seq (filter :block/name txs))] + (swap! (:all-existing-page-uuids import-state) merge (into {} (map (juxt :block/uuid identity) nodes))))) + +(defn- > blocks + (remove :block/pre-block?) + (map #(dissoc % :block/pre-block?)))] + (if-let [block (first blocks)] + (p/let [block-tx-data (> blocks (keep #(when (:block/pre-block? %) (:block/uuid %))) set) + blocks-tx ( (p/let [_ (set-ui-state [:graph/importing-state :current-idx] (inc idx)) + _ (set-ui-state [:graph/importing-state :current-page] path) + content ( ^js stat .-birthtime)) + modified-at (or (:mtime stat) (some-> ^js stat .-mtime) (:last-modified-at file)) + m {:file/path path :file/content content} + export-options (cond-> (dissoc options :set-ui-state : title + (string/replace (block-ref/->block-ref block-uuid) "") + (string/replace (page-ref/->page-ref block-uuid) "") + (string/replace #" {2,}" " ") + string/trim))) + +(defn- placeholder-block-ref? + [entity] + (and (:block/uuid entity) + (nil? (:block/title entity)))) + +(defn- cleanup-missing-block-refs-tx + [db] + (let [missing-ref-datoms + (->> (d/datoms db :aevt :block/refs) + (keep (fn [datom] + (let [ref-entity (d/entity db (:v datom))] + (when (placeholder-block-ref? ref-entity) + {:source-id (:e datom) + :ref-id (:v datom) + :ref-uuid (:block/uuid ref-entity)}))))) + missing-link-datoms + (->> (d/datoms db :aevt :block/link) + (keep (fn [datom] + (let [ref-entity (d/entity db (:v datom))] + (when (placeholder-block-ref? ref-entity) + {:source-id (:e datom) + :ref-id (:v datom) + :ref-uuid (:block/uuid ref-entity)}))))) + refs-by-source-id (group-by :source-id missing-ref-datoms) + retract-ref-tx + (mapcat (fn [[source-id refs]] + (map (fn [{:keys [ref-id]}] + [:db/retract source-id :block/refs ref-id]) + refs)) + refs-by-source-id) + retract-link-tx + (map (fn [{:keys [source-id ref-id]}] + [:db/retract source-id :block/link ref-id]) + missing-link-datoms) + update-title-tx + (keep (fn [[source-id refs]] + (let [source (d/entity db source-id) + title (:block/title source) + title' (reduce remove-block-ref-from-title title (map :ref-uuid refs))] + (when (and (string? title') (not= title title')) + [:db/add source-id :block/title title']))) + refs-by-source-id) + retract-placeholder-tx + (->> (concat missing-ref-datoms missing-link-datoms) + (map (juxt :ref-id :ref-uuid)) + distinct + (map (fn [[ref-id ref-uuid]] + [:db/retract ref-id :block/uuid ref-uuid])))] + (concat retract-ref-tx retract-link-tx update-title-tx retract-placeholder-tx))) + +(defn- cleanup-missing-block-refs! + [conn] + (let [tx (cleanup-missing-block-refs-tx @conn)] + (when (seq tx) + (ldb/transact! conn tx {::imported-data? true})))) + +(defn- journal-uuid-normalizations + [db] + (keep (fn [datom] + (let [entity (d/entity db (:e datom)) + old-uuid (:block/uuid entity) + journal-day (:block/journal-day entity) + standard-uuid (common-uuid/gen-uuid :journal-page-uuid journal-day)] + (when (and old-uuid (not= old-uuid standard-uuid)) + (when-let [target (d/entity db [:block/uuid standard-uuid])] + (when (not= (:db/id target) (:db/id entity)) + (throw (ex-info "Cannot normalize journal uuid because the standard uuid is already used" + {:journal-day journal-day + :old-uuid old-uuid + :standard-uuid standard-uuid + :target-id (:db/id target)})))) + {:eid (:db/id entity) + :old-uuid old-uuid + :standard-uuid standard-uuid}))) + (d/datoms db :avet :block/journal-day))) + +(defn- replace-journal-uuid-refs + [value uuid-replacements] + (if (seq uuid-replacements) + (walk/postwalk + (fn [x] + (if (string? x) + (reduce (fn [s [old-uuid standard-uuid]] + (-> s + (string/replace (page-ref/->page-ref old-uuid) + (page-ref/->page-ref standard-uuid)) + (string/replace (block-ref/->block-ref old-uuid) + (block-ref/->block-ref standard-uuid)))) + x + uuid-replacements) + x)) + value) + value)) + +(defn- normalize-journal-uuids-tx + [db] + (let [normalizations (vec (journal-uuid-normalizations db)) + uuid-replacements (map (juxt :old-uuid :standard-uuid) normalizations) + uuid-tx (mapcat (fn [{:keys [eid old-uuid standard-uuid]}] + [[:db/retract eid :block/uuid old-uuid] + [:db/add eid :block/uuid standard-uuid]]) + normalizations) + text-tx (when (seq uuid-replacements) + (keep (fn [datom] + (let [value (:v datom) + value' (when (or (string? value) (coll? value)) + (replace-journal-uuid-refs value uuid-replacements))] + (when (and (some? value') (not= value value')) + [:db/add (:e datom) (:a datom) value']))) + (d/datoms db :eavt)))] + (vec (concat uuid-tx text-tx)))) + +(defn- normalize-journal-uuids! + [conn] + (let [tx (normalize-journal-uuids-tx @conn)] + (when (seq tx) + (ldb/transact! conn tx {::imported-data? true})))) + +(defn export-doc-files + "Exports all user created files i.e. under journals/ and pages/. + Recommended to use build-doc-options and pass that as options" + [conn *doc-files (p/loop [_file-map (export-doc-file (get doc-files 0) conn = i (dec (count doc-files))) + (p/recur (export-doc-file (get doc-files (inc i)) conn (p/do! + (when custom-css + (-> ( (/logseq/config.edn + (node-path/dirname (node-path/dirname config-path))) + to-abs (fn [p] + (if (and base-dir (string? p) (not (string/blank? p)) (not (node-path/isAbsolute p))) + (path/path-join base-dir p) + p))] + (-> config + (update-in [:zotero/settings-v2 "default" :zotero-data-directory] to-abs) + (update-in [:zotero/settings-v2 "default" :zotero-linked-attachment-base-directory] to-abs)))) + +(defn export-config-file + "Exports logseq/config.edn by saving to database and setting any properties related to config" + [repo-or-conn config-file (> (d/q '[:find (pull ?b [:db/id :db/ident]) + :where [?b :block/tags :logseq.class/Tag]] @conn) + (map first) + (remove #(db-class/built-in-classes (:db/ident %)))) + class-to-prop-uuids + (->> (d/q '[:find ?t ?prop #_?class + :in $ ?user-classes + :where + [?b :block/tags ?t] + [?t :db/ident ?class] + [(contains? ?user-classes ?class)] + [?b ?prop _] + [?prop-e :db/ident ?prop] + [?prop-e :block/tags :logseq.class/Property]] + @conn + (set (map :db/ident user-classes))) + (remove #(ldb/built-in? (d/entity @conn (second %)))) + (reduce (fn [acc [class-id prop-ident]] + (update acc class-id (fnil conj #{}) prop-ident)) + {})) + tx (mapv (fn [[class-id prop-ids]] + {:db/id class-id + :logseq.property.class/properties (vec prop-ids)}) + class-to-prop-uuids)] + (ldb/transact! repo-or-conn tx))) + +(defn- (p/loop [_ (async-fn (get args-to-loop 0)) + i 0] + (when-not (>= i (dec (count args-to-loop))) + (p/recur (async-fn (get args-to-loop (inc i))) + (inc i)))) + (p/catch (fn [e] + (notify-user {:msg (str "Import has an unexpected error:\n" (.-message e)) + :level :error + :ex-data {:error e}}))))) + +(defn- read-and-copy-asset-files + "Reads and copies files under assets/" + [*asset-files ( m + edn-content + (assoc :edn-content edn-content)))] + {:with-edn-content with-edn-content + :pdf-annotation? pdf-annotation?}))) + (p/catch + (fn [error] + (notify-user {:msg (str "Import failed to read and copy " (pr-str path) " with error:\n" (.-message error)) + :level :error + :ex-data {:path path :error error}})))))] + (when (seq asset-files) + (set-ui-state [:graph/importing-state :current-page] "Read and copy asset files") + ( (:db/id (last acc)) dec) -1) + :block/order (db-order/gen-key nil) + :block/parent page-id + :block/page page-id})))) + [] + favorited-ids)] + (ldb/transact! repo-or-conn tx {::imported-data? true ::new-graph? true}))) + +(defn- favorite-config-page-name + "OG :favorites may be a bare name or a [[page]] ref." + [page-name] + (if (string? page-name) + (page-ref/get-page-name! (string/trim page-name)) + page-name)) + +(defn- find-namespace-page + "Resolve a flattened namespace page by walking parent/child after import. + OG `foo/bar` is stored as page `bar` whose parent is `foo`." + [db page-name] + (let [parts (string/split page-name ns-util/namespace-char)] + (when (next parts) + (reduce (fn [parent part] + (when parent + (some (fn [child] + (when (and (page-entity? child) + (= (common-util/page-name-sanity-lc part) + (:block/name child))) + child)) + (:block/_parent parent)))) + (ldb/get-page db (first parts)) + (rest parts))))) + +(defn- get-imported-favorite-page + [db page-name] + (let [page-name' (favorite-config-page-name page-name)] + (or (ldb/get-page db page-name') + (when (and (string? page-name') + (ns-util/namespace-page? page-name')) + (find-namespace-page db page-name'))))) + +(defn- export-favorites-from-config-edn + [conn repo config {:keys [log-fn] :or {log-fn prn}}] + (when-let [favorites (seq (:favorites config))] + (p/do! + (if-let [favorited-ids + (seq (keep (fn [page-name] + (some-> (get-imported-favorite-page @conn page-name) + :block/uuid)) + favorites))] + (let [page-entity (ldb/get-page @conn common-config/favorites-page-name)] + (insert-favorites repo favorited-ids (:db/id page-entity))) + (log-fn :no-favorites-found {:favorites favorites}))))) + +(defn build-doc-options + "Builds options for use with export-doc-files and assets" + [config options] + (-> {:extract-options {:date-formatter (get-date-formatter config) + ;; Remove config keys that break importing + :user-config (dissoc config :property-pages/excludelist :property-pages/enabled?) + :filename-format (or (:file/name-format config) :legacy) + :verbose (:verbose options)} + :user-config config + :user-options (merge {:remove-inline-tags? true :convert-all-tags? true} (:user-options options)) + :import-state (new-import-state) + :macros (or (:macros options) (:macros config))} + (merge (select-keys options [:set-ui-state :> (d/datoms db :avet :block/parent) + (keep (fn [d] + (let [child (d/entity db (:e d)) + parent (d/entity db (:v d))] + (when (and (nil? (:block/parent parent)) (page-entity? child) (page-entity? parent)) + parent)))) + (common-util/distinct-by :block/uuid)) + tx-data (map + (fn [parent] + {:db/id (:db/id parent) + :block/parent [:block/uuid library-id] + :block/order (db-order/gen-key)}) + top-parent-pages)] + (ldb/transact! repo-or-conn tx-data))) + +(defn export-file-graph + "Main fn which exports a file graph given its files and imports them + into a DB graph. Files is expected to be a seq of maps with a :path key. + The user experiences this as an import so all user-facing messages are + described as import. options map contains the following keys: + * :set-ui-state - fn which updates ui to indicate progress of import + * :notify-user - fn which notifies user of important messages with a map + containing keys :msg, :level and optionally :ex-data when there is an error + * :log-fn - fn which logs developer messages + * :rpath-key - keyword used to get relative path in file map. Default to :path + * : + (p/let [config (export-config-file + repo-or-conn config-file (select-keys options [:notify-user :default-config : (get f rpath-key) path/path-normalize)) + logseq-file? #(string/starts-with? (normalized-rpath %) "logseq/") + asset-file? #(string/starts-with? (normalized-rpath %) "assets/") + doc-files (->> files + (remove #(or (logseq-file? %) (asset-file? %))) + (filter #(contains? #{"md" "org" "markdown" "edn"} (path/file-ext (:path %))))) + asset-files (filter asset-file? files) + doc-options (build-doc-options config options)] + (log-fn "Importing" (count doc-files) "files ...") + ;; These export* fns are all the major export/import steps + (p/do! + (export-logseq-files repo-or-conn (filter logseq-file? files) (select-keys options [:notify-user : (:import-state doc-options) + ;; don't leak full asset content (which could be large) out of this ns + (dissoc :assets)) + :files files}))) + (p/finally (fn [_] + (reset! gp-block/*export-to-db-graph? false))) + (p/catch (fn [e] + (reset! gp-block/*export-to-db-graph? false) + (js/console.error e) + ((:notify-user options) + {:msg (str "Import has unexpected error:\n" (.-message e)) + :level :error + :ex-data {:error e}}) + (throw e))))) diff --git a/deps/graph-parser/src/logseq/graph_parser/extract.cljc b/deps/graph-parser/src/logseq/graph_parser/extract.cljc new file mode 100644 index 0000000..0b4d86f --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/extract.cljc @@ -0,0 +1,327 @@ +(ns logseq.graph-parser.extract + "For file graphs, handles extraction of blocks, pages and mldoc ast in + preparation for db transaction" + ;; Disable clj linters since we don't support clj + #?(:clj {:clj-kondo/config {:linters {:unresolved-namespace {:level :off} + :unresolved-symbol {:level :off}}}}) + (:require #?(:org.babashka/nbb [logseq.common.log :as log] + :default [lambdaisland.glogi :as log]) + [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] + [logseq.db :as ldb] + [logseq.graph-parser.block :as gp-block] + [logseq.graph-parser.mldoc :as gp-mldoc] + [logseq.graph-parser.property :as gp-property] + [logseq.graph-parser.text :as text])) + +(defn- mldoc-support? + [format'] + (contains? #{:org :markdown :md} (keyword format'))) + +(defn- path->file-name + ;; Only for internal paths, as they are converted to POXIS already + ;; https://github.com/logseq/logseq/blob/48b8e54e0fdd8fbd2c5d25b7f1912efef8814714/deps/graph-parser/src/logseq/graph_parser/extract.cljc#L32 + ;; Should be converted to POXIS first for external paths + [path] + (if (string/includes? path "/") + (last (common-util/split-last "/" path)) + path)) + +(defn- path->file-body + [path] + (when-let [file-name (path->file-name path)] + (if (string/includes? file-name ".") + (first (common-util/split-last "." file-name)) + file-name))) + +(defn- safe-url-decode + [string] + (if (string/includes? string "%") + (some-> string str common-util/safe-decode-uri-component) + string)) + +(defn- decode-namespace-underlines + "Decode namespace underlines to slashed; + If continuous underlines, only decode at start; + Having empty namespace is invalid." + [string] + (string/replace string "___" "/")) + +(defn- make-valid-namespaces + "Remove those empty namespaces from title to make it a valid page name." + [title] + (->> (string/split title "/") + (remove empty?) + (string/join "/"))) + +(defn- tri-lb-title-parsing + "Parsing file name under the new file name format + Avoid calling directly" + [file-name] + (some-> file-name + (decode-namespace-underlines) + (string/replace common-util/url-encoded-pattern safe-url-decode) + (make-valid-namespaces))) + +;; Keep for backward compatibility +;; Rule of dir-ver 0 +;; Source: https://github.com/logseq/logseq/blob/e7110eea6790eda5861fdedb6b02c2a78b504cd9/deps/graph-parser/src/logseq/graph_parser/extract.cljc#L35 +(defn- legacy-title-parsing + [file-name-body] + (let [title (string/replace file-name-body "." "/")] + (or (common-util/safe-decode-uri-component title) title))) + +(defn title-parsing + "Convert file name in the given file name format to page title" + [file-name-body filename-format] + (case filename-format + :triple-lowbar (tri-lb-title-parsing file-name-body) + (legacy-title-parsing file-name-body))) + +(defn- get-page-name + "Get page name with overridden order of + `title::` property + file name parsing + first block content + note: `page-name-order` is deprecated on Apr. 2021 + uri-encoded? - since paths on mobile are uri-encoded, need to decode them first + filename-format - the format used to parse file name + " + [file-path ast uri-encoded? filename-format] + ;; headline + (let [ast (map first ast) + file (if uri-encoded? (js/decodeURI file-path) file-path)] + ;; check backward compatibility? + ;; FIXME: use pre-config dir + (if (string/starts-with? file "pages/contents.") + "Contents" + (let [first-block (last (first (filter gp-block/heading-block? ast))) + property-name (some (fn [ast-node] + (when (contains? #{"Properties" "Property_Drawer"} (first ast-node)) + (let [properties-ast (second ast-node) + properties (zipmap (map (comp keyword string/lower-case first) properties-ast) + (map second properties-ast))] + (:title properties)))) + ast) + first-block-name (let [title (last (first (:title first-block)))] + (and first-block + (string? title) + title)) + file-name (when-let [result (path->file-body file)] + (if (mldoc-support? (common-util/get-file-ext file)) + (title-parsing result filename-format) + result))] + (or property-name + file-name + first-block-name))))) + +(defn- extract-page-alias-and-tags + [page-m page-name properties] + (let [alias (:alias properties) + alias' (if (coll? alias) alias [(str alias)]) + aliases (and alias' + (seq (remove #(or (= page-name (common-util/page-name-sanity-lc %)) + (string/blank? %)) ;; disable blank alias + alias'))) + aliases' (keep + (fn [alias] + (let [page-name (common-util/page-name-sanity-lc alias)] + {:block/name page-name + :block/title alias})) + aliases) + result (cond-> page-m + (seq aliases') + (assoc :block/alias aliases') + + (:tags properties) + (assoc :block/tags (concat + (:block/tags page-m) + (let [tags (:tags properties) + tags (if (coll? tags) tags [(str tags)]) + tags (remove string/blank? tags)] + (map (fn [tag] {:block/name (common-util/page-name-sanity-lc tag) + :block/title tag}) + tags)))))] + (update result :block/properties #(apply dissoc % gp-property/editable-linkable-built-in-properties)))) + +(defn- build-page-map + [properties invalid-properties properties-text-values file page page-name + {:keys [date-formatter db from-page skip-journal?]}] + (let [[*valid-properties *invalid-properties] + ((juxt filter remove) + (fn [[k _v]] (gp-property/valid-property-name? (str k))) properties) + valid-properties (into {} *valid-properties) + invalid-properties (set (->> (map (comp name first) *invalid-properties) + (concat invalid-properties))) + page-m (-> + (common-util/remove-nils-non-nested + (assoc + (gp-block/page-name->map page db true date-formatter + :from-page from-page + :skip-journal? skip-journal?) + :block/file {:file/path (common-util/path-normalize file)})) + (extract-page-alias-and-tags page-name properties))] + (cond-> + page-m + + (seq valid-properties) + (assoc :block/properties valid-properties + :block/properties-text-values (select-keys properties-text-values (keys valid-properties))) + + (seq invalid-properties) + (assoc :block/invalid-properties invalid-properties)))) + +(defn- attach-block-ids-if-match + "If block-ids are provided and match the number of blocks, attach them to blocks + If block-ids are provided but don't match the number of blocks, WARN and ignore + If block-ids are not provided (nil), just ignore" + [block-ids blocks] + (or (when block-ids + (if (= (count block-ids) (count blocks)) + (mapv (fn [block-id block] + (if (some? block-id) + (assoc block :block/uuid (uuid block-id)) + block)) + block-ids blocks) + (log/error :gp-extract/attach-block-ids-not-match "attach-block-ids-if-match: block-ids provided, but doesn't match the number of blocks, ignoring"))) + blocks)) + +(defn- build-pages-aux + [db page-map ref-pages date-formatter format] + (let [namespace-pages (let [page (:block/title page-map)] + (when (and (not (:block/journal-day page-map)) + (text/namespace-page? page)) + (->> (common-util/split-namespace-pages page) + (map (fn [page] + (-> (gp-block/page-name->map page db true date-formatter) + (assoc :block/format format))))))) + pages (->> (concat + [page-map] + @ref-pages + namespace-pages) + ;; remove block references + (remove vector?) + (remove nil?) + (filter :block/name)) + pages (common-util/distinct-by :block/name pages) + pages (remove nil? pages)] + (map (fn [page] + (let [page-id (if-let [journal-day (:block/journal-day page)] + (common-uuid/gen-uuid :journal-page-uuid journal-day) + (or (when db + (:block/uuid (ldb/get-page db (:block/name page)))) + (d/squuid)))] + (assoc page :block/uuid page-id))) + pages))) + +;; TODO: performance improvement +(defn- extract-pages-and-blocks + "uri-encoded? - if is true, apply URL decode on the file path + options - + :resolve-uuid-fn - Optional fn which is called to resolve uuids of each block. Enables diff-merge + (2 ways diff) based uuid resolution upon external editing. + returns a list of the uuids, given the receiving ast, or nil if not able to resolve. + Implemented in reset-file-handler/diff-merge-uuids-2ways for IoC + Called in gp-extract/extract as AST is being parsed and properties are extracted there" + [format ast properties file content {:keys [date-formatter db filename-format resolve-uuid-fn skip-journal?] + :or {resolve-uuid-fn (constantly nil)} + :as options}] + (assert db "Datascript DB is required") + (try + (let [page (get-page-name file ast false filename-format) + [page page-name _journal-day] + (if skip-journal? + [page (common-util/page-name-sanity-lc page) nil] + (gp-block/convert-page-if-journal page date-formatter)) + options' (assoc options :page-name page-name) + ;; In case of diff-merge (2way) triggered, use the uuids to override the ones extracted from the AST + override-uuids (resolve-uuid-fn format ast content options') + *extracted-block-ids (atom #{}) + blocks (->> (gp-block/extract-blocks ast content format options') + (attach-block-ids-if-match override-uuids) + (mapv #(gp-block/fix-block-id-if-duplicated! db page-name *extracted-block-ids %)) + ;; FIXME: use page uuid + (gp-block/with-parent-and-order {:block/name page-name}) + (vec)) + ref-pages (atom #{}) + blocks (map (fn [block] + (if (= (:block/type block) "macro") + block + (let [block-ref-pages (seq (:block/refs block))] + (when block-ref-pages + (swap! ref-pages set/union (set block-ref-pages))) + (-> block + (dissoc :ref-pages) + (assoc :block/page [:block/name page-name] + :block/refs block-ref-pages + :block/format format))))) + blocks) + [properties invalid-properties properties-text-values] + (if (:block/pre-block? (first blocks)) + [(:block/properties (first blocks)) + (:block/invalid-properties (first blocks)) + (:block/properties-text-values (first blocks))] + [properties [] {}]) + page-map (build-page-map properties invalid-properties properties-text-values file page page-name (assoc options' :from-page page)) + pages (build-pages-aux db page-map ref-pages date-formatter format) + blocks (->> (remove nil? blocks) + (map (fn [b] (dissoc b :block.temp/ast-title :block.temp/ast-body :block/level :block/children :block/meta))))] + [pages blocks]) + (catch :default e + (log/error :exception e)))) + +(defn extract + "Extracts pages, blocks and ast from given file" + [file-path content {:keys [user-config verbose] :or {verbose true} :as options}] + (if (string/blank? content) + {} + (let [format (common-util/get-format file-path) + _ (when verbose (println "Parsing start: " file-path)) + ast (gp-mldoc/->edn content (gp-mldoc/default-config format + ;; {:parse_outline_only? true} + ))] + (when verbose (println "Parsing finished: " file-path)) + (let [first-block (ffirst ast) + properties (let [properties (and (gp-property/properties-ast? first-block) + (->> (last first-block) + (map (fn [[x y mldoc-ast]] + (let [k (if (keyword? x) + (subs (str x) 1) + x)] + [(string/lower-case k) (text/parse-property k y mldoc-ast (assoc user-config :format format))]))) + (into {}) + (walk/keywordize-keys)))] + (when (and properties (seq properties)) + (if (:filters properties) + (update properties :filters + (fn [v] + (string/replace (or v "") "\\" ""))) + properties))) + [pages blocks] (extract-pages-and-blocks format ast properties file-path content options)] + {:pages pages + :blocks blocks + :ast ast})))) + +(defn- with-block-uuid + [pages] + (->> (common-util/distinct-by :block/name pages) + (map (fn [page] + (if-let [journal-day (:block/journal-day page)] + (assoc page :block/uuid (common-uuid/gen-uuid :journal-page-uuid journal-day)) + (cond-> page + (nil? (:block/uuid page)) + (assoc :block/uuid (d/squuid)))))))) + +(defn with-ref-pages + [pages blocks] + (let [ref-pages (->> (mapcat :block/refs blocks) + (filter :block/name))] + (->> (concat pages ref-pages) + (group-by :block/name) + vals + (map (partial apply merge)) + (with-block-uuid)))) diff --git a/deps/graph-parser/src/logseq/graph_parser/mldoc.cljc b/deps/graph-parser/src/logseq/graph_parser/mldoc.cljc new file mode 100644 index 0000000..576a4a8 --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/mldoc.cljc @@ -0,0 +1,417 @@ +(ns logseq.graph-parser.mldoc + "Wraps https://github.com/logseq/mldoc to parse files into mldoc ast. This ns + encapsulates mldoc's json api by only taking and returning edn" + ;; Disable clj linters since we don't support clj + #?(:clj {:clj-kondo/config {:linters {:unresolved-namespace {:level :off} + :unresolved-symbol {:level :off}}}}) + (:require #?(:org.babashka/nbb ["mldoc$default" :refer [Mldoc]] + :default ["mldoc" :refer [Mldoc]]) + #?(:org.babashka/nbb [logseq.common.log :as log] + :default [lambdaisland.glogi :as log]) + [cljs-bean.core :as bean] + [clojure.string :as string] + [clojure.walk :as walk] + [goog.object :as gobj] + [logseq.common.config :as common-config] + [logseq.common.util :as common-util] + [logseq.db.sqlite.util :as sqlite-util] + [logseq.graph-parser.utf8 :as utf8])) + +(defonce parseJson (gobj/get Mldoc "parseJson")) +(defonce parseInlineJson (gobj/get Mldoc "parseInlineJson")) +(defonce astExportMarkdown (gobj/get Mldoc "astExportMarkdown")) +(defonce getReferences (gobj/get Mldoc "getReferences")) + +(def default-references + (js/JSON.stringify + (clj->js {:embed_blocks [] + :embed_pages []}))) + +(defn- convert-export-md-remove-options [opts] + (->> opts + (mapv (fn [opt] + (case opt + :page-ref ["Page_ref"] + :emphasis ["Emphasis"] + []))) + (remove empty?))) + +(defn parse-json + [content config] + (parseJson content config)) + +(defn inline-parse-json + [text config] + (parseInlineJson text config)) + +(defn get-references + [text config] + (when-not (string/blank? text) + (common-util/json->clj (getReferences text config)))) + +(defn ast-export-markdown + [ast config references] + (astExportMarkdown ast + config + (or references default-references))) + +(defn default-config-map + ([format] + (default-config-map format {:export-heading-to-list? false})) + ([format {:keys [export-heading-to-list? export-keep-properties? export-md-indent-style export-md-remove-options parse_outline_only?]}] + (let [format (string/capitalize (name (or format :markdown)))] + (->> {:toc false + :parse_outline_only (or parse_outline_only? false) + :heading_number false + :keep_line_break true + :format format + :heading_to_list (or export-heading-to-list? false) + :exporting_keep_properties export-keep-properties? + :export_md_indent_style export-md-indent-style + :export_md_remove_options + (convert-export-md-remove-options export-md-remove-options)} + (filter #(not (nil? (second %)))) + (into {}))))) + +(defn default-config + ([format] + (default-config format {:export-heading-to-list? false})) + ([format opts] + (->> (default-config-map format opts) + bean/->js + js/JSON.stringify))) + +(defn remove-indentation-spaces + "Remove the indentation spaces from the content. Only for markdown. + level - ast level + 1 (2 for the first level, 3 for the second level, etc., as the non-first line of multi-line block has 2 more space + Ex. + - level 1 multiline block first line + level 1 multiline block second line + \t- level 2 multiline block first line + \t level 2 multiline block second line + remove-first-line? - apply the indentation removal to the first line or not" + [s level remove-first-line?] + (let [lines (string/split-lines s) + [f & r] lines + body (map (fn [line] + ;; Check if the indentation area only contains white spaces + ;; Level = ast level + 1, 1-based indentation level + ;; For markdown in Logseq, the indentation area for the non-first line of multi-line block is (ast level - 1) * "\t" + 2 * "(space)" + (if (string/blank? (common-util/safe-subs line 0 level)) + ;; If valid, then remove the indentation area spaces. Keep the rest of the line (might contain leading spaces) + (common-util/safe-subs line level) + ;; Otherwise, trim these invalid spaces + (string/triml line))) + (if remove-first-line? lines r)) + content (if remove-first-line? body (cons f body))] + (string/join "\n" content))) + +(defn- update-src-full-content + [ast content] + (let [content (utf8/encode content)] + (map (fn [[block pos-meta]] + (if (and (vector? block) + (= "Src" (first block))) + (let [{:keys [start_pos end_pos]} pos-meta + content (utf8/substring content start_pos end_pos) + spaces (re-find #"^[\t ]+" (first (string/split-lines content))) + content (if spaces (remove-indentation-spaces content (count spaces) true) + content) + block ["Src" (assoc (second block) :full_content content)]] + [block pos-meta]) + [block pos-meta])) ast))) + +(def ^:private inline-ast-types + #{"Plain" "Spaces" "Link" "Nested_link" "Target" "Subscript" "Superscript" + "Footnote_Reference" "Cookie" "Latex_Fragment" "Macro" "Entity" "Timestamp" + "Radio_Target" "Export_Snippet" "Inline_Source_Block" "Email" "Inline_Hiccup" + "Inline_Html" "Emphasis" "Verbatim" "Code" "Break_Line" "Hard_Break_Line"}) + +(defn- inline-coll? + [x] + (and (vector? x) + (seq x) + (every? #(and (vector? %) + (string? (first %)) + (contains? inline-ast-types (first %))) + x))) + +(defn- inline-ast->source + ;; Only reconstructs the source text for the node types that appear when + ;; mldoc splits a macro containing ^{}/{_{}. Nested rich-inline types + ;; (Emphasis, Code, etc.) inside a Superscript/Subscript will produce nil, + ;; which (apply str ...) silently drops. This is intentional: the function + ;; is only called during macro-recovery, not for general rendering. + [[typ content]] + (case typ + "Plain" content + "Spaces" content + "Link" (:full_text content) + ;; Nested_link is the AST node for [[page name]]; :content is the page name + ;; string. Without this case, collect-macro-source aborts via when-let + ;; whenever a page ref appears inside a fragmented macro. + "Nested_link" (str "[[" (:content content) "]]") + "Superscript" (str "^{" (apply str (map inline-ast->source content)) "}") + "Subscript" (str "_{" (apply str (map inline-ast->source content)) "}") + nil)) + +(defn- starts-with-at? + [s prefix idx] + (let [end (+ idx (count prefix))] + (and (<= end (count s)) + (= prefix (subs s idx end))))) + +(defn- unclosed-script-markup? + [s] + (loop [idx 0 + depth 0] + (if (< idx (count s)) + (cond + (or (starts-with-at? s "^{" idx) + (starts-with-at? s "_{" idx)) + (recur (+ idx 2) (inc depth)) + + (and (pos? depth) (= \} (nth s idx))) + (recur (inc idx) (dec depth)) + + :else + (recur (inc idx) depth)) + (pos? depth)))) + +(defn- split-macro-arguments + [s] + (if (string/blank? s) + [] + (loop [idx 0 + start 0 + page-ref-depth 0 + script-depth 0 + quoted? false + escaped? false + result []] + (if (< idx (count s)) + (let [c (nth s idx)] + (cond + escaped? + (recur (inc idx) start page-ref-depth script-depth quoted? false result) + + (= \\ c) + (recur (inc idx) start page-ref-depth script-depth quoted? true result) + + (= \" c) + (recur (inc idx) start page-ref-depth script-depth (not quoted?) false result) + + quoted? + (recur (inc idx) start page-ref-depth script-depth quoted? false result) + + (starts-with-at? s "[[" idx) + (recur (+ idx 2) start (inc page-ref-depth) script-depth quoted? false result) + + (and (pos? page-ref-depth) (starts-with-at? s "]]" idx)) + (recur (+ idx 2) start (dec page-ref-depth) script-depth quoted? false result) + + (or (starts-with-at? s "^{" idx) + (starts-with-at? s "_{" idx)) + (recur (+ idx 2) start page-ref-depth (inc script-depth) quoted? false result) + + (and (pos? script-depth) (= \} c)) + (recur (inc idx) start page-ref-depth (dec script-depth) quoted? false result) + + (and (zero? page-ref-depth) (zero? script-depth) (= \, c)) + (recur (inc idx) (inc idx) page-ref-depth script-depth quoted? false + (conj result (string/trim (subs s start idx)))) + + :else + (recur (inc idx) start page-ref-depth script-depth quoted? false result))) + (conj result (string/trim (subs s start))))))) + +(defn- macro-source->ast + [s] + (when (and (string/starts-with? s "{{") + (string/ends-with? s "}}")) + (let [content (-> s + (subs 2 (- (count s) 2)) + string/triml) + [_ name arguments] (re-matches #"([^\s]+)(?:\s+([\s\S]*))?" content)] + (when name + ["Macro" {:name name + :arguments (split-macro-arguments arguments)}])))) + +(defn- collect-macro-source + [items] + (loop [remaining items + source ""] + (when-let [[item & more] (seq remaining)] + (let [[typ content] item] + (cond + (= "Plain" typ) + (if-let [idx (string/index-of content "}}")] + (let [macro-source (str source (subs content 0 (+ idx 2)))] + (when-let [macro (macro-source->ast macro-source)] + (let [suffix (subs content (+ idx 2))] + {:macro macro + :suffix (when-not (string/blank? suffix) ["Plain" suffix]) + :remaining more}))) + (recur more (str source content))) + + :else + (when-let [item-source (inline-ast->source item)] + (recur more (str source item-source)))))))) + +(defn- close-script-markup-in-macro + ;; Appends a single "}" to the last argument to close the outermost + ;; unmatched ^{/{_ bracket. Handles only depth-1 cases; deeper nesting + ;; (e.g. Ca^{^{2}}) is not supported and is not expected in practice. + [macro] + (update-in macro [1 :arguments] + (fn [arguments] + (update arguments (dec (count arguments)) str "}")))) + +(defn- recover-inline-macros + [inline-list] + (loop [remaining inline-list + result []] + (if-let [[item & more] (seq remaining)] + (let [[typ content] item] + (cond + (and (= "Plain" typ) + (string/includes? content "{{")) + (let [idx (string/index-of content "{{") + prefix (subs content 0 idx) + start-item ["Plain" (subs content idx)]] + (if-let [{:keys [macro suffix remaining]} (collect-macro-source (cons start-item more))] + (recur (if suffix + (cons suffix remaining) + remaining) + (cond-> result + (not (string/blank? prefix)) (conj ["Plain" prefix]) + true (conj macro))) + (recur more (conj result item)))) + + (and (= "Macro" typ) + (seq (:arguments content)) + (unclosed-script-markup? (last (:arguments content))) + (= "Plain" (ffirst more)) + (string/starts-with? (second (first more)) "}")) + (let [[_ plain] (first more) + suffix (subs plain 1)] + (recur (if (string/blank? suffix) + (rest more) + (cons ["Plain" suffix] (rest more))) + (conj result (close-script-markup-in-macro item)))) + + :else + (recur more (conj result item)))) + result))) + +(defn- normalize-macro-asts + [ast] + (walk/postwalk + (fn [x] + (if (inline-coll? x) + (recover-inline-macros x) + x)) + ast)) + +(defn- macro-with-script-markup? + [content] + (and (string/includes? content "{{") + (or (string/includes? content "^{") + (string/includes? content "_{")))) + +(defn collect-page-properties + [ast config] + (when (seq ast) + (let [original-ast ast + directive? (fn [[item _]] (= "directive" (string/lower-case (first item)))) + grouped-ast (group-by directive? original-ast) + [properties-ast other-ast] [(->> (get grouped-ast true) + (map first)) + (get grouped-ast false)] + properties (map (fn [[_directive k v]] + [k v (get-references v config)]) + properties-ast)] + (if (seq properties) + (cons [["Properties" properties] nil] other-ast) + original-ast)))) + +(defn get-default-config + "Gets a mldoc default config for the given format. Works for DB and file graphs" + [repo format] + (let [db-based? (sqlite-util/db-based-graph? repo)] + (->> + (cond-> (default-config-map format) + db-based? + (assoc :enable_drawers false + :parse_marker false + :parse_priority false)) + bean/->js + js/JSON.stringify))) + +(defn ->edn + ;; TODO: Re-enable schema + ;; {:malli/schema [:=> [:cat :string :string] mldoc-schema/block-ast-with-pos-coll-schema]} + ([content config] + (if (string? content) + (try + (if (string/blank? content) + [] + (-> content + (parse-json config) + (common-util/json->clj) + (cond-> (macro-with-script-markup? content) + (normalize-macro-asts)) + (update-src-full-content content) + (collect-page-properties config))) + (catch :default e + (log/error :unexpected-error e) + [])) + (log/error :edn/wrong-content-type content))) + ([repo content format] + (->edn content (get-default-config repo format)))) + +(defn ->db-edn + "Wrapper around ->edn for DB graphs" + [content format] + (->edn "logseq_db_repo_stub" content format)) + +(defn inline->edn + [text config] + (try + (if (string/blank? text) + {} + (-> text + (inline-parse-json config) + (common-util/json->clj) + (cond-> (macro-with-script-markup? text) + (normalize-macro-asts)))) + (catch :default _e + []))) + +(defn ast-link? + [[type link]] + (let [[ref-type ref-value] (:url link)] + (and (= "Link" type) + (or + ;; 1. url + (not (contains? #{"Page_ref" "Block_ref"} ref-type)) + + (and (contains? #{"Page_ref"} ref-type) + (boolean (common-config/local-relative-asset? ref-value))))))) + +(defn mldoc-link? + "Check whether s is a link (including page/block refs)." + [format s] + (let [result (inline->edn s (default-config format))] + (and + (= 1 (count result)) + (let [result' (first result)] + (or (contains? #{"Nested_link"} (first result')) + (contains? #{"Page_ref" "Block_ref" "Complex"} (first (:url (second result'))))))))) + +(defn block-with-title? + [type] + (contains? #{"Paragraph" + "Raw_Html" + "Hiccup" + "Heading"} type)) diff --git a/deps/graph-parser/src/logseq/graph_parser/property.cljs b/deps/graph-parser/src/logseq/graph_parser/property.cljs new file mode 100644 index 0000000..ff3aaa5 --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/property.cljs @@ -0,0 +1,221 @@ +(ns logseq.graph-parser.property + "For file graphs, core vars and util fns for properties" + (:require [clojure.set :as set] + [clojure.string :as string] + [goog.string :as gstring] + [goog.string.format] + [logseq.common.util :as common-util])) + +(def colons "Property delimiter for markdown mode" "::") +(defn colons-org + "Property delimiter for org mode" + [property] + (str ":" property ":")) + +(defn properties-ast? + [block] + (and + (vector? block) + (contains? #{"Property_Drawer" "Properties"} + (first block)))) + +(defn valid-property-name? + [s] + {:pre [(string? s)]} + (and (common-util/valid-edn-keyword? s) + (not (re-find #"[\"|^|(|)|{|}]+" s)) + ;; Disallow tags as property names + (not (re-find #"^:#" s)))) + +;; Built-in properties are properties that logseq uses for its features. Most of +;; these properties are hidden from the user but a few like the editable ones +;; are visible for the user to edit. + +(def editable-linkable-built-in-properties + "Properties used by logseq that user can edit and that can have linkable property values" + #{:alias :aliases :tags}) + +(defn editable-built-in-properties + "Properties used by logseq that user can edit" + [] + (set/union #{:title :icon :template :template-including-parent :public :filters :exclude-from-graph-view + :logseq.query/nlp-date + ;; org-mode only + :macro :filetags} + editable-linkable-built-in-properties)) + +(defn hidden-built-in-properties + "Properties used by logseq that user can't edit or see" + [] + #{:custom-id :background_color :created_at :last_modified_at ; backward compatibility only + :id :background-color :heading :collapsed + :created-at :updated-at :last-modified-at + :query-table :query-properties :query-sort-by :query-sort-desc :ls-type + :hl-type :hl-page :hl-stamp :hl-color :hl-value :logseq.macro-name :logseq.macro-arguments + :logseq.order-list-type + ; task markers + :todo :doing :now :later :done}) + +(def built-in-property-types + "Types for built-in properties. Built-in properties whose values are to be + parsed by gp-text/parse-non-string-property-value should be added here" + {:template-including-parent :boolean + :public :boolean + :exclude-from-graph-view :boolean + :logseq.query/nlp-date :boolean + :heading :boolean ; FIXME: or integer + :collapsed :boolean + :created-at :integer + :created_at :integer + :updated-at :integer + :last-modified-at :integer + :last_modified_at :integer + :query-table :boolean + :query-sort-desc :boolean + :hl-page :integer + :hl-stamp :integer + :todo :integer + :doing :integer + :now :integer + :later :integer + :done :integer}) + +(assert (set/subset? (set (keys built-in-property-types)) + (set/union (hidden-built-in-properties) + (editable-built-in-properties))) + "Keys of built-in-property-types must be valid built-in properties") + +(defn unparsed-built-in-properties + "Properties whose values will not be parsed by gp-text/parse-property" + [] + (set/difference (set/union (hidden-built-in-properties) + (editable-built-in-properties)) + ;; Refs need to be parsed + editable-linkable-built-in-properties + ;; All these should be parsed by gp-text/parse-non-string-property-value + (set (keys built-in-property-types)))) + +(defonce properties-start ":PROPERTIES:") +(defonce properties-end ":END:") +(defonce properties-end-pattern + (re-pattern (gstring/format "%s[\t\r ]*\n|(%s\\s*$)" properties-end properties-end))) + +(defn- contains-properties? + [content] + (when content + (and (string/includes? content properties-start) + (re-find properties-end-pattern content)))) + +(defn ->new-properties + "New syntax: key:: value" + [content] + (if (contains-properties? content) + (let [lines (string/split-lines content) + start-idx (.indexOf lines properties-start) + end-idx (.indexOf lines properties-end)] + (if (and (>= start-idx 0) (> end-idx 0) (> end-idx start-idx)) + (let [before (subvec lines 0 start-idx) + middle (->> (subvec lines (inc start-idx) end-idx) + (map (fn [text] + (let [[k v] (common-util/split-first ":" (subs text 1))] + (if (and k v) + (let [k (string/replace k "_" "-") + compare-k (keyword (string/lower-case k)) + k (if (contains? #{:id :custom_id :custom-id} compare-k) "id" k) + k (if (contains? #{:last-modified-at} compare-k) "updated-at" k)] + (str k colons " " (string/trim v))) + text))))) + after (subvec lines (inc end-idx)) + lines (concat before middle after)] + (string/join "\n" lines)) + content)) + content)) + +(defn simplified-property? + [line] + (boolean + (and (string? line) + (re-find (re-pattern (str "^\\s?[^ ]+" colons)) line)))) + +(defn remove-properties + [format content] + (cond + (contains-properties? content) + (let [lines (string/split-lines content) + [title-lines properties&body] (split-with #(-> (string/triml %) + string/upper-case + (string/starts-with? properties-start) + not) + lines) + body (drop-while #(-> (string/trim %) + string/upper-case + (string/starts-with? properties-end) + not + (or (string/blank? %))) + properties&body) + body (if (and (seq body) + (-> (first body) + string/triml + string/upper-case + (string/starts-with? properties-end))) + (let [line (string/replace (first body) #"(?i):END:\s?" "")] + (if (string/blank? line) + (rest body) + (cons line (rest body)))) + body)] + (->> (concat title-lines body) + (string/join "\n"))) + + (not= format :org) + (let [lines (string/split-lines content) + lines (if (simplified-property? (first lines)) + (drop-while simplified-property? lines) + (cons (first lines) + (drop-while simplified-property? (rest lines))))] + (string/join "\n" lines)) + + :else + content)) + +(defn remove-logbook + [content] + (when (string? content) + (let [lines (string/split-lines content) + [result _in-logbook?] + (reduce (fn [[acc in-logbook?] line] + (let [trimmed (string/trim line) + upper (string/upper-case trimmed)] + (cond + (string/starts-with? upper ":LOGBOOK:") + [acc true] + + (and in-logbook? (string/starts-with? upper ":END:")) + [acc false] + + in-logbook? + [acc true] + + :else + [(conj acc line) in-logbook?]))) + [[] false] + lines)] + (string/join "\n" result)))) + +(defn remove-deadline-scheduled + [content] + (when (string? content) + (let [lines (string/split-lines content)] + (if (= 1 (count lines)) + content + (let [first-line (first lines) + rest-lines (rest lines) + rest-lines (keep (fn [line] + (let [upper (string/upper-case (string/triml line))] + (if (or (string/starts-with? upper "DEADLINE: ") + (string/starts-with? upper "SCHEDULED: ")) + (let [cleaned (-> line (string/replace #"(?i)(?:^|\s)(DEADLINE|SCHEDULED):\s+<[^>]*>" "") string/trim)] + (when-not (string/blank? cleaned) + cleaned)) + line))) + rest-lines)] + (string/join "\n" (cons first-line rest-lines))))))) diff --git a/deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc b/deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc new file mode 100644 index 0000000..4231770 --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc @@ -0,0 +1,217 @@ +(ns logseq.graph-parser.schema.mldoc + "Malli schema for mldoc AST") + +(defn- field-optional-and-maybe-nil + [k v] + [k {:optional true} [:maybe v]]) + +(def pos-schema + [:map + [:start_pos :int] + [:end_pos :int]]) + +(def nested-link-schema + [:schema {:registry {::nested-link + [:map + [:content :string] + [:children [:sequential [:or + [:tuple [:= "Label"] :string] + [:tuple [:= "Nested_link"] [:ref ::nested-link]]]]]]}} + ::nested-link]) + +(def timestamp-schema + [:map + [:date [:map + [:year :int] + [:month :int] + [:day :int]]] + [:wday :string] + (field-optional-and-maybe-nil + :time [:map + [:hour :int] + [:min :int]]) + (field-optional-and-maybe-nil + :repetition + :any) + [:active :boolean]]) + +(def ^:private time-range-schema + [:map + [:start [:ref ::timestamp]] + [:stop [:ref ::timestamp]]]) + +(def ^:private link-schema + [:map + [:url [:or + [:cat [:= "File"] :string] + [:cat [:= "Search"] :string] + [:cat [:= "Complex"] [:map + [:protocol :string] + [:link :string]]] + [:cat [:= "Page_ref"] :string] + [:cat [:= "Block_ref"] :string] + [:cat [:= "Embed_data"] :string]]] + [:label [:sequential [:ref ::inline]]] + (field-optional-and-maybe-nil :title :string) + [:full_text :string] + [:metadata :string]]) + +(def latex-fragment-schema + [:or + [:tuple [:= "Inline"] :string] + [:tuple [:= "Displayed"] :string]]) + +(def inline-ast-schema + [:schema {:registry {::timestamp timestamp-schema + ::time-range time-range-schema + ::link link-schema + ::inline + [:or + [:tuple [:= "Emphasis"] + [:tuple + [:tuple [:enum "Italic" "Bold" "Underline" "Strike_through" "Highlight"]] + [:sequential [:ref ::inline]]]] + + [:tuple [:= "Break_Line"]] + [:tuple [:= "Hard_Break_Line"]] + [:tuple [:= "Verbatim"] :string] + [:tuple [:= "Code"] :string] + [:tuple [:= "Tag"] [:sequential [:ref ::inline]]] + [:tuple [:= "Spaces"] :string] + [:tuple [:= "Plain"] :string] + [:tuple [:= "Link"] [:ref ::link]] + [:tuple [:= "Nested_link"] nested-link-schema] + [:tuple [:= "Target"] :string] + [:tuple [:= "Subscript"] [:sequential [:ref ::inline]]] + [:tuple [:= "Superscript"] [:sequential [:ref ::inline]]] + [:tuple [:= "Footnote_Reference"] [:map + [:id :int] + [:name :string] + (field-optional-and-maybe-nil + :definition [:sequential [:ref ::inline]])]] + [:tuple [:= "Cookie"] [:or + [:tuple [:= "Percent"] :int] + [:catn [:label [:= "Absolute"]] [:current :int] [:total :int]]]] + [:tuple [:= "Latex_Fragment"] latex-fragment-schema] + [:tuple [:= "Macro"] [:map + [:name :string] + [:arguments [:sequential :string]]]] + [:tuple [:= "Entity"] [:map + [:name :string] + [:latex :string] + [:latex_mathp :boolean] + [:html :string] + [:ascii :string] + [:unicode :string]]] + [:tuple [:= "Timestamp"] [:or + [:tuple [:= "Scheduled"] [:ref ::timestamp]] + [:tuple [:= "Deadline"] [:ref ::timestamp]] + [:tuple [:= "Date"] [:ref ::timestamp]] + [:tuple [:= "Closed"] [:ref ::timestamp]] + [:tuple [:= "Clock"] [:or + [:tuple [:= "Started"] [:ref ::timestamp]] + [:tuple [:= "Stopped"] [:ref ::time-range]]]] + [:tuple [:= "Range"] [:ref ::time-range]]]] + [:tuple [:= "Radio_Target"] :string] + [:tuple [:= "Export_Snippet"] :string :string] + [:tuple [:= "Inline_Source_Block"] [:map + [:language :string] + [:options :string] + [:code :string]]] + [:tuple [:= "Email"] [:map + [:local_part :string] + [:domain :string]]] + [:tuple [:= "Inline_Hiccup"] :string] + [:tuple [:= "Inline_Html"] :string]]}} + ::inline]) + +(def ^:private list-item-schema + [:map + [:content [:sequential [:ref ::block]]] + [:items [:sequential [:ref ::list-item]]] + (field-optional-and-maybe-nil + :number :int) + [:name [:sequential [:ref ::inline]]] + (field-optional-and-maybe-nil + :checkbox :boolean) + [:indent :int] + [:ordered :boolean]]) + +(def ^:private heading-schema + [:map + [:title [:sequential [:ref ::inline]]] + [:tags [:sequential :string]] + (field-optional-and-maybe-nil + :marker :string) + [:level :int] + (field-optional-and-maybe-nil + :numbering [:sequential :int]) + (field-optional-and-maybe-nil + :priority :string) + [:anchor :string] + [:meta :map] + (field-optional-and-maybe-nil + :size :int)]) + +(def block-ast-schema + [:schema {:registry {::inline inline-ast-schema + ::list-item list-item-schema + ::block + [:or + [:tuple [:= "Paragraph"] [:sequential [:ref ::inline]]] + [:tuple [:= "Paragraph_Sep"] :int] + [:tuple [:= "Heading"] heading-schema] + [:tuple [:= "List"] [:sequential [:ref ::list-item]]] + [:tuple [:= "Directive"] :string :string] + [:tuple [:= "Results"]] + [:tuple [:= "Example"] [:sequential :string]] + [:tuple [:= "Src"] [:map + [:lines [:sequential :string]] + (field-optional-and-maybe-nil + :language :string) + (field-optional-and-maybe-nil + :options [:sequential :string]) + [:pos_meta pos-schema]]] + [:tuple [:= "Quote"] [:sequential [:ref ::block]]] + [:catn + [:label [:= "Export"]] + [:type :string] + [:options [:maybe [:sequential :string]]] + [:content :string]] + [:tuple [:= "CommentBlock"] [:sequential :string]] + [:catn + [:label [:= "Custom"]] + [:type :string] + [:options [:maybe :string]] + [:result [:sequential [:ref ::block]]] + [:content :string]] + [:tuple [:= "Latex_Fragment"] latex-fragment-schema] + [:catn + [:label [:= "Latex_Environment"]] + [:name :string] + [:options [:maybe :string]] + [:content :string]] + [:tuple [:= "Displayed_Math"] :string] + [:tuple [:= "Drawer"] :string [:sequential :string]] + [:tuple [:= "Property_Drawer"] + [:sequential + [:catn [:k :string] [:v :string] [:other-info [:sequential [:ref ::inline]]]]]] + [:tuple [:= "Footnote_Definition"] :string [:sequential [:ref ::inline]]] + [:tuple [:= "Horizontal_Rule"]] + [:tuple [:= "Table"] + [:map + (field-optional-and-maybe-nil + :header [:sequential [:sequential [:ref ::inline]]]) + [:groups [:sequential [:sequential [:sequential [:sequential [:ref ::inline]]]]]] + [:col_groups [:sequential :int]]]] + [:tuple [:= "Comment"] :string] + [:tuple [:= "Raw_Html"] :string] + [:tuple [:= "Hiccup"] :string] + + ;; this block type is not from mldoc, + ;; but from `logseq.graph-parser.mldoc/collect-page-properties` + [:tuple [:= "Properties"] [:sequential :any]]]}} + ::block]) + +(def block-ast-with-pos-coll-schema + [:sequential [:cat block-ast-schema [:maybe pos-schema]]]) diff --git a/deps/graph-parser/src/logseq/graph_parser/text.cljs b/deps/graph-parser/src/logseq/graph_parser/text.cljs new file mode 100644 index 0000000..9455ca0 --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/text.cljs @@ -0,0 +1,171 @@ +(ns logseq.graph-parser.text + "Miscellaneous text util fns for the parser. Used by file and DB graphs" + (:require ["path" :as node-path] + [clojure.set :as set] + [clojure.string :as string] + [goog.string :as gstring] + [logseq.common.util :as common-util] + [logseq.common.util.namespace :as ns-util] + [logseq.common.util.page-ref :as page-ref] + [logseq.graph-parser.mldoc :as gp-mldoc] + [logseq.graph-parser.property :as gp-property])) + +(def get-file-basename page-ref/get-file-basename) + +(defn- get-file-rootname + "Returns the rootname of a file path. e.g. /a/b/c.md -> c" + [path] + (when-not (string/blank? path) + (.-name (node-path/parse (string/replace path "+" "/"))))) + +(defn get-page-name + "Similar to page-ref/get-page-name but handles format-specific page-refs e.g. org/md" + [s] + (and (string? s) + (or (when-let [[_ label _path] (re-matches page-ref/markdown-page-ref-re s)] + (string/trim label)) + (when-let [[_ path _label] (re-matches #"\[\[(file:.*)\]\[.+?\]\]" s)] + (some-> (get-file-rootname path) + (string/replace "." "/"))) + (-> (re-matches page-ref/page-ref-any-re s) + second)))) + +(def page-ref-un-brackets! page-ref/page-ref-un-brackets!) + +(defn get-nested-page-name + [page-name] + (when-let [first-match (re-find page-ref/page-ref-without-nested-re page-name)] + (second first-match))) + +(defn- remove-level-space-aux! + [text pattern space? trim-left?] + (let [pattern (gstring/format + (if space? + "^[%s]+\\s+" + "^[%s]+\\s?") + pattern) + text (if trim-left? (string/triml text) text)] + (string/replace-first text (re-pattern pattern) ""))) + +(defn remove-level-spaces + ([text format block-pattern] + (remove-level-spaces text format block-pattern false true)) + ([text format block-pattern space?] + (remove-level-spaces text format block-pattern space? true)) + ([text format block-pattern space? trim-left?] + (when format + (cond + (string/blank? text) + "" + + (and (= "markdown" (name format)) + (string/starts-with? text "---")) + text + + :else + (remove-level-space-aux! text block-pattern space? trim-left?))))) + +(defn parse-non-string-property-value + "Return parsed non-string property value or nil if none is found" + [v] + (cond + (= v "true") + true + + (= v "false") + false + + (re-find #"^\d+$" v) + (parse-long v))) + +(defn- get-ref-from-ast + [[typ data]] + (case typ + "Link" + (case (first (:url data)) + "Page_ref" + (second (:url data)) + + "Search" + (second (:url data)) + + nil) + + "Nested_link" + (get-page-name (:content data)) + + "Tag" + (if (= "Plain" (ffirst data)) + (second (first data)) + (get-ref-from-ast (first data))) + + nil)) + +(defn extract-refs-from-mldoc-ast + [v] + (into #{} + (comp + (remove gp-mldoc/ast-link?) + (keep get-ref-from-ast) + (map string/trim)) + v)) + +(defn- sep-by-comma + [s] + {:pre (string? s)} + (->> + (string/split s #"[\,,]{1}") + (map string/trim) + (remove string/blank?) + (set))) + +(defn separated-by-commas? + [config-state k] + (let [k' (if (keyword? k) k (keyword k))] + (contains? (set/union gp-property/editable-linkable-built-in-properties + (set (get config-state :property/separated-by-commas))) + k'))) + +(defn- extract-refs-by-commas + [v format] + (let [plains (->> (map first (gp-mldoc/->edn v (gp-mldoc/default-config format))) + first + second + (filter #(and (vector? %) (= "Plain" (first %)))) + (map second))] + (set (mapcat sep-by-comma plains)))) + +(defn- parse-property-refs [k v mldoc-references-ast config-state] + (let [refs (extract-refs-from-mldoc-ast mldoc-references-ast) + property-separated-by-commas? (separated-by-commas? config-state k)] + (if property-separated-by-commas? + (->> (extract-refs-by-commas v (get config-state :format :markdown)) + (set/union refs)) + refs))) + +(defn parse-property + "Property value parsing that takes into account built-in properties, format + and user config" + [k v mldoc-references-ast config-state] + (let [v' (string/trim (str v))] + (cond + (contains? (set/union + (set (map name (gp-property/unparsed-built-in-properties))) + (get config-state :ignored-page-references-keywords)) + (name k)) + v' + + (common-util/wrapped-by-quotes? v') + v' + + ;; parse property value as needed + :else + (let [refs (parse-property-refs k v' mldoc-references-ast config-state)] + (if (seq refs) + refs + (if-some [new-val (parse-non-string-property-value v')] + new-val + v')))))) + +(def namespace-page? ns-util/namespace-page?) +(def get-namespace-last-part ns-util/get-last-part) \ No newline at end of file diff --git a/deps/graph-parser/src/logseq/graph_parser/utf8.cljs b/deps/graph-parser/src/logseq/graph_parser/utf8.cljs new file mode 100644 index 0000000..cb7407d --- /dev/null +++ b/deps/graph-parser/src/logseq/graph_parser/utf8.cljs @@ -0,0 +1,24 @@ +(ns logseq.graph-parser.utf8 + "Utf8 utilities used by the parser") + +(defonce encoder + (js/TextEncoder. "utf-8")) + +(defonce decoder + (js/TextDecoder. "utf-8")) + +(defn encode + [s] + (.encode encoder s)) + +(defn decode + [arr] + (.decode decoder arr)) + +(defn substring + ([arr start] + (decode (.subarray arr start))) + ([arr start end] + (if end + (decode (.subarray arr start end)) + (decode (.subarray arr start))))) diff --git a/deps/graph-parser/test/logseq/graph_parser/block_test.cljs b/deps/graph-parser/test/logseq/graph_parser/block_test.cljs new file mode 100644 index 0000000..6afc47d --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/block_test.cljs @@ -0,0 +1,171 @@ +(ns logseq.graph-parser.block-test + (:require [cljs.test :refer [deftest are testing is]] + [datascript.core :as d] + [logseq.common.uuid :as common-uuid] + [logseq.db.test.helper :as db-test] + [logseq.graph-parser.block :as gp-block] + [logseq.graph-parser.mldoc :as gp-mldoc])) + +(defn- extract-properties + [properties user-config] + (gp-block/extract-properties + (map + (fn [[k v]] + (let [mldoc-ast (gp-mldoc/get-references v (gp-mldoc/default-config :markdown))] + [k v mldoc-ast])) + properties) + user-config)) + +(deftest test-fix-duplicate-id + (are [x y] + (let [result (gp-block/fix-duplicate-id (gp-block/block-keywordize x))] + (and (:block/uuid result) + (not= (:uuid x) (:block/uuid result)) + (= (select-keys result + [:block/properties :block/title :block/properties-text-values :block/properties-order]) (gp-block/block-keywordize y)))) + {:properties {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :tags [], :format :markdown, :meta {:start_pos 51, :end_pos 101}, :macros [], :title "bar\nid:: 63f199bc-c737-459f-983d-84acfcda14fe", :properties-text-values {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :level 1, :uuid #uuid "63f199bc-c737-459f-983d-84acfcda14fe", :properties-order [:id]} + {:properties {}, + :title "bar", + :properties-text-values {}, + :properties-order []} + + {:properties {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :tags [], :format :org, :meta {:start_pos 51, :end_pos 101}, :macros [], :title "bar\n:id: 63f199bc-c737-459f-983d-84acfcda14fe", :properties-text-values {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :level 1, :uuid #uuid "63f199bc-c737-459f-983d-84acfcda14fe", :properties-order [:id]} + {:properties {}, + :title "bar", + :properties-text-values {}, + :properties-order []} + + {:properties {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :tags [], :format :markdown, :meta {:start_pos 51, :end_pos 101}, :macros [], :title "bar\n \n id:: 63f199bc-c737-459f-983d-84acfcda14fe\nblock body", :properties-text-values {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :level 1, :uuid #uuid "63f199bc-c737-459f-983d-84acfcda14fe", :properties-order [:id]} + {:properties {}, + :title "bar\nblock body", + :properties-text-values {}, + :properties-order []})) + +(deftest test-extract-properties + (are [x y] (= (:properties (extract-properties x {})) y) + ;; Built-in properties + [["background-color" "#000000"]] {:background-color "#000000"} + [["alias" "[[name/with space]]"]] {:alias #{"name/with space"}} + [["tags" "[[foo]], [[bar]]"]] {:tags #{"foo" "bar"}} + [["tags" "[[foo]] [[bar]]"]] {:tags #{"foo" "bar"}} + [["tags" "bar"]] {:tags #{"bar"}} + [["file-path" "file:///home/x, y.pdf"]] {:file-path "file:///home/x, y.pdf"} + + ;; User properties + [["year" "1000"]] {:year 1000} + [["year" "\"1000\""]] {:year "\"1000\""} + [["year" "1000"] ["alias" "[[name/with space]]"]] {:year 1000, :alias #{"name/with space"}} + [["year" "1000"] ["tags" "[[name/with space]]"]] {:year 1000, :tags #{"name/with space"}} + [["year" "1000"] ["tags" "[[name/with space]], [[another]]"]] {:year 1000, :tags #{"name/with space" "another"}} + [["year" "1000"] ["alias" "[[name/with space]], [[another]]"]] {:year 1000, :alias #{"name/with space" "another"}} + [["year" "1000"] ["alias" "[[name/with space]], [[another [[nested]]]]"]] {:year 1000, :alias #{"name/with space" "another [[nested]]"}} + [["year" "1000"] ["alias" "[[name/with space]], [[[[nested]] another]]"]] {:year 1000, :alias #{"name/with space" "[[nested]] another"}} + [["foo" "bar"]] {:foo "bar"} + [["foo" "[[bar]], [[baz]]"]] {:foo #{"bar" "baz"}} + [["foo" "[[bar]], [[baz]]"]] {:foo #{"bar" "baz"}} + [["foo" "[[bar]], [[baz]]"]] {:foo #{"bar" "baz"}} + [["foo" "[[bar]], [[nested [[baz]]]]"]] {:foo #{"bar" "nested [[baz]]"}} + [["foo" "[[bar]], [[nested [[baz]]]]"]] {:foo #{"bar" "nested [[baz]]"}} + [["foo" "[[bar]], [[baz, test]]"]] {:foo #{"bar" "baz, test"}} + [["foo" "[[bar]], [[baz, test, [[nested]]]]"]] {:foo #{"bar" "baz, test, [[nested]]"}}) + + (testing "page-refs" + (are [x y] (= (vec (:page-refs + (extract-properties x {:property-pages/enabled? true}))) y) + [["year" "1000"]] ["year"] + [["year" "\"1000\""]] ["year"] + [["year" "1000"] ["month" "12"]] ["year" "month"] + [["foo" "[[bar]] test"]] ["bar" "foo"] + [["foo" "[[bar]] test [[baz]]"]] ["bar" "baz" "foo"] + [["foo" "[[bar]] test [[baz]] [[nested [[baz]]]]"]] ["bar" "baz" "nested [[baz]]" "foo"] + [["foo" "#bar, #baz"]] ["bar" "baz" "foo"] + [["foo" "[[nested [[page]]]], test"]] ["nested [[page]]" "foo"]) + + (are [x y] (= (vec (:page-refs + (extract-properties x {:property-pages/enabled? false}))) y) + [["year" "1000"]] [] + [["year" "1000"] ["month" "12"]] [] + [["foo" "[[bar]] test"]] ["bar"]) + + (is (= ["year"] + (:page-refs + (extract-properties [["year" "1000"] ["month" "12"]] + {:property-pages/enabled? true + :property-pages/excludelist #{:month :day}}))) + ":property-pages/exclude-list excludes specified properties") + + (is (= ["year"] + (:page-refs + (extract-properties [["year" "1000"]] + {}))) + "Default to enabled when :property-pages/enabled? is not in config") + + (is (= ["foo" "bar" "tags"] + (:page-refs + (extract-properties + ;; tags is linkable and background-color is not + [["tags" "[[foo]], [[bar]]"] ["background-color" "#008000"]] + {:property-pages/enabled? true}))) + "Only editable linkable built-in properties have page-refs in property values"))) + +(deftest test-page-name-map-namespace-for-slash-journals + (testing "slash-formatted journals do not keep namespace metadata" + (let [journal (gp-block/page-name->map "2026/05/18" nil false "yyyy/MM/dd")] + (is (= 20260518 (:block/journal-day journal))) + (is (= "2026/05/18" (:block/title journal))) + (is (= "may 18th, 2026" (:block/name journal))) + (is (= (common-uuid/gen-uuid :journal-page-uuid 20260518) + (:block/uuid journal))) + (is (nil? (:block/namespace journal))))) + (testing "non-journal slash pages keep namespace metadata" + (is (= {:block/name "project"} + (:block/namespace (gp-block/page-name->map "project/child" nil false "yyyy/MM/dd")))))) + +(deftest existing-journal-reference-reuses-stored-identity-test + (testing "a legacy journal name is not rewritten while parsing a reference" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:build/journal 20260727}}]}) + journal (db-test/find-journal-by-journal-day @conn 20260727) + journal-uuid (:block/uuid journal)] + (d/transact! conn [[:db/add :logseq.class/Journal + :logseq.property.journal/title-format "yyyy-MM-dd"] + {:db/id (:db/id journal) + :block/title "2026-07-27" + :block/name "2026-07-27"}]) + (let [reference (gp-block/page-name->map "2026-07-27" @conn false "yyyy-MM-dd")] + (is (= {:block/uuid journal-uuid + :block/title "2026-07-27" + :block/name "2026-07-27" + :block/journal-day 20260727} + (select-keys reference + [:block/uuid :block/title :block/name :block/journal-day])))))) + + (testing "an existing journal is found by day after the configured format changes" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks [{:page {:build/journal 20260727}}]}) + journal (db-test/find-journal-by-journal-day @conn 20260727) + journal-uuid (:block/uuid journal)] + (d/transact! conn [[:db/add :logseq.class/Journal + :logseq.property.journal/title-format "yyyy-MM-dd"] + {:db/id (:db/id journal) + :block/title "2026-07-27" + :block/name "2026-07-27"}]) + (d/transact! conn [[:db/add :logseq.class/Journal + :logseq.property.journal/title-format "dd/MM/yyyy"]]) + (let [reference (gp-block/page-name->map "27/07/2026" @conn false "dd/MM/yyyy")] + (is (= {:block/uuid journal-uuid + :block/title "2026-07-27" + :block/name "2026-07-27" + :block/journal-day 20260727} + (select-keys reference + [:block/uuid :block/title :block/name :block/journal-day]))))))) + +(defn find-block-for-content + [db content] + (->> (d/q '[:find (pull ?b [* {:block/refs [:block/uuid]}]) + :in $ ?content + :where [?b :block/title ?content]] + db + content) + (map first) + first)) diff --git a/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs b/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs new file mode 100644 index 0000000..059e00c --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs @@ -0,0 +1,2484 @@ +(ns ^:node-only logseq.graph-parser.exporter-test + (:require ["fs" :as fs] + ["os" :as os] + ["path" :as node-path] + [cljs.test :refer [are deftest is testing]] + [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [logseq.common.config :as common-config] + [logseq.common.graph :as common-graph] + [logseq.common.path :as path] + [logseq.common.util.block-ref :as block-ref] + [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.entity-plus :as entity-plus] + [logseq.db.frontend.asset :as db-asset] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.malli-schema :as db-malli-schema] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.rules :as rules] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.test.helper :as db-test] + [logseq.graph-parser.block :as gp-block] + [logseq.graph-parser.exporter :as gp-exporter] + [logseq.graph-parser.test.docs-graph-helper :as docs-graph-helper] + [logseq.graph-parser.test.helper :as test-helper :include-macros true :refer [deftest-async]] + [logseq.outliner.db-pipeline :as db-pipeline] + [promesa.core :as p])) + +;; Helpers +;; ======= +;; some have been copied from db-import script + +(defn- extract-rules + [rules] + (rules/extract-rules rules/db-query-dsl-rules + rules + {:deps rules/rules-dependencies})) + +(defn- find-block-by-property [db property] + (d/q '[:find [?b ...] + :in $ ?prop % + :where (has-property ?b ?prop)] + db property (extract-rules [:has-property]))) + +(defn- find-block-by-property-value [db property property-value] + (->> (d/q '[:find [?b ...] + :in $ ?prop ?prop-value % + :where (property ?b ?prop ?prop-value)] + db property property-value (extract-rules [:property])) + first + (d/entity db))) + +(defn- ordered-children + [block] + (->> (:block/_parent block) + (remove :logseq.property/created-from-property) + (sort-by :block/order) + vec)) + +(defn- block-tree-with-properties + [block] + {:title (:block/title block) + :properties (dissoc (db-test/readable-properties block) :block/tags) + :children (mapv block-tree-with-properties (ordered-children block))}) + +(defn- find-template-by-title + [db title] + (some->> (d/q '[:find [?b ...] + :in $ ?title + :where + [?b :block/title ?title] + [?b :block/tags :logseq.class/Template]] + db title) + first + (d/entity db))) + +(defn- template-content-trees + [db title] + (some->> (find-template-by-title db title) + ordered-children + (mapv block-tree-with-properties))) + +(defn- block-status + [db content] + (:logseq.property/status (db-test/find-block-by-content db content))) + +(defn- status-content + [db content] + (db-property/closed-value-content (block-status db content))) + +(defn- status-closed-value-contents + [db] + (set (map db-property/closed-value-content + (db-property/get-closed-property-values db :logseq.property/status)))) + +(defn- status-closed-value-content-frequencies + [db] + (frequencies (map db-property/closed-value-content + (db-property/get-closed-property-values db :logseq.property/status)))) + +(defn- blocks-by-title + [db title] + (->> (d/q '[:find [?b ...] + :in $ ?title + :where + [?b :block/page] + [?b :block/title ?title]] + db title) + (map #(d/entity db %)))) + +(defn- task-blocks-by-title + [db title] + (->> (d/q '[:find [?b ...] + :in $ ?title + :where + [?b :block/page] + [?b :block/title ?title] + [?b :block/tags :logseq.class/Task]] + db title) + (map #(d/entity db %)))) + +(defn- report-retracts-block-uuid? + [tx-report block-uuid] + (boolean + (some (fn [datom] + (and (= :block/uuid (:a datom)) + (= block-uuid (:v datom)) + (false? (:added datom)))) + (:tx-data tx-report)))) + +(defn- imported-favorite-titles + [db] + (->> (ldb/get-page-blocks db (:db/id (ldb/get-page db common-config/favorites-page-name))) + (keep :block/link) + (map #(d/entity db (:db/id %))) + (map ldb/get-title-with-parents))) + + +(defn- build-graph-files + "Given a file graph directory, return all files including assets and adds relative paths + on ::rpath since paths are absolute by default and exporter needs relative paths for + some operations" + [dir*] + (let [dir (node-path/resolve dir*)] + (->> (common-graph/get-files dir) + (concat (when (fs/existsSync (path/path-join dir* "assets")) + (common-graph/readdir (path/path-join dir* "assets")))) + (mapv #(hash-map :path % + ::rpath (node-path/relative dir* %)))))) + +(defn- (get-in m [:ex-data :error]) ex-data :sci.impl/callstack deref)] + (println (string/join + "\n" + (map + #(str (:file %) + (when (:line %) (str ":" (:line %))) + (when (:sci.impl/f-meta %) + (str " calls #'" (get-in % [:sci.impl/f-meta :ns]) "/" (get-in % [:sci.impl/f-meta :name])))) + (reverse stack)))) + (println (some-> (get-in m [:ex-data :error]) .-stack)))) + (when (= :error (:level m)) + (js/process.exit 1))) + +(def default-export-options + {;; common options + :rpath-key ::rpath + :notify-user notify-user + :name (:path file)) + asset-type (db-asset/asset-path->type (:path file)) + {:keys [with-edn-content pdf-annotation?]} (buffer-handler buffer)] + (when-not pdf-annotation? + (swap! *asset-ids conj asset-id)) + (swap! assets assoc asset-name + (with-edn-content + {:size (.-length buffer) + :type asset-type + :path (:path file) + :checksum checksum + :asset-id asset-id})) + buffer)) + +;; Copied from db-import script and tweaked for an in-memory import +(defn- import-file-graph-to-db + "Import a file graph dir just like UI does. However, unlike the UI the + exporter receives file maps containing keys :path and ::rpath since :path + are full paths" + [file-graph-dir conn {:keys [assets] :or {assets (atom [])} :as options}] + (let [*files (build-graph-files file-graph-dir) + config-file (first (filter #(string/ends-with? (:path %) "logseq/config.edn") *files)) + _ (assert config-file "No 'logseq/config.edn' found for file graph dir") + options' (merge default-export-options + {:user-options (merge {:convert-all-tags? false} (dissoc options :assets :verbose)) + ;; asset file options + : (p/let [doc-options (gp-exporter/build-doc-options (merge {:macros {} :file/name-format :triple-lowbar} + (:user-config options)) + (merge default-export-options + {:user-options (merge {:convert-all-tags? false} + (dissoc options :user-config :verbose))} + (select-keys options [:verbose]))) + files' (mapv #(hash-map :path %) files) + _ (gp-exporter/export-doc-files conn files' \n" + 1 " DEADLINE: <2026-01-09 Fri +2d>\n" + "") + extra-lines (generated-extra-lines *seed index) + nested-line (when (zero? (next-random! *seed 3)) + (str " - nested generated block " index + " [[Generated Nested " (next-random! *seed 30) "]]\n"))] + (str "- " title-prefix "generated block " index + " [[" page-ref "]] ((" ref-id ")) #" tag title-suffix "\n" + temporal-line + " id:: " id-value "\n" + " generated-ref:: [[" page-name "]]\n" + " generated-rank:: " (next-random! *seed 100) "\n" + extra-lines + nested-line))) + +(defn- generated-md-file-content + [*seed file-index page-name block-count] + (str (generated-page-preamble *seed page-name) + (apply str + (map #(generated-md-block *seed (+ (* file-index 100) %)) + (range block-count))))) + +(defn- generated-md-file-graph + [seed] + (let [*seed (atom seed) + pages (map-indexed + (fn [index page-name] + [(str "pages/" (string/replace (string/lower-case page-name) " " "_") ".md") + (generated-md-file-content *seed index page-name (+ 8 (next-random! *seed 8)))]) + generated-file-graph-page-names) + journals [["journals/2026_01_05.md" + (generated-md-file-content *seed 20 "2026_01_05" (+ 8 (next-random! *seed 8)))] + ["journals/2026_01_06.md" + (generated-md-file-content *seed 21 "2026_01_06" (+ 8 (next-random! *seed 8)))] + ["assets/generated.md" + "Generated asset content\n"]]] + (into {"logseq/config.edn" "{:preferred-format :markdown\n :journal/page-title-format \"yyyy_MM_dd\"}\n"} + (concat pages journals)))) + +(defn- assert-generated-md-file-graph-imports + [seed] + (let [graph-dir (write-temp-file-graph (generated-md-file-graph seed))] + (p/let [conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-file-graph-to-db graph-dir conn {}) + generated-block-count (->> (d/q '[:find [?title ...] + :where [?b :block/title ?title]] + @conn) + (filter #(string/includes? % "generated block")) + count) + validation-errors (map :entity (:errors (db-validate/validate-local-db! @conn)))] + (is (<= 60 generated-block-count) + (str "Seed " seed " imports the generated block corpus")) + (is (empty? validation-errors) + (str "Seed " seed " generated Markdown file graph validates"))))) + +(defn- assert-generated-md-file-graphs-import + [seeds] + (p/loop [remaining-seeds (seq seeds)] + (when remaining-seeds + (p/let [_ (assert-generated-md-file-graph-imports (first remaining-seeds))] + (p/recur (next remaining-seeds)))))) + +;; Tests +;; ===== + +(deftest-async import-block-with-journal-ref-and-time-property-value + (p/let [file (write-temp-graph-file + "journals/2023_06_21.md" + "- DONE foo bar #sometag1 #sometag2\n completed:: [[Sun, 06.08.2023]] *14:42*\n") + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {:user-config {:journal/page-title-format "EEE, dd.MM.yyyy"}})] + (is (some? (db-test/find-block-by-content @conn #"foo bar")) + "Block with a journal reference plus time in a property value imports") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates"))) + +(deftest-async import-quote-with-email-address + (p/let [file (write-temp-graph-file + "pages/email.md" + "- > \"CachyOS \"\n") + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {})] + (is (= "\"CachyOS \"" + (:block/title (db-test/find-block-by-content @conn #"CachyOS"))) + "Email addresses inside quotes are preserved during import") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates"))) + +(deftest-async import-org-page-title-when-property-appears-in-middle + (p/let [file (write-temp-graph-file + "pages/20230410145300-end_to_end_note.org" + ":PROPERTIES: +:ID: c537c812-1ec9-4f13-adaf-1a39fd7da967 +:END: +#+title: end_to_end_note +#+date: <2023-04-10 Mon 14:53> +#+filetags: :PUBLIC: + +abc + +#+hugo: more + +123 +") + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {})] + (is (some? (db-test/find-page-by-title @conn "end_to_end_note")) + "Org #+title is imported when another property appears in the middle of the file") + (is (nil? (db-test/find-page-by-title @conn "20230410145300-end_to_end_note")) + "Importer should not fall back to the org-roam file stem") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates"))) + +(deftest-async import-empty-journal-file + (p/let [file (write-temp-graph-file "journals/2025_11_11.md" "\n") + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {})] + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Empty imported files do not transact nil block refs"))) + +(deftest-async import-repeated-deadline-and-scheduled + (p/let [file (write-temp-graph-file + "pages/repeated-tasks.md" + (str "- TODO wish [[name]] a happy birthday\n" + " SCHEDULED: <2025-11-01 Sat 08:00 .+1y>\n" + "- TODO prepare weekly report\n" + " DEADLINE: <2025-11-07 Fri +2w>\n" + "- TODO plan release\n" + " DEADLINE: <2025-11-08 Sat>\n" + " SCHEDULED: <2025-11-07 Fri .+1w>\n")) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {})] + (let [birthday-properties (db-test/readable-properties + (db-test/find-block-by-content @conn #"happy birthday")) + report-properties (db-test/readable-properties + (db-test/find-block-by-content @conn #"weekly report")) + mixed-properties (db-test/readable-properties + (db-test/find-block-by-content @conn #"plan release")) + birthday-scheduled (:logseq.property/scheduled birthday-properties) + birthday-date (js/Date. birthday-scheduled)] + (is (= 20251101 (date-time-util/ms->journal-day birthday-scheduled)) + "Repeated scheduled timestamp keeps its scheduled date") + (is (= [8 0] [(.getHours birthday-date) (.getMinutes birthday-date)]) + "Repeated scheduled timestamp keeps its time") + (is (= {:logseq.property.repeat/repeated? true + :logseq.property.repeat/temporal-property :logseq.property/scheduled + :logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.dotted-plus + :logseq.property.repeat/recur-frequency 1 + :logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.year} + (select-keys birthday-properties + [:logseq.property.repeat/repeated? + :logseq.property.repeat/temporal-property + :logseq.property.repeat/repeat-type + :logseq.property.repeat/recur-frequency + :logseq.property.repeat/recur-unit])) + "Repeated scheduled timestamp keeps its repeat properties including the `.+` cookie kind") + (is (= {:logseq.property/deadline 20251107 + :logseq.property.repeat/repeated? true + :logseq.property.repeat/temporal-property :logseq.property/deadline + :logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.plus + :logseq.property.repeat/recur-frequency 2 + :logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.week} + (-> report-properties + (update :logseq.property/deadline date-time-util/ms->journal-day) + (select-keys [:logseq.property/deadline + :logseq.property.repeat/repeated? + :logseq.property.repeat/temporal-property + :logseq.property.repeat/repeat-type + :logseq.property.repeat/recur-frequency + :logseq.property.repeat/recur-unit]))) + "Repeated deadline timestamp keeps its repeat properties including the `+` cookie kind") + (is (= {:logseq.property/deadline 20251108 + :logseq.property/scheduled 20251107 + :logseq.property.repeat/repeated? true + :logseq.property.repeat/temporal-property :logseq.property/scheduled + :logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.dotted-plus + :logseq.property.repeat/recur-frequency 1 + :logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.week} + (-> mixed-properties + (update :logseq.property/deadline date-time-util/ms->journal-day) + (update :logseq.property/scheduled date-time-util/ms->journal-day) + (select-keys [:logseq.property/deadline + :logseq.property/scheduled + :logseq.property.repeat/repeated? + :logseq.property.repeat/temporal-property + :logseq.property.repeat/repeat-type + :logseq.property.repeat/recur-frequency + :logseq.property.repeat/recur-unit]))) + "Mixed deadline and scheduled timestamps keep both dates and the repeated temporal property") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates")))) + +(deftest-async import-preserves-legacy-task-markers-as-status-choices + (p/let [file (write-temp-graph-file + "pages/tasks.md" + (str "- TODO\n" + "- DONE\n" + "- I recorded a [[voice note]].\n" + " - TODO\n" + "- TODO todo item\n" + "- LATER later item\n" + "- NOW now item\n" + "- DOING doing item\n" + "- WAIT waiting item\n" + "- WAITING waiting full item\n" + "- IN-PROGRESS in-progress item\n" + "- DONE done item\n")) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {})] + (is (= :logseq.property/status.todo + (:db/ident (block-status @conn "todo item"))) + "TODO still imports to the built-in Todo status") + (is (= :logseq.property/status.doing + (:db/ident (block-status @conn "doing item"))) + "DOING still imports to the built-in Doing status") + (is (= :logseq.property/status.done + (:db/ident (block-status @conn "done item"))) + "DONE still imports to the built-in Done status") + (is (= {:logseq.property/status.todo 2 + :logseq.property/status.done 1} + (frequencies (map #(-> % db-test/readable-properties :logseq.property/status) + (task-blocks-by-title @conn "")))) + "Built-in task markers without titles still import as tasks") + (let [nested-empty-task (first (ordered-children (db-test/find-block-by-content @conn #"recorded")))] + (is (= "" (:block/title nested-empty-task)) + "Nested nameless task keeps an empty title") + (is (= {:logseq.property/status :logseq.property/status.todo + :block/tags [:logseq.class/Task]} + (select-keys (db-test/readable-properties nested-empty-task) + [:logseq.property/status :block/tags])) + "Nested nameless TODO keeps its task properties")) + (is (= :logseq.property/status.todo + (:db/ident (block-status @conn "later item"))) + "LATER imports to the built-in Todo status") + (is (= :logseq.property/status.doing + (:db/ident (block-status @conn "now item"))) + "NOW imports to the built-in Doing status") + (is (= "WAIT" (status-content @conn "waiting item")) + "WAIT imports as its own status choice") + (is (= "WAITING" (status-content @conn "waiting full item")) + "WAITING imports as its own status choice") + (is (= "IN-PROGRESS" (status-content @conn "in-progress item")) + "IN-PROGRESS imports as its own status choice") + (is (set/subset? #{"WAIT" "WAITING" "IN-PROGRESS"} + (status-closed-value-contents @conn)) + "Custom imported markers are added to Status closed values") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates"))) + +(deftest-async import-custom-task-marker-across-multiple-files + (p/let [first-file (write-temp-graph-file + "pages/custom-status-a.md" + "- WAITING first custom status item\n") + second-file (write-temp-graph-file + "pages/custom-status-b.md" + "- WAITING second custom status item\n") + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [first-file second-file] conn {})] + (is (= "WAITING" (status-content @conn "first custom status item")) + "Custom status marker imports from the first file") + (is (= "WAITING" (status-content @conn "second custom status item")) + "Custom status marker imports from the second file") + (is (= 1 (get (status-closed-value-content-frequencies @conn) "WAITING")) + "Custom status closed value is shared across imported files") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates"))) + +(deftest-async import-repairs-duplicated-block-ids + (let [duplicated-uuid #uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"] + (p/let [file (write-temp-graph-file + "pages/duplicated-ids.md" + (str "- First duplicated id\n" + " id:: " duplicated-uuid "\n" + "- Second duplicated id\n" + " id:: " duplicated-uuid "\n")) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {}) + first-block (db-test/find-block-by-content @conn "First duplicated id") + second-block (db-test/find-block-by-content @conn "Second duplicated id")] + (is (= duplicated-uuid (:block/uuid first-block)) + "The first imported block keeps the original id") + (is (some? (:block/uuid second-block)) + "The duplicate imported block gets a replacement id") + (is (not= duplicated-uuid (:block/uuid second-block)) + "The duplicate imported block does not keep the conflicting id") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates")))) + +(deftest-async import-removes-pre-block-marker-and-missing-block-refs + (let [missing-uuid #uuid "11111111-1111-1111-1111-111111111111" + missing-embed-uuid #uuid "55555555-5555-5555-5555-555555555555" + target-uuid #uuid "22222222-2222-2222-2222-222222222222" + empty-title-property-uuid #uuid "33333333-3333-3333-3333-333333333333" + empty-title-parent-uuid #uuid "44444444-4444-4444-4444-444444444444"] + (p/let [source-file (write-temp-graph-file + "pages/A.md" + (str "Plain pre-block\n" + "- Missing ref ((" missing-uuid "))\n" + "- {{embed ((" missing-embed-uuid "))}}\n" + "- Existing ref ((" target-uuid "))\n" + "- ((" empty-title-property-uuid "))\n" + " heading:: true\n" + " background-color:: yellow\n" + "- ((" empty-title-parent-uuid "))\n" + " - Child survives\n")) + target-file (write-temp-graph-file + "pages/Z.md" + (str "- Target block\n" + " id:: " target-uuid "\n")) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [source-file target-file] conn {}) + missing-block (db-test/find-block-by-content @conn #"Missing ref") + existing-block (db-test/find-block-by-content @conn #"Existing ref") + target-block (db-test/find-block-by-content @conn "Target block") + empty-title-blocks (blocks-by-title @conn "") + empty-title-property-block (some #(when (:logseq.property/heading %) %) empty-title-blocks) + empty-title-parent-block (some #(when (some (fn [child] + (= "Child survives" (:block/title child))) + (ordered-children %)) + %) + empty-title-blocks)] + (is (empty? (filter #(= :block/pre-block? (:a %)) + (d/datoms @conn :eavt))) + "Legacy pre-block markers are never transacted") + (is (= "Missing ref" (:block/title missing-block)) + "Missing OG block refs are removed from imported content") + (is (empty? (:block/refs missing-block)) + "Missing OG block refs are removed from imported refs") + (is (nil? (d/entity @conn [:block/uuid missing-uuid])) + "Missing OG block refs do not leave placeholder entities") + (is (nil? (d/entity @conn [:block/uuid missing-embed-uuid])) + "Missing OG block embeds do not leave placeholder entities") + (is (empty? (d/q '[:find ?b + :where [?b :block/link ?target] + [(missing? $ ?target :block/uuid)]] + @conn)) + "Missing OG block embeds do not leave dangling block links") + (is (nil? (d/entity @conn [:block/uuid empty-title-property-uuid])) + "Missing OG block refs in empty-title property blocks do not leave placeholder entities") + (is (nil? (d/entity @conn [:block/uuid empty-title-parent-uuid])) + "Missing OG block refs in empty-title parent blocks do not leave placeholder entities") + (is (= 3 (count empty-title-blocks)) + "Blocks whose titles become empty after cleanup are preserved") + (is (true? (:logseq.property/heading empty-title-property-block)) + "Empty-title blocks keep imported heading properties") + (is (= "yellow" + (:logseq.property/background-color (db-test/readable-properties empty-title-property-block))) + "Empty-title blocks keep imported background colors") + (is (= ["Child survives"] (mapv :block/title (ordered-children empty-title-parent-block))) + "Empty-title parent blocks keep their children") + (is (= [(:db/id target-block)] (mapv :db/id (:block/refs existing-block))) + "Existing block refs are preserved, including forward refs from later files") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates")))) + +(deftest-async import-generated-markdown-file-graph + (assert-generated-md-file-graphs-import generated-file-graph-test-seeds)) + +(deftest-async ^:integration import-generated-markdown-file-graph-fuzz + (assert-generated-md-file-graphs-import (range 1 101))) + +(deftest-async export-doc-files-propagates-missing-block-ref-cleanup-report + (let [missing-uuid #uuid "55555555-5555-5555-5555-555555555555" + tx-reports (atom [])] + (p/let [file (write-temp-graph-file + "pages/A.md" + (str "- Missing ref ((" missing-uuid "))\n")) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + doc-options (gp-exporter/build-doc-options + {:macros {} :file/name-format :triple-lowbar} + (merge default-export-options + {:user-options {:convert-all-tags? false} + :on-tx-report #(swap! tx-reports conj %)})) + _ (gp-exporter/export-doc-files conn [{:path file}] (gp-exporter/export-doc-files + conn + [{:path first-file} {:path second-file}] + (p/let [conn (db-test/create-conn) + assets (atom []) + {:keys [import-state]} (import-file-graph-to-db graph-dir conn {:assets assets})] + (is (= [{:reason "No asset data found for this asset path" + :path "../assets/missing-paper.pdf" + :location {:block "Missing local PDF [paper](../assets/missing-paper.pdf)"}}] + @(:ignored-assets import-state)) + "Missing local PDF asset links are reported through ignored assets") + (is (empty? @console-errors) + "Missing local PDF asset links are ignored without noisy console errors") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates")) + (p/finally (fn [_] + (set! (.-write (.-stderr js/process)) original-stderr-write)))))) + +(deftest extract-template-blocks + (let [page-uuid (random-uuid) + parent-uuid (random-uuid) + child-uuid (random-uuid) + include-children-only-uuid (random-uuid) + child-only-1-uuid (random-uuid) + child-only-2-uuid (random-uuid) + blocks [{:block/uuid parent-uuid + :block/title "source parent" + :block/page [:block/uuid page-uuid] + :block/parent {:block/uuid page-uuid} + :block/order "a" + :block/properties {:template " trimmed template " + :name ""} + :block/properties-text-values {:template " trimmed template " + :name ""} + :block/properties-order [:template :name]} + {:block/uuid child-uuid + :block/title "child" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid parent-uuid] + :block/order "b" + :block/properties {:template "nested child" + :name "child default"} + :block/properties-text-values {:template "nested child" + :name "child default"} + :block/properties-order [:template :name]} + {:block/uuid include-children-only-uuid + :block/title "exclude source block" + :block/page [:block/uuid page-uuid] + :block/parent {:block/uuid page-uuid} + :block/order "c" + :block/properties {:template "children only" + :template-including-parent false} + :block/properties-text-values {:template "children only" + :template-including-parent "false"} + :block/properties-order [:template :template-including-parent]} + {:block/uuid child-only-1-uuid + :block/title "first child" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid include-children-only-uuid] + :block/order "d"} + {:block/uuid child-only-2-uuid + :block/title "second child" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid include-children-only-uuid] + :block/order "e"}] + {:keys [blocks preserve-empty-properties-uuids]} + (#'gp-exporter/handle-template-blocks blocks)] + (testing "template roots replace source blocks" + (is (= ["trimmed template" + "source parent" + "nested child" + "child" + "children only" + "first child" + "second child"] + (mapv :block/title blocks))) + (is (= #{parent-uuid child-uuid include-children-only-uuid child-only-1-uuid child-only-2-uuid} + (set/intersection preserve-empty-properties-uuids + #{parent-uuid child-uuid include-children-only-uuid child-only-1-uuid child-only-2-uuid})))) + + (testing "template roots use trimmed names and include parent content when configured" + (is (= #{"trimmed template" "nested child" "children only"} + (->> blocks + (filter #(some #{:logseq.class/Template} (:block/tags %))) + (map :block/title) + set))) + (is (= ["source parent"] + (->> blocks + (remove #(some #{:logseq.class/Template} (:block/tags %))) + (filter #(= [:block/uuid parent-uuid] (:block/parent %))) + (map :block/title)))) + (is (= 2 + (count (set/difference preserve-empty-properties-uuids + #{parent-uuid child-uuid include-children-only-uuid child-only-1-uuid child-only-2-uuid}))) + "in-place template content blocks are marked to preserve empty properties")))) + +(deftest-async ^:integration export-docs-graph-with-convert-all-tags + (p/let [file-graph-dir "test/resources/docs-0.10.12" + _ (docs-graph-helper/clone-docs-repo-if-not-exists file-graph-dir "v0.10.12") + start-time (cljs.core/system-time) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + {:keys [import-state]} + (import-file-graph-to-db file-graph-dir conn {:convert-all-tags? true}) + end-time (cljs.core/system-time)] + + ;; Add multiplicative factor for CI as it runs about twice as slow + (let [max-time (-> 25 (* (if js/process.env.CI 2 1)))] + (is (< (-> end-time (- start-time) (/ 1000)) max-time) + (str "Importing large graph takes less than " max-time "s"))) + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + (is (= 0 (count @(:ignored-properties import-state))) "No ignored properties") + (is (= 0 (count @(:ignored-assets import-state))) "No ignored assets") + (is (= [] + (->> (d/q '[:find (pull ?b [:block/title {:block/tags [:db/ident]}]) + :where [?b :block/tags :logseq.class/Tag]] + @conn) + (map first) + (remove #(= [{:db/ident :logseq.class/Tag}] (:block/tags %))))) + "All classes only have :logseq.class/Tag as their tag (and don't have Page)"))) + +(deftest-async import-linked-file-pdf-annotations + (let [annotation-id #uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + area-annotation-id #uuid "cccccccc-cccc-cccc-cccc-cccccccccccc" + dir (fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-graph-parser-test-")) + external-pdf-path (node-path/join dir "external/Linked Paper.pdf") + graph-dir (node-path/join dir "graph") + area-image-stamp "area-stamp" + encoded-pdf-uri (str "file://" (string/replace external-pdf-path " " "%20"))] + (fs/mkdirSync (node-path/dirname external-pdf-path) #js {:recursive true}) + (fs/writeFileSync external-pdf-path "pdf") + (doseq [[relative-path content] + {"logseq/config.edn" "{}" + "pages/source.md" (str "- ![Linked Paper.pdf](" encoded-pdf-uri ")\n") + "pages/hls__Linked Paper.md" (str "file:: [Linked Paper.pdf](" encoded-pdf-uri ")\n" + "file-path:: " encoded-pdf-uri "\n\n" + "- External highlight from linked pdf\n" + " ls-type:: annotation\n" + " hl-page:: 3\n" + " hl-color:: yellow\n" + " id:: " annotation-id "\n" + "- External area highlight from linked pdf\n" + " ls-type:: annotation\n" + " hl-page:: 4\n" + " hl-color:: yellow\n" + " id:: " area-annotation-id "\n") + "assets/Linked Paper.edn" (str "{:highlights [{:id #uuid \"" annotation-id "\"," + " :page 3," + " :position {:bounding {:x1 1 :y1 2 :x2 3 :y2 4 :width 10 :height 20}," + " :rects ()," + " :page 3}," + " :content {:text \"External highlight from linked pdf\"}," + " :properties {:color \"yellow\"}}" + " {:id #uuid \"" area-annotation-id "\"," + " :page 4," + " :position {:bounding {:x1 11 :y1 12 :x2 13 :y2 14 :width 10 :height 20}," + " :rects ()," + " :page 4}," + " :content {:image \"" area-image-stamp "\"}," + " :properties {:color \"yellow\"}}]}") + (str "assets/Linked Paper/4_" area-annotation-id "_" area-image-stamp ".png") "png"}] + (let [file-path (node-path/join graph-dir relative-path)] + (fs/mkdirSync (node-path/dirname file-path) #js {:recursive true}) + (fs/writeFileSync file-path content))) + (p/let [conn (db-test/create-conn) + assets (atom []) + {:keys [import-state]} (import-file-graph-to-db graph-dir conn {:assets assets}) + asset (db-test/find-block-by-content @conn "Linked Paper") + annotation (db-test/find-block-by-content @conn "External highlight from linked pdf") + area-annotation (db-test/find-block-by-content @conn "External area highlight from linked pdf")] + (is (some? asset) + "Linked file PDF imports as an external Asset") + (is (= {:block/tags [:logseq.class/Asset] + :logseq.property.asset/type "pdf" + :logseq.property.asset/external-url encoded-pdf-uri} + (select-keys (db-test/readable-properties asset) + [:block/tags + :logseq.property.asset/type + :logseq.property.asset/external-url])) + "Linked file PDF keeps the file URI as external asset metadata") + (is (= {:block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Linked Paper" + :logseq.property.pdf/hl-page 3} + (select-keys (db-test/readable-properties annotation) + [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-page])) + "Linked file PDF annotations import and point at the external Asset") + (is (= {:block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Linked Paper" + :logseq.property.pdf/hl-page 4 + :logseq.property.pdf/hl-image "pdf area highlight" + :logseq.property.pdf/hl-type :area} + (select-keys (db-test/readable-properties area-annotation) + [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-page + :logseq.property.pdf/hl-image + :logseq.property.pdf/hl-type])) + "Linked file PDF area highlights import their image assets") + (is (= 0 (count @(:ignored-assets import-state))) "No ignored assets")))) + +(deftest-async import-linked-file-pdf-annotations-with-uppercase-extension + (let [annotation-id #uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + dir (fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-graph-parser-test-")) + external-pdf-path (node-path/join dir "external/Linked Paper.PDF") + graph-dir (node-path/join dir "graph") + encoded-pdf-uri (str "file://" (string/replace external-pdf-path " " "%20"))] + (fs/mkdirSync (node-path/dirname external-pdf-path) #js {:recursive true}) + (fs/writeFileSync external-pdf-path "pdf") + (doseq [[relative-path content] + {"logseq/config.edn" "{}" + "pages/source.md" (str "- ![Linked Paper.PDF](" encoded-pdf-uri ")\n") + "pages/hls__Linked Paper.md" (str "file:: [Linked Paper.PDF](" encoded-pdf-uri ")\n" + "file-path:: " encoded-pdf-uri "\n\n" + "- External highlight from linked pdf\n" + " ls-type:: annotation\n" + " hl-page:: 3\n" + " hl-color:: yellow\n" + " id:: " annotation-id "\n") + "assets/Linked Paper.edn" (str "{:highlights [{:id #uuid \"" annotation-id "\"," + " :page 3," + " :position {:bounding {:x1 1 :y1 2 :x2 3 :y2 4 :width 10 :height 20}," + " :rects ()," + " :page 3}," + " :content {:text \"External highlight from linked pdf\"}," + " :properties {:color \"yellow\"}}]}")}] + (let [file-path (node-path/join graph-dir relative-path)] + (fs/mkdirSync (node-path/dirname file-path) #js {:recursive true}) + (fs/writeFileSync file-path content))) + (p/let [conn (db-test/create-conn) + assets (atom []) + {:keys [import-state]} (import-file-graph-to-db graph-dir conn {:assets assets}) + asset (db-test/find-block-by-content @conn "Linked Paper") + annotation (db-test/find-block-by-content @conn "External highlight from linked pdf")] + (is (some? asset) + "Linked file PDF imports as an external Asset") + (is (= {:block/tags [:logseq.class/Asset] + :logseq.property.asset/type "pdf" + :logseq.property.asset/external-url encoded-pdf-uri} + (select-keys (db-test/readable-properties asset) + [:block/tags + :logseq.property.asset/type + :logseq.property.asset/external-url])) + "Linked file PDF keeps the file URI as external asset metadata") + (is (= {:block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Linked Paper" + :logseq.property.pdf/hl-page 3} + (select-keys (db-test/readable-properties annotation) + [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-page])) + "Linked file PDF annotations import and keep highlight positions from the EDN file") + (is (= 0 (count @(:ignored-assets import-state))) "No ignored assets")))) + +(deftest-async ^:integration import-large-flat-file-without-stack-overflow + (p/let [file (write-temp-graph-file + "pages/large.md" + (apply str (map #(str "- large line " % " #tag\n") (range 45000)))) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-files-to-db [file] conn {:convert-all-tags? true})] + (is (= 45000 + (->> (d/q '[:find [?title ...] + :where [?b :block/title ?title]] + @conn) + (filter #(string/starts-with? % "large line ")) + count)) + "Large flat files import without overflowing the stack") + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Imported graph validates"))) + +(deftest-async export-basic-graph-with-convert-all-tags + ;; This graph will contain basic examples of different features to import + (p/let [file-graph-dir "test/resources/exporter-test-graph" + conn (db-test/create-conn) + ;; Calculate refs like frontend + _ (db-pipeline/add-listener conn) + assets (atom []) + {:keys [import-state]} (import-file-graph-to-db file-graph-dir conn {:assets assets :convert-all-tags? true})] + + (testing "whole graph" + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + + ;; Counts + ;; Includes journals as property values e.g. :logseq.property/deadline + (is (= 34 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Journal]] @conn)))) + + (is (= 9 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Asset]] @conn)))) + (is (= 6 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Task]] @conn)))) + (is (= 4 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Query]] @conn)))) + (is (= 2 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Card]] @conn)))) + (is (= 1 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Cards]] @conn)))) + (is (= 2 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Code-block]] @conn)))) + (is (= 1 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Math-block]] @conn)))) + (is (= 9 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Template]] @conn)))) + (is (= 6 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Quote-block]] @conn)))) + (is (= 8 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Pdf-annotation]] @conn)))) + + ;; Properties and tags aren't included in this count as they aren't a Page + (is (= 13 + (->> (d/q '[:find [?b ...] + :where + [?b :block/title] + [_ :block/page ?b] + (not [?b :logseq.property/built-in?])] @conn) + (map #(d/entity @conn %)) + (filter ldb/internal-page?) + #_(map #(select-keys % [:block/title :block/tags])) + count)) + "Correct number of pages with block content") + (is (= 16 (->> @conn + (d/q '[:find [?ident ...] + :where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])]) + count)) + "Correct number of user classes") + (is (= 0 (count @(:ignored-properties import-state))) "No ignored properties") + (is (= 0 (count @(:ignored-assets import-state))) "No ignored assets") + (is (= 1 (count @(:ignored-files import-state))) "Ignore .edn for now") + ;; 2 zotero pdf are external files so not counted here + (is (= 7 (count @assets)))) + + (testing "logseq files" + (is (= ".foo {}\n" + (ffirst (d/q '[:find ?content :where [?b :file/path "logseq/custom.css"] [?b :file/content ?content]] @conn)))) + (is (= "logseq.api.show_msg('hello good sir!');\n" + (ffirst (d/q '[:find ?content :where [?b :file/path "logseq/custom.js"] [?b :file/content ?content]] @conn))))) + + (testing "favorites" + (is (= #{"Interstellar" "some page" "new page" "n1/x/y"} + (set (imported-favorite-titles @conn))))) + + (testing "user properties" + (is (= 23 + (->> @conn + (d/q '[:find [(pull ?b [:db/ident]) ...] + :where [?b :block/tags :logseq.class/Property]]) + (remove #(db-malli-schema/internal-ident? (:db/ident %))) + count)) + "Correct number of user properties") + (is (= #{{:db/ident :user.property/prop-bool :logseq.property/type :checkbox} + {:db/ident :user.property/prop-string :logseq.property/type :default} + {:db/ident :user.property/prop-num :logseq.property/type :number} + {:db/ident :user.property/sameas :logseq.property/type :url} + {:db/ident :user.property/rangeincludes :logseq.property/type :node} + {:db/ident :user.property/startedat :logseq.property/type :date}} + (->> @conn + (d/q '[:find [(pull ?b [:db/ident :logseq.property/type]) ...] + :where [?b :block/tags :logseq.class/Property]]) + (filter #(contains? #{:prop-bool :prop-string :prop-num :rangeincludes :sameas :startedat} + (keyword (name (:db/ident %))))) + set)) + "Main property types have correct inferred :type") + (is (= :default + (:logseq.property/type (d/entity @conn :user.property/description))) + "Property value consisting of text and refs is inferred as :default") + (is (= :url + (:logseq.property/type (d/entity @conn :user.property/url))) + "Property value with a macro correctly inferred as :url") + + (is (= {:user.property/prop-bool true + :user.property/prop-num 5 + :user.property/prop-string "woot"} + (db-test/readable-properties (db-test/find-block-by-content @conn "b1"))) + "Basic block has correct properties") + (is (= #{"prop-num" "prop-string" "prop-bool"} + (->> (db-test/find-block-by-content @conn "b1") + :block/refs + (map :block/title) + set)) + "Block with properties has correct refs") + + (is (= {:user.property/prop-num2 10 + :block/tags [:logseq.class/Page]} + (db-test/readable-properties (db-test/find-page-by-title @conn "new page"))) + "New page has correct properties") + (is (= {:user.property/prop-bool true + :user.property/prop-num 5 + :user.property/prop-string "yeehaw" + :block/tags [:logseq.class/Page :user.class/SomeNamespace]} + (db-test/readable-properties (db-test/find-page-by-title @conn "some page"))) + "Existing page has correct properties") + + (is (= {:user.property/rating 5.5} + (db-test/readable-properties (db-test/find-block-by-content @conn ":rating float"))) + "Block with float property imports as a float") + + (is (= [] + (->> (d/q '[:find (pull ?b [:block/title {:block/tags [:db/ident]}]) + :where [?b :block/tags :logseq.class/Property]] + @conn) + (map first) + (remove #(= [{:db/ident :logseq.class/Property}] (:block/tags %))))) + "All properties only have :logseq.class/Property as their tag (and don't have Page)")) + + (testing "built-in properties" + (is (= [(:db/id (db-test/find-block-by-content @conn "original block"))] + (mapv :db/id (:block/refs (db-test/find-block-by-content @conn #"ref to")))) + "block with a block-ref has correct :block/refs") + + (is (= "ref to [[65cbb772-fb79-462d-87c8-6f0dad751dee]]" + (:block/title (db-test/find-block-by-content @conn #"ref to"))) + "block-ref ((uuid)) is converted to page-ref [[uuid]] in block title on import") + + (is (= 20221126 + (-> (db-test/readable-properties (db-test/find-block-by-content @conn "only deadline")) + :logseq.property/deadline + date-time-util/ms->journal-day)) + "deadline block has correct journal as property value") + + (is (= {:logseq.property/scheduled 20221125 + :logseq.property/deadline 20221125} + (-> (db-test/readable-properties (db-test/find-block-by-content @conn #"deadline and scheduled")) + (select-keys [:logseq.property/scheduled :logseq.property/deadline]) + (update-vals date-time-util/ms->journal-day))) + "scheduled block converted to correct deadline") + + (is (= 1 (count (d/q '[:find [(pull ?b [*]) ...] + :in $ ?content + :where [?b :block/title ?content]] + @conn "Apr 1st, 2024"))) + "Only one journal page exists when deadline is on same day as journal") + + (is (= {:logseq.property/priority :logseq.property/priority.high} + (db-test/readable-properties (db-test/find-block-by-content @conn "high priority"))) + "priority block has correct property") + + (is (= {:logseq.property/status :logseq.property/status.doing + :logseq.property/priority :logseq.property/priority.medium + :block/tags [:logseq.class/Task]} + (db-test/readable-properties (db-test/find-block-by-content @conn "status test"))) + "status block has correct task properties and class") + (let [empty-title-task (first (task-blocks-by-title @conn ""))] + (is (= {:logseq.property/status :logseq.property/status.todo + :block/tags [:logseq.class/Task]} + (select-keys (db-test/readable-properties empty-title-task) + [:logseq.property/status :block/tags])) + "Empty-title TODO from file graph imports as a task")) + + (is (= #{:logseq.property/status :block/tags} + (set (keys (db-test/readable-properties (db-test/find-block-by-content @conn "old todo block"))))) + "old task properties like 'todo' are ignored") + + (is (= {:logseq.property/order-list-type "number"} + (db-test/readable-properties (db-test/find-block-by-content @conn "list one"))) + "numered block has correct property") + + (is (= #{"gpt"} + (:block/alias (db-test/readable-properties (db-test/find-page-by-title @conn "chat-gpt")))) + "alias set correctly") + (is (= ["y"] + (->> (d/q '[:find [?b ...] :where [?b :block/title "y"] [?b :block/parent]] + @conn) + first + (d/entity @conn) + :block/alias + (map :block/title))) + "alias set correctly on namespaced page") + + (is (= {:logseq.property.linked-references/includes #{"Oct 9th, 2024"} + :logseq.property.linked-references/excludes #{"ref2"}} + (select-keys (db-test/readable-properties (db-test/find-page-by-title @conn "chat-gpt")) + [:logseq.property.linked-references/excludes :logseq.property.linked-references/includes])) + "linked ref filters set correctly")) + + (testing "built-in classes and their properties" + ;; Queries + (is (= {:logseq.property.table/sorting [{:id :user.property/prop-num, :asc? false}] + :logseq.property.view/type :logseq.property.view/type.table + :logseq.property.table/ordered-columns [:block/title :user.property/prop-string :user.property/prop-num] + :logseq.property/query "(property :prop-string)" + :block/tags [:logseq.class/Query]} + (db-test/readable-properties (find-block-by-property-value @conn :logseq.property/query "(property :prop-string)"))) + "simple query block has correct query properties") + (is (= "For example, here's a query with title text:" + (:block/title (db-test/find-block-by-content @conn #"query with title text"))) + "Text around a simple query block is set as a query's title") + (is (= {:logseq.property.view/type :logseq.property.view/type.list + :logseq.property/query "{:query (task todo doing)}" + :block/tags [:logseq.class/Query] + :logseq.property.table/ordered-columns [:block/title]} + (db-test/readable-properties (db-test/find-block-by-content @conn #"tasks with todo"))) + "Advanced query has correct query properties") + (is (= "tasks with todo and doing" + (:block/title (db-test/find-block-by-content @conn #"tasks with todo"))) + "Advanced query has custom title migrated") + + ;; Card + (is (= {:block/tags [:logseq.class/Card]} + (db-test/readable-properties (db-test/find-block-by-content @conn "card 1"))) + "None of the card properties are imported since they are deprecated") + + ;; Cards (flashcard browser) + (is (= {:block/tags [:logseq.class/Cards] + :logseq.property/query "(tags #Card)"} + (db-test/readable-properties (find-block-by-property-value @conn :logseq.property/query "(tags #Card)"))) + "cards macro block has correct Cards class and query property") + + ;; Math blocks + (is (= {:block/tags [:logseq.class/Math-block] + :logseq.property.node/display-type :math} + (db-test/readable-properties (db-test/find-block-by-content @conn "E=mc^2"))) + "Math block has correct Math-block class and display-type") + (is (= "E=mc^2" (:block/title (db-test/find-block-by-content @conn "E=mc^2"))) + "Math block title has delimiters stripped") + + ;; Templates + (is (= #{"meeting" + "title-only-no-children" + "properties-only-no-children" + "title-only-with-children" + "empty-title-with-children" + "children-only" + "nested-father" + "nested-child-1" + "nested-child-2"} + (->> (d/q '[:find [?title ...] + :where + [?b :block/tags :logseq.class/Template] + [?b :block/title ?title]] + @conn) + set)) + "All template definitions are imported as Template blocks") + (let [journal-uuid (:block/uuid (db-test/find-journal-by-journal-day @conn 20240216)) + template-page-uuids (->> (d/q '[:find [?page-uuid ...] + :where + [?b :block/tags :logseq.class/Template] + [?b :block/page ?page] + [?page :block/uuid ?page-uuid]] + @conn) + set)] + (is (= #{journal-uuid} template-page-uuids) + "All template blocks are created on their source journal page")) + (is (= [{:title "MEETING TITLE" + :properties {:user.property/participants #{"TODO"}} + :children []}] + (template-content-trees @conn "meeting"))) + (is (= [{:title "TITLE" + :properties {} + :children []}] + (template-content-trees @conn "title-only-no-children"))) + (is (= [{:title "" + :properties {:user.property/name "" + :user.property/author ""} + :children []}] + (template-content-trees @conn "properties-only-no-children"))) + (is (= [{:title "TITLE" + :properties {} + :children [{:title "intro" :properties {} :children []} + {:title "notes" :properties {} :children []}]}] + (template-content-trees @conn "title-only-with-children"))) + (is (= [{:title "" + :properties {} + :children [{:title "intro" :properties {} :children []} + {:title "notes" :properties {} :children []}]}] + (template-content-trees @conn "empty-title-with-children"))) + (is (= [{:title "intro" :properties {} :children []} + {:title "notes" :properties {} :children []}] + (template-content-trees @conn "children-only"))) + (is (= [{:title "it's a template with nested templates" + :properties {:user.property/name "you named it"} + :children [{:title "nested-child-1" + :properties {} + :children [{:title "child-1" + :properties {:user.property/name ""} + :children [{:title "child-1-1" + :properties {:user.property/name ""} + :children []}]}]} + {:title "nested-child-2" + :properties {} + :children [{:title "child-2-1" + :properties {:user.property/name ""} + :children []}]} + {:title "child-3" + :properties {:user.property/name ""} + :children []}]}] + (template-content-trees @conn "nested-father"))) + (is (= [{:title "child-1" + :properties {:user.property/name ""} + :children [{:title "child-1-1" + :properties {:user.property/name ""} + :children []}]}] + (template-content-trees @conn "nested-child-1"))) + (is (= [{:title "child-2-1" + :properties {:user.property/name ""} + :children []}] + (template-content-trees @conn "nested-child-2"))) + + ;; Assets + (is (= {:block/tags [:logseq.class/Asset] + :logseq.property.asset/type "png" + :logseq.property.asset/checksum "3d5e620cac62159d8196c118574bfea7a16e86fa86efd1c3fa15a00a0a08792d" + :logseq.property.asset/size 753471 + :logseq.property.asset/resize-metadata {:height 288, :width 252}} + (db-test/readable-properties (db-test/find-block-by-content @conn "greg-popovich-thumbs-up_1704749687791_0"))) + "Asset has correct properties") + (is (= {:block/tags [:logseq.class/Asset] + :logseq.property.asset/type "pdf" + :logseq.property.asset/external-url "zotero://select/library/items/QDM8H6EH" + :logseq.property.asset/external-file-name "zotero-link://it/Understanding EXPLAIN.pdf"} + (select-keys + (db-test/readable-properties (db-test/find-block-by-content @conn "Understanding EXPLAIN")) + [:block/tags + :logseq.property.asset/type + :logseq.property.asset/external-url + :logseq.property.asset/external-file-name])) + "Zotero linked pdf asset has correct external path info") + (is (= {:block/tags [:logseq.class/Asset] + :logseq.property.asset/type "pdf" + :logseq.property.asset/external-url "zotero://select/library/items/RX5JS7SY" + :logseq.property.asset/external-file-name "zotero-path://RX5JS7SY/zlib.pdf"} + (select-keys + (db-test/readable-properties (db-test/find-block-by-content @conn "zlib")) + [:block/tags + :logseq.property.asset/type + :logseq.property.asset/external-url + :logseq.property.asset/external-file-name])) + "Zotero imported pdf asset has correct external path info") + (is (= (d/entity @conn :logseq.class/Asset) + (:block/page (db-test/find-block-by-content @conn "greg-popovich-thumbs-up_1704749687791_0"))) + "Imported into Asset page") + + ;; Annotations + (is (= {:logseq.property.pdf/hl-color :logseq.property/color.blue + :logseq.property.pdf/hl-page 8 + :block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0"} + (dissoc (db-test/readable-properties (db-test/find-block-by-content @conn #"Duke School - modified")) + :logseq.property.pdf/hl-value :logseq.property/ls-type)) + "Pdf text highlight has correct properties") + (is (= ["note about duke" "sub note"] + (mapv :block/title (rest (ldb/get-block-and-children @conn (:block/uuid (db-test/find-block-by-content @conn #"Duke School - modified")))))) + "Pdf text highlight has correct children blocks") + (is (= {:logseq.property.pdf/hl-color :logseq.property/color.yellow + :logseq.property.pdf/hl-page 1 + :block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0" + :logseq.property.pdf/hl-image "pdf area highlight" + :logseq.property.pdf/hl-type :area} + (dissoc (->> (d/q '[:find [?b ...] + :where [?b :block/tags :logseq.class/Pdf-annotation] [?b :block/title ""]] @conn) + first + (d/entity @conn) + db-test/readable-properties) + :logseq.property.pdf/hl-value :logseq.property/ls-type)) + "Pdf area highlight has correct properties") + (is (= "" + (:block/title + (d/entity @conn [:block/uuid #uuid "68702499-159a-4a14-a0cf-cf5f015535c2"]))) + "Pdf annotation without text imports with an empty title") + (is (= {:block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Understanding EXPLAIN" + :logseq.property.pdf/hl-color :logseq.property/color.yellow + :logseq.property.pdf/hl-page 6} + (select-keys + (db-test/readable-properties (db-test/find-block-by-content @conn #"EXPLAIN is a really nice command")) + [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-color + :logseq.property.pdf/hl-page])) + "Zotero linked pdf text highlight links to correct asset") + (is (= {:block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "zlib" + :logseq.property.pdf/hl-color :logseq.property/color.red + :logseq.property.pdf/hl-page 1} + (select-keys + (db-test/readable-properties (db-test/find-block-by-content @conn #"The zlib library is a general purpose data compression library")) + [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-color + :logseq.property.pdf/hl-page])) + "Zotero imported pdf text highlight links to correct asset") + (let [area-hl (d/q '[:find (pull ?b [:block/title + {:block/tags [:db/ident]} + {:logseq.property/asset [:block/title]} + {:logseq.property.pdf/hl-image [:block/title]} + {:logseq.property.pdf/hl-color [:db/ident]} + :logseq.property.pdf/hl-type + :logseq.property.pdf/hl-page]) . + :where + [?asset :block/title "Understanding EXPLAIN"] + [?asset :block/tags :logseq.class/Asset] + [?b :block/title "[:span]"] + [?b :logseq.property/asset ?asset]] + @conn)] + (is (= {:logseq.property.pdf/hl-color :logseq.property/color.green + :logseq.property.pdf/hl-page 8 + :block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "Understanding EXPLAIN" + :logseq.property.pdf/hl-image "pdf area highlight" + :logseq.property.pdf/hl-type :area} + (-> area-hl + (update :block/tags #(mapv :db/ident %)) + (update :logseq.property/asset #(:block/title %)) + (update :logseq.property.pdf/hl-color #(:db/ident %)) + (update :logseq.property.pdf/hl-image #(:block/title %)) + (select-keys [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-color + :logseq.property.pdf/hl-page + :logseq.property.pdf/hl-image + :logseq.property.pdf/hl-type]))) + "Zotero linked pdf area highlight links to correct asset")) + (let [area-hl (d/q '[:find (pull ?b [:block/title + {:block/tags [:db/ident]} + {:logseq.property/asset [:block/title]} + {:logseq.property.pdf/hl-image [:block/title]} + {:logseq.property.pdf/hl-color [:db/ident]} + :logseq.property.pdf/hl-type + :logseq.property.pdf/hl-page]) . + :where + [?asset :block/title "zlib"] + [?asset :block/tags :logseq.class/Asset] + [?b :block/title "[:span]"] + [?b :logseq.property/asset ?asset]] + @conn)] + (is (= {:logseq.property.pdf/hl-color :logseq.property/color.blue + :logseq.property.pdf/hl-page 1 + :block/tags [:logseq.class/Pdf-annotation] + :logseq.property/asset "zlib" + :logseq.property.pdf/hl-image "pdf area highlight" + :logseq.property.pdf/hl-type :area} + (-> area-hl + (update :block/tags #(mapv :db/ident %)) + (update :logseq.property/asset #(:block/title %)) + (update :logseq.property.pdf/hl-color #(:db/ident %)) + (update :logseq.property.pdf/hl-image #(:block/title %)) + (select-keys [:block/tags + :logseq.property/asset + :logseq.property.pdf/hl-color + :logseq.property.pdf/hl-page + :logseq.property.pdf/hl-image + :logseq.property.pdf/hl-type]))) + "Zotero imported pdf area highlight links to correct asset")) + + ;; Quotes + (is (string/starts-with? (:block/title (db-test/find-block-by-content @conn #"Saito")) "From Inception:\n> Saito:") + "Mixed #+BEGIN_QUOTE block: heading retained and quote content prefixed with '>'") + (is (nil? (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"Saito"))) + "Mixed #+BEGIN_QUOTE block is not converted to a Quote-block") + (is (= "markdown quote\n[[wut]]\nline 3" + (:block/title (db-test/find-block-by-content @conn #"markdown quote"))) + "Markdown quote imports as full multi-line quote") + (is (string/starts-with? (:block/title (db-test/find-block-by-content @conn #"Learn Datalog")) + "Test of various ast types:\n> *Italic*") + "Mixed #+BEGIN_QUOTE block retains heading and quote content in block title") + (is (= "Blockquotes\n> Nested Blockquotes" + (:block/title (db-test/find-block-by-content @conn #"Nested Blockquotes"))) + "Nested '>> quote' is preserved as '> ' prefix in Quote-block title") + (is (= :quote (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"Nested Blockquotes"))) + "Nested markdown quote block is tagged as Quote-block") + (is (= "it's a\n\norg blockquote" + (:block/title (db-test/find-block-by-content @conn #"org blockquote"))) + "#+BEGIN_QUOTE pure block title has no '> ' prefix — display-type provides blockquote styling") + (is (= :quote (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"org blockquote"))) + "#+BEGIN_QUOTE pure block is tagged as Quote-block") + (is (= "> Blockquotes\n> and\n\nsomething else" + (:block/title (db-test/find-block-by-content @conn #"Blockquotes\n> and"))) + "Mixed #+BEGIN_QUOTE at start of block: quote content prefixed with '>' and blank line separates following text") + (is (nil? (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"Blockquotes\n> and"))) + "Mixed #+BEGIN_QUOTE block at start is not converted to a Quote-block") + (let [block (db-test/find-block-by-content @conn #"Question 1")] + (is (string/includes? (:block/title block) "\n>> nested") + "Mixed markdown quote block preserves nested quote depth") + (is (nil? (:logseq.property.node/display-type block)) + "Mixed markdown quote block is not converted to a Quote-block")) + (let [block (db-test/find-block-by-content @conn #"Question 2")] + (is (string/includes? (:block/title block) "> Question 3") + "Mixed markdown quote child block preserves separated quote lines") + (is (string/includes? (:block/title block) "> Answer 3") + "Mixed markdown quote child block preserves quote content after blank quote line"))) + + (testing "embeds" + (is (= {:block/title ""} + (-> (d/q '[:find [(pull ?b [*]) ...] + :in $ ?title + :where [?b :block/link ?l] [?b :block/page ?bp] [?bp :block/journal-day 20250612] [?l :block/title ?title]] + @conn + "page embed") + first + (select-keys [:block/title]))) + "Page embed linked correctly") + (is (= {:block/title ""} + (-> (d/q '[:find [(pull ?b [*]) ...] + :in $ ?title + :where [?b :block/link ?l] [?b :block/page ?bp] [?bp :block/journal-day 20250612] [?l :block/title ?title]] + @conn + "test block embed") + first + (select-keys [:block/title]))) + "Block embed linked correctly")) + + (testing "tags convert to classes" + (is (= :user.class/Quotes___life + (:db/ident (db-test/find-page-by-title @conn "life"))) + "Namespaced tag's ident has hierarchy to make it unique") + + (is (= [:logseq.class/Tag] + (map :db/ident (:block/tags (db-test/find-page-by-title @conn "life")))) + "When a class is used and referenced on the same page, there should only be one instance of it") + + (is (= [:user.class/Quotes___life] + (mapv :db/ident (:block/tags (db-test/find-block-by-content @conn #"with namespace tag")))) + "Block tagged with namespace tag is only associated with leaf child tag") + + (is (= #{:user.class/ai :user.class/block-tag :user.class/p1} + (set (map :db/ident (:block/tags (db-test/find-block-by-content @conn #"Block tags"))))) + "Block with tags through tags property") + + (is (= [] + (->> (d/q '[:find (pull ?b [:block/title {:block/tags [:db/ident]}]) + :where [?b :block/tags :logseq.class/Tag]] + @conn) + (map first) + (remove #(= [{:db/ident :logseq.class/Tag}] (:block/tags %))))) + "All classes only have :logseq.class/Tag as their tag (and don't have Page)")) + + (testing "namespaces" + (let [expand-children (fn expand-children [ent parent] + (if-let [children (:block/_parent ent)] + (cons {:parent (:block/title parent) :child (:block/title ent)} + (mapcat #(expand-children % ent) children)) + [{:parent (:block/title parent) :child (:block/title ent)}]))] + ;; check pages only + (is (= [{:parent "n1" :child "x"} + {:parent "x" :child "z"} + {:parent "x" :child "y"}] + (take 3 (rest (expand-children (db-test/find-page-by-title @conn "n1") nil)))) + "First namespace tests duplicate parent page name") + (is (= [{:parent "n2" :child "x"} + {:parent "x" :child "z"} + {:parent "n2" :child "alias"}] + (rest (expand-children (db-test/find-page-by-title @conn "n2") nil))) + "First namespace tests duplicate child page name and built-in page name"))) + + (testing "journal timestamps" + (is (= (date-time-util/journal-day->ms 20240207) + (:block/created-at (db-test/find-page-by-title @conn "Feb 7th, 2024"))) + "journal pages are created on their journal day") + (is (= (date-time-util/journal-day->ms 20240207) + (:block/created-at (db-test/find-block-by-content @conn #"Inception"))) + "journal blocks are created on their page's journal day")) + + (testing "db attributes" + (is (= true + (:block/collapsed? (db-test/find-block-by-content @conn "collapsed block"))) + "Collapsed blocks are imported")) + + (testing "property :type changes" + (is (= :node + (:logseq.property/type (d/entity @conn :user.property/finishedat))) + ":date property to :node value changes to :node") + (is (= :default + (:logseq.property/type (d/entity @conn :user.property/participants))) + "template values cause participants to remain a :default property") + (is (= #{"[[Feb 7th, 2024]]"} + (:user.property/participants (db-test/readable-properties (db-test/find-block-by-content @conn #"test :node -> :date")))) + ":default participants property keeps the imported text value") + + (is (= :default + (:logseq.property/type (d/entity @conn :user.property/description))) + ":default property to :node (or any non :default value) remains :default") + (is (= "[[Jakob]]" + (:user.property/description (db-test/readable-properties (db-test/find-block-by-content @conn #":default to :node")))) + ":default to :node property saves :default property value default with full text") + + (testing "with changes to upstream/existing property value" + (is (= :default + (:logseq.property/type (d/entity @conn :user.property/duration))) + ":number property to :default value changes to :default") + (is (= "20" + (:user.property/duration (db-test/readable-properties (db-test/find-block-by-content @conn "existing :number to :default")))) + "existing :number property value correctly saved as :default") + (is (= :default + (:logseq.property/type (d/entity @conn :user.property/people2))) + ":node property changes to :default when :node is defined in same file") + + ;; tests :node :many to :default transition after :node is defined in separate file + (is (= {:logseq.property/type :default :db/cardinality :db.cardinality/many} + (select-keys (d/entity @conn :user.property/people) [:logseq.property/type :db/cardinality])) + ":node property to :default value changes to :default and keeps existing cardinality") + (is (= #{"[[Jakob]] [[Gabriel]]"} + (:user.property/people (db-test/readable-properties (db-test/find-block-by-content @conn ":node people")))) + "existing :node property value correctly saved as :default with full text") + (is (= #{"[[Gabriel]] [[Jakob]]"} + (:user.property/people (db-test/readable-properties (db-test/find-block-by-content @conn #"pending block for :node")))) + "pending :node property value correctly saved as :default with full text") + (is (some? (db-test/find-page-by-title @conn "Jakob")) + "Previous :node property value still exists") + (is (= 3 (count (find-block-by-property @conn :user.property/people))) + "Converted property has correct number of property values"))) + + (testing "imported concepts can have names of new-built concepts" + (is (= #{:logseq.property/description :user.property/description} + (set (d/q '[:find [?ident ...] :where [?b :db/ident ?ident] [?b :block/name "description"]] @conn))) + "user description property is separate from built-in one") + (is (= #{"Page" "Tag"} + (set (d/q '[:find [?t-title ...] :where + [?b :block/tags ?t] + [?b :block/name "task"] + [?t :block/title ?t-title]] @conn))) + "user page is separate from built-in class")) + + (testing "multiline blocks" + (is (= "|markdown| table|\n|some|thing|" (:block/title (db-test/find-block-by-content @conn #"markdown.*table")))) + (is (= "normal multiline block\na 2nd\nand a 3rd" (:block/title (db-test/find-block-by-content @conn #"normal multiline block")))) + (is (= "colored multiline block\nlast line" (:block/title (db-test/find-block-by-content @conn #"colored multiline block")))) + + (let [block (db-test/find-block-by-content @conn #"multiline block with prop and deadline")] + (is (= "multiline block with prop and deadline\nlast line" (:block/title block))) + (is (= 20221126 + (-> (db-test/readable-properties block) + :logseq.property/deadline + date-time-util/ms->journal-day)) + "multiline block has correct journal as property value") + (is (= "red" + (-> (db-test/readable-properties block) + :logseq.property/background-color)) + "multiline block has correct background color as property value")) + + (let [block (db-test/find-block-by-content @conn #"multiline block with deadline and scheduled in 1 line and sth else")] + (is (= "multiline block with deadline and scheduled in 1 line and sth else\nsomething else\nlast line" (:block/title block))) + (is (= 20221126 + (-> (db-test/readable-properties block) + :logseq.property/deadline + date-time-util/ms->journal-day)) + "multiline block with deadline and scheduled has correct deadline journal as property value") + (is (= 20221126 + (-> (db-test/readable-properties block) + :logseq.property/scheduled + date-time-util/ms->journal-day)) + "multiline block with deadline and scheduled has correct scheduled journal as property value")) + + (is (= "logbook block" (:block/title (db-test/find-block-by-content @conn #"^logbook block")))) + (is (= "multiline logbook block\nlast line" (:block/title (db-test/find-block-by-content @conn #"multiline logbook block"))))) + + (testing ":block/refs" + (let [page (db-test/find-page-by-title @conn "chat-gpt")] + (is (set/subset? + #{"type" "LargeLanguageModel"} + (->> page :block/refs (map #(:block/title (d/entity @conn (:db/id %)))) set)) + "Page has correct property and property value :block/refs")) + + (let [block (db-test/find-block-by-content @conn "old todo block")] + (is (set/subset? + #{:logseq.property/status :logseq.class/Task} + (->> block + :block/refs + (map #(:db/ident (d/entity @conn (:db/id %)))) + set)) + "Block has correct task tag and property :block/refs"))))) + +(deftest-async export-basic-graph-with-convert-all-tags-option-disabled + (p/let [file-graph-dir "test/resources/exporter-test-graph" + conn (db-test/create-conn) + {:keys [import-state]} + (import-file-graph-to-db file-graph-dir conn {:convert-all-tags? false})] + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + (is (= 0 (count @(:ignored-properties import-state))) "No ignored properties") + (is (= 0 (->> @conn + (d/q '[:find [?ident ...] + :where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])]) + count)) + "Correct number of user classes") + + (is (= 6 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Task]] @conn)))) + (is (= 4 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Query]] @conn)))) + (is (= 2 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Card]] @conn)))) + + (testing "replacing refs in :block/title when :remove-inline-tags? set" + (is (= 2 + (->> (entity-plus/lookup-kv-then-entity + (db-test/find-block-by-content @conn #"replace with same start string") + :block/raw-title) + (re-seq db-content/id-ref-pattern) + distinct + count)) + "A block with ref names that start with same string has 2 distinct refs") + + (is (= 1 + (->> (entity-plus/lookup-kv-then-entity + (db-test/find-block-by-content @conn #"replace case insensitive") + :block/raw-title) + (re-seq db-content/id-ref-pattern) + distinct + count)) + "A block with different case of same ref names has 1 distinct ref")) + + (testing "tags convert to page, refs and page-tags" + (let [block (db-test/find-block-by-content @conn #"Inception") + tag-page (db-test/find-page-by-title @conn "Movie") + tagged-page (db-test/find-page-by-title @conn "Interstellar")] + (is (string/starts-with? (str (:block/title block)) "Inception [[") + "tagged block tag converts tag to page ref") + (is (= [(:db/id tag-page)] (map :db/id (:block/refs block))) + "tagged block has correct refs") + (is (and tag-page (not (ldb/class? tag-page))) + "tag page is not a class") + + (is (= #{"Movie"} + (:logseq.property/page-tags (db-test/readable-properties tagged-page))) + "tagged page has existing page imported as a tag to page-tags") + (is (= #{"LargeLanguageModel" "fun" "ai"} + (:logseq.property/page-tags (db-test/readable-properties (db-test/find-page-by-title @conn "chat-gpt")))) + "tagged page has new page and other pages marked with '#' and '[[]]` imported as tags to page-tags"))))) + +(deftest-async import-journals-use-standard-uuids-and-keep-uuid-refs + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2026_01_27.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {})] + (let [journal (db-test/find-journal-by-journal-day @conn 20260127) + ref-journal (db-test/find-journal-by-journal-day @conn 20260101) + ref-block (some->> (d/q '[:find [?b ...] + :in $ ?page ?ref-page + :where + [?b :block/page ?page] + [?b :block/refs ?ref-page]] + @conn (:db/id journal) (:db/id ref-journal)) + first + (d/entity @conn))] + (is (= (common-uuid/gen-uuid :journal-page-uuid 20260127) + (:block/uuid journal)) + "Imported journal page keeps the standard journal uuid") + (is (= (common-uuid/gen-uuid :journal-page-uuid 20260101) + (:block/uuid ref-journal)) + "Referenced journal page keeps the standard journal uuid") + (is (= #{(:block/uuid ref-journal)} + (set (map :block/uuid (:block/refs ref-block)))) + "Journal refs point at the standard journal uuid")))) + +(deftest-async import-journal-with-slash-title-format-does-not-create-namespace-pages + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2026_01_27.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:user-config {:journal/page-title-format "yyyy/MM/dd"}}) + journal (db-test/find-journal-by-journal-day @conn 20260127)] + (is (= "2026/01/27" (:block/title journal)) + "Journal title follows slash title format") + (is (= (common-uuid/gen-uuid :journal-page-uuid 20260127) + (:block/uuid journal)) + "Slash-formatted journal keeps the standard journal uuid") + (is (nil? (:block/namespace journal)) + "Slash-formatted journal does not keep a namespace attribute") + (is (nil? (db-test/find-page-by-title @conn "2026")) + "Journal title is not split into a year namespace page") + (is (nil? (db-test/find-page-by-title @conn "01")) + "Journal title is not split into a month namespace page") + (is (nil? (db-test/find-page-by-title @conn "27")) + "Journal title is not split into a day namespace page"))) + +(deftest-async import-slash-journal-ref-does-not-create-namespace-pages + (p/let [file (write-temp-graph-file "journals/2026_05_18.md" "- yes\n- [[Sun, 2026/05/17]]\n") + conn (db-test/create-conn) + _ (import-files-to-db [file] conn {:user-config {:journal/page-title-format "EEE, yyyy/MM/dd"}}) + ref-journal (db-test/find-journal-by-journal-day @conn 20260517)] + (is (= "Sun, 2026/05/17" (:block/title ref-journal)) + "Journal reference is imported as a journal page") + (is (nil? (:block/namespace ref-journal)) + "Referenced slash-formatted journal does not keep a namespace attribute") + (is (nil? (db-test/find-page-by-title @conn "Sun, 2026")) + "Journal reference is not split into a parent namespace page") + (is (nil? (db-test/find-page-by-title @conn "05")) + "Journal reference is not split into a child namespace page"))) + +(deftest-async import-legacy-journal-file-name-refs-as-journals + (p/let [source-file (write-temp-graph-file + "journals/2026_04_01.md" + "- legacy journal ref [[2026_04_02]]\n") + target-file (write-temp-graph-file + "journals/2026_04_02.md" + "- target journal\n") + conn (db-test/create-conn) + _ (import-files-to-db [source-file target-file] conn {}) + legacy-journal (db-test/find-journal-by-journal-day @conn 20260402) + legacy-ref-block (db-test/find-block-by-content @conn #"legacy journal ref")] + (is (some? legacy-journal) + "Legacy yyyy_MM_dd journal page refs resolve imported journal files") + (is (= #{(:block/uuid legacy-journal)} + (set (map :block/uuid (:block/refs legacy-ref-block)))) + "Legacy journal page ref points at the journal page") + (is (nil? (db-test/find-page-by-title @conn "2026_04_02")) + "Legacy journal page ref does not create an ordinary page"))) + +(deftest-async import-default-format-journal-refs-with-custom-title-format + (p/let [dir (write-temp-file-graph + {"pages/source.md" + "- existing journal [[May 18th, 2021]]\n- missing journal [[May 19th, 2021]]\n" + "journals/2021_05_18.md" + "- journal entry\n" + "pages/2021_05_19.md" + "- ordinary date-named page\n"}) + source-file (-> (node-path/join dir "pages/source.md") + (string/replace "\\" "/")) + journal-file (-> (node-path/join dir "journals/2021_05_18.md") + (string/replace "\\" "/")) + ordinary-date-file (-> (node-path/join dir "pages/2021_05_19.md") + (string/replace "\\" "/")) + conn (db-test/create-conn) + _ (import-files-to-db [source-file journal-file ordinary-date-file] conn + {:user-config {:journal/page-title-format "EEEE, dd-MM-yyyy"}}) + existing-journal (db-test/find-journal-by-journal-day @conn 20210518) + missing-page (db-test/find-page-by-title @conn "May 19th, 2021") + existing-ref-block (db-test/find-block-by-content @conn #"existing journal") + missing-ref-block (db-test/find-block-by-content @conn #"missing journal")] + (is (= "Tuesday, 18-05-2021" (:block/title existing-journal)) + "Default-format ref resolves the journal file using the configured title") + (is (= #{:logseq.class/Journal} + (set (map :db/ident (:block/tags existing-journal)))) + "Existing journal does not retain the ordinary page tag") + (is (= #{(:block/uuid existing-journal)} + (set (map :block/uuid (:block/refs existing-ref-block)))) + "Existing journal ref points at the journal page") + (is (= #{:logseq.class/Page} + (set (map :db/ident (:block/tags missing-page)))) + "Default-format ref without a file under journals remains an ordinary page") + (is (nil? (db-test/find-journal-by-journal-day @conn 20210519)) + "Date-named file outside journals does not create a journal") + (is (= #{(:block/uuid missing-page)} + (set (map :block/uuid (:block/refs missing-ref-block)))) + "Missing journal ref points at the ordinary page"))) + +(deftest-async import-creates-missing-ordinary-page-refs + (p/let [file (write-temp-graph-file + "pages/source.md" + "- missing page ref [[Missing Page]]\n") + conn (db-test/create-conn) + _ (import-files-to-db [file] conn {}) + missing-page (db-test/find-page-by-title @conn "Missing Page") + source-block (db-test/find-block-by-content @conn #"missing page ref")] + (is (some? missing-page) + "Missing ordinary page refs create ordinary pages") + (is (= #{(:block/uuid missing-page)} + (set (map :block/uuid (:block/refs source-block)))) + "Missing ordinary page ref points at the created page"))) + +(deftest-async import-favorites-from-og-config-edn + (p/let [dir (write-temp-file-graph + {"logseq/config.edn" + (str "{:favorites [\"Projects\" \"[[Projects]]\" \"foo/bar\"]\n" + " :file/name-format :triple-lowbar}\n") + "pages/Projects.md" "- project work\n- [[foo/bar]]\n"}) + conn (db-test/create-conn) + _ (db-pipeline/add-listener conn) + _ (import-file-graph-to-db dir conn {}) + favorite-titles (imported-favorite-titles @conn)] + (is (= 3 (count favorite-titles)) + "Bare names, bracketed page refs, and namespaced pages each become a favorite link") + (is (= #{"Projects" "foo/bar"} + (set favorite-titles)) + "Imported favorites resolve to the original pages including flattened namespaces"))) + +(deftest-async import-normalizes-existing-random-journal-uuid-and-text-refs + (let [old-journal-uuid (random-uuid) + standard-journal-uuid (common-uuid/gen-uuid :journal-page-uuid 20260127) + title (str "refs " (page-ref/->page-ref old-journal-uuid) + " and " (block-ref/->block-ref old-journal-uuid)) + conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:build/journal 20260127 + :block/uuid old-journal-uuid + :build/keep-uuid? true} + :blocks [{:block/title title}]}]}) + file (write-temp-graph-file "pages/trigger-normalize.md" "- trigger normalize\n")] + (p/let [_ (import-files-to-db [file] conn {}) + journal (db-test/find-journal-by-journal-day @conn 20260127) + ref-block (db-test/find-block-by-content @conn #"refs")] + (is (= standard-journal-uuid (:block/uuid journal)) + "Existing random journal uuid is normalized to the standard journal uuid") + (is (nil? (d/entity @conn [:block/uuid old-journal-uuid])) + "Old journal uuid no longer resolves after normalization") + (is (= (str "refs " (page-ref/->page-ref standard-journal-uuid) + " and " (block-ref/->block-ref standard-journal-uuid)) + (:block/title ref-block)) + "Text references are rewritten to the standard journal uuid") + (is (= (:db/id journal) (get-in ref-block [:block/page :db/id])) + "Structured block page reference still points to the same journal entity")))) + +(deftest-async export-files-with-tag-classes-option + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2024_02_07.md" "pages/Interstellar.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:tag-classes ["movie"]})] + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + + (let [block (db-test/find-block-by-content @conn #"Inception") + tag-page (db-test/find-page-by-title @conn "Movie") + another-tag-page (db-test/find-page-by-title @conn "p0")] + (is (= (:block/title block) "Inception") + "tagged block with configured tag strips tag from content") + (is (= [:user.class/Movie] + (:block/tags (db-test/readable-properties block))) + "tagged block has configured tag imported as a class") + + (is (= [:logseq.class/Tag] (mapv :db/ident (:block/tags tag-page))) + "configured tag page in :tag-classes is a class") + (is (and another-tag-page (not (ldb/class? another-tag-page))) + "unconfigured tag page is not a class") + + (is (= {:block/tags [:logseq.class/Page :user.class/Movie]} + (db-test/readable-properties (db-test/find-page-by-title @conn "Interstellar"))) + "tagged page has configured tag imported as a class")))) + +(deftest-async export-files-with-property-classes-option + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) + ["journals/2024_02_23.md" "pages/url.md" "pages/Whiteboard___Tool.md" + "pages/Whiteboard___Arrow_head_toggle.md" + "pages/Library.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:property-classes ["type"]}) + _ (@#'gp-exporter/export-class-properties conn conn)] + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + + (is (= #{:user.class/Property :user.class/Movie :user.class/Class :user.class/Tool} + (->> @conn + (d/q '[:find [?ident ...] + :where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])]) + set)) + "All classes are correctly defined by :type") + + (is (= #{:user.property/url :user.property/sameas :user.property/rangeincludes} + (->> (d/entity @conn :user.class/Property) + :logseq.property.class/properties + (map :db/ident) + set)) + "Properties are correctly inferred for a class") + + (let [block (db-test/find-block-by-content @conn #"The Creator") + tag-page (db-test/find-page-by-title @conn "Movie")] + (is (= (:block/title block) "The Creator") + "tagged block with configured tag strips tag from content") + (is (= [:user.class/Movie] + (:block/tags (db-test/readable-properties block))) + "tagged block has configured tag imported as a class") + (is (= (:user.property/testtagclass block) (:block/tags block)) + "tagged block can have another property that references the same class it is tagged with, + without creating a duplicate class") + + (is (= [:logseq.class/Tag] (map :db/ident (:block/tags tag-page))) + "configured tag page derived from :property-classes is a class") + (is (nil? (db-test/find-page-by-title @conn "type")) + "No page exists for configured property") + + (is (= #{:user.class/Property :logseq.class/Property} + (set (:block/tags (db-test/readable-properties (db-test/find-page-by-title @conn "url"))))) + "tagged page has correct tags including one from option")))) + +(deftest-async export-files-with-remove-inline-tags + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2024_02_07.md" + "journals/2026_01_27.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:remove-inline-tags? false :convert-all-tags? true}) + namespaced-file (write-temp-graph-file + "pages/namespace-inline-tag.md" + "- #parent/child\n") + namespaced-conn (db-test/create-conn) + _ (import-files-to-db [namespaced-file] namespaced-conn {:remove-inline-tags? false :convert-all-tags? true}) + [block tag] (->> (d/q '[:find ?b ?t + :where + [?b :block/tags ?t] + [?b :block/page] + [?t :db/ident :user.class/parent___child]] + @namespaced-conn) + first + (map #(d/entity @namespaced-conn %))) + raw-title (:block/title block)] + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + (is (string/starts-with? (:block/title (db-test/find-block-by-content @conn #"Inception")) + "Inception #Movie") + "block with tag preserves inline tag") + (is (string/includes? (:block/title (db-test/find-block-by-content @conn #"block with multi word tag")) + "#[[another test]]") + "block with multi word tag preserves inline tag") + (testing "namespaced inline tag on first line is preserved as inline tag" + (is (some? block) + "imported first-line namespaced tag block") + (is (string? raw-title) + "imported block has raw title") + (when (string? raw-title) + (is (ldb/inline-tag? raw-title tag) + "first-line namespaced tag is stored as an inline tag"))))) + +(deftest-async export-files-with-ignored-properties + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["ignored/icon-page.md"]) + conn (db-test/create-conn) + {:keys [import-state]} (import-files-to-db files conn {})] + (is (= 2 + (count (filter #(= :icon (:property %)) @(:ignored-properties import-state)))) + "icon properties are visibly ignored in order to not fail import"))) + +(deftest-async export-files-with-property-parent-classes-option + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2024_11_26.md" + "pages/CreativeWork.md" "pages/Movie.md" "pages/type.md" + "pages/Whiteboard___Tool.md" "pages/Whiteboard___Arrow_head_toggle.md" + "pages/Property.md" "pages/url.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:property-parent-classes ["parent"] + ;; Also add this option to trigger some edge cases with namespace pages + :property-classes ["type"]})] + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors") + + (is (= #{:user.class/Movie :user.class/CreativeWork :user.class/Thing :user.class/Feature + :user.class/Class :user.class/Tool :user.class/Whiteboard___Tool :user.class/Property} + (->> @conn + (d/q '[:find [?ident ...] + :where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])]) + set)) + "All classes are correctly defined by :type") + + (is (= ["CreativeWork"] (map :block/title (:logseq.property.class/extends (d/entity @conn :user.class/Movie)))) + "Existing page correctly set as class parent") + (is (= ["Thing"] (map :block/title (:logseq.property.class/extends (d/entity @conn :user.class/CreativeWork)))) + "New page correctly set as class parent"))) + +(deftest-async export-files-with-property-pages-disabled + (p/let [file-graph-dir "test/resources/exporter-test-graph" + ;; any page with properties + files (mapv #(path/path-join file-graph-dir %) ["journals/2024_01_17.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:user-config {:property-pages/enabled? false + :property-pages/excludelist #{:prop-string}}})] + + (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) + "Created graph has no validation errors"))) + +(deftest-async export-config-file-sets-title-format + (p/let [conn (db-test/create-conn) + read-file #(p/do! "{:journal/page-title-format \"yyyy-MM-dd\"}") + _ (gp-exporter/export-config-file conn "logseq/config.edn" read-file {})] + (is (= "yyyy-MM-dd" + (:logseq.property.journal/title-format (d/entity @conn :logseq.class/Journal))) + "title format set correctly by config"))) + +(deftest split-title-by-code-fences + (let [split-fn #'gp-exporter/split-title-by-code-fences] + (testing "standalone code fence with language" + (is (= {:text-parts [] + :code-segs [{:text "it's an individual code snippet with language tag" + :lang "markdown"}]} + (split-fn "```markdown\nit's an individual code snippet with language tag\n```")))) + + (testing "standalone code fence without language" + (is (= {:text-parts [] + :code-segs [{:text "it's an individual code snippet without language tag" + :lang nil}]} + (split-fn "```\nit's an individual code snippet without language tag\n```")))) + + (testing "one code fence with leading text" + (is (= {:text-parts ["before code snippet"] + :code-segs [{:text "echo \"ok\"\nexit" + :lang nil}]} + (split-fn "before code snippet\n```\necho \"ok\"\nexit\n```")))) + + (testing "one code fence with leading and trailing text" + (is (= {:text-parts ["before code snippet" "after code snippet"] + :code-segs [{:text "echo \"ok\"\nexit" + :lang "bash"}]} + (split-fn "before code snippet\n```bash\necho \"ok\"\nexit\n```\nafter code snippet")))) + + (testing "one code fence followed by trailing text" + (is (= {:text-parts ["after code snippet"] + :code-segs [{:text "echo \"ok\"\nexit" + :lang "bash"}]} + (split-fn "```bash\necho \"ok\"\nexit\n```\nafter code snippet")))) + + (testing "multiple code fences mixed with text" + (is (= {:text-parts ["before code snippet" "middle" "after code snippet"] + :code-segs [{:text "echo \"ok\"\nexit" + :lang "bash"} + {:text "echo \"bye\"\nexit" + :lang "bash"}]} + (split-fn "before code snippet\n```bash\necho \"ok\"\nexit\n```\nmiddle\n```bash\necho \"bye\"\nexit\n```\nafter code snippet")))) + + (testing "edge: one code fence followed by opening fence without closing fence" + (is (= {:text-parts ["echo \"missing end fence\""] ;; no "```bash" ahead, it's fine as is; let's leave it + :code-segs [{:text "echo \"ok\"\nexit" + :lang "bash"}]} + (split-fn "```bash\necho \"ok\"\nexit\n```\n```bash\necho \"missing end fence\"")))) + + (testing "edge: pure multiple code fences with no extra text" + (let [{:keys [text-parts code-segs]} (split-fn "```markdown\n1st code snippet with language tag\n```\n```\n2nd code snippet without language tag\n```")] + (is (and (empty? text-parts) (> (count code-segs) 1)) "not pure single code and no mixed content"))) + + (testing "edge: opening fence without closing fence" + (let [title "```bash\necho \"missing end fence\"" + {:keys [text-parts code-segs]} (split-fn title)] + (is (and (= (count text-parts) 1) (not= (first text-parts) title) (empty? code-segs)) "not pure single code and no mixed content"))) + + (testing "edge: plain text without any code fence" + (is (= {:text-parts ["plain text only"] + :code-segs []} + (split-fn "plain text only")))) + + (testing "edge: empty title" + (is (= {:text-parts [""] + :code-segs []} + (split-fn "")))))) + +(deftest-async export-files-with-extract-code-snippet + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2026_03_01.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:extract-code-snippets? true}) + journal-page-eid (d/q '[:find ?p . :where [?p :block/journal-day 20260301]] @conn) + top-blocks (->> (d/q '[:find [?b ...] + :in $ ?page + :where + [?b :block/page ?page] + [?b :block/parent ?page]] + @conn journal-page-eid) + (map #(d/entity @conn %)) + (sort-by :block/order) + vec) + get-direct-children (fn [block] + (->> (d/q '[:find [?c ...] + :in $ ?parent + :where [?c :block/parent ?parent]] + @conn (:db/id block)) + (map #(d/entity @conn %))))] + + (testing "standalone code block with language tag" + (let [b (nth top-blocks 0)] + (is (= "it's an individual code snippet with language tag" (:block/title b)) + "Standalone code block title has fences stripped") + (is (= 0 (count (get-direct-children b))) + "Standalone code block has no children") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b)))) + "Standalone code block is tagged as Code-block") + (is (= "markdown" (:logseq.property.code/lang b)) + "Standalone code block has markdown language property"))) + + (testing "standalone code block without language tag" + (let [b (nth top-blocks 1)] + (is (= "it's an individual code snippet without language tag" (:block/title b)) + "Standalone code block title has fences stripped") + (is (= 0 (count (get-direct-children b))) + "Standalone code block has no children") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b)))) + "Standalone code block is tagged as Code-block") + (is (= nil (:logseq.property.code/lang b)) + "Standalone code block has no language property"))) + + (testing "text before code snippet" + (let [b (nth top-blocks 2) + children (get-direct-children b)] + (is (= "before code snippet" (:block/title b)) + "Block title has text only without code") + (is (= 1 (count children)) + "Block has 1 code child") + (is (= "echo \"ok\"\nexit" (:block/title (first children))) + "Child code block has correct content without fence markers") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags (first children))))) + "Child block is tagged as Code-block") + (is (= nil (:logseq.property.code/lang (first children))) + "Child block has no language property"))) + + (testing "text before and after code snippet" + (let [b (nth top-blocks 3) + children (get-direct-children b)] + (is (= "before code snippet\nafter code snippet" (:block/title b)) + "Block title has text only without code") + (is (= 1 (count children)) + "Block has 1 code child") + (is (= "echo \"ok\"\nexit" (:block/title (first children))) + "Child code block has correct content without fence markers") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags (first children))))) + "Child block is tagged as Code-block") + (is (= "bash" (:logseq.property.code/lang (first children))) + "Child block has bash language property"))) + + (testing "code snippet before text" + (let [b (nth top-blocks 4) + children (get-direct-children b)] + (is (= "after code snippet" (:block/title b)) + "Block title has text only without code") + (is (= 1 (count children)) + "Block has 1 code child") + (is (= "echo \"ok\"\nexit" (:block/title (first children))) + "Child code block has correct content without fence markers") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags (first children))))) + "Child block is tagged as Code-block") + (is (= "bash" (:logseq.property.code/lang (first children))) + "Child block has bash language property"))) + + (testing "multiple code snippets mixed with text" + (let [b (nth top-blocks 5) + children (sort-by :block/order (get-direct-children b))] + (is (= "before code snippet\nmiddle\nafter code snippet" (:block/title b)) + "Block title has all text parts without code") + (is (= 2 (count children)) + "Block has 2 code children") + (is (= "echo \"ok\"\nexit" (:block/title (first children))) + "First child code block has correct content without fence markers") + (is (= "echo \"bye\"\nexit" (:block/title (second children))) + "Second child code block has correct content without fence markers") + (is (every? #(= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags %)))) children) + "Both child blocks are tagged as Code-block") + (is (every? #(= "bash" (:logseq.property.code/lang %)) children) + "Both child blocks have bash language property"))))) + +(deftest-async export-files-without-extract-code-snippet + (p/let [file-graph-dir "test/resources/exporter-test-graph" + files (mapv #(path/path-join file-graph-dir %) ["journals/2026_03_01.md"]) + conn (db-test/create-conn) + _ (import-files-to-db files conn {:extract-code-snippets? false}) + journal-page-eid (d/q '[:find ?p . :where [?p :block/journal-day 20260301]] @conn) + top-blocks (->> (d/q '[:find [?b ...] + :in $ ?page + :where + [?b :block/page ?page] + [?b :block/parent ?page]] + @conn journal-page-eid) + (map #(d/entity @conn %)) + (sort-by :block/order) + vec) + get-direct-children (fn [block] + (->> (d/q '[:find [?c ...] + :in $ ?parent + :where [?c :block/parent ?parent]] + @conn (:db/id block)) + (map #(d/entity @conn %))))] + + (testing "standalone code block with language tag is still tagged as Code-block" + (let [b (nth top-blocks 0)] + (is (= "it's an individual code snippet with language tag" (:block/title b)) + "Standalone code block title has fences stripped") + (is (= 0 (count (get-direct-children b))) + "Standalone code block has no children") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b)))) + "Standalone code block is tagged as Code-block") + (is (= "markdown" (:logseq.property.code/lang b)) + "Standalone code block has markdown language property"))) + + (testing "standalone code block without language tag is still tagged as Code-block" + (let [b (nth top-blocks 1)] + (is (= "it's an individual code snippet without language tag" (:block/title b)) + "Standalone code block title has fences stripped") + (is (= 0 (count (get-direct-children b))) + "Standalone code block has no children") + (is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b)))) + "Standalone code block is tagged as Code-block") + (is (= nil (:logseq.property.code/lang b)) + "Standalone code block has no language property"))) + + (testing "mixed-content block is NOT extracted into children when extract-code-snippets? is false" + (let [b (nth top-blocks 2)] + (is (= 0 (count (get-direct-children b))) + "Block with text before code has no children extracted") + (is (string/includes? (:block/title b) "```") + "Block title retains raw code fence markup"))) + + (testing "another mixed-content block is NOT extracted when extract-code-snippets? is false" + (let [b (nth top-blocks 3)] + (is (= 0 (count (get-direct-children b))) + "Block with text surrounding code has no children extracted") + (is (string/includes? (:block/title b) "```") + "Block title retains raw code fence markup"))))) + +(deftest page-alias-sanitise-for-import + (testing "duplicate-owner alias is dropped and reported" + (let [alias-owners (atom {}) + ignored-props (atom []) + pages [{:block/name "p1" :block/alias [{:block/name "shared"}]} + {:block/name "p2" :block/alias [{:block/name "shared"}]}] + result (gp-exporter/sanitize-page-aliases-for-import! + pages alias-owners ignored-props)] + (is (= "p1" (get @alias-owners "shared")) + "first declarer wins ownership") + (is (= 1 (count (filter #(= :alias/duplicate-owner (:reason %)) @ignored-props))) + "duplicate alias is reported in ignored-properties") + (is (nil? (:block/alias (second result))) + "second page's conflicting alias is removed"))) + (testing "alias-of-alias is dropped and reported" + (let [alias-owners (atom {}) + ignored-props (atom []) + pages [{:block/name "root" :block/alias [{:block/name "mid"}]} + {:block/name "mid" :block/alias [{:block/name "leaf"}]}] + result (gp-exporter/sanitize-page-aliases-for-import! + pages alias-owners ignored-props)] + (is (= 1 (count (filter #(= :alias/alias-owns-aliases (:reason %)) @ignored-props))) + "alias-of-alias is reported in ignored-properties") + (is (nil? (:block/alias (first result))) + "alias pointing to a page that owns aliases is removed"))) + (testing "self-alias is dropped and reported" + (let [alias-owners (atom {}) + ignored-props (atom []) + pages [{:block/name "self" :block/alias [{:block/name "self"}]}] + result (gp-exporter/sanitize-page-aliases-for-import! + pages alias-owners ignored-props)] + (is (nil? (:block/alias (first result))) + "self-alias declaration is removed") + (is (some #(= :alias/self (:reason %)) @ignored-props) + "self-alias is reported in ignored-properties")))) + +(deftest page-alias-sanitise-for-import-cross-file + (testing "source-is-alias caught across files (root->mid in file 1, mid->leaf in file 2)" + (let [alias-owners (atom {}) + ignored-props (atom [])] + ;; File 1: root claims mid as alias → registers "mid" -> "root" + (gp-exporter/sanitize-page-aliases-for-import! + [{:block/name "root" :block/alias [{:block/name "mid"}]}] + alias-owners ignored-props) + ;; File 2: mid tries to declare leaf as its alias — but mid is already an alias + (let [result (gp-exporter/sanitize-page-aliases-for-import! + [{:block/name "mid" :block/alias [{:block/name "leaf"}]}] + alias-owners ignored-props)] + (is (nil? (:block/alias (first result))) + "mid's alias declaration dropped: mid is already an alias") + (is (some #(= :alias/source-is-alias (:reason %)) @ignored-props) + "source-is-alias reason recorded")))) + (testing "alias-owns-aliases caught across files (mid->leaf in file 1, root->mid in file 2)" + (let [alias-owners (atom {}) + ignored-props (atom [])] + ;; File 1: mid claims leaf as alias → registers "leaf" -> "mid"; mid is now an owner + (gp-exporter/sanitize-page-aliases-for-import! + [{:block/name "mid" :block/alias [{:block/name "leaf"}]}] + alias-owners ignored-props) + ;; File 2: root tries to use mid as alias — but mid already owns aliases + (let [result (gp-exporter/sanitize-page-aliases-for-import! + [{:block/name "root" :block/alias [{:block/name "mid"}]}] + alias-owners ignored-props)] + (is (nil? (:block/alias (first result))) + "root's alias pointing to mid dropped: mid already owns aliases") + (is (some #(= :alias/alias-owns-aliases (:reason %)) @ignored-props) + "alias-owns-aliases reason recorded"))))) diff --git a/deps/graph-parser/test/logseq/graph_parser/extract_test.cljs b/deps/graph-parser/test/logseq/graph_parser/extract_test.cljs new file mode 100644 index 0000000..8a9843e --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/extract_test.cljs @@ -0,0 +1,131 @@ +(ns logseq.graph-parser.extract-test + (:require [cljs.test :refer [deftest is are]] + [datascript.core :as d] + [logseq.graph-parser.extract :as extract])) + +;; This is a copy of frontend.components.repo/multiplatform-reserved-chars for reserved chars testing +(def multiplatform-reserved-chars ":\\*\\?\"<>|\\#\\\\") + +;; Stuffs should be parsable (don't crash) when users dump some random files +(deftest page-name-parsing-tests + (is (string? (#'extract/tri-lb-title-parsing "___-_-_-_---___----"))) + (is (string? (#'extract/tri-lb-title-parsing "_____///____---___----"))) + (is (string? (#'extract/tri-lb-title-parsing "/_/////---/_----"))) + (is (string? (#'extract/tri-lb-title-parsing "/\\#*%lasdf\\//__--dsll_____----....-._0x2B"))) + (is (string? (#'extract/tri-lb-title-parsing "/\\#*%l;;&&;&\\//__--dsll_____----....-._0x2B"))) + (is (string? (#'extract/tri-lb-title-parsing multiplatform-reserved-chars))) + (is (string? (#'extract/tri-lb-title-parsing "dsa&;l dsalfjk jkl")))) + +(deftest uri-decoding-tests + (is (= (#'extract/safe-url-decode "%*-sd%%%saf%=lks") "%*-sd%%%saf%=lks")) ;; Contains %, but invalid + (is (= (#'extract/safe-url-decode "%2FDownloads%2FCNN%3AIs%5CAll%3AYou%20Need.pdf") "/Downloads/CNN:Is\\All:You Need.pdf")) + (is (= (#'extract/safe-url-decode "asldkflksdaf啦放假啦睡觉啦啊啥的都撒娇浪费;dla") "asldkflksdaf啦放假啦睡觉啦啊啥的都撒娇浪费;dla"))) + +(deftest page-name-sanitization-backward-tests + (is (= "abc.def.ghi.jkl" (#'extract/tri-lb-title-parsing "abc.def.ghi.jkl"))) + (is (= "abc/def/ghi/jkl" (#'extract/tri-lb-title-parsing "abc%2Fdef%2Fghi%2Fjkl"))) + (is (= "abc%/def/ghi/jkl" (#'extract/tri-lb-title-parsing "abc%25%2Fdef%2Fghi%2Fjkl"))) + (is (= "abc%2——ef/ghi/jkl" (#'extract/tri-lb-title-parsing "abc%2——ef%2Fghi%2Fjkl"))) + (is (= "abc&2Fghi/jkl" (#'extract/tri-lb-title-parsing "abc&2Fghi%2Fjkl"))) + (is (= "abc<2Fghi/jkl" (#'extract/tri-lb-title-parsing "abc<2Fghi%2Fjkl"))) + (is (= "abc%2Fghi/jkl" (#'extract/tri-lb-title-parsing "abc%2Fghi%2Fjkl"))) + (is (= "abc;&;2Fghi/jkl" (#'extract/tri-lb-title-parsing "abc;&;2Fghi%2Fjkl"))) + ;; happens when importing some compatible files on *nix / macOS + (is (= multiplatform-reserved-chars (#'extract/tri-lb-title-parsing multiplatform-reserved-chars)))) + +(deftest path-utils-tests + (is (= "asldk lakls " (#'extract/path->file-body "/data/app/asldk lakls .lsad"))) + (is (= "asldk lakls " (#'extract/path->file-body "asldk lakls .lsad"))) + (is (= "asldk lakls" (#'extract/path->file-body "asldk lakls"))) + (is (= "asldk lakls" (#'extract/path->file-body "/data/app/asldk lakls"))) + (is (= "asldk lakls" (#'extract/path->file-body "file://data/app/asldk lakls.as"))) + (is (= "中文asldk lakls" (#'extract/path->file-body "file://中文data/app/中文asldk lakls.as")))) + +;; Bare minimum schema to test extract +(def file-schema + {:block/uuid {:db/unique :db.unique/identity} + :block/name {:db/unique :db.unique/identity}}) + +(defn- extract [file content & [options]] + (extract/extract file + content + (merge {:block-pattern "-" :db (d/empty-db file-schema) + :verbose false} + options))) + +(defn- extract-block-content + [text] + (let [{:keys [blocks]} (extract "a.md" text)] + (mapv :block/title blocks))) + +(defn- extract-title [file text] + (-> (extract file text) :pages first :block/properties :title)) + +(deftest extract-blocks-for-headings + (is (= ["a" "b" "c"] + (extract-block-content + "- a + - b + - c"))) + + (is (= ["## hello" "world" "nice" "nice" "bingo" "world"] + (extract-block-content "## hello + - world + - nice + - nice + - bingo + - world"))) + + (is (= ["# a" "## b" "### c" "#### d" "### e" "f" "g" "h" "i" "j"] + (extract-block-content "# a +## b +### c +#### d +### e +- f + - g + - h + - i +- j")))) + +(deftest parse-page-title + (is (= nil + (extract-title "foo.org" ""))) + (is (= "Howdy" + (extract-title "foo.org" "#+title: Howdy"))) + (is (= "Howdy" + (extract-title "foo.org" "#+TITLE: Howdy"))) + (is (= "Howdy" + (extract-title "foo.org" "#+TiTlE: Howdy"))) + (is (= "diagram/abcdef" + (extract-title "foo.org" ":PROPERTIES: +:ID: 72289d9a-eb2f-427b-ad97-b605a4b8c59b +:END: +#+TITLE: diagram/abcdef"))) + (is (= "diagram/abcdef" + (extract-title "foo.org" ":PROPERTIES: +:ID: 72289d9a-eb2f-427b-ad97-b605a4b8c59b +:END: +#+title: diagram/abcdef")))) + +(deftest extract-blocks-with-property-pages-config + (are [extract-args expected-refs] + (= expected-refs + (->> (apply extract extract-args) + :blocks + (mapcat #(->> % :block/refs (map :block/name))) + set)) + + ["a.md" "foo:: #bar\nbaz:: #bing" {:user-config {:property-pages/enabled? true}}] + #{"bar" "bing" "foo" "baz"} + + ["a.md" "foo:: #bar\nbaz:: #bing" {:user-config {:property-pages/enabled? false}}] + #{"bar" "bing"})) + +(deftest test-regression-1902 + (is (= ["line1" "line2" "line3" "line4"] + (extract-block-content + "- line1 + - line2 + - line3 + - line4")))) \ No newline at end of file diff --git a/deps/graph-parser/test/logseq/graph_parser/mldoc_test.cljs b/deps/graph-parser/test/logseq/graph_parser/mldoc_test.cljs new file mode 100644 index 0000000..cdc17ef --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/mldoc_test.cljs @@ -0,0 +1,150 @@ +(ns logseq.graph-parser.mldoc-test + (:require [cljs.test :refer [testing deftest are is]] + [logseq.graph-parser.mldoc :as gp-mldoc] + [logseq.graph-parser.text :as text])) + +(def md-config (gp-mldoc/default-config :markdown)) + +(deftest src-test + (is (= [["Src" + {:lines [": hello" "\n"], + :pos_meta {:start_pos 4, :end_pos 12}, + :full_content "```\n: hello\n```"}] + {:start_pos 0, :end_pos 15}] + (first (gp-mldoc/->edn "``` +: hello +```" md-config))) + "Basic src example") + + (is (= [["Src" + {:lines [" hello" "\n" " world" "\n"], + :pos_meta {:start_pos 7, :end_pos 25}, + :full_content "```\nhello\nworld\n```"}] + {:start_pos 1, :end_pos 29}] + (second (gp-mldoc/->edn " + ``` + hello + world + ``` +" md-config))) + "Src example with leading whitespace")) + +(defn- get-properties + [x] + (->> (gp-mldoc/->edn x md-config) + ffirst second + (map (fn [[k v ast]] + [(keyword k) (text/parse-property k v ast {})])) + (into {}))) + +(deftest md-properties-test + (are [x y] (= y (get-properties x)) + + ;; reference values + "property:: [[foo]], [[bar]]" + {:property #{"foo" "bar"}} + + ;; comma separated + "tags:: foo, bar, foo" + {:tags #{"foo" "bar"}})) + +(deftest name-definition-test + (is (= [["List" + [{:content [["Paragraph" [["Plain" "definition"]]]], + :items [], + :name [["Plain" "term"]], + :indent 0, + :ordered false}]] + {:start_pos 0, :end_pos 17}] + (first (gp-mldoc/->edn "term +: definition" md-config))))) + +(deftest macro-with-script-markup-test + (testing "inline macros can contain superscript and subscript markup" + (are [content ast] (= ast (gp-mldoc/inline->edn content md-config)) + "{{cloze Ca^{ +2} ions}}" + [["Macro" {:name "cloze" :arguments ["Ca^{ +2} ions"]}]] + + "{{cloze H_{2}O}}" + [["Macro" {:name "cloze" :arguments ["H_{2}O"]}]] + + "{{cloze Ca^{+2}}}" + [["Macro" {:name "cloze" :arguments ["Ca^{+2}"]}]] + + "{{foo Ca^{ +2} ions, [[a, b]], \"c, d\"}}" + [["Macro" {:name "foo" :arguments ["Ca^{ +2} ions" "[[a, b]]" "\"c, d\""]}]] + + ;; page ref inside a macro that also has script markup — exercises + ;; the Nested_link case in inline-ast->source + "{{cloze Ca^{ +2} [[water]]}}" + [["Macro" {:name "cloze" :arguments ["Ca^{ +2} [[water]]"]}]])) + + (testing "normal macros without script markup are not modified" + (are [content ast] (= ast (gp-mldoc/inline->edn content md-config)) + "{{cloze simple text}}" + [["Macro" {:name "cloze" :arguments ["simple text"]}]] + + "{{foo a, b, c}}" + [["Macro" {:name "foo" :arguments ["a" "b" "c"]}]] + + "{{cloze [[page name]]}}" + [["Macro" {:name "cloze" :arguments ["[[page name]]"]}]])) + + (testing "block parsing keeps the issue reproduction as macro nodes" + (is (= [["Plain" "This is usually highlighted by the accumulation of the "] + ["Macro" {:name "cloze" :arguments ["Ca^{ +2} ions"]}] + ["Plain" " . This will be seen as "] + ["Macro" {:name "cloze" :arguments ["large flocculent amorphous densities in TEM"]}]] + (-> (gp-mldoc/->edn + "- This is usually highlighted by the accumulation of the {{cloze Ca^{ +2} ions}} . This will be seen as {{cloze large flocculent amorphous densities in TEM}}" + md-config) + ffirst + second + :title))) + + (is (= [["Plain" "Water formula is "] + ["Macro" {:name "cloze" :arguments ["H_{2}O"]}]] + (-> (gp-mldoc/->edn + "- Water formula is {{cloze H_{2}O}}" + md-config) + ffirst + second + :title))))) + +(defn- parse-properties + [text] + (->> (gp-mldoc/->edn text (gp-mldoc/default-config :org)) + (filter #(= "Properties" (ffirst %))) + ffirst + second)) + +(deftest org-properties-test + (testing "just title" + (let [content "#+TITLE: some title " + props (parse-properties content)] + (is (= "some title " (second (first props)))))) + + (testing "filetags" + (let [content "#+FILETAGS: :tag1:tag2:@tag: +#+TAGS: tag3 +body" + props (parse-properties content)] + (is ["@tag" "tag1" "tag2"] (sort (:filetags props))) + (is ["@tag" "tag1" "tag2" "tag3"] (sort (:tags props)))))) + +(deftest remove-indentation-spaces + (testing "Remove indentations for every line" + (is (= "block 1.1\n line 1\n line 2\nline 3\nline 4" + (let [s "block 1.1 + line 1 + line 2 + line 3 +line 4"] + (gp-mldoc/remove-indentation-spaces s 2 false)))) + (is (= "\t- block 1.1\n line 1\n line 2\nline 3\nline 4" + (let [s "\t- block 1.1 +\t line 1 +\t line 2 +\t line 3 +\tline 4"] + (gp-mldoc/remove-indentation-spaces s 3 false)))))) diff --git a/deps/graph-parser/test/logseq/graph_parser/property_test.cljs b/deps/graph-parser/test/logseq/graph_parser/property_test.cljs new file mode 100644 index 0000000..9bd9bca --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/property_test.cljs @@ -0,0 +1,62 @@ +(ns logseq.graph-parser.property-test + (:require [cljs.test :refer [are deftest testing]] + [logseq.graph-parser.property :as gp-property])) + +(def test-db "test-db") + +(deftest test->new-properties + (are [x y] (= (gp-property/->new-properties x) y) + ":PROPERTIES:\n:foo: bar\n:END:" + "foo:: bar" + + "hello\n:PROPERTIES:\n:foo: bar\n:END:" + "hello\nfoo:: bar" + + "hello\n:PROPERTIES:\n:foo: bar\n:nice: bingo\n:END:" + "hello\nfoo:: bar\nnice:: bingo" + + "hello\n:PROPERTIES:\n:foo: bar\n:nice: bingo\n:END:" + "hello\nfoo:: bar\nnice:: bingo" + + "hello\n:PROPERTIES:\n:foo: bar\n:nice: bingo\n:END:\nnice" + "hello\nfoo:: bar\nnice:: bingo\nnice" + + "hello\n:PROPERTIES:\n:foo: bar\n:nice:\n:END:\nnice" + "hello\nfoo:: bar\nnice:: \nnice" + + "hello\n:PROPERTIES:\n:foo: bar\n:nice\n:END:\nnice" + "hello\nfoo:: bar\n:nice\nnice")) + +(deftest test-remove-properties + (testing "properties with non-blank lines" + (are [x y] (= x y) + (gp-property/remove-properties :org "** hello\n:PROPERTIES:\n:x: y\n:END:\n") + "** hello" + + (gp-property/remove-properties :org "** hello\n:PROPERTIES:\n:x: y\na:b\n:END:\n") + "** hello" + + (gp-property/remove-properties :markdown "** hello\nx:: y\na:: b\n") + "** hello" + + (gp-property/remove-properties :markdown "** hello\nx:: y\na::b\n") + "** hello")) + + (testing "properties with blank lines" + (are [x y] (= x y) + (gp-property/remove-properties :org "** hello\n:PROPERTIES:\n\n:x: y\n:END:\n") + "** hello" + + (gp-property/remove-properties :org "** hello\n:PROPERTIES:\n:x: y\n\na:b\n:END:\n") + "** hello")) + + (testing "invalid-properties" + (are [x y] (= x y) + (gp-property/remove-properties :markdown "hello\nnice\nfoo:: bar") + "hello\nnice\nfoo:: bar" + + (gp-property/remove-properties :markdown "hello\nnice\nfoo:: bar\ntest") + "hello\nnice\nfoo:: bar\ntest" + + (gp-property/remove-properties :markdown "** hello\nx:: y\n\na:: b\n") + "** hello\n\na:: b"))) \ No newline at end of file diff --git a/deps/graph-parser/test/logseq/graph_parser/test/docs_graph_helper.cljs b/deps/graph-parser/test/logseq/graph_parser/test/docs_graph_helper.cljs new file mode 100644 index 0000000..bd56aa8 --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/test/docs_graph_helper.cljs @@ -0,0 +1,20 @@ +(ns logseq.graph-parser.test.docs-graph-helper + "Helper fns for setting up and running tests against docs graph" + (:require ["child_process" :as child-process] + ["fs" :as fs])) + +;; Helper fns for test setup +;; ========================= +(defn- sh + "Run shell cmd synchronously and print to inherited streams by default. Aims + to be similar to babashka.tasks/shell" + [cmd opts] + (child-process/spawnSync (first cmd) + (clj->js (rest cmd)) + (clj->js (merge {:stdio "inherit"} opts)))) + +(defn clone-docs-repo-if-not-exists + [dir branch] + (when-not (.existsSync fs dir) + (sh ["git" "clone" "--depth" "1" "-b" branch "-c" "advice.detachedHead=false" + "https://github.com/logseq/docs" dir] {}))) \ No newline at end of file diff --git a/deps/graph-parser/test/logseq/graph_parser/test/helper.cljc b/deps/graph-parser/test/logseq/graph_parser/test/helper.cljc new file mode 100644 index 0000000..08278ae --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/test/helper.cljc @@ -0,0 +1,25 @@ +(ns logseq.graph-parser.test.helper) + +;; Copied from https://github.com/babashka/nbb/blob/e5d84b0fac59774f5d7a4a9e807240cce04bf252/test/nbb/test_macros.clj +(defmacro deftest-async + "A wrapper around deftest that handles async and done in all cases. + Importantly, it prevents unexpected failures in an async test from abruptly + ending a test suite" + [name opts & body] + (let [[opts body] + (if (map? opts) + [opts body] + [nil (cons opts body)])] + `(cljs.test/deftest ~name + ~@(when-let [pre (:before opts)] + [pre]) + (cljs.test/async + ~'done + (-> (do ~@body) + (.catch (fn [err#] + (cljs.test/is (= 1 0) (str err# (.-stack err#))))) + (.finally + (fn [] + ~@(when-let [post (:after opts)] + [post]) + (~'done)))))))) diff --git a/deps/graph-parser/test/logseq/graph_parser/test_runner.cljs b/deps/graph-parser/test/logseq/graph_parser/test_runner.cljs new file mode 100644 index 0000000..50f8d1d --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/test_runner.cljs @@ -0,0 +1,6 @@ +(ns logseq.graph-parser.test-runner + "Test runner which enables https://github.com/PEZ/baldr by default" + (:require [nextjournal.test-runner :as next-runner] + [pez.baldr])) + +(def -main next-runner/-main) diff --git a/deps/graph-parser/test/logseq/graph_parser/text_test.cljs b/deps/graph-parser/test/logseq/graph_parser/text_test.cljs new file mode 100644 index 0000000..0834fda --- /dev/null +++ b/deps/graph-parser/test/logseq/graph_parser/text_test.cljs @@ -0,0 +1,117 @@ +(ns logseq.graph-parser.text-test + (:require [cljs.test :refer [are deftest testing]] + [logseq.graph-parser.text :as text] + [logseq.graph-parser.mldoc :as gp-mldoc])) + +(deftest test-get-page-name + (are [x y] (= (text/get-page-name x) y) + "[[page]]" "page" + "[[another page]]" "another page" + "[single bracket]" nil + "no brackets" nil + + "[[another page]]" "another page" + "[[nested [[page]]]]" "nested [[page]]" + + "[[file:./page.org][page]]" "page" + "[[file:./pages/page.org][page]]" "page" + + "[[file:./namespace.page.org][namespace/page]]" "namespace/page" + "[[file:./pages/namespace.page.org][namespace/page]]" "namespace/page" + "[[file:./pages/namespace.page.org][please don't change me]]" "namespace/page" + + "[page](file:./page.md)" "page" + "[page](file:.pages/page.md)" "page" + + "[logseq/page](file:./logseq.page.md)" "logseq/page" + "[logseq/page](file:./pages/logseq.page.md)" "logseq/page")) + +(deftest page-ref-un-brackets! + (are [x y] (= (text/page-ref-un-brackets! x) y) + "[[page]]" "page" + "[[another page]]" "another page" + "[[nested [[page]]]]" "nested [[page]]" + "[single bracket]" "[single bracket]" + "no brackets" "no brackets")) + +(def block-patterns + {:markdown "-" + :org "*"}) + +(deftest remove-level-spaces + (testing "markdown" + (are [x y] (= (text/remove-level-spaces x :markdown (block-patterns :markdown) true) y) + "- foobar" "foobar" + " - foobar" "foobar")) + (testing "markdown without spaces between the `#` and title" + (are [x y] (= (text/remove-level-spaces x :markdown (block-patterns :markdown)) y) + "-foobar" "foobar")) + (testing "org" + (are [x y] (= (text/remove-level-spaces x :org (block-patterns :org) true) y) + "* foobar" "foobar" + "** foobar" "foobar" + "********************* foobar" "foobar")) + (testing "org without spaces between the `#` and title" + (are [x y] (= (text/remove-level-spaces x :org (block-patterns :org)) y) + "*foobar" "foobar" + "**foobar" "foobar" + "*********************foobar" "foobar"))) + +(defn- parse-property + [k v user-config] + (let [references (gp-mldoc/get-references v (gp-mldoc/default-config :markdown))] + (text/parse-property k v references user-config))) + +(deftest test-parse-property + (testing "for default comma separated properties" + (are [k v y] (= (parse-property k v {}) y) + :tags "foo" #{"foo"} + :tags "comma, separated" #{"comma" "separated"} + :alias "one, two, one" #{"one" "two"})) + + (testing "for user comma separated properties" + (are [k v y] (= (parse-property k v {:property/separated-by-commas #{:comma-prop}}) y) + :comma-prop "foo" #{"foo"} + :comma-prop "comma, separated" #{"comma" "separated"} + :comma-prop "one, two, one" #{"one" "two"})) + + (testing "for user comma separated properties with mixed values" + (are [k v y] (= (parse-property k v {:property/separated-by-commas #{:comma-prop}}) y) + :comma-prop "foo, #bar" #{"foo", "bar"} + :comma-prop "comma, separated, [[page ref]], [[nested [[page]]]], #[[nested [[tag]]]], end" #{"page ref" "nested [[page]]" "nested [[tag]]" "comma" "separated" "end"})) + + (testing "for normal properties" + (are [k v y] (= (parse-property k v {}) y) + :normal "[[foo]] [[bar]]" #{"foo" "bar"} + :normal "[[foo]], [[bar]]" #{"foo" "bar"} + :normal "[[foo]]" #{"foo"} + :normal "[[foo]], [[bar]], #baz" #{"foo" "bar" "baz"} + :normal "[[foo [[bar]]]]" #{"foo [[bar]]"} + :normal "[[foo [[bar]]]], [[baz]]" #{"baz" "foo [[bar]]"} + :title "comma, is ok" "comma, is ok")) + + (testing "for tags in properties with punctuation" + (are [k v y] (= (parse-property k v {}) y) + :prop "#foo, #bar. #baz!" #{"foo" "bar" "baz"} + :prop "#foo: '#bar'" #{"foo" "bar"})) + + (testing "parse-property with quoted strings" + (are [k v y] (= (parse-property k v {}) y) + :tags "\"foo, bar\"" "\"foo, bar\"" + :tags "\"[[foo]], [[bar]]\"" "\"[[foo]], [[bar]]\"")) + + (testing "parse title property with square bracket" + (are [k v y] (= (parse-property k v {}) y) + :title "[[Jan 11th, 2022]] 21:26" "[[Jan 11th, 2022]] 21:26" + :title "[[[[aldsfkd]] a.b/c.d]]" "[[[[aldsfkd]] a.b/c.d]]")) + + (testing "built-in properties parse as expected" + (are [k v y] (= (parse-property k v {}) y) + :id "62e98716-9c0b-4253-83e7-7f8e8a23fe19" "62e98716-9c0b-4253-83e7-7f8e8a23fe19" + :filters "{\"product process\" true}" "{\"product process\" true}" + :collapsed "false" false + :created-at "1609233702047" 1609233702047 + :background-color "#533e7d" "#533e7d"))) + + +#_(cljs.test/test-ns 'logseq.graph-parser.text-test) diff --git a/deps/graph-parser/test/resources/book/it/Understanding EXPLAIN.pdf b/deps/graph-parser/test/resources/book/it/Understanding EXPLAIN.pdf new file mode 100644 index 0000000..f8ce0cd Binary files /dev/null and b/deps/graph-parser/test/resources/book/it/Understanding EXPLAIN.pdf differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/.gitignore b/deps/graph-parser/test/resources/exporter-test-graph/.gitignore new file mode 100644 index 0000000..0a71a8f --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/.gitignore @@ -0,0 +1 @@ +/logseq/bak diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/HEART_Teams.png b/deps/graph-parser/test/resources/exporter-test-graph/assets/HEART_Teams.png new file mode 100644 index 0000000..a0eb0ec Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/HEART_Teams.png differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.edn b/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.edn new file mode 100644 index 0000000..75f2a8c --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.edn @@ -0,0 +1,42 @@ +{:highlights [{:id #uuid "687022ae-dac1-42a6-9d4c-39a0dba05918", + :page 1, + :position {:bounding {:x1 131, + :y1 336, + :x2 483, + :y2 399, + :width 574.0000000000001, + :height 573.999986224}, + :rects (), + :page 1}, + :content {:text "", :image 1752179374600}, + :properties {:color "yellow"}} + {:id #uuid "68702394-3613-4bac-85a7-28643d58237f", + :page 8, + :position {:bounding {:x1 10.680589094758034, + :y1 183.2645263671875, + :x2 119.76637782156467, + :y2 204.954345703125, + :width 574.0000000000001, + :height 573.999986224}, + :rects ({:x1 10.680589094758034, + :y1 183.2645263671875, + :x2 119.76637782156467, + :y2 204.954345703125, + :width 574.0000000000001, + :height 573.999986224}), + :page 8}, + :content {:text "Duke School"}, + :properties {:color "blue"}} + {:id #uuid "68702499-159a-4a14-a0cf-cf5f015535c2", + :page 2, + :position {:bounding {:x1 120, + :y1 240, + :x2 360, + :y2 280, + :width 574.0000000000001, + :height 573.999986224}, + :rects (), + :page 2}, + :content {:text nil}, + :properties {:color "yellow"}}], + :extra {:page 2}} diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf b/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf new file mode 100644 index 0000000..1b323b1 Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0/1_687022ae-dac1-42a6-9d4c-39a0dba05918_1752179374600.png b/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0/1_687022ae-dac1-42a6-9d4c-39a0dba05918_1752179374600.png new file mode 100644 index 0000000..c5390ce Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0/1_687022ae-dac1-42a6-9d4c-39a0dba05918_1752179374600.png differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/Understanding EXPLAIN.edn b/deps/graph-parser/test/resources/exporter-test-graph/assets/Understanding EXPLAIN.edn new file mode 100644 index 0000000..e3804a0 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/assets/Understanding EXPLAIN.edn @@ -0,0 +1,53 @@ +{:highlights [{:id #uuid "697874ba-26e9-4e8a-a55d-0639d337dcc6", + :page 6, + :position {:bounding {:x1 67.03125, + :y1 344.90625, + :x2 612.7423095703125, + :y2 379.3333740234375, + :width 702, + :height 993.418487394958}, + :rects ({:x1 67.03125, + :y1 344.90625, + :x2 612.7423095703125, + :y2 364.2396240234375, + :width 702, + :height 993.418487394958} + {:x1 67.03125, + :y1 360, + :x2 238.0740203857422, + :y2 379.3333740234375, + :width 702, + :height 993.418487394958}), + :page 6}, + :content {:text "EXPLAIN is a really nice command that gives you lots of information but it's often easy to not know what to do with all this"}, + :properties {:color "yellow"}} + {:id #uuid "69787518-9eb8-4fd0-ad3b-b394e28bb547", + :page 8, + :position {:bounding {:x1 84, + :y1 163, + :x2 637, + :y2 327, + :width 702, + :height 993.418487394958}, + :rects (), + :page 8}, + :content {:text "[:span]", :image 1769501975346}, + :properties {:color "green"}} + {:id #uuid "69787540-7a52-40af-a70a-cedd315934cf", + :page 42, + :position {:bounding {:x1 119.05208587646484, + :y1 95.67709350585938, + :x2 243.25509643554688, + :y2 133.01040649414062, + :width 702, + :height 993.418487394958}, + :rects ({:x1 119.05208587646484, + :y1 95.67709350585938, + :x2 243.25509643554688, + :y2 133.01040649414062, + :width 702, + :height 993.418487394958}), + :page 42}, + :content {:text "Conclusion"}, + :properties {:color "blue"}}], + :extra {:page 8}} diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/Understanding EXPLAIN/8_69787518-9eb8-4fd0-ad3b-b394e28bb547_1769501975346.png b/deps/graph-parser/test/resources/exporter-test-graph/assets/Understanding EXPLAIN/8_69787518-9eb8-4fd0-ad3b-b394e28bb547_1769501975346.png new file mode 100644 index 0000000..54b4606 Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/Understanding EXPLAIN/8_69787518-9eb8-4fd0-ad3b-b394e28bb547_1769501975346.png differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/greg-popovich-thumbs-up_1704749687791_0.png b/deps/graph-parser/test/resources/exporter-test-graph/assets/greg-popovich-thumbs-up_1704749687791_0.png new file mode 100644 index 0000000..d7efaaa Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/greg-popovich-thumbs-up_1704749687791_0.png differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/subdir/partydino.gif b/deps/graph-parser/test/resources/exporter-test-graph/assets/subdir/partydino.gif new file mode 100644 index 0000000..acc272f Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/subdir/partydino.gif differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/zlib.edn b/deps/graph-parser/test/resources/exporter-test-graph/assets/zlib.edn new file mode 100644 index 0000000..08e3194 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/assets/zlib.edn @@ -0,0 +1,36 @@ +{:highlights [{:id #uuid "69787399-f51c-45a2-8971-78dc6c66a0ec", + :page 1, + :position {:bounding {:x1 123.89583587646484, + :y1 164.3125, + :x2 438.8349304199219, + :y2 180.9791717529297, + :width 702, + :height 908.470588235294}, + :rects ({:x1 123.89583587646484, + :y1 164.3125, + :x2 438.8349304199219, + :y2 180.9791717529297, + :width 702, + :height 908.470588235294} + {:x1 143.625, + :y1 166.9791717529297, + :x2 432.40625, + :y2 178.4479217529297, + :width 702, + :height 908.470588235294}), + :page 1}, + :content {:text "The zlib library is a general purpose data compression library."}, + :properties {:color "red"}} + {:id #uuid "697873d7-5953-4753-897e-62ac47348634", + :page 1, + :position {:bounding {:x1 116, + :y1 530, + :x2 627, + :y2 690, + :width 702, + :height 908.470588235294}, + :rects (), + :page 1}, + :content {:text "[:span]", :image 1769501651181}, + :properties {:color "blue"}}], + :extra {:page 2}} diff --git a/deps/graph-parser/test/resources/exporter-test-graph/assets/zlib/1_697873d7-5953-4753-897e-62ac47348634_1769501651181.png b/deps/graph-parser/test/resources/exporter-test-graph/assets/zlib/1_697873d7-5953-4753-897e-62ac47348634_1769501651181.png new file mode 100644 index 0000000..d8e380f Binary files /dev/null and b/deps/graph-parser/test/resources/exporter-test-graph/assets/zlib/1_697873d7-5953-4753-897e-62ac47348634_1769501651181.png differ diff --git a/deps/graph-parser/test/resources/exporter-test-graph/ignored/about.org b/deps/graph-parser/test/resources/exporter-test-graph/ignored/about.org new file mode 100644 index 0000000..7821f7f --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/ignored/about.org @@ -0,0 +1,65 @@ +#+TITLE: About + +** Hi, welcome to [[https://logseq.com][Logseq]]. + + *Logseq* is a /local-only/, /non-linear/, /outliner/ notebook for organizing and + [[https://logseq.com/blog][sharing]] your personal knowledge base. + + Use it to organize your todo list, to write your journals, or to record your unique + life. + + The server will never store or analyze your private notes. Your data are + plain text files, we support both Markdown and Emacs Org mode for the time being. Even if the website is down or can't be maintained, your data is always yours. + + *Logseq* is hugely inspired by [[https://roamresearch.com/][Roam Research]], [[https://orgmode.org/][Org Mode]], [[https://tiddlywiki.com/][Tiddlywiki]] and [[https://workflowy.com/][Workflowy]], hats off to all of them! +[[https://cdn.logseq.com/%2F8b9a461d-437e-4ca5-a2da-18b51077b5142020_07_25_Screenshot%202020-07-25%2013-29-49%20%2B0800.png?Expires=4749255017&Signature=Qbx6jkgAytqm6nLxVXQQW1igfcf~umV1OcG6jXUt09TOVhgXyA2Z5jHJ3AGJASNcphs31pZf4CjFQ5mRCyVKw6N8wb8Nn-MxuTJl0iI8o-jLIAIs9q1v-2cusCvuFfXH7bq6ir8Lpf0KYAprzuZ00FENin3dn6RBW35ENQwUioEr5Ghl7YOCr8bKew3jPV~OyL67MttT3wJig1j3IC8lxDDT8Ov5IMG2GWcHERSy00F3mp3tJtzGE17-OUILdeuTFz6d-NDFAmzB8BebiurYz0Bxa4tkcdLUpD5ToFHU08jKzZExoEUY8tvaZ1-t7djmo3d~BAXDtlEhC2L1YC2aVQ__&Key-Pair-Id=APKAJE5CCD6X7MP6PTEA][2020_07_25_Screenshot 2020-07-25 13-29-49 +0800.png]] +** Where are my notes saved? + Your notes will be stored in the local browser storage. We are using IndexedDB. +** How do I use it? +*** 1. Sync between multiple devices + Currently, we only support syncing through GitHub, more options (e.g. + Gitlab, Dropbox, Google Drive, WebDAV, etc.) will be added soon. + + We are using an excellent web git client called [[https://isomorphic-git.org/][isomorphic-git]]. +**** Step 1 + Click the button /Login with GitHub/. +**** Step 2 + Set your GitHub personal access token, the token will be encrypted and + stored in the browser local storage, our server will never store it. + + If you know nothing about either Git or the personal access token, no worries, + just follow the steps here: https://logseq.com/blog/faq#How_to_create_a_Github_personal_access_token-3f- +**** Step 3 + Start writing! +*** 2. Use it locally (no need to login) + Just remember to backup your notes periodically (we'll provide export and import soon)! +** Features + - Backlinks between ~[[Page]]~s + - Block embed + - Page embed + - Graph visualization + - Heading properties + - Datalog queries, the notes db is powered by [[https://github.com/tonsky/datascript][Datascript]] + - Custom view component + - Document built-in supports: + * Code highlights + * Katex latex + * Raw [[https://github.com/weavejester/hiccup][hiccup]] + * Raw html +** Learn more + - Twitter: https://twitter.com/logseq + - Discord: https://discord.gg/KpN4eHY where we ask questions and share tips + - Website: https://logseq.com/ + - GitHub: https://github.com/logseq/logseq everyone is encouraged to report issues! + - Our blog: https://logseq.com/blog +** Credits to + - [[https://roamresearch.com/][Roam Research]] + - [[https://orgmode.org/][Org Mode]] + - [[https://tiddlywiki.com/][Tiddlywiki]] + - [[https://workflowy.com/][Workflowy]] + - [[https://clojure.org][Clojure && Clojurescript]] + - [[https://ocaml.org/][OCaml]] && [[https://github.com/inhabitedtype/angstrom][Angstrom]], the document [[https://github.com/mldoc/mldoc][parser]] is built on Angstrom. + - [[https://github.com/talex5/cuekeeper][Cuekeeper]] - Browser-based GTD (TODO list) system. + - [[https://github.com/tonsky/datascript][Datascript]] - Immutable database and Datalog query engine for Clojure, ClojureScript and JS + - [[https://github.com/borkdude/sci][sci]] - Small Clojure Interpreter + - [[https://isomorphic-git.org/][isomorphic-git]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/ignored/icon-page.md b/deps/graph-parser/test/resources/exporter-test-graph/ignored/icon-page.md new file mode 100644 index 0000000..cc82824 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/ignored/icon-page.md @@ -0,0 +1,4 @@ +icon:: 😆 + +- has some content + icon:: 😆 diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_05.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_05.md new file mode 100644 index 0000000..6509a38 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_05.md @@ -0,0 +1,2 @@ +- test! + - child block \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_08.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_08.md new file mode 100644 index 0000000..831e671 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_08.md @@ -0,0 +1,3 @@ +- [[some page]] tests page ref being parsed before page +- [[prop-num]] tests page existing before being used as and converted into a property +- ![greg-popovich-thumbs-up.png](../assets/greg-popovich-thumbs-up_1704749687791_0.png){:height 288, :width 252} \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_17.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_17.md new file mode 100644 index 0000000..a0720b6 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_01_17.md @@ -0,0 +1,4 @@ +- b1 + prop-string:: woot + prop-num:: 5 + Prop-bool:: true \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_07.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_07.md new file mode 100644 index 0000000..55ecd3f --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_07.md @@ -0,0 +1,2 @@ +- Inception #Movie +- TODO do X #p0 \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_13.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_13.md new file mode 100644 index 0000000..a51ab00 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_13.md @@ -0,0 +1,3 @@ +- original block + id:: 65cbb772-fb79-462d-87c8-6f0dad751dee +- ref to ((65cbb772-fb79-462d-87c8-6f0dad751dee)) \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_14.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_14.md new file mode 100644 index 0000000..588af3d --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_14.md @@ -0,0 +1,10 @@ +- TODO old todo block + todo:: 1612237041309 + done:: 1612237041309 + now:: 1612237041309 + later:: 1612237041309 +- {{query (property :prop-string)}} + query-table:: true + query-properties:: [:block :page :prop-string :prop-num] + query-sort-by:: prop-num + query-sort-desc:: true \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_15.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_15.md new file mode 100644 index 0000000..36f0435 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_15.md @@ -0,0 +1,10 @@ +- existing :number to :default + duration:: 20 +- Review 15 candidates #Meeting + participants:: [[Gabriel]] [[Jakob]] + description:: [[Jakob]] thought candidate was #awesome +- numbered list + - list one + logseq.order-list-type:: number + - list two + logseq.order-list-type:: number \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_16.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_16.md new file mode 100644 index 0000000..64a804d --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_16.md @@ -0,0 +1,51 @@ +- test :dates + startedAt:: [[Feb 6th, 2024]] + finishedAt:: [[Feb 7th, 2024]] +- MEETING TITLE + template:: meeting + participants:: TODO +- TITLE + template:: title-only-no-children +- template:: properties-only-no-children + name:: + author:: +- TITLE + template:: title-only-with-children + - intro + - notes +- + template:: empty-title-with-children + - intro + - notes +- it should not be included in the template + template:: children-only + template-including-parent:: false + - intro + - notes +- it's a template with nested templates + template:: nested-father + template-including-parent:: true + name:: you named it + - child-1 + template:: nested-child-1 + name:: + - child-1-1 + name:: + - child-2 + template:: nested-child-2 + template-including-parent:: false + name:: + - child-2-1 + name:: + - child-3 + name:: +- pending block for :number to :default + duration:: 10 +- test :number to :default + duration:: 20m +- test :default to :node + description:: [[Jakob]] +- test :node -> :date + participants:: [[Feb 7th, 2024]] +- :node people + people:: [[Jakob]] [[Gabriel]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_23.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_23.md new file mode 100644 index 0000000..b621f96 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_23.md @@ -0,0 +1,5 @@ +- The Creator + type:: [[Movie]] + testTagClass:: #Movie +- test :date -> :node + finishedAt:: [[Gabriel]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_28.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_28.md new file mode 100644 index 0000000..e3a9265 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_28.md @@ -0,0 +1,7 @@ +- collapsed block + collapsed:: true + - child +- pending block for :node to :default + people:: [[Gabriel]] [[Jakob]] +- test :node :many to :default + people:: some text \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_29.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_29.md new file mode 100644 index 0000000..587d9d8 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_02_29.md @@ -0,0 +1,4 @@ +- b1 + rating:: 5 +- :rating float + rating:: 5.5 \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_03_01.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_03_01.md new file mode 100644 index 0000000..515bd2d --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_03_01.md @@ -0,0 +1,5 @@ +- test ignore blank values so that property type doesn't change + sameAs:: +- replace tags edge cases + - replace with same start string #foo #foo-bar + - replace case insensitive #foo #Foo \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_04_01.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_04_01.md new file mode 100644 index 0000000..574cbc6 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_04_01.md @@ -0,0 +1,13 @@ +- only deadline + id:: 669168ed-8734-4943-8a86-5e3a553a526d + DEADLINE: <2022-11-26 Sat> +- deadline and scheduled, also tests using same day on one block + DEADLINE: <2022-11-25 Sat> + SCHEDULED: <2022-11-25 Fri .+1d> +- [#A] high priority +- DOING [#B] status test + :LOGBOOK: + CLOCK: [2024-04-01 Mon 10:39:40] + :END: +- deadline on same day + DEADLINE: <2024-04-01 Mon 13:50> \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_07_24.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_07_24.md new file mode 100644 index 0000000..96f35d3 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_07_24.md @@ -0,0 +1,3 @@ +- unusual class example #123 +- unusual property example +123:: works \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_08_07.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_08_07.md new file mode 100644 index 0000000..27edf87 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_08_07.md @@ -0,0 +1,76 @@ +- For example, here's a query with title text: + {{query (property type book)}} +- test multilines in this page +- |markdown| table| + |some|thing| +- block with props + prop-num:: 10 +- normal multiline block + a 2nd + and a 3rd +- colored multiline block + background-color:: red + last line +- multiline block with prop and deadline + background-color:: red + DEADLINE: <2022-11-26 Sat> + last line +- multiline block with deadline and scheduled in 1 line and sth else + DEADLINE: <2022-11-26 Sat> SCHEDULED: <2022-11-26 Sat> something else + last line +- DONE multiline logbook block + :LOGBOOK: + CLOCK: [2024-08-07 Wed 11:47:50] + CLOCK: [2024-08-07 Wed 11:47:53] + :END: + last line +- DOING logbook block + :LOGBOOK: + CLOCK: [2024-08-07 Wed 11:47:50] + CLOCK: [2024-08-07 Wed 11:47:53] + :END: +- TODO +- Text before + query-table:: false + query-properties:: [:block] + #+BEGIN_QUERY + {:title "tasks with todo and doing" + :query (task todo doing)} + #+END_QUERY + Text after +- collapsed:: true + #+BEGIN_QUERY + {:title "⌛ Pretty print advanced query test" + :query [:find (pull ?b [*]) + :in $ ?today + :where + [?b :block/page ?p] + [?p :page/journal? true] + [?p :page/journal-day ?jd] + [(str ?jd) ?jds] + [(subs ?jds 4 8) ?md1] + [(str ?today) ?td] + [(subs ?td 4 8) ?md2] + [(= ?md1 ?md2)] + [(< ?jd ?today)] + ] + :inputs [:today] + :breadcrumb-show? true + :collapsed? False + } + #+END_QUERY +- Get all tasks with a tag "project" + #+BEGIN_SRC clojure + #+BEGIN_QUERY + {:title "All blocks with tag project" + :query [:find (pull ?b [*]) + :where + [?p :block/name "project"] + [?b :block/refs ?p]]} + #+END_QUERY + #+END_SRC +- Find the blocks containing `tag2` but not `tag1` + + #+BEGIN_EXAMPLE + {{query (and [[tag2]] (not [[tag1]]))}} + #+END_EXAMPLE diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_10_09.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_10_09.md new file mode 100644 index 0000000..93e7f7d --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_10_09.md @@ -0,0 +1,3 @@ +- test linked ref filters + - [[ref1]] [[chat-gpt]] + - [[ref2]] [[chat-gpt]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_10_17.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_10_17.md new file mode 100644 index 0000000..651f1aa --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_10_17.md @@ -0,0 +1,5 @@ +- test namespaces + - [[y]] + - [[n1/x/z]] + - [[n2/x/z]] + - [[n2/alias]] tests name that overlaps with built-in ones \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_12.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_12.md new file mode 100644 index 0000000..be52123 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_12.md @@ -0,0 +1,2 @@ +- block with namespace tag #Quotes/life +- block with namespace ref [[Quotes/life]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_18.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_18.md new file mode 100644 index 0000000..109c66d --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_18.md @@ -0,0 +1,2 @@ +- Block with journal ref [[Nov 3rd, 2020]] +- Another block with #Quotes/life \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_26.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_26.md new file mode 100644 index 0000000..3d0723e --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2024_11_26.md @@ -0,0 +1,11 @@ +- card 1 #card + card-last-score:: 3 + card-repeats:: 1 + card-next-schedule:: 2024-11-30T20:27:33.372Z + card-last-interval:: 4 + card-ease-factor:: 2.36 + card-last-reviewed:: 2024-11-26T20:27:33.373Z +- card 2 with cloze {{cloze surprise!}} #card + card-next-schedule:: 2024-11-27T05:00:00.000Z +- This block should test a page with a future parent class `#Property` + FIXME: Property tag triggers an intermittent, invalid hanging block ref \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_01_10.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_01_10.md new file mode 100644 index 0000000..900ac13 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_01_10.md @@ -0,0 +1 @@ +- A page ref with same as a block (task in this case) shouldn't fail [[do X]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_06_12.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_06_12.md new file mode 100644 index 0000000..c31fff4 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_06_12.md @@ -0,0 +1,61 @@ +- Test paragraph before an asset + + + ![dino!](assets/subdir/partydino.gif){:width 105} tests an asset with a manual link, custom title and in a subdirectory +- ![greg-popovich-thumbs-up.png](../assets/greg-popovich-thumbs-up_1704749687791_0.png){:height 288, :width 252} and ![HEART Teams](../assets/HEART_Teams.png){:width 335.99774169921875} +- From Inception: + #+BEGIN_QUOTE + Saito: Cobb? Impossible. We were young men together. I'm an old man. + ... + Cobb: I've come back for you... to remind you of something. Something you once knew... + #+END_QUOTE +- > markdown quote + [[wut]] + line 3 +- Test of various ast types: + #+BEGIN_QUOTE + *Italic* ~~Strikethrough~~ ^^Highlight^^ #[[foo]] + **Learn Datalog Today** is an interactive tutorial designed to teach you the [Datomic](http://datomic.com/) dialect of [Datalog](http://en.wikipedia.org/wiki/Datalog). Datalog is a declarative **database query language** with roots in logic programming. Datalog has similar expressive power as [SQL](http://en.wikipedia.org/wiki/Sql). + #+END_QUOTE +- test page embed + {{embed [[page embed]]}} +- test block embed + id:: 685434e1-0bb9-468c-a660-1642b00b2854 +- {{embed ((685434e1-0bb9-468c-a660-1642b00b2854))}} +- > Blockquotes +- > Blockquotes + and + + something else +- #+BEGIN_QUOTE + Blockquotes + and + #+END_QUOTE + something else +- > Blockquotes + >> Nested Blockquotes +- Q: + > Question 1 + + A: + > Answer 1 + and + >> nested + + and something else + - Q: + + > Question 2 + + > Question 3 + + A: + + > Answer 2 + > + > Answer 3 +- #+BEGIN_QUOTE + it's a + + org blockquote + #+END_QUOTE \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_06_23.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_06_23.md new file mode 100644 index 0000000..2f57548 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_06_23.md @@ -0,0 +1,4 @@ +- tests :node to :default like :people property but for a new property + people2:: [[Gabriel]] +- changes :node to :default + people2:: some text \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_07_10.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_07_10.md new file mode 100644 index 0000000..cf3f204 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_07_10.md @@ -0,0 +1 @@ +- ![Sina de Capoeria Batizado 2025 - Program Itinerary.pdf](../assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf) \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_07_15.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_07_15.md new file mode 100644 index 0000000..3bb149e --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_07_15.md @@ -0,0 +1,7 @@ +- Test ref to an annotation: ((68702394-3613-4bac-85a7-28643d58237f)) +- background-color:: yellow + > The distribution of $x^n ~\text{mod}~ 1$ is more subtle. We have three basic facts. + > + > 1. If $0 ≤ x < 1$, then $x^n → 0$ as $n → ∞$. + > 2. If $x = 1$ then $x^n = 1$ for all $n$. + > 3. For almost all $x > 1$ the sequence $x^n ~\text{mod}~ 1$ is uniformly distributed. But for some values of $x$ it is not, and there’s no known classification for these exceptional values of $x$. \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_11_04.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_11_04.md new file mode 100644 index 0000000..85cf611 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_11_04.md @@ -0,0 +1,8 @@ +- #+BEGIN_QUOTE + test a code block in a quote: + + ```shell + $ echo hi + $ echo gbye + ``` + #+END_QUOTE \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_12_17.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_12_17.md new file mode 100644 index 0000000..290877c --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2025_12_17.md @@ -0,0 +1,5 @@ +tags:: #diary +- This journal has a tag to test journals with tags +- Block tags #p1 + tags:: ai, #block-tag +- block w/ longer ns tag #n1/n2/n3 \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_01_01.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_01_01.md new file mode 100644 index 0000000..c1e7039 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_01_01.md @@ -0,0 +1,2 @@ +- [[zlib]] +- [[Understanding EXPLAIN]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_01_27.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_01_27.md new file mode 100644 index 0000000..a0aec91 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_01_27.md @@ -0,0 +1,5 @@ +- 2nd instance of block w/ longer ns tag #n1/n2/n3 +- property pages with '/' should be valid + key/value:: 1 +- block with multi word tag #[[another test]] doesn't get removed by page-ref replacement +- [[Jan 1st, 2026]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_03_01.md b/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_03_01.md new file mode 100644 index 0000000..a7d0b41 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/journals/2026_03_01.md @@ -0,0 +1,35 @@ +- ```markdown + it's an individual code snippet with language tag + ``` +- ``` + it's an individual code snippet without language tag + ``` +- before code snippet + ``` + echo "ok" + exit + ``` +- before code snippet + ```bash + echo "ok" + exit + ``` + after code snippet +- ```bash + echo "ok" + exit + ``` + after code snippet +- before code snippet + ```bash + echo "ok" + exit + ``` + middle + ```bash + echo "bye" + exit + ``` + after code snippet +- $$E=mc^2$$ +- {{cards (tags #Card)}} \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/logseq/config.edn b/deps/graph-parser/test/resources/exporter-test-graph/logseq/config.edn new file mode 100644 index 0000000..66947fa --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/logseq/config.edn @@ -0,0 +1,408 @@ +{:meta/version 1 + + ;; Set the preferred format. + ;; This is _only_ for file graphs. + ;; Available options: + ;; - Markdown (default) + ;; - Org + ;; :preferred-format "Markdown" + + ;; Set the preferred workflow style. + ;; Available options: + ;; - :now for NOW/LATER style (default) + ;; - :todo for TODO/DOING style + :preferred-workflow :now + + ;; Exclude directories/files. + ;; Example usage: + ;; :hidden ["/archived" "/test.md" "../assets/archived"] + :hidden ["ignored"] + + ;; Define the default journal page template. + ;; Enter the template name between the quotes. + :default-templates + {:journals ""} + + ;; Set a custom date format for the journal page title. + ;; Default value: "MMM do, yyyy" + ;; e.g., "Jan 19th, 2038" + ;; Example usage e.g., "Tue 19th, Jan 2038" + ;; :journal/page-title-format "EEE do, MMM yyyy" + + ;; Specify the journal filename format using a valid date format string. + ;; !Warning: + ;; This configuration is not retroactive and affects only new journals. + ;; To show old journal files in the app, manually rename the files in the + ;; journal directory to match the new format. + ;; Default value: "yyyy_MM_dd" + ;; :journal/file-name-format "yyyy_MM_dd" + + ;; Enable tooltip preview on hover. + ;; Default value: true + :ui/enable-tooltip? true + + ;; Display brackets [[]] around page references. + ;; Default value: true + ;; :ui/show-brackets? true + + ;; Display all lines of a block when referencing ((block)). + ;; Default value: false + :ui/show-full-blocks? false + + ;; Automatically expand block references when zooming in. + ;; Default value: true + :ui/auto-expand-block-refs? true + + ;; Disable accent marks when searching. + ;; After changing this setting, rebuild the search index by pressing (^C ^S). + ;; Default value: true + :feature/enable-search-remove-accents? true + + ;; Enable journals. + ;; Default value: true + ;; :feature/enable-journals? true + + ;; Enable flashcards. + ;; Default value: true + ;; :feature/enable-flashcards? true + + ;; Enable whiteboards. + ;; Default value: true + ;; :feature/enable-whiteboards? true + + ;; Disable the journal's built-in 'Scheduled tasks and deadlines' query. + ;; Default value: false + ;; :feature/disable-scheduled-and-deadline-query? false + + ;; Specify the number of days displayed in the future for + ;; the 'scheduled tasks and deadlines' query. + ;; Example usage: + ;; Display all scheduled and deadline blocks for the next 14 days: + ;; :scheduled/future-days 14 + ;; Default value: 7 + ;; :scheduled/future-days 7 + + ;; Specify the first day of the week. + ;; Available options: + ;; - integer from 0 to 6 (Monday to Sunday) + ;; Default value: 6 (Sunday) + :start-of-week 6 + + ;; Specify a custom CSS import. + ;; This option takes precedence over the local `logseq/custom.css` file. + ;; Example usage: + ;; :custom-css-url "@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');" + + ;; Specify a custom JS import. + ;; This option takes precedence over the local `logseq/custom.js` file. + ;; Example usage: + ;; :custom-js-url "https://cdn.logseq.com/custom.js" + + ;; Set bullet indentation when exporting + ;; Available options: + ;; - `:eight-spaces` as eight spaces + ;; - `:four-spaces` as four spaces + ;; - `:two-spaces` as two spaces + ;; - `:tab` as a tab character (default) + ;; :export/bullet-indentation :tab + + ;; Publish all pages within the Graph + ;; Regardless of whether individual pages have been marked as public. + ;; Default value: false + ;; :publishing/all-pages-public? false + + ;; Define the default home page and sidebar status. + ;; If unspecified, the journal page will be loaded on startup and the right sidebar will stay hidden. + ;; The `:page` value represents the name of the page displayed at startup. + ;; Available options for `:sidebar` are: + ;; - "Contents" to display the Contents page in the right sidebar. + ;; - A specific page name to display in the right sidebar. + ;; - An array of multiple pages, e.g., ["Contents" "Page A" "Page B"]. + ;; If `:sidebar` remains unset, the right sidebar will stay hidden. + ;; Examples: + ;; 1. Set "Changelog" as the home page and display "Contents" in the right sidebar: + ;; :default-home {:page "Changelog", :sidebar "Contents"} + ;; 2. Set "Jun 3rd, 2021" as the home page without the right sidebar: + ;; :default-home {:page "Jun 3rd, 2021"} + ;; 3. Set "home" as the home page and display multiple pages in the right sidebar: + ;; :default-home {:page "home", :sidebar ["Page A" "Page B"]} + + ;; Set the default location for storing notes. + ;; Default value: "pages" + ;; :pages-directory "pages" + + ;; Set the default location for storing journals. + ;; Default value: "journals" + ;; :journals-directory "journals" + + ;; Set the default location for storing whiteboards. + ;; Default value: "whiteboards" + ;; :whiteboards-directory "whiteboards" + + ;; Enabling this option converts + ;; [[Grant Ideas]] to [[file:./grant_ideas.org][Grant Ideas]] for org-mode. + ;; For more information, visit https://github.com/logseq/logseq/issues/672 + ;; :org-mode/insert-file-link? false + + ;; Configure custom shortcuts. + ;; Syntax: + ;; 1. + indicates simultaneous key presses, e.g., `Ctrl+Shift+a`. + ;; 2. A space between keys represents key chords, e.g., `t s` means + ;; pressing `t` followed by `s`. + ;; 3. mod refers to `Ctrl` for Windows/Linux and `Command` for Mac. + ;; 4. Use false to disable a specific shortcut. + ;; 5. You can define multiple bindings for a single action, e.g., ["ctrl+j" "down"]. + ;; The full list of configurable shortcuts is available at: + ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs + ;; Example: + ;; :shortcuts + ;; {:editor/new-block "enter" + ;; :editor/new-line "shift+enter" + ;; :editor/insert-link "mod+shift+k" + ;; :editor/highlight false + ;; :ui/toggle-settings "t s" + ;; :editor/up ["ctrl+k" "up"] + ;; :editor/down ["ctrl+j" "down"] + ;; :editor/left ["ctrl+h" "left"] + ;; :editor/right ["ctrl+l" "right"]} + :shortcuts {} + + ;; Configure the behavior of pressing Enter in document mode. + ;; if set to true, pressing Enter will create a new block. + ;; Default value: false + :shortcut/doc-mode-enter-for-new-block? false + + ;; Block content larger than `block/title-max-length` will not be searchable + ;; or editable for performance. + ;; Default value: 10000 + :block/title-max-length 10000 + + ;; Display command documentation on hover. + ;; Default value: true + :ui/show-command-doc? true + + ;; Display empty bullet points. + ;; Default value: false + :ui/show-empty-bullets? false + + ;; Pre-defined :view function to use with advanced queries. + :query/views + {:pprint + (fn [r] [:pre.code (pprint r)])} + + ;; Advanced queries `:result-transform` function. + ;; Transform the query result before displaying it. + :query/result-transforms + {:sort-by-priority + (fn [result] (sort-by (fn [h] (get h :block/priority "Z")) result))} + + ;; The following queries will be displayed at the bottom of today's journal page. + ;; The "NOW" query returns tasks with "NOW" or "DOING" status. + ;; The "NEXT" query returns tasks with "NOW", "LATER", or "TODO" status. + :default-queries + {:journals + [{:title "🔨 NOW" + :query [:find (pull ?h [*]) + :in $ ?start ?today + :where + [?h :block/marker ?marker] + [(contains? #{"NOW" "DOING"} ?marker)] + [?h :block/page ?p] + [?p :block/journal-day ?d] + [(>= ?d ?start)] + [(<= ?d ?today)]] + :inputs [:14d :today] + :result-transform (fn [result] + (sort-by (fn [h] + (get h :block/priority "Z")) result)) + :group-by-page? false + :collapsed? false} + {:title "📅 NEXT" + :query [:find (pull ?h [*]) + :in $ ?start ?next + :where + [?h :block/marker ?marker] + [(contains? #{"NOW" "LATER" "TODO"} ?marker)] + [?h :block/page ?p] + [?p :block/journal-day ?d] + [(> ?d ?start)] + [(< ?d ?next)]] + :inputs [:today :7d-after] + :group-by-page? false + :collapsed? false}]} + + ;; Add custom commands to the command palette + ;; Example usage: + ;; :commands + ;; [ + ;; ["js" "Javascript"] + ;; ["md" "Markdown"] + ;; ] + :commands [] + + ;; Enable collapsing blocks with titles but no children. + ;; By default, only blocks with children can be collapsed. + ;; Setting `:outliner/block-title-collapse-enabled?` to true allows collapsing + ;; blocks with titles (multiple lines) and content. For example: + ;; - block title + ;; block content + ;; Default value: false + :outliner/block-title-collapse-enabled? false + + ;; Macros replace texts and will make you more productive. + ;; Example usage: + ;; Change the :macros value below to: + ;; {"poem" "Rose is $1, violet's $2. Life's ordered: Org assists you."} + ;; input "{{poem red,blue}}" + ;; becomes + ;; Rose is red, violet's blue. Life's ordered: Org assists you. + :macros + {"docs-base-url" "https://docs.logseq.com/#/page/$1"} + + ;; Configure the default expansion level for linked references. + ;; For example, consider the following block hierarchy: + ;; - a [[page]] (level 1) + ;; - b (level 2) + ;; - c (level 3) + ;; - d (level 4) + ;; + ;; With the default value of level 2, block b will be collapsed. + ;; If the level's value is set to 3, block c will be collapsed. + ;; Default value: 2 + :ref/default-open-blocks-level 2 + + ;; Graph view configuration. + ;; Example usage: + ;; :graph/settings + ;; {:orphan-pages? true ; Default value: true + ;; :builtin-pages? false ; Default value: false + ;; :excluded-pages? false ; Default value: false + ;; :journal? false} ; Default value: false + + ;; Favorites to list on the left sidebar + :favorites ["Interstellar" "some page" "[[new page]]" "n1/x/y"] + + ;; Set flashcards interval. + ;; Expected value: + ;; - Float between 0 and 1 + ;; higher values result in faster changes to the next review interval. + ;; Default value: 0.5 + ;; :srs/learning-fraction 0.5 + + ;; Set the initial interval after the first successful review of a card. + ;; Default value: 4 + ;; :srs/initial-interval 4 + + ;; Hide specific block properties. + ;; Example usage: + ;; :block-hidden-properties #{:public :icon} + + ;; Create a page for all properties. + ;; Default value: true + :property-pages/enabled? true + + ;; Properties to exclude from having property pages + ;; Example usage: + ;; :property-pages/excludelist #{:duration :author} + + ;; By default, property value separated by commas will not be treated as + ;; page references. You can add properties to enable it. + ;; Example usage: + ;; :property/separated-by-commas #{:alias :tags} + + ;; Properties that are ignored when parsing property values for references + ;; Example usage: + ;; :ignored-page-references-keywords #{:author :website} + + ;; logbook configuration. + ;; :logbook/settings + ;; {:with-second-support? false ;limit logbook to minutes, seconds will be eliminated + ;; :enabled-in-all-blocks true ;display logbook in all blocks after timetracking + ;; :enabled-in-timestamped-blocks false ;don't display logbook at all + ;; } + + ;; Mobile photo upload configuration. + ;; :mobile/photo + ;; {:allow-editing? true + ;; :quality 80} + + ;; Mobile features options + ;; Gestures + ;; Example usage: + ;; :mobile + ;; {:gestures/disabled-in-block-with-tags ["kanban"]} + + ;; Extra CodeMirror options + ;; See https://codemirror.net/5/doc/manual.html#config for possible options + ;; Example usage: + ;; :editor/extra-codemirror-options + ;; {:lineWrapping false ; Default value: false + ;; :lineNumbers true ; Default value: true + ;; :readOnly false} ; Default value: false + + ;; Enable logical outdenting + ;; Default value: false + ;; :editor/logical-outdenting? false + + ;; Prefer pasting the file when text and a file are in the clipboard. + ;; Default value: false + ;; :editor/preferred-pasting-file? false + + ;; Quick capture templates for receiving content from other apps. + ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded + ;; by receiving content from other apps. Note: the {} cannot be omitted. + ;; - {time}: capture time + ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference + ;; - {text}: text that users selected before sharing. + ;; - {url}: URL or assets path for media files stored in Logseq. + ;; You can also reorder them or use only one or two of them in the template. + ;; You can also insert or format any text in the template, as shown in the following examples. + ;; :quick-capture-templates + ;; {:text "[[quick capture]] **{time}**: {text} from {url}" + ;; :media "[[quick capture]] **{time}**: {url}"} + + ;; Quick capture options. + ;; - insert-today? Insert the capture at the end of today's journal page (boolean). + ;; - redirect-page? Redirect to the quick capture page after capturing (boolean). + ;; - default-page The default page to capture to if insert-today? is false (string). + ;; - prettify-url? Whether urls to YouTube or X should be wrapped with {{video ...}} or {{twitter ...}} + ;; :quick-capture-options + ;; {:insert-today? false ;; Default value: true + ;; :redirect-page? false ;; Default value: false + ;; :default-page "quick capture" ;; Default page: "quick capture" + ;; :prettify-url? true} ;; Default value: true + + + ;; File sync options + ;; Ignore these files when syncing, regexp is supported. + ;; :file-sync/ignore-files [] + + ;; Configure the Enter key behavior for + ;; context-aware editing with DWIM (Do What I Mean). + ;; context-aware Enter key behavior implies that pressing Enter will + ;; have different outcomes based on the context. + ;; For instance, pressing Enter within a list generates a new list item, + ;; whereas pressing Enter in a block reference opens the referenced block. + ;; :dwim/settings + ;; {:admonition&src? true ;; Default value: true + ;; :markup? false ;; Default value: false + ;; :block-ref? true ;; Default value: true + ;; :page-ref? true ;; Default value: true + ;; :properties? true ;; Default value: true + ;; :list? false} ;; Default value: false + + ;; Configure the escaping method for special characters in page titles. + ;; Warning: + ;; This is a dangerous operation. To modify the setting, + ;; access the 'Filename format' setting and follow the instructions. + ;; Otherwise, You may need to manually rename all affected files and + ;; re-index them on all clients after synchronization. + ;; Incorrect handling may result in messy page titles. + ;; Available options: + ;; - :triple-lowbar (default) + ;; ;use triple underscore `___` for slash `/` in page title + ;; ;use Percent-encoding for other invalid characters + + ;; use relative path here only for ci test, won't work on the Electron client + :zotero/settings-v2 {"default" {:type-id "1234567", :prefer-citekey? false, :attachments-block-text "Attachments", :page-insert-prefix "", :extra-tags "", :zotero-linked-attachment-base-directory "../book", :zotero-data-directory "../zotero"}} + :file/name-format :triple-lowbar} diff --git a/deps/graph-parser/test/resources/exporter-test-graph/logseq/custom.css b/deps/graph-parser/test/resources/exporter-test-graph/logseq/custom.css new file mode 100644 index 0000000..33afbeb --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/logseq/custom.css @@ -0,0 +1 @@ +.foo {} diff --git a/deps/graph-parser/test/resources/exporter-test-graph/logseq/custom.js b/deps/graph-parser/test/resources/exporter-test-graph/logseq/custom.js new file mode 100644 index 0000000..e2e170c --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/logseq/custom.js @@ -0,0 +1 @@ +logseq.api.show_msg('hello good sir!'); diff --git a/deps/graph-parser/test/resources/exporter-test-graph/logseq/srs-of-matrix.edn b/deps/graph-parser/test/resources/exporter-test-graph/logseq/srs-of-matrix.edn new file mode 100644 index 0000000..8f92a84 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/logseq/srs-of-matrix.edn @@ -0,0 +1 @@ +{0 {2.5 3.45}} \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/CreativeWork.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/CreativeWork.md new file mode 100644 index 0000000..eb65d78 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/CreativeWork.md @@ -0,0 +1,4 @@ +parent:: [[Thing]] + +- block with date property value that doesn't have the same format as the current date format +misconfigured-date:: [[2021_12_06]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Interstellar.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Interstellar.md new file mode 100644 index 0000000..71e9f52 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Interstellar.md @@ -0,0 +1,3 @@ +tags:: Movie + +- \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Library.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Library.md new file mode 100644 index 0000000..d381600 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Library.md @@ -0,0 +1 @@ +type:: [[Class]] diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Movie.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Movie.md new file mode 100644 index 0000000..5106261 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Movie.md @@ -0,0 +1 @@ +parent:: [[CreativeWork]] diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Priority.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Priority.md new file mode 100644 index 0000000..49ecd31 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Priority.md @@ -0,0 +1,2 @@ +parent:: [[HumanConcept]] +- this page triggers bug with a class created via :parent that is then used by :type \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Property.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Property.md new file mode 100644 index 0000000..db9c5b2 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Property.md @@ -0,0 +1 @@ +parent:: [[Thing]] diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Task.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Task.md new file mode 100644 index 0000000..15c7a26 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Task.md @@ -0,0 +1 @@ +- Notes not about new Task class \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Understanding EXPLAIN.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Understanding EXPLAIN.md new file mode 100644 index 0000000..00a590c --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Understanding EXPLAIN.md @@ -0,0 +1,5 @@ +- Attachments + - [Understanding EXPLAIN.pdf](zotero://select/library/items/QDM8H6EH) {{zotero-linked-file "it/Understanding EXPLAIN.pdf"}} +- Notes + - ((697874ba-26e9-4e8a-a55d-0639d337dcc6)) definition + - ((69787518-9eb8-4fd0-ad3b-b394e28bb547)) select \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Whiteboard___Arrow_head_toggle.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Whiteboard___Arrow_head_toggle.md new file mode 100644 index 0000000..02aba4a --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Whiteboard___Arrow_head_toggle.md @@ -0,0 +1 @@ +type:: [[Tool]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/Whiteboard___Tool.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/Whiteboard___Tool.md new file mode 100644 index 0000000..5c727eb --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/Whiteboard___Tool.md @@ -0,0 +1,4 @@ +alias:: Whiteboard tool, Tool, Tools +type:: [[Class]] +parent:: [[Feature]] +description:: Tools on the [[Whiteboard/Toolbar]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/chat-gpt.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/chat-gpt.md new file mode 100644 index 0000000..b55b9c5 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/chat-gpt.md @@ -0,0 +1,6 @@ +filters:: {"oct 9th, 2024" true, "ref2" false} +type:: [[LargeLanguageModel]] +tags:: ai, #fun, [[LargeLanguageModel]] +alias:: gpt + +- some text \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/contents.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/contents.md new file mode 100644 index 0000000..3cf20d5 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/contents.md @@ -0,0 +1 @@ +- \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.md new file mode 100644 index 0000000..1c347ec --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.md @@ -0,0 +1,16 @@ +file:: [Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf](../assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf) +file-path:: ../assets/Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0.pdf + +- id:: 687022ae-dac1-42a6-9d4c-39a0dba05918 + ls-type:: annotation + hl-page:: 1 + hl-color:: yellow + hl-type:: area + hl-stamp:: 1752179374600 +- Duke School - modified + hl-page:: 8 + ls-type:: annotation + id:: 68702394-3613-4bac-85a7-28643d58237f + hl-color:: blue + - note about duke + - sub note \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__Understanding EXPLAIN.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__Understanding EXPLAIN.md new file mode 100644 index 0000000..ae84333 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__Understanding EXPLAIN.md @@ -0,0 +1,20 @@ +file:: [Understanding EXPLAIN.pdf](../../book/it/Understanding EXPLAIN.pdf) +file-path:: ../../book/it/Understanding EXPLAIN.pdf + +- EXPLAIN is a really nice command that gives you lots of information but it's often easy to not know what to do with all this + ls-type:: annotation + hl-page:: 6 + hl-color:: yellow + id:: 697874ba-26e9-4e8a-a55d-0639d337dcc6 +- [:span] + ls-type:: annotation + hl-page:: 8 + hl-color:: green + id:: 69787518-9eb8-4fd0-ad3b-b394e28bb547 + hl-type:: area + hl-stamp:: 1769501975346 +- Conclusion + ls-type:: annotation + hl-page:: 42 + hl-color:: blue + id:: 69787540-7a52-40af-a70a-cedd315934cf \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__zlib.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__zlib.md new file mode 100644 index 0000000..b797199 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/hls__zlib.md @@ -0,0 +1,15 @@ +file:: [zlib.pdf](../../zotero/RX5JS7SY/zlib.pdf) +file-path:: ../../zotero/storage/RX5JS7SY/zlib.pdf + +- The zlib library is a general purpose data compression library. + ls-type:: annotation + hl-page:: 1 + hl-color:: red + id:: 69787399-f51c-45a2-8971-78dc6c66a0ec +- [:span] + ls-type:: annotation + hl-page:: 1 + hl-color:: blue + id:: 697873d7-5953-4753-897e-62ac47348634 + hl-type:: area + hl-stamp:: 1769501651181 \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/ignored.edn b/deps/graph-parser/test/resources/exporter-test-graph/pages/ignored.edn new file mode 100644 index 0000000..e69de29 diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/later.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/later.md new file mode 100644 index 0000000..20320f5 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/later.md @@ -0,0 +1 @@ +type:: [[Priority]] diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/n1___x___y.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/n1___x___y.md new file mode 100644 index 0000000..e6858f8 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/n1___x___y.md @@ -0,0 +1,4 @@ +alias:: y +description:: page property triggers aliased namespace bug + +- some content \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/new page.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/new page.md new file mode 100644 index 0000000..0b1974d --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/new page.md @@ -0,0 +1,4 @@ +prop-num2:: 10 + + - test if pre-block child causes issue + - grandchild test diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/page with #tag.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/page with #tag.md new file mode 100644 index 0000000..b06ac1f --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/page with #tag.md @@ -0,0 +1 @@ +- HA diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/some page.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/some page.md new file mode 100644 index 0000000..ff96440 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/some page.md @@ -0,0 +1,6 @@ +prop-string:: yeehaw +prop-num:: 5 +prop-bool:: true +tags:: [[Some / Namespace ]] + +- has some content diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/type.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/type.md new file mode 100644 index 0000000..6f05aac --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/type.md @@ -0,0 +1 @@ +url:: http://www.w3.org/1999/02/22-rdf-syntax-ns#type \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/unlinked-highlights.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/unlinked-highlights.md new file mode 100644 index 0000000..fe5bef1 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/unlinked-highlights.md @@ -0,0 +1,6 @@ +- This is a highlights page that doesn't start with 'hls__' and isn't linked to a specific asset file +- blarg blarg + ls-type:: annotation + hl-page:: 110 + hl-color:: purple + id:: 654bafc6-f7e8-4633-b6a6-810dc41cc559 \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/url.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/url.md new file mode 100644 index 0000000..87eadea --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/url.md @@ -0,0 +1,4 @@ +type:: [[Property]] +url:: {{docs-base-url url}} +sameAs:: https://schema.org/url +rangeIncludes:: [[Property]] \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/pages/zlib.md b/deps/graph-parser/test/resources/exporter-test-graph/pages/zlib.md new file mode 100644 index 0000000..6b23bff --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/pages/zlib.md @@ -0,0 +1,5 @@ +- Attachments + - [PDF](zotero://select/library/items/RX5JS7SY) {{zotero-imported-file RX5JS7SY, "zlib.pdf"}} +- Notes + - ((69787399-f51c-45a2-8971-78dc6c66a0ec)) definition + - ((697873d7-5953-4753-897e-62ac47348634)) links \ No newline at end of file diff --git a/deps/graph-parser/test/resources/exporter-test-graph/whiteboards/block tests.edn b/deps/graph-parser/test/resources/exporter-test-graph/whiteboards/block tests.edn new file mode 100644 index 0000000..ab26b65 --- /dev/null +++ b/deps/graph-parser/test/resources/exporter-test-graph/whiteboards/block tests.edn @@ -0,0 +1,125 @@ +{:blocks ({:block/title "block with page ref [[some page]]" + :block/created-at 1720805247589 + :block/format :markdown + :block/parent + {:block/uuid #uuid "6691676f-2eed-4619-b56a-69fd7d572c59"} + :block/properties + {} + :block/updated-at 1720809014394 + :block/uuid #uuid "6691677f-c208-4c83-aa40-6efc4286100c"} + {:block/created-at 1721935480784 + :block/properties + {:ls-type :whiteboard-shape + :logseq.tldraw.shape + {:blockType "B" + :stroke "" + :collapsed false + :borderRadius 8 + :scale [1 1] + :pageId "6691677f-c208-4c83-aa40-6efc4286100c" + :scaleLevel "md" + :fill "" + :compact true + :isAutoResizing true + :type "logseq-portal" + :size [400 124.9781494140625] + :strokeType "line" + :strokeWidth 2 + :opacity 1 + :id "01b33340-4074-11ef-956e-7d9aebf284ae" + :noFill false + :point [430.41563108563423 233.00156784057617] + :parentId "6691676f-2eed-4619-b56a-69fd7d572c59" + :collapsedHeight 0 + :nonce 1720805246071 + :pageName nil}} + :block/updated-at 1721935480784} + {:block/title "block with block ref ((669168ed-8734-4943-8a86-5e3a553a526d))" + :block/created-at 1720808993012 + :block/format :markdown + :block/parent + {:block/uuid #uuid "6691676f-2eed-4619-b56a-69fd7d572c59"} + :block/properties + {} + :block/updated-at 1720809157098 + :block/uuid #uuid "66917621-93ae-475b-aa4c-6ae9e797cf68"} + {:block/properties + {:ls-type :whiteboard-shape + :logseq.tldraw.shape + {:blockType "B" + :stroke "" + :collapsed false + :borderRadius 8 + :scale [1 1] + :pageId "66917621-93ae-475b-aa4c-6ae9e797cf68" + :scaleLevel "md" + :fill "" + :compact true + :isAutoResizing true + :type "logseq-portal" + :size [400 79.9781265258789] + :strokeType "line" + :strokeWidth 2 + :opacity 1 + :id "b999ee10-407c-11ef-956e-7d9aebf284ae" + :noFill false + :point [478.6382088826831 337.7821947259741] + :parentId "6691676f-2eed-4619-b56a-69fd7d572c59" + :collapsedHeight 0 + :nonce 1720808990579 + :pageName nil}} + :block/updated-at 1720809157310 + :block/created-at 1720809157310} + {:block/title "block with props\nprop-num:: 10" + :block/created-at 1721935480737 + :block/format :markdown + :block/parent + {:block/uuid #uuid "6691676f-2eed-4619-b56a-69fd7d572c59"} + :block/properties + {:prop-num 10} + :block/updated-at 1721935504617 + :block/uuid #uuid "66a2a678-1cea-44b6-a458-4b8c15e18a8d"} + {:block/properties + {:ls-type :whiteboard-shape + :logseq.tldraw.shape + {:blockType "B" + :stroke "" + :collapsed false + :borderRadius 8 + :scale [1 1] + :pageId "66a2a678-1cea-44b6-a458-4b8c15e18a8d" + :scaleLevel "md" + :fill "" + :compact true + :isAutoResizing true + :type "logseq-portal" + :size [400 320] + :strokeType "line" + :strokeWidth 2 + :opacity 1 + :id "86f86420-4abb-11ef-9161-b98dd17dbef1" + :noFill false + :point [648.5671437694965 179.003191006195] + :parentId "6691676f-2eed-4619-b56a-69fd7d572c59" + :collapsedHeight 0 + :nonce 1721935475555 + :pageName nil}} + :block/updated-at 1721935504609 + :block/created-at 1721935504609}) + :pages ({:block/tx-id 536871657 + :block/uuid #uuid "6691676f-2eed-4619-b56a-69fd7d572c59" + :block/properties + {:ls-type :whiteboard-page + :logseq.tldraw.page + {:id "6691676f-2eed-4619-b56a-69fd7d572c59" + :name "ref page" + :bindings + {} + :nonce 1 + :assets []}} + :block/updated-at 1721935504609 + :block/created-at 1720805231835 + :block/format :markdown + :block/type "whiteboard" + :block/name "ref page" + :block/title "ref page"})} diff --git a/deps/graph-parser/test/resources/zotero/storage/RX5JS7SY/zlib.pdf b/deps/graph-parser/test/resources/zotero/storage/RX5JS7SY/zlib.pdf new file mode 100644 index 0000000..6fa519c Binary files /dev/null and b/deps/graph-parser/test/resources/zotero/storage/RX5JS7SY/zlib.pdf differ diff --git a/deps/outliner/.carve/config.edn b/deps/outliner/.carve/config.edn new file mode 100644 index 0000000..08806eb --- /dev/null +++ b/deps/outliner/.carve/config.edn @@ -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}} diff --git a/deps/outliner/.carve/ignore b/deps/outliner/.carve/ignore new file mode 100644 index 0000000..48400ba --- /dev/null +++ b/deps/outliner/.carve/ignore @@ -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! diff --git a/deps/outliner/.clj-kondo/config.edn b/deps/outliner/.clj-kondo/config.edn new file mode 100644 index 0000000..1868946 --- /dev/null +++ b/deps/outliner/.clj-kondo/config.edn @@ -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}} diff --git a/deps/outliner/.gitignore b/deps/outliner/.gitignore new file mode 100644 index 0000000..db8abf3 --- /dev/null +++ b/deps/outliner/.gitignore @@ -0,0 +1 @@ +/.clj-kondo/.cache diff --git a/deps/outliner/README.md b/deps/outliner/README.md new file mode 100644 index 0000000..3789241 --- /dev/null +++ b/deps/outliner/README.md @@ -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). diff --git a/deps/outliner/bb.edn b/deps/outliner/bb.edn new file mode 100644 index 0000000..f331553 --- /dev/null +++ b/deps/outliner/bb.edn @@ -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}}}} diff --git a/deps/outliner/deps.edn b/deps/outliner/deps.edn new file mode 100644 index 0000000..0885517 --- /dev/null +++ b/deps/outliner/deps.edn @@ -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"]}}} diff --git a/deps/outliner/nbb.edn b/deps/outliner/nbb.edn new file mode 100644 index 0000000..9b91060 --- /dev/null +++ b/deps/outliner/nbb.edn @@ -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"}}} diff --git a/deps/outliner/package.json b/deps/outliner/package.json new file mode 100644 index 0000000..75d666f --- /dev/null +++ b/deps/outliner/package.json @@ -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" + } +} diff --git a/deps/outliner/pnpm-lock.yaml b/deps/outliner/pnpm-lock.yaml new file mode 100644 index 0000000..dc5e5fc --- /dev/null +++ b/deps/outliner/pnpm-lock.yaml @@ -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 diff --git a/deps/outliner/script/transact.cljs b/deps/outliner/script/transact.cljs new file mode 100644 index 0000000..3a73021 --- /dev/null +++ b/deps/outliner/script/transact.cljs @@ -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*)) diff --git a/deps/outliner/src/logseq/outliner/cli.cljs b/deps/outliner/src/logseq/outliner/cli.cljs new file mode 100644 index 0000000..75a3ef9 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/cli.cljs @@ -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) diff --git a/deps/outliner/src/logseq/outliner/core.cljs b/deps/outliner/src/logseq/outliner/core.cljs new file mode 100644 index 0000000..c677b3c --- /dev/null +++ b/deps/outliner/src/logseq/outliner/core.cljs @@ -0,0 +1,1321 @@ +(ns logseq.outliner.core + "Provides the primary outliner operations and fns" + (:require [clojure.set :as set] + [clojure.string :as string] + [clojure.walk :as walk] + [datascript.core :as d] + [datascript.impl.entity :as de :refer [Entity]] + [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.class :as db-class] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.sqlite.create-graph :as sqlite-create-graph] + [logseq.outliner.datascript :as ds] + [logseq.outliner.page :as outliner-page] + [logseq.outliner.pipeline :as outliner-pipeline] + [logseq.outliner.tree :as otree] + [logseq.outliner.tx-meta :as outliner-tx-meta] + [logseq.outliner.validate :as outliner-validate] + [malli.core :as m] + [malli.util :as mu])) + +(defn- direct-op-entry + [outliner-op args] + (letfn [(->block-id [block] (:block/uuid block))] + (case outliner-op + :save-block + (let [[_conn block opts] args] + [:save-block [block opts]]) + + :insert-blocks + (let [[_conn blocks target-block opts] args] + [:insert-blocks [blocks (->block-id target-block) opts]]) + + :delete-blocks + (let [[_conn blocks opts] args] + [:delete-blocks [(mapv ->block-id blocks) opts]]) + + :move-blocks + (let [[_conn blocks target-block opts] args] + [:move-blocks [(mapv ->block-id blocks) (->block-id target-block) opts]]) + + :move-blocks-up-down + (let [[_conn blocks up?] args] + [:move-blocks-up-down [(mapv ->block-id blocks) up?]]) + + :indent-outdent-blocks + (let [[_conn blocks indent? opts] args] + [:indent-outdent-blocks [(mapv ->block-id blocks) indent? opts]]) + + nil))) + +(def ^:private block-map + (mu/optional-keys + [:map + [:db/id :int] + ;; FIXME: tests use ints when they should use uuids + [:block/uuid [:or :uuid :int]] + [:block/order :string] + [:block/parent :map] + [:block/page :map]])) + +(def ^:private block-map-or-entity + [:or [:fn de/entity?] block-map]) + +(defn ^:api block-with-timestamps + [block] + (let [updated-at (common-util/time-ms) + block (cond-> + (assoc block :block/updated-at updated-at) + (nil? (:block/created-at block)) + (assoc :block/created-at updated-at))] + block)) + +(defn ^:api block-with-updated-at + [block] + (let [updated-at (common-util/time-ms)] + (assoc block :block/updated-at updated-at))) + +(defn- filter-top-level-blocks + [db blocks] + (let [parent-ids (set/intersection (set (map (comp :db/id :block/parent) blocks)) + (set (map :db/id blocks)))] + (->> blocks + (remove (fn [e] (contains? parent-ids (:db/id (:block/parent e))))) + (map (fn [block] + (if (de/entity? block) block (d/entity db (:db/id block)))))))) + +(defn- remove-orphaned-page-refs! + [db {db-id :db/id} txs-state old-refs new-refs] + (when (not= old-refs new-refs) + (let [new-refs (set (map (fn [ref] + (or (:block/name ref) + (and (:db/id ref) + (:block/name (d/entity db (:db/id ref)))))) new-refs)) + old-pages (->> (keep :db/id old-refs) + (d/pull-many db '[*]) + (remove (fn [e] (contains? new-refs (:block/name e)))) + (map :block/name) + (remove nil?)) + orphaned-pages (when (seq old-pages) + (ldb/get-orphaned-pages db {:pages old-pages + :built-in-pages-names sqlite-create-graph/built-in-pages-names + :empty-ref-f (fn [page] + (let [refs (:block/_refs page)] + (and (or (zero? (count refs)) + (= #{db-id} (set (map :db/id refs)))) + (not (ldb/class? page)) + (not (ldb/property? page)))))}))] + (when (seq orphaned-pages) + (let [tx (mapv (fn [page] [:db/retractEntity (:db/id page)]) orphaned-pages)] + (swap! txs-state (fn [state] (vec (concat state tx))))))))) + +(defn- update-page-when-save-block + [txs-state block-entity] + (when-let [e (:block/page block-entity)] + (let [m' (cond-> {:db/id (:db/id e) + :block/updated-at (common-util/time-ms)} + (not (:block/created-at e)) + (assoc :block/created-at (common-util/time-ms))) + txs [m']] + (swap! txs-state into txs)))) + +(defn- remove-orphaned-refs-when-save + [db txs-state block-entity m] + (let [remove-self-page #(remove (fn [b] + (= (:db/id b) (:db/id (:block/page block-entity)))) %) + ;; only provide content based refs for db graphs instead of removing + ;; as calculating all non-content refs is more complex + old-refs (let [content-refs (set (outliner-pipeline/block-content-refs db block-entity))] + (filter #(contains? content-refs (:db/id %)) (:block/refs block-entity))) + new-refs (remove-self-page (:block/refs m))] + (remove-orphaned-page-refs! db block-entity txs-state old-refs new-refs))) + +(defn- get-last-child-or-self + [db block] + (let [last-child (some->> (ldb/get-block-last-direct-child-id db (:db/id block) true) + (d/entity db)) + target (or last-child block)] + [target (some? last-child)])) + +(declare move-blocks) + +(defn ^:api rebuild-block-refs + [db block] + (outliner-pipeline/db-rebuild-block-refs db block)) + +(defn- fix-tag-ids + "Fix or remove tags related when entered via `Escape`" + [m db] + (let [refs (set (keep :block/name (seq (:block/refs m)))) + tags (seq (:block/tags m))] + (if (and (seq refs) tags) + (update m :block/tags + (fn [tags] + (let [tags (map (fn [tag] (or (and (:db/id tag) + (let [e (d/entity db (:db/id tag))] + (select-keys e [:db/id :block/uuid :block/title :block/name]))) + tag)) + tags)] + (cond->> + ;; Update :block/tag to reference ids from :block/refs + (map (fn [tag] + (if (contains? refs (:block/name tag)) + (let [matched-ref (first (filter (fn [r] (= (:block/name tag) + (:block/name r))) + (:block/refs m)))] + (cond-> (assoc tag :block/uuid (:block/uuid matched-ref)) + (:db/ident matched-ref) + (assoc :db/ident (:db/ident matched-ref)))) + tag)) + tags) + + true + ;; Remove tags changing case with `Escape` + ((fn [tags'] + (let [ref-titles (->> (map :block/title (:block/refs m)) + (remove nil?) + set) + lc-ref-titles (set (map string/lower-case ref-titles))] + (remove (fn [tag] + (when-let [title (:block/title tag)] + (and (not (contains? ref-titles title)) + (contains? lc-ref-titles (string/lower-case title))))) + tags')))))))) + m))) + +(defn- new-page-ref? + [ref] + (and (contains? #{"page" "journal"} (:block/type ref)) + (nil? (:db/id ref)) + (nil? (:db/ident ref)))) + +(defn- resolve-page-ref + [db ref tag-names] + (if (new-page-ref? ref) + (let [class? (contains? tag-names (:block/name ref))] + (if-let [page (and (not class?) (ldb/get-page db (:block/name ref)))] + [(merge (select-keys page [:db/id :block/uuid :block/title :block/name :db/ident]) + (select-keys ref [:block.temp/original-page-name])) + nil] + (let [{:keys [page-uuid tx-data]} (outliner-page/create db (:block/title ref) + {:uuid (:block/uuid ref) + :class? class? + :journal? (= "journal" (:block/type ref))})] + [(cond-> (assoc (select-keys ref [:block/title :block/name :block.temp/original-page-name]) + :block/uuid page-uuid) + class? (assoc :db/ident (or (some :db/ident tx-data) + (:db/ident (d/entity db [:block/uuid page-uuid]))))) + tx-data]))) + [ref nil])) + +(defn- resolve-page-refs + [db block] + (if-let [refs (seq (:block/refs block))] + (let [tag-names (into #{} (keep :block/name) (:block/tags block)) + resolved-refs (mapv #(resolve-page-ref db % tag-names) refs) + refs' (mapv first resolved-refs) + page-txs (mapcat second resolved-refs) + tag-refs (into {} (keep (fn [ref] + (when (:db/ident ref) + [(:block/name ref) ref]))) + refs') + tags' (mapv (fn [tag] + (if-let [ref (get tag-refs (:block/name tag))] + (merge (dissoc tag :block/type) + (select-keys ref [:block/uuid :db/ident])) + tag)) + (:block/tags block)) + replacements (keep (fn [[ref ref']] + (when (not= (:block/uuid ref) (:block/uuid ref')) + [(:block/uuid ref) (:block/uuid ref')])) + (map vector refs refs')) + replace-refs (fn [title] + (reduce (fn [title [old-uuid new-uuid]] + (string/replace title + (page-ref/->page-ref old-uuid) + (page-ref/->page-ref new-uuid))) + title + replacements))] + {:block (cond-> (assoc block :block/refs refs' + :block/tags tags') + (and (seq replacements) (string? (:block/title block))) + (update :block/title replace-refs) + + (and (seq replacements) (string? (:block/raw-title block))) + (update :block/raw-title replace-refs)) + :page-txs page-txs}) + {:block block})) + +(defn- remove-tags-when-title-changed + [block new-content] + (when (and (:block/raw-title block) new-content) + (->> (:block/tags block) + (filter (fn [tag] + (and (ldb/inline-tag? (:block/raw-title block) tag) + (not (ldb/inline-tag? new-content tag))))) + (map (fn [tag] + [:db/retract (:db/id block) :block/tags (:db/id tag)]))))) + +(defn- add-missing-tag-idents + [db tags] + (mapcat + (fn [t] + (when (and (not (:db/id t)) (not (:db/ident t)) (:block/uuid t)) ; new tag without db/ident + (let [eid [:block/uuid (:block/uuid t)]] + [[:db/add eid :db/ident (db-class/create-user-class-ident-from-name db (:block/title t))] + [:db/add eid :logseq.property.class/extends :logseq.class/Root] + [:db/retract eid :block/tags :logseq.class/Page]]))) + tags)) + +(defn- inline-tag-disallowed? + [db t] + ;; both disallowed tags and built-in pages shouldn't be used as inline tags + (let [disallowed-idents (into db-class/disallowed-inline-tags + #{:logseq.property/query :logseq.property/asset})] + (and (map? t) + (or + (contains? + disallowed-idents + (or (:db/ident t) + (when-let [id (:block/uuid t)] + (:db/ident (d/entity db [:block/uuid id]))))) + (contains? + sqlite-create-graph/built-in-pages-names + (or (:block/title t) + (when-let [id (:block/uuid t)] + (:block/title (d/entity db [:block/uuid id]))))))))) + +(defn- remove-disallowed-inline-classes + [db {:block/keys [tags] :as block}] + (if (or (ldb/page? (d/entity db (:db/id block))) (:block/name block)) + block + (let [tags' (cond + (or (integer? tags) + (qualified-keyword? tags) + (and (vector? tags) + (= :block/uuid (first tags)))) + [(d/entity db tags)] + (every? qualified-keyword? tags) + (map #(d/entity db %) tags) + :else + tags) + block (assoc block :block/tags tags') + disallowed-tag? (fn [tag] (inline-tag-disallowed? db tag)) + disallowed-tags (filter disallowed-tag? tags')] + (if (and (seq disallowed-tags) + (some (fn [tag] + (string/includes? (:block/title block) (str "#" (page-ref/->page-ref (:block/uuid tag))))) + disallowed-tags)) + (-> block + (update :block/tags + (fn [tags] + (->> (remove disallowed-tag? tags) + (remove nil?)))) + (update :block/refs + (fn [refs] (->> (remove disallowed-tag? refs) + (remove nil?)))) + (update :block/title (fn [title] + (reduce + (fn [title tag] + (-> (string/replace title + (str "#" (page-ref/->page-ref (:block/uuid tag))) + (str "#" (:block/title tag))) + string/trim)) + title + disallowed-tags)))) + block)))) + +(extend-type Entity + otree/INode + (-save [this *txs-state db {:keys [retract-attributes? retract-attributes outliner-op] + :or {retract-attributes? true}}] + (assert (ds/outliner-txs-state? *txs-state) + "db should be satisfied outliner-tx-state?") + (let [data (if (de/entity? this) + (assoc (.-kv ^js this) :db/id (:db/id this)) + this) + data' (remove-disallowed-inline-classes db data) + {:keys [block page-txs]} (resolve-page-refs db + (dissoc data' :block/children :block/meta :block/unordered + :block.temp/ast-title :block.temp/ast-body :block/level + :block.temp/load-status :block.temp/has-children?)) + collapse-or-expand? (= outliner-op :collapse-expand-blocks) + m* (cond-> + (fix-tag-ids block db) + (not collapse-or-expand?) + block-with-updated-at) + db-id (:db/id this) + block-uuid (:block/uuid this) + eid (or db-id (when block-uuid [:block/uuid block-uuid])) + block-entity (d/entity db eid) + page? (ldb/page? block-entity) + m* (if (and (:block/title m*) + (not (:logseq.property.node/display-type block-entity))) + (update m* :block/title common-util/clear-markdown-heading) + m*) + block-title (:block/title m*) + page-title-changed? (and page? block-title + (not= block-title (:block/title block-entity))) + _ (when (and page? block-title) + (outliner-validate/validate-page-title-characters block-title {:node m*})) + m (if page-title-changed? + (let [_ (outliner-validate/validate-page-title (:block/title m*) {:node m*}) + page-name (if-let [journal-day (:block/journal-day block-entity)] + (common-util/page-name-sanity-lc + (date-time-util/int->journal-title journal-day date-time-util/default-journal-title-formatter)) + (common-util/page-name-sanity-lc (:block/title m*)))] + (assoc m* :block/name page-name)) + m*) + _ (when (and ;; page or object changed? + (or (ldb/page? block-entity) (ldb/object? block-entity)) + (:block/title m) + (not= (:block/title m) (:block/title block-entity))) + (outliner-validate/validate-block-title db (:block/title m) block-entity))] + ;; Ensure block UUID never changes + (let [e (d/entity db db-id)] + (when (and e block-uuid) + (let [uuid-not-changed? (= block-uuid (:block/uuid e))] + (when-not uuid-not-changed? + (js/console.error "Block UUID shouldn't be changed once created")) + (assert uuid-not-changed? "Block UUID changed")))) + + (when (seq page-txs) + (swap! *txs-state into page-txs)) + + (when eid + ;; Retract attributes to prepare for tx which rewrites block attributes + (when (or (and retract-attributes? (:block/title m)) + (seq retract-attributes)) + (let [retract-attributes (concat + db-schema/retract-attributes + retract-attributes)] + (swap! *txs-state (fn [txs] + (vec + (concat txs + (map (fn [attribute] + [:db/retract eid attribute]) + retract-attributes))))))) + + ;; Update block's page attributes + (when-not collapse-or-expand? + (update-page-when-save-block *txs-state block-entity)) + ;; Remove orphaned refs from block + (when (and (:block/title m) (not= (:block/title m) (:block/title block-entity))) + (remove-orphaned-refs-when-save db *txs-state block-entity m))) + + ;; handle others txs + (let [other-tx (:db/other-tx m)] + (when (seq other-tx) + (swap! *txs-state (fn [txs] + (vec (concat txs other-tx))))) + (swap! *txs-state conj + (dissoc m :db/other-tx))) + + (when (and (:block/tags block-entity) block-entity) + (let [;; delete tags when title changed + tx-data (remove-tags-when-title-changed block-entity (:block/title m))] + (when (seq tx-data) + (swap! *txs-state (fn [txs] (concat txs tx-data)))))) + + (let [tx-data (add-missing-tag-idents db (:block/tags m))] + (when (seq tx-data) + (swap! *txs-state (fn [txs] (concat txs tx-data))))) + + this)) + + (-del [this *txs-state db] + (assert (ds/outliner-txs-state? *txs-state) + "db should be satisfied outliner-tx-state?") + (let [block-id (:block/uuid this) + block (d/entity db [:block/uuid block-id])] + (if (ldb/page? block) + (swap! *txs-state concat [[:db/retract (:db/id block) :block/parent] + [:db/retract (:db/id block) :block/order] + [:db/retract (:db/id block) :block/page]]) + (let [ids (cons (:db/id this) (ldb/get-block-full-children-ids db (:db/id block))) + txs (map (fn [id] [:db/retractEntity id]) ids)] + (swap! *txs-state concat txs) + block-id))))) + +(defn- assoc-level-aux + [tree-vec children-key init-level] + (map (fn [block] + (let [children (get block children-key) + children' (assoc-level-aux children children-key (inc init-level))] + (cond-> (assoc block :block/level init-level) + (seq children') + (assoc children-key children')))) tree-vec)) + +(defn- assoc-level + [children-key tree-vec] + (assoc-level-aux tree-vec children-key 1)) + +(defn- assign-temp-id + [blocks target-block replace-empty-target?] + (->> blocks + (map-indexed + (fn [idx block] + (let [replacing-block? (and replace-empty-target? (zero? idx)) + db-id (or (when (:block.temp/use-old-db-id? block) + (:db/id block)) + (dec (- idx)))] + (if replacing-block? + [(assoc block + :db/id (:db/id target-block) + :block/uuid (:block/uuid target-block) + :block/order (:block/order target-block))] + [(assoc block :db/id db-id)])))) + (apply concat))) + +(defn- get-id + [x] + (cond + (map? x) + (:db/id x) + + (vector? x) + (second x) + + :else + x)) + +(defn- compute-block-parent + [block parent target-block top-level? sibling? get-new-id outliner-op replace-empty-target? idx] + (cond + ;; replace existing block + (and (contains? #{:paste :insert-blocks} outliner-op) + replace-empty-target? + (string/blank? (:block/title target-block)) + (zero? idx)) + (get-id (:block/parent target-block)) + + top-level? + (if sibling? + (:db/id (:block/parent target-block)) + (:db/id target-block)) + + :else + (get-new-id block parent))) + +;;; ### public utils + +(defn tree-vec-flatten + "Converts a `tree-vec` to blocks with `:block/level`. + A `tree-vec` example: + [{:id 1, :children [{:id 2, + :children [{:id 3}]}]} + {:id 4, :children [{:id 5} + {:id 6}]}]" + ([tree-vec] + (tree-vec-flatten tree-vec :children)) + ([tree-vec children-key] + (->> tree-vec + (assoc-level children-key) + (mapcat #(tree-seq map? children-key %)) + (map #(dissoc % :block/children))))) + +(defn ^:api save-block + "Save the `block`." + [db block opts] + {:pre [(map? block)]} + (let [eid (or (:db/id block) (when-let [id (:block/uuid block)] [:block/uuid id]))] + (when (nil? eid) + (throw (ex-info "Block eid doesn't exist" + {:block block}))) + (when-let [entity (d/entity db eid)] + (when (outliner-validate/built-in-entity? entity) + (throw (ex-info "Built-in nodes can't be modified" + {:type :notification + :payload {:message "Built-in nodes can't be modified" + :type :error}}))) + (let [*txs-state (atom []) + block' (if (de/entity? block) + block + (merge entity block))] + (otree/-save block' *txs-state db opts) + {:tx-data @*txs-state})))) + +(defn- get-right-siblings + "Get `node`'s right siblings." + [node] + (when-let [parent (:block/parent node)] + (let [children (ldb/sort-by-order (:block/_parent parent))] + (->> (split-with #(not= (:block/uuid node) (:block/uuid %)) children) + last + rest)))) + +(defn- blocks-with-ordered-list-props + [blocks target-block sibling?] + (let [target-block (if sibling? target-block (when target-block (ldb/get-down target-block))) + list-type-fn (fn [block] + (:db/id (:logseq.property/order-list-type block)))] + (if-let [list-type (and target-block (list-type-fn target-block))] + (mapv + (fn [block] + (let [list?' (and (some? (:block/uuid block)) + (nil? (list-type-fn block)))] + (cond-> block + list?' + ((fn [b] + (assoc b :logseq.property/order-list-type list-type)))))) + blocks) + blocks))) + +;;; ### insert-blocks, delete-blocks, move-blocks + +(defn- get-block-orders + [blocks target-block sibling? keep-block-order?] + (if (and keep-block-order? (every? :block/order blocks)) + (map :block/order blocks) + (let [target-order (:block/order target-block) + start-order (when sibling? target-order) + end-order (if sibling? + (:block/order (ldb/get-right-sibling target-block)) + (let [first-child (ldb/get-down target-block)] + (:block/order first-child))) + orders (db-order/gen-n-keys (count blocks) start-order end-order)] + orders))) + +(defn- update-property-ref-when-paste + [block uuids] + (let [id-lookup (fn [v] (and (vector? v) (= :block/uuid (first v)))) + resolve-id (fn [v] [:block/uuid (get uuids (last v) (last v))])] + (reduce-kv + (fn [r k v] + (let [v' (cond + (id-lookup v) + (resolve-id v) + (and (coll? v) (every? id-lookup v)) + (map resolve-id v) + :else + v)] + (assoc r k v'))) + {} + block))) + +(defn- get-target-block-page + [target-block sibling?] + (or + (:db/id (:block/page target-block)) + ;; target parent is a page + (when sibling? + (when-let [parent (:block/parent target-block)] + (when (ldb/page? parent) + (:db/id parent)))) + + ;; target-block is a page itself + (:db/id target-block))) + +(defn- build-insert-blocks-tx + [db target-block blocks uuids get-new-id {:keys [sibling? outliner-op replace-empty-target? insert-template? keep-block-order?]}] + (let [block-ids (set (map :block/uuid blocks)) + target-page (get-target-block-page target-block sibling?) + orders (get-block-orders blocks target-block sibling? keep-block-order?)] + (loop [db db + idx 0 + blocks blocks + entries []] + (if-let [{:block/keys [parent] :as block} (first blocks)] + (if-let [uuid' (get uuids (:block/uuid block))] + (let [{:keys [block page-txs]} + (resolve-page-refs db (remove-disallowed-inline-classes db block)) + top-level? (= (:block/level block) 1) + parent (compute-block-parent block parent target-block top-level? sibling? get-new-id outliner-op replace-empty-target? idx) + order (nth orders idx) + _ (assert (and parent order) (str "Parent or order is nil: " {:parent parent :order order})) + template-ref-block-ids (when insert-template? + (when-let [block (d/entity db (:db/id block))] + (let [ref-ids (set (map :block/uuid (:block/refs block)))] + (->> (set/intersection block-ids ref-ids) + (remove #{(:block/uuid block)}))))) + m {:db/id (:db/id block) + :block/uuid uuid' + :block/parent parent + :block/order order} + result* (-> (if (de/entity? block) + (assoc m :block/level (:block/level block)) + (merge block m)) + (update :block/title + (fn [value] + (if (seq template-ref-block-ids) + (reduce (fn [value id] + (string/replace value + (page-ref/->page-ref id) + (page-ref/->page-ref (uuids id)))) + value + template-ref-block-ids) + value)))) + result* (if (:block.temp/use-old-db-id? result*) + result* + (dissoc result* :db/id)) + page? (or (ldb/page? block) (:block/name block)) + result (cond-> result* + (not page?) (assoc :block/page target-page) + page? (dissoc :block/page)) + db' (if (seq page-txs) + (:db-after (d/with db page-txs)) + db)] + (recur db' (inc idx) (rest blocks) + (conj entries [(update-property-ref-when-paste result uuids) page-txs]))) + (recur db (inc idx) (rest blocks) (conj entries nil))) + entries)))) + +(defn- insert-blocks-aux + [db blocks target-block {:keys [replace-empty-target? keep-uuid?] + :as opts}] + (let [block-uuids (map :block/uuid blocks) + uuids (zipmap block-uuids + (if keep-uuid? + block-uuids + (repeatedly common-uuid/gen-uuid))) + uuids (if replace-empty-target? + (assoc uuids (:block/uuid (first blocks)) (:block/uuid target-block)) + uuids) + id->new-uuid (->> (map (fn [block] (when-let [id (:db/id block)] + [id (get uuids (:block/uuid block))])) blocks) + (into {})) + get-new-id (fn [block lookup] + (cond + (or (map? lookup) (vector? lookup) (de/entity? lookup)) + (when-let [uuid' (if (and (vector? lookup) (= (first lookup) :block/uuid)) + (get uuids (last lookup)) + (get id->new-uuid (:db/id lookup)))] + [:block/uuid uuid']) + + (integer? lookup) + lookup + + :else + (throw (js/Error. (str "[insert-blocks] illegal lookup: " lookup ", block: " block))))) + entries (build-insert-blocks-tx db target-block blocks uuids get-new-id opts) + blocks-tx (mapv first entries)] + {:blocks-tx blocks-tx + :page-txs (mapcat second entries) + :id->new-uuid id->new-uuid + :uuid->new-uuid uuids})) + +(defn- get-target-block + [db blocks target-block {:keys [outliner-op bottom? top? indent? sibling? up? replace-empty-target?]}] + (when-let [block (if (:db/id target-block) + (d/entity db (:db/id target-block)) + (when (:block/uuid target-block) + (d/entity db [:block/uuid (:block/uuid target-block)])))] + (let [linked (:block/link block) + library? (ldb/library? block) + up-down? (= outliner-op :move-blocks-up-down) + [block sibling?] (cond + up-down? + (if sibling? + [block sibling?] + (let [target (or linked block)] + (if (and up? + ;; target is not any parent of the first block + (not= (:db/id (:block/parent (first blocks))) + (:db/id target)) + (not= (:db/id (:block/parent + (d/entity db (:db/id (:block/parent (first blocks)))))) + (:db/id target))) + (get-last-child-or-self db target) + [target false]))) + + (and (= outliner-op :indent-outdent-blocks) + (or (not indent?) + (and indent? sibling?))) + [block sibling?] + + (contains? #{:insert-blocks :move-blocks} outliner-op) + (cond + top? + [block false] + + (and bottom? (not replace-empty-target?)) + (if-let [last-child (last (ldb/sort-by-order (:block/_parent block)))] + [last-child true] + [block false]) + :else + [block (if library? false sibling?)]) + + linked + (get-last-child-or-self db linked) + + :else + [block sibling?]) + block (if (de/entity? block) block (d/entity db (:db/id block)))] + [block sibling?]))) + +(defn ^:api blocks-with-level + "Calculate `:block/level` for all the `blocks`. Blocks should be sorted already." + [blocks] + {:pre [(seq blocks)]} + (let [blocks (if (sequential? blocks) blocks [blocks]) + root (assoc (first blocks) :block/level 1)] + (loop [result [root] + id->level (cond-> {} + (:db/id root) (assoc (:db/id root) 1)) + uuid->level (cond-> {} + (:block/uuid root) (assoc (:block/uuid root) 1)) + blocks (rest blocks)] + (if (empty? blocks) + result + (let [block (first blocks) + parent (:block/parent block) + parent-level (cond + (map? parent) (get id->level (:db/id parent)) + (vector? parent) (get uuid->level (second parent))) + level (if parent-level + (inc parent-level) + 1) + block' (assoc block :block/level level)] + (recur (conj result block') + (cond-> id->level + (:db/id block') (assoc (:db/id block') level)) + (cond-> uuid->level + (:block/uuid block') (assoc (:block/uuid block') level)) + (rest blocks))))))) + +(defn ^:api ^:large-vars/cleanup-todo insert-blocks + "Insert blocks as children (or siblings) of target-node. + Args: + `db`: db + `blocks`: blocks should be sorted already. + `target-block`: where `blocks` will be inserted. + Options: + `sibling?`: as siblings (true) or children (false). + `bottom?`: inserts block to the bottom. + `top?`: inserts block to the top. + `keep-uuid?`: whether to replace `:block/uuid` from the parameter `blocks`. + For example, if `blocks` are from internal copy, the uuids + need to be changed, but there's no need for internal cut or drag & drop. + `keep-block-order?`: whether to replace `:block/order` from the parameter `blocks`. + `outliner-op`: what's the current outliner operation. + `replace-empty-target?`: If the `target-block` is an empty block, whether + to replace it, it defaults to be `false`. + `update-timestamps?`: whether to update `blocks` timestamps. + ``" + [db blocks target-block {:keys [_sibling? keep-uuid? keep-block-order? + outliner-op outliner-real-op replace-empty-target? update-timestamps? + insert-template?] + :as opts + :or {update-timestamps? true}}] + {:pre [(seq blocks)]} + (when (m/validate block-map-or-entity target-block) + (let [blocks (cond->> + (keep (fn [b] + (if-let [eid (or (:db/id b) + (when-let [id (:block/uuid b)] + [:block/uuid id]))] + (let [b' (if-let [e (if (de/entity? b) + b + (d/entity db eid))] + (merge + (into {} e) + {:db/id (:db/id e) + :block/title (or (:block/raw-title e) (:block/title e))} + b) + b) + dissoc-keys (concat [:block/tx-id] + (when (and (contains? #{:insert-template-blocks :paste} outliner-op) + (not (contains? #{:paste-text} outliner-real-op))) + [:block/refs]))] + (apply dissoc b' dissoc-keys)) + b)) + blocks) + (or (= outliner-op :paste) + insert-template?) + (remove ldb/asset?)) + [target-block sibling?] (get-target-block db blocks target-block opts) + _ (assert (some? target-block) (str "Invalid target: " target-block)) + replace-empty-target? (if (and (some? replace-empty-target?) + (:block/title target-block) + (string/blank? (:block/title target-block))) + replace-empty-target? + (and sibling? + (:block/title target-block) + (string/blank? (:block/title target-block)) + (> (count blocks) 1)))] + (when (seq blocks) + (let [blocks' (let [blocks' (blocks-with-level blocks)] + (cond->> (blocks-with-ordered-list-props blocks' target-block sibling?) + update-timestamps? + (mapv #(dissoc % :block/created-at :block/updated-at)) + true + (mapv block-with-timestamps))) + insert-opts {:sibling? sibling? + :replace-empty-target? replace-empty-target? + :keep-uuid? keep-uuid? + :keep-block-order? keep-block-order? + :outliner-op outliner-op + :insert-template? insert-template?} + {:keys [id->new-uuid uuid->new-uuid blocks-tx page-txs]} + (insert-blocks-aux db blocks' target-block insert-opts)] + (if (some (fn [b] (or (nil? (:block/parent b)) (nil? (:block/order b)))) blocks-tx) + (throw (ex-info "Invalid outliner data" + {:opts insert-opts + :tx (vec blocks-tx) + :blocks (vec blocks) + :target-block target-block})) + (let [tx (assign-temp-id blocks-tx target-block replace-empty-target?) + old-db-id-blocks (->> (filter :block.temp/use-old-db-id? tx) + (map :block/uuid) + (set)) + uuids-tx (->> (map :block/uuid blocks-tx) + (remove old-db-id-blocks) + (remove nil?) + (map (fn [uuid'] {:block/uuid uuid'}))) + from-property (:logseq.property/created-from-property target-block) + many? (= :db.cardinality/many (:db/cardinality from-property)) + property-values-tx (when (and sibling? from-property many?) + (let [top-level-blocks (filter #(= 1 (:block/level %)) blocks')] + (mapcat (fn [block] + (when-let [new-id (or (id->new-uuid (:db/id block)) + (uuid->new-uuid (:block/uuid block)) + (:block/uuid block))] + [{:block/uuid new-id + :logseq.property/created-from-property (:db/id from-property)} + [:db/add + (:db/id (:block/parent target-block)) + (:db/ident (d/entity db (:db/id from-property))) + [:block/uuid new-id]]])) top-level-blocks))) + full-tx (common-util/concat-without-nil page-txs + (if (and keep-uuid? replace-empty-target?) (rest uuids-tx) uuids-tx) + tx + property-values-tx) + ;; Replace entities with eid because Datascript doesn't support entity transaction + full-tx' (walk/prewalk + (fn [f] + (cond + (de/entity? f) + (if-let [id (id->new-uuid (:db/id f))] + [:block/uuid id] + (:db/id f)) + (map? f) + (dissoc f :block/level) + :else + f)) + full-tx)] + {:tx-data full-tx' + :blocks tx}))))))) + +(defn- sort-non-consecutive-blocks + [db blocks] + (let [page-blocks (group-by :block/page blocks)] + (mapcat (fn [[_page blocks]] + (ldb/sort-page-random-blocks db blocks)) + page-blocks))) + +(defn- get-top-level-blocks + [top-level-blocks non-consecutive?] + (let [reversed? (and (not non-consecutive?) + (:block/order (first top-level-blocks)) + (:block/order (second top-level-blocks)) + (> (compare (:block/order (first top-level-blocks)) + (:block/order (second top-level-blocks))) 0))] + (if reversed? (reverse top-level-blocks) top-level-blocks))) + +(def ^:private comments-tag-ident :logseq.class/Comments) +(def ^:private comment-tag-ident :logseq.class/Comment) +(def ^:private comments-blocks-property :logseq.property.comments/blocks) + +(defn- tagged-with? + [block tag-ident] + (boolean + (some (fn [tag] + (= tag-ident + (if (keyword? tag) tag (:db/ident tag)))) + (:block/tags block)))) + +(defn- comments-area? + [block] + (tagged-with? block comments-tag-ident)) + +(defn- comment-block? + [block] + (or (tagged-with? block comment-tag-ident) + (comments-area? (:block/parent block)))) + +(defn- protected-comment-block? + [block] + (or (comments-area? block) + (comment-block? block))) + +(defn- move-source-allowed-for-comments? + [block sibling?] + (and (not (comment-block? block)) + (or sibling? + (not (comments-area? block))))) + +(defn- move-target-allowed-for-comments? + [target-block sibling?] + (and (not (comment-block? target-block)) + (or sibling? + (not (comments-area? target-block))))) + +(defn- block-subtree-ids + [db block] + (into #{(:db/id block)} + (ldb/get-block-full-children-ids db (:db/id block)))) + +(defn- datom-value-ids + [value] + (if (set? value) + value + #{value})) + +(defn- orphaned-range-comments-areas + [db deleted-block-ids] + (let [comments-area-target-ids + (->> (d/datoms db :aevt comments-blocks-property) + (mapcat (fn [datom] + (map (fn [target-id] [(:e datom) target-id]) + (datom-value-ids (:v datom))))) + (group-by first) + (map (fn [[comments-area-id entries]] + [comments-area-id (set (map second entries))])) + (into {})) + candidate-comments-areas + (->> comments-area-target-ids + (filter (fn [[_comments-area-id target-ids]] + (seq (set/intersection deleted-block-ids target-ids)))) + (map first) + (keep #(d/entity db %)) + (filter comments-area?) + (remove #(contains? deleted-block-ids (:db/id %))) + (common-util/distinct-by :db/id))] + (filter + (fn [comments-area] + (let [targets (seq (get comments-area-target-ids (:db/id comments-area)))] + (and targets + (every? #(contains? deleted-block-ids %) targets)))) + candidate-comments-areas))) + +(defn ^:api ^:large-vars/cleanup-todo delete-blocks + "Delete blocks from the tree." + [db blocks _opts] + (let [top-level-blocks (filter-top-level-blocks db blocks) + non-consecutive? (and (> (count top-level-blocks) 1) (seq (ldb/get-non-consecutive-blocks db top-level-blocks))) + top-level-blocks* (get-top-level-blocks top-level-blocks non-consecutive?) + top-level-blocks (remove outliner-validate/built-in-entity? top-level-blocks*) + deleted-block-ids (into #{} (mapcat #(block-subtree-ids db %) top-level-blocks)) + orphaned-comments-areas (orphaned-range-comments-areas db deleted-block-ids) + top-level-blocks (concat top-level-blocks orphaned-comments-areas) + txs-state (ds/new-outliner-txs-state) + block-ids (map (fn [b] [:block/uuid (:block/uuid b)]) top-level-blocks) + start-block (first top-level-blocks) + end-block (last top-level-blocks) + delete-one-block? (or (= 1 (count top-level-blocks)) (= start-block end-block))] + + ;; Validate before `when` since top-level-blocks will be empty when deleting one built-in/internal block + (when (seq (filter outliner-validate/built-in-entity? top-level-blocks*)) + (throw (ex-info "Built-in nodes can't be deleted" + {:type :notification + :payload {:message "Built-in nodes can't be deleted." + :i18n-key :node/built-in-cant-delete-error + :type :error}}))) + (when (seq top-level-blocks) + (let [from-property (:logseq.property/created-from-property start-block) + default-value-property? (and (:logseq.property/default-value from-property) + (not= (:db/id start-block) + (:db/id (:logseq.property/default-value from-property))) + (not (:block/closed-value-property start-block)))] + (cond + (and delete-one-block? default-value-property?) + (let [datoms (d/datoms db :avet (:db/ident from-property) (:db/id start-block)) + tx-data (map (fn [d] {:db/id (:e d) + (:db/ident from-property) :logseq.property/empty-placeholder}) datoms)] + (when (seq tx-data) (swap! txs-state concat tx-data))) + + :else + (doseq [id block-ids] + (let [node (d/entity db id)] + (otree/-del node txs-state db)))))) + {:tx-data @txs-state})) + +(defn- move-to-original-position? + [blocks target-block sibling? non-consecutive-blocks?] + (let [block (first blocks) + db (.-db target-block)] + (and (not non-consecutive-blocks?) + (if sibling? + (= (:db/id (ldb/get-left-sibling block)) (:db/id target-block)) + (= (:db/id (ldb/get-first-child db (:db/id target-block))) (:db/id block)))))) + +(defn- move-block + [db block target-block sibling?] + (let [target-block (d/entity db (:db/id target-block)) + block (d/entity db (:db/id block)) + target-without-parent? (and sibling? (nil? (:block/parent target-block))) + move-page-as-block-child? (and (not sibling?) + (not (ldb/page? target-block)) + (ldb/page? block))] + (if (or target-without-parent? move-page-as-block-child?) + (throw (ex-info "not-allowed-move-block-page" + {:reason (if target-without-parent? + :move-to-target-without-parent + :move-page-to-be-child-of-block)})) + (let [first-block-page (:db/id (:block/page block)) + target-page (get-target-block-page target-block sibling?) + not-same-page? (not= first-block-page target-page) + block-order (if sibling? + (db-order/gen-key (:block/order target-block) + (:block/order (ldb/get-right-sibling target-block))) + (db-order/gen-key nil + (:block/order (ldb/get-down target-block)))) + + tx-data [(cond-> + {:db/id (:db/id block) + :block/parent (if sibling? + (:db/id (:block/parent target-block)) + (:db/id target-block)) + :block/order block-order} + (not (ldb/page? block)) + (assoc :block/page target-page))] + children-page-tx (when (and not-same-page? (not (ldb/page? block))) + (let [children-ids (ldb/get-block-full-children-ids db (:db/id block))] + (keep (fn [id] + (let [child (d/entity db id)] + (when-not (ldb/page? child) + {:block/uuid (:block/uuid child) + :block/page target-page}))) children-ids))) + target-from-property (:logseq.property/created-from-property target-block) + block-from-property (:logseq.property/created-from-property block) + property-tx (let [retract-property-tx (when block-from-property + [[:db/retract (:db/id (:block/parent block)) (:db/ident block-from-property) (:db/id block)] + [:db/retract (:db/id block) :logseq.property/created-from-property]]) + add-property-tx (when (and sibling? target-from-property (not block-from-property)) + [[:db/add (:db/id block) :logseq.property/created-from-property (:db/id target-from-property)] + [:db/add (:db/id (:block/parent target-block)) (:db/ident target-from-property) (:db/id block)]])] + (concat retract-property-tx add-property-tx))] + (common-util/concat-without-nil tx-data children-page-tx property-tx))))) + +(defn- transact-move-blocks! + [conn blocks target-block sibling? opts outliner-op top-level-blocks] + (ldb/batch-transact-with-temp-conn! + conn + {:outliner-op :move-blocks + :outliner-ops [[:move-blocks [(mapv :block/uuid top-level-blocks) + (:block/uuid target-block) + opts]]]} + (fn [conn] + (doseq [[idx block] (map vector (range (count blocks)) blocks)] + (let [first-block? (zero? idx) + sibling? (if first-block? sibling? true) + target-block (if first-block? target-block + (d/entity @conn (:db/id (nth blocks (dec idx))))) + block (d/entity @conn (:db/id block))] + (when-not (move-to-original-position? [block] target-block sibling? false) + (let [tx-data (move-block @conn block target-block sibling?)] + ;; FIXME: move-blocks should be pure fn + ;; (prn "==>> move blocks tx:" tx-data) + (ldb/transact! conn tx-data {:sibling? sibling? + :outliner-op (or outliner-op :move-blocks)})))))))) + +(defn- move-blocks + "Move `blocks` to `target-block` as siblings or children." + [conn blocks target-block {:keys [_sibling? _top? _bottom? _up? outliner-op _indent?] + :as opts}] + {:pre [(seq blocks)]} + (when (m/validate block-map-or-entity target-block) + (doseq [b blocks] + (let [entity (d/entity @conn (:db/id b))] + (when (outliner-validate/built-in-entity? entity) + (throw (ex-info "Built-in nodes can't be modified" + {:type :notification + :payload {:message "Built-in nodes can't be modified" + :type :error}}))))) + (let [db @conn + current-blocks (map (fn [block] + (d/entity db (:db/id block))) + blocks) + top-level-blocks (remove comment-block? + (filter-top-level-blocks db current-blocks))] + (when (seq top-level-blocks) + (let [[target-block sibling?] (get-target-block db top-level-blocks target-block opts) + non-consecutive? (and (> (count top-level-blocks) 1) (seq (ldb/get-non-consecutive-blocks db top-level-blocks))) + top-level-blocks (get-top-level-blocks top-level-blocks non-consecutive?) + blocks (->> (if non-consecutive? + (sort-non-consecutive-blocks db top-level-blocks) + top-level-blocks) + (map (fn [block] + (if (de/entity? block) + block + (d/entity db (:db/id block)))))) + original-position? (move-to-original-position? blocks target-block sibling? non-consecutive?)] + (when (and (every? #(move-source-allowed-for-comments? % sibling?) blocks) + (move-target-allowed-for-comments? target-block sibling?) + (not (contains? (set (map :db/id blocks)) (:db/id target-block))) + (not original-position?)) + (let [parents' (->> (ldb/get-block-parents db (:block/uuid target-block) {}) + (map :db/id) + (set)) + move-parents-to-child? (some parents' (map :db/id blocks))] + (when-not move-parents-to-child? + (transact-move-blocks! conn blocks target-block sibling? opts outliner-op top-level-blocks) + nil)))))))) + +(defn- move-blocks-up-down + "Move blocks up/down." + [conn blocks up?] + {:pre [(seq blocks) (boolean? up?)]} + (let [db @conn + top-level-blocks (filter-top-level-blocks db blocks) + opts {:outliner-op :move-blocks-up-down}] + (if up? + (let [first-block (d/entity db (:db/id (first top-level-blocks))) + first-block-parent (:block/parent first-block) + first-block-left-sibling (ldb/get-left-sibling first-block) + left-or-parent (or first-block-left-sibling first-block-parent) + left-left (or (ldb/get-left-sibling left-or-parent) + first-block-parent) + sibling? (= (:db/id (:block/parent left-left)) + (:db/id first-block-parent))] + (when (and left-left + (not= (:db/id (:block/page first-block-parent)) + (:db/id left-left)) + (not (and (:logseq.property/created-from-property first-block) + (nil? first-block-left-sibling)))) + (move-blocks conn top-level-blocks left-left (merge opts {:sibling? sibling? + :up? up?})))) + + (let [last-top-block (last top-level-blocks) + last-top-block-right (ldb/get-right-sibling last-top-block) + right (or + last-top-block-right + (let [parent (:block/parent last-top-block)] + (ldb/get-right-sibling parent))) + sibling? (= (:db/id (:block/parent last-top-block)) + (:db/id (:block/parent right)))] + (when (and right + (not (and (:logseq.property/created-from-property last-top-block) + (nil? last-top-block-right)))) + (move-blocks conn blocks right (merge opts {:sibling? sibling? + :up? up?}))))))) + +(defn- ^:large-vars/cleanup-todo indent-outdent-blocks + "Indent or outdent `blocks`." + [conn blocks indent? & {:keys [parent-original logical-outdenting?]}] + {:pre [(seq blocks) (boolean? indent?)]} + (let [db @conn + top-level-blocks (filter-top-level-blocks db blocks) + non-consecutive? (and (> (count top-level-blocks) 1) (seq (ldb/get-non-consecutive-blocks @conn top-level-blocks))) + top-level-blocks (get-top-level-blocks top-level-blocks non-consecutive?)] + (when-not (or non-consecutive? + (and (not indent?) + ;; property value blocks shouldn't be outdented + (some :logseq.property/created-from-property top-level-blocks))) + (let [first-block (d/entity db (:db/id (first top-level-blocks))) + left (ldb/get-left-sibling first-block) + parent (:block/parent first-block) + concat-tx-fn (fn [& results] + {:tx-data (->> (map :tx-data results) + (apply common-util/concat-without-nil)) + :tx-meta (:tx-meta (first results))}) + opts {:outliner-op :indent-outdent-blocks}] + (if indent? + (when left + (let [last-direct-child-id (ldb/get-block-last-direct-child-id db (:db/id left)) + blocks' (drop-while (fn [b] + (= (:db/id (:block/parent b)) + (:db/id left))) + top-level-blocks)] + (when (seq blocks') + (if last-direct-child-id + (let [last-direct-child (d/entity db last-direct-child-id) + result (move-blocks conn blocks' last-direct-child (merge opts {:sibling? true + :indent? true})) + ;; expand `left` if it's collapsed + collapsed-tx (when (:block/collapsed? left) + {:tx-data [{:db/id (:db/id left) + :block/collapsed? false}]})] + (concat-tx-fn result collapsed-tx)) + (move-blocks conn blocks' left (merge opts {:sibling? false + :indent? true})))))) + (if parent-original + (let [blocks' (take-while (fn [b] + (not= (:db/id (:block/parent b)) + (:db/id (:block/parent parent)))) + top-level-blocks)] + (move-blocks conn blocks' parent-original (merge opts {:outliner-op :indent-outdent-blocks + :sibling? true + :indent? false}))) + + (when parent + (let [blocks' (take-while (fn [b] + (not= (:db/id (:block/parent b)) + (:db/id (:block/parent parent)))) + top-level-blocks) + result (move-blocks conn blocks' parent (merge opts {:sibling? true}))] + (if logical-outdenting? + result + ;; direct outdenting (default behavior) + (let [last-top-block (d/entity db (:db/id (last blocks'))) + right-siblings (remove protected-comment-block? + (get-right-siblings last-top-block))] + (if (seq right-siblings) + (if-let [last-direct-child-id (ldb/get-block-last-direct-child-id db (:db/id last-top-block))] + (move-blocks conn right-siblings (d/entity db last-direct-child-id) (merge opts {:sibling? true})) + (move-blocks conn right-siblings last-top-block (merge opts {:sibling? false}))) + result))))))))))) + +;;; ### write-operations have side-effects (do transactions) ;;;;;;;;;;;;;;;; + +(defn- op-transact! + [outliner-op f & args] + {:pre [(fn? f)]} + (try + (let [result (apply f args)] + (when result + (let [tx-meta (outliner-tx-meta/ensure-outliner-ops + (:tx-meta result) + (direct-op-entry outliner-op args)) + tx-meta (assoc tx-meta + :outliner-op outliner-op)] + (ldb/transact! (first args) (:tx-data result) tx-meta))) + result) + (catch :default e + (when-not (= "not-allowed-move-block-page" (ex-message e)) + (js/console.error e) + (throw e))))) + +(let [f (fn [conn block opts] + (save-block @conn block opts))] + (defn save-block! + [conn block & {:as opts}] + (op-transact! :save-block f conn block + (if (:outliner-op opts) + opts + (assoc opts :outliner-op :save-block))))) + +(let [f (fn [conn blocks target-block opts] + (insert-blocks @conn blocks target-block opts))] + (defn insert-blocks! + [conn blocks target-block opts] + (op-transact! :insert-blocks f conn blocks target-block + (if (:outliner-op opts) + opts + (assoc opts :outliner-op :insert-blocks))))) + +(let [f (fn [conn blocks opts] + (delete-blocks @conn blocks opts))] + (defn delete-blocks! + [conn blocks opts] + (op-transact! :delete-blocks f conn blocks opts))) + +(defn move-blocks! + [conn blocks target-block opts] + (op-transact! :move-blocks move-blocks conn blocks target-block + (if (:outliner-op opts) + opts + (assoc opts :outliner-op :move-blocks)))) + +(defn move-blocks-up-down! + [conn blocks up?] + (op-transact! :move-blocks-up-down move-blocks-up-down conn blocks up?)) + +(defn indent-outdent-blocks! + [conn blocks indent? & {:as opts}] + (op-transact! :indent-outdent-blocks indent-outdent-blocks conn blocks indent? opts)) diff --git a/deps/outliner/src/logseq/outliner/datascript.cljs b/deps/outliner/src/logseq/outliner/datascript.cljs new file mode 100644 index 0000000..f92763d --- /dev/null +++ b/deps/outliner/src/logseq/outliner/datascript.cljs @@ -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))) diff --git a/deps/outliner/src/logseq/outliner/datascript_report.cljs b/deps/outliner/src/logseq/outliner/datascript_report.cljs new file mode 100644 index 0000000..9f49b59 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/datascript_report.cljs @@ -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))) \ No newline at end of file diff --git a/deps/outliner/src/logseq/outliner/db_pipeline.cljs b/deps/outliner/src/logseq/outliner/db_pipeline.cljs new file mode 100644 index 0000000..d45d5f8 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/db_pipeline.cljs @@ -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)))) diff --git a/deps/outliner/src/logseq/outliner/op.cljs b/deps/outliner/src/logseq/outliner/op.cljs new file mode 100644 index 0000000..040d9ad --- /dev/null +++ b/deps/outliner/src/logseq/outliner/op.cljs @@ -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)) diff --git a/deps/outliner/src/logseq/outliner/op/construct.cljc b/deps/outliner/src/logseq/outliner/op/construct.cljc new file mode 100644 index 0000000..0c0e9c0 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/op/construct.cljc @@ -0,0 +1,1309 @@ +(ns logseq.outliner.op.construct + "Construct canonical forward and reverse outliner ops for history actions." + (:require [clojure.string :as string] + [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.common.util :as common-util] + [logseq.common.util.date-time :as date-time-util] + [logseq.common.uuid :as common-uuid] + [logseq.db :as ldb] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.property :as db-property])) + +(def ^:api semantic-outliner-ops + #{:save-block + :insert-blocks + :apply-template + :move-blocks + :move-blocks-up-down + :indent-outdent-blocks + :delete-blocks + :create-page + :rename-page + :delete-page + :restore-recycled + :recycle-delete-permanently + :upsert-property}) + +(def ^:private transient-block-keys + #{:db/id + :block/tx-id + :block/created-at + :block/updated-at + :block/meta + :block/unordered + :block/level + :block.temp/ast-title + :block.temp/ast-body + :block.temp/load-status + :block.temp/has-children? + :logseq.property/created-by-ref}) + +(def ^:private delete-restore-transient-block-keys + (disj transient-block-keys :logseq.property/created-by-ref)) + +(def ^:api rebase-refs-key :block.temp/sync-rebase-refs) +(def ^:api rebase-created-refs-key :block.temp/sync-created-refs) + +(defn- stable-entity-ref + [db x] + (cond + (or (map? x) (de/entity? x)) + (let [eid (or (:db/id x) + (when-let [id (:block/uuid x)] + (:db/id (d/entity db [:block/uuid id]))))] + (stable-entity-ref db eid)) + (uuid? x) + [:block/uuid x] + (and (integer? x) (not (neg? x))) + (if-let [ent (d/entity db x)] + (cond + (:block/uuid ent) [:block/uuid (:block/uuid ent)] + (:db/ident ent) (:db/ident ent) + :else x) + x) + :else x)) + +(defn- tx-data-block-uuid-ref + [tx-data entity-ref] + (some (fn [item] + (when (and (= entity-ref (:e item)) + (= :block/uuid (:a item)) + (uuid? (:v item))) + [:block/uuid (:v item)])) + tx-data)) + +(defn- stable-entity-ref-with-tx-data + [db tx-data x] + (let [entity-ref (stable-entity-ref db x)] + (if (and (integer? entity-ref) (not (neg? entity-ref))) + (or (tx-data-block-uuid-ref tx-data entity-ref) + entity-ref) + entity-ref))) + +(defn- stable-block-ref-with-tx-data + [db tx-data x] + (stable-entity-ref-with-tx-data db tx-data x)) + +(defn- sanitize-ref-value + ([db v] + (sanitize-ref-value db nil v)) + ([db tx-data v] + (cond + (vector? v) (stable-entity-ref-with-tx-data db tx-data v) + (or (set? v) (sequential? v)) (set (map #(stable-entity-ref-with-tx-data db tx-data %) v)) + :else (stable-entity-ref-with-tx-data db tx-data v)))) + +(defn- sanitize-upsert-property-schema + [db schema] + (reduce-kv (fn [m k v] + (assoc m k + (if (= :logseq.property/classes k) + (sanitize-ref-value db v) + v))) + {} + schema)) + +(defn- sanitize-block-refs + [refs] + (->> refs + (keep (fn [ref-entity] + (when (:block/uuid ref-entity) + (select-keys ref-entity [:block/uuid :block/title :db/ident])))) + vec)) + +(defn- ref-attr? + [db a] + (and (keyword? a) + (= :db.type/ref + (:db/valueType (d/entity db a))))) + +(defn- sanitize-block-payload + ([db block] + (sanitize-block-payload db block nil)) + ([db block {:keys [created-uuids tx-data]}] + (if (map? block) + (let [refs (sanitize-block-refs (:block/refs block)) + created-ref-uuids (when (and (seq created-uuids) (seq refs)) + (->> refs + (keep :block/uuid) + (filter (set created-uuids)) + distinct + vec)) + m (reduce-kv + (fn [m k v] + (cond + (contains? transient-block-keys k) m + (= "block.temp" (namespace k)) m + (ref-attr? db k) + (assoc m k (sanitize-ref-value db tx-data v)) + :else + (assoc m k v))) + {} + block)] + (cond-> m + (seq refs) + (assoc rebase-refs-key refs) + + (seq created-ref-uuids) + (assoc rebase-created-refs-key created-ref-uuids))) + block))) + +(defn- get-missing-ref-by-lookup + [missing-refs tag-lookups] + (let [now (common-util/time-ms)] + (->> missing-refs + (keep (fn [{:block/keys [title] :as block :keys [db/ident]}] + (when-let [block-id (:block/uuid block)] + (let [lookup [:block/uuid block-id] + tag-ref? (contains? tag-lookups lookup) + entity (cond-> {:block/uuid block-id + :block/title (or title "") + :block/created-at now + :block/updated-at now + :block/tags (if tag-ref? :logseq.class/Tag :logseq.class/Page)} + (string? title) + (assoc :block/name (common-util/page-name-sanity-lc title)) + tag-ref? + (assoc :logseq.property.class/extends :logseq.class/Root) + ident + (assoc :db/ident ident))] + [lookup entity])))) + (into {})))) + +(defn rewrite-block-title-with-retracted-refs + [db block] + (let [refs (get block rebase-refs-key) + created-ref-uuids (set (get block rebase-created-refs-key)) + missing-refs (remove (fn [ref-entity] (d/entity db [:block/uuid (:block/uuid ref-entity)])) refs) + retracted-refs (remove (fn [block] + (contains? created-ref-uuids (:block/uuid block))) + missing-refs) + tag-lookups (->> (:block/tags block) + (filter (fn [v] + (and (vector? v) + (= :block/uuid (first v))))) + set) + missing-ref-by-lookup (get-missing-ref-by-lookup missing-refs tag-lookups) + rewrite-retracted-refs (fn [v] + (let [rewrite-ref (fn [block-ref] + (or (get missing-ref-by-lookup block-ref) + block-ref))] + (map rewrite-ref v))) + block' (cond-> block + (seq retracted-refs) + (update :block/title + (fn [title] + (-> title + (db-content/content-id-ref->page retracted-refs)))) + + (seq missing-ref-by-lookup) + (-> (update :block/refs rewrite-retracted-refs) + (update :block/tags rewrite-retracted-refs)))] + (dissoc block' rebase-refs-key rebase-created-refs-key))) + +(defn- sanitize-insert-block-payload + [db tx-data block] + (let [block' (sanitize-block-payload db block {:tx-data tx-data})] + (if (map? block') + (dissoc block' :block/page :block/order rebase-refs-key) + block'))) + +(defn- stable-id-coll + [db ids] + (mapv #(stable-entity-ref db %) ids)) + +(defn- stable-block-uuid + [db x] + (let [entity-ref (stable-entity-ref db x)] + (cond + (uuid? entity-ref) + entity-ref + + (and (vector? entity-ref) + (= :block/uuid (first entity-ref)) + (uuid? (second entity-ref))) + (second entity-ref) + + :else + entity-ref))) + +(defn- resolve-target-and-sibling + [block] + (if-let [left-sibling (ldb/get-left-sibling block)] + [(:db/id left-sibling) true] + (when-let [parent (:block/parent block)] + [(:db/id parent) false]))) + +(defn- resolve-move-target + [db ids] + (when-let [first-block (some->> ids first (d/entity db))] + (resolve-target-and-sibling first-block))) + +(defn- created-block-uuids-from-tx-data + [tx-data] + (->> tx-data + (keep (fn [item] + (cond + (and (map? item) (:block/uuid item)) + (:block/uuid item) + + (and (some? (:a item)) + (= :block/uuid (:a item)) + (true? (:added item))) + (:v item) + + (and (vector? item) + (= :db/add (first item)) + (>= (count item) 4) + (= :block/uuid (nth item 2))) + (nth item 3) + + :else + nil))) + distinct + vec)) + +(defn- created-page-uuid-from-tx-data + [tx-data title] + (or + (some (fn [item] + (when (and (map? item) + (= title (:block/title item)) + (:block/uuid item)) + (:block/uuid item))) + tx-data) + (let [grouped (group-by :e tx-data)] + (some (fn [[_ datoms]] + (let [title' (some (fn [datom] + (when (and (= :block/title (:a datom)) + (true? (:added datom))) + (:v datom))) + datoms) + uuid' (some (fn [datom] + (when (and (= :block/uuid (:a datom)) + (true? (:added datom))) + (:v datom))) + datoms)] + (when (and (= title title') (uuid? uuid')) + uuid'))) + grouped)))) + +(defn- created-db-ident-from-tx-data + [tx-data] + (or + (some (fn [item] + (when (and (map? item) + (qualified-keyword? (:db/ident item))) + (:db/ident item))) + tx-data) + (some (fn [item] + (when (and (map? item) + (= :db/ident (:a item)) + (qualified-keyword? (:v item))) + (:v item))) + tx-data) + (some (fn [item] + (when (and (vector? item) + (keyword? (nth item 1 nil)) + (= :db/ident (nth item 1 nil)) + (qualified-keyword? (nth item 2 nil))) + (nth item 2))) + tx-data) + (some (fn [item] + (when (and (vector? item) + (= :db/add (first item)) + (>= (count item) 4) + (= :db/ident (nth item 2)) + (qualified-keyword? (nth item 3))) + (nth item 3))) + tx-data))) + +(defn- property-ident-by-title + [db property-name] + (some-> (d/q '[:find ?ident . + :in $ ?title + :where + [?e :block/title ?title] + [?e :block/tags :logseq.class/Property] + [?e :db/ident ?ident]] + db + property-name) + (as-> ident + (when (qualified-keyword? ident) + ident)))) + +(defn- maybe-rewrite-delete-block-ids + [db tx-data ids] + (let [deleted-ids-from-tx-data (->> tx-data + (keep (fn [item] + (cond + (and (vector? item) + (= :db/retractEntity (first item)) + (>= (count item) 2)) + (second item) + + ;; d/with-style datom maps after retractEntity + ;; can be used to recover deleted entity ids. + (and (map? item) + (false? (:added item)) + (= :block/uuid (:a item))) + (:e item) + + ;; datascript Datom records from d/with + (and (some? (:a item)) + (false? (:added item)) + (= :block/uuid (:a item))) + (:e item) + + :else + nil))) + distinct + vec) + ids-from-tx-data' (some->> deleted-ids-from-tx-data + (stable-id-coll db) + seq + vec) + ids' (stable-id-coll db ids) + created-uuids (created-block-uuids-from-tx-data tx-data) + unresolved-created-lookups? (and (seq created-uuids) + (= (count ids') (count created-uuids)) + (every? (fn [id] + (and (vector? id) + (= :block/uuid (first id)) + (nil? (d/entity db id)))) + ids'))] + (cond + ;; Prefer the delete ids directly observed in tx-data to avoid carrying stale + ;; UI selection ids that weren't actually retracted in this transaction. + (seq ids-from-tx-data') + ids-from-tx-data' + + unresolved-created-lookups? + (mapv (fn [block-uuid] [:block/uuid block-uuid]) created-uuids) + :else + ids'))) + +(defn- moved-block-ids-from-tx-data + [tx-data] + (->> tx-data + (keep (fn [[e a _v _t added?]] + (when (and (= :block/parent a) (true? added?)) + e))) + distinct + vec)) + +(defn- remap-lookup-ref-by-uuid-map + [uuid-map v] + (cond + (and (vector? v) + (= :block/uuid (first v)) + (uuid? (second v))) + [:block/uuid (get uuid-map (second v) (second v))] + + (set? v) + (into #{} (map #(remap-lookup-ref-by-uuid-map uuid-map %) v)) + + (sequential? v) + (into (empty v) (map #(remap-lookup-ref-by-uuid-map uuid-map %) v)) + + :else + v)) + +(defn- remap-block-lookup-values-by-uuid-map + [block uuid-map] + (if (map? block) + (reduce-kv (fn [m k v] + (assoc m k (remap-lookup-ref-by-uuid-map uuid-map v))) + {} + block) + block)) + +(defn- template-children-blocks-for-history + [db template-ref] + (when-let [template (d/entity db template-ref)] + (let [template-id (:db/id template) + template-blocks (some->> (ldb/get-block-and-children db (:block/uuid template) + {:include-property-block? true}) + rest + seq + vec)] + (when (seq template-blocks) + (vec + (cons (assoc (into {} (first template-blocks)) + :db/id (:db/id (first template-blocks)) + :logseq.property/used-template template-id) + (map (fn [block] + (assoc (into {} block) :db/id (:db/id block))) + (rest template-blocks)))))))) + +(defn- canonicalize-insert-blocks-op + [db tx-data args] + (let [[blocks target-id opts] args + created-uuids (created-block-uuids-from-tx-data tx-data) + source-blocks (mapv #(sanitize-insert-block-payload db tx-data %) blocks) + source-uuids (mapv :block/uuid source-blocks) + target-ref (stable-entity-ref db target-id) + target (d/entity db target-id) + block-with-new-id (fn [block block-uuid] + (assoc block + :block/uuid block-uuid + :block/parent (let [parent (:block/parent (d/entity db [:block/uuid block-uuid]))] + [:block/uuid (:block/uuid parent)]))) + blocks* (if (seq created-uuids) + (if (and (:replace-empty-target? opts) + (= (inc (count created-uuids)) (count source-blocks))) + (let [[fst-block & rst-blocks] source-blocks + created-rst-uuids created-uuids] + (into [(assoc fst-block :block/uuid (:block/uuid target))] + (if (seq created-rst-uuids) + (map block-with-new-id rst-blocks created-rst-uuids) + rst-blocks))) + (mapv block-with-new-id source-blocks created-uuids)) + source-blocks) + uuid-remap (->> (map vector source-uuids (map :block/uuid blocks*)) + (keep (fn [[old-uuid new-uuid]] + (when (and (uuid? old-uuid) + (uuid? new-uuid) + (not= old-uuid new-uuid)) + [old-uuid new-uuid]))) + (into {})) + blocks* (if (seq uuid-remap) + (mapv #(remap-block-lookup-values-by-uuid-map % uuid-remap) blocks*) + blocks*)] + [blocks* + target-ref + (assoc (dissoc (or opts {}) :outliner-op) + :keep-uuid? true)])) + +(defn- canonical-move-op-for-block + [db block-id opts] + (when-let [[target-id sibling?] (resolve-move-target db [block-id])] + [:move-blocks [[(stable-entity-ref db block-id)] + (stable-entity-ref db target-id) + (assoc (dissoc (or opts {}) :outliner-op) + :sibling? sibling?)]])) + +(defn- canonicalize-indent-outdent-op + [db tx-data ids indent? opts] + (let [moved-ids (moved-block-ids-from-tx-data tx-data)] + (if (seq moved-ids) + (let [move-ops (->> moved-ids + (keep #(canonical-move-op-for-block db % opts)) + vec)] + (if (= (count moved-ids) (count move-ops)) + move-ops + [[:indent-outdent-blocks [(stable-id-coll db ids) + indent? + opts]]])) + [[:indent-outdent-blocks [(stable-id-coll db ids) + indent? + opts]]]))) + +(defn- ^:large-vars/cleanup-todo canonicalize-semantic-outliner-op + [db tx-data [op args]] + (case op + :save-block + (let [[block opts] args + created-uuids (created-block-uuids-from-tx-data tx-data)] + [:save-block [(sanitize-block-payload db block {:created-uuids created-uuids + :tx-data tx-data}) + opts]]) + + :insert-blocks + [:insert-blocks + (canonicalize-insert-blocks-op db tx-data args)] + + :apply-template + (let [[template-id target-id opts] args + template-ref (stable-entity-ref db template-id) + target-ref (stable-entity-ref db target-id) + template-blocks (or (some-> (:template-blocks opts) seq vec) + (template-children-blocks-for-history db template-ref)) + opts-base (dissoc opts :template-id :outliner-op) + opts' (if (seq template-blocks) + (let [[blocks* _target-ref insert-opts] + (canonicalize-insert-blocks-op db tx-data [template-blocks target-id opts-base])] + (assoc insert-opts :template-blocks blocks*)) + (dissoc opts-base :template-blocks))] + (when-not (and template-ref target-ref) + (throw (ex-info "Invalid apply-template args" + {:args args}))) + [:apply-template [template-ref target-ref opts']]) + + :move-blocks-up-down + (let [[ids up?] args] + [:move-blocks-up-down + [(stable-id-coll db ids) + up?]]) + + :indent-outdent-blocks + (let [[ids indent? opts] args] + (canonicalize-indent-outdent-op db tx-data ids indent? opts)) + + :move-blocks + (let [[ids target-id opts] args] + [:move-blocks [(stable-id-coll db ids) + (stable-block-ref-with-tx-data db tx-data target-id) + opts]]) + + :delete-blocks + (let [[ids opts] args] + [:delete-blocks [(maybe-rewrite-delete-block-ids db tx-data ids) opts]]) + + :create-page + (let [[title opts] args + page-uuid (created-page-uuid-from-tx-data tx-data title)] + [:create-page [title + (cond-> (or opts {}) + page-uuid + (assoc :uuid page-uuid))]]) + + :rename-page + (let [[page-uuid new-title] args] + [:save-block [{:block/uuid (stable-block-uuid db page-uuid) + :block/title new-title} + {}]]) + + :delete-page + (let [[page-uuid opts] args] + [:delete-page [(stable-block-uuid db page-uuid) opts]]) + + :restore-recycled + (let [[root-id] args] + [:restore-recycled [(stable-block-uuid db root-id)]]) + + :recycle-delete-permanently + (let [[root-id] args] + [:recycle-delete-permanently [(stable-block-uuid db root-id)]]) + + :upsert-property + (let [[property-id schema opts] args + property-id' (or (stable-entity-ref db property-id) + (property-ident-by-title db (:property-name opts)) + (created-db-ident-from-tx-data tx-data))] + [:upsert-property [property-id' schema opts]]) + + [op args])) + +(defn- save-block-keys + ([block] + (save-block-keys block transient-block-keys)) + ([block transient-keys] + (->> (keys block) + (remove transient-keys) + (remove #(= :db/other-tx %)) + (remove nil?)))) + +(defn- worker-ref-attr? + [db a] + (and (keyword? a) + (= :db.type/ref + (:db/valueType (d/entity db a))))) + +(defn- block-entity + [db block] + (cond + (map? block) + (or (when-let [block-uuid (:block/uuid block)] + (d/entity db [:block/uuid block-uuid])) + (when-let [db-id (:db/id block)] + (d/entity db db-id))) + + (integer? block) + (d/entity db block) + + (uuid? block) + (d/entity db [:block/uuid block]) + + (vector? block) + (d/entity db block) + + :else + nil)) + +(defn- build-inverse-save-block + [db-before block opts] + (when-let [before-ent (block-entity db-before block)] + (let [keys-to-restore (save-block-keys block) + inverse-block (reduce + (fn [m k] + (let [v (if (= :block/title k) + (:block/raw-title before-ent) + (get before-ent k))] + (assoc m k + (if (worker-ref-attr? db-before k) + (sanitize-ref-value db-before v) + v)))) + {:block/uuid (:block/uuid before-ent)} + keys-to-restore)] + [:save-block [inverse-block opts]]))) + +(defn- build-insert-block-payload + [db-before ent] + (when-let [block-uuid (:block/uuid ent)] + (->> (save-block-keys ent delete-restore-transient-block-keys) + (remove #(string/starts-with? (name %) "_")) + (reduce (fn [m k] + (let [v (get ent k)] + (assoc m k + (if (worker-ref-attr? db-before k) + (sanitize-ref-value db-before v) + v)))) + {:block/uuid block-uuid})))) + +(defn- selected-block-roots + [db-before ids] + (let [resolved-entities (mapv #(block-entity db-before %) ids) + unresolved-id? (some nil? resolved-entities) + entities (reduce (fn [acc ent] + (if (some #(= (:db/id %) (:db/id ent)) acc) + acc + (conj acc ent))) + [] + (remove nil? resolved-entities)) + selected-ids (set (map :db/id entities)) + has-selected-ancestor? (fn [ent] + (loop [parent (:block/parent ent)] + (if-let [parent-id (some-> parent :db/id)] + (if (contains? selected-ids parent-id) + true + (recur (:block/parent parent))) + false)))] + {:roots (->> entities + (remove has-selected-ancestor?) + vec) + :incomplete? (boolean unresolved-id?)})) + +(defn- block-restore-target + [ent] + (if-let [left-sibling-id (:db/id (ldb/get-left-sibling ent))] + [left-sibling-id true] + (when-let [parent-id (or (:db/id (:block/parent ent)) + (:db/id (:block/page ent)))] + [parent-id false]))) + +(defn- to-insert-op + [db-before {:keys [blocks target-id sibling?]}] + [:insert-blocks [blocks + (stable-entity-ref db-before target-id) + {:sibling? (boolean sibling?) + :keep-uuid? true + :keep-block-order? true}]]) + +(defn- delete-root->restore-plan + [db-before root] + (let [root-id (:db/id root) + root-uuid (:block/uuid root) + blocks (when root-uuid + (->> (ldb/get-block-and-children db-before root-uuid) + (keep #(build-insert-block-payload db-before %)) + vec)) + [target-id sibling?] (block-restore-target root) + [target-id sibling?] (if (and target-id (= target-id root-id)) + [(or (:db/id (:block/parent root)) + (:db/id (:block/page root))) + false] + [target-id sibling?])] + (when (and (seq blocks) (some? target-id)) + {:blocks blocks + :target-id (stable-entity-ref db-before target-id) + :sibling? sibling?}))) + +(defn- build-inverse-delete-blocks + [db-before ids] + (let [{:keys [roots incomplete?]} (selected-block-roots db-before ids) + plans (mapv #(delete-root->restore-plan db-before %) roots)] + (when (and (not incomplete?) + (seq roots) + (every? some? plans)) + (->> plans + (mapv #(to-insert-op db-before %)) + seq)))) + +(defn- move-root->restore-op + [db-before root] + (let [root-id (:db/id root) + [target-id sibling?] (block-restore-target root)] + (when (and (some? root-id) + (some? target-id)) + [:move-blocks + [[(stable-entity-ref db-before root-id)] + (stable-entity-ref db-before target-id) + {:sibling? (boolean sibling?)}]]))) + +(defn- build-inverse-move-blocks + [db-before ids] + (let [{:keys [roots incomplete?]} (selected-block-roots db-before ids) + restore-ops (mapv #(move-root->restore-op db-before %) roots)] + (when (and (not incomplete?) + (seq roots) + (every? some? restore-ops)) + (seq restore-ops)))) + +(defn- page-top-level-blocks + [page] + (let [page-id (:db/id page)] + (->> (:block/_page page) + (filter #(= page-id (some-> % :block/parent :db/id))) + ldb/sort-by-order + vec))) + +(defn- entity->save-op + [db-before ent] + (build-inverse-save-block db-before (into {} ent) nil)) + +(defn- build-inverse-delete-page + [db-before page-uuid] + (when-let [page (d/entity db-before [:block/uuid page-uuid])] + (let [class-or-property? (or (ldb/class? page) + (ldb/property? page)) + today-page? (when-let [day (:block/journal-day page)] + (= (date-time-util/ms->journal-day (common-util/time-ms)) day)) + root-plans (mapv #(delete-root->restore-plan db-before %) (page-top-level-blocks page))] + (cond + class-or-property? + (let [page-save-op (entity->save-op db-before (assoc (into {} page) :db/ident (:db/ident page))) + create-op (if (ldb/class? page) + (let [class-ident-namespace (some-> (:db/ident page) namespace)] + [:create-page + [(:block/title page) + (cond-> {:uuid page-uuid + :class? true + :redirect? false + :split-namespace? true} + class-ident-namespace + (assoc :class-ident-namespace class-ident-namespace))]]) + [:upsert-property + [(:db/ident page) + (db-property/get-property-schema (into {} page)) + {:property-name (:block/title page)}]]) + restore-root-ops (when (every? some? root-plans) + (mapv #(to-insert-op db-before %) root-plans))] + (cond-> [] + create-op + (conj create-op) + page-save-op + (conj page-save-op) + (seq restore-root-ops) + (into restore-root-ops) + :always + seq)) + + today-page? + (when (every? some? root-plans) + (->> root-plans + (mapv #(to-insert-op db-before %)) + seq)) + + :else + ;; Soft-deleted pages are moved to Recycle with recycle metadata. + ;; Use restore semantics instead of save-block to retract recycle markers. + [:restore-recycled [page-uuid]])))) + +(defn- restore-target-insert-op + [db-before db-after target-id opts] + (when (:replace-empty-target? opts) + (when-let [target-ref (stable-entity-ref db-before target-id)] + (when (d/entity db-after target-ref) + (when-let [target (d/entity db-before target-ref)] + (let [insert-block (build-insert-block-payload db-before target) + [target-id sibling?] (resolve-target-and-sibling target)] + [[:delete-blocks [[target-ref] {}]] + (to-insert-op db-before {:blocks [insert-block] + :target-id (stable-entity-ref db-before target-id) + :sibling? sibling?})])))))) + +(defn- build-inverse-insert-like + [db-before db-after tx-data args] + (let [[_blocks target-id opts] args + new-block-eids (keep + (fn [d] + (when (and (= :block/uuid (:a d)) + (:added d) + (nil? (d/entity db-before (:e d)))) + [:block/uuid (:v d)])) + tx-data) + restore-op (restore-target-insert-op db-before db-after target-id opts)] + (cond-> [] + (seq new-block-eids) + (conj [:delete-blocks [new-block-eids {}]]) + + restore-op + (into restore-op) + + :always + seq))) + +(defn- reverse-inverse-entry-groups + [inverse-entries forward-op-group-sizes] + (loop [entries inverse-entries + sizes forward-op-group-sizes + groups []] + (if-let [group-size (first sizes)] + (let [[group remaining] (split-at group-size entries)] + (recur remaining (next sizes) (conj groups group))) + (mapcat identity (reverse groups))))) + +(defn- ^:large-vars/cleanup-todo build-strict-inverse-outliner-ops + [db-before db-after tx-data forward-ops forward-op-group-sizes] + (when (seq forward-ops) + (let [inverse-entries + (mapv (fn [[op args]] + (let [inverse-entry + (case op + :save-block + (let [[block opts] args] + (build-inverse-save-block db-before block opts)) + + :insert-blocks + (build-inverse-insert-like db-before db-after tx-data args) + + :apply-template + (build-inverse-insert-like db-before db-after tx-data args) + + :move-blocks + (let [[ids _target-id _opts] args] + (build-inverse-move-blocks db-before ids)) + + :indent-outdent-blocks + (let [[ids indent? opts] args] + [:indent-outdent-blocks [(stable-id-coll db-before ids) + (not indent?) + opts]]) + + :move-blocks-up-down + (let [[ids up?] args] + [:move-blocks-up-down + [(stable-id-coll db-before ids) + (not up?)]]) + + :delete-blocks + (let [[ids _opts] args] + (build-inverse-delete-blocks db-before ids)) + + :create-page + (let [[_title opts] args] + (when-let [page-uuid (:uuid opts)] + [:delete-page [page-uuid {}]])) + + :delete-page + (let [[page-uuid _opts] args] + (build-inverse-delete-page db-before page-uuid)) + + :upsert-property + (let [[property-id _schema _opts] args] + (when (qualified-keyword? property-id) + (if-let [property (d/entity db-before property-id)] + [:upsert-property + [property-id + (sanitize-upsert-property-schema + db-before + (db-property/get-property-schema (into {} property))) + {:property-name (:block/title property)}]] + [:delete-page [(common-uuid/gen-uuid :db-ident-block-uuid property-id) {}]]))) + + nil)] + (if (and (sequential? inverse-entry) + (empty? inverse-entry)) + nil + inverse-entry))) + forward-ops)] + ;; Any missing inverse entry means the whole semantic inverse is incomplete. + ;; Use raw reversed tx instead of partially replaying. + (when (every? some? inverse-entries) + (some->> (reverse-inverse-entry-groups inverse-entries forward-op-group-sizes) + (mapcat #(if (and (sequential? %) + (sequential? (first %))) + % + [%])) + vec + seq))))) + +(defn- has-replace-empty-target-insert-op? + [forward-ops] + (some (fn [[op [_blocks _target-id opts]]] + (and (contains? #{:insert-blocks :apply-template} op) + (:replace-empty-target? opts))) + forward-ops)) + +(defn contains-transact-op? + [ops] + (let [ops' (cond + (nil? ops) + nil + + (and (sequential? ops) + (keyword? (first ops))) + [(vec ops)] + + :else + ops)] + (some (fn [entry] + (and (sequential? entry) + (= :transact (first entry)))) + ops'))) + +(defn- normalize-op-entries + [ops] + (let [ops' (some-> ops seq vec)] + (cond + (nil? ops') + nil + + (and (keyword? (first ops')) + (vector? (second ops'))) + [ops'] + + :else + ops'))) + +(defn- canonical-block-id + [db block-id] + (cond + (uuid? block-id) + block-id + + (and (vector? block-id) + (= :block/uuid (first block-id)) + (uuid? (second block-id))) + (second block-id) + + (and (integer? block-id) + (not (neg? block-id))) + (stable-block-uuid db block-id) + + :else + block-id)) + +(defn- canonical-property-id + [db property-id] + (cond + (qualified-keyword? property-id) + property-id + + (and (integer? property-id) + (not (neg? property-id))) + (or (some-> (d/entity db property-id) :db/ident) + property-id) + + :else + property-id)) + +(defn- normalize-block-op-entry-ids + [id ids op args] + (case op + :save-block + (let [[block opts] args] + [op [block opts]]) + + :insert-blocks + [op [(first args) (id (second args)) (nth args 2)]] + + :apply-template + [op [(id (first args)) (id (second args)) (nth args 2)]] + + :delete-blocks + [op [(ids (first args)) (second args)]] + + :move-blocks + [op [(ids (first args)) (id (second args)) (nth args 2)]] + + :move-blocks-up-down + [op [(ids (first args)) (second args)]] + + :indent-outdent-blocks + [op [(ids (first args)) (second args) (nth args 2)]] + + nil)) + +(defn- normalize-property-op-entry-ids + [id ids property-id op args] + (case op + :set-block-property + [op [(id (first args)) (property-id (second args)) (nth args 2)]] + + :remove-block-property + [op [(id (first args)) (property-id (second args))]] + + :delete-property-value + [op [(id (first args)) (property-id (second args)) (nth args 2)]] + + :create-property-text-block + [op [(some-> (first args) id) (property-id (second args)) (nth args 2) (nth args 3)]] + + :batch-set-property + [op [(ids (first args)) (property-id (second args)) (nth args 2) (nth args 3)]] + + :batch-remove-property + [op [(ids (first args)) (property-id (second args))]] + + :batch-delete-property-value + [op [(ids (first args)) (property-id (second args)) (nth args 2)]] + + :class-add-property + [op [(id (first args)) (property-id (second args))]] + + :class-remove-property + [op [(id (first args)) (property-id (second args))]] + + :upsert-property + [op [(some-> (first args) property-id) (second args) (nth args 2)]] + + :upsert-closed-value + [op [(property-id (first args)) (second args)]] + + :delete-closed-value + [op [(property-id (first args)) (id (second args))]] + + :add-existing-values-to-closed-values + [op [(property-id (first args)) (second args)]] + + nil)) + +(defn- normalize-op-entry-ids + [db [op args :as op-entry]] + (let [id (fn [v] (canonical-block-id db v)) + property-id (fn [v] (canonical-property-id db v)) + ids (fn [vs] (mapv id vs)) + normalized (or (normalize-block-op-entry-ids id ids op args) + (normalize-property-op-entry-ids id ids property-id op args))] + (or normalized op-entry))) + +(defn- canonicalize-explicit-outliner-op-groups + [db tx-data ops] + (let [ops' (normalize-op-entries ops)] + (cond + (nil? ops') + nil + + (seq ops') + (->> ops' + (mapv (fn [op] + (let [canonicalized-op (canonicalize-semantic-outliner-op db tx-data op)] + (if (and (sequential? canonicalized-op) + (sequential? (first canonicalized-op)) + (keyword? (ffirst canonicalized-op))) + (vec canonicalized-op) + [canonicalized-op])))) + vec) + + :else + nil))) + +(defn- canonicalize-explicit-outliner-ops + [db tx-data ops] + (some->> (canonicalize-explicit-outliner-op-groups db tx-data ops) + (mapcat identity) + vec)) + +(defn- patch-inverse-delete-block-ops + [inverse-outliner-ops forward-outliner-ops] + (let [forward-insert-ops* (atom (->> forward-outliner-ops + reverse + (filter #(contains? #{:insert-blocks :apply-template} (first %))) + vec)) + op->inserted-ids (fn [[op args]] + (let [blocks (case op + :insert-blocks + (first args) + + :apply-template + (get-in args [2 :template-blocks]) + + nil)] + (->> (or blocks []) + (keep (fn [block] + (when-let [block-uuid (:block/uuid block)] + [:block/uuid block-uuid]))) + vec)))] + (mapv (fn [[op args :as inverse-op]] + (if (and (= :delete-blocks op) + (seq @forward-insert-ops*)) + (let [ids (op->inserted-ids (first @forward-insert-ops*))] + (swap! forward-insert-ops* subvec 1) + (if (seq ids) + [:delete-blocks [ids (second args)]] + inverse-op)) + inverse-op)) + inverse-outliner-ops))) + +(defn- patch-forward-delete-block-op-ids + [db-before outliner-ops] + (some->> outliner-ops + (mapv (fn [[op args :as op-entry]] + (if (= :delete-blocks op) + (let [[ids opts] args] + [:delete-blocks [(stable-id-coll db-before ids) opts]]) + op-entry))) + seq + vec)) + +(defn- canonicalize-outliner-op-groups + [db tx-meta tx-data] + (let [explicit-forward-ops (normalize-op-entries (:db-sync/forward-outliner-ops tx-meta)) + outliner-ops (normalize-op-entries (:outliner-ops tx-meta))] + (cond + (seq explicit-forward-ops) + (canonicalize-explicit-outliner-op-groups db tx-data explicit-forward-ops) + + (seq outliner-ops) + (canonicalize-explicit-outliner-op-groups db tx-data outliner-ops) + + :else + nil))) + +(defn- unresolved-numeric-entity-id? + [x] + (and (integer? x) + (not (neg? x)))) + +(defn- numeric-id-in-ref-value? + [v] + (cond + (unresolved-numeric-entity-id? v) + true + + (set? v) + (some numeric-id-in-ref-value? v) + + (sequential? v) + (some numeric-id-in-ref-value? v) + + :else + false)) + +(defn- numeric-id-in-block-ref-attrs? + [db block] + (and (map? block) + (some (fn [[k v]] + (and (ref-attr? db k) + (numeric-id-in-ref-value? v))) + block))) + +(defn- stale-numeric-id-in-page-ops? + [db op args] + (case op + :save-block + (let [[block _opts] args] + (numeric-id-in-block-ref-attrs? db block)) + + :insert-blocks + (let [[blocks target-id _opts] args] + (or (some #(numeric-id-in-block-ref-attrs? db %) blocks) + (unresolved-numeric-entity-id? target-id))) + + :create-page + (let [[_title opts] args] + (unresolved-numeric-entity-id? (:uuid opts))) + + :rename-page + (let [[page-uuid _new-title] args] + (unresolved-numeric-entity-id? page-uuid)) + + :delete-page + (let [[page-uuid _opts] args] + (unresolved-numeric-entity-id? page-uuid)) + + :restore-recycled + (let [[root-id] args] + (unresolved-numeric-entity-id? root-id)) + + :apply-template + (let [[template-id target-id opts] args + template-blocks (:template-blocks opts)] + (or (unresolved-numeric-entity-id? template-id) + (unresolved-numeric-entity-id? target-id) + (some #(numeric-id-in-block-ref-attrs? db %) template-blocks))) + + :recycle-delete-permanently + (let [[root-id] args] + (unresolved-numeric-entity-id? root-id)) + + nil)) + +(defn- stale-numeric-id-in-schema-ops? + [op args] + (case op + :upsert-property + (let [[property-id _schema _opts] args] + (unresolved-numeric-entity-id? property-id)) + + nil)) + +(defn- stale-numeric-id-in-op? + [db [op args]] + (and (not= :transact op) + (boolean + (or (stale-numeric-id-in-page-ops? db op args) + (stale-numeric-id-in-schema-ops? op args))))) + +(defn- assert-no-stale-numeric-ids! + [db ops stage] + (when-let [[idx op-entry] (some (fn [[idx op-entry]] + (when (stale-numeric-id-in-op? db op-entry) + [idx op-entry])) + (map-indexed vector ops))] + (throw (ex-info "Non-transact outliner ops contain numeric entity ids" + {:stage stage + :index idx + :op op-entry + :ops ops})))) + +(defn ^:api assert-no-numeric-entity-ids! + [db ops stage] + (assert-no-stale-numeric-ids! db (some-> ops seq vec) stage)) + +(defn derive-history-outliner-ops + [db-before db-after tx-data tx-meta] + (let [canonical-forward-outliner-op-groups + (some->> (canonicalize-outliner-op-groups db-after tx-meta tx-data) + (mapv (fn [group] + (->> (patch-forward-delete-block-op-ids db-before group) + (mapv #(normalize-op-entry-ids db-after %)))))) + canonical-forward-outliner-ops (some->> canonical-forward-outliner-op-groups + (mapcat identity) + seq + vec) + _ (assert-no-stale-numeric-ids! db-after canonical-forward-outliner-ops :forward-outliner-ops) + forward-outliner-ops canonical-forward-outliner-ops + forward-op-group-sizes (mapv count canonical-forward-outliner-op-groups) + built-inverse-outliner-ops (some-> (build-strict-inverse-outliner-ops db-before + db-after + tx-data + forward-outliner-ops + forward-op-group-sizes) + seq + vec + (->> (mapv #(normalize-op-entry-ids db-before %)))) + _ (assert-no-stale-numeric-ids! db-before built-inverse-outliner-ops :built-inverse-outliner-ops) + explicit-inverse-outliner-ops (some-> (canonicalize-explicit-outliner-ops db-after tx-data (:db-sync/inverse-outliner-ops tx-meta)) + (patch-inverse-delete-block-ops forward-outliner-ops) + seq + vec + (->> (mapv #(normalize-op-entry-ids db-after %)))) + _ (assert-no-stale-numeric-ids! db-after explicit-inverse-outliner-ops :explicit-inverse-outliner-ops) + inverse-outliner-ops (cond + (and (= :apply-template (:outliner-op tx-meta)) + (:undo? tx-meta) + (seq (:db-sync/inverse-outliner-ops tx-meta))) + explicit-inverse-outliner-ops + + (has-replace-empty-target-insert-op? forward-outliner-ops) + built-inverse-outliner-ops + + (seq built-inverse-outliner-ops) + built-inverse-outliner-ops + + (nil? explicit-inverse-outliner-ops) + nil + + :else + explicit-inverse-outliner-ops) + inverse-outliner-ops (some-> inverse-outliner-ops + seq + vec + (->> (mapv #(normalize-op-entry-ids db-before %)))) + _ (assert-no-stale-numeric-ids! db-before inverse-outliner-ops :inverse-outliner-ops)] + {:forward-outliner-ops forward-outliner-ops + :inverse-outliner-ops inverse-outliner-ops})) diff --git a/deps/outliner/src/logseq/outliner/page.cljs b/deps/outliner/src/logseq/outliner/page.cljs new file mode 100644 index 0000000..908696d --- /dev/null +++ b/deps/outliner/src/logseq/outliner/page.cljs @@ -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])) diff --git a/deps/outliner/src/logseq/outliner/pipeline.cljs b/deps/outliner/src/logseq/outliner/pipeline.cljs new file mode 100644 index 0000000..ebc6376 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/pipeline.cljs @@ -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)) diff --git a/deps/outliner/src/logseq/outliner/property.cljs b/deps/outliner/src/logseq/outliner/property.cljs new file mode 100644 index 0000000..edb5e19 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/property.cljs @@ -0,0 +1,1274 @@ +(ns logseq.outliner.property + "Property related operations" + (:require [clojure.set :as set] + [clojure.string :as string] + [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.common.util :as common-util] + [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.db-ident :as db-ident] + [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.db.frontend.property.type :as db-property-type] + [logseq.db.frontend.schema :as db-schema] + [logseq.db.sqlite.util :as sqlite-util] + [logseq.outliner.core :as outliner-core] + [logseq.outliner.page :as outliner-page] + [logseq.outliner.validate :as outliner-validate] + [malli.core :as m] + [malli.error :as me] + [malli.util :as mu])) + +(defn- throw-error-if-read-only-property + [property-ident] + (when (db-property/read-only-properties property-ident) + (throw (ex-info "Read-only property value shouldn't be edited" + {:property property-ident})))) + +(defn- db-ident->eid + [db db-ident] + (assert (qualified-keyword? db-ident)) + (let [id (:db/id (d/entity db db-ident))] + (when-not id + (throw (ex-info "Wrong property db/ident" {:db-ident db-ident}))) + id)) + +(defonce ^:private built-in-class-property->properties + (->> + (mapcat + (fn [[class-ident {:keys [properties]}]] + (map + (fn [property] [class-ident property]) + (cons :block/tags (keys properties)))) + db-class/built-in-classes) + (concat + (mapcat + (fn [[property-ident {:keys [properties]}]] + (map + (fn [property] [property-ident property]) + (cons :block/tags (keys properties)))) + db-property/built-in-properties)) + set)) + +(defn- throw-error-if-deleting-protected-property + [entity-idents property-ident] + (when (some #(built-in-class-property->properties [% property-ident]) entity-idents) + (throw (ex-info "Property is protected and can't be deleted" + {:type :notification + :payload {:type :error + :message "Property is protected and can't be deleted." + :i18n-key :property.validation/protected + :entity-idents entity-idents + :property property-ident}})))) + +(defn- throw-error-if-removing-private-tag + [entities] + (when-let [private-tags + (seq (set/intersection (set (mapcat #(map :db/ident (:block/tags %)) entities)) + ldb/private-tags))] + (throw (ex-info "Can't remove private tags" + {:type :notification + :payload {:message (str "Can't remove private tags: " (string/join ", " private-tags) ".") + :i18n-key :class.validation/cant-remove-private-tags + :i18n-args [(string/join ", " private-tags)] + :type :error} + :property-id :block/tags})))) + +(defn- throw-error-if-deleting-required-property + [property-ident] + (when (contains? db-malli-schema/required-properties property-ident) + (throw (ex-info "Can't remove required property" + {:type :notification + :payload {:message "Can't remove required property." + :i18n-key :property.validation/cant-remove-required + :type :error} + :property-id property-ident})))) + +(declare get-block-classes-properties) + +(defn- validate-batch-deletion-of-property + "Validates that the given property can be batch deleted from multiple nodes" + [entities property-ident] + (throw-error-if-deleting-protected-property (map :db/ident entities) property-ident) + (when (= :block/tags property-ident) (throw-error-if-removing-private-tag entities)) + (outliner-validate/disallow-editing-private-built-in-nodes entities) + (throw-error-if-deleting-required-property property-ident)) + +(defn- block-classes-provide-property? + [db block property-id] + (->> (:classes-properties (get-block-classes-properties db (:db/id block))) + (some #(= property-id (:db/ident %))) + boolean)) + +(defn- should-add-task-tag-for-property? + [conn block property-id] + (and (contains? #{:logseq.property/status :logseq.property/scheduled :logseq.property/deadline} property-id) + (or (empty? (:block/tags block)) + (ldb/internal-page? block) + (not (block-classes-provide-property? @conn block property-id))))) + +(defn- class-lookup-ref? + [value] + ;; Class extends accepts only entity refs that can point at class blocks. + ;; Keep this narrow to avoid per-value schema/entity lookups while parsing vectors. + (and (vector? value) + (= 2 (count value)) + (contains? #{:db/ident :block/uuid} (first value)))) + +(defn- single-entity-ref? + [value] + (or (de/entity? value) + (integer? value) + (keyword? value) + (class-lookup-ref? value))) + +(defn- ->entity-ids + [db value] + (letfn [(entity-id [item] + (or (cond + (de/entity? item) (:db/id item) + (integer? item) item + (keyword? item) (:db/id (d/entity db item)) + (class-lookup-ref? item) (:db/id (d/entity db item))) + (throw (ex-info "Unsupported class extends entity reference" + {:value item}))))] + (->> (cond + (nil? value) [] + (single-entity-ref? value) [value] + (coll? value) value + :else [value]) + (map entity-id)))) + +(defn- class-ancestor-ids + [db class-ids] + (set (mapcat (fn [class-id] + (some->> (d/entity db class-id) + ldb/get-class-extends + (map :db/id))) + class-ids))) + +(defn- direct-extends-retraction-tx-data + [class redundant-parent-ids] + (when (and (ldb/class? class) (seq redundant-parent-ids)) + (keep (fn [parent] + (when (contains? redundant-parent-ids (:db/id parent)) + [:db/retract (:db/id class) :logseq.property.class/extends (:db/id parent)])) + (:logseq.property.class/extends class)))) + +(defn- canonical-extends-ids + [db value] + (let [parent-ids (vec (->entity-ids db value))] + (remove (class-ancestor-ids db parent-ids) parent-ids))) + +(defn- normalize-extends-value + [db value] + (let [ids (vec (canonical-extends-ids db value))] + (if (single-entity-ref? value) + (first ids) + ids))) + +(defn- redundant-extends-retraction-tx-data + [db class value] + (let [parent-ids (set (->entity-ids db value)) + ancestor-ids (class-ancestor-ids db parent-ids) + inherited-parent-ids (set/union parent-ids ancestor-ids)] + (concat + (direct-extends-retraction-tx-data class ancestor-ids) + (mapcat #(direct-extends-retraction-tx-data (d/entity db %) inherited-parent-ids) + (db-class/get-structured-children db (:db/id class)))))) + +(defn- build-property-value-tx-data + [conn block property-id value] + (when (some? value) + (let [old-value (get block property-id) + property (d/entity @conn property-id) + multiple-values? (= :db.cardinality/many (:db/cardinality property)) + retract-multiple-values? (and multiple-values? (sequential? value)) + multiple-values-empty? (and (sequential? old-value) + (contains? (set (map :db/ident old-value)) :logseq.property/empty-placeholder)) + extends? (= property-id :logseq.property.class/extends) + tx-value (if extends? + (let [value' (normalize-extends-value @conn value)] + (if (coll? value') (set value') value')) + value) + update-block-tx (cond-> (outliner-core/block-with-updated-at {:db/id (:db/id block)}) + true + (assoc property-id tx-value) + (should-add-task-tag-for-property? conn block property-id) + (assoc :block/tags :logseq.class/Task) + (= :logseq.property/template-applied-to property-id) + (assoc :block/tags :logseq.class/Template))] + (cond-> [] + multiple-values-empty? + (conj [:db/retract (:db/id update-block-tx) property-id :logseq.property/empty-placeholder]) + retract-multiple-values? + (conj [:db/retract (:db/id update-block-tx) property-id]) + extends? + (into (redundant-extends-retraction-tx-data @conn block value)) + true + (conj update-block-tx))))) + +(defn- get-property-value-schema + "Gets a malli schema to validate the property value for the given property type and builds + it with additional args like datascript db" + [db property-type property & {:keys [new-closed-value?] + :or {new-closed-value? false}}] + (let [property-val-schema (or (get db-property-type/built-in-validation-schemas property-type) + (throw (ex-info (str "No validation for property type " (pr-str property-type)) {}))) + [schema-opts schema-fn] (if (vector? property-val-schema) + (rest property-val-schema) + [{} property-val-schema])] + [:fn + schema-opts + (fn property-value-schema [property-val] + (db-malli-schema/validate-property-value db schema-fn [property property-val] {:new-closed-value? new-closed-value?}))])) + +(defn- fail-parse-double + [v-str] + (let [result (parse-double v-str)] + (or result + (throw (ex-info (str "Can't convert \"" v-str "\" to a number") + {:type :notification + :payload {:message (str "Can't convert \"" v-str "\" to a number.") + :i18n-key :property.validation/cant-convert-to-number + :i18n-args [v-str] + :type :error}}))))) + +(defn ^:api convert-property-input-string + [block-type property v-str] + (let [schema-type (:logseq.property/type property)] + (if (and (or (= :number schema-type) + (and (= (:db/ident property) :logseq.property/default-value) + (= :number block-type))) + (string? v-str)) + (fail-parse-double v-str) + v-str))) + +(defn- update-datascript-schema + "Updates property type and cardinality" + [property schema] + (let [new-type (:logseq.property/type schema) + cardinality (:db/cardinality schema) + ident (:db/ident property) + cardinality (if (#{:many :db.cardinality/many} cardinality) + :db.cardinality/many + :db.cardinality/one) + old-type (:logseq.property/type property) + old-ref-type? (db-property-type/user-ref-property-types old-type) + ref-type? (db-property-type/user-ref-property-types new-type)] + (cond-> [(cond-> + (outliner-core/block-with-updated-at + {:db/ident ident + :db/cardinality cardinality}) + ref-type? + (assoc :db/valueType :db.type/ref))] + (and new-type old-ref-type? (not ref-type?)) + (conj [:db/retract (:db/id property) :db/valueType])))) + +(defn- validate-property-name-update + [conn property property-name] + (when (and (some? property-name) (not= property-name (:block/title property))) + (outliner-validate/validate-page-title property-name {:node property}) + (outliner-validate/validate-page-title-characters property-name {:node property}) + (outliner-validate/validate-block-title @conn property-name property) + (outliner-validate/validate-property-title property-name))) + +(defn- update-property + [conn db-ident property schema {:keys [property-name properties]}] + (validate-property-name-update conn property property-name) + (outliner-validate/validate-editing-built-in-property property schema) + (let [changed-property-attrs + ;; Only update property if something has changed as we are updating a timestamp + (cond-> (->> (dissoc schema :db/cardinality) + (keep (fn [[k v]] + (when-not (= (get property k) v) + [k v]))) + (into {})) + (and (some? property-name) (not= property-name (:block/title property))) + (assoc :block/title property-name + :block/name (common-util/page-name-sanity-lc property-name))) + property-tx-data + (cond-> [] + (seq changed-property-attrs) + (conj (outliner-core/block-with-updated-at + (merge {:db/ident db-ident} + changed-property-attrs))) + (and (seq schema) + (or (not= (:logseq.property/type schema) (:logseq.property/type property)) + (and (:db/cardinality schema) (not= (:db/cardinality schema) (keyword (name (:db/cardinality property))))) + (and (= :default (:logseq.property/type schema)) (not= :db.type/ref (:db/valueType property))) + (seq (entity-plus/lookup-kv-then-entity property :property/closed-values)))) + (concat (update-datascript-schema property schema))) + tx-data (concat property-tx-data + (when (seq properties) + (mapcat + (fn [[property-id v]] + (build-property-value-tx-data conn property property-id v)) properties))) + many->one? (and (db-property/many? property) + ;; For UI calls, :db/cardinality can have :one and :many values + (contains? #{:one :db.cardinality/one} (:db/cardinality schema)))] + (when (and many->one? (seq (d/datoms @conn :avet db-ident))) + (throw (ex-info "Disallowed many to one conversion" + {:type :notification + :payload {:message "This property can't change from multiple values to one value because it has existing data." + :i18n-key :property.validation/many-to-one + :type :warning}}))) + (when (and (contains? changed-property-attrs :logseq.property/type) + (seq (d/datoms @conn :avet db-ident))) + (throw (ex-info "Disallowed type change with existing data" + {:type :notification + :payload {:message "This property's type can't be changed because it has existing data." + :type :error}}))) + (when (seq tx-data) + (ldb/transact! conn tx-data {:outliner-op :update-property + :property-id (:db/id property)})) + property)) + +(defn- validate-property-value-aux + [schema value {:keys [many?]}] + ;; normalize :many values since most components update them as a single value + (let [value' (if (and many? (not (sequential? value))) + #{value} + value)] + (me/humanize (mu/explain-data schema value')))) + +(defn validate-property-value + [db property value] + (let [property-type (:logseq.property/type property) + many? (= :db.cardinality/many (:db/cardinality property)) + schema (get-property-value-schema db property-type property)] + (validate-property-value-aux schema value {:many? many?}))) + +(defn- validate! + "Validates `data` against `schema`. + Throws an ex-info with readable message if validation fails." + [property schema value] + (when-not (and + (= :db.type/ref (:db/valueType property)) + (= value :logseq.property/empty-placeholder)) + (when-not (m/validate schema value) + (let [errors (-> (m/explain schema value) + (me/humanize)) + error-msg (str "Property validation failed: \"" (:block/title property) "\" " (if (coll? errors) (first errors) errors))] + (throw + (ex-info "Schema validation failed" + {:type :notification + :payload {:message error-msg + :i18n-key :property.validation/invalid-value + :i18n-args [(:block/title property) (if (coll? errors) (first errors) errors)] + :type :warning} + :property (:db/ident property) + :value value + :errors errors})))))) + +(defn- throw-error-if-invalid-property-value + [db property value] + (let [property-type (:logseq.property/type property) + many? (= :db.cardinality/many (:db/cardinality property)) + schema (get-property-value-schema db property-type property) + value' (if (and many? (not (sequential? value))) + #{value} + value)] + (validate! property schema value'))) + +(defn- throw-error-if-invalid-new-property-value + [db property value] + (let [property-type (:logseq.property/type property) + many? (= :db.cardinality/many (:db/cardinality property)) + schema (get-property-value-schema db property-type property :new-closed-value? true) + value' (if (and many? (not (or (sequential? value) (set? value)))) + #{value} + value)] + (validate! property schema value'))) + +(defn- ->eid + [id] + (if (uuid? id) [:block/uuid id] id)) + +(defn- raw-set-block-property! + "Adds the raw property pair (value not modified) to the given block if the property value is valid" + [conn block property new-value] + (throw-error-if-read-only-property (:db/ident property)) + (throw-error-if-invalid-property-value @conn property new-value) + (let [property-id (:db/ident property) + tx-data (build-property-value-tx-data conn block property-id new-value)] + (ldb/transact! conn tx-data {:outliner-op :save-block}))) + +(defn create-property-text-block! + "Creates a property value block for the given property and value. + + Adds it to `block-id` unless `:set-block-property?` is false." + [conn block-id property-id value {:keys [new-block-id set-block-property?] + :or {set-block-property? true}}] + (let [property (d/entity @conn property-id) + block (when block-id (d/entity @conn block-id)) + _ (assert (some? property) (str "Property " property-id " doesn't exist yet")) + value' (convert-property-input-string (:logseq.property/type block) + property value) + _ (when (and (not= (:logseq.property/type property) :number) + (not (string? value'))) + (throw (ex-info "value should be a string" {:block-id block-id + :property-id property-id + :value value'}))) + new-value-block (cond-> (db-property-build/build-property-value-block (or block property) property value') + new-block-id + (assoc :block/uuid new-block-id))] + (ldb/batch-transact-with-temp-conn! + conn + {:outliner-op :create-property-text-block} + (fn [conn] + (ldb/transact! conn [new-value-block] {:outliner-op :insert-blocks}) + (let [property-id (:db/ident property)] + (when (and property-id block set-block-property?) + (when-let [block-id (:db/id (d/entity @conn [:block/uuid (:block/uuid new-value-block)]))] + (raw-set-block-property! conn block property block-id)))))) + (:block/uuid new-value-block))) + +(defn- get-property-value-eid + [db property-id raw-value] + (if (= property-id :block/tags) + (first + (d/q '[:find [?v ...] + :in $ ?title + :where + [?v :block/title ?title] + [?v :block/tags :logseq.class/Tag]] + db + raw-value)) + (first + (d/q '[:find [?v ...] + :in $ ?property-id ?raw-value + :where + [?b ?property-id ?v] + (or [?v :block/title ?raw-value] + [?v :logseq.property/value ?raw-value])] + db + property-id + raw-value)))) + +(defn- find-or-create-property-value + "Find or create a property value. Only to be used with properties that have ref types" + [conn property-id v block-id] + (let [property (d/entity @conn property-id) + closed-values? (seq (entity-plus/lookup-kv-then-entity property :property/closed-values)) + default-or-url? (contains? #{:default :url} (:logseq.property/type property))] + (cond + closed-values? + (some (fn [item] + (when (or (= (:block/title item) v) + (= (:logseq.property/value item) v)) + (:db/id item))) (:block/_closed-value-property property)) + + (and default-or-url? + ;; FIXME: remove this when :logseq.property/order-list-type updated to closed values + (not= property-id :logseq.property/order-list-type)) + (let [_ (throw-error-if-invalid-new-property-value @conn property v) + v-uuid (create-property-text-block! conn block-id property-id v {:set-block-property? false})] + (:db/id (d/entity @conn [:block/uuid v-uuid]))) + + :else + (or (get-property-value-eid @conn property-id v) + (let [v-uuid (create-property-text-block! conn nil property-id v {})] + (:db/id (d/entity @conn [:block/uuid v-uuid]))))))) + +(defn- convert-ref-property-value + "Converts a ref property's value whether it's an integer or a string. Creates + a property ref value for a string value if necessary" + [conn property-id v property-type block-id] + (let [number-property? (= property-type :number)] + (cond + (and (qualified-keyword? v) (not= :keyword property-type)) + (db-ident->eid @conn v) + + (and (coll? v) + (every? integer? v) + (not number-property?)) + v + + (and (integer? v) + (or (not number-property?) + ;; Allows :number property to use number as a ref (for closed value) or value + (and number-property? + (= property-id (:db/ident (:logseq.property/created-from-property (d/entity @conn v))))))) + v + + (= property-type :page) + (let [error-data {:property-id property-id + :property-type property-type + :v v}] + (if (or (string/blank? v) (not (string? v))) + (throw (ex-info "Value should be non-empty string" error-data)) + (let [page (ldb/get-page @conn v)] + (if (entity-util/page? page) + (:db/id page) + (let [[_ page-uuid] (outliner-page/create! conn v error-data)] + (if-not page-uuid + (throw (ex-info "Failed to create page" {})) + (:db/id (d/entity @conn [:block/uuid page-uuid])))))))) + + :else + ;; only value-ref-property types should call this + (when-let [v' (if (and number-property? (string? v)) + (parse-double v) + v)] + (find-or-create-property-value conn property-id v' block-id))))) + +(defn- convert-ref-property-values + [conn property-id value property-type {:keys [many? block-id]}] + (if-not (and many? (or (sequential? value) (set? value))) + (convert-ref-property-value conn property-id value property-type block-id) + (try + (mapv #(convert-ref-property-value conn property-id % property-type block-id) value) + (catch :default e + (throw (ex-info "Failed to convert many property values" + (merge + {:property-id property-id + :property-type property-type + :value value + :many? many? + :value-shape (cond + (vector? value) :vector + (set? value) :set + (sequential? value) :seq + :else :single)} + (ex-data e)) + e)))))) + +(defn- throw-error-if-self-value + [block value ref?] + (let [values (if (or (sequential? value) (set? value)) value [value])] + (when (and ref? + (some #(= % (:db/id block)) values)) + (throw (ex-info "Can't set this block itself as own property value" + {:type :notification + :payload {:message "Can't set this block itself as own property value." + :i18n-key :property.validation/cant-set-self-value + :type :error}}))))) + +(defn- remove-status! + [conn block-ids {:keys [preserve-task-tag?]} tx-meta] + (let [blocks (keep #(d/entity @conn (->eid %)) block-ids) + task-properties (:logseq.property.class/properties + (d/entity @conn :logseq.class/Task)) + other-task-properties (disj (set (map :db/ident task-properties)) + :logseq.property/status)] + (validate-batch-deletion-of-property blocks :logseq.property/status) + (when (seq blocks) + (let [txs (mapcat + (fn [block] + (let [status (:logseq.property/status block) + direct-status? (contains? (d/pull @conn + [:logseq.property/status] + (:db/id block)) + :logseq.property/status) + empty-placeholder? (and direct-status? + (= :logseq.property/empty-placeholder + (:db/ident status))) + task? (some #(= :logseq.class/Task (:db/ident %)) + (:block/tags block)) + status-provided? (or task? + (block-classes-provide-property? + @conn block :logseq.property/status)) + remove-task? (and task? + (not empty-placeholder?) + (not preserve-task-tag?) + (not-any? #(get block %) other-task-properties))] + (cond + empty-placeholder? + [] + + remove-task? + [[:db/retract (:db/id block) :logseq.property/status] + [:db/retract (:db/id block) :block/tags :logseq.class/Task]] + + status-provided? + [{:db/id (:db/id block) + :logseq.property/status :logseq.property/empty-placeholder}] + + direct-status? + [[:db/retract (:db/id block) :logseq.property/status]] + + :else + []))) + blocks)] + (ldb/transact! conn txs tx-meta))))) + +(defn batch-remove-property! + ([conn block-ids property-id] + (batch-remove-property! conn block-ids property-id {})) + ([conn block-ids property-id opts] + (throw-error-if-read-only-property property-id) + (if (= :logseq.property/status property-id) + (remove-status! conn block-ids opts {:outliner-op :batch-remove-property}) + (let [block-eids (map ->eid block-ids) + blocks (keep (fn [id] (d/entity @conn id)) block-eids) + block-id-set (set (map :db/id blocks))] + (validate-batch-deletion-of-property blocks property-id) + (when (seq blocks) + (when-let [property (d/entity @conn property-id)] + (let [txs (mapcat + (fn [block] + (let [value (get block property-id) + entities (cond + (de/entity? value) [value] + (and (sequential? value) (every? de/entity? value)) value + :else nil) + deleting-entities (filter + (fn [value] + (let [value-referrers* + (d/q '[:find [?e ...] + :in $ ?property-id ?value-id + :where + [?e ?property-id ?value-id]] + @conn + (:db/ident property) + (:db/id value)) + value-referrers + (cond + (nil? value-referrers*) + #{} + + (coll? value-referrers*) + (set value-referrers*) + + :else + #{value-referrers*})] + (and + (:logseq.property/created-from-property value) + (not (or (entity-util/page? value) (ldb/closed-value? value))) + (empty? (set/difference value-referrers block-id-set))))) + entities) + retract-blocks-tx (when (seq deleting-entities) + (:tx-data (outliner-core/delete-blocks @conn deleting-entities {})))] + (concat + [[:db/retract (:db/id block) (:db/ident property)]] + retract-blocks-tx))) + blocks)] + (when (seq txs) + (ldb/transact! conn txs + {:outliner-op :batch-remove-property}))))))))) + +(defn- validate-batch-set-property + [conn block-eids property-id v] + (outliner-validate/disallow-editing-private-built-in-nodes (mapv #(d/entity @conn %) block-eids)) + (when (= property-id :block/tags) + (outliner-validate/validate-tags-property @conn block-eids v)) + (when (= property-id :logseq.property.class/extends) + (doseq [parent-id (->entity-ids @conn v)] + (outliner-validate/validate-extends-property + @conn + (d/entity @conn parent-id) + (map #(d/entity @conn %) block-eids))))) + +(defn- normalize-default-url-property-value + [conn property value] + (if (number? value) + (do + (throw-error-if-invalid-property-value @conn property value) + (:block/title (d/entity @conn value))) + value)) + +(defn- normalize-and-validate-default-url-property-value + [conn property value] + (let [value' (normalize-default-url-property-value conn property value)] + (when-not (qualified-keyword? value') + (throw-error-if-invalid-new-property-value @conn property value')) + value')) + +(defn- normalize-and-validate-default-url-property-values + [conn property value many?] + (if (and many? (or (sequential? value) (set? value))) + (mapv #(normalize-and-validate-default-url-property-value conn property %) value) + (normalize-and-validate-default-url-property-value conn property value))) + +(defn- throw-error-if-invalid-alias + "Validate alias ownership invariants when adding a :block/alias value." + [db source-block alias-id] + (when (= alias-id (:db/id source-block)) + (throw (ex-info "Alias can't be the page itself" + {:type :notification + :payload {:type :error + :i18n-key :page.validation/alias-self + :message "Alias can't be the page itself."}}))) + (let [alias-entity (d/entity db alias-id)] + (when-let [existing-owner (first (:block/_alias alias-entity))] + (when (not= (:db/id existing-owner) (:db/id source-block)) + (throw (ex-info "Page is already an alias of another page" + {:type :notification + :payload {:type :error + :i18n-key :page.validation/alias-duplicate-owner + :message "This page is already an alias of another page."}})))) + (when (seq (:block/alias alias-entity)) + (throw (ex-info "A page that has aliases can't be used as an alias" + {:type :notification + :payload {:type :error + :i18n-key :page.validation/alias-owns-aliases + :message "A page that has aliases can't be used as an alias."}})))) + (when (seq (:block/_alias (d/entity db (:db/id source-block)))) + (throw (ex-info "A page that is an alias of another page can't have its own aliases" + {:type :notification + :payload {:type :error + :i18n-key :page.validation/alias-source-is-alias + :message "A page that is an alias of another page can't have its own aliases."}})))) + +(defn- throw-error-if-batch-alias-targets + [block-eids property-id] + (when (and (= property-id :block/alias) + (> (count block-eids) 1)) + (throw (ex-info "Aliases can't be batch-set on multiple pages" + {:type :notification + :payload {:type :error + :i18n-key :page.validation/alias-batch-multiple-owners + :message "Aliases can't be batch-set on multiple pages."}})))) + +(defn ^:large-vars/cleanup-todo batch-set-property! + "Sets properties for multiple blocks. Automatically handles property value refs. + Does no validation of property values. For :many properties, passing a collection + replaces existing values in one call, while passing a scalar preserves add-single-value behavior." + ([conn block-ids property-id v] + (batch-set-property! conn block-ids property-id v {})) + ([conn block-ids property-id v options] + (assert property-id "property-id is nil") + (throw-error-if-read-only-property property-id) + (if (nil? v) + (batch-remove-property! conn block-ids property-id options) + (let [block-eids (map ->eid block-ids) + _ (throw-error-if-batch-alias-targets block-eids property-id) + _ (validate-batch-set-property conn block-eids property-id v) + property (d/entity @conn property-id) + _ (when (nil? property) + (throw (ex-info (str "Property " property-id " doesn't exist yet") {:property-id property-id}))) + property-type (get property :logseq.property/type :default) + many? (= :db.cardinality/many (:db/cardinality property)) + entity-id? (and (:entity-id? options) (number? v)) + ref? (contains? db-property-type/all-ref-property-types property-type) + extends? (= property-id :logseq.property.class/extends) + default-url-not-closed? (and (contains? #{:default :url} property-type) + (not extends?) + (not (seq (entity-plus/lookup-kv-then-entity property :property/closed-values)))) + v' (cond + extends? + (normalize-extends-value @conn v) + + (and ref? (not entity-id?)) + (if default-url-not-closed? + (normalize-and-validate-default-url-property-values conn property v many?) + (convert-ref-property-values conn property-id v property-type {:many? many?})) + + :else + v) + _ (when (nil? v') + (throw (ex-info "Property value must be not nil" {:v v}))) + txs (doall + (mapcat + (fn [eid] + (if-let [block (d/entity @conn eid)] + (let [v' (if (and default-url-not-closed? + (not (and (keyword? v) entity-id?))) + (convert-ref-property-values conn property-id v' property-type + {:many? many? + :block-id (:db/id block)}) + v')] + (throw-error-if-self-value block v' ref?) + (throw-error-if-invalid-property-value @conn property v') + (when (= property-id :block/alias) + (doseq [alias-id (if (coll? v') v' [v'])] + (when (number? alias-id) + (throw-error-if-invalid-alias @conn block alias-id)))) + (build-property-value-tx-data conn block property-id v')) + (js/console.error "Skipping setting a block's property because the block id could not be found:" eid))) + block-eids))] + (when (seq txs) + (ldb/transact! conn txs {:outliner-op :batch-set-property})))))) + +(defn remove-block-property! + [conn eid property-id] + (throw-error-if-read-only-property property-id) + (let [eid (->eid eid) + block (d/entity @conn eid) + property (d/entity @conn property-id) + tx-meta {:outliner-op :remove-block-property}] + (when-not (contains? #{:logseq.property.class/extends :logseq.property/status} property-id) + (validate-batch-deletion-of-property [block] property-id)) + (when block + (cond + (= :logseq.property/empty-placeholder (:db/ident (get block property-id))) + nil + + (= :logseq.property/status property-id) + (remove-status! conn [eid] {} tx-meta) + + (and (:logseq.property/default-value property) + (= (:logseq.property/default-value property) (get block property-id))) + (ldb/transact! conn + [{:db/id (:db/id block) + property-id :logseq.property/empty-placeholder}] + tx-meta) + + (and (ldb/class? block) (= property-id :logseq.property.class/extends)) + (ldb/transact! conn + [[:db/retract (:db/id block) :logseq.property.class/extends] + [:db/add (:db/id block) :logseq.property.class/extends :logseq.class/Root]] + tx-meta) + + (contains? db-property/db-attribute-properties property-id) + (ldb/transact! conn + [[:db/retract (:db/id block) property-id]] + tx-meta) + + :else + (batch-remove-property! conn [eid] property-id))))) + +(defn- set-block-db-attribute! + [conn block property property-id raw-v tx-v] + (let [validation-db @conn + validation-v (if (and (= :db.type/ref (:db/valueType property)) + (some? tx-v)) + tx-v + raw-v)] + (throw-error-if-invalid-property-value validation-db property validation-v) + (when (= property-id :block/alias) + (doseq [alias-id (if (coll? tx-v) tx-v [tx-v])] + (throw-error-if-invalid-alias validation-db block alias-id))) + (let [tx-data (cond-> + [{:db/id (:db/id block) property-id tx-v}] + (= property-id :logseq.property.class/extends) + (conj [:db/retract (:db/id block) :logseq.property.class/extends :logseq.class/Root]))] + (ldb/transact! conn tx-data + {:outliner-op :save-block})))) + +(defn ^:large-vars/cleanup-todo set-block-property! + "Updates a block property's value for an existing property-id and block. If + property is a ref type, automatically handles a raw property value i.e. you + can pass \"value\" instead of the property value entity. Also handle db + attributes as properties" + [conn block-eid property-id v] + (ldb/batch-transact-with-temp-conn! + conn + {:outliner-op :set-block-property} + (fn [conn] + (throw-error-if-read-only-property property-id) + (let [block-eid (->eid block-eid) + _ (assert (qualified-keyword? property-id) "property-id should be a keyword") + block (d/entity @conn block-eid) + db-attribute? (some? (db-schema/schema property-id)) + property (d/entity @conn property-id) + property-type (get property :logseq.property/type :default) + ref? (db-property-type/all-ref-property-types property-type) + extends? (= property-id :logseq.property.class/extends) + v' (cond + extends? + (normalize-extends-value @conn v) + + ref? + (convert-ref-property-value conn property-id v property-type block-eid) + + :else + v)] + (when-not (and block property) + (throw (ex-info "Set block property failed: block or property doesn't exist" + {:block-eid block-eid + :property-id property-id + :block block + :property property}))) + (if (nil? v') + (remove-block-property! conn block-eid property-id) + (do + (when (= property-id :block/tags) + (outliner-validate/validate-tags-property @conn [block-eid] v')) + (when extends? + (doseq [parent-id (->entity-ids @conn v')] + (outliner-validate/validate-extends-property @conn (d/entity @conn parent-id) [block]))) + (cond + db-attribute? + (set-block-db-attribute! conn block property property-id v v') + + :else + (let [_ (assert (some? property) (str "Property " property-id " doesn't exist yet")) + ref? (db-property-type/all-ref-property-types property-type) + existing-value (get block property-id) + many? (= :db.cardinality/many (:db/cardinality property)) + many-ref-value-ids (fn [value] + (->> (cond + (nil? value) [] + (de/entity? value) [value] + (sequential? value) value + :else [value]) + (map (fn [item] + (if (de/entity? item) + (:db/id item) + item))) + set)) + value-matches? (if ref? + (if (and many? (coll? v')) + (= (many-ref-value-ids existing-value) + (many-ref-value-ids v')) + (= existing-value v')) + (= existing-value v'))] + (throw-error-if-self-value block v' ref?) + (when-not value-matches? + (raw-set-block-property! conn block property v')))))))))) + +(defn upsert-property! + "Updates property if property-id is given. Otherwise creates a property + with the given property-id or :property-name option. When a property is created + it is ensured to have a unique :db/ident" + [conn property-id schema {:keys [property-name properties] :as opts}] + (let [db @conn + db-ident (or property-id + (try (db-property/create-user-property-ident-from-name property-name) + (catch :default e + (throw (ex-info (str e) + {:type :notification + :payload {:message "Property failed to create. Please try a different property name." + :i18n-key :property/create-error + :type :error}})))))] + (assert (qualified-keyword? db-ident)) + (when (and (contains? #{:checkbox} (:logseq.property/type schema)) + (= :db.cardinality/many (:db/cardinality schema))) + (throw (ex-info ":checkbox property doesn't allow multiple values" + {:property-id property-id + :schema schema}))) + (if-let [property (and (qualified-keyword? property-id) (d/entity db db-ident))] + (update-property conn db-ident property schema opts) + (let [k-name (or (and property-name (name property-name)) + (name property-id)) + db-ident' (db-ident/ensure-unique-db-ident @conn db-ident)] + (assert (some? k-name) + (prn "property-id: " property-id ", property-name: " property-name)) + (outliner-validate/validate-page-title k-name {:node {:db/ident db-ident'}}) + (outliner-validate/validate-page-title-characters k-name {:node {:db/ident db-ident'}}) + (outliner-validate/validate-property-title k-name {:node {:db/ident db-ident'}}) + (let [db-id (:db/id properties) + opts' (cond-> {:title k-name + :properties properties} + (integer? db-id) + (assoc :block-uuid (:block/uuid (d/entity db db-id)))) + tx-data (concat + [(sqlite-util/build-new-property db-ident' schema opts')] + ;; Convert page to property + (when db-id + [[:db/retract db-id :block/tags :logseq.class/Page]]))] + (ldb/transact! conn tx-data + {:outliner-op :upsert-property})) + (d/entity @conn db-ident'))))) + +(defn batch-delete-property-value! + "batch delete value when a property has multiple values" + [conn block-eids property-id property-value] + (let [block-eids (map ->eid block-eids)] + (ldb/batch-transact-with-temp-conn! + conn + {:outliner-op :batch-delete-property-value} + (fn [conn] + (when-let [property (d/entity @conn property-id)] + (when (and (db-property/many? property) + (not (some #(= property-id (:db/ident (d/entity @conn %))) block-eids))) + (when (= property-id :block/tags) + (outliner-validate/validate-tags-property-deletion @conn block-eids property-value)) + (if (= property-id :block/tags) + (let [tx-data (map (fn [id] [:db/retract id property-id property-value]) block-eids)] + (ldb/transact! conn tx-data {:outliner-op :save-block})) + (doseq [block-eid block-eids] + (when-let [block (d/entity @conn block-eid)] + (let [current-val (get block property-id) + fv (first current-val)] + (if (and (= 1 (count current-val)) + (or (= property-value fv) + (= property-value (:db/id fv)))) + (remove-block-property! conn (:db/id block) property-id) + (ldb/transact! conn + [[:db/retract (:db/id block) property-id property-value]] + {:outliner-op :save-block})))))))))))) + +(defn delete-property-value! + "Delete value if a property has multiple values" + [conn block-eid property-id property-value] + (batch-delete-property-value! conn [block-eid] property-id property-value)) + +(defn ^:api get-classes-parents + [tags] + (ldb/get-classes-parents tags)) + +(defn ^:api get-class-properties + [class] + (let [class-parents (get-classes-parents [class])] + (->> (mapcat (fn [class] + (:logseq.property.class/properties class)) (concat [class] class-parents)) + (common-util/distinct-by :db/id) + (ldb/sort-by-order)))) + +(defn ^:api get-block-classes + [db eid] + (let [block (d/entity db eid) + classes (->> (:block/tags block) + (sort-by :block/name) + (filter ldb/class?)) + class-parents (get-classes-parents classes)] + (->> (concat classes class-parents) + (filter (fn [class] + (seq (:logseq.property.class/properties class))))))) + +(defn ^:api get-block-classes-properties + [db eid] + (let [block (d/entity db eid) + classes (->> (:block/tags block) + (sort-by :block/name)) + class-parents (get-classes-parents classes) + all-classes (->> (concat classes class-parents) + (filter (fn [class] + (seq (:logseq.property.class/properties class))))) + all-properties (-> (mapcat (fn [class] + (:logseq.property.class/properties class)) all-classes) + distinct)] + {:classes classes + :all-classes all-classes ; block own classes + parent classes + :classes-properties all-properties})) + +(defn ^:api get-block-full-properties + "Get block's full properties including its own and classes' properties" + [db eid] + (let [block (d/entity db eid)] + (->> + (concat + (map (fn [ident] (d/entity db ident)) (keys (entity-plus/lookup-kv-then-entity block :block/properties))) + (:classes-properties (get-block-classes-properties db eid))) + (common-util/distinct-by :db/id)))) + +(defn- schema-or-tag-related-property? + [property-id] + (let [property-ns (some-> property-id namespace)] + (or (= :block/tags property-id) + (= "logseq.property.class" property-ns) + (contains? db-property/schema-properties property-id)))) + +(defn- tag-class-page? + [db block] + (or (= :logseq.class/Tag (:db/ident block)) + (and db + (ldb/class-instance? (entity-plus/entity-memoized db :logseq.class/Tag) block)))) + +(defn- bottom-position-property? + [_db _block property] + (let [property-id (:db/ident property) + property-type (:logseq.property/type property) + node-many? (and (= :node property-type) + (= :db.cardinality/many (:db/cardinality property))) + default-bottom? (and (not (contains? #{:url :asset} property-type)) + (or node-many? + (not= :default property-type) + (seq (:property/closed-values property))) + (not (schema-or-tag-related-property? property-id)))] + default-bottom?)) + +(defn- resolved-property-position + [db block property] + (let [ui-position (:logseq.property/ui-position property)] + (cond + (contains? #{:properties :block-left :block-right :block-below} ui-position) + ui-position + + (bottom-position-property? db block property) + :block-below + + :else + :properties))) + +(defn- property-with-position? + [db property-id block position {:keys [allow-empty-block-below?]}] + (when-let [property (entity-plus/entity-memoized db property-id)] + (let [property-position' (resolved-property-position db block property)] + (and + (not (false? (:logseq.property/public? property))) + (= property-position' position) + (not (and (:logseq.property/hide-empty-value property) + (nil? (get block property-id)))) + (not (:logseq.property/hide? property)) + (not (and + (= property-position' :block-below) + (nil? (get block property-id)) + (not allow-empty-block-below?) + (not (tag-class-page? db block)))))))) + +(defn property-with-other-position? + [db block property] + (not= :properties (resolved-property-position db block property))) + +(defn get-block-positioned-properties + [db eid position] + (let [block (d/entity db eid) + own-properties (:block.temp/property-keys block) + classes-properties (when-not (tag-class-page? db block) + (:classes-properties (get-block-classes-properties db eid))) + classes-properties-set (set (map :db/ident classes-properties)) + positioned-property-ids (if (tag-class-page? db block) + (distinct own-properties) + (->> classes-properties + (map :db/ident) + (concat own-properties) + distinct))] + (->> positioned-property-ids + (filter (fn [id] + (property-with-position? db id block position + {:allow-empty-block-below? + (contains? classes-properties-set id)}))) + (map #(d/entity db %)) + (ldb/sort-by-order)))) + +(defn- build-closed-value-tx + [db property resolved-value {:keys [id icon scoped-class-id]}] + (let [block (when id (d/entity db [:block/uuid id])) + block-id (or id (ldb/new-block-id)) + icon (when-not (and (string? icon) (string/blank? icon)) icon) + tx-data (if block + [(cond-> + (outliner-core/block-with-updated-at + (merge + {:block/uuid id + :block/closed-value-property (:db/id property)} + (if (db-property-type/property-value-content? (:logseq.property/type block) property) + {:logseq.property/value resolved-value} + {:block/title resolved-value}))) + icon + (assoc :logseq.property/icon icon))] + (let [max-order (:block/order (last (entity-plus/lookup-kv-then-entity property :property/closed-values))) + new-block (-> (db-property-build/build-closed-value-block block-id nil resolved-value + property {:icon icon}) + (assoc :block/order (db-order/gen-key max-order nil)))] + [new-block + (outliner-core/block-with-updated-at + {:db/id (:db/id property)})])) + tx-data' (if (and (:db/id block) (nil? icon)) + (conj tx-data [:db/retract (:db/id block) :logseq.property/icon]) + tx-data)] + (cond-> (vec tx-data') + scoped-class-id + (conj [:db/add [:block/uuid block-id] :logseq.property/choice-classes scoped-class-id])))) + +(defn upsert-closed-value! + "id should be a block UUID or nil" + [conn property-id {:keys [id value description _scoped-class-id] :as opts}] + (assert (or (nil? id) (uuid? id))) + (let [db @conn + property (d/entity db property-id) + property-type (:logseq.property/type property)] + (when (contains? db-property-type/closed-value-property-types property-type) + (let [value' (if (string? value) (string/trim value) value) + resolved-value (convert-property-input-string nil property value') + validate-message (validate-property-value-aux + (get-property-value-schema @conn property-type property {:new-closed-value? true}) + resolved-value + {:many? (db-property/many? property)})] + (cond + (some (fn [b] + (and (= (str resolved-value) (str (or (db-property/closed-value-content b) + (:block/uuid b)))) + (not= id (:block/uuid b)))) + (entity-plus/lookup-kv-then-entity property :property/closed-values)) + (throw (ex-info "Closed value choice already exists" + {:error :value-exists + :type :notification + :payload {:message "Choice already exists." + :i18n-key :property.choice/already-exists + :type :warning}})) + + validate-message + (throw (ex-info "Invalid property value" + {:error :value-invalid + :type :notification + :payload {:message (str "Invalid choice \"" value' "\" for this property: " validate-message ".") + :i18n-key :property.choice/invalid + :i18n-args [value' validate-message] + :type :warning}})) + + (nil? resolved-value) + nil + + :else + (let [tx-data (build-closed-value-tx @conn property resolved-value opts)] + (ldb/batch-transact-with-temp-conn! + conn + {:outliner-op :upsert-closed-value} + (fn [conn] + (ldb/transact! conn tx-data) + (when (seq description) + (if-let [desc-ent (and id (:logseq.property/description (d/entity db [:block/uuid id])))] + (ldb/transact! conn + [(outliner-core/block-with-updated-at {:db/id (:db/id desc-ent) + :block/title description})]) + (set-block-property! conn + [:block/uuid (or id (:block/uuid (first tx-data)))] + :logseq.property/description + description))))))))))) + +(defn add-existing-values-to-closed-values! + "Adds existing values as closed values and returns their new block uuids" + [conn property-id values] + (when-let [property (d/entity @conn property-id)] + (when (seq values) + (let [values' (remove string/blank? values)] + (assert (every? uuid? values') "existing values should all be UUIDs") + (let [values (keep #(d/entity @conn [:block/uuid %]) values')] + (when (seq values) + (let [property-db-id (:db/id property) + value-property-tx (map (fn [id] + {:db/id id + :block/closed-value-property property-db-id + :block/parent property-db-id + :block/page property-db-id}) + (map :db/id values)) + property-tx (outliner-core/block-with-updated-at {:db/id property-db-id})] + (ldb/transact! conn (cons property-tx value-property-tx) + {:outliner-op :add-existing-values-to-closed-values})))))))) + +(defn delete-closed-value! + "Returns true when deleted or if not deleted displays warning and returns false" + [conn property-id value-block-id] + (when (or (nil? property-id) + (nil? value-block-id)) + (throw (ex-info "empty property-id or value-block-id when delete-closed-value!" + {:property-id property-id + :value-block-id value-block-id}))) + (let [property (d/entity @conn property-id)] + (when-not (ldb/property? property) + (throw (ex-info "Invalid property" {:property-id property-id}))) + (when-let [value-block (d/entity @conn value-block-id)] + (if (ldb/built-in? value-block) + (throw (ex-info "The choice can't be deleted" + {:type :notification + :payload {:message "The choice can't be deleted because it's built-in." + :i18n-key :property.choice/cant-delete-built-in + :type :warning}})) + (let [tx-data (conj (:tx-data (outliner-core/delete-blocks @conn [value-block] {})) + (outliner-core/block-with-updated-at {:db/id (:db/id property)}))] + (ldb/transact! conn tx-data + {:outliner-op :delete-closed-value})))))) + +(defn class-add-property! + [conn class-id property-id] + (when-not (contains? #{:logseq.property/empty-placeholder} property-id) + (when-let [class (d/entity @conn class-id)] + (if (ldb/class? class) + (when-let [property (d/entity @conn property-id)] + (when (ldb/property? property) + (ldb/transact! conn + [[:db/add (:db/id class) :logseq.property.class/properties property-id]] + {:outliner-op :class-add-property}))) + (throw (ex-info "Can't add a property to a block that isn't a class" + {:class-id class-id :property-id property-id})))))) + +(defn class-remove-property! + [conn class-id property-id] + (when-let [class (d/entity @conn class-id)] + (when (ldb/class? class) + (when-let [property (d/entity @conn property-id)] + (when (ldb/property? property) + (when-not (ldb/built-in-class-property? class property) + (ldb/transact! conn + [[:db/retract (:db/id class) :logseq.property.class/properties property-id]] + {:outliner-op :class-remove-property}))))))) diff --git a/deps/outliner/src/logseq/outliner/recycle.cljs b/deps/outliner/src/logseq/outliner/recycle.cljs new file mode 100644 index 0000000..9424920 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/recycle.cljs @@ -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)) diff --git a/deps/outliner/src/logseq/outliner/template.cljs b/deps/outliner/src/logseq/outliner/template.cljs new file mode 100644 index 0000000..e7a8d5c --- /dev/null +++ b/deps/outliner/src/logseq/outliner/template.cljs @@ -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))) diff --git a/deps/outliner/src/logseq/outliner/transaction.cljc b/deps/outliner/src/logseq/outliner/transaction.cljc new file mode 100644 index 0000000..1e19396 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/transaction.cljc @@ -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))) diff --git a/deps/outliner/src/logseq/outliner/tree.cljs b/deps/outliner/src/logseq/outliner/tree.cljs new file mode 100644 index 0000000..b4c6041 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/tree.cljs @@ -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)))) diff --git a/deps/outliner/src/logseq/outliner/tx_meta.cljs b/deps/outliner/src/logseq/outliner/tx_meta.cljs new file mode 100644 index 0000000..a8875bf --- /dev/null +++ b/deps/outliner/src/logseq/outliner/tx_meta.cljs @@ -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]))) diff --git a/deps/outliner/src/logseq/outliner/validate.cljs b/deps/outliner/src/logseq/outliner/validate.cljs new file mode 100644 index 0000000..b537027 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/validate.cljs @@ -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}}))))) diff --git a/deps/outliner/test/logseq/outliner/core_test.cljs b/deps/outliner/test/logseq/outliner/core_test.cljs new file mode 100644 index 0000000..cd7188e --- /dev/null +++ b/deps/outliner/test/logseq/outliner/core_test.cljs @@ -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))))))))) diff --git a/deps/outliner/test/logseq/outliner/op_construct_test.cljs b/deps/outliner/test/logseq/outliner/op_construct_test.cljs new file mode 100644 index 0000000..b43b53b --- /dev/null +++ b/deps/outliner/test/logseq/outliner/op_construct_test.cljs @@ -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))))) diff --git a/deps/outliner/test/logseq/outliner/op_test.cljs b/deps/outliner/test/logseq/outliner/op_test.cljs new file mode 100644 index 0000000..681a950 --- /dev/null +++ b/deps/outliner/test/logseq/outliner/op_test.cljs @@ -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]]] + {}))))))) diff --git a/deps/outliner/test/logseq/outliner/page_test.cljs b/deps/outliner/test/logseq/outliner/page_test.cljs new file mode 100644 index 0000000..7520dca --- /dev/null +++ b/deps/outliner/test/logseq/outliner/page_test.cljs @@ -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"))) diff --git a/deps/outliner/test/logseq/outliner/pipeline_test.cljs b/deps/outliner/test/logseq/outliner/pipeline_test.cljs new file mode 100644 index 0000000..5b85efe --- /dev/null +++ b/deps/outliner/test/logseq/outliner/pipeline_test.cljs @@ -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"))) diff --git a/deps/outliner/test/logseq/outliner/property_test.cljs b/deps/outliner/test/logseq/outliner/property_test.cljs new file mode 100644 index 0000000..e55b483 --- /dev/null +++ b/deps/outliner/test/logseq/outliner/property_test.cljs @@ -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"))) diff --git a/deps/outliner/test/logseq/outliner/recycle_test.cljs b/deps/outliner/test/logseq/outliner/recycle_test.cljs new file mode 100644 index 0000000..a1eb8d1 --- /dev/null +++ b/deps/outliner/test/logseq/outliner/recycle_test.cljs @@ -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]))))) diff --git a/deps/outliner/test/logseq/outliner/tree_test.cljs b/deps/outliner/test/logseq/outliner/tree_test.cljs new file mode 100644 index 0000000..4f5ef74 --- /dev/null +++ b/deps/outliner/test/logseq/outliner/tree_test.cljs @@ -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)))))) diff --git a/deps/outliner/test/logseq/outliner/validate_test.cljs b/deps/outliner/test/logseq/outliner/validate_test.cljs new file mode 100644 index 0000000..6d0590a --- /dev/null +++ b/deps/outliner/test/logseq/outliner/validate_test.cljs @@ -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)))))))))) diff --git a/deps/publish/.carve/config.edn b/deps/publish/.carve/config.edn new file mode 100644 index 0000000..9391fcb --- /dev/null +++ b/deps/publish/.carve/config.edn @@ -0,0 +1,3 @@ +{:paths ["src"] + :api-namespaces [logseq.publish.worker] + :report {:format :ignore}} diff --git a/deps/publish/.clj-kondo/config.edn b/deps/publish/.clj-kondo/config.edn new file mode 100644 index 0000000..52ae314 --- /dev/null +++ b/deps/publish/.clj-kondo/config.edn @@ -0,0 +1,18 @@ +{:linters + {:aliased-namespace-symbol {:level :warning} + :namespace-name-mismatch {:level :warning} + :used-underscored-binding {:level :warning} + :shadowed-var {:level :warning + :exclude [meta name key keys uuid type]} + + :consistent-alias + {:aliases {clojure.pprint pprint + clojure.string string + datascript.core d + datascript.transit dt + logseq.publish.common publish-common + logseq.publish.model publish-model}}} + :lint-as {promesa.core/let clojure.core/let + shadow.cljs.modern/defclass clj-kondo.lint-as/def-catch-all} + :skip-comments true + :output {:progress true}} diff --git a/deps/publish/.gitignore b/deps/publish/.gitignore new file mode 100644 index 0000000..4c8e645 --- /dev/null +++ b/deps/publish/.gitignore @@ -0,0 +1 @@ +.clj-kondo/.cache diff --git a/deps/publish/README.md b/deps/publish/README.md new file mode 100644 index 0000000..261de57 --- /dev/null +++ b/deps/publish/README.md @@ -0,0 +1,42 @@ +## Description + +Shared library for page publish features (snapshot payloads, SSR helpers, shared schemas, and storage contracts). + +The Cloudflare Durable Object implementation is expected to use SQLite with the +Logseq datascript fork layered on top. Page publish payloads are expected to +send datoms (transit) so the DO can reconstruct/query datascript state. + +See `deps/publish/worker` for a Cloudflare Worker skeleton that stores transit +blobs in R2 and metadata in a SQLite-backed Durable Object. + +## API + +Namespaces live under `logseq.publish`. + +## Usage + +This module is intended to be consumed by the Logseq app and the publish worker. + +## Dev + +Keep this module aligned with the main repo's linting and testing conventions. +Most of the same linters are used, with configurations that are specific to this +library. See [this library's CI file](/.github/workflows/deps-publish.yml) for +linting examples. + + +### Local Testing + +For one-time setup, install the [CloudFlare cli wrangler](https://developers.cloudflare.com/workers/wrangler/) with `npm install -g wrangler@latest`. + +To test the publish features locally, follow these steps: + +* Run `pnpm watch` or `pnpm release` to build the publish worker js asset. +* Run `wrangler dev` in worker/ to start a local cloudflare worker server. +* In `frontend.config`, enable the commented out `PUBLISH-API-BASE` which points to a localhost url. +* Login on the desktop app. +* Go to any page and select `Publish` from its page menu. + +### Deploy + +When changes are ready to be deployed, run `pnpm deploy`. \ No newline at end of file diff --git a/deps/publish/bb.edn b/deps/publish/bb.edn new file mode 100644 index 0000000..2faca2f --- /dev/null +++ b/deps/publish/bb.edn @@ -0,0 +1,31 @@ +{: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 + {:metadata-exceptions #{:large-vars/cleanup-todo} + ;; AI generated code has its tradeoffs + :max-lines-count 150}}} diff --git a/deps/publish/deps.edn b/deps/publish/deps.edn new file mode 100644 index 0000000..ad32dec --- /dev/null +++ b/deps/publish/deps.edn @@ -0,0 +1,26 @@ +{:paths ["src" "test" "../../resources"] + :deps + {org.clojure/clojure {:mvn/version "1.12.4"} + io.factorhouse/hsx {:git/url "https://github.com/logseq/hsx" + :sha "21615ffa2aa530cc830e3464b793a53ef4fdd18c"} + + datascript/datascript {:git/url "https://github.com/logseq/datascript" ;; fork + :sha "3f141af97b70e1f14c65eaa119acd822ebece37e"} + datascript-transit/datascript-transit {:mvn/version "0.3.0" + :exclusions [datascript/datascript]} + funcool/promesa {:mvn/version "11.0.678"} + thheller/shadow-cljs {:mvn/version "3.4.4"} + logseq/common {:local/root "../common"} + logseq/graph-parser {:local/root "../graph-parser"} + logseq/db {:local/root "../db"} + com.cognitect/transit-cljs {:mvn/version "0.8.280"} + hiccups/hiccups {:mvn/version "0.3.0"}} + :aliases + {:cljs {:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.0"} + cider/cider-nrepl {:mvn/version "0.59.0"} + org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} + tortue/spy {:mvn/version "2.15.0"}} + :main-opts ["-m" "shadow.cljs.devtools.cli"]} + :clj-kondo + {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/publish/package.json b/deps/publish/package.json new file mode 100644 index 0000000..b518047 --- /dev/null +++ b/deps/publish/package.json @@ -0,0 +1,19 @@ +{ + "name": "@logseq/publish", + "version": "1.0.0", + "packageManager": "pnpm@10.33.0", + "private": true, + "scripts": { + "dev": "cd ./worker && pnpm exec wrangler dev", + "watch": "clojure -M:cljs watch publish-worker", + "release": "clojure -M:cljs release publish-worker", + "test": "clojure -M:cljs compile publish-test && node worker/dist/worker-test.js", + "clean": "rm -rf ./worker/dist/", + "bump-publish-version": "node ./scripts/bump-publish-version.js", + "deploy": "pnpm bump-publish-version && pnpm clean && pnpm release && cd ./worker && pnpm exec wrangler deploy --env prod" + }, + "dependencies": { + "mldoc": "^1.5.9", + "shadow-cljs": "^3.4.4" + } +} diff --git a/deps/publish/pnpm-lock.yaml b/deps/publish/pnpm-lock.yaml new file mode 100644 index 0000000..dfcd3cf --- /dev/null +++ b/deps/publish/pnpm-lock.yaml @@ -0,0 +1,530 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + mldoc: + specifier: ^1.5.9 + version: 1.5.9 + shadow-cljs: + specifier: ^3.4.4 + version: 3.4.4 + +packages: + + 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==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + 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'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + execa@1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + 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'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + 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==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + 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'} + + mldoc@1.5.9: + resolution: {integrity: sha512-87FQ7hseS87tsk+VdpIigpu8LH+GwmbbFgpxgFwvnbH5oOjmIrc47laH4Dyggzqiy8/vMjDHkl7vsId0eXhCDQ==} + hasBin: true + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + 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'} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + + 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==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + shadow-cljs-jar@1.3.4: + resolution: {integrity: sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==} + + shadow-cljs@3.4.4: + resolution: {integrity: sha512-6/Z4XVWBavdDSbAR+ECbZTnq22Gws1zy+Ilfwcdl5SMf3ZdOYiYczqykfg/rLjlU0Ap9BvX8cEZGBExK4PGDaw==} + engines: {node: '>=6.0.0'} + hasBin: true + + 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==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + 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'} + + 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'} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + 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==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + 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: + + ansi-regex@2.1.1: {} + + ansi-regex@3.0.1: {} + + base64-js@1.5.1: {} + + buffer-from@1.1.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + camelcase@5.3.1: {} + + 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: {} + + end-of-stream@1.4.5: + 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 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + get-caller-file@1.0.3: {} + + get-stream@4.1.0: + dependencies: + pump: 3.0.4 + + ieee754@1.2.1: {} + + 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: {} + + isexe@3.1.1: {} + + 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: {} + + mldoc@1.5.9: + dependencies: + yargs: 12.0.5 + + nice-try@1.0.5: {} + + 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: {} + + process@0.11.10: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + readline-sync@1.4.10: {} + + require-directory@2.1.1: {} + + require-main-filename@1.0.1: {} + + semver@5.7.2: {} + + set-blocking@2.0.0: {} + + shadow-cljs-jar@1.3.4: {} + + shadow-cljs@3.4.4: + dependencies: + buffer: 6.0.3 + process: 0.11.10 + readline-sync: 1.4.10 + shadow-cljs-jar: 1.3.4 + source-map-support: 0.5.21 + which: 5.0.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + + shebang-regex@1.0.0: {} + + signal-exit@3.0.7: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.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 + + 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: {} + + which-module@2.0.1: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.1 + + wrap-ansi@2.1.0: + dependencies: + string-width: 1.0.2 + strip-ansi: 3.0.1 + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + 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 diff --git a/deps/publish/scripts/bump-publish-version.js b/deps/publish/scripts/bump-publish-version.js new file mode 100644 index 0000000..9c60e96 --- /dev/null +++ b/deps/publish/scripts/bump-publish-version.js @@ -0,0 +1,17 @@ +const fs = require("fs"); +const path = require("path"); + +const renderPath = path.join(__dirname, "..", "src", "logseq", "publish", "render.cljs"); +const source = fs.readFileSync(renderPath, "utf8"); +const timestamp = Date.now(); + +const next = source.replace( + /\(defonce version [^)]+\)/, + `(defonce version ${timestamp})` +); + +if (next === source) { + throw new Error("Failed to update logseq.publish.render/version."); +} + +fs.writeFileSync(renderPath, next); diff --git a/deps/publish/shadow-cljs.edn b/deps/publish/shadow-cljs.edn new file mode 100644 index 0000000..c418f99 --- /dev/null +++ b/deps/publish/shadow-cljs.edn @@ -0,0 +1,17 @@ +;; shadow-cljs configuration +{:deps true + :http {:port 9631} + :nrepl {:port 8702} + :builds + {:publish-worker {:target :esm + :output-dir "worker/dist/worker" + :modules {:main {:exports {default logseq.publish.worker/worker + PublishMetaDO logseq.publish.worker/PublishMetaDO}}} + :js-options {:js-provider :import} + :closure-defines {shadow.cljs.devtools.client.env/enabled false} + :devtools {:enabled false}} + :publish-test {:target :node-test + :output-to "worker/dist/worker-test.js" + :devtools {:enabled false} + :compiler-options {:static-fns false} + :main logseq.publish.test-runner/main}}} diff --git a/deps/publish/src/logseq/publish/assets.cljs b/deps/publish/src/logseq/publish/assets.cljs new file mode 100644 index 0000000..e251b73 --- /dev/null +++ b/deps/publish/src/logseq/publish/assets.cljs @@ -0,0 +1,68 @@ +(ns logseq.publish.assets + "Handles publishing assets" + (:require [clojure.string :as string] + [logseq.common.authorization :as authorization] + [logseq.publish.common :as publish-common] + [promesa.core :as p])) + +(defn asset-content-type [ext] + (case (string/lower-case (or ext "")) + ("png") "image/png" + ("jpg" "jpeg") "image/jpeg" + ("gif") "image/gif" + ("webp") "image/webp" + ("svg") "image/svg+xml" + ("bmp") "image/bmp" + ("avif") "image/avif" + ("mp4") "video/mp4" + ("webm") "video/webm" + ("mov") "video/quicktime" + ("mp3") "audio/mpeg" + ("wav") "audio/wav" + ("ogg") "audio/ogg" + ("pdf") "application/pdf" + "application/octet-stream")) + +(defn parse-asset-meta-header [request] + (let [meta-header (.get (.-headers request) "x-asset-meta")] + (when meta-header + (try + (publish-common/normalize-meta (js/JSON.parse meta-header)) + (catch :default _ + nil))))) + +(defn handle-post-asset [request env] + (p/let [auth-header (.get (.-headers request) "authorization") + token (when (and auth-header (string/starts-with? auth-header "Bearer ")) + (subs auth-header 7)) + claims (cond + (nil? token) nil + :else (authorization/verify-jwt token env))] + (if (nil? claims) + (publish-common/unauthorized) + (let [meta (parse-asset-meta-header request) + graph-uuid (get meta :graph) + asset-uuid (get meta :asset_uuid) + asset-type (get meta :asset_type) + checksum (get meta :checksum)] + (if (or (nil? meta) (string/blank? graph-uuid) (string/blank? asset-uuid) (string/blank? asset-type)) + (publish-common/bad-request "missing asset metadata") + (p/let [body (.arrayBuffer request) + r2 (aget env "PUBLISH_R2") + r2-key (str "publish/assets/" graph-uuid "/" asset-uuid "." asset-type) + ^js existing (.head r2 r2-key) + existing-checksum (when existing + (when-let [meta (.-customMetadata existing)] + (aget meta "checksum"))) + content-type (or (get meta :content_type) + (asset-content-type asset-type)) + put? (not (and existing-checksum checksum (= existing-checksum checksum))) + _ (when put? + (.put r2 r2-key body + #js {:httpMetadata #js {:contentType content-type} + :customMetadata #js {:checksum (or checksum "") + :owner_sub (aget claims "sub")}}))] + (publish-common/json-response {:asset_uuid asset-uuid + :graph_uuid graph-uuid + :asset_type asset-type + :asset_url (str "/asset/" graph-uuid "/" asset-uuid "." asset-type)}))))))) diff --git a/deps/publish/src/logseq/publish/common.cljs b/deps/publish/src/logseq/publish/common.cljs new file mode 100644 index 0000000..8a720f6 --- /dev/null +++ b/deps/publish/src/logseq/publish/common.cljs @@ -0,0 +1,294 @@ +(ns logseq.publish.common + "Provides common util fns" + (:require [clojure.string :as string] + [cognitect.transit :as transit] + [datascript.transit :as dt] + [logseq.db :as ldb] + [promesa.core :as p])) + +(def text-decoder (js/TextDecoder.)) +(def text-encoder (js/TextEncoder.)) + +(def ^:private fallback-transit-reader + (let [handlers (assoc dt/read-handlers + "datascript/Entity" identity + "error" (fn [m] (ex-info (:message m) (:data m))) + "js/Error" (fn [m] (js/Error. (:message m)))) + reader (transit/reader :json {:handlers handlers})] + (fn [s] + (transit/read reader s)))) + +(defn read-transit-safe [s] + (try + (ldb/read-transit-str s) + (catch :default _ + (fallback-transit-reader s)))) + +(defn cors-headers + [] + #js {"access-control-allow-origin" "*" + "access-control-allow-methods" "GET,POST,DELETE,OPTIONS" + "access-control-allow-headers" "content-type,authorization,x-publish-meta,x-asset-meta,if-none-match" + "access-control-expose-headers" "etag"}) + +(defn merge-headers [base extra] + (let [headers (js/Headers. base)] + (doseq [[k v] (js/Object.entries extra)] + (.set headers k v)) + headers)) + +(defn json-response + ([data] (json-response data 200)) + ([data status] + (js/Response. + (js/JSON.stringify (clj->js data)) + #js {:status status + :headers (merge-headers + #js {"content-type" "application/json"} + (cors-headers))}))) + +(defn unauthorized [] + (json-response {:error "unauthorized"} 401)) + +(defn forbidden [] + (json-response {:error "forbidden"} 403)) + +(defn bad-request [message] + (json-response {:error message} 400)) + +(defn not-found [] + (json-response {:error "not found"} 404)) + +(defn normalize-meta [meta] + (when meta + (if (map? meta) + meta + (js->clj meta :keywordize-keys true)))) + +(defn parse-meta-header [request] + (let [meta-header (.get (.-headers request) "x-publish-meta")] + (when meta-header + (try + (normalize-meta (js/JSON.parse meta-header)) + (catch :default _ + nil))))) + +(defn get-publish-meta [payload] + (when payload + (:meta payload))) + +(defn meta-from-body [buffer] + (try + (let [payload (read-transit-safe (.decode text-decoder buffer)) + meta (get-publish-meta payload)] + (normalize-meta meta)) + (catch :default e + (js/console.warn "publish: failed to parse meta from body" e) + nil))) + +(defn valid-meta? [{:keys [content_hash graph page_uuid]}] + (and content_hash graph page_uuid)) + +(defn get-sql-rows [^js result] + (let [iter-fn (when result (aget result js/Symbol.iterator))] + (cond + (nil? result) [] + (fn? (.-toArray result)) (.toArray result) + (fn? iter-fn) (vec (js/Array.from result)) + (array? (.-results result)) (.-results result) + (array? (.-rows result)) (.-rows result) + (array? result) (if (empty? result) + [] + (let [first-row (first result)] + (cond + (array? (.-results first-row)) (.-results first-row) + (array? (.-rows first-row)) (.-rows first-row) + :else result))) + :else []))) + +(defn sql-exec + [sql sql-str & args] + (.apply (.-exec sql) sql (to-array (cons sql-str args)))) + +(defn to-hex [buffer] + (->> (js/Uint8Array. buffer) + (array-seq) + (map (fn [b] (.padStart (.toString b 16) 2 "0"))) + (apply str))) + +(defn sha256-hex [message] + (p/let [data (.encode text-encoder message) + digest (.digest js/crypto.subtle "SHA-256" data)] + (to-hex digest))) + +(def password-kdf-max-iterations 90000) +(def password-kdf-iterations 90000) + +(defn bytes->base64url [data] + (let [binary (apply str (map #(js/String.fromCharCode %) (array-seq data))) + b64 (js/btoa binary)] + (-> b64 + (string/replace #"\+" "-") + (string/replace #"/" "_") + (string/replace #"=+$" "")))) + +(defn hash-password [password] + (p/let [salt (doto (js/Uint8Array. 16) + (js/crypto.getRandomValues)) + crypto-key (.importKey js/crypto.subtle + "raw" + (.encode text-encoder password) + #js {:name "PBKDF2"} + false + #js ["deriveBits"]) + iterations (min password-kdf-iterations password-kdf-max-iterations) + derived (.deriveBits js/crypto.subtle + #js {:name "PBKDF2" + :hash "SHA-256" + :salt salt + :iterations iterations} + crypto-key + 256) + derived-bytes (js/Uint8Array. derived) + salt-encoded (bytes->base64url salt) + hash-encoded (bytes->base64url derived-bytes)] + (str "pbkdf2$sha256$" + iterations + "$" + salt-encoded + "$" + hash-encoded))) + +(defn base64url->uint8array [input] + (let [pad (if (pos? (mod (count input) 4)) + (apply str (repeat (- 4 (mod (count input) 4)) "=")) + "") + base64 (-> (str input pad) + (string/replace "-" "+") + (string/replace "_" "/")) + raw (js/atob base64) + data (js/Uint8Array. (.-length raw))] + (dotimes [i (.-length raw)] + (aset data i (.charCodeAt raw i))) + data)) + +(defn verify-password [password stored-hash] + (let [parts (when (string? stored-hash) + (string/split stored-hash #"\$"))] + (if-not (and (= 5 (count parts)) + (= "pbkdf2" (nth parts 0)) + (= "sha256" (nth parts 1))) + false + (p/let [iterations (js/parseInt (nth parts 2))] + (if (> iterations password-kdf-max-iterations) + false + (p/let [salt (base64url->uint8array (nth parts 3)) + expected (base64url->uint8array (nth parts 4)) + crypto-key (.importKey js/crypto.subtle + "raw" + (.encode text-encoder password) + #js {:name "PBKDF2"} + false + #js ["deriveBits"]) + derived (.deriveBits js/crypto.subtle + #js {:name "PBKDF2" + :hash "SHA-256" + :salt salt + :iterations iterations} + crypto-key + (* 8 (.-length expected))) + derived-bytes (js/Uint8Array. derived)] + (if (not= (.-length derived-bytes) (.-length expected)) + false + (let [mismatch (reduce (fn [acc idx] + (bit-or acc + (bit-xor (aget derived-bytes idx) + (aget expected idx)))) + 0 + (range (.-length expected)))] + (zero? mismatch))))))))) + +(defn hmac-sha256 [key message] + (p/let [crypto-key (.importKey js/crypto.subtle + "raw" + key + #js {:name "HMAC" :hash "SHA-256"} + false + #js ["sign"])] + (.sign js/crypto.subtle "HMAC" crypto-key message))) + +(defn encode-rfc3986 [value] + (-> (js/encodeURIComponent value) + (.replace #"[!'()*]" (fn [c] + (str "%" + (.toUpperCase (.toString (.charCodeAt c 0) 16))))))) + +(defn encode-path [path] + (->> (string/split path #"/") + (map encode-rfc3986) + (string/join "/"))) + +(defn get-signature-key [secret date-stamp region service] + (p/let [k-date (hmac-sha256 + (.encode text-encoder (str "AWS4" secret)) + (.encode text-encoder date-stamp)) + k-region (hmac-sha256 k-date (.encode text-encoder region)) + k-service (hmac-sha256 k-region (.encode text-encoder service))] + (hmac-sha256 k-service (.encode text-encoder "aws4_request")))) + +(defn presign-r2-url [r2-key env] + (p/let [region "auto" + service "s3" + host (str (aget env "R2_ACCOUNT_ID") ".r2.cloudflarestorage.com") + bucket (aget env "R2_BUCKET") + method "GET" + now (js/Date.) + amz-date (.replace (.toISOString now) #"[ :-]|\.\d{3}" "") + date-stamp (.slice amz-date 0 8) + credential-scope (str date-stamp "/" region "/" service "/aws4_request") + params (->> [["X-Amz-Algorithm" "AWS4-HMAC-SHA256"] + ["X-Amz-Credential" (str (aget env "R2_ACCESS_KEY_ID") "/" credential-scope)] + ["X-Amz-Date" amz-date] + ["X-Amz-Expires" "300"] + ["X-Amz-SignedHeaders" "host"]] + (sort-by first)) + canonical-query (->> params + (map (fn [[k v]] + (str (encode-rfc3986 k) "=" (encode-rfc3986 v)))) + (string/join "&")) + canonical-uri (str "/" bucket "/" (encode-path r2-key)) + canonical-headers (str "host:" host "\n") + signed-headers "host" + payload-hash "UNSIGNED-PAYLOAD" + canonical-request (string/join "\n" + [method + canonical-uri + canonical-query + canonical-headers + signed-headers + payload-hash]) + canonical-hash (sha256-hex canonical-request) + string-to-sign (string/join "\n" + ["AWS4-HMAC-SHA256" + amz-date + credential-scope + canonical-hash]) + signing-key (get-signature-key (aget env "R2_SECRET_ACCESS_KEY") + date-stamp + region + service) + raw-signature (hmac-sha256 signing-key (.encode text-encoder string-to-sign)) + signature (to-hex raw-signature) + signed-query (str canonical-query "&X-Amz-Signature=" signature)] + (str "https://" host canonical-uri "?" signed-query))) + +(defn normalize-etag [etag] + (when etag + (string/replace etag #"\"" ""))) + +(defn short-id-for-page [graph-uuid page-uuid] + (p/let [payload (.encode text-encoder (str graph-uuid ":" page-uuid)) + digest (.digest js/crypto.subtle "SHA-256" payload)] + (let [data (js/Uint8Array. digest) + encoded (bytes->base64url data)] + (subs encoded 0 10)))) diff --git a/deps/publish/src/logseq/publish/index.cljs b/deps/publish/src/logseq/publish/index.cljs new file mode 100644 index 0000000..3dbc8c2 --- /dev/null +++ b/deps/publish/src/logseq/publish/index.cljs @@ -0,0 +1,93 @@ +(ns logseq.publish.index + "Builds indices from payloads for features like tagged nodes" + (:require [clojure.string :as string] + [logseq.publish.model :as publish-model])) + +(defn page-refs-from-payload [payload page-eid page-uuid page-title graph-uuid] + (let [entities (publish-model/datoms->entities (:datoms payload)) + refs (->> entities + (mapcat (fn [[_e entity]] + (when (and (= (:block/page entity) page-eid) + (not= (:block/uuid entity) page-uuid)) + (let [block-uuid (some-> (:block/uuid entity) str) + block-content (or (:block/title entity) + (:block/name entity) + "") + block-format (name (or (:block/format entity) :markdown)) + refs (:block/refs entity) + refs (if (sequential? refs) refs (when refs [refs])) + targets (->> refs + (map publish-model/ref-eid) + (keep #(get entities %)) + distinct)] + (when (seq targets) + (map (fn [target-entity] + (let [target-uuid (some-> (:block/uuid target-entity) str) + target-title (publish-model/entity->title target-entity) + target-name (or (:block/name target-entity) + target-title) + target-name (when target-name + (string/lower-case (str target-name)))] + {:graph_uuid graph-uuid + :target_page_uuid target-uuid + :target_page_title target-title + :target_page_name target-name + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid block-uuid + :source_block_content block-content + :source_block_format block-format + :updated_at (.now js/Date)})) + targets)))))))] + (vec refs))) + +(defn page-tagged-nodes-from-payload [payload page-eid page-uuid page-title graph-uuid] + (let [entities (publish-model/datoms->entities (:datoms payload)) + normalize-tags (fn [tags] + (let [tags (if (sequential? tags) tags (when tags [tags]))] + (->> tags + (map publish-model/ref-eid) + (keep #(get entities %)) + (keep (fn [entity] + (when-let [uuid (:block/uuid entity)] + {:tag_page_uuid (str uuid) + :tag_title (publish-model/entity->title entity)}))) + distinct))) + page-entity (get entities page-eid) + page-tags (normalize-tags (:block/tags page-entity)) + page-entries (when (seq page-tags) + (map (fn [tag] + {:graph_uuid graph-uuid + :tag_page_uuid (:tag_page_uuid tag) + :tag_title (:tag_title tag) + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid (str page-uuid) + :source_block_content nil + :source_block_format "page" + :updated_at (.now js/Date)}) + page-tags)) + block-entries (mapcat (fn [[_e entity]] + (when (and (= (:block/page entity) page-eid) + (not= (:block/uuid entity) page-uuid) + (not (:logseq.property/created-from-property entity))) + (let [block-uuid (some-> (:block/uuid entity) str) + block-content (or (:block/title entity) + (:block/name entity) + "") + block-format (name (or (:block/format entity) :markdown)) + tags (normalize-tags (:block/tags entity))] + (when (seq tags) + (map (fn [tag] + {:graph_uuid graph-uuid + :tag_page_uuid (:tag_page_uuid tag) + :tag_title (:tag_title tag) + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid block-uuid + :source_block_content block-content + :source_block_format block-format + :updated_at (.now js/Date)}) + tags))))) + entities)] + (vec (distinct (concat page-entries block-entries))))) diff --git a/deps/publish/src/logseq/publish/meta_store.cljs b/deps/publish/src/logseq/publish/meta_store.cljs new file mode 100644 index 0000000..671f0ed --- /dev/null +++ b/deps/publish/src/logseq/publish/meta_store.cljs @@ -0,0 +1,469 @@ +(ns logseq.publish.meta-store + "Handles storing Durable Object in SQLite" + (:require [clojure.string :as string] + [logseq.publish.common :as publish-common] + [promesa.core :as p])) + +(defn init-schema! [sql] + (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);")) + drop? (some #(contains? #{"page_id" "graph"} (aget % "name")) cols)] + (when drop? + (publish-common/sql-exec sql "DROP TABLE IF EXISTS pages;")) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS pages (" + "page_uuid TEXT NOT NULL," + "page_title TEXT," + "page_tags TEXT," + "graph_uuid TEXT NOT NULL," + "schema_version TEXT," + "block_count INTEGER," + "content_hash TEXT NOT NULL," + "content_length INTEGER," + "r2_key TEXT NOT NULL," + "owner_sub TEXT," + "owner_username TEXT," + "created_at INTEGER," + "updated_at INTEGER," + "password_hash TEXT," + "PRIMARY KEY (graph_uuid, page_uuid)" + ");")) + (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);")) + col-names (set (map #(aget % "name") cols))] + (when-not (contains? col-names "page_title") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_title TEXT;")) + (when-not (contains? col-names "page_tags") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_tags TEXT;")) + (when-not (contains? col-names "short_id") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN short_id TEXT;")) + (when-not (contains? col-names "owner_username") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN owner_username TEXT;")) + (when-not (contains? col-names "password_hash") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN password_hash TEXT;"))) + (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(page_refs);")) + col-names (set (map #(aget % "name") cols))] + (when (seq col-names) + (when-not (contains? col-names "target_page_title") + (publish-common/sql-exec sql "ALTER TABLE page_refs ADD COLUMN target_page_title TEXT;")) + (when-not (contains? col-names "target_page_name") + (publish-common/sql-exec sql "ALTER TABLE page_refs ADD COLUMN target_page_name TEXT;")))) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS page_refs (" + "graph_uuid TEXT NOT NULL," + "target_page_uuid TEXT NOT NULL," + "target_page_title TEXT," + "target_page_name TEXT," + "source_page_uuid TEXT NOT NULL," + "source_page_title TEXT," + "source_block_uuid TEXT," + "source_block_content TEXT," + "source_block_format TEXT," + "updated_at INTEGER," + "PRIMARY KEY (graph_uuid, target_page_uuid, source_block_uuid)" + ");")) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS page_tags (" + "graph_uuid TEXT NOT NULL," + "tag_page_uuid TEXT NOT NULL," + "tag_title TEXT," + "source_page_uuid TEXT NOT NULL," + "source_page_title TEXT," + "source_block_uuid TEXT NOT NULL," + "source_block_content TEXT," + "source_block_format TEXT," + "updated_at INTEGER," + "PRIMARY KEY (graph_uuid, tag_page_uuid, source_block_uuid)" + ");")) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS page_blocks (" + "graph_uuid TEXT NOT NULL," + "page_uuid TEXT NOT NULL," + "block_uuid TEXT NOT NULL," + "block_content TEXT," + "updated_at INTEGER," + "PRIMARY KEY (graph_uuid, block_uuid)" + ");")))) + +(defn parse-page-tags [value] + (cond + (nil? value) #js [] + (array? value) value + (string? value) (try + (js/JSON.parse value) + (catch :default _ + #js [])) + :else #js [])) + +(defn row->meta [row] + (let [data (js->clj row :keywordize-keys false) + page-tags (parse-page-tags (get data "page_tags")) + short-id (get data "short_id")] + (assoc data + "graph" (get data "graph_uuid") + "page_tags" page-tags + "short_id" short-id + "short_url" (when short-id (str "/p/" short-id)) + "content_hash" (get data "content_hash") + "content_length" (get data "content_length")))) + +(defn ^:large-vars/cleanup-todo do-fetch [^js self request] + (let [sql (.-sql self)] + (init-schema! sql) + (cond + (= "POST" (.-method request)) + (p/let [body (.json request)] + (let [page-uuid (aget body "page_uuid") + graph-uuid (aget body "graph")] + (if (and (string? page-uuid) (string? graph-uuid)) + (publish-common/sql-exec sql + (str "INSERT INTO pages (" + "page_uuid," + "page_title," + "page_tags," + "graph_uuid," + "schema_version," + "block_count," + "content_hash," + "content_length," + "r2_key," + "owner_sub," + "owner_username," + "created_at," + "updated_at," + "short_id," + "password_hash" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + " ON CONFLICT(graph_uuid, page_uuid) DO UPDATE SET" + " page_uuid=excluded.page_uuid," + " page_title=excluded.page_title," + " page_tags=excluded.page_tags," + " schema_version=excluded.schema_version," + " block_count=excluded.block_count," + " content_hash=excluded.content_hash," + " content_length=excluded.content_length," + " r2_key=excluded.r2_key," + " owner_sub=excluded.owner_sub," + " owner_username=excluded.owner_username," + " updated_at=excluded.updated_at," + " short_id=excluded.short_id," + " password_hash=excluded.password_hash;") + page-uuid + (aget body "page_title") + (aget body "page_tags") + graph-uuid + (aget body "schema_version") + (aget body "block_count") + (aget body "content_hash") + (aget body "content_length") + (aget body "r2_key") + (aget body "owner_sub") + (aget body "owner_username") + (aget body "created_at") + (aget body "updated_at") + (aget body "short_id") + (aget body "password_hash")) + (throw (js/Error. "publish: missing page_uuid or graph"))) + (let [refs (aget body "refs") + tagged-nodes (aget body "tagged_nodes") + blocks (aget body "blocks") + graph-uuid (aget body "graph") + page-uuid (aget body "page_uuid")] + (when (and graph-uuid page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_refs WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_tags WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (doseq [ref refs] + (publish-common/sql-exec sql + (str "INSERT OR REPLACE INTO page_refs (" + "graph_uuid, target_page_uuid, target_page_title, target_page_name, source_page_uuid, " + "source_page_title, source_block_uuid, source_block_content, " + "source_block_format, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);") + (aget ref "graph_uuid") + (aget ref "target_page_uuid") + (aget ref "target_page_title") + (aget ref "target_page_name") + (aget ref "source_page_uuid") + (aget ref "source_page_title") + (aget ref "source_block_uuid") + (aget ref "source_block_content") + (aget ref "source_block_format") + (aget ref "updated_at"))) + + (doseq [tag tagged-nodes] + (publish-common/sql-exec sql + (str "INSERT OR REPLACE INTO page_tags (" + "graph_uuid, tag_page_uuid, tag_title, source_page_uuid, " + "source_page_title, source_block_uuid, source_block_content, " + "source_block_format, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);") + (aget tag "graph_uuid") + (aget tag "tag_page_uuid") + (aget tag "tag_title") + (aget tag "source_page_uuid") + (aget tag "source_page_title") + (aget tag "source_block_uuid") + (aget tag "source_block_content") + (aget tag "source_block_format") + (aget tag "updated_at")))) + (publish-common/sql-exec sql + "DELETE FROM page_blocks WHERE graph_uuid = ? AND page_uuid = ?;" + graph-uuid + page-uuid) + (doseq [block blocks] + (publish-common/sql-exec sql + (str "INSERT OR REPLACE INTO page_blocks (" + "graph_uuid, page_uuid, block_uuid, block_content, updated_at" + ") VALUES (?, ?, ?, ?, ?);") + (aget body "graph") + (aget block "page_uuid") + (aget block "block_uuid") + (aget block "block_content") + (aget block "updated_at")))) + (publish-common/json-response {:ok true}))) + + (= "GET" (.-method request)) + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (cond + (= (nth parts 1 nil) "search") + (let [graph-uuid (nth parts 2 nil) + query (.get (.-searchParams url) "q") + query (some-> query string/trim) + query (when (and query (not (string/blank? query))) + (string/lower-case query))] + (if (or (string/blank? graph-uuid) (string/blank? query)) + (publish-common/bad-request "missing graph uuid or query") + (let [like-query (str "%" query "%") + pages (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, short_id " + "FROM pages " + "WHERE graph_uuid = ? " + "AND password_hash IS NULL " + "AND page_title IS NOT NULL " + "AND lower(page_title) LIKE ? " + "ORDER BY updated_at DESC " + "LIMIT 20;") + graph-uuid + like-query)) + blocks (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_blocks.page_uuid, page_blocks.block_uuid, " + "page_blocks.block_content, pages.page_title, pages.short_id " + "FROM page_blocks " + "LEFT JOIN pages " + "ON pages.graph_uuid = page_blocks.graph_uuid " + "AND pages.page_uuid = page_blocks.page_uuid " + "WHERE page_blocks.graph_uuid = ? " + "AND pages.password_hash IS NULL " + "AND page_blocks.block_content IS NOT NULL " + "AND lower(page_blocks.block_content) LIKE ? " + "ORDER BY page_blocks.updated_at DESC " + "LIMIT 50;") + graph-uuid + like-query))] + (publish-common/json-response {:pages pages :blocks blocks})))) + + (= (nth parts 1 nil) "tag") + (let [tag-name (when-let [raw (nth parts 2 nil)] + (js/decodeURIComponent raw)) + tagged-rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_tags.graph_uuid, page_tags.tag_page_uuid, page_tags.tag_title, " + "page_tags.source_page_uuid, page_tags.source_page_title, page_tags.source_block_uuid, " + "page_tags.source_block_content, page_tags.source_block_format, page_tags.updated_at, " + "pages.short_id " + "FROM page_tags " + "INNER JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.tag_title = ? " + "AND pages.password_hash IS NULL " + "ORDER BY page_tags.updated_at DESC;") + tag-name)) + page-rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, " + "pages.short_id, " + "MAX(page_tags.updated_at) AS updated_at " + "FROM page_tags " + "INNER JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.tag_title = ? " + "AND pages.password_hash IS NULL " + "GROUP BY page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, pages.short_id " + "ORDER BY updated_at DESC;") + tag-name))] + (publish-common/json-response {:pages (map (fn [row] + (js->clj row :keywordize-keys false)) + page-rows) + :tagged_nodes (map (fn [row] + (js->clj row :keywordize-keys false)) + tagged-rows)})) + + (= (nth parts 1 nil) "ref") + (let [ref-name (when-let [raw (nth parts 2 nil)] + (js/decodeURIComponent raw)) + rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, " + "pages.short_id, " + "MAX(page_refs.updated_at) AS updated_at " + "FROM page_refs " + "INNER JOIN pages " + "ON pages.graph_uuid = page_refs.graph_uuid " + "AND pages.page_uuid = page_refs.source_page_uuid " + "WHERE ((lower(page_refs.target_page_title) = lower(?)) " + "OR (page_refs.target_page_name = lower(?))) " + "AND pages.password_hash IS NULL " + "GROUP BY page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, pages.short_id " + "ORDER BY updated_at DESC;") + ref-name + ref-name))] + (publish-common/json-response {:pages (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (= (nth parts 1 nil) "short") + (let [short-id (nth parts 2 nil) + rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, graph_uuid, page_title, short_id " + "FROM pages WHERE short_id = ? LIMIT 1;") + short-id)) + row (first rows)] + (publish-common/json-response {:page (when row (js->clj row :keywordize-keys false))})) + + (= (nth parts 1 nil) "user") + (let [raw-username (nth parts 2 nil) + username (when raw-username (js/decodeURIComponent raw-username)) + rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, short_id, graph_uuid, updated_at, owner_username " + "FROM pages " + "WHERE owner_username = ? " + "AND password_hash IS NULL " + "ORDER BY updated_at DESC;") + username))] + (publish-common/json-response {:user {:username username} + :pages (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (= (nth parts 4 nil) "password") + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT password_hash " + "FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;") + graph-uuid + page-uuid)) + row (first rows)] + (if-not row + (publish-common/not-found) + (publish-common/json-response {:password_hash (aget row "password_hash")}))) + + (= (nth parts 4 nil) "refs") + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT graph_uuid, target_page_uuid, source_page_uuid, " + "target_page_title, target_page_name, source_page_title, source_block_uuid, source_block_content, " + "source_block_format, updated_at " + "FROM page_refs WHERE graph_uuid = ? AND target_page_uuid = ? " + "ORDER BY updated_at DESC;") + graph-uuid + page-uuid))] + (publish-common/json-response {:refs (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (= (nth parts 4 nil) "tagged_nodes") + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_tags.graph_uuid, page_tags.tag_page_uuid, page_tags.tag_title, page_tags.source_page_uuid, " + "page_tags.source_page_title, page_tags.source_block_uuid, page_tags.source_block_content, " + "page_tags.source_block_format, page_tags.updated_at " + "FROM page_tags " + "INNER JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.graph_uuid = ? AND page_tags.tag_page_uuid = ? " + "AND pages.password_hash IS NULL " + "ORDER BY page_tags.updated_at DESC;") + graph-uuid + page-uuid))] + (publish-common/json-response {:tagged_nodes (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (and graph-uuid page-uuid) + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, " + "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at " + "FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;") + graph-uuid + page-uuid)) + row (first rows)] + (if-not row + (publish-common/not-found) + (publish-common/json-response (row->meta row)))) + + graph-uuid + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, " + "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at " + "FROM pages WHERE graph_uuid = ? ORDER BY updated_at DESC;") + graph-uuid))] + (publish-common/json-response {:pages (map row->meta rows)})) + + :else + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, " + "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at " + "FROM pages ORDER BY updated_at DESC;")))] + (publish-common/json-response {:pages (map row->meta rows)})))) + + (= "DELETE" (.-method request)) + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (cond + (and graph-uuid page-uuid) + (do + (publish-common/sql-exec sql + "DELETE FROM pages WHERE graph_uuid = ? AND page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_refs WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_tags WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/json-response {:ok true})) + + graph-uuid + (do + (publish-common/sql-exec sql "DELETE FROM pages WHERE graph_uuid = ?;" graph-uuid) + (publish-common/sql-exec sql "DELETE FROM page_refs WHERE graph_uuid = ?;" graph-uuid) + (publish-common/sql-exec sql "DELETE FROM page_tags WHERE graph_uuid = ?;" graph-uuid) + (publish-common/json-response {:ok true})) + + :else + (publish-common/bad-request "missing graph uuid or page uuid"))) + + :else + (publish-common/json-response {:error "method not allowed"} 405)))) diff --git a/deps/publish/src/logseq/publish/model.cljs b/deps/publish/src/logseq/publish/model.cljs new file mode 100644 index 0000000..0d496fb --- /dev/null +++ b/deps/publish/src/logseq/publish/model.cljs @@ -0,0 +1,42 @@ +(ns logseq.publish.model + "Handles entity/model operations like conversion from datoms") + +(defn merge-attr + [entity attr value] + (let [existing (get entity attr ::none)] + (cond + (= existing ::none) (assoc entity attr value) + (vector? existing) (assoc entity attr (conj existing value)) + (set? existing) (assoc entity attr (conj existing value)) + :else (assoc entity attr [existing value])))) + +(defn datoms->entities + [datoms] + (reduce + (fn [acc datom] + (let [[e a v _tx added?] datom] + (if added? + (update acc e (fn [entity] + (merge-attr (or entity {:db/id e}) a v))) + acc))) + {} + datoms)) + +(defn entity->title + [entity] + (or (:block/title entity) + (:block/name entity) + (str (:logseq.property/value entity)) + "Untitled")) + +(defn page-entity? + [entity] + (and (nil? (:block/page entity)) + (or (:block/name entity) + (:block/title entity)))) + +(defn ref-eid [value] + (cond + (number? value) value + (map? value) (:db/id value) + :else nil)) diff --git a/deps/publish/src/logseq/publish/publish.css b/deps/publish/src/logseq/publish/publish.css new file mode 100644 index 0000000..60d9d28 --- /dev/null +++ b/deps/publish/src/logseq/publish/publish.css @@ -0,0 +1,994 @@ +@import url("https://cdn.jsdelivr.net/npm/katex@0.16.38/dist/katex.min.css"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"); + +:root { + color-scheme: light; + --bg: #fffcf0; + --bg-accent: #f2f0e5; + --surface: #fffcf0; + --surface-strong: #f2f0e5; + --ink: #282726; + --muted: #6f6e69; + --border: #cecdc3; + --link: #282726; + --action: #24837B; + --shadow: 0 18px 40px rgba(40, 39, 38, 0.1); + --bg-gradient-1: rgba(218, 112, 44, 0.12); + --bg-gradient-2: rgba(67, 133, 190, 0.1); + --button-bg: #f2e9d6; + --button-bg-hover: #efe0c2; + --code-bg: #1f2933; + --code-ink: #f8f4ec; + --code-muted: #b59d82; + --math-bg: #f6ede2; + --quote-border: #282726; + --card-bg: #fff7ee; + --input-bg: #fffcf0; + --image-shadow: 0 12px 24px rgba(40, 39, 38, 0.08); + --icon-day: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor' %3E%3Cpath fill-rule='evenodd' d='M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z' clip-rule='evenodd' /%3E%3C/svg%3E"); + --icon-night: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24' %3E%3Cpath d='M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z' /%3E%3C/svg%3E"); +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #100f0f; + --bg-accent: #1c1b1a; + --surface: #1c1b1a; + --surface-strong: #282726; + --ink: #e6e4d9; + --muted: #b7b5ac; + --border: #403e3c; + --link: #e6e4d9; + --action: #3AA99F; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + --bg-gradient-1: rgba(218, 112, 44, 0.18); + --bg-gradient-2: rgba(67, 133, 190, 0.14); + --button-bg: #2f2c2b; + --button-bg-hover: #3a3635; + --code-bg: #1a1f24; + --code-ink: #f8f4ec; + --code-muted: #a3a091; + --math-bg: #242220; + --quote-border: #e6e4d9; + --card-bg: #1f1d1c; + --input-bg: #1c1b1a; + --image-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(1200px 600px at 10% -10%, var(--bg-gradient-1), transparent 60%), + radial-gradient(900px 400px at 90% 0%, var(--bg-gradient-2), transparent 60%), + linear-gradient(180deg, var(--bg) 0%, var(--bg-accent) 100%); + color: var(--ink); + font-family: "Inter", "Segoe UI", sans-serif; + line-height: 1.65; + letter-spacing: 0.01em; +} + +.inline-flex { + display: inline-flex; +} + +.publish-home { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow: hidden; +} + +.publish-home-bg { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; +} + +.publish-home-card { + position: relative; + z-index: 1; + max-width: 520px; + padding: 32px 28px 28px; + border-radius: 20px; + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow); + text-align: center; +} + +.publish-home-logo { + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 18px; +} + +.publish-home-title { + margin: 0 0 12px; + font-size: clamp(26px, 3.6vw, 36px); + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink); +} + +.publish-home-subtitle { + margin: 0; + font-size: 14px; + color: var(--muted); + line-height: 1.6; +} + +.publish-home-subtitle code { + font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + background: var(--bg-accent); + padding: 1px 4px; + border-radius: 4px; +} + +@media (max-width: 600px) { + .publish-home-card { + margin: 0 16px; + padding: 24px 20px 22px; + } +} + +.wrap { + max-width: 920px; + margin: 32px auto 56px; + padding: 32px 28px 40px; + overflow-x: hidden; +} + +h1 { + font-size: clamp(30px, 3.4vw, 44px); + margin-top: 1.5em; + margin-bottom: 0.25em; + font-weight: 600; + letter-spacing: 0.01em; +} + +h2 { + font-weight: 500; + letter-spacing: 0.01em; +} + +.block-heading { + margin: 0; + font-weight: 600; + line-height: 1.4; +} + +h1.block-heading { + font-size: 1.6em; +} + +h2.block-heading { + font-size: 1.4em; +} + +h3.block-heading { + font-size: 1.25em; +} + +h4.block-heading { + font-size: 1.1em; +} + +h5.block-heading, +h6.block-heading { + font-size: 1em; +} + +a { + color: var(--link); + text-decoration: underline; +} + +a:hover { + color: var(--action); +} + +.page-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + padding: 12px 0; + margin: -8px 0 24px; +} + +.page-toolbar .toolbar-btn:first-child { + margin-right: auto; +} + +.publish-search { + position: relative; + display: flex; + align-items: center; + gap: 6px; + min-width: 240px; + flex-direction: row-reverse; + height: 32px; +} + +.publish-search-toggle { + border: none; + background: var(--button-bg); + color: var(--ink); + width: 32px; + height: 32px; + padding: 0; + border-radius: 999px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.publish-search-toggle:hover { + background: var(--button-bg-hover); + box-shadow: 0 6px 14px rgba(40, 39, 38, 0.12); + transform: translateY(-1px); +} + +.publish-search-toggle .ti { + font-size: 16px; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.publish-search-input { + width: 0; + opacity: 0; + padding: 0 12px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--ink); + font-size: 13px; + border-radius: 999px; + pointer-events: none; + transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease; +} + +.publish-search.is-expanded .publish-search-input { + width: min(320px, 70vw); + padding: 8px 12px; + opacity: 1; + pointer-events: auto; +} + +.publish-search-input:focus { + outline: none; +} + +.publish-search-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.publish-search-results { + opacity: 0; + transform: translateY(-6px); + position: absolute; + right: 0; + top: calc(100% + 24px); + z-index: 10; + width: min(480px, 90vw); + max-height: 320px; + overflow: auto; + border-radius: 16px; + background: var(--surface-strong); + box-shadow: var(--shadow); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.publish-search.is-expanded .publish-search-results:not([hidden]) { + opacity: 1; + transform: translateY(0); +} + +.publish-search-hint { + position: absolute; + right: 40px; + top: calc(100% + 6px); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.publish-search.is-expanded .publish-search-hint { + opacity: 1; +} +.publish-search-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.publish-search-section { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--muted); + margin: 6px 6px 2px; +} + +.publish-search-result { + background: transparent; + text-align: left; + display: grid; + padding: 4px 12px; + cursor: pointer; + color: var(--ink); + text-decoration: none; +} + +.publish-search-result:hover { + background: var(--surface); +} + +.publish-search-result:first-child { + padding-top: 12px; +} + +.publish-search-result:last-child { + padding-bottom: 12px; +} + +.publish-search-result.is-active { + background: var(--surface); +} + +.publish-search-kind { + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); +} + +.publish-search-title { + font-size: 13px; + font-weight: 600; +} + +.publish-search-snippet { + font-size: 12px; + color: var(--muted); +} + +.publish-search-empty { + font-size: 12px; + color: var(--muted); + padding: 8px; +} + +.toolbar-btn { + background: transparent; + border: none; + color: var(--ink); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.08em; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.theme-toggle { + position: relative; + width: 62px; + height: 30px; + padding: 0 8px; + background: var(--button-bg); + border: none; + border-radius: 999px; + color: var(--ink); + display: inline-flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.theme-toggle:hover { + background: var(--button-bg-hover); + box-shadow: 0 6px 16px rgba(40, 39, 38, 0.12); +} + +.theme-toggle__thumb { + position: absolute; + top: 3px; + left: 3px; + width: 24px; + height: 24px; + border-radius: 999px; + background: var(--surface); + box-shadow: 0 4px 10px rgba(40, 39, 38, 0.12); + transition: transform 0.2s ease; +} + +.theme-toggle.is-dark .theme-toggle__thumb { + transform: translateX(32px); +} + +.theme-toggle__icon { + width: 14px; + height: 14px; + background-color: currentColor; + opacity: 0.6; + transition: opacity 0.2s ease; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; +} + +.theme-toggle__icon--day { + mask-image: var(--icon-day); + -webkit-mask-image: var(--icon-day); +} + +.theme-toggle__icon--night { + mask-image: var(--icon-night); + -webkit-mask-image: var(--icon-night); +} + +.theme-toggle.is-dark .theme-toggle__icon--night, +.theme-toggle:not(.is-dark) .theme-toggle__icon--day { + opacity: 1; +} + +.blocks { + margin: 0; + padding-left: 0; + list-style: none; +} + +.block-children .blocks { + list-style: none; + padding-left: 0 !important; +} + +.block-children .block { + position: relative; + padding-left: 16px; +} + +.block-children .block::before { + content: ""; + position: absolute; + left: 0; + top: 11px; + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--muted); +} + +.block { + margin: 8px 0; +} + +.block-content { + white-space: pre-wrap; + display: flex; + gap: 4px; + align-items: flex-start; + min-height: 26px; +} + +.positioned-properties { + display: inline-flex; + align-items: center; + gap: 2px; + flex-wrap: wrap; +} + +.positioned-properties.block-left, .positioned-properties.block-right { + display: flex; + align-items: center; +} + +.block-content > .positioned-properties.block-left { + align-self: flex-start; + margin-top: 6px; +} + +.positioned-properties.block-right { + margin-left: auto; +} + +.positioned-properties.block-below { + margin: 6px 0 0 22px; + gap: 8px 12px; +} + +.positioned-property { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.positioned-property .property-name { + color: var(--muted); + font-weight: 500; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.positioned-property .property-value { + color: var(--ink); +} + +.property-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + line-height: 1; + font-size: 18px; + color: currentColor; +} + +.property-icon svg { + width: 18px; + height: 18px; +} + +.positioned-properties .ls-icon-Backlog { + color: var(--rx-gray-05, #a8a29e); +} + +.positioned-properties .ls-icon-Todo { + color: var(--rx-gray-10, #78716c); +} + +.positioned-properties .ls-icon-InProgress50 { + color: var(--rx-yellow-08, #ca8a04); +} + +.positioned-properties .ls-icon-InReview { + color: var(--rx-blue-09, #1d4ed8); +} + +.positioned-properties .ls-icon-Done { + color: var(--rx-green-08, #16a34a); +} + +.positioned-properties .ls-icon-Cancelled { + color: var(--rx-red-08, #dc2626); +} + +.property-value-with-icon { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.block-text { + flex: 1; + font-size: 16px; +} + +.macro-embed { + width: min(100%, 560px); + aspect-ratio: 16 / 9; + margin: 10px 0; + border-radius: 14px; + overflow: hidden; + background: var(--surface-strong); + box-shadow: var(--image-shadow); +} + +.macro-embed iframe { + width: 100%; + height: 100%; + border: 0; +} + +.macro-embed--tweet { + aspect-ratio: 4 / 5; +} + +.cloze { + padding: 0 6px; + border-radius: 6px; + background: var(--surface-strong); + box-shadow: inset 0 0 0 1px rgba(40, 39, 38, 0.12); +} + +.code-block { + flex: 1; + position: relative; + margin: 1.5em 0; + border-radius: 14px; + overflow: hidden; + /* background: var(--code-bg); */ +} + +.code-block[data-lang]:before { + content: attr(data-lang); + position: absolute; + top: 10px; + right: 12px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--code-muted); + z-index: 2; +} + +.code-block .cm-editor { + height: auto; + /* background: var(--code-bg); */ + /* color: var(--code-ink); */ + font-size: 13px; + line-height: 1.6; +} + +.code-block .cm-scroller { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; +} + +.ͼ2 .cm-gutters.cm-gutters-before { + border-right-width: 0; +} + +.math-block { + flex: 1; + padding: 12px 14px; +} + +.quote-block, blockquote { + padding-left: 1.5em; + margin: 1.5em 0; + border-left: 2px solid var(--quote-border); +} + +.asset-image { + max-width: 100%; + border-radius: 14px; + box-shadow: var(--image-shadow); +} + +.asset-video, +.asset-audio { + width: 100%; +} + +.asset-link { + color: var(--ink); + font-weight: 500; +} + +.page-properties { + margin: 0 0 28px; + padding: 16px 18px; + border-radius: 16px; + background: var(--surface-strong); +} + +.properties { + margin: 0; + display: grid; + grid-template-columns: 160px 1fr; + gap: 6px 16px; +} + +.property { + display: contents; +} + +.property-name { + margin: 0; + color: var(--muted); + font-weight: 500; + font-size: 13px; + letter-spacing: 0.08em; +} + +.property-value { + margin: 0; + color: var(--ink); +} + +.block-properties { + margin: 8px 0 0 22px; +} + +.block-properties .properties { + grid-template-columns: 120px 1fr; + font-size: 13px; +} + +.block-toggle { + border: none; + background: transparent; + cursor: pointer; + font-size: 14px; + line-height: 1; + margin-top: 4px; + margin-left: auto; + color: var(--muted); +} + +.block.is-collapsed > .block-content > .block-toggle { + transform: rotate(-90deg); +} + +.block-children { + margin-left: 16px; +} + +.block.is-collapsed > .block-children { + display: none; +} + +.linked-refs, +.tagged-pages { + margin-top: 36px; +} + +.linked-refs h2, +.tagged-pages h2 { + font-size: 14px; + margin: 64px 0 16px 0; + color: var(--muted); +} + +.ref-page { + margin: 0 0 16px; +} + +.ref-blocks { + margin: 8px 0 0 18px; + padding: 0; + list-style: disc; +} + +.ref-block { + margin: 6px 0; +} + +.tagged-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.tagged-item { + padding: 12px 14px; + border-radius: 14px; + background: var(--surface-strong); + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.tagged-main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.tagged-block { + font-size: 13px; + color: var(--ink); + white-space: pre-wrap; +} + +.tagged-sub, +.tagged-meta { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.graph-meta, +.tag-sub { + color: var(--muted); + font-size: 13px; + margin: 0 0 20px; + letter-spacing: 0.12em; +} + +.password-card { + margin: 20px auto 0; + max-width: 460px; + padding: 24px; + border-radius: 18px; + background: var(--card-bg); + box-shadow: 0 12px 24px rgba(40, 39, 38, 0.12); + text-align: center; +} + +.password-form { + margin-top: 18px; + display: grid; + gap: 12px; +} + +.password-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--muted); +} + +.password-input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: none; + background: var(--input-bg); + box-shadow: inset 0 0 0 1px rgba(40, 39, 38, 0.1); + font-size: 15px; + font-family: inherit; +} + +.password-input:focus { + outline: 2px solid var(--action); +} + +.password-error { + margin: 8px 0 0; + color: #b42318; + font-size: 13px; + font-weight: 600; +} + +.not-found { + text-align: center; + padding: 32px 16px 8px; +} + +.not-found-eyebrow { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 14px; + letter-spacing: 0.4em; + text-transform: uppercase; + color: var(--ink); + margin: 0 0 12px; +} + +.page-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.page-item { + padding: 14px 16px; + border-radius: 16px; + background: var(--surface-strong); + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.page-item:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(40, 39, 38, 0.12); +} + +.page-links { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.short-link { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.04em; +} + +.page-link, +.page-ref { + color: var(--link); + overflow-wrap: anywhere; +} + +.page-updated-at { + color: var(--muted); + white-space: nowrap; + margin-left: 2px; +} + +.page-authors { + color: var(--muted); + white-space: nowrap; + margin-left: 2px; +} + +.page-meta { + display: flex; + margin-bottom: 2rem; + gap: 8px; + font-size: 13px; +} + +@media (max-width: 720px) { + .wrap { + margin: 16px; + padding: 4px; + } + + .page-toolbar { + gap: 10px; + justify-content: flex-end; + } + + .publish-search { + min-width: 100%; + } + + .publish-search-input { + width: 0; + } + + .publish-search.is-expanded .publish-search-input { + width: 100%; + } + + .block-children { + margin-left: 12px; + } + + .block-children .blocks { + padding-left: 12px; + } + + .properties { + grid-template-columns: 1fr; + } + + .block-properties .properties { + grid-template-columns: 1fr; + } + + .page-item, + .tagged-item { + flex-direction: column; + align-items: flex-start; + } + + .page-updated-at, + .page-authors, + .tagged-meta { + white-space: normal; + } +} diff --git a/deps/publish/src/logseq/publish/publish.js b/deps/publish/src/logseq/publish/publish.js new file mode 100644 index 0000000..0a14b14 --- /dev/null +++ b/deps/publish/src/logseq/publish/publish.js @@ -0,0 +1,712 @@ +import katexPkg from "https://esm.sh/katex@0.16.38?bundle"; + +// Core CodeMirror pieces +import { EditorState } from "https://esm.sh/@codemirror/state@6"; +import { + EditorView, + lineNumbers, +} from "https://esm.sh/@codemirror/view@6"; + +// Highlighting +import { + syntaxHighlighting, + defaultHighlightStyle, +} from "https://esm.sh/@codemirror/language@6"; + +// Languages +import { javascript } from "https://esm.sh/@codemirror/lang-javascript@6"; +import { python } from "https://esm.sh/@codemirror/lang-python@6"; +import { html } from "https://esm.sh/@codemirror/lang-html@6"; +import { json } from "https://esm.sh/@codemirror/lang-json@6"; +import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6"; +import { sql } from "https://esm.sh/@codemirror/lang-sql@6"; +import { css } from "https://esm.sh/@codemirror/lang-css@6"; +import { clojure } from "https://esm.sh/@nextjournal/lang-clojure"; +import emojiData from "https://esm.sh/@emoji-mart/data@1?bundle"; + +const katex = katexPkg.default || katexPkg; +const THEME_KEY = "publish-theme"; + +document.addEventListener("click", (event) => { + const btn = event.target.closest(".block-toggle"); + if (!btn) return; + const li = btn.closest("li.block"); + if (!li) return; + const collapsed = li.classList.toggle("is-collapsed"); + btn.setAttribute("aria-expanded", String(!collapsed)); +}); + +const getEmojiNative = (id) => { + const emoji = emojiData?.emojis?.[id]; + if (!emoji) return null; + return emoji?.skins?.[0]?.native || null; +}; + +const toKebabCase = (value) => + (value || "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([a-zA-Z])([0-9])/g, "$1-$2") + .replace(/([0-9])([a-zA-Z])/g, "$1-$2") + .replace(/[_\s]+/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); + +const toPascalCase = (value) => + (value || "") + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + +const toTablerIconName = (id) => { + if (!id) return null; + return id.startsWith("Icon") ? id : `Icon${toPascalCase(id)}`; +}; + +const svgNamespace = "http://www.w3.org/2000/svg"; + +const isReactElement = (node) => + node && + typeof node === "object" && + node.$$typeof && + node.type && + node.props; + +const setDomAttribute = (el, key, val, isSvg) => { + if (key === "className") { + el.setAttribute("class", val); + return; + } + if (key === "style" && val && typeof val === "object") { + Object.entries(val).forEach(([styleKey, styleVal]) => { + el.style[styleKey] = styleVal; + }); + return; + } + if (key === "ref" || key === "key" || key === "children") return; + if (val === true) { + el.setAttribute(key, ""); + return; + } + if (val === false || val == null) return; + + let attr = key; + if (isSvg) { + if (key === "strokeWidth") attr = "stroke-width"; + else if (key === "strokeLinecap") attr = "stroke-linecap"; + else if (key === "strokeLinejoin") attr = "stroke-linejoin"; + else if (key !== "viewBox" && /[A-Z]/.test(key)) { + attr = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + } + } + el.setAttribute(attr, val); +}; + +const reactNodeToDom = (node, parentIsSvg = false) => { + if (node == null || node === false) return null; + if (Array.isArray(node)) { + const frag = document.createDocumentFragment(); + node.forEach((child) => { + const childNode = reactNodeToDom(child, parentIsSvg); + if (childNode) frag.appendChild(childNode); + }); + return frag; + } + if (typeof node === "string" || typeof node === "number") { + return document.createTextNode(String(node)); + } + if (node.nodeType) return node; + if (isReactElement(node)) { + if (node.type === Symbol.for("react.fragment")) { + return reactNodeToDom(node.props?.children, parentIsSvg); + } + if (typeof node.type === "function") { + return reactNodeToDom(node.type(node.props), parentIsSvg); + } + const tag = node.type; + const isSvg = parentIsSvg || tag === "svg"; + const el = isSvg + ? document.createElementNS(svgNamespace, tag) + : document.createElement(tag); + const props = node.props || {}; + Object.entries(props).forEach(([key, val]) => { + setDomAttribute(el, key, val, isSvg); + }); + const children = props.children; + if (children != null) { + const childNode = reactNodeToDom(children, isSvg); + if (childNode) el.appendChild(childNode); + } + return el; + } + return null; +}; + +const getTablerExtIcon = (id) => { + const name = toTablerIconName(id); + if (!name) return null; + return window.tablerIcons?.[name] || null; +}; + +const renderTablerExtIcon = (el, id) => { + const iconFn = getTablerExtIcon(id); + if (!iconFn) return false; + const node = iconFn({ size: 18 }); + if (!node) return false; + el.textContent = ""; + const domNode = reactNodeToDom(node); + if (!domNode) return false; + if (domNode.nodeType === 11) { + el.appendChild(domNode); + return true; + } + if (domNode.nodeType) { + if (domNode.tagName === "svg") { + domNode.setAttribute("aria-hidden", "true"); + } + el.appendChild(domNode); + return true; + } + return false; +}; + +const renderPropertyIcons = () => { + const icons = Array.from( + document.querySelectorAll(".property-icon[data-icon-type][data-icon-id]") + ); + if (!icons.length) return; + + icons.forEach((el) => { + const id = el.dataset.iconId; + const type = el.dataset.iconType; + if (!id) return; + + if (type === "emoji") { + const native = getEmojiNative(id); + el.textContent = native || id; + return; + } + + el.textContent = ""; + el.setAttribute("aria-hidden", "true"); + + if (type === "tabler-ext-icon") { + if (renderTablerExtIcon(el, id)) return; + const slug = toKebabCase(id); + el.classList.add("tie", `tie-${slug}`); + return; + } + + if (type === "tabler-icon") { + if (renderTablerExtIcon(el, id)) return; + const slug = toKebabCase(id); + el.classList.add("ti", `ti-${slug}`); + return; + } + + el.textContent = id; + }); +}; + +let sequenceKey = null; +let sequenceTimer = null; +const SEQUENCE_TIMEOUT_MS = 900; + +const resetSequence = () => { + sequenceKey = null; + if (sequenceTimer) { + clearTimeout(sequenceTimer); + sequenceTimer = null; + } +}; + +const isTypingTarget = (target) => { + if (!target) return false; + const tag = target.tagName; + return ( + tag === "INPUT" || + tag === "TEXTAREA" || + target.isContentEditable + ); +}; + +document.addEventListener("keydown", (event) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + + const key = (event.key || "").toLowerCase(); + if (!key) return; + + if (sequenceKey === "t" && key === "o") { + resetSequence(); + window.toggleTopBlocks(); + event.preventDefault(); + return; + } + + if (sequenceKey === "t" && key === "t") { + resetSequence(); + window.toggleTheme(); + event.preventDefault(); + return; + } + + if (key === "t") { + sequenceKey = "t"; + if (sequenceTimer) clearTimeout(sequenceTimer); + sequenceTimer = setTimeout(resetSequence, SEQUENCE_TIMEOUT_MS); + return; + } + + resetSequence(); +}); + +document.addEventListener("click", (event) => { + const toggle = event.target.closest(".theme-toggle"); + if (!toggle) return; + event.preventDefault(); + window.toggleTheme(); +}); + +const searchStateMap = new WeakMap(); + +const getSearchContainerState = () => { + const container = + document.querySelector(".publish-search.is-expanded") || + document.querySelector(".publish-search"); + if (!container) return null; + return searchStateMap.get(container) || null; +}; + +document.addEventListener("keydown", (event) => { + const isMod = event.metaKey || event.ctrlKey; + if (!isMod) return; + + const key = (event.key || "").toLowerCase(); + if (!key) return; + + const typingTarget = isTypingTarget(event.target); + if ( + typingTarget && + !event.target.classList?.contains("publish-search-input") + ) { + return; + } + + const state = getSearchContainerState(); + if (!state) return; + + if (key === "k") { + event.preventDefault(); + state.setExpanded(true); + state.focusInput(); + return; + } +}); + +window.toggleTopBlocks = (btn) => { + const list = document.querySelector(".blocks"); + if (!list) return; + const collapsed = list.classList.toggle("collapsed-all"); + list.querySelectorAll(":scope > .block").forEach((el) => { + if (collapsed) { + el.classList.add("is-collapsed"); + } else { + el.classList.remove("is-collapsed"); + } + }); + if (btn) { + btn.textContent = collapsed ? "Expand all" : "Collapse all"; + } +}; + +const applyTheme = (theme) => { + document.documentElement.setAttribute("data-theme", theme); + document.querySelectorAll(".theme-toggle").forEach((toggle) => { + toggle.classList.toggle("is-dark", theme === "dark"); + toggle.setAttribute("aria-checked", String(theme === "dark")); + }); +}; + +const preferredTheme = () => { + const stored = window.localStorage.getItem(THEME_KEY); + if (stored) return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +}; + +window.toggleTheme = () => { + const current = document.documentElement.getAttribute("data-theme") || "light"; + const next = current === "dark" ? "light" : "dark"; + applyTheme(next); + window.localStorage.setItem(THEME_KEY, next); +}; + +const initTwitterEmbeds = () => { + const tweetTargets = document.querySelectorAll(".twitter-tweet"); + if (!tweetTargets.length) return; + + const ensureTwitterScript = () => + new Promise((resolve) => { + if (window.twttr?.widgets?.createTweet) { + return resolve(window.twttr); + } + + let script = document.querySelector("script[data-twitter-widget]"); + if (!script) { + script = document.createElement("script"); + script.src = "https://platform.twitter.com/widgets.js"; + script.async = true; + script.defer = true; + script.setAttribute("data-twitter-widget", "true"); + document.body.appendChild(script); + } + + script.addEventListener("load", () => { + resolve(window.twttr); + }); + }); + + ensureTwitterScript().then((twttr) => { + if (!twttr?.widgets?.createTweet) return; + + tweetTargets.forEach((el) => { + const a = el.querySelector("a[href*='/status/']"); + if (!a) return; + const m = a.href.match(/status\/(\d+)/); + if (!m) return; + const tweetId = m[1]; + + // Clear fallback text + el.innerHTML = ""; + + // Optional: theme based on your current theme + const theme = + (document.documentElement.getAttribute("data-theme") || "light") === + "dark" + ? "dark" + : "light"; + + twttr.widgets.createTweet(tweetId, el, { theme }); + }); + }); +}; + +const buildSnippet = (text, query) => { + const haystack = text.toLowerCase(); + const needle = query.toLowerCase(); + const idx = haystack.indexOf(needle); + if (idx < 0) return text.slice(0, 160); + const start = Math.max(0, idx - 48); + const end = Math.min(text.length, idx + needle.length + 48); + return text.slice(start, end).replace(/\s+/g, " ").trim(); +}; + +const initSearch = () => { + const containers = Array.from( + document.querySelectorAll(".publish-search") + ); + if (!containers.length) return; + + containers.forEach((container) => { + const graphUuid = container.dataset.graphUuid; + const input = container.querySelector(".publish-search-input"); + const toggleBtn = container.querySelector(".publish-search-toggle"); + const toggleIcon = container.querySelector(".publish-search-toggle .ti"); + const resultsEl = container.querySelector(".publish-search-results"); + if (!input || !resultsEl || !toggleBtn) return; + + let debounceTimer = null; + let activeController = null; + let activeIndex = -1; + let activeItems = []; + + const hideResults = () => { + resultsEl.hidden = true; + resultsEl.innerHTML = ""; + activeIndex = -1; + activeItems = []; + }; + + const renderResults = (query, data) => { + const pages = data?.pages || []; + const blocks = data?.blocks || []; + + if (!pages.length && !blocks.length) { + resultsEl.innerHTML = ""; + const empty = document.createElement("div"); + empty.className = "publish-search-empty"; + empty.textContent = `No results for "${query}".`; + resultsEl.appendChild(empty); + resultsEl.hidden = false; + activeIndex = -1; + activeItems = []; + return; + } + + const list = document.createElement("div"); + list.className = "publish-search-list"; + + if (pages.length) { + pages.forEach((page) => { + const title = page.page_title || page.page_uuid; + const href = `/page/${graphUuid}/${page.page_uuid}`; + const item = document.createElement("a"); + item.className = "publish-search-result"; + item.href = href; + + const kind = document.createElement("span"); + kind.className = "publish-search-kind"; + kind.textContent = "Page"; + + const titleEl = document.createElement("span"); + titleEl.className = "publish-search-title"; + titleEl.textContent = title; + + item.appendChild(kind); + item.appendChild(titleEl); + list.appendChild(item); + }); + } + + if (blocks.length) { + blocks.forEach((block) => { + const title = block.page_title || block.page_uuid; + const href = `/page/${graphUuid}/${block.page_uuid}#block-${block.block_uuid}`; + const snippet = buildSnippet(block.block_content || "", query); + const item = document.createElement("a"); + item.className = "publish-search-result"; + item.href = href; + + const titleEl = document.createElement("span"); + titleEl.className = "publish-search-title"; + titleEl.textContent = title; + + const snippetEl = document.createElement("span"); + snippetEl.className = "publish-search-snippet"; + snippetEl.textContent = snippet; + + item.appendChild(titleEl); + item.appendChild(snippetEl); + list.appendChild(item); + }); + } + + resultsEl.innerHTML = ""; + resultsEl.appendChild(list); + resultsEl.hidden = false; + activeIndex = -1; + activeItems = Array.from( + resultsEl.querySelectorAll(".publish-search-result") + ); + activeItems.forEach((item, index) => { + item.addEventListener("mouseenter", () => { + activeIndex = index; + updateActive(); + }); + }); + }; + + const updateActive = () => { + if (!activeItems.length) return; + activeItems.forEach((item, index) => { + item.classList.toggle("is-active", index === activeIndex); + }); + const activeEl = activeItems[activeIndex]; + if (activeEl) { + activeEl.scrollIntoView({ block: "nearest" }); + } + }; + + const moveActive = (direction) => { + if (!activeItems.length) { + activeItems = Array.from( + resultsEl.querySelectorAll(".publish-search-result") + ); + } + if (!activeItems.length) return; + + if (activeIndex === -1) { + activeIndex = direction > 0 ? 0 : activeItems.length - 1; + } else { + activeIndex = + (activeIndex + direction + activeItems.length) % + activeItems.length; + } + updateActive(); + }; + + const activateSelection = () => { + if (!activeItems.length) { + activeItems = Array.from( + resultsEl.querySelectorAll(".publish-search-result") + ); + } + if (!activeItems.length) return; + const item = + activeIndex >= 0 ? activeItems[activeIndex] : activeItems[0]; + if (item?.href) { + window.location.href = item.href; + } + }; + + const setExpanded = (expanded) => { + container.classList.toggle("is-expanded", expanded); + toggleBtn.setAttribute("aria-expanded", String(expanded)); + if (toggleIcon) { + toggleIcon.classList.toggle("ti-search", !expanded); + toggleIcon.classList.toggle("ti-x", expanded); + } + if (expanded) { + input.focus(); + } else { + input.value = ""; + hideResults(); + } + }; + + const runSearch = async (query) => { + if (!query) { + hideResults(); + return; + } + + if (activeController) activeController.abort(); + activeController = new AbortController(); + + try { + const resp = await fetch( + `/search/${encodeURIComponent(graphUuid)}?q=${encodeURIComponent(query)}`, + { signal: activeController.signal } + ); + if (!resp.ok) throw new Error("search request failed"); + const data = await resp.json(); + renderResults(query, data); + } catch (error) { + if (error?.name === "AbortError") return; + hideResults(); + } + }; + + if (graphUuid) { + input.addEventListener("input", () => { + const query = input.value.trim(); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => runSearch(query), 250); + }); + } + + input.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + setExpanded(false); + } + if (event.key === "Enter") { + if (!resultsEl.hidden && input.value.trim()) { + activateSelection(); + event.preventDefault(); + } + } + if ( + !resultsEl.hidden && + input.value.trim() && + resultsEl.querySelector(".publish-search-result") + ) { + const key = event.key; + if (key === "ArrowDown" || key === "Down") { + moveActive(1); + event.preventDefault(); + } else if (key === "ArrowUp" || key === "Up") { + moveActive(-1); + event.preventDefault(); + } else if ((event.metaKey || event.ctrlKey) && key === "n") { + moveActive(1); + event.preventDefault(); + } else if ((event.metaKey || event.ctrlKey) && key === "p") { + moveActive(-1); + event.preventDefault(); + } + } + }); + + document.addEventListener("click", (event) => { + if (!container.contains(event.target)) setExpanded(false); + }); + + toggleBtn.addEventListener("click", () => { + const expanded = container.classList.contains("is-expanded"); + setExpanded(!expanded); + }); + + searchStateMap.set(container, { + setExpanded, + focusInput: () => input.focus(), + moveActive, + activateSelection, + hasResults: () => !!resultsEl.querySelector(".publish-search-result"), + isExpanded: () => container.classList.contains("is-expanded"), + }); + }); +}; + +const initPublish = () => { + applyTheme(preferredTheme()); + renderPropertyIcons(); + if (!window.tablerIcons) { + window.addEventListener("load", renderPropertyIcons, { once: true }); + } + + initTwitterEmbeds(); + initSearch(); + + document.querySelectorAll(".math-block").forEach((el) => { + const tex = el.textContent; + try { + katex.render(tex, el, { displayMode: true, throwOnError: false }); + } catch (_) {} + }); + + document.querySelectorAll(".code-block").forEach((block) => { + const codeEl = block.querySelector("code"); + const doc = codeEl ? codeEl.textContent : ""; + block.textContent = ""; + + const lang = (block.dataset.lang || "").toLowerCase(); + const langExt = (() => { + if (!lang) return null; + if (["js", "javascript", "ts", "typescript"].includes(lang)) { + return javascript({ typescript: lang.startsWith("t") }); + } + if (["py", "python"].includes(lang)) return python(); + if (["html", "htm"].includes(lang)) return html(); + if (["json"].includes(lang)) return json(); + if (["md", "markdown"].includes(lang)) return markdown(); + if (["sql"].includes(lang)) return sql(); + if (["css", "scss"].includes(lang)) return css(); + if (["clj", "cljc", "cljs", "clojure"].includes(lang)) return clojure(); + return null; + })(); + + const extensions = [ + lineNumbers(), + syntaxHighlighting(defaultHighlightStyle), + EditorView.editable.of(false), + EditorView.lineWrapping, + ]; + + if (langExt) extensions.push(langExt); + + const state = EditorState.create({ + doc, + extensions, + }); + + new EditorView({ state, parent: block }); + }); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initPublish); +} else { + initPublish(); +} diff --git a/deps/publish/src/logseq/publish/render.cljs b/deps/publish/src/logseq/publish/render.cljs new file mode 100644 index 0000000..72e30a7 --- /dev/null +++ b/deps/publish/src/logseq/publish/render.cljs @@ -0,0 +1,1611 @@ +(ns logseq.publish.render + "Renders published content as HTML" + (:require-macros [hiccups.core]) + (:require [clojure.string :as string] + [hiccups.runtime] + [logseq.common.util :as common-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.graph-parser.mldoc :as gp-mldoc] + [logseq.publish.common :as publish-common] + [logseq.publish.model :as publish-model])) + +;; Timestamp in milliseconds used for cache busting static assets. +(defonce version 1779272152856) + +(def ref-regex + (js/RegExp. "\\[\\[([0-9a-fA-F-]{36})\\]\\]|\\(\\(([0-9a-fA-F-]{36})\\)\\)" "g")) + +(defonce inline-config + (gp-mldoc/default-config :markdown)) + +(defn- block-ast + [text] + (when-not (string/blank? text) + (->> (gp-mldoc/->edn text inline-config) + (map first)))) + +(defn inline-ast [text] + (gp-mldoc/inline->edn text inline-config)) + +(defn content->nodes [content uuid->title graph-uuid] + (let [s (or content "") + re ref-regex] + (set! (.-lastIndex re) 0) + (loop [idx 0 out []] + (let [m (.exec re s)] + (if (nil? m) + (cond-> out + (< idx (count s)) (conj (subs s idx))) + (let [start (.-index m) + end (.-lastIndex re) + uuid (or (aget m 1) (aget m 2)) + title (get uuid->title uuid uuid) + href (when graph-uuid + (str "/page/" graph-uuid "/" uuid)) + node (if href + [:a.page-ref {:href href} title] + title) + out (cond-> out + (< idx start) (conj (subs s idx start)) + true (conj node))] + (recur end out))))))) + +(defn property-title + [prop-key property-title-by-ident] + (cond + (string? prop-key) prop-key + (keyword? prop-key) (or (get property-title-by-ident prop-key) + (name prop-key)) + :else (str prop-key))) + +(defn property-value-empty? + [value] + (cond + (nil? value) true + (string? value) (string/blank? value) + (coll? value) (empty? value) + :else false)) + +(defn format-datetime + [value] + (let [date (cond + (instance? js/Date value) value + (number? value) (js/Date. value) + (string? value) (js/Date. value) + :else nil)] + (when date + (-> (.toISOString date) + (string/replace "T" " ") + (string/replace "Z" ""))))) + +(defn nodes-join + [nodes-list sep] + (reduce (fn [acc nodes] + (if (empty? nodes) + acc + (if (seq acc) + (into (conj acc sep) nodes) + (into [] nodes)))) + [] + nodes-list)) + +(defn- normalize-nodes + [nodes] + (cond + (nil? nodes) [] + (and (vector? nodes) (keyword? (first nodes))) [nodes] + :else nodes)) + +(defn- icon-span + [icon] + (when (and (map? icon) (string? (:id icon)) (not (string/blank? (:id icon)))) + (let [icon-type (:type icon) + icon-id (:id icon) + class-name (str "property-icon" + (when (and (= :tabler-icon icon-type) + (not (string/blank? icon-id))) + (str " ls-icon-" icon-id)))] + [:span + (cond-> + {:class class-name + :data-icon-id (:id icon) + :data-icon-type (name (:type icon))} + (:color icon) + (assoc :style (str "color: " (:color icon) ";")))]))) + +(defn- with-icon + [icon nodes] + (let [icon-node (icon-span icon)] + (if icon-node + (into [:span {:class "property-value-with-icon"} icon-node] nodes) + nodes))) + +(defn- page-title-node + [title icon] + (let [icon-node (icon-span icon)] + (if icon-node + [:h1 [:span {:class "property-value-with-icon"} icon-node title]] + [:h1 title]))) + +(defn- theme-toggle-node + [] + [:button.theme-toggle + {:type "button" + :role "switch" + :aria-checked "false"} + [:span.theme-toggle__icon.theme-toggle__icon--day {:aria-hidden "true"}] + [:span.theme-toggle__thumb {:aria-hidden "true"}] + [:span.theme-toggle__icon.theme-toggle__icon--night {:aria-hidden "true"}]]) + +(defn- toolbar-node + [& nodes] + (into [:div.page-toolbar] nodes)) + +(defn- search-node + [graph-uuid] + (let [graph-id (some-> graph-uuid str)] + [:div.publish-search {:data-graph-uuid graph-id} + [:button.publish-search-toggle + {:type "button" + :aria-label "Search" + :aria-expanded "false"} + [:span.ti.ti-search {:aria-hidden "true"}]] + [:input.publish-search-input + (cond-> + {:id "publish-search-input" + :type "search" + :placeholder "Search graph (Cmd+K)" + :autocomplete "off" + :spellcheck "false" + :aria-label "Search graph"} + (string/blank? (or graph-id "")) + (assoc :disabled true :placeholder "Search unavailable"))] + [:div.publish-search-hint "Up/Down to navigate"] + [:div.publish-search-results + {:id "publish-search-results" + :hidden true}]])) + +(defn- theme-init-script + [] + [:script + "(function(){try{var k='publish-theme';var t=localStorage.getItem(k);if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();"]) + +(defn- publish-script + [] + [:script {:type "module" :src (str "/static/publish.js?v=" version)}]) + +(defn- icon-runtime-script + [] + [:script + "(function(){if(window.React&&window.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED){return;}var s='http://www.w3.org/2000/svg';var k=function(n){return n.replace(/[A-Z]/g,function(m){return'-'+m.toLowerCase();});};var a=function(el,key,val){if(key==='className'){el.setAttribute('class',val);return;}if(key==='style'&&val&&typeof val==='object'){for(var sk in val){el.style[sk]=val[sk];}return;}if(key==='ref'||key==='key'||key==='children'){return;}if(val===true){el.setAttribute(key,'');return;}if(val===false||val==null){return;}var attr=key;if(key==='strokeWidth'){attr='stroke-width';}else if(key==='strokeLinecap'){attr='stroke-linecap';}else if(key==='strokeLinejoin'){attr='stroke-linejoin';}else if(key!=='viewBox'&&/[A-Z]/.test(key)){attr=k(key);}el.setAttribute(attr,val);};var c=function(el,child){if(child==null||child===false){return;}if(Array.isArray(child)){child.forEach(function(n){c(el,n);});return;}if(typeof child==='string'||typeof child==='number'){el.appendChild(document.createTextNode(child));return;}if(child.nodeType){el.appendChild(child);} };var e=function(type,props){var children=Array.prototype.slice.call(arguments,2);if(type===Symbol.for('react.fragment')){var frag=document.createDocumentFragment();children.forEach(function(n){c(frag,n);});return frag;}if(typeof type==='function'){return type(Object.assign({},props,{children:children}));}var isSvg=type==='svg'||(props&&props.xmlns===s);var el=isSvg?document.createElementNS(s,type):document.createElement(type);if(props){for(var p in props){a(el,p,props[p]);}}children.forEach(function(n){c(el,n);});return el;};window.React={createElement:e,forwardRef:function(fn){return fn;},Fragment:Symbol.for('react.fragment'),__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:{ReactCurrentOwner:{current:null}}};window.PropTypes=new Proxy({}, {get:function(){return function(){return null;};}});})();"]) + +(defn- head-node + [title {:keys [description keywords topics tags url custom-css-hash graph-uuid]}] + (let [description (when (string? description) + (string/trim description)) + keywords (->> [keywords topics tags] + (map #(when (string? %) (string/trim %))) + (remove string/blank?) + (string/join ", ")) + meta-tags (remove nil? + [[:meta {:name "description" :content description}] + (when (seq keywords) + [:meta {:name "keywords" :content keywords}]) + (when (string? tags) + [:meta {:name "tags" :content tags}]) + (when (string? topics) + [:meta {:name "topics" :content topics}]) + [:meta {:content "summary_large_image" :name "twitter:card"}] + (when description + [:meta {:content description :name "twitter:description"}]) + [:meta {:content "@logseq" :name "twitter:site"}] + [:meta {:content title :name "twitter:title"}] + [:meta {:content "https://asset.logseq.com/static/img/social-banner-230118.png" + :name "twitter:image:src"}] + (when description + [:meta {:content description :name "twitter:image:alt"}]) + [:meta {:content title :property "og:title"}] + [:meta {:content "article" :property "og:type"}] + (when url + [:meta {:content url :property "og:url"}]) + [:meta {:content "https://asset.logseq.com/static/img/social-banner-230118.png" + :property "og:image"}] + (when description + [:meta {:content description :property "og:description"}]) + [:meta {:content "logseq" :property "og:site_name"}]]) + custom-css (when (and (string? custom-css-hash) (string? graph-uuid)) + [:link {:rel "stylesheet" + :href (str "/asset/" graph-uuid "/publish.css?v=" custom-css-hash)}])] + [:head + [:meta {:charset "UTF-8"}] + [:meta {:name "viewport" + :content "width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0"}] + [:meta {:http-equiv "X-UA-Compatible" :content "ie=edge"}] + [:title title] + [:link {:href "https://asset.logseq.com/static/img/logo.png" + :rel "shortcut icon" + :type "image/png"}] + [:link {:href "https://asset.logseq.com/static/img/logo.png" + :rel "shortcut icon" + :sizes "192x192"}] + [:link {:href "https://asset.logseq.com/static/img/logo.png" + :rel "apple-touch-icon"}] + [:meta {:content "Logseq" :name "apple-mobile-web-app-title"}] + [:meta {:content "yes" :name "apple-mobile-web-app-capable"}] + [:meta {:content "yes" :name "apple-touch-fullscreen"}] + [:meta {:content "black-translucent" :name "apple-mobile-web-app-status-bar-style"}] + [:meta {:content "yes" :name "mobile-web-app-capable"}] + (theme-init-script) + (icon-runtime-script) + [:script {:defer true :src "/static/tabler.ext.js"}] + [:link {:rel "stylesheet" + :href "https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.0/dist/tabler-icons.min.css"}] + [:link {:rel "stylesheet" :href (str "/static/tabler-extension.css?v=" version)}] + [:link {:rel "stylesheet" :href (str "/static/publish.css?v=" version)}] + custom-css + meta-tags])) + +(defn- render-head + ([title] (render-head title nil)) + ([title opts] + (head-node title (or opts {})))) + +(defn- meta-value + [meta k] + (or (get meta k) + (get meta (name k)))) + +(defn property-type + [prop-key property-type-by-ident] + (or (get property-type-by-ident prop-key) + (get-in db-property/built-in-properties [prop-key :schema :type]))) + +(defn property-hidden? + [prop-key property-hidden-by-ident] + (or (true? (get property-hidden-by-ident prop-key)) + (true? (get-in db-property/built-in-properties [prop-key :schema :hide?])))) + +(defn page-ref->uuid [name name->uuid] + (or (get name->uuid name) + (get name->uuid (common-util/page-name-sanity-lc name)))) + +(defn- entity->link-node + [entity ctx prop-key] + (let [title (publish-model/entity->title entity) + uuid (:block/uuid entity) + graph-uuid (:graph-uuid ctx)] + (cond + (and uuid graph-uuid (publish-model/page-entity? entity)) + [[:a.page-ref {:href (str "/page/" graph-uuid "/" uuid)} + (str (when (= prop-key :block/tags) "#") title)]] + (common-util/url? title) + [[:a {:href title} title]] + :else + [title]))) + +(defn property-value->nodes + [value prop-key ctx entities] + (let [prop-type (property-type prop-key (:property-type-by-ident ctx)) + ref-type? (contains? db-property-type/all-ref-property-types prop-type)] + (cond + (nil? value) + [] + + (string? value) + (cond + (= prop-type :datetime) + (if-let [formatted (format-datetime value)] + [formatted] + (content->nodes value (:uuid->title ctx) (:graph-uuid ctx))) + + :else + (content->nodes value (:uuid->title ctx) (:graph-uuid ctx))) + + (keyword? value) + [(name value)] + + (map? value) + (if-let [eid (:db/id value)] + (property-value->nodes eid prop-key ctx entities) + (if-let [content (db-property/property-value-content value)] + (property-value->nodes content prop-key ctx entities) + [(pr-str value)])) + + (or (set? value) (sequential? value)) + (nodes-join (map #(property-value->nodes % prop-key ctx entities) value) ", ") + + (number? value) + (cond + (= prop-type :datetime) + (if-let [formatted (format-datetime value)] + [formatted] + [(str value)]) + + (and ref-type? (get entities value)) + (let [entity (get entities value)] + (with-icon (:logseq.property/icon entity) + (entity->link-node entity ctx prop-key))) + + :else + [(str value)]) + + :else + [(str value)]))) + +(defn built-in-tag? + [entity] + (when-let [ident (:db/ident entity)] + (= "logseq.class" (namespace ident)))) + +(defn filter-tags + [values entities] + (let [values (if (sequential? values) values [values])] + (->> values + (remove (fn [value] + (cond + (keyword? value) (= "logseq.class" (namespace value)) + :else + (let [entity (cond + (map? value) value + (number? value) (get entities value) + :else nil)] + (and entity (built-in-tag? entity)))))) + vec))) + +(defn entity-properties + [entity ctx entities] + (let [props (db-property/properties entity) + inline-props (:block/properties entity) + props (if (map? inline-props) + (merge props inline-props) + props) + props (->> props + (remove (fn [[k _]] + (property-hidden? k (:property-hidden-by-ident ctx)))) + (map (fn [[k v]] + (if (= k :block/tags) + [k (filter-tags v entities)] + [k v]))) + (remove (fn [[_ v]] (property-value-empty? v))) + (remove (fn [[k v]] + (and (= k :block/tags) (property-value-empty? v))))) + props (into {} props)] + props)) + +(defn render-properties + [props ctx entities] + (when (seq props) + [:dl.properties + (for [[k v] (sort-by (fn [[prop-key _]] + (string/lower-case + (property-title prop-key (:property-title-by-ident ctx)))) + props)] + [:div.property + [:dt.property-name (property-title k (:property-title-by-ident ctx))] + [:dd.property-value {:dir "auto"} + (into [:span] (normalize-nodes (property-value->nodes v k ctx entities)))]])])) + +(defn- property-ui-position + [prop-key ctx] + (when-let [property (get (:property-entity-by-ident ctx) prop-key)] + (:logseq.property/ui-position property))) + +(defn- split-properties-by-position + [props ctx] + (reduce (fn [acc [k v]] + (let [position (property-ui-position k ctx) + bucket (case position + (:block-left :block-right :block-below) position + :properties)] + (update acc bucket assoc k v))) + {:properties {} + :block-left {} + :block-right {} + :block-below {}} + props)) + +(defn- sorted-properties + [props ctx] + (sort-by (fn [[prop-key _]] + (get-in ctx [:property-entity-by-ident prop-key :block/order])) + props)) + +(defn- class-has? + [class-name target] + (some #{target} (string/split (or class-name "") #"\s+"))) + +(defn- node-has-class? + [node target] + (when (and (vector? node) (keyword? (first node))) + (let [attrs (second node)] + (and (map? attrs) (class-has? (:class attrs) target))))) + +(defn- strip-positioned-value + [node] + (if (node-has-class? node "property-value-with-icon") + (let [[tag attrs & children] node + icon-children (filter #(node-has-class? % "property-icon") children)] + (if (seq icon-children) + (into [tag attrs] icon-children) + node)) + node)) + +(defn- positioned-value-nodes + [value prop-key ctx entities] + (cond + (= prop-key :logseq.property/icon) + (let [icon-node (icon-span value)] + (if icon-node [icon-node] [])) + + (= prop-key :block/tags) + (normalize-nodes (property-value->nodes value prop-key ctx entities)) + + :else + (->> (property-value->nodes value prop-key ctx entities) + normalize-nodes + (map strip-positioned-value)))) + +(defn- render-positioned-properties + [props ctx entities position] + (when (seq props) + (case position + :block-below + [:div.positioned-properties.block-below + (for [[k v] (sorted-properties props ctx)] + [:div.positioned-property + [:span.property-name (property-title k (:property-title-by-ident ctx))] + [:span.property-value {:dir "auto"} + (into [:span.inline-flex] (positioned-value-nodes v k ctx entities))]])] + + [:div {:class (str "positioned-properties " (name position))} + (for [[k v] (sorted-properties props ctx)] + [:span.positioned-property + (into [:span.inline-flex] (positioned-value-nodes v k ctx entities))])]))) + +(def ^:private youtube-regex #"^((?:https?:)?//)?((?:www|m).)?((?:youtube.com|youtu.be|y2u.be|youtube-nocookie.com))(/(?:shorts/|[\w-]+\?v=|embed/|v/)?)([\w-]+)([\S^\?]+)?$") +(def ^:private vimeo-regex #"^((?:https?:)?//)?((?:www).)?((?:player.vimeo.com|vimeo.com))(/(?:video/)?)([\w-]+)(\S+)?$") +(def ^:private bilibili-regex #"^((?:https?:)?//)?((?:www).)?((?:bilibili.com))(/(?:video/)?)([\w-]+)(\?p=(\d+))?(\S+)?$") +(def ^:private loom-regex #"^((?:https?:)?//)?((?:www).)?((?:loom.com))(/(?:share/|embed/))([\w-]+)(\S+)?$") + +(defn- safe-match + [re value] + (when (and (string? value) (not (string/blank? value))) + (re-find re value))) + +(defn- macro-iframe + [src {:keys [class title]}] + (when (and (string? src) (not (string/blank? src))) + (let [class-name (string/join " " (remove nil? ["macro-embed" class]))] + [:div {:class class-name} + [:iframe {:src src + :title (or title "Embedded content") + :loading "lazy" + :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + :allowfullscreen true}]]))) + +(defn- youtube-embed + [url] + (let [id (cond + (and (string? url) (= 11 (count url))) url + :else (nth (safe-match youtube-regex url) 5 nil))] + (when (and id (string? id)) + (macro-iframe (str "https://www.youtube.com/embed/" id) {:class "macro-embed--video" :title "YouTube"})))) + +(defn- vimeo-embed + [url] + (let [id (nth (safe-match vimeo-regex url) 5 nil)] + (when (and id (string? id)) + (macro-iframe (str "https://player.vimeo.com/video/" id) {:class "macro-embed--video" :title "Vimeo"})))) + +(defn- bilibili-embed + [url] + (let [id (if (<= (count (or url "")) 15) + url + (nth (safe-match bilibili-regex url) 5 nil))] + (when (and id (string? id) (not (string/blank? id))) + (macro-iframe (str "https://player.bilibili.com/player.html?bvid=" id "&high_quality=1&autoplay=0") + {:class "macro-embed--video" :title "Bilibili"})))) + +(defn- video-embed + [url] + (when (common-util/url? url) + (let [matches (or (safe-match youtube-regex url) + (safe-match loom-regex url) + (safe-match vimeo-regex url) + (safe-match bilibili-regex url)) + src (cond + (and matches (contains? #{"youtube.com" "youtu.be" "y2u.be" "youtube-nocookie.com"} (nth matches 3))) + (let [id (nth matches 5)] + (when (= 11 (count (or id ""))) + (str "https://www.youtube.com/embed/" id))) + + (and matches (string/ends-with? (nth matches 3) "loom.com")) + (str "https://www.loom.com/embed/" (nth matches 5)) + + (and matches (string/ends-with? (nth matches 3) "vimeo.com")) + (str "https://player.vimeo.com/video/" (nth matches 5)) + + (and matches (= (nth matches 3) "bilibili.com")) + (str "https://player.bilibili.com/player.html?bvid=" (nth matches 5) "&high_quality=1&autoplay=0") + + :else + url)] + (macro-iframe src {:class "macro-embed--video" :title "Video"})))) + +(defn- tweet-embed + [url] + (let [url (cond + (and (string? url) (<= (count url) 15)) (str "https://x.com/i/status/" url) + :else url)] + (when url + [:div.twitter-tweet + [:a {:href url} url]]))) + +(defn- tweet-embed-from-html + [html] + (let [id (last (safe-match #"/status/(\d+)" html))] + (when (and id (string? id)) + (tweet-embed id)))) + +(defn- macro->nodes + [ctx {:keys [name arguments]}] + (let [name (string/lower-case (or name "")) + arguments (if (sequential? arguments) arguments []) + first-arg (first arguments)] + (cond + (= name "cloze") + [[:span.cloze (string/join ", " arguments)]] + + (= name "youtube") + (when-let [node (youtube-embed first-arg)] [node]) + + (= name "vimeo") + (when-let [node (vimeo-embed first-arg)] [node]) + + (= name "bilibili") + (when-let [node (bilibili-embed first-arg)] [node]) + + (= name "video") + (when-let [node (video-embed first-arg)] [node]) + + (contains? #{"tweet" "twitter"} name) + (when-let [node (tweet-embed first-arg)] [node]) + + :else + (content->nodes (str "{{" name (when (seq arguments) + (str " " (string/join ", " arguments))) "}}") + (:uuid->title ctx) + (:graph-uuid ctx))))) + +(defn- parse-macro-text + [value] + (when-let [[_ name args] (and (string? value) + (re-find #"\{\{\s*([^\s\}]+)\s*([^}]*)\}\}" value))] + (let [args (->> (string/split (or args "") #",") + (map string/trim) + (remove string/blank?) + vec)] + {:name name + :arguments args}))) + +(defn- normalize-macro-data + [data] + (cond + (map? data) data + (string? data) (parse-macro-text data) + (and (sequential? data) (seq data)) + (let [name (first data) + args (second data)] + {:name (when (string? name) name) + :arguments (if (sequential? args) args [])}) + :else nil)) + +(defn- macro-embed-node? + [node] + (when (vector? node) + (let [tag (first node) + attrs (second node)] + (and (= tag :div) + (map? attrs) + (string? (:class attrs)) + (string/includes? (:class attrs) "macro-embed"))))) + +(defn inline->nodes [ctx item] + (let [[type data] item + {:keys [uuid->title name->uuid graph-uuid]} ctx] + (cond + (or (= "Plain" type) (= "Spaces" type)) + (let [sub-ast (inline-ast data) + simple-plain? (and (= 1 (count sub-ast)) + (= "Plain" (ffirst sub-ast)))] + (if (and (seq sub-ast) (not simple-plain?)) + (mapcat #(inline->nodes ctx %) sub-ast) + (content->nodes data uuid->title graph-uuid))) + + (= "Break_Line" type) + [[:br]] + + (= "Emphasis" type) + (let [[[kind] items] data + tag (case kind + "Bold" :strong + "Italic" :em + "Underline" :ins + "Strike_through" :del + "Highlight" :mark + :span) + children (mapcat #(inline->nodes ctx %) items)] + [(into [tag] children)]) + + (or (= "Verbatim" type) (= "Code" type)) + [[:code data]] + + (= "Link" type) + (let [url (:url data) + label (:label data) + [link-type link-value] url + label-nodes (cond + (vector? label) (mapcat #(inline->nodes ctx %) label) + (seq? label) (mapcat #(inline->nodes ctx %) label) + (string? label) (content->nodes label uuid->title graph-uuid) + :else []) + page-uuid (when (= "Page_ref" link-type) + (or (page-ref->uuid link-value name->uuid) + (when (common-util/uuid-string? link-value) link-value))) + page-title (when page-uuid + (get uuid->title page-uuid)) + label-nodes (cond + (seq label-nodes) label-nodes + page-title [page-title] + (string? link-value) [link-value] + :else [""]) + href (cond + page-uuid (str "/page/" graph-uuid "/" page-uuid) + (= "Complex" link-type) (when (and (map? link-value) + (string? (:protocol link-value)) + (string? (:link link-value))) + (str (:protocol link-value) "://" (:link link-value))) + (string? link-value) link-value + :else nil)] + (if href + [(into [:a {:class (when page-uuid "page-ref") + :href href}] label-nodes)] + label-nodes)) + + (= "Tag" type) + (let [s (or (second data) "") + page-uuid (page-ref->uuid s name->uuid)] + (if page-uuid + [[:a.page-ref {:href (str "/page/" graph-uuid "/" page-uuid)} (str "#" s)]] + (if (and graph-uuid (not (string/blank? s))) + [[:a.page-ref {:href (str "/tag/" (js/encodeURIComponent s))} (str "#" s)]] + [(str "#" s)]))) + + (= "Macro" type) + (if-let [macro-data (normalize-macro-data data)] + (or (macro->nodes ctx macro-data) []) + (content->nodes (str data) uuid->title graph-uuid)) + + (= "Email" type) + (let [email (str (:local_part data) "@" (:domain data))] + [[:a {:href (str "mailto:" email)} email]]) + + (or (= "Inline_Html" type) (= "Export_Snippet" type)) + (if-let [node (tweet-embed-from-html data)] + [node] + []) + + :else + (content->nodes (str data) uuid->title graph-uuid)))) + +(defn- inline-coll->nodes + [ctx inline-coll] + (mapcat #(inline->nodes ctx %) (or inline-coll []))) + +(declare block-ast->nodes) +(defn- block-ast-coll->nodes + [ctx content] + (mapcat #(block-ast->nodes ctx %) (or content []))) + +(defn- list-items->node + [ctx items] + (into + [:ul] + (map (fn [item] + (let [content (let [content (:content item)] + (if (and (sequential? content) + (every? #(and (vector? %) (string? (first %))) content)) + (block-ast-coll->nodes ctx content) + (inline-coll->nodes ctx content))) + nested (when (seq (:items item)) + [(list-items->node ctx (:items item))]) + children (concat content nested)] + (into [:li] children))) + items))) + +(defn- block-ast->nodes + [ctx block-ast'] + (let [[type data] block-ast'] + (case type + "Paragraph" + (let [children (inline-coll->nodes ctx data)] + (when (seq children) + [(into [:p] children)])) + + "Heading" + (let [children (inline-coll->nodes ctx (:title data))] + (when (seq children) + [(into [:p] children)])) + + "List" + (when (seq data) + [(list-items->node ctx data)]) + + "Quote" + (when (seq data) + [(into [:blockquote] (mapcat #(block-ast->nodes ctx %) data))]) + + "Example" + (when (seq data) + [[:pre (string/join "\n" data)]]) + + "Src" + (let [lines (:lines data) + code (if (sequential? lines) (string/join "\n" lines) (str lines))] + [[:pre [:code code]]]) + + "Paragraph_Sep" + [[:br]] + + "Horizontal_Rule" + [[:hr]] + + (let [fallback (content->nodes (str data) (:uuid->title ctx) (:graph-uuid ctx))] + (when (seq fallback) + [(into [:p] fallback)]))))) + +(defn- block-ast-complex? + [block-asts] + (let [block-asts (seq block-asts)] + (and block-asts + (or (> (count block-asts) 1) + (some (fn [[type _]] + (not (contains? #{"Paragraph" "Heading"} type))) + block-asts))))) + +(defn- heading-level + [block depth] + (let [legacy (:block/heading-level block) + prop (:logseq.property/heading block) + legacy (when (and (number? legacy) (<= 1 legacy 6)) legacy) + prop (cond + (and (number? prop) (<= 1 prop 6)) prop + (true? prop) (min (inc depth) 6) + :else nil)] + (or legacy prop))) + +(defn- strip-heading-prefix + [s] + (string/replace s #"^\s*#+\s+" "")) + +(defn- property-value->text + [value ctx entities] + (cond + (nil? value) nil + (string? value) value + (keyword? value) (name value) + (number? value) + (if-let [entity (get entities value)] + (publish-model/entity->title entity) + (str value)) + (map? value) + (if (:db/id value) + (publish-model/entity->title value) + (if-let [content (db-property/property-value-content value)] + (str content) + (str value))) + (sequential? value) + (->> value + (map #(property-value->text % ctx entities)) + (remove string/blank?) + distinct + (string/join ", ")) + :else (str value))) + +(defn block-content-nodes [block ctx depth] + (let [raw (or (:block/title block) + (:block/name block) + "") + heading (heading-level block depth) + raw (if heading + (strip-heading-prefix raw) + raw) + block-asts (when-not heading (block-ast raw)) + block-level? (and (not heading) (block-ast-complex? block-asts)) + content (if block-level? + (mapcat #(block-ast->nodes ctx %) block-asts) + (let [ast (inline-ast raw)] + (if (seq ast) + (mapcat #(inline->nodes ctx %) ast) + (content->nodes raw (:uuid->title ctx) (:graph-uuid ctx))))) + container (cond + heading (keyword (str "h" heading ".block-text.block-heading")) + block-level? :div.block-text + (some macro-embed-node? content) :div.block-text + :else :span.block-text)] + (into [container {:dir "auto"}] content))) + +(defn block-raw-content [block] + (or (:block/title block) + (:block/name block) + "")) + +(defn- asset-url [block ctx] + (let [asset-type (:logseq.property.asset/type block) + asset-uuid (:block/uuid block) + external-url (:logseq.property.asset/external-url block) + graph-uuid (:graph-uuid ctx)] + (cond + (string? external-url) external-url + (and asset-uuid asset-type graph-uuid) + (str "/asset/" graph-uuid "/" asset-uuid "." asset-type) + :else nil))) + +(def ^:private publish-image-variant-sizes + [1024 1600]) + +(def ^:private publish-image-variant-types + #{"png" "jpg" "jpeg" "webp"}) + +(def ^:private publish-image-sizes-attr + "(max-width: 640px) 92vw, (max-width: 1024px) 88vw, 920px") + +(defn- asset-variant-url + [graph-uuid asset-uuid asset-type variant] + (str "/asset/" graph-uuid "/" asset-uuid "@" variant "." asset-type)) + +(defn- variant-width + [block size] + (let [asset-width (:logseq.property.asset/width block) + asset-height (:logseq.property.asset/height block)] + (if (and (number? asset-width) + (number? asset-height) + (pos? asset-width) + (pos? asset-height)) + (let [max-dim (max asset-width asset-height) + scale (min 1 (/ size max-dim))] + (js/Math.round (* asset-width scale))) + size))) + +(defn- asset-node [block ctx] + (let [asset-type (:logseq.property.asset/type block) + asset-url' (asset-url block ctx) + external-url (:logseq.property.asset/external-url block) + title (or (:block/title block) (str asset-type)) + ext (string/lower-case (or asset-type "")) + graph-uuid (:graph-uuid ctx) + asset-uuid (:block/uuid block) + variant? (and (not (string? external-url)) + graph-uuid + asset-uuid + (contains? publish-image-variant-types ext)) + srcset (when variant? + (->> publish-image-variant-sizes + (map (fn [size] + (let [width (variant-width block size)] + (str (asset-variant-url graph-uuid asset-uuid asset-type size) + " " + width + "w")))) + (string/join ", ")))] + (when asset-url' + (cond + (contains? #{"png" "jpg" "jpeg" "gif" "webp" "svg" "bmp" "avif"} ext) + [:img.asset-image (cond-> {:src asset-url' :alt title} + srcset (assoc :srcset srcset :sizes publish-image-sizes-attr))] + + (contains? #{"mp4" "webm" "mov"} ext) + [:video.asset-video {:src asset-url' :controls true}] + + (contains? #{"mp3" "wav" "ogg"} ext) + [:audio.asset-audio {:src asset-url' :controls true}] + + :else + [:a.asset-link {:href asset-url' :target "_blank"} title])))) + +(defn block-display-node [block ctx depth] + (let [display-type (:logseq.property.node/display-type block) + asset-node' (when (:logseq.property.asset/type block) + (asset-node block ctx))] + (case display-type + :asset asset-node' + :code + (let [lang (:logseq.property.code/lang block) + attrs (cond-> {:class "code-block"} + (string? lang) (assoc :data-lang lang))] + [:div attrs [:code (block-raw-content block)]]) + + :math + [:div.math-block (block-raw-content block)] + + :quote + [:blockquote.quote-block (block-content-nodes block ctx depth)] + + (or asset-node' + (block-content-nodes block ctx depth))))) + +(defn block-content-from-ref [ref ctx] + (let [raw (or (get ref "source_block_content") "") + block-asts (block-ast raw) + block-level? (block-ast-complex? block-asts) + content (if block-level? + (mapcat #(block-ast->nodes ctx %) block-asts) + (let [ast (inline-ast raw)] + (if (seq ast) + (mapcat #(inline->nodes ctx %) ast) + (content->nodes raw (:uuid->title ctx) (:graph-uuid ctx)))))] + (into [(if block-level? :div.block-text :span.block-text) {:dir "auto"}] content))) + +(comment + (def ^:private void-tags + #{"area" "base" "br" "col" "embed" "hr" "img" "input" "link" "meta" "param" "source" "track" "wbr"})) + +(defn render-hiccup [node] + (hiccups.core/html node)) + +(defn sort-blocks [blocks] + (sort-by (fn [block] + (or (:block/order block) (:block/uuid block) "")) + blocks)) + +(defn- linked-block-entity + [block ctx visited] + (let [link (:block/link block) + linked-id (cond + (map? link) (:db/id link) + (number? link) link + :else nil)] + (when (and linked-id (not (contains? visited linked-id))) + (get (:entities ctx) linked-id)))) + +(defn render-block-tree + ([page-children-by-parent linked-children-by-parent parent-id ctx] + (render-block-tree page-children-by-parent linked-children-by-parent parent-id ctx #{} 1)) + ([page-children-by-parent linked-children-by-parent parent-id ctx visited depth] + (let [children (get page-children-by-parent parent-id)] + (when (seq children) + [:ul.blocks + (map (fn [block] + (let [linked-block (linked-block-entity block ctx visited) + display-block (or linked-block block) + display-id (:db/id display-block) + visited (cond-> visited linked-block (conj display-id)) + nested (render-block-tree + (if linked-block linked-children-by-parent page-children-by-parent) + linked-children-by-parent + display-id + ctx + visited + (inc depth)) + has-children? (boolean nested) + collapsed? (:block/collapsed? display-block) + raw-props (entity-properties display-block ctx (:entities ctx)) + icon-prop (get raw-props :logseq.property/icon) + tags-prop (get raw-props :block/tags) + raw-props (dissoc raw-props :logseq.property/icon :block/tags) + {:keys [properties block-left block-right block-below]} + (split-properties-by-position raw-props ctx) + block-left (cond-> block-left + (and icon-prop (not (property-value-empty? icon-prop))) + (assoc :logseq.property/icon icon-prop)) + block-right (cond-> block-right + (and tags-prop (not (property-value-empty? tags-prop))) + (assoc :block/tags tags-prop)) + positioned-left (render-positioned-properties block-left ctx (:entities ctx) :block-left) + positioned-right (render-positioned-properties block-right ctx (:entities ctx) :block-right) + positioned-below (render-positioned-properties block-below ctx (:entities ctx) :block-below) + properties (render-properties properties ctx (:entities ctx)) + block-uuid (:block/uuid display-block) + block-uuid-str (some-> block-uuid str)] + [:li + (cond-> {:data-block-uuid block-uuid-str + :class (if collapsed? "block is-collapsed" "block")} + block-uuid-str (assoc :id (str "block-" block-uuid-str))) + [:div.block-content + (when positioned-left positioned-left) + (block-display-node display-block ctx depth) + (when positioned-right positioned-right) + (when has-children? + [:button.block-toggle + {:type "button" + :aria-expanded (str (not collapsed?))} + "▾"])] + (when positioned-below positioned-below) + (when properties + [:div.block-properties properties]) + (when nested + [:div.block-children nested])])) + (sort-blocks children))])))) + +(defn linked-references + [ctx graph-uuid linked-by-page] + [:section.linked-refs + [:h2 "Linked references"] + (for [{:keys [page_uuid page_title blocks]} linked-by-page] + (let [ref-page-uuid page_uuid + ref-page-title page_title + href (when (and graph-uuid ref-page-uuid) + (str "/page/" graph-uuid "/" ref-page-uuid))] + [:div.ref-page + (if href + [:a.page-ref {:href href} ref-page-title] + [:div.ref-title ref-page-title]) + (when (seq blocks) + [:ul.ref-blocks + (for [block blocks] + [:li.ref-block [:div.block-content (block-content-from-ref block ctx)]])])]))]) + +(defn tag-item-val [item k] + (cond + (map? item) (or (get item k) + (get item (name k))) + (object? item) (or (aget item (name k)) + (aget item k)) + :else nil)) + +(defn format-timestamp + [ts] + (when (number? ts) + (.toLocaleString (js/Date. ts)))) + +(defn render-tagged-item + [graph-uuid item] + (let [item-graph-uuid (tag-item-val item :graph_uuid) + graph-uuid (or item-graph-uuid graph-uuid) + source-page-uuid (tag-item-val item :source_page_uuid) + source-page-title (tag-item-val item :source_page_title) + source-block-uuid (tag-item-val item :source_block_uuid) + source-block-content (tag-item-val item :source_block_content) + updated-at (tag-item-val item :updated_at) + page? (and source-page-uuid (= source-page-uuid source-block-uuid)) + href (when (and graph-uuid source-page-uuid) + (str "/page/" graph-uuid "/" source-page-uuid))] + [:li.tagged-item + [:div.tagged-main + (if href + [:a.page-ref {:href href} (or source-page-title source-page-uuid)] + [:span (or source-page-title source-page-uuid)]) + (when (and source-block-content (not page?)) + [:div.tagged-block source-block-content])] + [:span.tagged-meta (or (format-timestamp updated-at) "—")]])) + +(defn- author-usernames + [entities page-eid page-entity] + (let [author-ids (->> entities + (keep (fn [[_e entity]] + (when (= (:block/page entity) page-eid) + (publish-model/ref-eid (:logseq.property/created-by-ref entity))))) + (concat [(publish-model/ref-eid (:logseq.property/created-by-ref page-entity))]) + (remove nil?) + distinct)] + (->> author-ids + (map #(get entities %)) + (keep publish-model/entity->title) + (remove string/blank?) + distinct + sort))) + +(defn ^:large-vars/cleanup-todo render-page-html + [transit page-uuid-str refs-data tagged-nodes] + (let [payload (publish-common/read-transit-safe transit) + meta (publish-common/get-publish-meta payload) + graph-uuid (when meta + (or (:graph meta) + (:publish/graph meta) + (get meta "graph") + (get meta "publish/graph"))) + datoms (:datoms payload) + entities (publish-model/datoms->entities datoms) + page-uuid (uuid page-uuid-str) + page-entity (some (fn [[_e entity]] + (when (= (:block/uuid entity) page-uuid) + entity)) + entities) + page-title (publish-model/entity->title page-entity) + page-updated-at (:block/updated-at page-entity) + page-eid (some (fn [[e entity]] + (when (= (:block/uuid entity) page-uuid) + e)) + entities) + authors (author-usernames entities page-eid page-entity) + uuid->title (reduce (fn [acc [_e entity]] + (if-let [uuid-value (:block/uuid entity)] + (assoc acc (str uuid-value) (publish-model/entity->title entity)) + acc)) + {} + entities) + name->uuid (reduce (fn [acc [_e entity]] + (if-let [uuid-value (:block/uuid entity)] + (let [uuid-str (str uuid-value) + title (:block/title entity)] + (assoc acc title uuid-str)) + acc)) + {} + entities) + property-title-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident (publish-model/entity->title entity)) + acc)) + {} + entities) + property-type-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident (:logseq.property/type entity)) + acc)) + {} + entities) + property-hidden-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident (true? (:logseq.property/hide? entity))) + acc)) + {} + entities) + property-entity-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident entity) + acc)) + {} + entities) + children-by-parent (->> entities + (reduce (fn [acc [e entity]] + (if (and (= (:block/page entity) page-eid) + (not= e page-eid) + (not (:logseq.property/created-from-property entity))) + (let [parent (or (:block/parent entity) page-eid)] + (update acc parent (fnil conj []) entity)) + acc)) + {}) + (reduce-kv (fn [acc k v] + (assoc acc k (sort-blocks v))) + {})) + linked-children-by-parent (->> entities + (reduce (fn [acc [_e entity]] + (if (and (:block/parent entity) + (not (:logseq.property/created-from-property entity))) + (update acc (:block/parent entity) (fnil conj []) entity) + acc)) + {}) + (reduce-kv (fn [acc k v] + (assoc acc k (sort-blocks v))) + {})) + ctx {:uuid->title uuid->title + :name->uuid name->uuid + :graph-uuid graph-uuid + :property-title-by-ident property-title-by-ident + :property-type-by-ident property-type-by-ident + :property-hidden-by-ident property-hidden-by-ident + :property-entity-by-ident property-entity-by-ident + :entities entities} + page-props (entity-properties page-entity ctx entities) + page-properties (render-properties (dissoc page-props + :logseq.property/icon + :logseq.property.publish/published-url) + ctx + entities) + blocks (render-block-tree children-by-parent linked-children-by-parent page-eid ctx) + linked-by-page (when refs-data + (->> (get refs-data "refs") + (group-by #(get % "source_page_uuid")) + (map (fn [[_ items]] + {:page_title (get (first items) "source_page_title") + :page_uuid (get (first items) "source_page_uuid") + :blocks items})) + (sort-by (fn [{:keys [page_title]}] + (string/lower-case (or page_title "")))))) + linked-refs (when (seq linked-by-page) + (linked-references ctx graph-uuid linked-by-page)) + tagged-section (when (seq tagged-nodes) + [:section.tagged-pages + [:h2 "Tagged nodes"] + [:ul.tagged-list + (for [item tagged-nodes] + (render-tagged-item graph-uuid item))]]) + description (property-value->text (get page-props :logseq.property/description) ctx entities) + tags (property-value->text (or (get page-props :block/tags) + (get page-props :logseq.property/page-tags)) + ctx + entities) + keywords (property-value->text (get page-props :logseq.property/keywords) ctx entities) + topics (property-value->text (get page-props :logseq.property/topics) ctx entities) + page-url (when (and graph-uuid page-uuid-str) + (str "/page/" graph-uuid "/" page-uuid-str)) + custom-css-hash (meta-value meta :custom_publish_css_hash) + custom-js-hash (meta-value meta :custom_publish_js_hash) + doc [:html + (render-head page-title {:description description + :keywords keywords + :topics topics + :tags tags + :url page-url + :custom-css-hash custom-css-hash + :graph-uuid graph-uuid}) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + + (page-title-node page-title (:logseq.property/icon page-entity)) + [:div.page-meta + (when true + [:div.page-authors (str "By: " (string/join ", " authors))]) + + (let [updated-at (format-timestamp page-updated-at)] + [:div.page-updated-at updated-at])] + + (when page-properties + [:section.page-properties + page-properties]) + + (when blocks blocks) + (when tagged-section tagged-section) + (when linked-refs linked-refs)] + (publish-script) + (when (and (string? custom-js-hash) (string? graph-uuid)) + [:script {:defer true + :src (str "/asset/" graph-uuid "/publish.js?v=" custom-js-hash)}])]]] + (str "" (render-hiccup doc)))) + +(defn render-graph-html + [graph-uuid pages] + (let [rows (->> pages + (map (fn [page] + (let [page-uuid (aget page "page_uuid") + page-title (aget page "page_title") + updated-at (aget page "updated_at") + href (str "/page/" graph-uuid "/" page-uuid) + short-id (aget page "short_id")] + {:page-uuid page-uuid + :page-title page-title + :href href + :short-id short-id + :updated-at updated-at}))) + (sort-by (fn [row] + (or (:updated-at row) 0))) + reverse) + doc [:html + (render-head "Published pages") + [:body + [:main.wrap + (toolbar-node + (search-node graph-uuid) + (theme-toggle-node)) + [:h1 "Published pages"] + (if (seq rows) + [:ul.page-list + (for [{:keys [page-uuid page-title href updated-at]} rows] + [:li.page-item + [:div.page-links + [:a.page-link {:href href} (or page-title page-uuid)]] + [:span.page-meta (or (format-timestamp updated-at) "—")]])] + [:p "No pages have been published yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-user-html + [username user pages] + (let [username (or (aget user "username") username) + rows (->> pages + (map (fn [page] + (let [page-uuid (aget page "page_uuid") + page-title (aget page "page_title") + updated-at (aget page "updated_at") + graph-uuid (aget page "graph_uuid") + href (str "/page/" graph-uuid "/" page-uuid) + short-id (aget page "short_id")] + {:page-uuid page-uuid + :page-title page-title + :href href + :short-id short-id + :updated-at updated-at + :graph-uuid graph-uuid}))) + (sort-by (fn [row] + (or (:updated-at row) 0))) + reverse) + title (str "Published by " username) + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + (search-node nil) + (theme-toggle-node)) + [:h1 title] + (if (seq rows) + [:ul.page-list + (for [{:keys [page-uuid page-title href updated-at]} rows] + [:li.page-item + [:div.page-links + [:a.page-link {:href href} (or page-title page-uuid)]] + [:span.page-meta + (or (format-timestamp updated-at) "—")]])] + [:p "No pages have been published yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-tag-html + [graph-uuid tag-uuid tag-title tag-items] + (let [rows tag-items + title (or tag-title tag-uuid) + doc [:html + (render-head (str "#" title)) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + [:h1 (str "#" title)] + (if (seq rows) + [:ul.page-list + (for [item rows] + (render-tagged-item graph-uuid item))] + [:p "No published nodes use this tag yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-tag-name-html + [tag-name tag-title tag-items] + (let [rows tag-items + title (or tag-title tag-name) + doc [:html + (render-head (str "Tag - " title)) + [:body + [:main.wrap + (toolbar-node + [:a.toolbar-btn {:href "/"} "Home"] + (theme-toggle-node)) + [:h1 (str "#" title)] + (if (seq rows) + [:ul.page-list + (for [row rows] + (render-tagged-item (tag-item-val row :graph_uuid) row))] + [:p "No published pages use this tag yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-home-html + [] + (let [doc [:html + (render-head "Logseq Publish") + [:body.publish-home + [:svg#publish-home-bg.publish-home-bg + {:aria-hidden "true"}] + [:main.publish-home-card + [:div.publish-home-logo "Logseq Publish"] + [:h1.publish-home-title + "Small notes," + [:br] + [:strong "big "] + "connections!"] + [:p.publish-home-subtitle + "Publish your Logseq notes to the web. Each note links through " + [:code "#tag"] + " or " + [:code "[[page]] references"] + ", connecting your dots with others."]] + [:script + "(function(){\n" + " const svg = document.getElementById('publish-home-bg');\n" + " if (!svg) return;\n" + " let width = window.innerWidth;\n" + " let height = window.innerHeight;\n" + " const POINTS_COUNT = 40;\n" + " const MAX_DIST = 160;\n" + " const pts = [];\n" + " let circlesGroup;\n" + " let linesGroup;\n" + "\n" + " const cssVar = (name) =>\n" + " getComputedStyle(document.documentElement)\n" + " .getPropertyValue(name)\n" + " .trim();\n" + "\n" + " function resize() {\n" + " width = window.innerWidth;\n" + " height = window.innerHeight;\n" + " svg.setAttribute('width', width);\n" + " svg.setAttribute('height', height);\n" + " svg.setAttribute('viewBox', `0 0 ${width} ${height}`);\n" + " }\n" + "\n" + " function createSvgElement(tag, attrs) {\n" + " const el = document.createElementNS('http://www.w3.org/2000/svg', tag);\n" + " for (const k in attrs) el.setAttribute(k, attrs[k]);\n" + " return el;\n" + " }\n" + "\n" + " function init() {\n" + " resize();\n" + " svg.innerHTML = '';\n" + "\n" + " const lineColor = cssVar('--muted') || '#6f6e69';\n" + " const dotColor = cssVar('--ink') || '#282726';\n" + "\n" + " linesGroup = createSvgElement('g', {\n" + " stroke: lineColor,\n" + " 'stroke-width': 0.6,\n" + " 'stroke-linecap': 'round',\n" + " opacity: 0.35\n" + " });\n" + " circlesGroup = createSvgElement('g', { fill: dotColor, opacity: 0.65 });\n" + "\n" + " svg.appendChild(linesGroup);\n" + " svg.appendChild(circlesGroup);\n" + " pts.length = 0;\n" + "\n" + " for (let i = 0; i < POINTS_COUNT; i++) {\n" + " const x = Math.random() * width;\n" + " const y = Math.random() * height;\n" + " const speed = 0.15 + Math.random() * 0.25;\n" + " const angle = Math.random() * Math.PI * 2;\n" + " const vx = Math.cos(angle) * speed;\n" + " const vy = Math.sin(angle) * speed;\n" + "\n" + " const circle = createSvgElement('circle', {\n" + " cx: x,\n" + " cy: y,\n" + " r: 2 + Math.random() * 1.2\n" + " });\n" + "\n" + " circlesGroup.appendChild(circle);\n" + " pts.push({ x, y, vx, vy, circle });\n" + " }\n" + " }\n" + "\n" + " function step() {\n" + " linesGroup.innerHTML = '';\n" + "\n" + " for (let i = 0; i < pts.length; i++) {\n" + " const p = pts[i];\n" + " p.x += p.vx;\n" + " p.y += p.vy;\n" + "\n" + " if (p.x < 0 || p.x > width) p.vx *= -1;\n" + " if (p.y < 0 || p.y > height) p.vy *= -1;\n" + "\n" + " p.circle.setAttribute('cx', p.x);\n" + " p.circle.setAttribute('cy', p.y);\n" + " }\n" + "\n" + " for (let i = 0; i < pts.length; i++) {\n" + " for (let j = i + 1; j < pts.length; j++) {\n" + " const p1 = pts[i];\n" + " const p2 = pts[j];\n" + " const dx = p1.x - p2.x;\n" + " const dy = p1.y - p2.y;\n" + " const dist = Math.sqrt(dx * dx + dy * dy);\n" + " if (dist < MAX_DIST) {\n" + " const opacity = 0.35 * (1 - dist / MAX_DIST);\n" + " const line = createSvgElement('line', {\n" + " x1: p1.x,\n" + " y1: p1.y,\n" + " x2: p2.x,\n" + " y2: p2.y,\n" + " opacity: opacity.toString()\n" + " });\n" + " linesGroup.appendChild(line);\n" + " }\n" + " }\n" + " }\n" + "\n" + " requestAnimationFrame(step);\n" + " }\n" + "\n" + " window.addEventListener('resize', () => {\n" + " init();\n" + " });\n" + "\n" + " init();\n" + " requestAnimationFrame(step);\n" + "})();\n"]]]] + (str "" (render-hiccup doc)))) + +(defn render-ref-html + [graph-uuid ref-name ref-title ref-items] + (let [rows ref-items + title (or ref-title ref-name) + doc [:html + (render-head (str "Ref - " title)) + [:body + [:main.wrap + (toolbar-node + [:a.toolbar-btn {:href "/"} "Home"] + (theme-toggle-node)) + [:h1 title] + [:p.tag-sub (str "Reference: " ref-name)] + (if (seq rows) + [:ul.page-list + (for [row rows + :let [graph-id (or (tag-item-val row :graph_uuid) graph-uuid) + page-uuid (tag-item-val row :source_page_uuid) + page-title (tag-item-val row :source_page_title) + href (when (and graph-id page-uuid) + (str "/page/" graph-id "/" page-uuid))]] + [:li.page-item + [:div.page-links + (if href + [:a.page-ref {:href href} (or page-title page-uuid)] + [:span (or page-title page-uuid)])] + [:span.page-meta (or (format-timestamp (tag-item-val row :updated_at)) "—")]])] + [:p "No published pages reference this yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-not-published-html + [graph-uuid] + (let [title "Page not published" + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + [:h1 title] + [:p.tag-sub "This page hasn't been published yet."] + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-password-html + [graph-uuid page-uuid wrong?] + (let [title "Protected page" + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + [:div.password-card + [:h1 title] + [:p.tag-sub "This page is password protected."] + (when wrong? + [:p.password-error "Incorrect password."]) + [:form.password-form {:method "GET"} + (when page-uuid + [:input {:type "hidden" :name "page" :value page-uuid}]) + [:label.password-label {:for "publish-password"} "Enter password"] + [:input.password-input {:id "publish-password" + :name "password" + :type "password" + :placeholder "Password" + :required true}] + [:button.toolbar-btn {:type "submit"} "Unlock"]]] + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-404-html + [] + (let [title "Page not found" + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + [:a.toolbar-btn {:href "/"} "Home"] + (theme-toggle-node)) + [:div.not-found + [:p.not-found-eyebrow "404"] + [:h1 title] + [:p.tag-sub "We couldn't find that page. It may have been removed or never published."]] + (publish-script)]]]] + (str "" (render-hiccup doc)))) diff --git a/deps/publish/src/logseq/publish/routes.cljs b/deps/publish/src/logseq/publish/routes.cljs new file mode 100644 index 0000000..b24d09e --- /dev/null +++ b/deps/publish/src/logseq/publish/routes.cljs @@ -0,0 +1,784 @@ +(ns logseq.publish.routes + "Handles routing and responses for worker" + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [logseq.common.authorization :as authorization] + [logseq.publish.assets :as publish-assets] + [logseq.publish.common :as publish-common] + [logseq.publish.index :as publish-index] + [logseq.publish.model :as publish-model] + [logseq.publish.render :as publish-render] + [promesa.core :as p] + [shadow.resource :as resource])) + +(def publish-css (resource/inline "logseq/publish/publish.css")) +(def publish-js (resource/inline "logseq/publish/publish.js")) +(def tabler-ext-js (resource/inline "js/tabler.ext.js")) +;; Should this be used? +;; (def tabler-extension-css (resource/inline "css/tabler-extension.css")) + +(defn- request-password + [request] + (let [url (js/URL. (.-url request)) + query (.get (.-searchParams url) "password") + header (.get (.-headers request) "x-publish-password")] + (or header query))) + +(defn- fetch-page-password-hash + [graph-uuid page-uuid env] + ;; NOTE: DO stubs from (.get do-ns do-id) are thenable under the + ;; new cloudflare:workers DurableObject RPC runtime. Binding them + ;; in (p/let) causes promesa to await them, triggering + ;; structured-clone of the stub (DataCloneError on RpcPromise). + ;; Keep stub acquisition in a sync (let). + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/password") + #js {:method "GET"})] + (when (.-ok resp) + (p/let [data (.json resp)] + (aget data "password_hash")))))) + +(defn- check-page-password + [request graph-uuid page-uuid env] + (p/let [stored-hash (fetch-page-password-hash graph-uuid page-uuid env)] + (if (string/blank? stored-hash) + {:allowed? true :provided? false} + (let [provided (request-password request)] + (if (string? provided) + (p/let [valid? (publish-common/verify-password provided stored-hash)] + {:allowed? valid? :provided? true}) + {:allowed? false :provided? false}))))) + +(defn- auth-claims + [request env] + (p/let [auth-header (.get (.-headers request) "authorization") + token (when (and auth-header (string/starts-with? auth-header "Bearer ")) + (subs auth-header 7)) + claims (cond + (nil? token) nil + :else (authorization/verify-jwt token env))] + {:claims claims})) + +(defn handle-post-pages [request env] + (-> (p/let [auth-header (.get (.-headers request) "authorization") + token (when (and auth-header (string/starts-with? auth-header "Bearer ")) + (subs auth-header 7)) + claims (cond + (nil? token) nil + :else (authorization/verify-jwt token env))] + (if (nil? claims) + (publish-common/unauthorized) + (p/let [body (.arrayBuffer request)] + (let [{:keys [content_hash content_length graph page_uuid schema_version block_count created_at] :as meta} + (or (publish-common/parse-meta-header request) + (publish-common/meta-from-body body)) + payload (publish-common/read-transit-safe (.decode publish-common/text-decoder body)) + payload-entities (publish-model/datoms->entities (:datoms payload)) + page-eid (some (fn [[e entity]] + (when (= (:block/uuid entity) (uuid page_uuid)) + e)) + payload-entities) + page-title (or (:page-title payload) + (get payload "page-title") + (when page-eid + (publish-model/entity->title (get payload-entities page-eid)))) + blocks (or (:blocks payload) + (get payload "blocks")) + page-password (or (:page-password payload) + (get payload "page-password")) + refs (when (and page-eid page-title) + (publish-index/page-refs-from-payload payload page-eid page_uuid page-title graph)) + tagged-nodes (when (and page-eid page-title) + (publish-index/page-tagged-nodes-from-payload payload page-eid page_uuid page-title graph))] + (cond + (not (publish-common/valid-meta? meta)) + (publish-common/bad-request "missing publish metadata") + + :else + ;; NOTE: DO stubs from (.get do-ns do-id) are thenable under the + ;; new cloudflare:workers DurableObject RPC runtime. Binding them + ;; in a (p/let) causes promesa to await them, which triggers + ;; structured-clone of the stub and crashes with + ;; DataCloneError: Could not serialize object of type "RpcPromise". + ;; Keep stubs in a sync (let) so promesa never awaits them. + (let [graph-uuid graph + r2-key (str "publish/" graph-uuid "/" + content_hash ".transit") + r2 (aget env "PUBLISH_R2") + ^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns + (str graph-uuid + ":" + page_uuid)) + do-stub (.get do-ns do-id) + page-tags (or (:page-tags payload) + (get payload "page-tags")) + owner-sub (:owner_sub meta) + owner-username (:owner_username meta) + updated-at (.now js/Date)] + (when-not (and owner-sub owner-username) + (throw (ex-info "owner sub or username is missing" + {:owner-sub owner-sub + :owner-username owner-username}))) + (p/let [existing (.head r2 r2-key) + _ (when-not existing + (.put r2 r2-key body + #js {:httpMetadata #js {:contentType "application/transit+json"}})) + short-id (publish-common/short-id-for-page graph-uuid page_uuid) + password-hash (when (and (string? page-password) + (not (string/blank? page-password))) + (publish-common/hash-password page-password))] + (let [payload (bean/->js + {:page_uuid page_uuid + :page_title page-title + :page_tags (when page-tags + (js/JSON.stringify (clj->js page-tags))) + :password_hash password-hash + :graph graph-uuid + :schema_version schema_version + :block_count block_count + :content_hash content_hash + :content_length content_length + :r2_key r2-key + :owner_sub owner-sub + :owner_username owner-username + :created_at created_at + :updated_at updated-at + :short_id short-id + :refs refs + :tagged_nodes tagged-nodes + :blocks (when (seq blocks) + (map (fn [block] + (assoc block :updated_at updated-at)) + blocks))}) + body-str (js/JSON.stringify payload)] + (p/let [meta-resp (.fetch do-stub "https://publish/pages" + #js {:method "POST" + :headers #js {"content-type" "application/json"} + :body body-str})] + (if-not (.-ok meta-resp) + (publish-common/json-response {:error "metadata store failed"} 500) + (let [index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [_ (.fetch index-stub "https://publish/pages" + #js {:method "POST" + :headers #js {"content-type" "application/json"} + :body body-str})] + (publish-common/json-response {:page_uuid page_uuid + :graph_uuid graph-uuid + :r2_key r2-key + :short_id short-id + :short_url (str "/p/" short-id) + :updated_at (.now js/Date)}))))))))))))) + (p/catch (fn [^js err] + (js/console.error "publish:post-pages error" + (some-> err .-name) + (some-> err .-message) + (some-> err .-stack)) + (publish-common/json-response + {:error "internal"} + 500))))) + +(defn handle-tag-page-html [graph-uuid tag-uuid env] + (if (or (nil? graph-uuid) (nil? tag-uuid)) + (publish-common/bad-request "missing graph uuid or tag uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" tag-uuid "/tagged_nodes") + #js {:method "GET"})] + (if-not (.-ok tags-resp) + (publish-common/not-found) + (p/let [raw (.json tags-resp) + tag-items (js->clj (or (aget raw "tagged_nodes") #js []) + :keywordize-keys true) + tag-title (or (some (fn [item] + (let [title (publish-render/tag-item-val item :tag_title)] + (when (and title (not (string/blank? title))) + title))) + tag-items) + tag-uuid)] + (js/Response. + (publish-render/render-tag-html graph-uuid tag-uuid tag-title tag-items) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + +(defn handle-get-page [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] + (if-not (.-ok meta-resp) + (handle-tag-page-html graph-uuid page-uuid env) + (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (p/let [meta (.json meta-resp) + etag (aget meta "content_hash") + if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))] + (if (and etag if-none-match (= etag if-none-match)) + (js/Response. nil #js {:status 304 + :headers (publish-common/merge-headers + #js {:etag etag} + (publish-common/cors-headers))}) + (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))))))) + +(defn handle-get-page-transit [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] + (if-not (.-ok meta-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (p/let [meta (.json meta-resp) + r2-key (aget meta "r2_key")] + (if-not r2-key + (publish-common/json-response {:error "missing transit"} 404) + (p/let [etag (aget meta "content_hash") + if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match")) + signed-url (when-not (and etag if-none-match (= etag if-none-match)) + (publish-common/presign-r2-url r2-key env))] + (if (and etag if-none-match (= etag if-none-match)) + (js/Response. nil #js {:status 304 + :headers (publish-common/merge-headers + #js {:etag etag} + (publish-common/cors-headers))}) + (publish-common/json-response {:url signed-url + :expires_in 300 + :etag etag} + 200))))))))))))) + +(defn handle-get-page-refs [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [refs-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))] + (if-not (.-ok refs-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [refs (.json refs-resp)] + (publish-common/json-response (js->clj refs :keywordize-keys true) 200)))))))))) + +(defn handle-get-page-tagged-nodes [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))] + (if-not (.-ok tags-resp) + (publish-common/not-found) + (p/let [tags (.json tags-resp)] + (publish-common/json-response (js->clj tags :keywordize-keys true) 200)))))))))) + +(defn handle-list-graph-pages-by-uuid [graph-uuid env] + (if-not graph-uuid + (publish-common/bad-request "missing graph uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid) + #js {:method "GET"})] + (if-not (.-ok meta-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [meta (.json meta-resp)] + (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))) + +(defn handle-graph-search [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + query (.get (.-searchParams url) "q")] + (if (or (string/blank? graph-uuid) (string/blank? query)) + (publish-common/bad-request "missing graph uuid or query") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub + (str "https://publish/search/" graph-uuid + "?q=" (js/encodeURIComponent query)) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp)] + (publish-common/json-response (js->clj data :keywordize-keys true) 200)))))))) + +(defn handle-graph-html [graph-uuid env] + (if-not graph-uuid + (publish-common/bad-request "missing graph uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid) + #js {:method "GET"})] + (if-not (.-ok meta-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [meta (.json meta-resp) + pages (or (aget meta "pages") #js [])] + (js/Response. + (publish-render/render-graph-html graph-uuid pages) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + +(defn handle-tag-name-json [tag-name env] + (if-not tag-name + (publish-common/bad-request "missing tag name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [data (.json resp)] + (publish-common/json-response (js->clj data :keywordize-keys true) 200))))))) + +(defn handle-tag-name-html [tag-name env] + (if-not tag-name + (publish-common/bad-request "missing tag name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [data (.json resp) + rows (or (aget data "tagged_nodes") #js []) + title (or tag-name "Tag")] + (js/Response. + (publish-render/render-tag-name-html tag-name title rows) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + +(defn handle-ref-name-json [ref-name env] + (if-not ref-name + (publish-common/bad-request "missing ref name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp)] + (publish-common/json-response (js->clj data :keywordize-keys true) 200))))))) + +(defn handle-ref-name-html [ref-name env] + (if-not ref-name + (publish-common/bad-request "missing ref name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp) + rows (or (aget data "pages") #js []) + title (or ref-name "Reference")] + (js/Response. + (publish-render/render-ref-html "all" ref-name title rows) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + +(defn handle-list-graph-pages [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil)] + (handle-list-graph-pages-by-uuid graph-uuid env))) + +(defn handle-delete-page [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (p/let [{:keys [claims]} (auth-claims request env)] + (if (nil? claims) + (publish-common/unauthorized) + (let [^js do-ns (aget env "PUBLISH_META_DO") + page-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + page-stub (.get do-ns page-id) + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [meta-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "GET"})] + (if-not (.-ok meta-resp) + (publish-common/not-found) + (p/let [meta (.json meta-resp) + owner-sub (aget meta "owner_sub") + subject (aget claims "sub")] + (if (or (string/blank? owner-sub) + (not= owner-sub subject)) + (publish-common/forbidden) + (p/let [page-resp (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "DELETE"}) + index-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "DELETE"})] + (if (or (not (.-ok page-resp)) (not (.-ok index-resp))) + (publish-common/not-found) + (publish-common/json-response {:ok true} 200))))))))))))) + +(defn handle-delete-graph [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil)] + (if-not graph-uuid + (publish-common/bad-request "missing graph uuid") + (p/let [{:keys [claims]} (auth-claims request env)] + (if (nil? claims) + (publish-common/unauthorized) + (let [^js do-ns (aget env "PUBLISH_META_DO") + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [list-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid) + #js {:method "GET"})] + (if-not (.-ok list-resp) + (publish-common/not-found) + (p/let [data (.json list-resp) + pages (or (aget data "pages") #js []) + subject (aget claims "sub") + owner-mismatch? (some (fn [page] + (let [owner-sub (aget page "owner_sub")] + (or (string/blank? owner-sub) + (not= owner-sub subject)))) + (array-seq pages))] + (if owner-mismatch? + (publish-common/forbidden) + (p/let [_ (js/Promise.all + (map (fn [page] + (let [page-uuid (aget page "page_uuid") + page-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + page-stub (.get do-ns page-id)] + (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "DELETE"}))) + pages)) + del-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid) + #js {:method "DELETE"})] + (if-not (.-ok del-resp) + (publish-common/not-found) + (publish-common/json-response {:ok true} 200))))))))))))) + +(defn handle-page-html [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + do-stub (.get do-ns do-id) + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] + (if-not (.-ok meta-resp) + (p/let [tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes") + #js {:method "GET"})] + (if (and tags-resp (.-ok tags-resp)) + (p/let [raw (.json tags-resp) + tag-items (js->clj (or (aget raw "tagged_nodes") #js []) + :keywordize-keys true) + tag-title (or (some (fn [item] + (let [title (publish-render/tag-item-val item :tag_title)] + (when (and title (not (string/blank? title))) + title))) + tag-items) + page-uuid)] + (if (seq tag-items) + (js/Response. + (publish-render/render-tag-html graph-uuid page-uuid tag-title tag-items) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (js/Response. + (publish-render/render-not-published-html graph-uuid) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))) + (js/Response. + (publish-render/render-not-published-html graph-uuid) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))) + (p/let [{:keys [allowed? provided?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (js/Response. + (publish-render/render-password-html graph-uuid page-uuid provided?) + #js {:status 401 + :headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [meta (.json meta-resp) + etag (aget meta "content_hash") + if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match")) + refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs")) + refs-json (when (and refs-resp (.-ok refs-resp)) + (p/let [raw (.json refs-resp)] + (js->clj raw :keywordize-keys false))) + tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes") + #js {:method "GET"}) + tagged-nodes (when (and tags-resp (.-ok tags-resp)) + (p/let [raw (.json tags-resp)] + (js->clj (or (aget raw "tagged_nodes") #js []) + :keywordize-keys true))) + r2 (aget env "PUBLISH_R2") + object (.get r2 (aget meta "r2_key"))] + (if (and etag if-none-match (= etag if-none-match)) + (js/Response. nil #js {:status 304 + :headers (publish-common/merge-headers + #js {:etag etag + "cache-control" "public, max-age=300, must-revalidate"} + (publish-common/cors-headers))}) + (if-not object + (publish-common/json-response {:error "missing transit blob"} 404) + (p/let [buffer (.arrayBuffer object) + transit (.decode publish-common/text-decoder buffer)] + (let [headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8" + "cache-control" "public, max-age=300, must-revalidate"} + (publish-common/cors-headers))] + (when etag + (.set headers "etag" etag)) + (js/Response. + (publish-render/render-page-html transit page-uuid refs-json tagged-nodes) + #js {:headers headers})))))))))))))) + +(defn- rewrite-request-path + [request new-path] + (let [request-url (js/URL. (.-url request)) + new-url (js/URL. (str (.-origin request-url) new-path))] + (set! (.-search new-url) (.-search request-url)) + (js/Request. (str new-url) request))) + +(defn ^:large-vars/cleanup-todo handle-fetch [request env] + (let [url (js/URL. (.-url request)) + path (.-pathname url) + method (.-method request)] + (cond + (= method "OPTIONS") + (js/Response. nil #js {:status 204 :headers (publish-common/cors-headers)}) + + (and (= path "/static/publish.css") (= method "GET")) + (js/Response. + publish-css + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/css; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (= path "/static/publish.js") (= method "GET")) + (js/Response. + publish-js + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/javascript; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (= path "/static/tabler.ext.js") (= method "GET")) + (js/Response. + tabler-ext-js + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/javascript; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (= path "/") (= method "GET")) + (js/Response. + (publish-render/render-home-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (string/starts-with? path "/page/") (= method "GET")) + (handle-page-html request env) + + (and (= path "/assets") (= method "POST")) + (publish-assets/handle-post-asset request env) + + (and (= path "/pages") (= method "POST")) + (handle-post-pages request env) + + (and (string/starts-with? path "/search/") (= method "GET")) + (handle-graph-search request env) + + (and (string/starts-with? path "/graph/") (= method "GET")) + (let [parts (string/split path #"/") + graph-uuid (nth parts 2 nil)] + (if (= (nth parts 3 nil) "json") + (handle-list-graph-pages-by-uuid graph-uuid env) + (handle-graph-html graph-uuid env))) + + (and (string/starts-with? path "/tag/") (= method "GET")) + (let [parts (string/split path #"/") + raw-name (nth parts 2 nil) + tag-name (when raw-name + (js/decodeURIComponent raw-name))] + (if (= (nth parts 3 nil) "json") + (handle-tag-name-json tag-name env) + (handle-tag-name-html tag-name env))) + + (and (string/starts-with? path "/ref/") (= method "GET")) + (let [parts (string/split path #"/") + raw-name (nth parts 2 nil) + ref-name (when raw-name + (js/decodeURIComponent raw-name))] + (if (= (nth parts 3 nil) "json") + (handle-ref-name-json ref-name env) + (handle-ref-name-html ref-name env))) + + (and (string/starts-with? path "/asset/") (= method "GET")) + (let [parts (string/split path #"/") + graph-uuid (nth parts 2 nil) + file-name (nth parts 3 nil)] + (if (or (string/blank? graph-uuid) (string/blank? file-name)) + (publish-common/bad-request "missing asset id") + (let [ext-idx (string/last-index-of file-name ".") + asset-uuid (when (and ext-idx (pos? ext-idx)) + (subs file-name 0 ext-idx)) + asset-type (when (and ext-idx (pos? ext-idx)) + (subs file-name (inc ext-idx)))] + (if (or (string/blank? asset-uuid) (string/blank? asset-type)) + (publish-common/bad-request "invalid asset id") + (p/let [r2 (aget env "PUBLISH_R2") + r2-key (str "publish/assets/" graph-uuid "/" asset-uuid "." asset-type) + ^js object (.get r2 r2-key)] + (if-not object + (publish-common/not-found) + (let [headers (publish-common/merge-headers + #js {"content-type" (or (some-> object .-httpMetadata .-contentType) + (publish-assets/asset-content-type asset-type)) + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))] + (js/Response. (.-body object) + #js {:headers headers})))))))) + + (and (or (string/starts-with? path "/p/") + (string/starts-with? path "/s/")) + (= method "GET")) + (let [parts (string/split path #"/") + short-id (nth parts 2 nil)] + (if (string/blank? short-id) + (publish-common/bad-request "missing short id") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/short/" short-id) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp) + row (aget data "page")] + (if-not row + (publish-common/not-found) + (let [graph-uuid (aget row "graph_uuid") + page-uuid (aget row "page_uuid") + page-path (str "/page/" graph-uuid "/" page-uuid) + page-request (rewrite-request-path request page-path)] + (handle-page-html page-request env))))))))) + + (and (string/starts-with? path "/u/") (= method "GET")) + (let [parts (string/split path #"/") + username (nth parts 2 nil)] + (if (string/blank? username) + (publish-common/bad-request "missing username") + (let [^js do-ns (aget env "PUBLISH_META_DO") + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [resp (.fetch index-stub (str "https://publish/user/" username) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp) + user (aget data "user") + rows (or (aget data "pages") #js [])] + (js/Response. + (publish-render/render-user-html username user rows) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + + (and (string/starts-with? path "/pages/") (= method "GET")) + (let [parts (string/split path #"/")] + (cond + (= (count parts) 3) (handle-list-graph-pages request env) + (= (nth parts 4 nil) "transit") (handle-get-page-transit request env) + (= (nth parts 4 nil) "refs") (handle-get-page-refs request env) + (= (nth parts 4 nil) "tagged_nodes") (handle-get-page-tagged-nodes request env) + :else (handle-get-page request env))) + + (and (string/starts-with? path "/pages/") (= method "DELETE")) + (let [parts (string/split path #"/")] + (if (= (count parts) 3) + (handle-delete-graph request env) + (handle-delete-page request env))) + + :else + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))})))) diff --git a/deps/publish/src/logseq/publish/worker.cljs b/deps/publish/src/logseq/publish/worker.cljs new file mode 100644 index 0000000..4b40ef1 --- /dev/null +++ b/deps/publish/src/logseq/publish/worker.cljs @@ -0,0 +1,23 @@ +(ns logseq.publish.worker + "Main public ns for worker" + (:require ["cloudflare:workers" :refer [DurableObject]] + [logseq.publish.meta-store :as meta-store] + [logseq.publish.routes :as publish-routes] + [shadow.cljs.modern :refer (defclass)])) + +(def worker + #js {:fetch (fn [request env _ctx] + (publish-routes/handle-fetch request env))}) + +(defclass PublishMetaDO + (extends DurableObject) + + (constructor [this ^js state env] + (super state env) + (set! (.-state this) state) + (set! (.-env this) env) + (set! (.-sql this) (.-sql ^js (.-storage state)))) + + Object + (fetch [this request] + (meta-store/do-fetch this request))) diff --git a/deps/publish/test/logseq/publish/common_test.cljs b/deps/publish/test/logseq/publish/common_test.cljs new file mode 100644 index 0000000..97669a1 --- /dev/null +++ b/deps/publish/test/logseq/publish/common_test.cljs @@ -0,0 +1,100 @@ +(ns logseq.publish.common-test + (:require [cljs.test :refer [async deftest is testing]] + [logseq.publish.common :as publish-common] + [promesa.core :as p])) + +(deftest merge-headers-overrides-and-preserves-values + (let [headers (publish-common/merge-headers #js {"a" "1" "keep" "ok"} + #js {"a" "2" "b" "3"})] + (is (= "2" (.get headers "a"))) + (is (= "3" (.get headers "b"))) + (is (= "ok" (.get headers "keep"))))) + +(deftest parse-meta-header-valid-and-invalid-json + (testing "valid json meta header is parsed into keywordized map" + (let [request (js/Request. "https://publish.example/pages" + #js {:headers #js {"x-publish-meta" "{\"content_hash\":\"h\",\"graph\":\"g\",\"page_uuid\":\"p\"}"}}) + meta (publish-common/parse-meta-header request)] + (is (= "h" (:content_hash meta))) + (is (= "g" (:graph meta))) + (is (= "p" (:page_uuid meta))))) + (testing "invalid json returns nil" + (let [request (js/Request. "https://publish.example/pages" + #js {:headers #js {"x-publish-meta" "{not-json"}})] + (is (nil? (publish-common/parse-meta-header request)))))) + +(deftest valid-meta-requires-core-fields + (is (some? (publish-common/valid-meta? {:content_hash "h" :graph "g" :page_uuid "p"}))) + (is (nil? (publish-common/valid-meta? {:content_hash "h" :graph "g"}))) + (is (nil? (publish-common/valid-meta? nil)))) + +(deftest get-sql-rows-handles-supported-shapes + (is (= [] (publish-common/get-sql-rows nil))) + (is (= [{"a" 1}] + (js->clj (publish-common/get-sql-rows #js {:rows #js [#js {"a" 1}]}) + :keywordize-keys false))) + (let [row (js-obj)] + (aset row "rows" #js [#js {"b" 2}]) + (is (= [{"rows" [{"b" 2}]}] + (js->clj (publish-common/get-sql-rows #js [row]) + :keywordize-keys false))))) + +(deftest json-error-response-helpers-set-status + (async done + (-> (p/let [unauthorized (publish-common/unauthorized) + forbidden (publish-common/forbidden) + bad-request (publish-common/bad-request "bad") + not-found (publish-common/not-found) + unauthorized-body (.json unauthorized) + forbidden-body (.json forbidden) + bad-request-body (.json bad-request) + not-found-body (.json not-found)] + (is (= 401 (.-status unauthorized))) + (is (= 403 (.-status forbidden))) + (is (= 400 (.-status bad-request))) + (is (= 404 (.-status not-found))) + (is (= "unauthorized" (aget unauthorized-body "error"))) + (is (= "forbidden" (aget forbidden-body "error"))) + (is (= "bad" (aget bad-request-body "error"))) + (is (= "not found" (aget not-found-body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest normalize-etag-removes-double-quotes + (is (= "abc" (publish-common/normalize-etag "\"abc\""))) + (is (= "abc" (publish-common/normalize-etag "abc"))) + (is (nil? (publish-common/normalize-etag nil)))) + +(deftest encode-path-encodes-path-segments + (is (= "with%20space/plus%2Bsign" (publish-common/encode-path "with space/plus+sign")))) + +(deftest short-id-for-page-is-deterministic-and-fixed-length + (async done + (-> (p/let [a (publish-common/short-id-for-page "graph-1" "page-1") + b (publish-common/short-id-for-page "graph-1" "page-1") + c (publish-common/short-id-for-page "graph-1" "page-2")] + (is (= 10 (count a))) + (is (= a b)) + (is (not= a c)) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest hash-and-verify-password-roundtrip + (async done + (-> (p/let [hashed (publish-common/hash-password "secret-value") + ok? (publish-common/verify-password "secret-value" hashed) + wrong? (publish-common/verify-password "wrong-value" hashed)] + (is (string? hashed)) + (is (true? ok?)) + (is (false? wrong?)) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest verify-password-rejects-invalid-hash-format + (is (false? (publish-common/verify-password "secret" "not-a-valid-hash")))) diff --git a/deps/publish/test/logseq/publish/render_test.cljs b/deps/publish/test/logseq/publish/render_test.cljs new file mode 100644 index 0000000..1a5c707 --- /dev/null +++ b/deps/publish/test/logseq/publish/render_test.cljs @@ -0,0 +1,58 @@ +(ns logseq.publish.render-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.publish.render :as render])) + +(deftest entity-properties-filters-explicitly-hidden-properties + (testing "property is filtered when ctx marks it hidden" + (let [entity {:user.property/secret "value"} + ctx {:property-hidden-by-ident {:user.property/secret true}} + result (render/entity-properties entity ctx {})] + (is (nil? (get result :user.property/secret)))))) + +(deftest entity-properties-filters-built-in-hidden-properties + (testing "built-in properties with :hide? true are filtered without property entity metadata" + (let [entity {:logseq.property/created-from-property "some-value"} + ctx {:property-hidden-by-ident {}} + result (render/entity-properties entity ctx {})] + (is (nil? (get result :logseq.property/created-from-property)))))) + +(deftest filter-tags-removes-built-in-tag-values + (testing "built-in class keyword tags are removed" + (let [result (render/filter-tags [:logseq.class/Tag :user.property/custom] {})] + (is (= [:user.property/custom] result)))) + (testing "built-in class entities are removed" + (let [entities {1 {:db/ident :logseq.class/Tag} + 2 {:db/ident :user.property/custom}} + result (render/filter-tags [1 2] entities)] + (is (= [2] result))))) + +(deftest block-content-nodes-use-auto-dir + (let [node (render/block-content-nodes + {:block/title "english then عربي"} + {:uuid->title {} :graph-uuid "g"} + 1)] + (is (= "auto" (get-in node [1 :dir]))))) + +(deftest block-content-from-ref-uses-auto-dir + (let [node (render/block-content-from-ref + {"source_block_content" "كلام عربي then english"} + {:uuid->title {} :graph-uuid "g"})] + (is (= "auto" (get-in node [1 :dir]))))) + +(deftest render-properties-use-auto-dir + (let [props {:user.property/sample "english then عربي"} + ctx {:property-title-by-ident {} + :property-type-by-ident {}} + rendered (render/render-properties props ctx {}) + entry (first (second rendered)) + dd-node (nth entry 2)] + (is (= :dd.property-value (first dd-node))) + (is (= "auto" (get-in dd-node [1 :dir]))))) + +(deftest block-content-nodes-embeds-youtube-shorts-player + (let [node (render/block-content-nodes + {:block/title "{{video https://www.youtube.com/shorts/xu9p5ynlhZk}}"} + {:uuid->title {} :graph-uuid "g"} + 1) + html (render/render-hiccup node)] + (is (re-find #"https://www\.youtube\.com/embed/xu9p5ynlhZk" html)))) diff --git a/deps/publish/test/logseq/publish/routes_test.cljs b/deps/publish/test/logseq/publish/routes_test.cljs new file mode 100644 index 0000000..a202cbf --- /dev/null +++ b/deps/publish/test/logseq/publish/routes_test.cljs @@ -0,0 +1,393 @@ +(ns logseq.publish.routes-test + (:require [cljs.test :refer [async deftest is testing]] + [logseq.common.authorization :as authorization] + [logseq.publish.common :as publish-common] + [logseq.publish.routes :as routes] + [promesa.core :as p])) + +(defn- json-response + [data] + (js/Response. + (js/JSON.stringify data) + #js {:status 200 + :headers #js {"content-type" "application/json"}})) + +(defn- short-url-env + [] + (let [do-stub #js {:fetch (fn [url _opts] + (cond + (= "https://publish/short/abc123" url) + (js/Promise.resolve + (json-response + #js {:page #js {"graph_uuid" "graph-1" + "page_uuid" "page-1"}})) + + (re-matches #"https://publish/pages/[^/]+/[^/]+/password" url) + (js/Promise.resolve (json-response #js {})) + + (re-matches #"https://publish/pages/[^/]+/[^/]+" url) + (js/Promise.resolve + (json-response + #js {"content_hash" "etag-1" + "r2_key" "publish/graph-1/page-1.transit"})) + + :else + (js/Promise.resolve + (js/Response. "not found" #js {:status 404}))))} + do-ns #js {:idFromName (fn [_name] "index") + :get (fn [_id] do-stub)} + r2 #js {:get (fn [_key] + (js/Promise.resolve + #js {:arrayBuffer (fn [] + (let [payload (.encode (js/TextEncoder.) "{}")] + (js/Promise.resolve (.-buffer payload))))}))}] + #js {"PUBLISH_META_DO" do-ns + "PUBLISH_R2" r2})) + +(defn- empty-env [] + #js {}) + +(defn- json-error-response + [status message] + (js/Response. + (js/JSON.stringify #js {"error" message}) + #js {:status status + :headers #js {"content-type" "application/json"}})) + +(defn- ok-json-response + [data] + (js/Response. + (js/JSON.stringify data) + #js {:status 200 + :headers #js {"content-type" "application/json"}})) + +(defn- method-from-opts + [opts] + (or (some-> opts (aget "method")) + "GET")) + +(defn- permission-env + [route-dispatch] + (let [do-ns #js {:idFromName (fn [name] name) + :get (fn [id] + #js {:fetch (fn [url opts] + (js/Promise.resolve + (route-dispatch id url (method-from-opts opts))))})}] + #js {"PUBLISH_META_DO" do-ns})) + +(deftest short-url-does-not-redirect-to-uuid-url + (testing "short URL should not redirect to /page/:graph/:page" + (async done + (let [request (js/Request. "https://publish.example/p/abc123?password=s3cr3t") + env (short-url-env)] + (-> (p/let [response (routes/handle-fetch request env) + body (.text response)] + (is (= 200 (.-status response))) + (is (nil? (.get (.-headers response) "location"))) + (is (re-find #"" body)) + (is (not (re-find #"Page not found" body))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))))) + +(deftest legacy-s-short-url-is-supported + (testing "legacy /s/:short-id should render page html" + (async done + (let [request (js/Request. "https://publish.example/s/abc123") + env (short-url-env)] + (-> (p/let [response (routes/handle-fetch request env) + body (.text response)] + (is (= 200 (.-status response))) + (is (nil? (.get (.-headers response) "location"))) + (is (re-find #"" body)) + (is (not (re-find #"Page not found" body))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))))) + +(deftest page-uuid-route-is-still-supported + (testing "legacy /page/:graph-uuid/:page-uuid should still render page html" + (async done + (let [request (js/Request. "https://publish.example/page/3bc00ad3-f421-41e7-8c65-40861c298be5/6954ee2a-506b-4dd9-bd6d-0dc24db9c055") + env (short-url-env)] + (-> (p/let [response (routes/handle-fetch request env) + body (.text response)] + (is (= 200 (.-status response))) + (is (nil? (.get (.-headers response) "location"))) + (is (re-find #"" body)) + (is (not (re-find #"Page not found" body))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))))) + +(deftest options-route-returns-cors-no-content + (async done + (let [request (js/Request. "https://publish.example/any" + #js {:method "OPTIONS"})] + (-> (p/let [response (routes/handle-fetch request (empty-env))] + (is (= 204 (.-status response))) + (is (= "*" (.get (.-headers response) "access-control-allow-origin"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest home-route-renders-html + (async done + (let [request (js/Request. "https://publish.example/")] + (-> (p/let [response (routes/handle-fetch request (empty-env)) + body (.text response)] + (is (= 200 (.-status response))) + (is (re-find #"" body)) + (is (re-find #"Logseq Publish" body)) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest static-assets-return-content-types + (async done + (-> (p/let [css-resp (routes/handle-fetch (js/Request. "https://publish.example/static/publish.css") (empty-env)) + js-resp (routes/handle-fetch (js/Request. "https://publish.example/static/publish.js") (empty-env)) + ext-resp (routes/handle-fetch (js/Request. "https://publish.example/static/tabler.ext.js") (empty-env))] + (is (= "text/css; charset=utf-8" (.get (.-headers css-resp) "content-type"))) + (is (= "text/javascript; charset=utf-8" (.get (.-headers js-resp) "content-type"))) + (is (= "text/javascript; charset=utf-8" (.get (.-headers ext-resp) "content-type"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest short-url-missing-id-returns-bad-request + (async done + (-> (p/let [response (routes/handle-fetch (js/Request. "https://publish.example/p/") (empty-env)) + body (.json response)] + (is (= 400 (.-status response))) + (is (= "missing short id" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest short-url-not-found-returns-not-found + (async done + (let [do-stub #js {:fetch (fn [_url _opts] + (js/Promise.resolve (js/Response. "nope" #js {:status 404})))} + do-ns #js {:idFromName (fn [_name] "index") + :get (fn [_id] do-stub)} + env #js {"PUBLISH_META_DO" do-ns}] + (-> (p/let [response (routes/handle-fetch (js/Request. "https://publish.example/p/abc") env) + body (.json response)] + (is (= 404 (.-status response))) + (is (= "not found" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest asset-route-validates-missing-or-invalid-id + (async done + (-> (p/let [missing-id-resp (routes/handle-fetch (js/Request. "https://publish.example/asset//") (empty-env)) + missing-id-body (.json missing-id-resp) + invalid-id-resp (routes/handle-fetch (js/Request. "https://publish.example/asset/g/noext") (empty-env)) + invalid-id-body (.json invalid-id-resp)] + (is (= 400 (.-status missing-id-resp))) + (is (= "missing asset id" (aget missing-id-body "error"))) + (is (= 400 (.-status invalid-id-resp))) + (is (= "invalid asset id" (aget invalid-id-body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest user-route-validates-missing-username + (async done + (-> (p/let [response (routes/handle-fetch (js/Request. "https://publish.example/u/") (empty-env)) + body (.json response)] + (is (= 400 (.-status response))) + (is (= "missing username" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest post-pages-without-auth-is-unauthorized + (async done + (-> (p/let [request (js/Request. "https://publish.example/pages" + #js {:method "POST" + :headers #js {"content-type" "application/transit+json"} + :body "{}"}) + response (routes/handle-fetch request (empty-env)) + body (.json response)] + (is (= 401 (.-status response))) + (is (= "unauthorized" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest delete-page-without-auth-is-unauthorized + (async done + (-> (p/let [request (js/Request. "https://publish.example/pages/graph-1/page-1" + #js {:method "DELETE"}) + response (routes/handle-fetch request (empty-env)) + body (.json response)] + (is (= 401 (.-status response))) + (is (= "unauthorized" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))) + +(deftest delete-page-owner-mismatch-is-forbidden + (async done + (let [env (permission-env + (fn [id url method] + (cond + (and (= id "index") + (= method "GET") + (= url "https://publish/pages/graph-1/page-1")) + (ok-json-response #js {"owner_sub" "owner-a"}) + + :else + (json-error-response 404 "not found")))) + request (js/Request. "https://publish.example/pages/graph-1/page-1" + #js {:method "DELETE" + :headers #js {"authorization" "Bearer token"}})] + (-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-b"})] + (routes/handle-fetch request env)) + body (.json response)] + (is (= 403 (.-status response))) + (is (= "forbidden" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest delete-page-owner-match-succeeds + (async done + (let [env (permission-env + (fn [id url method] + (cond + (and (= id "index") + (= method "GET") + (= url "https://publish/pages/graph-1/page-1")) + (ok-json-response #js {"owner_sub" "owner-a"}) + + (and (= id "index") + (= method "DELETE") + (= url "https://publish/pages/graph-1/page-1")) + (ok-json-response #js {"ok" true}) + + (and (= id "graph-1:page-1") + (= method "DELETE") + (= url "https://publish/pages/graph-1/page-1")) + (ok-json-response #js {"ok" true}) + + :else + (json-error-response 404 "not found")))) + request (js/Request. "https://publish.example/pages/graph-1/page-1" + #js {:method "DELETE" + :headers #js {"authorization" "Bearer token"}})] + (-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-a"})] + (routes/handle-fetch request env)) + body (.json response)] + (is (= 200 (.-status response))) + (is (true? (aget body "ok"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest delete-graph-owner-mismatch-is-forbidden + (async done + (let [env (permission-env + (fn [id url method] + (cond + (and (= id "index") + (= method "GET") + (= url "https://publish/pages/graph-1")) + (ok-json-response #js {"pages" #js [#js {"owner_sub" "owner-a" "page_uuid" "page-1"}]}) + + :else + (json-error-response 404 "not found")))) + request (js/Request. "https://publish.example/pages/graph-1" + #js {:method "DELETE" + :headers #js {"authorization" "Bearer token"}})] + (-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-b"})] + (routes/handle-fetch request env)) + body (.json response)] + (is (= 403 (.-status response))) + (is (= "forbidden" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest delete-graph-owner-match-succeeds + (async done + (let [env (permission-env + (fn [id url method] + (cond + (and (= id "index") + (= method "GET") + (= url "https://publish/pages/graph-1")) + (ok-json-response #js {"pages" #js [#js {"owner_sub" "owner-a" "page_uuid" "page-1"} + #js {"owner_sub" "owner-a" "page_uuid" "page-2"}]}) + + (and (= id "index") + (= method "DELETE") + (= url "https://publish/pages/graph-1")) + (ok-json-response #js {"ok" true}) + + (and (= method "DELETE") + (or (and (= id "graph-1:page-1") (= url "https://publish/pages/graph-1/page-1")) + (and (= id "graph-1:page-2") (= url "https://publish/pages/graph-1/page-2")))) + (ok-json-response #js {"ok" true}) + + :else + (json-error-response 404 "not found")))) + request (js/Request. "https://publish.example/pages/graph-1" + #js {:method "DELETE" + :headers #js {"authorization" "Bearer token"}})] + (-> (p/let [response (p/with-redefs [authorization/verify-jwt (fn [_ _] #js {"sub" "owner-a"})] + (routes/handle-fetch request env)) + body (.json response)] + (is (= 200 (.-status response))) + (is (true? (aget body "ok"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) + +(deftest get-page-requires-correct-password-when-protected + (async done + (let [env (permission-env + (fn [id url method] + (cond + (and (= id "graph-1:page-1") + (= method "GET") + (= url "https://publish/pages/graph-1/page-1")) + (ok-json-response #js {"content_hash" "h-1" + "r2_key" "publish/graph-1/page-1.transit"}) + + (and (= id "index") + (= method "GET") + (= url "https://publish/pages/graph-1/page-1/password")) + (ok-json-response #js {"password_hash" "pbkdf2$sha256$90000$x$y"}) + + :else + (json-error-response 404 "not found")))) + request (js/Request. "https://publish.example/pages/graph-1/page-1")] + (-> (p/let [response (p/with-redefs [publish-common/verify-password (fn [_ _] (p/resolved false))] + (routes/handle-fetch request env)) + body (.json response)] + (is (= 401 (.-status response))) + (is (= "password required" (aget body "error"))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done))))))) diff --git a/deps/publish/test/logseq/publish/test_runner.cljs b/deps/publish/test/logseq/publish/test_runner.cljs new file mode 100644 index 0000000..d82bd39 --- /dev/null +++ b/deps/publish/test/logseq/publish/test_runner.cljs @@ -0,0 +1,23 @@ +(ns logseq.publish.test-runner + (:require [cljs.test :as ct] + [logseq.publish.common-test] + [logseq.publish.render-test] + [logseq.publish.routes-test] + [shadow.test :as st] + [shadow.test.env :as env])) + +(derive ::node ::ct/default) + +(defmethod ct/report [::node :end-run-tests] [m] + (if (ct/successful? m) + (js/process.exit 0) + (js/process.exit 1))) + +(defn ^:dev/after-load reset-test-data! [] + (-> (env/get-test-data) + (env/reset-test-data!))) + +(defn main [& _args] + (reset-test-data!) + (let [test-env (ct/empty-env ::node)] + (st/run-all-tests test-env nil))) diff --git a/deps/publish/worker/README.md b/deps/publish/worker/README.md new file mode 100644 index 0000000..416c9d2 --- /dev/null +++ b/deps/publish/worker/README.md @@ -0,0 +1,50 @@ +## Cloudflare Publish Worker (Skeleton) + +This worker accepts publish payloads and stores transit blobs in R2 while keeping +metadata in a Durable Object backed by SQLite. + +### Bindings + +- `PUBLISH_META_DO`: Durable Object namespace +- `PUBLISH_R2`: R2 bucket +- `R2_ACCOUNT_ID`: Cloudflare account id for signing +- `R2_BUCKET`: R2 bucket name for signing +- `R2_ACCESS_KEY_ID`: R2 access key for signing +- `R2_SECRET_ACCESS_KEY`: R2 secret key for signing +- `COGNITO_JWKS_URL`: JWKS URL for Cognito user pool +- `COGNITO_ISSUER`: Cognito issuer URL +- `COGNITO_CLIENT_ID`: Cognito client ID +- `DEV_SKIP_AUTH`: set to `true` to bypass JWT verification in local dev + +### Routes + +- `GET /p/:graph-uuid/:page-uuid` + - Returns server-rendered HTML for the page +- `POST /pages` + - Requires `Authorization: Bearer ` + - Requires `x-publish-meta` header (JSON) + - Body is transit payload (stored in R2 as-is) +- `GET /pages/:graph-uuid/:page-uuid` + - Returns metadata for the page +- `GET /pages/:graph-uuid/:page-uuid/transit` + - Returns JSON with a signed R2 URL and `etag` +- `DELETE /pages/:graph-uuid/:page-uuid` + - Deletes a published page +- `DELETE /pages/:graph-uuid` + - Deletes all pages for a graph +- `GET /tag/:tag-name` + - List all pages tagged with `:tag-name` +- `GET /ref/:page-name` + - List all pages that reference `:page-name` +- `GET /user/:user-name` + - List all pages for `:user-name` + +### Notes + +- This is a starter implementation. Integrate with your deployment tooling + (wrangler, etc.) as needed. +- For local testing, run `wrangler dev` and use `deps/publish/worker/scripts/dev_test.sh`. +- If you switch schema versions, clear local DO state with + `deps/publish/worker/scripts/clear_dev_state.sh`. +- Build the worker bundle with `clojure -M:cljs release publish-worker` before running the worker. +- For dev, you can run `clojure -M:cljs watch publish-worker` in one terminal. diff --git a/deps/publish/worker/scripts/clear_dev_state.sh b/deps/publish/worker/scripts/clear_dev_state.sh new file mode 100755 index 0000000..26006a1 --- /dev/null +++ b/deps/publish/worker/scripts/clear_dev_state.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +GRAPH_UUID=${GRAPH_UUID:-"00000000-0000-0000-0000-000000000000"} + +cat <=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==} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + 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==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + 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==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + 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==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + 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: {} + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + get-caller-file@1.0.3: {} + + get-stream@4.1.0: + dependencies: + pump: 3.0.2 + + github-from-package@0.0.0: {} + + graceful-fs@4.2.11: {} + + 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: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + 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 + + universalify@2.0.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 diff --git a/deps/publishing/script/publishing.cljs b/deps/publishing/script/publishing.cljs new file mode 100644 index 0000000..2a62668 --- /dev/null +++ b/deps/publishing/script/publishing.cljs @@ -0,0 +1,45 @@ +(ns publishing + "Basic script for publishing from CLI" + (:require ["path" :as node-path] + [clojure.edn :as edn] + [datascript.core :as d] + [logseq.db.common.sqlite-cli :as sqlite-cli] + [logseq.db.sqlite.util :as sqlite-util] + [logseq.publishing :as publishing] + [nbb.core :as nbb])) + +(defn- publish-db-graph [static-dir graph-dir output-path opts] + (let [db-name (node-path/basename graph-dir) + conn (sqlite-cli/open-db! (node-path/dirname graph-dir) db-name) + repo-config (-> (d/q '[:find ?content + :where [?b :file/path "logseq/config.edn"] [?b :file/content ?content]] + @conn) + ffirst + edn/read-string)] + (publishing/export @conn + static-dir + graph-dir + output-path + (merge opts {:repo (str sqlite-util/db-version-prefix db-name) + :repo-config repo-config + :ui/theme "dark" + :ui/radix-color :cyan})))) + +(defn- resolve-path + "If relative path, resolve with $ORIGINAL_PWD" + [path] + (if (node-path/isAbsolute path) + path + (node-path/join (or js/process.env.ORIGINAL_PWD ".") path))) + +(defn -main + [args] + (when (< (count args) 3) + (println "Usage: $0 STATIC-DIR GRAPH-DIR OUT-DIR") + (js/process.exit 1)) + (let [[static-dir graph-dir output-path] (map resolve-path args) + options {:dev? (contains? (set args) "--dev")}] + (publish-db-graph static-dir graph-dir output-path options))) + +(when (= nbb/*file* (nbb/invoked-file)) + (-main *command-line-args*)) \ No newline at end of file diff --git a/deps/publishing/src/logseq/publishing.cljs b/deps/publishing/src/logseq/publishing.cljs new file mode 100644 index 0000000..42f294f --- /dev/null +++ b/deps/publishing/src/logseq/publishing.cljs @@ -0,0 +1,34 @@ +(ns ^:node-only logseq.publishing + "This node only ns provides api fns for exporting a publishing app" + (:require [logseq.publishing.export :as publish-export] + [logseq.publishing.html :as publish-html])) + +(defn- default-notification-fn [msg] + (if (= "error" (:type msg)) + (throw (ex-info (:payload msg) {})) + (js/console.log (:payload msg)))) + +(defn export + "Exports the given graph-dir and db to the specific output-dir. Most of the graph +configuration is done through logseq/config.edn. There are a few explicit options that +can be passed: +* :ui/theme - Theme mode that can either be 'light' or 'dark'. +* :ui/radix-color - Accent color. See available values in Settings. +* :html-options - A map of values that are inserted into index.html. Map keys + can be icon, name, alias, title, description and url +* :repo - Name of repo +* :repo-config - A graph's configuration +* :default-notification-fn - Configure how errors are reported when creating the export. + Default is to throw an exception when it occurs." + [db static-dir graph-dir output-dir {:keys [notification-fn dev?] + :or {notification-fn default-notification-fn} + :as options}] + (let [options' (cond-> options + (:ui/theme options) + (assoc-in [:app-state :ui/theme] (:ui/theme options)) + (:ui/radix-color options) + (assoc-in [:app-state :ui/radix-color] (:ui/radix-color options))) + {:keys [html asset-filenames]} (publish-html/build-html db options')] + (publish-export/create-export html static-dir graph-dir output-dir {:asset-filenames asset-filenames + :notification-fn notification-fn + :dev? dev?}))) diff --git a/deps/publishing/src/logseq/publishing/db.cljs b/deps/publishing/src/logseq/publishing/db.cljs new file mode 100644 index 0000000..3934766 --- /dev/null +++ b/deps/publishing/src/logseq/publishing/db.cljs @@ -0,0 +1,179 @@ +(ns logseq.publishing.db + "Provides db fns and associated util fns for publishing" + (:require [clojure.set :as set] + [datascript.core :as d] + [logseq.db.common.entity-plus :as entity-plus] + [logseq.db.frontend.malli-schema :as db-malli-schema] + [logseq.db.frontend.rules :as rules])) + +(defn- get-db-public-pages + "Returns public pages and anything they are directly related to: their tags, + their properties and any property values that are pages. Anything on the + related pages are _not_ included e.g. properties on tag or property pages" + [db] + (let [pages (->> (d/q + '[:find ?p + :in $ % + :where (property ?p :logseq.property/publishing-public? true) [?p :block/name]] + db + (rules/extract-rules rules/db-query-dsl-rules [:property])) + (map first) + set) + page-ents (map #(d/entity db %) pages) + tag-pages* (mapcat #(map :db/id (:block/tags %)) page-ents) + tag-pages (concat tag-pages* + ;; built-in property needs to be public to display tags + (when (seq tag-pages*) + (some-> (d/entity db :block/tags) :db/id vector))) + property-pages (mapcat (fn [ent] + (->> (keys (entity-plus/lookup-kv-then-entity ent :block/properties)) + (map #(:db/id (d/entity db %))))) + page-ents)] + (concat pages tag-pages property-pages))) + +(defn- get-db-public-false-pages + [db] + (->> (d/q + '[:find ?p + :in $ % + :where (property ?p :logseq.property/publishing-public? false) [?p :block/name]] + db + (rules/extract-rules rules/db-query-dsl-rules [:property])) + (map first) + set)) + +(defn- get-aliases-for-page-ids + [db page-ids] + (->> (d/q '[:find ?e + :in $ ?pages % + :where + [?page :block/name] + [(contains? ?pages ?page)] + (alias ?page ?e)] + db + (set page-ids) + (:alias rules/rules)) + (map first) + set)) + +(defn- get-db-assets + [db] + (->> (d/q '[:find [(pull ?b [:block/uuid :logseq.property.asset/type]) ...] + :where [?b :block/tags :logseq.class/Asset]] + db) + (map #(str (:block/uuid %) "." (:logseq.property.asset/type %))))) + +(defn- add-missing-built-in-block-timestamps + [db] + (let [tx-data (->> (d/datoms db :avet :logseq.property/built-in? true) + (map :e) + (map #(d/entity db %)) + (mapcat + (fn [entity] + (let [created-at (or (:block/created-at entity) + (:block/updated-at entity) + 0) + updated-at (or (:block/updated-at entity) + created-at)] + (cond-> [] + (nil? (:block/created-at entity)) + (conj [:db/add (:db/id entity) :block/created-at created-at]) + + (nil? (:block/updated-at entity)) + (conj [:db/add (:db/id entity) :block/updated-at updated-at]))))))] + (if (seq tx-data) + (:db-after (d/with db tx-data)) + db))) + +(defn clean-export! + "Prepares a database assuming all pages are public unless a page has a publishing-public? property set to false" + [db] + (let [remove? #(contains? #{"file"} %) + non-public-datom-ids (get-db-public-false-pages db) + filtered-db (d/filter db + (fn [_db datom] + (let [ns' (namespace (:a datom))] + (and (not (remove? ns')) + (not (contains? non-public-datom-ids (:e datom))) + (not (and (contains? non-public-datom-ids (:v datom)) + (= :block/page (:a datom)))))))) + datoms (d/datoms filtered-db :eavt) + assets (get-db-assets filtered-db)] + ;; (prn :public-counts :datoms (count datoms) :assets (count assets)) + [(add-missing-built-in-block-timestamps @(d/conn-from-datoms datoms (:schema db))) assets])) + +(defn- db-filter-only-public + [public-ents _db datom] + (contains? public-ents (:e datom))) + +(defn- get-properties-on-nodes + [db nodes] + (->> (d/q '[:find ?p + :in $ [?node ...] + :where + [?p :db/ident ?a] + [?node ?a ?v] + [(missing? $ ?a :logseq.property/built-in?)]] + db + nodes) + (map first) + set)) + +(defn- get-property-values-on-nodes + [db nodes] + (->> (d/q '[:find ?pv + :in $ [?node ...] + :where + [?p :db/ident ?a] + [?p :db/valueType :db.type/ref] + [?node ?a ?pv] + [(missing? $ ?p :logseq.property/built-in?)]] + db + nodes) + (map first) + set)) + +(defn- get-db-public-ents + [db public-pages] + (let [page-blocks (->> (d/datoms db :avet :block/page) + (keep #(when (contains? public-pages (:v %)) (:e %))) + set) + public-nodes (into public-pages page-blocks) + eavt-datoms (d/datoms db :eavt) + tags (->> eavt-datoms + (keep #(when (and (contains? public-nodes (:e %)) (= :block/tags (:a %))) + (:v %))) + set) + properties (get-properties-on-nodes db public-nodes) + ;; This makes nodes from other pages visible on a current public page. + ;; BUT clicking on a node will not display the node's page + property-values (get-property-values-on-nodes db public-nodes) + internal-ents (set/union + (->> eavt-datoms + (keep #(when (and (= :db/ident (:a %)) (db-malli-schema/internal-ident? (:v %))) + (:e %))) + set) + (->> (d/datoms db :avet :logseq.property/built-in? true) + (map :e) + set)) + ents (set/union internal-ents public-pages page-blocks properties property-values tags)] + #_(prn :public :pages (count public-pages) :page-blocks (count page-blocks) + :properties (count properties) :property-values (count property-values) + :tags (count tags) :internal (count internal-ents)) + (when-let [invalid-ents (seq (remove integer? ents))] + (throw (ex-info (str "The following ents are invalid: " (pr-str (vec invalid-ents))) {}))) + ents)) + +(defn filter-only-public-pages-and-blocks + "Prepares a database assuming all pages are private unless a page has a publishing-public? property set to true" + [db] + {:post [(some? %) (sequential? %)]} + (let [public-pages* (seq (get-db-public-pages db)) + public-pages (set/union (set public-pages*) + (get-aliases-for-page-ids db public-pages*)) + filter-fn (partial db-filter-only-public (get-db-public-ents db public-pages)) + filtered-db (d/filter db filter-fn) + datoms (d/datoms filtered-db :eavt) + assets (get-db-assets filtered-db)] + ;; (prn :private-counts :internal (count internal-ents) :datoms (count datoms) :assets (count assets)) + [(add-missing-built-in-block-timestamps @(d/conn-from-datoms datoms (:schema db))) assets])) diff --git a/deps/publishing/src/logseq/publishing/export.cljs b/deps/publishing/src/logseq/publishing/export.cljs new file mode 100644 index 0000000..9fb75fc --- /dev/null +++ b/deps/publishing/src/logseq/publishing/export.cljs @@ -0,0 +1,125 @@ +(ns ^:node-only logseq.publishing.export + "This electron only ns (for the main process) exports files from multiple + locations to provide a complete publishing app" + (:require ["fs" :as fs] + ["fs-extra" :as fse] + ["path" :as node-path] + [promesa.core :as p])) + +(def ^:api js-files + "js files from publishing release build" + ["main.js" "code-editor.js"]) + +(def ^:api static-dirs + "dirs under static dir to copy over" + ["css" "icons" "img" "js"]) + +(defn- remove-js-source-maps + [output-static-dir] + (let [js-dir (node-path/join output-static-dir "js")] + (doseq [file (fs/readdirSync js-dir)] + (when (.endsWith file ".map") + (fs/rmSync (node-path/join js-dir file) #js {:force true}))))) + +(defn- default-notification + [msg] + (if (= (:type msg) "success") + (js/console.log (:payload msg)) + (js/console.error (:payload msg)))) + +(defn- copy-file! + [from to] + (fs/mkdirSync (node-path/dirname to) #js {:recursive true}) + (fs/copyFileSync from to)) + +(defn- copy-path! + [from to] + (let [stat (fs/statSync from)] + (cond + (.isDirectory stat) + (do + (fs/mkdirSync to #js {:recursive true}) + (doseq [entry (fs/readdirSync from)] + (copy-path! (node-path/join from entry) + (node-path/join to entry)))) + + (.isFile stat) + (copy-file! from to)))) + +(defn- cleanup-js-dir + "Moves used js files to the correct dir and removes unused js files" + [output-static-dir source-static-dir {:keys [dev?]}] + (let [publishing-dir (node-path/join output-static-dir "js" "publishing")] + (p/let [_ (when-not dev? (remove-js-source-maps output-static-dir)) + _ (p/all (map (fn [file] + (fs/rmSync (node-path/join output-static-dir "js" file) #js {:force true})) + js-files)) + _ (when dev? + (fse/remove (node-path/join output-static-dir "js" "cljs-runtime"))) + _ (p/all (map (fn [file] + (if dev? + (fs/symlinkSync + (node-path/join source-static-dir "js" "publishing" file) + (node-path/join output-static-dir "js" file)) + (fs/renameSync + (node-path/join publishing-dir file) + (node-path/join output-static-dir "js" file)))) + js-files)) + _ (when dev? + (fs/symlinkSync (node-path/join source-static-dir "js" "publishing" "cljs-runtime") + (node-path/join output-static-dir "js" "cljs-runtime"))) + ;; remove publishing-dir + _ (when-not dev? (fse/remove publishing-dir))]))) + +(defn- copy-static-files-and-assets + [static-dir repo-path output-dir {:keys [log-error-fn asset-filenames] + :or {asset-filenames [] + log-error-fn js/console.error}}] + (let [assets-from-dir (node-path/join repo-path "assets") + assets-to-dir (node-path/join output-dir "assets") + output-static-dir (node-path/join output-dir "static")] + (p/let [_ (fs/mkdirSync assets-to-dir #js {:recursive true}) + _ (copy-path! (node-path/join static-dir "404.html") + (node-path/join output-dir "404.html")) + _ (doseq [part static-dirs] + (copy-path! (node-path/join static-dir part) + (node-path/join output-static-dir part))) + _ (p/all + (map + (fn [filename] + (-> (fse/copy (node-path/join assets-from-dir filename) (node-path/join assets-to-dir filename)) + (p/catch + (fn [e] + (log-error-fn "Failed to copy" + (str {:from (node-path/join assets-from-dir filename) + :to (node-path/join assets-to-dir filename)}) + e))))) + asset-filenames))]))) + +(defn create-export + "Given a graph's directory, the generated html and the directory containing + html/static assets, creates the export at the specified output-dir and + includes the index.html with supporting assets" + [html static-dir repo-path output-dir {:keys [notification-fn] + :or {notification-fn default-notification} + :as options}] + (let [custom-css-path (node-path/join repo-path "logseq" "custom.css") + export-css-path (node-path/join repo-path "logseq" "export.css") + custom-js-path (node-path/join repo-path "logseq" "custom.js") + output-static-dir (node-path/join output-dir "static") + index-html-path (node-path/join output-dir "index.html")] + (-> (p/let [_ (fs/mkdirSync output-static-dir #js {:recursive true}) + _ (fs/writeFileSync index-html-path html) + _ (copy-static-files-and-assets static-dir repo-path output-dir options) + export-css (if (fs/existsSync export-css-path) (str (fs/readFileSync export-css-path)) "") + _ (fs/writeFileSync (node-path/join output-static-dir "css" "export.css") export-css) + custom-css (if (fs/existsSync custom-css-path) (str (fs/readFileSync custom-css-path)) "") + _ (fs/writeFileSync (node-path/join output-static-dir "css" "custom.css") custom-css) + custom-js (if (fs/existsSync custom-js-path) (str (fs/readFileSync custom-js-path)) "") + _ (fs/writeFileSync (node-path/join output-static-dir "js" "custom.js") custom-js) + _ (cleanup-js-dir output-static-dir static-dir options)] + (notification-fn {:type "success" + :payload (str "Export public pages and publish assets to " output-dir " successfully 🎉")})) + (p/catch (fn [error] + (notification-fn {:type "error" + :payload (str "Export public pages unexpectedly failed with: " error)})))))) diff --git a/deps/publishing/src/logseq/publishing/html.cljs b/deps/publishing/src/logseq/publishing/html.cljs new file mode 100644 index 0000000..3403e1b --- /dev/null +++ b/deps/publishing/src/logseq/publishing/html.cljs @@ -0,0 +1,159 @@ +(ns logseq.publishing.html + "This frontend only ns builds the publishing html including doing all the +necessary db filtering" + (:require [clojure.string :as string] + [datascript.core :as d] + [datascript.transit :as dt] + [goog.string :as gstring] + [goog.string.format] + [logseq.publishing.db :as db])) + +;; Copied from hiccup but tweaked for publish usage +;; Any changes here should also be made in frontend.publishing/unescape-html +(defn- escape-html + "Change special characters into HTML character entities." + [text] + (-> text + (string/replace "&" "logseq____&") + (string/replace "<" "logseq____<") + (string/replace ">" "logseq____>") + (string/replace "\"" "logseq____"") + (string/replace "'" "logseq____'"))) + +;; Copied from https://github.com/babashka/babashka/blob/8c1077af00c818ade9e646dfe1297bbe24b17f4d/examples/notes.clj#L21 +(defn- html [v] + (cond (vector? v) + (let [tag (first v) + attrs (second v) + attrs (when (map? attrs) attrs) + elts (if attrs (nnext v) (next v)) + tag-name (name tag)] + (gstring/format "<%s%s>%s\n" tag-name (html attrs) (html elts) tag-name)) + (map? v) + (string/join "" + (keep (fn [[k v]] + ;; Skip nil values because some html tags haven't been + ;; given values through html-options + (when (some? v) + (gstring/format " %s=\"%s\"" (name k) v))) v)) + (seq? v) + (string/join " " (map html v)) + :else (str v))) + +(defn- ^:large-vars/html publishing-html + [transit-db app-state options] + (let [{name' :name :keys [icon alias title description url]} options + icon (or icon "static/img/logo.png") + project (or alias name')] + (str "\n" + (html + (list + [:head + [:meta {:charset "utf-8"}] + [:meta + {:content + "minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no", + :name "viewport"}] + [:link {:type "text/css", :href "static/css/style.css", :rel "stylesheet"}] + [:link {:type "text/css", :href "static/css/custom.css", :rel "stylesheet"}] + [:link {:type "text/css", :href "static/css/export.css", :rel "stylesheet"}] + [:link + {:href icon + :type "image/png", + :rel "shortcut icon"}] + [:link + {:href icon + :sizes "192x192", + :rel "shortcut icon"}] + [:link + {:href icon + :rel "apple-touch-icon"}] + + [:meta {:name "apple-mobile-web-app-title" :content project}] + [:meta {:name "apple-mobile-web-app-capable" :content "yes"}] + [:meta {:name "apple-touch-fullscreen" :content "yes"}] + [:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}] + [:meta {:name "mobile-web-app-capable" :content "yes"}] + + [:meta {:content title, :property "og:title"}] + [:meta {:content "site", :property "og:type"}] + (when url [:meta {:content url, :property "og:url"}]) + [:meta + {:content icon + :property "og:image"}] + [:meta + {:content description + :property "og:description"}] + [:title title] + [:meta {:content project, :property "og:site_name"}] + [:meta + {:description description}]] + [:body + [:div {:id "root"}] + [:script (gstring/format "window.logseq_db=%s" (js/JSON.stringify (escape-html transit-db)))] + [:script (str "window.logseq_state=" (js/JSON.stringify (pr-str app-state)))] + [:script {:type "text/javascript"} + "// Single Page Apps for GitHub Pages + // https://github.com/rafgraph/spa-github-pages + // Copyright (c) 2016 Rafael Pedicini, licensed under the MIT License + // ---------------------------------------------------------------------- + // This script checks to see if a redirect is present in the query string + // and converts it back into the correct url and adds it to the + // browser's history using window.history.replaceState(...), + // which won't cause the browser to attempt to load the new url. + // When the single page app is loaded further down in this file, + // the correct url will be waiting in the browser's history for + // the single page app to route accordingly. + (function(l) { + if (l.search) { + var q = {}; + l.search.slice(1).split('&').forEach(function(v) { + var a = v.split('='); + q[a[0]] = a.slice(1).join('=').replace(/~and~/g, '&'); + }); + if (q.p !== undefined) { + window.history.replaceState(null, null, + l.pathname.slice(0, -1) + (q.p || '') + + (q.q ? ('?' + q.q) : '') + + l.hash + ); + } + } + }(window.location))"] + ;; TODO: should make this configurable + [:script {:src "static/js/react.production.min.js"}] + [:script {:src "static/js/react-dom.production.min.js"}] + [:script {:src "static/js/main.js"}] + ;; Deferring scripts above results in errors + [:script {:defer true :src "static/js/interact.min.js"}] + [:script {:defer true :src "static/js/highlight.min.js"}] + [:script {:defer true :src "static/js/katex.min.js"}] + [:script {:defer true :type "module" :src "static/js/pdfjs/pdf.mjs"}] + [:script {:defer true :type "module" :src "static/js/pdf_viewer3.mjs"}] + [:script {:defer true :src "static/js/html2canvas.min.js"}] + [:script {:defer true :src "static/js/code-editor.js"}] + [:script {:defer true :src "static/js/custom.js"}]]))))) + +(defn build-html + "Given the graph's db, filters the db using the given options and returns the +generated index.html string and assets used by the html" + [db* {:keys [repo app-state repo-config html-options dev?]}] + (let [all-pages-public? (if-let [value (:publishing/all-pages-public? repo-config)] + value + (:all-pages-public? repo-config)) + [db asset-filenames'] (if all-pages-public? + (db/clean-export! db*) + (db/filter-only-public-pages-and-blocks db*)) + _ (when dev? + (println "Exporting" (count (d/datoms db :eavt)) "of" (count (d/datoms db* :eavt)) "datoms and" + (count asset-filenames') "asset(s)...")) + asset-filenames (remove nil? asset-filenames') + + db-str (dt/write-transit-str db) + ;; The repo-name is used by the client and thus determines whether + ;; it's a db graph or not + state (assoc app-state + :config {repo repo-config}) + raw-html-str (publishing-html db-str state html-options)] + {:html raw-html-str + :asset-filenames asset-filenames})) diff --git a/deps/publishing/test/logseq/publishing/db_test.cljs b/deps/publishing/test/logseq/publishing/db_test.cljs new file mode 100644 index 0000000..5ac5879 --- /dev/null +++ b/deps/publishing/test/logseq/publishing/db_test.cljs @@ -0,0 +1,114 @@ +(ns logseq.publishing.db-test + (:require [cljs.test :refer [deftest is]] + [clojure.set :as set] + [datascript.core :as d] + [logseq.common.config :as common-config] + [logseq.db :as ldb] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.test.helper :as db-test] + [logseq.publishing.db :as publish-db])) + +(deftest clean-export! + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "page1" + :build/properties {:logseq.property/publishing-public? false}} + :blocks [{:block/title "b11"} + {:block/title "b12"} + {:block/title "![awesome.png](../assets/awesome_1648822509908_0.png"}]} + {:page {:block/title "page2"} + :blocks [{:block/title "b21"} + {:block/title "![thumb-on-fire.PNG](../assets/thumb-on-fire_1648822523866_0.PNG)"}]} + {:page {:block/title "page3"} + :blocks [{:block/title "b31"}]}]) + [filtered-db _assets] (publish-db/clean-export! @conn) + exported-pages (->> (d/q '[:find (pull ?b [*]) + :where [?b :block/name]] + filtered-db) + (map (comp :block/name first)) + set) + exported-blocks (->> (d/q '[:find (pull ?p [*]) + :where + [?b :block/title] + [?b :block/page ?p] + [(missing? $ ?p :logseq.property/built-in?)]] + filtered-db) + (map (comp :block/name first)) + set)] + (is (set/subset? #{"page2" "page3"} exported-pages) + "Contains all pages that haven't been marked private") + (is (not (contains? exported-pages "page1")) + "Doesn't contain private page") + (is (= #{"page2" "page3"} exported-blocks) + "Only exports blocks from public pages") + ;; TODO: Create assets to test this + #_(is (= ["thumb-on-fire_1648822523866_0.PNG"] assets) + "Only exports assets from public pages"))) + +(deftest filter-only-public-pages-and-blocks-adds-missing-built-in-page-timestamps + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "page1" + :build/properties {:logseq.property/publishing-public? true}} + :blocks [{:block/title "b1"}]} + {:page {:block/title common-config/views-page-name + :build/properties {:logseq.property/built-in? true + :logseq.property/hide? true}} + :blocks []}]) + views-page (ldb/get-page @conn common-config/views-page-name) + _ (d/transact! conn + [[:db/retract (:db/id views-page) :block/created-at (:block/created-at views-page)] + [:db/retract (:db/id views-page) :block/updated-at (:block/updated-at views-page)]]) + [filtered-db _assets] (publish-db/filter-only-public-pages-and-blocks @conn) + exported-views-page (ldb/get-page filtered-db common-config/views-page-name)] + (is (int? (:block/created-at exported-views-page)) + "Missing created-at is added to exported built-in pages") + (is (int? (:block/updated-at exported-views-page)) + "Missing updated-at is added to exported built-in pages") + (is (empty? (:errors (db-validate/validate-db filtered-db))) + "Publishing DB remains valid after keeping hidden built-in pages"))) + +(deftest filter-only-public-pages-and-blocks + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "page1" + :build/properties {:logseq.property/publishing-public? false}} + :blocks [{:block/title "b11"} + {:block/title "b12"} + {:block/title "![awesome.png](../assets/awesome_1648822509908_0.png"}]} + {:page {:block/title "page2" + :build/properties {:logseq.property/publishing-public? true + :block/alias #{[:build/page {:block/title "page2-alias"}]}}} + :blocks [{:block/title "b21"} + {:block/title "![thumb-on-fire.PNG](../assets/thumb-on-fire_1648822523866_0.PNG)"}]} + {:page {:block/title "page3" + :build/properties {:logseq.property/publishing-public? true}} + :blocks [{:block/title "b31"}]}]) + [filtered-db _assets] (publish-db/filter-only-public-pages-and-blocks @conn) + exported-pages (->> (d/q '[:find (pull ?b [*]) + :where [?b :block/name]] + filtered-db) + (map (comp :block/name first)) + set) + exported-block-pages (->> (d/q '[:find (pull ?p [*]) + :where + [?b :block/title] + [?b :block/page ?p] + [(missing? $ ?p :logseq.property/built-in?)]] + filtered-db) + (map (comp :block/name first)) + set) + exported-page2-children (->> (:block/_parent (ldb/get-page filtered-db "page2")) + (map :block/title) + set)] + + (is (set/subset? #{"page2" "page3"} exported-pages) + "Contains all pages that have been marked public") + (is (not (contains? exported-pages "page1")) + "Doesn't contain private page") + (is (seq (ldb/get-page filtered-db "page2-alias")) + "Alias of public page is exported") + (is (= #{"page2" "page3"} exported-block-pages) + "Only exports blocks from public pages") + (is (contains? exported-page2-children "b21") + "Public page children are still available through block parent refs") + ;; TODO: Create assets to test this + #_(is (= ["thumb-on-fire_1648822523866_0.PNG"] assets) + "Only exports assets from public pages"))) diff --git a/deps/publishing/test/logseq/publishing/export_test.cljs b/deps/publishing/test/logseq/publishing/export_test.cljs new file mode 100644 index 0000000..949017f --- /dev/null +++ b/deps/publishing/test/logseq/publishing/export_test.cljs @@ -0,0 +1,142 @@ +(ns logseq.publishing.export-test + (:require [cljs.test :as t :refer [is use-fixtures async]] + [logseq.publishing.test.helper :as test-helper :include-macros true :refer [deftest-async]] + [logseq.publishing.export :as publish-export] + [promesa.core :as p] + [clojure.set :as set] + ["fs" :as fs] + ["path" :as path])) + +(use-fixtures + :each + ;; Cleaning tmp/ before leaves last tmp/ after a test run for dev and debugging + {:before + #(async done + (if (fs/existsSync "tmp") + (fs/rm "tmp" #js {:recursive true} (fn [err] + (when err (js/console.log err)) + (done))) + (done)))}) + +(defn get-dirs [path] + (->> (fs/readdirSync path) + (map #(path/join path %)) + (filter #(.isDirectory (fs/statSync %))))) + +(defn get-files [path] + (->> (fs/readdirSync path) + (map #(path/join path %)) + (filter #(.isFile (fs/statSync %))))) + +(defn get-files-recursively [dir] + (let [dirs (get-dirs dir)] + (->> dirs + (map get-files-recursively) + (reduce concat) + (concat (get-files dir))))) + +(defn- js-map-files [] + (map #(str % ".map") publish-export/js-files)) + +(defn- create-export + [static-dir graph-dir output-dir {:keys [html assets] + :or {html "" + assets []}}] + (publish-export/create-export + html + static-dir + graph-dir + output-dir + {:asset-filenames assets + :notification-fn (fn [msg] + (if (= "error" (:type msg)) + (throw (ex-info (:payload msg) {})) + (js/console.log (:payload msg))))})) + +(defn- create-static-dir [dir] + (fs/mkdirSync (path/join dir) #js {:recursive true}) + (mapv #(fs/mkdirSync (path/join dir %)) publish-export/static-dirs) + (fs/mkdirSync (path/join dir "css" "fonts") #js {:recursive true}) + (fs/writeFileSync (path/join dir "css" "style.css") "style") + (fs/writeFileSync (path/join dir "css" "fonts" "font.woff2") "font") + (fs/mkdirSync (path/join dir "js" "publishing")) + (mapv #(fs/writeFileSync (path/join dir "js" "publishing" %) %) + (conj publish-export/js-files "manifest.edn")) + (doseq [file (conj (vec (js-map-files)) "db-worker.js.map")] + (fs/writeFileSync (path/join dir "js" file) file)) + (fs/writeFileSync (path/join dir "404.html") "")) + +(defn- create-logseq-graph + "Creates a minimal graph to test publishing" + [dir] + (fs/mkdirSync (path/join dir "logseq") #js {:recursive true}) + (fs/writeFileSync (path/join dir "logseq" "config.edn") "{}") + (fs/mkdirSync (path/join dir "assets"))) + +(deftest-async create-export-with-basic-graph + (create-static-dir "tmp/static") + (create-logseq-graph "tmp/test-graph") + + (p/let [_ (create-export "tmp/static" "tmp/test-graph" "tmp/published-graph" {:html "
WOOT
"})] + (let [original-paths (map path/basename (get-files-recursively "tmp/static")) + copied-paths (map path/basename (get-files-recursively "tmp/published-graph")) + new-files (set/difference (set copied-paths) (set original-paths))] + (is (= #{"index.html" "custom.css" "export.css" "custom.js"} + new-files) + "A published graph has the correct new files") + (is (= "
WOOT
" + (str (fs/readFileSync "tmp/published-graph/index.html"))) + "index.html is copied correctly") + (is (= "main.js" + (str (fs/readFileSync "tmp/published-graph/static/js/main.js"))) + "cljs frontend compiled as main.js is copied correctly") + (is (= "style" + (str (fs/readFileSync "tmp/published-graph/static/css/style.css"))) + "static files are copied recursively") + (is (= "font" + (str (fs/readFileSync "tmp/published-graph/static/css/fonts/font.woff2"))) + "nested static files are copied recursively") + (is (empty? (filter #(.endsWith (path/basename %) ".map") + (get-files "tmp/published-graph/static/js"))) + "source maps are not copied")))) + +(deftest-async create-export-with-css-files + (create-static-dir "tmp/static") + (create-logseq-graph "tmp/test-graph") + (fs/writeFileSync "tmp/test-graph/logseq/custom.css" ".foo {background-color: blue}") + (fs/writeFileSync "tmp/test-graph/logseq/export.css" ".foo {background-color: red}") + + (p/let [_ (create-export "tmp/static" "tmp/test-graph" "tmp/published-graph" {})] + (is (= ".foo {background-color: blue}" + (str (fs/readFileSync "tmp/published-graph/static/css/custom.css"))) + "custom.css is copied correctly") + (is (= ".foo {background-color: red}" + (str (fs/readFileSync "tmp/published-graph/static/css/export.css"))) + "export.css is copied correctly"))) + +(deftest-async create-export-with-js-files + (create-static-dir "tmp/static") + (create-logseq-graph "tmp/test-graph") + (fs/writeFileSync "tmp/test-graph/logseq/custom.js" "// foo") + + (p/let [_ (create-export "tmp/static" "tmp/test-graph" "tmp/published-graph" {})] + (is (= "// foo" + (str (fs/readFileSync "tmp/published-graph/static/js/custom.js"))) + "custom.js is copied correctly"))) + +(deftest-async create-export-with-assets + (create-static-dir "tmp/static") + (create-logseq-graph "tmp/test-graph") + (fs/writeFileSync "tmp/test-graph/assets/foo.jpg" "foo") + (fs/writeFileSync "tmp/test-graph/assets/bar.png" "bar") + + (p/let [_ (create-export "tmp/static" + "tmp/test-graph" + "tmp/published-graph" + {:assets ["foo.jpg" "bar.png"]})] + (is (= "foo" + (str (fs/readFileSync "tmp/published-graph/assets/foo.jpg"))) + "first asset is copied correctly") + (is (= "bar" + (str (fs/readFileSync "tmp/published-graph/assets/bar.png"))) + "second asset is copied correctly"))) diff --git a/deps/publishing/test/logseq/publishing/test/helper.clj b/deps/publishing/test/logseq/publishing/test/helper.clj new file mode 100644 index 0000000..2cd339b --- /dev/null +++ b/deps/publishing/test/logseq/publishing/test/helper.clj @@ -0,0 +1,25 @@ +(ns logseq.publishing.test.helper) + +;; Copied from https://github.com/babashka/nbb/blob/e5d84b0fac59774f5d7a4a9e807240cce04bf252/test/nbb/test_macros.clj +(defmacro deftest-async + "A wrapper around deftest that handles async and done in all cases. + Importantly, it prevents unexpected failures in an async test from abruptly + ending a test suite" + [name' opts & body] + (let [[opts body] + (if (map? opts) + [opts body] + [nil (cons opts body)])] + `(cljs.test/deftest ~name' + ~@(when-let [pre (:before opts)] + [pre]) + (cljs.test/async + ~'done + (-> (do ~@body) + (.catch (fn [err#] + (cljs.test/is (= 1 0) (str err# (.-stack err#))))) + (.finally + (fn [] + ~@(when-let [post (:after opts)] + [post]) + (~'done)))))))) diff --git a/deps/shui/.clj-kondo/config.edn b/deps/shui/.clj-kondo/config.edn new file mode 100644 index 0000000..7d48388 --- /dev/null +++ b/deps/shui/.clj-kondo/config.edn @@ -0,0 +1,17 @@ +{ :config-in-ns + ;; :used-underscored-binding is turned off for components because of false positive + ;; for rum/defcs and _state. + {all-components {:linters {:used-underscored-binding {:level :off}}}} + + :linters + {;; Disable until it doesn't trigger false positives on rum/defcontext + :earmuffed-var-not-dynamic {:level :off}} + :hooks {:analyze-call {rum.core/defc hooks.rum/defc + rum.core/defcs hooks.rum/defcs + clojure.string/join hooks.path-invalid-construct/string-join}} + :lint-as {rum.core/defcc rum.core/defc + rum.core/with-context clojure.core/let + rum.core/defcontext clojure.core/def + rum.core/defc clojure.core/defn + rum.core/defcs clojure.core/defn + frontend.react/defc clojure.core/defn}} diff --git a/deps/shui/README.md b/deps/shui/README.md new file mode 100644 index 0000000..9470ecf --- /dev/null +++ b/deps/shui/README.md @@ -0,0 +1,11 @@ +## Description + +Shui is the component library for logseq. It has 3 main goals: + +1. Provide an abstraction for specific components, separate from the main codebase +2. Provide a consistent look and feel for the future of logseq +3. Provide ready to use components to plugin authors to allow for a more consistent better user experience of plugin authors and users + +## API + +This library is under the parent namespace `logseq.shui`. \ No newline at end of file diff --git a/deps/shui/deps.edn b/deps/shui/deps.edn new file mode 100644 index 0000000..c79772b --- /dev/null +++ b/deps/shui/deps.edn @@ -0,0 +1,10 @@ +{:paths ["src"] + :deps + {org.clojure/clojure {:mvn/version "1.12.4"} + org.clojure/clojurescript {:mvn/version "1.12.134"} + funcool/promesa {:mvn/version "11.0.678"} + io.factorhouse/hsx {:git/url "https://github.com/logseq/hsx" + :sha "21615ffa2aa530cc830e3464b793a53ef4fdd18c"} + + medley/medley {:mvn/version "1.4.0"} + cljs-bean/cljs-bean {:mvn/version "1.9.0"}}} diff --git a/deps/shui/src/logseq/shui/base/core.cljs b/deps/shui/src/logseq/shui/base/core.cljs new file mode 100644 index 0000000..f030bc3 --- /dev/null +++ b/deps/shui/src/logseq/shui/base/core.cljs @@ -0,0 +1,61 @@ +(ns logseq.shui.base.core + (:require [cljs-bean.core :as bean] + ["react" :as react] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.icon.v2 :as tabler-icon] + [logseq.shui.util :as util])) + +(def button-base (util/ui-wrap "Button" {:static? false})) +(def link (util/ui-wrap "Link")) + +;; Note: used for the shui popup trigger +(defn trigger-as + ([as & props-or-children] + (let [[props children] [(first props-or-children) (rest props-or-children)] + props' (cond-> + {:on-key-down #(case (.-key %) + (" " "Enter") + (do (some-> (.-target %) (.click)) + (.preventDefault %) + (.stopPropagation %)) + :dune)} + (map? props) + (merge props)) + children (if (map? props) children (cons props children))] + (into [as props'] children)))) + +;; Note: fix the custom trigger content +;; for the {:as-child true} menu trigger +(defn trigger-child-wrap + [& props-and-children] + (let [props (first props-and-children) + children (rest props-and-children) + children (if (map? props) children (cons props children)) + children (when (seq children) (hsx/create-element children)) + props (if (map? props) props {})] + (apply react/createElement "div" (bean/->js props) children))) + +;; Note: keep this as a plain function +;; to be compatible for the radix as-child option +(defn button + [& props-and-children] + (let [props (first props-and-children) + children (rest props-and-children) + children (if (map? props) children (cons props children)) + props' (if (map? props) props {})] + (apply button-base props' children))) + +(defn button-icon + [variant icon-name {:keys [icon-props button-size] :as props} child] + (button + (merge (dissoc props :icon-props :button-size) + {:variant variant + :size :icon + :data-button :icon + :style (when button-size {:width button-size :height button-size})}) + (tabler-icon/root (name icon-name) (merge {:size 20} icon-props)) + child)) + +(def button-ghost-icon (partial button-icon :ghost)) +(def button-outline-icon (partial button-icon :outline)) +(def button-secondary-icon (partial button-icon :secondary)) diff --git a/deps/shui/src/logseq/shui/components.cljs b/deps/shui/src/logseq/shui/components.cljs new file mode 100644 index 0000000..06dfde4 --- /dev/null +++ b/deps/shui/src/logseq/shui/components.cljs @@ -0,0 +1,1365 @@ +(ns logseq.shui.components + (:require + ["@base-ui/react/alert-dialog" :refer [AlertDialog] :rename {AlertDialog AlertDialogPrimitive}] + ["@base-ui/react/avatar" :refer [Avatar] :rename {Avatar AvatarPrimitive}] + ["@base-ui/react/button" :refer [Button] :rename {Button ButtonPrimitive}] + ["@base-ui/react/checkbox" :refer [Checkbox] :rename {Checkbox CheckboxPrimitive}] + ["@base-ui/react/context-menu" :refer [ContextMenu] :rename {ContextMenu ContextMenuPrimitive}] + ["@base-ui/react/dialog" :refer [Dialog] :rename {Dialog DialogPrimitive}] + ["@base-ui/react/input" :refer [Input] :rename {Input InputPrimitive}] + ["@base-ui/react/menu" :refer [Menu] :rename {Menu MenuPrimitive}] + ["@base-ui/react/popover" :refer [Popover] :rename {Popover PopoverPrimitive}] + ["@base-ui/react/radio" :refer [Radio] :rename {Radio RadioPrimitive}] + ["@base-ui/react/radio-group" :refer [RadioGroup] :rename {RadioGroup RadioGroupPrimitive}] + ["@base-ui/react/select" :refer [Select] :rename {Select SelectPrimitive}] + ["@base-ui/react/separator" :refer [Separator] :rename {Separator SeparatorPrimitive}] + ["@base-ui/react/slider" :refer [Slider] :rename {Slider SliderPrimitive}] + ["@base-ui/react/switch" :refer [Switch] :rename {Switch SwitchPrimitive}] + ["@base-ui/react/tabs" :refer [Tabs] :rename {Tabs TabsPrimitive}] + ["@base-ui/react/toast" :refer [Toast] :rename {Toast ToastPrimitive}] + ["@base-ui/react/toolbar" :refer [Toolbar] :rename {Toolbar ToolbarPrimitive}] + ["@base-ui/react/toggle" :refer [Toggle] :rename {Toggle TogglePrimitive}] + ["@base-ui/react/toggle-group" :refer [ToggleGroup] :rename {ToggleGroup ToggleGroupPrimitive}] + ["@base-ui/react/tooltip" :refer [Tooltip] :rename {Tooltip TooltipPrimitive}] + ["@hookform/resolvers/yup" :refer [yupResolver]] + ["@tabler/icons-react" :refer [IconCheck IconChevronDown IconChevronLeft IconChevronRight IconChevronUp IconCircle IconX]] + ["react" :as react] + ["react-day-picker" :refer [DayPicker]] + ["react-hook-form" :refer [Controller FormProvider useForm useFormContext]] + ["yup" :as yup] + [clojure.string :as string] + [goog.object :as gobj])) + +(defn- primitive-part + [primitive k] + (or (gobj/get primitive k) + (throw (js/Error. (str "Missing Base UI primitive part: " k))))) + +(def AlertDialogBackdropPart (primitive-part AlertDialogPrimitive "Backdrop")) +(def AlertDialogDescriptionPart (primitive-part AlertDialogPrimitive "Description")) +(def AlertDialogPopupPart (primitive-part AlertDialogPrimitive "Popup")) +(def AlertDialogPortalPart (primitive-part AlertDialogPrimitive "Portal")) +(def AlertDialogRootPart (primitive-part AlertDialogPrimitive "Root")) +(def AlertDialogTitlePart (primitive-part AlertDialogPrimitive "Title")) +(def AlertDialogTriggerPart (primitive-part AlertDialogPrimitive "Trigger")) +(def AvatarFallbackPart (primitive-part AvatarPrimitive "Fallback")) +(def AvatarImagePart (primitive-part AvatarPrimitive "Image")) +(def AvatarRootPart (primitive-part AvatarPrimitive "Root")) +(def CheckboxIndicatorPart (primitive-part CheckboxPrimitive "Indicator")) +(def CheckboxRootPart (primitive-part CheckboxPrimitive "Root")) +(def ContextMenuCheckboxItemPart (primitive-part ContextMenuPrimitive "CheckboxItem")) +(def ContextMenuGroupPart (primitive-part ContextMenuPrimitive "Group")) +(def ContextMenuGroupLabelPart (primitive-part ContextMenuPrimitive "GroupLabel")) +(def ContextMenuItemPart (primitive-part ContextMenuPrimitive "Item")) +(def ContextMenuPopupPart (primitive-part ContextMenuPrimitive "Popup")) +(def ContextMenuPortalPart (primitive-part ContextMenuPrimitive "Portal")) +(def ContextMenuPositionerPart (primitive-part ContextMenuPrimitive "Positioner")) +(def ContextMenuRadioGroupPart (primitive-part ContextMenuPrimitive "RadioGroup")) +(def ContextMenuRadioItemPart (primitive-part ContextMenuPrimitive "RadioItem")) +(def ContextMenuRootPart (primitive-part ContextMenuPrimitive "Root")) +(def ContextMenuSeparatorPart (primitive-part ContextMenuPrimitive "Separator")) +(def ContextMenuSubmenuRootPart (primitive-part ContextMenuPrimitive "SubmenuRoot")) +(def ContextMenuTriggerPart (primitive-part ContextMenuPrimitive "Trigger")) +(def DialogBackdropPart (primitive-part DialogPrimitive "Backdrop")) +(def DialogClosePart (primitive-part DialogPrimitive "Close")) +(def DialogDescriptionPart (primitive-part DialogPrimitive "Description")) +(def DialogPopupPart (primitive-part DialogPrimitive "Popup")) +(def DialogPortalPart (primitive-part DialogPrimitive "Portal")) +(def DialogRootPart (primitive-part DialogPrimitive "Root")) +(def DialogTitlePart (primitive-part DialogPrimitive "Title")) +(def DialogTriggerPart (primitive-part DialogPrimitive "Trigger")) +(def MenuArrowPart (primitive-part MenuPrimitive "Arrow")) +(def MenuCheckboxItemPart (primitive-part MenuPrimitive "CheckboxItem")) +(def MenuCheckboxItemIndicatorPart (primitive-part MenuPrimitive "CheckboxItemIndicator")) +(def MenuGroupPart (primitive-part MenuPrimitive "Group")) +(def MenuGroupLabelPart (primitive-part MenuPrimitive "GroupLabel")) +(def MenuItemPart (primitive-part MenuPrimitive "Item")) +(def MenuPopupPart (primitive-part MenuPrimitive "Popup")) +(def MenuPortalPart (primitive-part MenuPrimitive "Portal")) +(def MenuPositionerPart (primitive-part MenuPrimitive "Positioner")) +(def MenuRadioGroupPart (primitive-part MenuPrimitive "RadioGroup")) +(def MenuRadioItemPart (primitive-part MenuPrimitive "RadioItem")) +(def MenuRadioItemIndicatorPart (primitive-part MenuPrimitive "RadioItemIndicator")) +(def MenuRootPart (primitive-part MenuPrimitive "Root")) +(def MenuSeparatorPart (primitive-part MenuPrimitive "Separator")) +(def MenuSubmenuRootPart (primitive-part MenuPrimitive "SubmenuRoot")) +(def MenuSubmenuTriggerPart (primitive-part MenuPrimitive "SubmenuTrigger")) +(def MenuTriggerPart (primitive-part MenuPrimitive "Trigger")) +(def PopoverArrowPart (primitive-part PopoverPrimitive "Arrow")) +(def PopoverClosePart (primitive-part PopoverPrimitive "Close")) +(def PopoverPopupPart (primitive-part PopoverPrimitive "Popup")) +(def PopoverPortalPart (primitive-part PopoverPrimitive "Portal")) +(def PopoverPositionerPart (primitive-part PopoverPrimitive "Positioner")) +(def PopoverRootPart (primitive-part PopoverPrimitive "Root")) +(def PopoverTriggerPart (primitive-part PopoverPrimitive "Trigger")) +(def RadioIndicatorPart (primitive-part RadioPrimitive "Indicator")) +(def RadioRootPart (primitive-part RadioPrimitive "Root")) +(def SelectGroupPart (primitive-part SelectPrimitive "Group")) +(def SelectGroupLabelPart (primitive-part SelectPrimitive "GroupLabel")) +(def SelectIconPart (primitive-part SelectPrimitive "Icon")) +(def SelectItemPart (primitive-part SelectPrimitive "Item")) +(def SelectItemIndicatorPart (primitive-part SelectPrimitive "ItemIndicator")) +(def SelectItemTextPart (primitive-part SelectPrimitive "ItemText")) +(def SelectListPart (primitive-part SelectPrimitive "List")) +(def SelectPopupPart (primitive-part SelectPrimitive "Popup")) +(def SelectPortalPart (primitive-part SelectPrimitive "Portal")) +(def SelectPositionerPart (primitive-part SelectPrimitive "Positioner")) +(def SelectRootPart (primitive-part SelectPrimitive "Root")) +(def SelectScrollDownArrowPart (primitive-part SelectPrimitive "ScrollDownArrow")) +(def SelectScrollUpArrowPart (primitive-part SelectPrimitive "ScrollUpArrow")) +(def SelectSeparatorPart (primitive-part SelectPrimitive "Separator")) +(def SelectTriggerPart (primitive-part SelectPrimitive "Trigger")) +(def SelectValuePart (primitive-part SelectPrimitive "Value")) +(def SliderIndicatorPart (primitive-part SliderPrimitive "Indicator")) +(def SliderRootPart (primitive-part SliderPrimitive "Root")) +(def SliderThumbPart (primitive-part SliderPrimitive "Thumb")) +(def SliderTrackPart (primitive-part SliderPrimitive "Track")) +(def SwitchRootPart (primitive-part SwitchPrimitive "Root")) +(def SwitchThumbPart (primitive-part SwitchPrimitive "Thumb")) +(def TabsIndicatorPart (primitive-part TabsPrimitive "Indicator")) +(def TabsListPart (primitive-part TabsPrimitive "List")) +(def TabsPanelPart (primitive-part TabsPrimitive "Panel")) +(def TabsRootPart (primitive-part TabsPrimitive "Root")) +(def TabsTabPart (primitive-part TabsPrimitive "Tab")) +(def ToolbarButtonPart (primitive-part ToolbarPrimitive "Button")) +(def ToolbarGroupPart (primitive-part ToolbarPrimitive "Group")) +(def ToolbarInputPart (primitive-part ToolbarPrimitive "Input")) +(def ToolbarLinkPart (primitive-part ToolbarPrimitive "Link")) +(def ToolbarRootPart (primitive-part ToolbarPrimitive "Root")) +(def ToolbarSeparatorPart (primitive-part ToolbarPrimitive "Separator")) +(def TooltipArrowPart (primitive-part TooltipPrimitive "Arrow")) +(def TooltipPopupPart (primitive-part TooltipPrimitive "Popup")) +(def TooltipPortalPart (primitive-part TooltipPrimitive "Portal")) +(def TooltipPositionerPart (primitive-part TooltipPrimitive "Positioner")) +(def TooltipProviderPart (primitive-part TooltipPrimitive "Provider")) +(def TooltipRootPart (primitive-part TooltipPrimitive "Root")) +(def TooltipTriggerPart (primitive-part TooltipPrimitive "Trigger")) + +(def ToastProvider (gobj/get ToastPrimitive "Provider")) +(def ToastPortal (gobj/get ToastPrimitive "Portal")) +(def ToastViewport (gobj/get ToastPrimitive "Viewport")) +(def ToastPositioner (gobj/get ToastPrimitive "Positioner")) +(def ToastRoot (gobj/get ToastPrimitive "Root")) +(def ToastContent (gobj/get ToastPrimitive "Content")) +(def ToastTitle (gobj/get ToastPrimitive "Title")) +(def ToastDescription (gobj/get ToastPrimitive "Description")) +(def ToastClose (gobj/get ToastPrimitive "Close")) +(def createToastManager (gobj/get ToastPrimitive "createToastManager")) +(def useToastManagerImpl (gobj/get ToastPrimitive "useToastManager")) + +(defn- prop + [^js props k] + (gobj/get props k)) + +(defn- set-prop! + [^js props k v] + (gobj/set props k v) + props) + +(defn- copy-props + [^js props] + (js/Object.assign #js {} (or props #js {}))) + +(defn- clean-props! + [^js props & ks] + (doseq [k ks] + (js-delete props k)) + props) + +(defn- adapt-focus-props! + [^js props] + (when (and (nil? (prop props "initialFocus")) + (some? (prop props "onOpenAutoFocus"))) + (set-prop! props "initialFocus" false)) + (when (and (nil? (prop props "finalFocus")) + (some? (prop props "onCloseAutoFocus"))) + (set-prop! props "finalFocus" false)) + (clean-props! props "onOpenAutoFocus" "onCloseAutoFocus")) + +(defn- clean-radix-popup-props! + [^js props] + (clean-props! props "onEscapeKeyDown" "onPointerDownOutside")) + +(def ^:private portal-prop-names + ["container" "keepMounted"]) + +(def ^:private positioner-prop-names + ["align" + "alignItemWithTrigger" + "alignOffset" + "anchor" + "collisionAvoidance" + "collisionBoundary" + "collisionPadding" + "disableAnchorTracking" + "positionMethod" + "side" + "sideOffset" + "sticky"]) + +(defn- copy-named-props + [^js props prop-names] + (let [props' #js {}] + (doseq [k prop-names + :let [v (prop props k)] + :when (some? v)] + (set-prop! props' k v)) + props')) + +(defn- merge-object-props! + [^js props & sources] + (doseq [source sources + :when source] + (js/Object.assign props source)) + props) + +(defn- apply-default-collision-avoidance! + [^js props] + (set-prop! props "collisionAvoidance" + (merge-object-props! #js {:fallbackAxisSide "none"} + (prop props "collisionAvoidance")))) + +(defn- prop-name + [v] + (cond + (nil? v) nil + (keyword? v) (name v) + :else (str v))) + +(defn- variant-split-index + [class-name] + (let [length (count class-name)] + (loop [idx 0 + bracket-depth 0 + split-idx nil] + (if (< idx length) + (let [ch (.charAt class-name idx)] + (cond + (= ch "[") (recur (inc idx) (inc bracket-depth) split-idx) + (= ch "]") (recur (inc idx) (max 0 (dec bracket-depth)) split-idx) + (and (= ch ":") (zero? bracket-depth)) (recur (inc idx) bracket-depth idx) + :else (recur (inc idx) bracket-depth split-idx))) + split-idx)))) + +(defn- utility-conflict + [class-name] + (let [split-idx (variant-split-index class-name) + variant-prefix (if split-idx (subs class-name 0 (inc split-idx)) "") + utility (if split-idx (subs class-name (inc split-idx)) class-name) + important? (string/starts-with? utility "!") + utility (cond-> utility important? (subs 1)) + group (cond + (re-matches #"^-?h-.+" utility) "h" + (re-matches #"^-?min-h-.+" utility) "min-h" + (re-matches #"^-?max-h-.+" utility) "max-h" + (re-matches #"^-?w-.+" utility) "w" + (re-matches #"^-?min-w-.+" utility) "min-w" + (re-matches #"^-?max-w-.+" utility) "max-w" + (re-matches #"^-?p-.+" utility) "p" + (re-matches #"^-?px-.+" utility) "px" + (re-matches #"^-?py-.+" utility) "py" + (re-matches #"^-?pt-.+" utility) "pt" + (re-matches #"^-?pr-.+" utility) "pr" + (re-matches #"^-?pb-.+" utility) "pb" + (re-matches #"^-?pl-.+" utility) "pl")] + (when group + {:key (str variant-prefix group) + :important? important?}))) + +(defn- merge-classes + [classes] + (:classes + (reduce + (fn [{:keys [classes indexes important?] :as state} class-name] + (if-let [{key :key current-important? :important?} (utility-conflict class-name)] + (if-let [idx (get indexes key)] + (if (and (get important? key) (not current-important?)) + state + {:classes (conj (assoc classes idx nil) class-name) + :indexes (assoc indexes key (count classes)) + :important? (assoc important? key current-important?)}) + {:classes (conj classes class-name) + :indexes (assoc indexes key (count classes)) + :important? (assoc important? key current-important?)}) + (update state :classes conj class-name))) + {:classes [] + :indexes {} + :important? {}} + classes))) + +(defn cn + [& xs] + (->> xs + flatten + (remove #(or (nil? %) (false? %) (= "" %))) + (map prop-name) + (mapcat #(string/split % #"\s+")) + (remove string/blank?) + merge-classes + (remove nil?) + (string/join " "))) + +(defn- with-class-props + [^js props base-class extra-class] + (let [props' (copy-props props)] + (set-prop! props' "className" (cn base-class extra-class (prop props "className"))) + props')) + +(defn- adapt-menu-item-props! + [^js props] + (when-let [on-select (prop props "onSelect")] + (when (nil? (prop props "onClick")) + (set-prop! props "onClick" on-select)) + (clean-props! props "onSelect")) + props) + +(defn- native-event + [^js event] + (or (some-> event (.-nativeEvent)) event)) + +(defn- event-target + [^js event] + (let [^js native-event (native-event event)] + (or (some-> event (.-target)) + (some-> native-event (.-target)) + (some-> event (.-currentTarget)) + (some-> native-event (.-currentTarget))))) + +(defn- input-target? + [target] + (and (instance? js/Element target) + (or (instance? js/HTMLInputElement target) + (instance? js/HTMLTextAreaElement target) + (instance? js/HTMLSelectElement target) + (some? (.closest target "[contenteditable='true']"))))) + +(defn- prevent-base-ui-handler! + [^js event] + (when (fn? (.-preventBaseUIHandler event)) + (.preventBaseUIHandler event))) + +(defn- popup-key-down-handler + [handler] + (fn [^js event] + (some-> handler (apply [event])) + (when (input-target? (event-target event)) + (prevent-base-ui-handler! event)))) + +(def ^:private popup-scroll-style + #js {:maxHeight "var(--available-height)" + :overflowY "auto" + :overflowX "hidden"}) + +(def ^:private popup-transition-class + "animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2") + +(def ^:private tooltip-transition-class + popup-transition-class) + +(def ^:private overlay-transition-class + "animate-in fade-in-0") + +(def ^:private dialog-transition-class + "ui__dialog-zoom-in") + +(defn- popup-normal-style + [] + (doto (merge-object-props! #js {:fontSize "1rem" + :lineHeight 1.5} + popup-scroll-style) + (set-prop! "--ls-page-title-size" "1rem"))) + +(defn- forward-dom + ([tag base-class] + (forward-dom tag base-class nil)) + ([tag base-class extra-class-fn] + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props base-class (some-> extra-class-fn (apply [props])))] + (when ref (set-prop! props' "ref" ref)) + (react/createElement tag props')))))) + +(defn- child-array + [children] + (react/Children.toArray children)) + +(defn- only-child + [children] + (first (array-seq (child-array children)))) + +(defn- own-prop? + [^js props k] + (and props (gobj/containsKey props k))) + +(defn- select-item-entry + [^js props] + (doto #js {} + (set-prop! "value" (prop props "value")) + (set-prop! "label" (prop props "children")))) + +(defn- infer-select-items + [children] + (letfn [(infer [children] + (mapcat + (fn [child] + (if (react/isValidElement child) + (let [props (prop child "props")] + (if (own-prop? props "value") + [(select-item-entry props)] + (infer (prop props "children")))) + [])) + (array-seq (child-array children))))] + (seq (infer children)))) + +(defn- as-child? + [^js props] + (true? (or (prop props "asChild") + (prop props "as-child")))) + +(defn- forward-part + ([component base-class] + (forward-part component base-class nil)) + ([component base-class extra-class-fn] + (react/forwardRef + (fn [^js props ref] + (let [as-child? (as-child? props) + render-child (when as-child? (only-child (prop props "children"))) + props' (with-class-props props base-class (some-> extra-class-fn (apply [props])))] + (when ref (set-prop! props' "ref" ref)) + (when as-child? + (clean-props! props' "asChild" "as-child" "children") + (set-prop! props' "render" render-child)) + (react/createElement component props')))))) + +(defn- composed-popup + [portal positioner popup base-class & {:keys [extra-class-fn]}] + (react/forwardRef + (fn [^js props ref] + (let [portal-props (merge-object-props! (copy-named-props props portal-prop-names) + (prop props "portalProps")) + positioner-props (merge-object-props! (copy-named-props props positioner-prop-names) + (prop props "positionerProps")) + popup-props (with-class-props props + (cn base-class popup-transition-class "outline-none focus:outline-none focus-visible:outline-none") + (some-> extra-class-fn (apply [props]))) + on-key-down (prop popup-props "onKeyDown") + children (prop props "children")] + (set-prop! positioner-props "style" + (merge-object-props! #js {:zIndex 99999} (prop positioner-props "style"))) + (apply-default-collision-avoidance! positioner-props) + (set-prop! popup-props "style" + (merge-object-props! (popup-normal-style) (prop popup-props "style"))) + (set-prop! popup-props "data-state" "open") + (set-prop! popup-props "onKeyDown" (popup-key-down-handler on-key-down)) + (adapt-focus-props! popup-props) + (clean-radix-popup-props! popup-props) + (when ref (set-prop! popup-props "ref" ref)) + (doseq [k (concat portal-prop-names + positioner-prop-names + ["portalProps" "positionerProps" + "children" "withoutAnimation" "position"])] + (js-delete popup-props k)) + (react/createElement + portal portal-props + (react/createElement + positioner positioner-props + (react/createElement popup popup-props children))))))) + +(defn- with-button-classes + [^js props] + (let [variant (or (prop-name (prop props "variant")) "default") + size (or (prop-name (prop props "size")) "default")] + (cn + "ui__button inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md text-sm gap-1 font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none" + (case variant + "default" "bg-primary/90 hover:bg-primary/100 active:opacity-90 text-primary-foreground hover:text-primary-foreground as-solid" + "solid" "bg-primary/90 hover:bg-primary/100 active:opacity-90 text-primary-foreground hover:text-primary-foreground as-solid" + "destructive" "bg-destructive/90 hover:bg-destructive/100 active:opacity-90 text-destructive-foreground hover:text-destructive-foreground as-destructive" + "outline" "border bg-background hover:bg-accent hover:text-accent-foreground active:opacity-80 as-outline" + "secondary" "bg-secondary/70 text-secondary-foreground hover:bg-secondary/100 active:opacity-80 as-secondary" + "ghost" "hover:bg-secondary/70 hover:text-secondary-foreground active:opacity-80 as-ghost" + "text" "hover:bg-secondary/70 hover:text-secondary-foreground active:opacity-80 as-text" + "link" "text-primary underline-offset-4 hover:underline active:opacity-80 as-link" + "") + (case size + "md" "h-9 px-4 rounded-md py-2" + "lg" "h-11 text-base rounded-md px-8" + "sm" "h-7 rounded px-3 py-1" + "xs" "h-6 text-xs rounded px-3" + "icon" "box-content h-6 w-6 p-1 overflow-hidden" + "h-10 px-4 py-2")))) + +(defn- button-like + [component ^js props ref] + (let [as-child? (as-child? props) + props' (copy-props props) + class-name (cn (with-button-classes props) (prop props "className"))] + (clean-props! props' "variant" "size") + (set-prop! props' "className" class-name) + (when ref (set-prop! props' "ref" ref)) + (if as-child? + (let [child (only-child (prop props "children"))] + (clean-props! props' "asChild" "as-child" "children") + (set-prop! props' "render" child) + (when (nil? (prop props "nativeButton")) + (set-prop! props' "nativeButton" false)) + (react/createElement component props')) + (do + (clean-props! props' "asChild" "as-child") + (react/createElement component props'))))) + +(def Button + (react/forwardRef + (fn [^js props ref] + (button-like ButtonPrimitive props ref)))) + +(def ToolbarButton + (react/forwardRef + (fn [^js props ref] + (button-like ToolbarButtonPart props ref)))) + +(def Link (forward-dom "a" "ui__link")) +(def ButtonGroup (forward-dom "div" "ui__button-group inline-flex items-center gap-1")) + +(def Alert (forward-dom "div" "ui__alert relative w-full rounded-lg border p-4")) +(def AlertTitle (forward-dom "h5" "ui__alert-title mb-1 font-medium leading-none tracking-tight")) +(def AlertDescription (forward-dom "div" "ui__alert-description text-sm [&_p]:leading-relaxed")) +(def Badge (forward-dom "div" "ui__badge inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors")) +(def Skeleton (forward-dom "div" "ui__skeleton animate-pulse rounded-md bg-muted")) +(def Card (forward-dom "div" "ui__card rounded-lg border bg-card text-card-foreground shadow-sm")) +(def CardHeader (forward-dom "div" "ui__card-header flex flex-col space-y-1.5 p-6")) +(def CardTitle (forward-dom "h3" "ui__card-title text-2xl font-semibold leading-none tracking-tight")) +(def CardDescription (forward-dom "p" "ui__card-description text-sm text-muted-foreground")) +(def CardContent (forward-dom "div" "ui__card-content p-6 pt-0")) +(def CardFooter (forward-dom "div" "ui__card-footer flex items-center p-6 pt-0")) +(def Input (forward-part InputPrimitive "ui__input flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50")) +(def Textarea (forward-dom "textarea" "ui__textarea flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50")) +(def Label (forward-dom "label" "ui__label text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")) +(def Separator (forward-part SeparatorPrimitive "ui__separator shrink-0 bg-border data-[orientation=horizontal]:h-[1px] data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[1px]")) +(def Avatar (forward-part AvatarRootPart "ui__avatar relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full")) +(def AvatarImage (forward-part AvatarImagePart "ui__avatar-image aspect-square h-full w-full")) +(def AvatarFallback (forward-part AvatarFallbackPart "ui__avatar-fallback flex h-full w-full items-center justify-center rounded-full bg-muted")) +(defn- CalendarChevron + [^js props] + (let [orientation (prop props "orientation") + class-name (cn "h-4 w-4 text-foreground" (prop props "className"))] + (react/createElement + (case orientation + "left" IconChevronLeft + "up" IconChevronUp + "down" IconChevronDown + IconChevronRight) + #js {:className class-name + :size 16 + :style #js {:width 16 + :height 16} + :aria-hidden true}))) + +(def Calendar + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__calendar p-3" nil) + class-names (merge-object-props! + #js {:months "relative flex flex-col sm:flex-row space-y-4 sm:space-y-0" + :month "rdp-month w-max space-y-3" + :month_caption "flex justify-start pt-1 relative items-center w-[276px]" + :caption_label "text-sm font-medium" + :dropdowns "flex items-center justify-start gap-2" + :months_dropdown "rdp-dropdown_month" + :years_dropdown "rdp-dropdown_year" + :dropdown_root "relative flex h-8 items-center rounded-md border border-input bg-background px-2" + :dropdown "absolute inset-0 z-[2] w-full opacity-0 cursor-pointer" + :nav "absolute left-[200px] top-1 z-10 flex items-center gap-1" + :button_previous (cn (with-button-classes #js {:variant "outline"}) + "h-8 w-9 bg-transparent !p-0 !px-0 !py-0 opacity-80 hover:opacity-100") + :button_next (cn (with-button-classes #js {:variant "outline"}) + "h-8 w-9 bg-transparent !p-0 !px-0 !py-0 opacity-80 hover:opacity-100") + :month_grid "w-max border-collapse space-y-1" + :weekdays "hidden" + :weekday "text-muted-foreground rounded-md w-9 text-center font-normal text-[0.8rem]" + :week "flex w-max mt-1 gap-1" + :day "h-9 w-9 flex shrink-0 items-center justify-center text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-transparent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20" + :day_button (cn (with-button-classes #js {:variant "ghost"}) + "h-9 w-9 p-0 font-normal aria-selected:opacity-100") + :range_end "day-range-end" + :selected "[&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:hover]:text-primary-foreground [&>button:focus]:bg-primary [&>button:focus]:text-primary-foreground" + :today "[&>button]:bg-accent [&>button]:text-accent-foreground" + :outside "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30" + :disabled "text-muted-foreground opacity-50" + :range_middle "aria-selected:bg-accent aria-selected:text-accent-foreground" + :hidden "invisible"} + (prop props "classNames")) + components (merge-object-props! #js {:Chevron CalendarChevron} + (prop props "components")) + style (js/Object.assign #js {:maxWidth "100%"} (prop props "style"))] + (when ref (set-prop! props' "ref" ref)) + (when (nil? (prop props' "showOutsideDays")) + (set-prop! props' "showOutsideDays" true)) + (set-prop! props' "classNames" class-names) + (set-prop! props' "components" components) + (set-prop! props' "style" style) + (react/createElement DayPicker props'))))) +(def Toolbar (forward-part ToolbarRootPart "ui__toolbar flex items-center gap-1")) +(def ToolbarGroup (forward-part ToolbarGroupPart "ui__toolbar-group inline-flex items-center gap-1")) +(def ToolbarInput (forward-part ToolbarInputPart "ui__toolbar-input")) +(def ToolbarLink (forward-part ToolbarLinkPart "ui__toolbar-link")) +(def ToolbarSeparator (forward-part ToolbarSeparatorPart "ui__toolbar-separator shrink-0 bg-border data-[orientation=horizontal]:h-[1px] data-[orientation=horizontal]:w-5 data-[orientation=vertical]:h-5 data-[orientation=vertical]:w-[1px]")) + +(def Checkbox + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__checkbox peer h-4 w-4 shrink-0 cursor-pointer rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-primary data-[checked]:text-primary-foreground" nil) + checked? (true? (prop props "checked")) + disabled? (true? (prop props "disabled"))] + (when ref (set-prop! props' "ref" ref)) + (if (true? (prop props "data-inputless")) + (do + (clean-props! props' "checked" "data-inputless") + (set-prop! props' "role" "checkbox") + (set-prop! props' "aria-checked" checked?) + (set-prop! props' "data-state" (if checked? "checked" "unchecked")) + (set-prop! props' "data-checked" (when checked? "")) + (set-prop! props' "data-disabled" (when disabled? "")) + (react/createElement + "span" props' + (when checked? + (react/createElement IconCheck #js {:className "h-4 w-4"})))) + (do + (set-prop! props' "render" (react/createElement "button")) + (set-prop! props' "nativeButton" true) + (react/createElement + CheckboxRootPart props' + (react/createElement CheckboxIndicatorPart nil + (react/createElement IconCheck #js {:className "h-4 w-4"}))))))))) + +(def Switch + (react/forwardRef + (fn [^js props ref] + (let [size (or (prop-name (prop props "size")) "default") + small? (= size "sm") + props' (with-class-props props + (cn "ui__switch peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:justify-end data-[checked]:bg-primary data-[unchecked]:justify-start data-[unchecked]:bg-input pr-[1px] pl-[1px]" + (if small? "h-4.5 w-8" "h-6 w-11")) + nil)] + (clean-props! props' "size") + (when ref (set-prop! props' "ref" ref)) + (react/createElement + SwitchRootPart props' + (react/createElement SwitchThumbPart #js {:className (cn "pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform" + (if small? + "h-3 w-3" + "h-5 w-5"))})))))) + +(def RadioGroup (forward-part RadioGroupPrimitive "ui__radio-group grid gap-2")) +(def RadioGroupItem + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__radio-group-item aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 flex items-center justify-center" nil)] + (when ref (set-prop! props' "ref" ref)) + (react/createElement + RadioRootPart props' + (react/createElement RadioIndicatorPart #js {:className "flex items-center justify-center"} + (react/createElement IconCircle #js {:className "h-2.5 w-2.5 fill-current text-current"}))))))) + +(def Slider (forward-part SliderRootPart "ui__slider relative flex w-full touch-none select-none items-center")) +(def SliderTrack (forward-part SliderTrackPart "ui__slider-track relative h-2 w-full grow overflow-hidden rounded-full bg-secondary")) +(def SliderRange (forward-part SliderIndicatorPart "ui__slider-range absolute h-full bg-primary")) +(def SliderThumb (forward-part SliderThumbPart "ui__slider-thumb block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50")) + +(def DropdownMenu (forward-part MenuRootPart nil)) +(def DropdownMenuTrigger (forward-part MenuTriggerPart nil)) +(def DropdownMenuArrow (forward-part MenuArrowPart nil)) +(def DropdownMenuGroup (forward-part MenuGroupPart nil)) +(def DropdownMenuPortal MenuPortalPart) + +(def ^:private menu-content-selector + ".ui__dropdown-menu-content, .ui__dropdown-menu-sub-content, .ui__context-menu-content, .ui__context-menu-sub-content") + +(def ^:private submenu-content-selector + ".ui__dropdown-menu-sub-content, .ui__context-menu-sub-content") + +(def ^:private submenu-internal-close-reasons + #{"trigger-hover" "focus-out"}) + +(defn- event-related-target + [^js event-details] + (let [event (some-> event-details (.-event)) + native-event (or (some-> event (.-nativeEvent)) event)] + (or (some-> event (.-relatedTarget)) + (some-> native-event (.-relatedTarget)) + (some-> event (.-toElement)) + (some-> native-event (.-toElement))))) + +(defn- content-target? + [selector target] + (and (instance? js/Element target) + (some? (.closest target selector)))) + +(defn- menu-content-target? + [target] + (content-target? menu-content-selector target)) + +(defn- submenu-content-target? + [target] + (content-target? submenu-content-selector target)) + +(defn- submenu-retained-target? + [reason target] + (case reason + "trigger-hover" (submenu-content-target? target) + "focus-out" (menu-content-target? target) + false)) + +(defn- submenu-internal-close? + [open? ^js event-details] + (let [reason (some-> event-details (.-reason))] + (and (false? open?) + (contains? submenu-internal-close-reasons reason) + (submenu-retained-target? reason (event-related-target event-details))))) + +(defn- menu-sub + [component] + (react/forwardRef + (fn [^js props ref] + (let [props' (copy-props props) + on-open-change (prop props "onOpenChange")] + (when ref (set-prop! props' "ref" ref)) + (set-prop! props' "onOpenChange" + (fn [open? event-details] + (if (submenu-internal-close? open? event-details) + (some-> event-details (.cancel)) + (when (fn? on-open-change) + (on-open-change open? event-details))))) + (react/createElement component props'))))) + +(def DropdownMenuSub (menu-sub MenuSubmenuRootPart)) +(def DropdownMenuRadioGroup MenuRadioGroupPart) +(def DropdownMenuContent + (composed-popup MenuPortalPart MenuPositionerPart MenuPopupPart + "ui__dropdown-menu-content z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md")) +(def DropdownMenuSubContent + (composed-popup MenuPortalPart MenuPositionerPart MenuPopupPart + "ui__dropdown-menu-sub-content z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg")) +(def DropdownMenuItem + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__dropdown-menu-item relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50" nil)] + (adapt-menu-item-props! props') + (when ref (set-prop! props' "ref" ref)) + (react/createElement MenuItemPart props'))))) +(def DropdownMenuCheckboxItem + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__dropdown-menu-checkbox-item relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50" nil) + children (prop props "children")] + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "children") + (react/createElement + MenuCheckboxItemPart props' + (react/createElement "span" #js {:className "absolute left-2 flex h-3.5 w-3.5 items-center justify-center"} + (react/createElement MenuCheckboxItemIndicatorPart nil + (react/createElement IconCheck #js {:className "h-4 w-4"}))) + children))))) +(def DropdownMenuRadioItem + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__dropdown-menu-radio-item relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50" nil) + children (prop props "children")] + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "children") + (react/createElement + MenuRadioItemPart props' + (react/createElement "span" #js {:className "absolute left-2 flex h-3.5 w-3.5 items-center justify-center"} + (react/createElement MenuRadioItemIndicatorPart nil + (react/createElement IconCircle #js {:className "h-2 w-2 fill-current"}))) + children))))) +(def DropdownMenuLabel + (forward-dom "div" "ui__dropdown-menu-label px-2 py-1.5 text-sm font-semibold")) +(def DropdownMenuSeparator (forward-part MenuSeparatorPart "ui__dropdown-menu-separator -mx-1 my-1 h-px bg-muted")) +(def DropdownMenuShortcut (forward-dom "span" "ui__dropdown-menu-shortcut ml-auto text-xs opacity-60")) +(def DropdownMenuSubTrigger + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__dropdown-menu-sub-trigger flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-muted data-[open]:bg-muted" (when (prop props "inset") "pl-8")) + children (prop props "children")] + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "inset" "children") + (react/createElement MenuSubmenuTriggerPart props' + children + (react/createElement IconChevronRight #js {:className "ml-auto h-4 w-4"})))))) + +(def ContextMenu (forward-part ContextMenuRootPart nil)) +(def ContextMenuTrigger (forward-part ContextMenuTriggerPart nil)) +(def ContextMenuGroup (forward-part ContextMenuGroupPart nil)) +(def ContextMenuPortal ContextMenuPortalPart) +(def ContextMenuSub (menu-sub ContextMenuSubmenuRootPart)) +(def ContextMenuRadioGroup ContextMenuRadioGroupPart) +(def ContextMenuContent + (composed-popup ContextMenuPortalPart ContextMenuPositionerPart ContextMenuPopupPart + "ui__context-menu-content z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none focus:outline-none focus-visible:outline-none")) +(def ContextMenuSubContent + (composed-popup ContextMenuPortalPart ContextMenuPositionerPart ContextMenuPopupPart + "ui__context-menu-sub-content z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg outline-none focus:outline-none focus-visible:outline-none")) +(def ContextMenuItem (forward-part ContextMenuItemPart "ui__context-menu-item relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50")) +(def ContextMenuCheckboxItem (forward-part ContextMenuCheckboxItemPart "ui__context-menu-checkbox-item relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50")) +(def ContextMenuRadioItem (forward-part ContextMenuRadioItemPart "ui__context-menu-radio-item relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50")) +(def ContextMenuLabel + (forward-dom "div" "ui__context-menu-label px-2 py-1.5 text-sm font-semibold")) +(def ContextMenuSeparator (forward-part ContextMenuSeparatorPart "ui__context-menu-separator -mx-1 my-1 h-px bg-muted")) +(def ContextMenuShortcut DropdownMenuShortcut) +(def ContextMenuSubTrigger DropdownMenuSubTrigger) + +(def Popover (forward-part PopoverRootPart nil)) +(def PopoverTrigger (forward-part PopoverTriggerPart nil)) +(def PopoverArrow (forward-part PopoverArrowPart nil)) +(def PopoverClose (forward-part PopoverClosePart nil)) +(def PopoverContent + (composed-popup PopoverPortalPart PopoverPositionerPart PopoverPopupPart + "ui__popover-content z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none")) +(def PopoverRemoveScroll (forward-dom "div" nil)) + +(def Dialog (forward-part DialogRootPart nil)) +(def DialogPortal DialogPortalPart) +(def DialogOverlay (forward-part DialogBackdropPart (cn "ui__dialog-overlay fixed inset-0 z-50 bg-background/90 flex justify-center items-center" overlay-transition-class))) +(def DialogClose (forward-part DialogClosePart nil)) +(def DialogTrigger (forward-part DialogTriggerPart nil)) +(def DialogContent + (react/forwardRef + (fn [^js props ref] + (let [overlay-props (or (prop props "overlayProps") #js {}) + popup-props (with-class-props props (cn "ui__dialog-content fixed left-[50%] top-[50%] z-50 grid w-full max-w-2xl lg:max-w-3xl gap-4 border sm:rounded-lg bg-background p-6 shadow-lg" dialog-transition-class) nil) + children (prop props "children") + show-close? (not (false? (prop props "data-close-btn")))] + (adapt-focus-props! popup-props) + (clean-radix-popup-props! popup-props) + (set-prop! popup-props "style" (js/Object.assign #js {} (prop popup-props "style") #js {:transform "translate(-50%, -50%) scale(calc(1 - var(--nested-dialogs, 0) * 0.03))"})) + (set-prop! popup-props "data-state" "open") + (when ref (set-prop! popup-props "ref" ref)) + (clean-props! popup-props "overlayProps" "children") + (react/createElement + DialogPortalPart nil + (react/createElement DialogBackdropPart (doto (with-class-props overlay-props (cn "ui__dialog-overlay fixed inset-0 z-50 bg-background/90 flex justify-center items-center" overlay-transition-class) nil) + (set-prop! "data-state" "open"))) + (react/createElement DialogPopupPart popup-props + children + (when show-close? + (react/createElement DialogClosePart #js {:className "ui__dialog-close absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"} + (react/createElement IconX #js {:className "h-4 w-4"}))))))))) +(def DialogHeader (forward-dom "div" "ui__dialog-header flex flex-col space-y-1.5 text-center sm:text-left")) +(def DialogFooter (forward-dom "div" "ui__dialog-footer flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2")) +(def DialogTitle (forward-part DialogTitlePart "ui__dialog-title text-lg font-semibold leading-none tracking-tight")) +(def DialogDescription (forward-part DialogDescriptionPart "ui__dialog-description text-sm text-muted-foreground")) + +(def AlertDialog (forward-part AlertDialogRootPart nil)) +(def AlertDialogPortal AlertDialogPortalPart) +(def AlertDialogOverlay (forward-part AlertDialogBackdropPart (cn "ui__alert-dialog-overlay fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" overlay-transition-class))) +(def AlertDialogTrigger (forward-part AlertDialogTriggerPart nil)) +(def AlertDialogContent + (react/forwardRef + (fn [^js props ref] + (let [overlay-props (or (prop props "overlayProps") #js {}) + popup-props (with-class-props props (cn "ui__alert-dialog-content fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg sm:rounded-lg" dialog-transition-class) nil)] + (adapt-focus-props! popup-props) + (set-prop! popup-props "style" (js/Object.assign #js {} (prop popup-props "style") #js {:transform "translate(-50%, -50%)"})) + (set-prop! popup-props "data-state" "open") + (when ref (set-prop! popup-props "ref" ref)) + (clean-props! popup-props "overlayProps") + (react/createElement + AlertDialogPortalPart nil + (react/createElement AlertDialogBackdropPart (doto (with-class-props overlay-props (cn "ui__alert-dialog-overlay fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" overlay-transition-class) nil) + (set-prop! "data-state" "open"))) + (react/createElement AlertDialogPopupPart popup-props)))))) +(def AlertDialogHeader (forward-dom "div" "ui__alert-dialog-header flex flex-col space-y-2 text-center sm:text-left")) +(def AlertDialogFooter (forward-dom "div" "ui__alert-dialog-footer flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2")) +(def AlertDialogTitle (forward-part AlertDialogTitlePart "ui__alert-dialog-title text-lg font-semibold")) +(def AlertDialogDescription (forward-part AlertDialogDescriptionPart "ui__alert-dialog-description text-sm text-muted-foreground")) +(def AlertDialogAction Button) +(def AlertDialogCancel + (react/forwardRef + (fn [^js props ref] + (let [props' (copy-props props)] + (set-prop! props' "variant" "outline") + (set-prop! props' "className" (cn "mt-2 sm:mt-0" (prop props "className"))) + (when ref (set-prop! props' "ref" ref)) + (react/createElement Button props'))))) + +(def Select + (react/forwardRef + (fn [^js props ref] + (let [props' (copy-props props)] + (when ref (set-prop! props' "ref" ref)) + (when-not (own-prop? props' "items") + (when-let [items (infer-select-items (prop props "children"))] + (set-prop! props' "items" (to-array items)))) + (react/createElement SelectRootPart props'))))) +(def SelectGroup (forward-part SelectGroupPart nil)) +(def SelectValue (forward-part SelectValuePart nil)) +(def SelectTrigger + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__select-trigger flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1" nil) + children (prop props "children")] + (when ref (set-prop! props' "ref" ref)) + (react/createElement + SelectTriggerPart + props' + children + (react/createElement + SelectIconPart + #js {:className "ui__select-icon shrink-0 text-muted-foreground"} + (react/createElement IconChevronDown #js {:className "h-4 w-4"}))))))) +(def SelectIcon (forward-part SelectIconPart nil)) +(def SelectLabel (forward-part SelectGroupLabelPart "ui__select-label py-1.5 pl-8 pr-2 text-sm font-semibold")) +(def SelectSeparator (forward-part SelectSeparatorPart "ui__select-separator -mx-1 my-1 h-px bg-muted")) +(def SelectScrollUpButton (forward-part SelectScrollUpArrowPart "ui__select-up-button flex cursor-default items-center justify-center py-1")) +(def SelectScrollDownButton (forward-part SelectScrollDownArrowPart "ui__select-down-button flex cursor-default items-center justify-center py-1")) +(def SelectContent + (react/forwardRef + (fn [^js props ref] + (let [portal-props (merge-object-props! (copy-named-props props portal-prop-names) + (prop props "portalProps")) + positioner-props (merge-object-props! (copy-named-props props positioner-prop-names) + (prop props "positionerProps")) + popup-props (with-class-props props (cn "ui__select-content relative z-[99999] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md" popup-transition-class) nil) + children (prop props "children")] + (when ref (set-prop! popup-props "ref" ref)) + (set-prop! positioner-props "className" (cn "z-[99999]" (prop positioner-props "className"))) + (set-prop! positioner-props "style" (js/Object.assign #js {} #js {:zIndex 99999} (prop positioner-props "style"))) + (apply-default-collision-avoidance! positioner-props) + (set-prop! popup-props "style" + (js/Object.assign #js {} + popup-scroll-style + #js {:display "flex" + :flexDirection "column"} + (prop popup-props "style") + #js {:zIndex 99999})) + (set-prop! popup-props "data-state" "open") + (doseq [k (concat portal-prop-names + positioner-prop-names + ["portalProps" "positionerProps" + "children" "position"])] + (js-delete popup-props k)) + (react/createElement + SelectPortalPart portal-props + (react/createElement + SelectPositionerPart positioner-props + (react/createElement + SelectPopupPart popup-props + (react/createElement SelectListPart #js {:className "p-1" + :style #js {:flex "1 1 auto" + :minHeight 0 + :maxHeight "100%" + :overflowY "auto" + :overflowX "hidden"}} + children)))))))) +(def SelectItem + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props "ui__select-item flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50" nil) + children (prop props "children")] + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "children") + (set-prop! props' "render" + (fn [^js item-props ^js state] + (react/createElement + "div" item-props + (react/createElement + "span" + #js {:className "flex h-4 w-4 shrink-0 items-center justify-center"} + (when (.-selected state) + (react/createElement IconCheck #js {:className "h-4 w-4"}))) + (react/createElement SelectItemTextPart nil + (react/createElement "span" nil children))))) + (react/createElement + SelectItemPart props'))))) + +(def Tooltip (forward-part TooltipRootPart nil)) +(def TooltipTrigger (forward-part TooltipTriggerPart nil)) +(def TooltipPortal TooltipPortalPart) +(def TooltipContent + (react/forwardRef + (fn [^js props ref] + (let [portal-props (merge-object-props! (copy-named-props props portal-prop-names) + (prop props "portalProps")) + positioner-props (merge-object-props! (copy-named-props props positioner-prop-names) + (prop props "positionerProps")) + popup-props (with-class-props props (cn "ui__tooltip-content z-50 rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md" tooltip-transition-class) nil) + children (prop props "children")] + (set-prop! positioner-props "style" + (merge-object-props! #js {:zIndex 99999} (prop positioner-props "style"))) + (apply-default-collision-avoidance! positioner-props) + (set-prop! popup-props "style" + (merge-object-props! #js {} popup-scroll-style (prop popup-props "style"))) + (set-prop! popup-props "data-state" "open") + (adapt-focus-props! popup-props) + (clean-radix-popup-props! popup-props) + (when ref (set-prop! popup-props "ref" ref)) + (doseq [k (concat portal-prop-names + positioner-prop-names + ["portalProps" "positionerProps" + "children" "withoutAnimation" "position"])] + (js-delete popup-props k)) + (react/createElement + TooltipPortalPart portal-props + (react/createElement + TooltipPositionerPart positioner-props + (react/createElement + TooltipPopupPart popup-props + children + (react/createElement TooltipArrowPart + #js {:className "ui__tooltip-arrow h-2 w-2 rotate-45 border bg-popover data-[side=top]:-bottom-1 data-[side=bottom]:-top-1 data-[side=left]:-right-1 data-[side=right]:-left-1"})))))))) +(def TooltipProvider TooltipProviderPart) +(def TooltipArrow (forward-part TooltipArrowPart nil)) + +(def Tabs (forward-part TabsRootPart nil)) +(defn- tabs-list-class + [props] + (case (prop-name (prop props "variant")) + "line" "ui__tabs-list relative inline-flex h-auto items-center justify-start bg-transparent p-0 text-muted-foreground" + "ui__tabs-list inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground")) + +(defn- tabs-trigger-class + [props] + (case (prop-name (prop props "variant")) + "line" "ui__tabs-trigger inline-flex items-center justify-center whitespace-nowrap px-0 pb-3 pt-0 text-base font-medium text-muted-foreground transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:text-foreground aria-selected:text-foreground" + "ui__tabs-trigger inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-background data-[active]:text-foreground data-[active]:shadow-sm aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm")) + +(def TabsIndicator + (forward-part TabsIndicatorPart "ui__tabs-indicator absolute bottom-0 left-[var(--active-tab-left)] h-[3px] w-[var(--active-tab-width)] bg-foreground transition-all duration-200 ease-out")) + +(def TabsList + (react/forwardRef + (fn [^js props ref] + (let [line? (= "line" (prop-name (prop props "variant"))) + props' (with-class-props props (tabs-list-class props) nil)] + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "variant") + (if line? + (react/createElement TabsListPart props' + (prop props' "children") + (react/createElement TabsIndicator nil)) + (react/createElement TabsListPart props')))))) + +(def TabsTrigger + (react/forwardRef + (fn [^js props ref] + (let [props' (with-class-props props (tabs-trigger-class props) nil)] + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "variant") + (react/createElement TabsTabPart props'))))) +(def TabsContent (forward-part TabsPanelPart "ui__tabs-content mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2")) +(def Toggle (forward-part TogglePrimitive "ui__toggle inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[pressed]:bg-accent data-[pressed]:text-accent-foreground")) +(def ToggleGroup (forward-part ToggleGroupPrimitive "ui__toggle-group flex items-center justify-center gap-1")) +(def ToggleGroupItem Toggle) + +(def Form FormProvider) +(def FormItemContext (react/createContext #js {})) +(def FormFieldContext (react/createContext #js {})) +(def FormField + (fn [^js props] + (react/createElement + FormFieldContext.Provider #js {:value #js {:name (prop props "name")}} + (react/createElement Controller props)))) + +(defn useFormField [] + (let [field-context (react/useContext FormFieldContext) + item-context (react/useContext FormItemContext) + methods (useFormContext) + field-state (.getFieldState methods (prop field-context "name") (.-formState methods)) + id (or (prop item-context "id") (str "ui-form-" (random-uuid)))] + (js/Object.assign + #js {:id id + :name (prop field-context "name") + :formItemId (str id "-form-item") + :formDescriptionId (str id "-form-item-description") + :formMessageId (str id "-form-item-message")} + field-state))) + +(def FormItem + (react/forwardRef + (fn [^js props ref] + (let [id (react/useId) + props' (with-class-props props "ui__form-item" nil)] + (when ref (set-prop! props' "ref" ref)) + (react/createElement + FormItemContext.Provider #js {:value #js {:id id}} + (react/createElement "div" props')))))) + +(def FormLabel + (react/forwardRef + (fn [^js props ref] + (let [state (useFormField) + error (prop state "error") + props' (with-class-props props "ui__form-label" (when error "text-destructive"))] + (set-prop! props' "htmlFor" (prop state "formItemId")) + (when ref (set-prop! props' "ref" ref)) + (react/createElement Label props'))))) + +(def FormControl + (react/forwardRef + (fn [^js props ref] + (let [state (useFormField) + error (prop state "error") + child (only-child (prop props "children")) + child-props #js {:id (prop state "formItemId") + :aria-describedby (if error + (str (prop state "formDescriptionId") " " (prop state "formMessageId")) + (prop state "formDescriptionId")) + :aria-invalid (boolean error)}] + (when ref (set-prop! child-props "ref" ref)) + (react/cloneElement child child-props))))) + +(def FormDescription + (react/forwardRef + (fn [^js props ref] + (let [state (useFormField) + props' (with-class-props props "ui__form-description text-sm text-muted-foreground" nil)] + (set-prop! props' "id" (prop state "formDescriptionId")) + (when ref (set-prop! props' "ref" ref)) + (react/createElement "p" props'))))) + +(def FormMessage + (react/forwardRef + (fn [^js props ref] + (let [state (useFormField) + error (prop state "error") + body (or (some-> error (prop "message") str) (prop props "children"))] + (when body + (let [props' (with-class-props props "ui__form-message text-sm font-medium text-destructive" nil)] + (set-prop! props' "id" (prop state "formMessageId")) + (when ref (set-prop! props' "ref" ref)) + (clean-props! props' "children") + (react/createElement "p" props' body))))))) + +(def toast-manager (createToastManager)) +(defn genToastId [] (str (random-uuid))) +(defn- normalize-toast-options + ([^js options] + (normalize-toast-options options true)) + ([^js options include-id?] + (let [props (copy-props options) + id (prop options "id") + toast-id (or id (genToastId)) + variant (prop options "variant") + duration (prop options "duration") + action (prop options "action") + icon (prop options "icon") + class-name (prop options "className") + on-dismiss (prop options "onDismiss") + data (js/Object.assign #js {} (or (prop options "data") #js {}))] + (doseq [k ["variant" "duration" "action" "icon" "className" "onDismiss" "open" "onOpenChange"]] + (js-delete props k)) + (when include-id? + (set-prop! props "id" toast-id)) + (when variant + (set-prop! props "type" variant) + (set-prop! data "variant" variant)) + (when duration + (set-prop! props "timeout" duration)) + (when action (set-prop! data "action" action)) + (when icon (set-prop! data "icon" icon)) + (when class-name (set-prop! data "className" class-name)) + (when on-dismiss + (set-prop! props "onClose" #(on-dismiss toast-id))) + (set-prop! props "data" data) + props))) + +(defn- add-toast + [^js options] + (let [props (normalize-toast-options options) + id (.add toast-manager props)] + #js {:id id + :dismiss #(.close toast-manager id) + :update #(.update toast-manager id (normalize-toast-options %))})) + +(defn useToast [] + #js {:toast add-toast + :dismiss #(.close toast-manager %) + :update #(.update toast-manager %1 (normalize-toast-options %2 false))}) +(defn- ToastList [] + (let [^js manager (useToastManagerImpl) + toasts (.-toasts manager)] + (into-array + (map + (fn [^js toast] + (let [data (.-data toast) + variant (or (.-type toast) (prop data "variant")) + action (prop data "action") + icon (prop data "icon") + title? (not (string/blank? (.-title toast)))] + (react/createElement + ToastRoot #js {:key (.-id toast) + :toast toast + :className (cn "ui__toast group pointer-events-auto" + variant + (prop data "className"))} + (react/createElement + ToastContent #js {:className (str "ui__toast-content " (when-not title? "untitled")) + :data-base-ui-swipe-ignore "true"} + (react/createElement + "div" #js {:className "ui__toast-header"} + (when title? icon) + (react/createElement ToastClose #js {:className "ui__toast-close"} + (react/createElement IconX #js {:className "h-4 w-4"}))) + (react/createElement + "div" #js {:className "ui__toast-body"} + (react/createElement + "div" #js {:className "ui__toast-text"} + (if-let [title (.-title toast)] + (react/createElement ToastTitle #js {:className "ui__toast-title"} title) icon) + (when-let [description (.-description toast)] + (react/createElement ToastDescription #js {:className "ui__toast-description"} description))) + action + (when-let [action-props (.-actionProps toast)] + (react/createElement "button" action-props))))))) + (array-seq toasts))))) + +(def Toaster + (react/forwardRef + (fn [_props _ref] + (react/createElement + ToastProvider #js {:toastManager toast-manager} + (react/createElement + ToastPortal nil + (react/createElement + ToastViewport #js {:className "ui__toaster-viewport"} + (react/createElement ToastList nil))))))) + +(def registry + #js {"Button" Button + "Link" Link + "ButtonGroup" ButtonGroup + "Toolbar" Toolbar + "ToolbarGroup" ToolbarGroup + "ToolbarButton" ToolbarButton + "ToolbarInput" ToolbarInput + "ToolbarLink" ToolbarLink + "ToolbarSeparator" ToolbarSeparator + "Alert" Alert + "AlertTitle" AlertTitle + "AlertDescription" AlertDescription + "Badge" Badge + "Skeleton" Skeleton + "Card" Card + "CardHeader" CardHeader + "CardTitle" CardTitle + "CardDescription" CardDescription + "CardContent" CardContent + "CardFooter" CardFooter + "Input" Input + "Textarea" Textarea + "Label" Label + "Separator" Separator + "Calendar" Calendar + "Avatar" Avatar + "AvatarImage" AvatarImage + "AvatarFallback" AvatarFallback + "Checkbox" Checkbox + "Switch" Switch + "RadioGroup" RadioGroup + "RadioGroupItem" RadioGroupItem + "Slider" Slider + "SliderTrack" SliderTrack + "SliderRange" SliderRange + "SliderThumb" SliderThumb + "DropdownMenu" DropdownMenu + "DropdownMenuTrigger" DropdownMenuTrigger + "DropdownMenuArrow" DropdownMenuArrow + "DropdownMenuContent" DropdownMenuContent + "DropdownMenuItem" DropdownMenuItem + "DropdownMenuCheckboxItem" DropdownMenuCheckboxItem + "DropdownMenuRadioItem" DropdownMenuRadioItem + "DropdownMenuLabel" DropdownMenuLabel + "DropdownMenuSeparator" DropdownMenuSeparator + "DropdownMenuShortcut" DropdownMenuShortcut + "DropdownMenuGroup" DropdownMenuGroup + "DropdownMenuPortal" DropdownMenuPortal + "DropdownMenuSub" DropdownMenuSub + "DropdownMenuSubContent" DropdownMenuSubContent + "DropdownMenuSubTrigger" DropdownMenuSubTrigger + "DropdownMenuRadioGroup" DropdownMenuRadioGroup + "ContextMenu" ContextMenu + "ContextMenuTrigger" ContextMenuTrigger + "ContextMenuContent" ContextMenuContent + "ContextMenuItem" ContextMenuItem + "ContextMenuCheckboxItem" ContextMenuCheckboxItem + "ContextMenuRadioItem" ContextMenuRadioItem + "ContextMenuLabel" ContextMenuLabel + "ContextMenuSeparator" ContextMenuSeparator + "ContextMenuShortcut" ContextMenuShortcut + "ContextMenuGroup" ContextMenuGroup + "ContextMenuPortal" ContextMenuPortal + "ContextMenuSub" ContextMenuSub + "ContextMenuSubContent" ContextMenuSubContent + "ContextMenuSubTrigger" ContextMenuSubTrigger + "ContextMenuRadioGroup" ContextMenuRadioGroup + "Popover" Popover + "PopoverTrigger" PopoverTrigger + "PopoverContent" PopoverContent + "PopoverArrow" PopoverArrow + "PopoverClose" PopoverClose + "PopoverRemoveScroll" PopoverRemoveScroll + "Dialog" Dialog + "DialogPortal" DialogPortal + "DialogOverlay" DialogOverlay + "DialogClose" DialogClose + "DialogTrigger" DialogTrigger + "DialogContent" DialogContent + "DialogHeader" DialogHeader + "DialogFooter" DialogFooter + "DialogTitle" DialogTitle + "DialogDescription" DialogDescription + "AlertDialog" AlertDialog + "AlertDialogPortal" AlertDialogPortal + "AlertDialogOverlay" AlertDialogOverlay + "AlertDialogTrigger" AlertDialogTrigger + "AlertDialogContent" AlertDialogContent + "AlertDialogHeader" AlertDialogHeader + "AlertDialogFooter" AlertDialogFooter + "AlertDialogTitle" AlertDialogTitle + "AlertDialogDescription" AlertDialogDescription + "AlertDialogAction" AlertDialogAction + "AlertDialogCancel" AlertDialogCancel + "Select" Select + "SelectGroup" SelectGroup + "SelectValue" SelectValue + "SelectTrigger" SelectTrigger + "SelectContent" SelectContent + "SelectLabel" SelectLabel + "SelectItem" SelectItem + "SelectIcon" SelectIcon + "SelectSeparator" SelectSeparator + "SelectScrollUpButton" SelectScrollUpButton + "SelectScrollDownButton" SelectScrollDownButton + "Tooltip" Tooltip + "TooltipTrigger" TooltipTrigger + "TooltipPortal" TooltipPortal + "TooltipContent" TooltipContent + "TooltipProvider" TooltipProvider + "TooltipArrow" TooltipArrow + "Tabs" Tabs + "TabsList" TabsList + "TabsTrigger" TabsTrigger + "TabsIndicator" TabsIndicator + "TabsContent" TabsContent + "Toggle" Toggle + "ToggleGroup" ToggleGroup + "ToggleGroupItem" ToggleGroupItem + "Form" Form + "FormField" FormField + "FormItem" FormItem + "FormLabel" FormLabel + "FormControl" FormControl + "FormDescription" FormDescription + "FormMessage" FormMessage + "Toaster" Toaster + "useToast" useToast + "genToastId" genToastId + "useForm" useForm + "useFormContext" useFormContext + "useFormField" useFormField + "yupResolver" yupResolver + "yup" yup}) + +;; export registry to global scope `window.LSUI` for dynamic component rendering in Logseq +(set! (.-LSUI js/window) registry) diff --git a/deps/shui/src/logseq/shui/demo.cljs b/deps/shui/src/logseq/shui/demo.cljs new file mode 100644 index 0000000..38692a7 --- /dev/null +++ b/deps/shui/src/logseq/shui/demo.cljs @@ -0,0 +1,629 @@ +(ns logseq.shui.demo + (:require [dommy.core :refer-macros [sel1]] + [frontend.handler.notification :as notification] + [logseq.shui.dialog.core :as dialog-core] + [logseq.shui.form.core :refer [yup] :as form-core] + [logseq.shui.hooks :as hooks] + [logseq.shui.ui :as shui] + [promesa.core :as p] + [io.factorhouse.hsx.core :as hsx])) + +(hsx/defc section-item + [title children] + [:section.mb-4 + [:h2.text-xl.font-semibold.py-2.italic.opacity-50 title] + [:div.py-4 children]]) + +(hsx/defc sample-dropdown-menu-content + [] + (let [icon #(shui/tabler-icon (name %1) {:class "scale-90 pr-1 opacity-80"})] + (shui/dropdown-menu-content + {:class "w-56" + :on-click (fn [^js e] (some-> (.-target e) (.-innerText) + (#(identity ["You select: " [:b.text-red-700 %1]])) (shui/toast! :info)))} + (shui/dropdown-menu-label "My Account") + (shui/dropdown-menu-separator) + (shui/dropdown-menu-group + ;; items + (shui/dropdown-menu-item (icon :user) "Profile" (shui/dropdown-menu-shortcut "⌘P")) + (shui/dropdown-menu-item (icon :brand-mastercard) [:span "Billing"] (shui/dropdown-menu-shortcut "⌘B")) + (shui/dropdown-menu-item (icon :adjustments-alt) [:span "Settings"] (shui/dropdown-menu-shortcut "⌘,")) + (shui/dropdown-menu-item (icon :keyboard) [:span "Keyboard shortcuts"])) + (shui/dropdown-menu-separator) + ;; group + (shui/dropdown-menu-group + ;; items + (shui/dropdown-menu-item (icon :users) "Team") + ;; sub menu + (shui/dropdown-menu-sub + (shui/dropdown-menu-sub-trigger + (icon :user-plus) [:span "Invite users"]) + (shui/dropdown-menu-sub-content + (shui/dropdown-menu-item (icon :mail) "Email") + (shui/dropdown-menu-item (icon :message) "Message") + (shui/dropdown-menu-item (icon :dots-circle-horizontal) "More..."))) + ;; menu item + (shui/dropdown-menu-item (icon :plus) "New Team" (shui/dropdown-menu-shortcut "⌘+T"))) + (shui/dropdown-menu-separator) + (shui/dropdown-menu-item (icon :brand-github) "GitHub") + (shui/dropdown-menu-item {:disabled true} (icon :cloud) "Cloud API") + (shui/dropdown-menu-separator) + (shui/dropdown-menu-item (icon :logout) "Logout" (shui/dropdown-menu-shortcut "⌘+Q"))))) + +(hsx/defc sample-context-menu-content + [] + (let [icon #(shui/tabler-icon (name %1) {:class "scale-90 pr-1 opacity-80"})] + (shui/context-menu + ;; trigger + (shui/context-menu-trigger + [:div.border.px-6.py-12.border-dashed.rounded.text-center.select-none + {:key "ctx-menu-click"} + [:span.opacity-50 "Right click here"]]) + ;; content + (shui/context-menu-content + {:class "w-60 max-h-[80vh] overflow-auto"} + (shui/context-menu-item + (icon "arrow-left") + "Back" + (shui/context-menu-shortcut "⌘[")) + (shui/context-menu-item {:disabled true} + (icon "arrow-right") + "Forward" + (shui/context-menu-shortcut "⌘]")) + (shui/context-menu-item + (icon "refresh") + "Reload" + (shui/context-menu-shortcut "⌘R")) + ;; Sub menu + (shui/context-menu-sub + (shui/context-menu-sub-trigger {:inset true} "More tools") + (shui/context-menu-sub-content {:class "w-48"} + (shui/context-menu-item "Save page As..." + (shui/context-menu-shortcut "⇧⌘S")) + (shui/context-menu-item "Create Shortcut...") + (shui/context-menu-item "Name Window...") + (shui/context-menu-separator) + (shui/context-menu-item "Developer Tools"))) + ;; more + (shui/context-menu-separator) + (shui/context-menu-checkbox-item {:checked true} + "Show Bookmarks Bar" (shui/context-menu-shortcut "⌘⇧B")) + (shui/context-menu-checkbox-item "Show Full URLs") + (shui/context-menu-separator) + (shui/context-menu-radio-group {:value "pedro"} + (shui/context-menu-label {:inset true} "People") + (shui/context-menu-separator) + (shui/context-menu-radio-item {:value "pedro"} "Pedro Duarte") + (shui/context-menu-radio-item {:value "colm"} "Colm Tuite")))))) + +(hsx/defc sample-tabs + [] + (shui/tabs + {:defaultValue "account" + :className "w-[400px]"} + (shui/tabs-list + (shui/tabs-trigger + {:value "account"} + "Account") + (shui/tabs-trigger + {:value "password"} + "Password")) + (shui/tabs-content + {:value "account"} + "Make changes to your account here.") + (shui/tabs-content + {:value "password"} + "Change your password here."))) + +(hsx/defc sample-form-basic + [] + [:div.border.p-6.rounded.bg-gray-01 + (let [form-ctx (form-core/use-form + {:defaultValues {:username "" + :agreement true + :notification "all" + :bio ""} + :yupSchema (-> (.object yup) + (.shape #js {:username (-> (.string yup) (.required))}) + (.required))}) + handle-submit (:handleSubmit form-ctx) + on-submit-valid (handle-submit + (fn [^js e] + (js/console.log "[form] submit: " e) + (js/alert (js/JSON.stringify e nil 2))))] + + (shui/form-provider form-ctx + [:form + {:on-submit on-submit-valid} + + ;; field item + (shui/form-field {:name "username"} + (fn [field error] + (shui/form-item + (shui/form-label "Username") + (shui/form-control + (shui/input (merge {:placeholder "Username"} field))) + (shui/form-description + (if error + [:b.text-red-800 (:message error)] + "This is your public display name."))))) + + (shui/form-field {:name "bio"} + (fn [field _error] + (shui/form-item + {:class "pt-4"} + (shui/form-control + (shui/textarea (merge {:placeholder "Bio text..."} field)))))) + + ;; radio + (shui/form-field {:name "notification"} + ;; item render + (fn [field] + (shui/form-item + {:class "space-y-3 my-4"} + (shui/form-control + (shui/radio-group + {:value (:value field) + :on-value-change (:onChange field) + :class "flex flex-col space-y-3"} + (shui/form-item + {:class "flex flex-row space-x-2 items-center space-y-0"} + (shui/form-control + (shui/radio-group-item {:value "all"})) + (shui/form-label "All")) + + (shui/form-item + {:class "flex flex-row space-x-2 items-center space-y-0"} + (shui/form-control + (shui/radio-group-item {:value "direct"})) + (shui/form-label "Direct messages and mentions"))))))) + + [:hr] + + ;; checkbox + (shui/form-field {:name "agreement"} + (fn [field] + (shui/form-item + {:class "flex justify-start items-center space-x-2 space-y-0 my-3 pr-3"} + (shui/form-control + (shui/checkbox {:checked (:value field) + :on-checked-change (:onChange field)})) + (shui/form-label {:class "font-normal cursor-pointer"} "Agreement terms")))) + + ;; actions + [:div.relative.px-2 + (shui/button {:type "submit" :class "!absolute right-0 top-[-40px]"} "Submit")]]))]) + +(hsx/defc sample-date-picker + [] + (let [[open? set-open!] (hooks/use-state false) + [date set-date!] (hooks/use-state (js/Date.))] + (shui/popover + {:open open? + :on-open-change (fn [o] (set-open! o))} + ;; trigger + (shui/popover-trigger + {:as-child true + :class "w-2/3"} + (shui/input + {:type :text + :placeholder "pick a date" + :default-value (.toDateString date)})) + ;; content + (shui/popover-content + {:on-open-auto-focus #(.preventDefault %) + :side-offset 8 + :class "p-0"} + (shui/calendar + {:selected date + :on-day-click + (fn [^js d] + (set-date! d) + (set-open! false))}))))) + +(hsx/defc sample-dialog-basic + [] + (let [[open? set-open!] (hooks/use-state false)] + (shui/dialog + {:open open? + :on-open-change #(set-open! %)} + (shui/dialog-trigger + {:as-child true} + (shui/button {:variant :outline} + (shui/tabler-icon "notification") "Open as modal locally")) + (shui/dialog-content + (shui/dialog-header + (shui/dialog-title "Header") + (shui/dialog-description + "Description")) + [:div.max-h-96.overflow-y-auto + {:class "-mx-6"} + [:section.px-6 + (repeat 8 [:p "Your custom content"])]] + (shui/dialog-footer + (shui/button + {:on-click #(set-open! false) + :size :md} "🍄 * Footer")))))) + +(hsx/defc sample-notifications + [] + (let [[last-dismissed set-last-dismissed!] (hooks/use-state nil) + progress-id :demo/notification-progress + callback-id :demo/notification-callback] + [:div.flex.flex-col.gap-3 + [:div.flex.flex-row.flex-wrap.gap-2 + (shui/button + {:variant :outline + :size :sm + :on-click #(notification/show! "Saved successfully" :success)} + "Success") + (shui/button + {:variant :outline + :size :sm + :on-click #(notification/show! "Background sync is running" :info)} + "Info") + (shui/button + {:variant :outline + :size :sm + :on-click #(notification/show! "Storage space is almost full" :warning false)} + "Warning") + (shui/button + {:variant :destructive + :size :sm + :on-click #(notification/show! "Upload failed. Fix it before retrying." :error false)} + "Persistent error")] + + [:div.flex.flex-row.flex-wrap.gap-2 + (shui/button + {:class "primary-green" + :size :sm + :on-click (fn [] + (notification/show! "Syncing notifications demo..." :info true progress-id 10000) + (js/setTimeout + #(notification/show! "Notifications demo synced" :success true progress-id 1500) + 1200))} + "Update same id") + (shui/button + {:variant :secondary + :size :sm + :on-click #(notification/clear! progress-id)} + "Clear updated one") + (shui/button + {:variant :secondary + :size :sm + :on-click #(notification/show! "Dismiss me to trigger callback" :warning true callback-id 4000 + (fn [uid] (set-last-dismissed! (name uid))))} + "Dismiss callback") + (shui/button + {:variant :ghost + :size :sm + :on-click notification/clear-all!} + "Clear all")] + + [:div.text-sm.opacity-70 + "Last dismissed notification: " + [:code (or last-dismissed "none")]]])) + +(hsx/defc page [] + (shui/tooltip-provider + [:div.sm:p-10 + [:hr] + [:input + {:type "checkbox" :on-change #(js/console.log "===>> onChange:" % (.-value (.-target %)))}] + (shui/checkbox {:on-click + (fn [^js e] (js/console.log "==>> click:" + (set! (. (.-target e) -checked) (.-state (.-dataset (.-target e)))) + (.-checked (.-target e)))) + :on-checked-change #(js/console.log "==>> on checked change:" %)} "abc") + + [:h1.text-3xl.font-bold "Logseq UI"] + [:hr] + + ;; Button + (section-item "Button" + [:div.flex.flex-row.flex-wrap.gap-2 + (let [[loading? set-loading!] (hooks/use-state false)] + (shui/button + {:size :sm + :on-click (fn [] + (set-loading! true) + (js/setTimeout #(set-loading! false) 5000)) + :disabled loading?} + (when loading? + (shui/tabler-icon "loader2" {:class "animate-spin"})) + "Logseq Classic Button" + (shui/tabler-icon "arrow-right"))) + + (shui/button {:variant :outline :size :sm} "Outline") + (shui/button {:variant :secondary :size :sm} "Secondary") + (shui/button {:disabled true :size :sm} "Disabled") + (shui/button {:variant :destructive :size :sm} "Destructive") + (shui/button {:class "primary-green" :size :sm} "Custom (.primary-green)") + (shui/button {:variant :ghost :size :sm} "Ghost") + (shui/button {:variant :link :size :sm} "Link") + (shui/button + {:variant :icon + :size :sm} + [:a.flex.items-center.text-blue-rx-10.hover:text-blue-rx-10-alpha + {:href "https://x.com/logseq" :target "_blank"} + (shui/tabler-icon "brand-twitter" {:size 15})])]) + + ;; Toast + (section-item "Toast" + [:div.flex.flex-row.flex-wrap.gap-2 + (shui/button + {:size :md + :variant :outline + :on-click #(shui/toast! + "Check for updates ..." + (nth [:success :error :default :info :warning] (rand-int 3)) + {:title (if (odd? (js/Date.now)) "History of China" "") + :duration 3000})} + "Open random toast" + (shui/tabler-icon "arrow-right")) + + (shui/button + {:variant :secondary + :size :md + :on-click (fn [] + (shui/toast! + (fn [{:keys [id dismiss! update!]}] + [:b.text-red-700 + [:div.flex.items-center.gap-2 + (shui/tabler-icon "info-circle") + (str "#(" id ") ") + (.toLocaleString (js/Date.))] + [:div.flex.flex-row.gap-2 + (shui/button + {:on-click #(dismiss! id) :size :sm} + "x close") + + (shui/button + {:on-click #(update! {:title (js/Date.now) + :action [:b (shui/button {:on-click (fn [] (shui/toast-dismiss!))} "clear all")]}) + :size :sm} + "x update")]]) + :default + {:duration 3000 :onDismiss #(js/console.log "===>> dismiss?:" %1)}))} + (shui/tabler-icon "apps") + "Toast callback handle") + + (shui/button + {:on-click #(shui/toast! "A message from SoundCloud..." + {:class "text-orange-rx-10" + :icon [:b.pl-1 (shui/tabler-icon "brand-soundcloud" {:size 20})] + :duration 3000}) + :class "primary-orange" + :size :md} + "Custom icon")]) + + (section-item "Notification" + (sample-notifications)) + + [:div.flex.flex-row.space-x-16.items-center + ;; Tips + (section-item "Tips" + [:div.flex.flex-row.flex-wrap.gap-2 + (shui/tooltip-provider + (shui/tooltip + (shui/tooltip-trigger + (shui/button + {:variant :outline + :on-click #(dialog-core/open! [:h1.text-9xl.text-center.scale-110 "🍄"])} + "Tip for hint?")) + (shui/tooltip-content + {:class "w-42 px-8 py-4 text-xl border-green-rx-08 bg-green-rx-07-alpha"} + "🍄")))]) + ;; Avatar + (section-item "Avatar" + [:div.flex.flex-row.space-x-6.items-center + (shui/avatar + (shui/avatar-image {:src "https://avatars.githubusercontent.com/u/63385289?s=200&v=4"}) + (shui/avatar-fallback "L")) + (shui/avatar + (shui/avatar-fallback "CH"))])] + + ;; Badge + (section-item "Badge" + [:div.flex.flex-row.flex-wrap.gap-2 + (shui/badge "Default") + (shui/badge {:variant :outline} "Outline") + (shui/badge {:variant :secondary} "Secondary") + (shui/badge {:variant :destructive} "Destructive") + (shui/badge {:class "primary-yellow"} "Custom (.primary-yellow)")]) + + [:div.grid.sm:grid-cols-3.sm:gap-8 + ;; Dropdown + (section-item "Dropdown" + (shui/dropdown-menu + (shui/tooltip + (shui/tooltip-trigger + (shui/dropdown-menu-trigger + {:as-child true} + (shui/button {:variant :outline} + (shui/tabler-icon "list") "Open dropdown menu"))) + (shui/tooltip-content "test hide?")) + + (sample-dropdown-menu-content))) + + ;; Context menu + [:div.col-span-2 + (section-item "Context Menu" + (sample-context-menu-content))]] + + (section-item "Tabs" (sample-tabs)) + + ;; Dialog + (section-item "Dialog" + [:div.flex.flex-row.flex-wrap.gap-2 + (sample-dialog-basic) + (let [fc #(dialog-core/open! + (fn [] + [:div.py-2 + [:p [:strong "a modal dialog from `open!`"]] + [:p "You can put any content here, and even use shui components!"] + [:p (shui/button {:on-click (fn [] (dialog-core/alert! [:p "nest"] {}))} "open nested dialog")]]) + {:title "Title"})] + (shui/button + {:on-click fc} + "Imperative API: open!")) + + (shui/button + {:class "primary-yellow" + :on-click (fn [] + (-> (dialog-core/alert! + "a alert dialog from `alert!`" + {:title [:div.flex.flex-row.space-x-2.items-center + (shui/tabler-icon "alert-triangle" {:size 18}) + [:span "Alert"]]}) + (p/then #(js/console.log "=> alert (promise): " %))))} + "Imperative API: alert!") + + (shui/button + {:class "primary-green" + :on-click (fn [] + (-> (dialog-core/confirm! + "a alert dialog from `confirm!`" + {:title [:div.flex.flex-row.space-x-2.items-center + (shui/tabler-icon "alert-triangle" {:size 18}) + [:span "Confirm"]]}) + (p/then #(js/console.log "=> confirm (promise): " %)) + (p/catch #(js/console.log "=> confirm (promise): " %))))} + "Imperative API: confirm!")]) + + ;; Alert + (section-item "Alert" + [:<> + (shui/alert + {:class "text-orange-rx-09 border-orange-rx-07-alpha mb-4"} + (shui/tabler-icon "brand-soundcloud") + (shui/alert-title "Title is SoundCloud") + (shui/alert-description + "content: radix colors for Logseq")) + (shui/alert + (shui/tabler-icon "brand-github") + (shui/alert-title "GitHub") + (shui/alert-description + "content: radix colors for Logseq"))]) + + ;; Slider + [:div.grid.sm:grid-cols-8.gap-4 + [:div.col-span-4.mr-6 + (section-item "Slider" (shui/slider))] + [:div.col-span-1 + (section-item "Switch" + (shui/switch {:size :sm :class "relative top-[-8px]"}))] + [:div.col-span-3.pl-4.pr-2 + (section-item "Select" + (shui/select + {:on-value-change (fn [v] (shui/toast! v :info))} + ;; trigger + (shui/select-trigger + (shui/select-value {:placeholder "Select a fruit"})) + ;; content + (shui/select-content + (shui/select-group + (shui/select-label "Fruits") + (shui/select-item {:value "apple"} "Apple") + (shui/select-item {:value "pear"} "Pear") + (shui/select-item {:value "grapes"} "Grapes")))))]] + + ;; Form + (section-item "Form" + [:<> + (sample-form-basic)]) + + ;; Card + [:div.grid.sm:grid-cols-2.sm:gap-8 + (section-item "Card" + (shui/card + (shui/card-header + (shui/card-title "Title") + (shui/card-description "Description")) + (shui/card-content "This is content") + (shui/card-footer "Footer"))) + + (section-item "Skeleton" + (shui/card + (shui/card-header + (shui/card-title + (shui/skeleton {:class "h-4 w-1/2"})) + (shui/card-description + (shui/skeleton {:class "h-2 w-full"}))) + (shui/card-content + (shui/skeleton {:class "h-3 mb-1"}) + (shui/skeleton {:class "h-3 mb-1"}) + (shui/skeleton {:class "h-3 w-2/3"})) + + (shui/card-footer + (shui/skeleton {:class "h-4 w-full mb-2"}))))] + + ;; Calendar + [:div.grid.sm:grid-cols-2.sm:gap-8 + (section-item "Calendar" + (shui/card + {:class "inline-flex"} + (shui/calendar {:on-day-click #(shui/toast! (.toString %) :success)}))) + (section-item "Date Picker" + (sample-date-picker))] + + [:hr.mb-80]])) + +(defn- get-head-container + [] + (sel1 "#head")) + +(defn- get-main-scroll-container + [] + (sel1 "#main-content-container")) + +(hsx/defc sticky-table + [] + + (let [el-ref (hooks/use-ref nil)] + (hooks/use-effect! + (fn [] + (let [^js container (get-main-scroll-container) + ^js el (hooks/deref el-ref) + ^js cls (.-classList el) + *ticking? (volatile! false) + el-top (-> el (.getBoundingClientRect) (.-top)) + head-top (-> (get-head-container) (js/getComputedStyle) (.-height) (js/parseInt)) + translate (fn [offset] + (set! (. (.-style el) -transform) (str "translate3d(0, " offset "px , 0)")) + (if (zero? offset) + (.remove cls "translated") + (.add cls "translated"))) + *last-offset (volatile! 0) + handle (fn [] + (let [scroll-top (js/parseInt (.-scrollTop container)) + offset (if (> (+ scroll-top head-top) el-top) + (+ (- scroll-top el-top) head-top 1) 0) + offset (js/parseInt offset) + last-offset @*last-offset] + (if (and (not (zero? last-offset)) + (not= offset last-offset)) + (let [dir (if (neg? (- offset last-offset)) -1 1)] + (loop [offset' (+ last-offset dir)] + (translate offset') + (if (and (not= offset offset') + (< (abs (- offset offset')) 100)) + (recur (+ offset' dir)) + (translate offset)))) + (translate offset)) + (vreset! *last-offset offset))) + handler (fn [^js _e] + (when (not @*ticking?) + (js/window.requestAnimationFrame + #(do (handle) (vreset! *ticking? false))) + (vreset! *ticking? true)))] + (.addEventListener container "scroll" handler) + #(.removeEventListener container "scroll" handler))) + []) + + [:div.charlie-table + [:div.charlie-table-header + {:ref el-ref} + [:strong "header"]] + [:div.charlie-table-content + [:strong "content"]]])) diff --git a/deps/shui/src/logseq/shui/demo2.cljs b/deps/shui/src/logseq/shui/demo2.cljs new file mode 100644 index 0000000..7898930 --- /dev/null +++ b/deps/shui/src/logseq/shui/demo2.cljs @@ -0,0 +1,392 @@ +(ns logseq.shui.demo2 + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [frontend.components.icon :refer [emojis-cp emojis icon-search]] + [frontend.storage :as storage] + [logseq.shui.hooks :as hooks] + [logseq.shui.popup.core :refer [update-popup! get-popup]] + [logseq.shui.select.multi :refer [x-select-content]] + [logseq.shui.ui :as shui] + [promesa.core :as p] + [io.factorhouse.hsx.core :as hsx])) + +(defn do-fetch! + ([action] (do-fetch! action nil)) + ([action query-str] + (-> (js/window.fetch + (str "https://movies-api14.p.rapidapi.com/" (name action) (when query-str (str "?" query-str))) + #js {:method "GET" + :headers #js {:X-RapidAPI-Key "808ffd08c0mshc67d496f6024b46p164350jsn7b35179966c9", + :X-RapidAPI-Host "movies-api14.p.rapidapi.com"}}) + (p/then #(.json %))))) + +(hsx/defc multi-select-demo + [] + + [:div.sm:p-10 + [:h1.text-3xl.font-bold.border-b.pb-4.mb-8 + "Multi X Select"] + + (let [[items set-items!] (hooks/use-state []) + [q set-q!] (hooks/use-state "") + [fetching? set-fetching?] (hooks/use-state nil) + + [selected-items set-selected-items!] + (hooks/use-state (storage/get :ls-demo-multi-selected-items)) + + rm-item! (fn [item-or-id] + (set-selected-items! + (remove #(or (= item-or-id %) + (= item-or-id (str (:id %)))) + selected-items))) + add-item! (fn [item] (set-selected-items! (conj selected-items item))) + + [open? set-open!] (hooks/use-state false)] + + (hooks/use-effect! + (fn [] + (storage/set :ls-demo-multi-selected-items selected-items)) + [selected-items]) + + (shui/card + (shui/card-header + (shui/card-title "Search Movies") + (shui/card-description "x multiselect for the remote items")) + (shui/card-content + + ;; Basic + (shui/dropdown-menu + {:open open?} + ;; trigger + (shui/dropdown-menu-trigger + [:div.border.p-2.rounded.w-full.cursor-pointer.flex.items-center.gap-1.flex-wrap + {:on-click (fn [^js e] + (let [^js target (.-target e)] + (if-let [^js c (some-> target (.closest ".close"))] + (some-> (.-dataset c) (.-k) (rm-item!)) + (set-open! true))))} + (for [{:keys [id original_title class poster_path]} selected-items] + (shui/badge {:variant :secondary :class (str class " group relative")} + [:span.flex.items-center.gap-1.flex-nowrap + [:img {:src poster_path :class "w-[16px] scale-75"}] + [:b original_title]] + (shui/button + {:variant :destructive + :size :icon + :data-k id + :class "!rounded-full !h-4 !w-4 absolute top-[-7px] right-[-3px] group-hover:visible invisible close"} + (shui/tabler-icon "x" {:size 12})))) + (shui/button {:variant :link :size :sm} "+")]) + ;; content + (x-select-content items selected-items + {;; test item render + :open? open? + :close! #(set-open! false) + :search-enabled? true + :search-key q + :search-fn (fn [items] + (when (not fetching?) items)) + :on-search-key-change (fn [v] + (set-q! v) + (if (string/blank? v) + (set-items! []) + (when (not fetching?) + (set-fetching? true) + (-> (do-fetch! :search (str "query=" v)) + (p/then #(when-let [ret (bean/->clj %)] + (when-let [items (:contents ret)] + (set-items! (map (fn [item] (assoc item :id (:_id item))) (take 12 items)))))) + (p/finally #(set-fetching? false)))))) + + :item-render (fn [item {:keys [selected?]}] + (if item + (shui/dropdown-menu-checkbox-item + {:checked selected? + :on-click (fn [] + (if selected? + (rm-item! item) + (add-item! item)) + ;(set-open! false) + )} + [:div.flex.items-center.gap-2 + [:span [:img {:src (:poster_path item) + :class "w-[20px]"}]] + [:span.flex.flex-col + [:b (:original_title item)] + [:small.opacity-50 + {:class "text-[10px]"} + (:release_date item)]]]) + (shui/dropdown-menu-separator))) + + :head-render (fn [] (when (and fetching? (not (string/blank? q))) + [:b.flex.items-center.justify-center.py-4 + (shui/tabler-icon "loader" {:class "animate-spin"})])) + ;:foot-render (fn [] [:b "footer"]) + + :content-props + {:align "start" + :class "w-80"}}))))) + + [:hr] + + (let [items [{:key 1 :value "Apple" :class "bg-gray-800 text-gray-50"} + {:key 2 :value "Orange" :class "bg-orange-700 text-gray-50"} + {:key 3 :value "Pear"} + {:key 4 :value "Banana" :class "bg-yellow-700 text-gray-700"}] + + [selected-items set-selected-items!] + (hooks/use-state [(second items)]) + + [search? set-search?] (hooks/use-state false) + + rm-item! (fn [item] (set-selected-items! (remove #(= item %) selected-items))) + add-item! (fn [item] (set-selected-items! (conj selected-items item))) + on-chosen (fn [item {:keys [selected?]}] + (if (true? selected?) + (rm-item! item) (add-item! item))) + [open? set-open!] (hooks/use-state false)] + + (shui/card + (shui/card-header + (shui/card-title "Basic") + (shui/card-description "x multiselect for shui")) + (shui/card-content + [:label.block.flex.items-center.pb-3.cursor-pointer + (shui/checkbox {:checked search? + :on-click #(set-search? (not search?))}) + [:small.pl-2 "Enable basic search input"]] + ;; Basic + (shui/dropdown-menu + {:open open?} + ;; trigger + (shui/dropdown-menu-trigger + [:p.border.p-2.rounded.w-full.cursor-pointer + {:on-click #(set-open! true)} + (for [{:keys [key value class]} selected-items] + (shui/badge {:variant :secondary :class class} (str "#" key " " value))) + (shui/button {:variant :link :size :sm} "+")]) + ;; content + (x-select-content items selected-items + {:close! #(set-open! false) + :search-enabled? search? + :search-key-render (fn [q {:keys [items]}] + (when (and (not (string/blank? q)) + (not (seq items))) + [:b.flex.items-center.justify-center.py-4.gap-2.font-normal.opacity-80 + (shui/tabler-icon "lemon") [:small "No fruits!"]])) + :on-chosen on-chosen + :value-render (fn [v {:keys [selected?]}] + (if selected? + [:b.text-red-800 v] + [:b.text-green-800 v])) + :content-props + {:class "w-48"}}))))) + + [:hr] + + (let [[items _set-items!] + (hooks/use-state + [{:key 1 :value "Apple" :class "bg-gray-800 text-gray-50"} + {:key 2 :value "Orange" :class "bg-orange-700 text-gray-50"} + nil + {:key 3 :value "Pear"} + {:key 4 :value "Banana" :class "bg-yellow-700 text-gray-700"}]) + + [selected-items set-selected-items!] + (hooks/use-state [(last items) (first items)]) + + rm-item! (fn [item] (set-selected-items! (remove #(= item %) selected-items))) + add-item! (fn [item] (set-selected-items! (conj selected-items item))) + _on-chosen (fn [item {:keys [selected?]}] + (if (true? selected?) + (rm-item! item) (add-item! item))) + [open? set-open!] (hooks/use-state false)] + + (shui/card + (shui/card-header + (shui/card-title "Search & Custom") + (shui/card-description "x multiselect for shui")) + (shui/card-content + + ;; Basic + (shui/dropdown-menu + {:open open?} + ;; trigger + (shui/dropdown-menu-trigger + [:p.border.p-2.rounded.w-full.cursor-pointer + {:on-click #(set-open! true)} + (for [{:keys [key value class]} selected-items] + (shui/badge {:variant :secondary :class class} (str "#" key " " value))) + (shui/button {:variant :link :size :sm} "+")]) + ;; content + (x-select-content items selected-items + {;; test item render + :open? open? + :close! #(set-open! false) + :search-enabled? true + :item-render (fn [item {:keys [selected?]}] + (if item + (shui/dropdown-menu-checkbox-item + {:checked selected? + :on-click (fn [] + (if selected? + (rm-item! item) + (add-item! item)))} + (:value item)) + (shui/dropdown-menu-separator))) + + :search-key-render + (fn [k {:keys [_items x-item exist-fn]}] + (when (and + (not (string/blank? k)) + (not (exist-fn))) + (x-item + {:on-click (fn [] + (shui/toast! (str "Create: " k) :warning) + (set-open! false))} + (str "+ create: " k)))) + + ;:head-render (fn [] [:b "header"]) + ;:foot-render (fn [] [:b "footer"]) + :content-props + {:align "start" + :class "w-48"}})))))]) + +(hsx/defc icon-picker-demo + [] + [:div.sm:p-10 + [:h1.text-3xl.font-bold.border-b.pb-4.mb-8 + "UI X Emojis & Icons Picker"] + + [:div.border.rounded.bg-gray-01.overflow-hidden + {:class "w-fit"} + (icon-search {})]]) + +(hsx/defc popup-demo + [] + [:div.sm:p-10 + [:h1.text-3xl.font-bold.border-b.pb-4 "UI X Popup"] + + (let [[emoji set-emoji!] (hooks/use-state nil) + [q set-q!] (hooks/use-state "") + *q-ref (hooks/use-ref nil) + + emoji-picker + (fn [_nested?] + [:p.py-4 + "Choose a inline " + [:a.underline + {:on-click + #(shui/popup-show! % + (fn [_config] + [:div.max-h-72.overflow-auto.p-1 + (emojis-cp (take 80 emojis) + {:on-chosen + (fn [_ t] + (set-emoji! t) + (shui/popup-hide-all!))})]) + {:content-props {:class "w-72 p-0"} + :as-dropdown? true})} + (if emoji [:strong.px-1.text-6xl [:em-emoji emoji]] "emoji :O")] "."])] + [:<> + (emoji-picker nil) + + [:p.py-4 + (shui/button + {:variant :secondary + :on-click #(shui/popup-show! % + (fn [] + [:p.p-4 + (emoji-picker true)]))} + "Play a nested x popup.")] + + [:p.py-4 + (let [gen-content + (fn [q] + [:p.x-input-popup-content.bg-green-rx-06 + (shui/button {:on-click #(shui/toast! "Just a joke :)")} "play a magic") + (emoji-picker true) + [:strong.px-1.text-6xl q]])] + (shui/input + {:placeholder "Select a fruit." + :ref *q-ref + :value q + :on-change (fn [^js e] + (let [val (.-value (.-target e))] + (set-q! val) + (update-popup! :select-a-fruit-input [:content] (gen-content val)))) + :class "w-1/5" + :on-focus (fn [^js e] + (let [id :select-a-fruit-input + [_ popup] (get-popup id)] + (if (not popup) + (shui/popup-show! (.-target e) + (gen-content q) + {:id id + :align "start" + :content-props + {:class "x-input-popup-content" + :onPointerDownOutside + (fn [^js e] + (js/console.log "===>> onPointerDownOutside:" e (hooks/deref *q-ref)) + (when-let [q-ref (hooks/deref *q-ref)] + (let [^js target (or (.-relatedTarget e) + (.-target e))] + (js/console.log "t:" target) + (when (and + (not (.contains q-ref target)) + (not (.closest target ".x-input-popup-content"))) + (shui/popup-hide! id))))) + :onOpenAutoFocus #(.preventDefault %)}}) + + ;; update content + (update-popup! id [:content] + (gen-content q))))) + ;:on-blur (fn [^js e] + ; (let [^js target (.-relatedTarget e)] + ; (js/console.log "==>>>" target) + ; (when-not (.closest target ".x-input-popup-content") + ; (hide-x-popup! :select-a-fruit-input)))) + }))] + + [:div.w-full.p-4.border.rounded.dotted.h-48.mt-8.bg-gray-02 + {:on-click #(shui/popup-show! % + (->> (range 8) + (map (fn [it] + (shui/dropdown-menu-item + {:on-select (fn [] + (shui/toast! it) + (shui/popup-hide-all!))} + [:strong it])))) + {:as-dropdown? true + :content-props {:class "w-48"}}) + :on-context-menu #(shui/popup-show! % + [:h1.text-3xl.font-bold "hi x popup for custom context menu!"])}]])]) + +(hsx/defc custom-trigger-content + [] + [:p + [:code "more content"] [:br] + (shui/input {:auto-focus true}) [:br] + (shui/button "select sth")]) + +(hsx/defc sample-dropdown-trigger + [] + + [:div.py-4 + [:h1.text-3xl.font-bold.border-b.pb-4 "Sample dropdown/menu trigger"] + [:div.py-4 + (shui/dropdown-menu + (shui/dropdown-menu-trigger + {:as-child true} + (shui/trigger-child-wrap + {:class "border p-6 border"} + (custom-trigger-content))) + (shui/dropdown-menu-content + (shui/dropdown-menu-item "A item") + (shui/dropdown-menu-item "B item") + (shui/dropdown-menu-item "C item")))]]) + +(hsx/defc page + [] + (sample-dropdown-trigger)) diff --git a/deps/shui/src/logseq/shui/dialog/core.cljs b/deps/shui/src/logseq/shui/dialog/core.cljs new file mode 100644 index 0000000..4dedfc9 --- /dev/null +++ b/deps/shui/src/logseq/shui/dialog/core.cljs @@ -0,0 +1,372 @@ +(ns logseq.shui.dialog.core + (:require [io.factorhouse.hsx.core :as hsx] + [logseq.shui.base.core :as base] + [logseq.shui.form.core :as form] + [logseq.shui.hooks :as hooks] + [logseq.shui.util :as util] + [medley.core :as medley] + [promesa.core :as p])) + +;; provider +(def dialog (util/ui-wrap "Dialog")) +(def dialog-portal (util/ui-wrap "DialogPortal")) +(def alert-dialog (util/ui-wrap "AlertDialog")) +(def alert-dialog-portal (util/ui-wrap "AlertDialogPortal")) + +;; ui +(def dialog-overlay (util/ui-wrap "DialogOverlay")) +(def dialog-close (util/ui-wrap "DialogClose")) +(def dialog-trigger (util/ui-wrap "DialogTrigger")) +(def dialog-content (util/ui-wrap "DialogContent")) +(def dialog-header (util/ui-wrap "DialogHeader")) +(def dialog-footer (util/ui-wrap "DialogFooter")) +(def dialog-title (util/ui-wrap "DialogTitle")) +(def dialog-description (util/ui-wrap "DialogDescription")) +(def alert-dialog-overlay (util/ui-wrap "AlertDialogOverlay")) +(def alert-dialog-trigger (util/ui-wrap "AlertDialogTrigger")) +(def alert-dialog-content (util/ui-wrap "AlertDialogContent")) +(def alert-dialog-header (util/ui-wrap "AlertDialogHeader")) +(def alert-dialog-title (util/ui-wrap "AlertDialogTitle")) +(def alert-dialog-description (util/ui-wrap "AlertDialogDescription")) +(def alert-dialog-footer (util/ui-wrap "AlertDialogFooter")) +(def alert-dialog-action (util/ui-wrap "AlertDialogAction")) +(def alert-dialog-cancel (util/ui-wrap "AlertDialogCancel")) + +(defn interpret-vals + [config ks & args] + (reduce (fn [config k] + (let [v (get config k) + v (if (fn? v) (apply v args) v)] + (if (vector? v) (assoc config k (hsx/create-element v)) config))) + config ks)) + +;; {:id :title :description :content :footer :open? :on-close ...} +(def ^:private *dialogs (atom [])) +(def ^:private *id (atom 0)) +(def ^:private gen-id #(reset! *id (inc @*id))) + +(defn get-dialog + [id] + (when id + (some->> (medley/indexed @*dialogs) + (filter #(= id (:id (second %)))) (first)))) + +(defn- open-dialogs + [] + (filter :open? @*dialogs)) + +(defn update-dialog! + [id ks val & {:keys [closing?]}] + (when-let [[index config] (get-dialog id)] + (let [ks (if (coll? ks) ks [ks]) + config (if (nil? val) + (medley/dissoc-in config ks) + (assoc-in config ks val))] + (swap! *dialogs assoc index config) + (when (and (false? (:open? config)) + (fn? (:on-close config)) + (not closing?)) + ((:on-close config) id))))) + +(defn upsert-dialog! + [config] + (when-let [_id (:id config)] + (swap! *dialogs conj config))) + +(defn detach-dialog! + [id] + (when-let [[index] (get-dialog id)] + (swap! *dialogs #(->> % (medley/remove-nth index) (vec))))) + +(defn has-dialog? + [] + (boolean (some :open? @*dialogs))) + +(defn- close-prevented? + [handler-result ^js event-details ^js native-event] + (when (or (false? handler-result) + (some-> native-event (.-defaultPrevented)) + (some-> event-details (.-isCanceled))) + (some-> event-details (.cancel)) + true)) + +(defn- call-close-handler! + [handler ^js event-details] + (let [native-event (some-> event-details (.-event)) + result (when (fn? handler) (handler native-event))] + (close-prevented? result event-details native-event))) + +;; apis +(declare close!) + +(defn open! + [content-or-config & config'] + (let [config (if (map? content-or-config) + content-or-config + {:content content-or-config}) + content (:content config) + generated-id (gen-id) + config (merge {:id generated-id :open? true} config (first config')) + id (:id config) + config (cond-> config + (nil? (:close config)) + (assoc :close #(close! id))) + config (cond-> config + (fn? content) + (assoc :content (content config)))] + (upsert-dialog! (update config :content-props + (fn [content-props] + (merge {:onCloseAutoFocus #(.preventDefault %)} + content-props)))))) + +(defn alert! + [content-or-config & config'] + (let [deferred (p/deferred)] + (open! content-or-config + (merge {:alert? :default :deferred deferred} (first config'))) + (p/promise deferred))) + +(defn confirm! + [content-or-config & config'] + (alert! content-or-config (assoc (first config') :alert? :confirm))) + +(defn get-last-dialog-id + [] + (some-> (last (open-dialogs)) (:id))) + +(defn get-first-dialog-id + [] + (some-> (first (open-dialogs)) (:id))) + +(defn- top-dialog? + [id] + (= id (get-last-dialog-id))) + +(defn- prevent-dismiss! + [^js event-details] + (some-> event-details (.-event) (.preventDefault)) + (some-> event-details (.cancel)) + true) + +(defn close! + ([] (close! (get-last-dialog-id))) + ([id] (update-dialog! id :open? false {:closing? true}))) + +(defn close-all! [] + (doseq [{:keys [id]} @*dialogs] + (close! id))) + +;; components +(hsx/defc dialog-inner + [config] + (let [{:keys [id title description content footer on-open-change align open? + auto-width? close-btn? root-props content-props + onEscapeKeyDown onPointerDownOutside]} config + props (dissoc config + :id :title :description :content :footer :auto-width? :close-btn? + :close :align :on-open-change :open? :root-props :content-props + :onEscapeKeyDown :onPointerDownOutside) + props (assoc-in props [:overlay-props :data-align] (name (or align :center)))] + + (hooks/use-effect! + (fn [] + (when (false? open?) + (detach-dialog! id))) + [open?]) + + (dialog + (merge root-props + {:key (str "dialog-" id) + :open open? + :on-open-change (fn [v e] + (let [set-open! #(update-dialog! id :open? %) + reason (some-> e (.-reason)) + escape-handler (or (:onEscapeKeyDown content-props) + onEscapeKeyDown) + pointer-handler (or (:onPointerDownOutside content-props) + onPointerDownOutside) + prevented? (when (false? v) + (cond + (and (contains? #{"escape-key" "outside-press"} reason) + (not (top-dialog? id))) + (prevent-dismiss! e) + + (= reason "escape-key") + (call-close-handler! escape-handler e) + + (= reason "outside-press") + (call-close-handler! pointer-handler e) + + :else false))] + (when-not prevented? + (if (fn? on-open-change) + (on-open-change {:value v :set-open! set-open!}) + (set-open! v)))))}) + (let [onPointerDownOutside (:onPointerDownOutside content-props) + onEscapeKeyDown (:onEscapeKeyDown content-props) + handle-key-escape! (fn [^js e] + (if (top-dialog? id) + (if (fn? onEscapeKeyDown) + (onEscapeKeyDown e) + (do + (.preventDefault e) + (close! id))) + (.preventDefault e))) + handle-pointer-down-outside! (fn [^js e] + (if (top-dialog? id) + (when (fn? onPointerDownOutside) + (onPointerDownOutside e)) + (.preventDefault e))) + content-props (-> content-props + (dissoc :onEscapeKeyDown :onPointerDownOutside) + (assoc :onEscapeKeyDown handle-key-escape! + :onPointerDownOutside handle-pointer-down-outside!))] + (dialog-content + (cond-> (merge props content-props) + auto-width? (assoc :data-auto-width true) + (false? close-btn?) (assoc :data-close-btn false)) + + ;; Title component is required for accessible dialog content. + (dialog-title {:class (when (nil? title) "hidden")} title) + (when description (dialog-description description)) + + (when content + [:div.ui__dialog-main-content content]) + + (when footer + (dialog-footer footer))))))) + +(hsx/defc alert-inner + [config] + (let [{:keys [id title description content footer deferred open? ok-label + root-props content-props]} config + props (dissoc config + :id :title :description :content :footer :deferred :open? :alert? :ok-label + :close :on-close :root-props :content-props + :outside-cancel? :cancel-label :data-reminder :data-reminder-label) + ok-label (or ok-label "OK")] + + (hooks/use-effect! + (fn [] + (when (false? open?) + (detach-dialog! id))) + [open?]) + + (alert-dialog + (merge root-props + {:key (str "alert-" id) + :open open? + :on-open-change (fn [v e] + (let [reason (some-> e (.-reason))] + (if (and (false? v) + (= reason "escape-key") + (not (top-dialog? id))) + (prevent-dismiss! e) + (update-dialog! id :open? v))))}) + (alert-dialog-content (merge props content-props) + (when (or title description) + (alert-dialog-header + {:class "ui__alert-dialog-header"} + (when title (alert-dialog-title title)) + (when description (alert-dialog-description description)))) + + (when content + [:div.ui__alert-dialog-main-content content]) + + (alert-dialog-footer + {:class "ui__alert-dialog-footer"} + (if footer + footer + [:<> + (base/button + {:key "ok" + :on-click #(do (close!) (p/resolve! deferred true)) + :size :sm} ok-label)]))) + ))) + +(hsx/defc confirm-inner + [config] + (let [{:keys [id deferred outside-cancel? data-reminder data-reminder-label + cancel-label ok-label]} config + reminder? (boolean (and id data-reminder)) + [ready?, set-ready!] (hooks/use-state (not reminder?)) + *ok-ref (hooks/use-ref nil) + *reminder-ref (hooks/use-ref nil) + cancel-label (or cancel-label "Cancel") + ok-label (or ok-label "OK") + footer [:<> + [:span.flex.items-center.pt-1 + (when (and id data-reminder data-reminder-label) + [:label.flex.items-center.gap-1.text-sm + (form/checkbox {:ref *reminder-ref}) + [:span.opacity-50 data-reminder-label]])] + [:span.flex.gap-2 + (base/button + {:key "cancel" + :on-click #(do (close!) (p/reject! deferred false)) + :variant :outline + :size :sm} cancel-label) + (base/button + {:key "ok" + :ref *ok-ref + :on-click (fn [] + (when-let [^js reminder (and id data-reminder (hooks/deref *reminder-ref))] + (when (= "checked" (.-state (.-dataset reminder))) + (js/localStorage.setItem (str id) (js/Date.now)))) + (close!) + (p/resolve! deferred true)) + :size :sm} ok-label)]]] + + (hooks/use-effect! + (fn [] + (when ready? + (let [timeout (js/setTimeout + #(some-> (hooks/deref *ok-ref) (.focus)) 128)] + #(js/clearTimeout timeout)))) + [ready?]) + + (hooks/use-effect! + (fn [] + (try + (if-let [reminder-v (and reminder? (js/localStorage.getItem (str id)))] + (if (< (- (js/Date.now) reminder-v) (* 1000 60 10)) + (do (detach-dialog! id) (p/resolve! deferred true)) + (set-ready! true)) + (set-ready! true)) + (catch js/Error _e + (set-ready! true)))) + []) + + (when ready? + (alert-inner + (assoc config + :data-mode :confirm + :overlay-props + {:on-click #(when outside-cancel? (close!) (p/reject! deferred nil))} + :footer footer))))) + +(defn- render-dialog + [config] + (let [id (:id config) + alert? (:alert? config) + config (interpret-vals config + [:title :description :content :footer] + {:id id})] + (case alert? + :default + (alert-inner config) + + :confirm + (confirm-inner config) + + (dialog-inner config)))) + +(defn- render-dialogs + [configs] + (when-let [configs (seq (filter map? configs))] + (into [:<>] (map render-dialog configs)))) + +(hsx/defc install-dialogs + [] + (let [[dialogs _set-dialogs!] (util/use-atom *dialogs)] + (render-dialogs dialogs))) diff --git a/deps/shui/src/logseq/shui/form/core.cljs b/deps/shui/src/logseq/shui/form/core.cljs new file mode 100644 index 0000000..a511dc1 --- /dev/null +++ b/deps/shui/src/logseq/shui/form/core.cljs @@ -0,0 +1,58 @@ +(ns logseq.shui.form.core + (:require [io.factorhouse.hsx.core :as hsx] + [logseq.shui.util :as util] + [cljs-bean.core :as bean])) + + +;; State +(def form-provider (util/ui-wrap "Form" {:static? false})) +(def form-field' (util/ui-wrap "FormField" {:static? false})) + +(hsx/defc form-field + [render' & args] + (let [[props render'] + (if (map? render') + [render' (first args)] + [(first args) render']) + _ (assert (contains? props :name) ":name is required for ") + render (fn [^js ctx] + ;; TODO: convert field-state? + (render' + (bean/bean (.-field ctx)) + (some-> (.-fieldState ctx) (.-error) (bean/bean)) + (bean/bean (.-fieldState ctx)) + ctx))] + (form-field' (assoc props :render render)))) + +(def form-control (util/ui-wrap "FormControl" {:static? false})) + +(def ^js yup (util/ui-get "yup")) +(def yup-resolver (util/ui-get "yupResolver")) + +;; Hooks +;; https://react-hook-form.com/docs/useform#resolver +(def use-form' (util/ui-get "useForm")) +(def use-form-context (util/ui-get "useFormContext")) + +(defn use-form + ([] (use-form {})) + ([opts] + (let [yup-schema (:yupSchema opts) + ^js form-methods (use-form' (bean/->js + (cond-> opts + (not (nil? yup-schema)) + (assoc :resolver (yup-resolver yup-schema)))))] + ;; NOTE: just shallow convert return object! + (bean/bean form-methods)))) + +;; UI +(def form-item (util/ui-wrap "FormItem")) +(def form-label (util/ui-wrap "FormLabel")) +(def form-description (util/ui-wrap "FormDescription")) +(def form-message (util/ui-wrap "FormMessage")) +(def input (util/ui-wrap "Input")) +(def textarea (util/ui-wrap "Textarea")) +(def switch (util/ui-wrap "Switch")) +(def checkbox (util/ui-wrap "Checkbox")) +(def radio-group (util/ui-wrap "RadioGroup")) +(def radio-group-item (util/ui-wrap "RadioGroupItem")) diff --git a/deps/shui/src/logseq/shui/form/password.cljs b/deps/shui/src/logseq/shui/form/password.cljs new file mode 100644 index 0000000..1658099 --- /dev/null +++ b/deps/shui/src/logseq/shui/form/password.cljs @@ -0,0 +1,25 @@ +(ns logseq.shui.form.password + (:require [clojure.string :as string] + [logseq.shui.base.core :as base-core] + [logseq.shui.form.core :as form-core] + [logseq.shui.hooks :as hooks] + [logseq.shui.icon.v2 :as icon-v2] + [io.factorhouse.hsx.core :as hsx])) + +(hsx/defc toggle-password + [option] + (let [[visible? set-visible!] (hooks/use-state false)] + [:div.ls-toggle-password-input.relative + (form-core/input + (merge + option + {:type (or (:type option) + (if visible? "text" "password"))})) + (when-not (string/blank? (:value option)) + (base-core/button + {:variant :ghost + :class "absolute right-1" + :style {:top 6} + :size :sm + :on-click #(set-visible! (not visible?))} + (icon-v2/root (if visible? "eye-off" "eye"))))])) diff --git a/deps/shui/src/logseq/shui/hooks.cljs b/deps/shui/src/logseq/shui/hooks.cljs new file mode 100644 index 0000000..c770553 --- /dev/null +++ b/deps/shui/src/logseq/shui/hooks.cljs @@ -0,0 +1,291 @@ +(ns logseq.shui.hooks + "React custom hooks." + (:refer-clojure :exclude [deref]) + (:require ["react" :as react] + [goog.functions :as gfun])) + +(defn- memo-deps + [equal-fn deps] + (let [equal-fn (or equal-fn =) + ^js deps-ref (react/useRef deps)] + (when-not (equal-fn (.-current deps-ref) deps) + (set! (.-current deps-ref) deps)) + (.-current deps-ref))) + +(defn- deps-array + [deps equal-fn] + (if (empty? deps) + #js[] + #js[(memo-deps equal-fn deps)])) + +(defn- effect-result + [result] + (if (fn? result) + result + js/undefined)) + +(defn use-memo + [f deps & {:keys [equal-fn]}] + (react/useMemo f (deps-array deps equal-fn))) + +(defn use-effect! + "setup-fn will be invoked every render of component when no deps arg provided" + ([setup-fn] + (assert (fn? setup-fn) "use-effect! setup-fn should be a function") + (react/useEffect (fn [] + (let [result (setup-fn)] + (effect-result result))))) + ([setup-fn deps & {:keys [equal-fn]}] + (assert (fn? setup-fn) "use-effect! setup-fn should be a function") + (react/useEffect (fn [] + (let [result (setup-fn)] + (effect-result result))) + (deps-array deps equal-fn)))) + +(defn use-layout-effect! + ([setup-fn] + (assert (fn? setup-fn) "use-layout-effect! setup-fn should be a function") + (react/useLayoutEffect (fn [] + (let [result (setup-fn)] + (effect-result result))))) + ([setup-fn deps & {:keys [equal-fn]}] + (assert (fn? setup-fn) "use-layout-effect! setup-fn should be a function") + (react/useLayoutEffect (fn [] + (let [result (setup-fn)] + (effect-result result))) + (deps-array deps equal-fn)))) + +(defn use-callback + [callback deps & {:keys [equal-fn]}] + (react/useCallback callback (deps-array deps equal-fn))) + +(defn- event-target + [target] + (cond + (fn? target) + (target) + + (and target (not (undefined? (.-current target)))) + (.-current target) + + :else + target)) + +(defn use-event-listener + ([target event-name handler deps] + (use-event-listener target event-name handler deps nil)) + ([target event-name handler deps options] + (use-effect! + (fn [] + (when-let [target' (event-target target)] + (.addEventListener target' event-name handler options) + #(.removeEventListener target' event-name handler options))) + (into [target event-name options] deps)))) + +(defn use-window-keydown + ([handler deps] + (use-window-keydown handler deps nil)) + ([handler deps options] + (use-event-listener js/window "keydown" handler deps options))) + +(defn use-window-keyup + ([handler deps] + (use-window-keyup handler deps nil)) + ([handler deps options] + (use-event-listener js/window "keyup" handler deps options))) + +(defn use-hide-on-esc-or-outside + [{:keys [active? root-ref on-hide ignore-class outside-event outside-options] + :or {ignore-class "ignore-outside-event" + outside-event "mousedown"}}] + (let [outside-handler (use-callback + (fn [e] + (when (and active? + (not (some-> (event-target root-ref) + (.contains (.-target e)))) + (not (some-> e .-target .-classList (.contains ignore-class)))) + (on-hide e))) + [active? root-ref on-hide ignore-class]) + keydown-handler (use-callback + (fn [e] + (when (and active? (= 27 (.-keyCode e))) + (on-hide e))) + [active? on-hide])] + (use-event-listener js/window outside-event outside-handler [outside-handler] outside-options) + (use-window-keydown keydown-handler [keydown-handler]))) + +;;; React hooks +(def use-ref react/useRef) +(def create-ref react/createRef) +(defn deref [ref] (.-current ref)) +(defn set-ref! [ref value] (set! (.-current ref) value)) +(defn- choose-value [nv cv] + (if (= nv cv) + cv + nv)) + +;; borrowed from https://github.com/pitch-io/uix/blob/master/core/src/uix/hooks/alpha.cljs +(defn- use-clojure-aware-updater + "Replicates React's behaviour when updating state with identical JS value, + but using Clojure's value equality here" + [updater] + (react/useCallback + (fn [v & args] + (updater + (fn [cv] + (if (fn? v) + (choose-value (apply v cv args) cv) + (choose-value v cv))))) + #js [updater])) + +(defn use-state [value] + (let [[state set-state] (react/useState value) + set-state (use-clojure-aware-updater set-state)] + #js [state set-state])) + +(defn- clojure-aware-reducer-updater + "Same as `use-clojure-primitive-aware-updater` but for `use-reducer`" + [f] + (fn [state action] + (choose-value (f state action) state))) + +(defn use-reducer + ([f value] + (let [updater (clojure-aware-reducer-updater f)] + (react/useReducer updater value))) + ([f value init-state] + (let [updater (clojure-aware-reducer-updater f)] + (react/useReducer updater value init-state)))) + +;;; other custom hooks + +(defn use-debounced-value + "Return the debounced value" + [value msec] + (let [[debounced-value set-value!] (use-state value) + cb (use-callback (gfun/debounce set-value! msec) [])] + (use-effect! #(cb value) [value]) + debounced-value)) + +(defn- is-touch-event? [e] + (exists? (.-touches e))) + +(defn- prevent-default [e] + (when (and (is-touch-event? e) + (< (.-length (.-touches e)) 2) + (.-preventDefault e)) + (.preventDefault e))) + +(defn use-long-press + [{:keys [on-click on-long-press prevent-default? delay] + :or {prevent-default? true + delay 300}}] + (let [[long-press-triggered set-long-press-triggered] (use-state false) + timeout-ref (use-ref nil) + target-ref (use-ref nil) + start (use-callback + (fn [e] + (when (and prevent-default? (.-target e)) + (.addEventListener (.-target e) "touchend" prevent-default #js {:passive false}) + (set! (.-current target-ref) (.-target e))) + (set! (.-current timeout-ref) + (js/setTimeout + (fn [] + (on-long-press e) + (set-long-press-triggered true)) + delay))) + [on-long-press delay prevent-default?]) + + clear (use-callback + (fn [_e should-trigger-click] + (when (.-current timeout-ref) + (js/clearTimeout (.-current timeout-ref))) + (when (and (or (nil? should-trigger-click) should-trigger-click) + (not long-press-triggered)) + (on-click)) + (set-long-press-triggered false) + (when (and prevent-default? (.-current target-ref)) + (.removeEventListener (.-current target-ref) "touchend" prevent-default))) + [prevent-default? on-click long-press-triggered])] + + {:onMouseDown #(start %) + :onTouchStart #(start %) + :onMouseUp #(clear % true) + :onMouseLeave #(clear % false) + :onTouchEnd #(clear % true)})) + +(defn- use-atom-fn + [a getter-fn setter-fn] + (let [[val set-val] (use-state (getter-fn @a))] + (use-effect! + (fn [] + (let [current-value (getter-fn @a)] + (when-not (= val current-value) + (set-val current-value))) + (let [id (str (random-uuid))] + (add-watch a id (fn [_ _ prev-state next-state] + (let [prev-value (getter-fn prev-state) + next-value (getter-fn next-state)] + (when-not (= prev-value next-value) + (set-val next-value))))) + #(remove-watch a id))) + [a]) + [val #(swap! a setter-fn %)])) + +(defn use-atom + "(use-atom my-atom)" + [a] + (use-atom-fn a identity (fn [_ v] v))) + +(defn use-atom-value + [a] + (first (use-atom a))) + +(defn use-atom-in + [a ks] + (let [ks (if (keyword? ks) [ks] ks)] + (use-atom-fn a #(get-in % ks) (fn [a' v] (assoc-in a' ks v))))) + +(defn use-modal-state + [initial-open?] + (let [open-atom (use-memo #(atom (boolean initial-open?)) []) + [open-value] (use-atom open-atom) + close-fn #(reset! open-atom false) + open-fn #(reset! open-atom true) + toggle-fn #(swap! open-atom not)] + {:open? open-value + :open-atom open-atom + :close-fn close-fn + :open-fn open-fn + :toggle-fn toggle-fn})) + +(defn use-mounted + [] + (let [mounted-ref (use-ref false)] + (use-effect! + (fn [] + (set-ref! mounted-ref true) + #(set-ref! mounted-ref false)) + []) + #(deref mounted-ref))) + +(defn use-bounding-client-rect + "Return a callback ref and the current bounding client rect for that node." + ([] (use-bounding-client-rect nil)) + ([tick] + (let [[ref set-ref] (use-state nil) + [rect set-rect] (use-state nil)] + (use-effect! + (if ref + (fn [] + (let [update-rect #(set-rect (.getBoundingClientRect ref)) + update-from-observer! (fn [entries] + (when (some-> entries (aget 0) .-contentRect) + (update-rect))) + observer (js/ResizeObserver. update-from-observer!)] + (update-rect) + (.observe observer ref) + #(.disconnect observer))) + #()) + [ref tick]) + [set-ref rect]))) diff --git a/deps/shui/src/logseq/shui/icon/v2.cljs b/deps/shui/src/logseq/shui/icon/v2.cljs new file mode 100644 index 0000000..167010c --- /dev/null +++ b/deps/shui/src/logseq/shui/icon/v2.cljs @@ -0,0 +1,58 @@ +(ns logseq.shui.icon.v2 + (:require + ["@tabler/icons-react" :as tabler-icons] + [camel-snake-kebab.core :as csk] + [clojure.string :as string] + [goog.object :as gobj] + [goog.string :as gstring] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.util :as shui-utils])) + +(def ^:private tabler-extension-icon-names + #{"add-link" "app-feature" "block" "block-search" "cloud-exclamation" + "connector" "group" "h-auto" "heading-off" "internal-link" + "link-to-block" "link-to-page" "link-to-whiteboard" "move-to-sidebar-right" + "new-block" "new-page" "new-whiteboard" "new-whiteboard-element" + "object-compact" "object-expanded" "open-as-page" "page" "page-search" + "references-hide" "references-show" "select-cursor" "text" "ungroup" + "whiteboard" "whiteboard-element" "whiteboard-search"}) + +(defn- font-icon + [name extension? opts] + [:span.ui__icon (merge {:class + (gstring/format + (str "%s-" name + (when (:class opts) + (str " " (string/trim (:class opts))))) + (if extension? "tie tie" "ti ti"))} + (dissoc opts :class :extension? :font?))]) + +(defn- svg-icon + [icons name icon-component-name class opts] + (let [f (shui-utils/component-wrap icons icon-component-name)] + [:span.ui__icon.ti + {:class (str "ls-icon-" name " " class)} + (f (merge {:size 18} + (shui-utils/map-keys->camel-case + (dissoc opts :class :extension? :font?))))])) + +(hsx/defc root + ([name] (root name nil)) + ([name {:keys [extension? font? class] :as opts}] + (when (and (string? name) + (not (string/blank? name))) + (let [^js js-tabler-icons (gobj/get js/window "tablerIcons") + icon-component-name (str "Icon" (csk/->PascalCase name))] + (cond + font? + (font-icon name extension? opts) + + (and js-tabler-icons (gobj/get js-tabler-icons icon-component-name)) + (svg-icon js-tabler-icons name icon-component-name class opts) + + (and (not extension?) (gobj/get tabler-icons icon-component-name)) + (svg-icon tabler-icons name icon-component-name class opts) + + :else + (font-icon name (or extension? + (contains? tabler-extension-icon-names name)) opts)))))) diff --git a/deps/shui/src/logseq/shui/popup/core.cljs b/deps/shui/src/logseq/shui/popup/core.cljs new file mode 100644 index 0000000..ca3471b --- /dev/null +++ b/deps/shui/src/logseq/shui/popup/core.cljs @@ -0,0 +1,418 @@ +(ns logseq.shui.popup.core + (:require [dommy.core :as d] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.util :as util :refer [use-atom]] + [medley.core :as medley])) + +;; ui +(def button (util/ui-wrap "Button")) +(def popover (util/ui-wrap "Popover")) +(def popover-trigger (util/ui-wrap "PopoverTrigger")) +(def popover-content (util/ui-wrap "PopoverContent")) +(def popover-arrow (util/ui-wrap "PopoverArrow")) +(def popover-close (util/ui-wrap "PopoverClose")) +(def popover-remove-scroll (util/ui-wrap "PopoverRemoveScroll")) +(def dropdown-menu (util/ui-wrap "DropdownMenu")) +(def dropdown-menu-trigger (util/ui-wrap "DropdownMenuTrigger")) +(def dropdown-menu-content (util/ui-wrap "DropdownMenuContent")) +(def dropdown-menu-arrow (util/ui-wrap "DropdownMenuArrow")) +(def dropdown-menu-group (util/ui-wrap "DropdownMenuGroup")) +(def dropdown-menu-item (util/ui-wrap "DropdownMenuItem")) +(def dropdown-menu-checkbox-item (util/ui-wrap "DropdownMenuCheckboxItem")) +(def dropdown-menu-radio-group (util/ui-wrap "DropdownMenuRadioGroup")) +(def dropdown-menu-radio-item (util/ui-wrap "DropdownMenuRadioItem")) +(def dropdown-menu-label (util/ui-wrap "DropdownMenuLabel")) +(def dropdown-menu-separator (util/ui-wrap "DropdownMenuSeparator")) +(def dropdown-menu-shortcut (util/ui-wrap "DropdownMenuShortcut")) +(def dropdown-menu-sub (util/ui-wrap "DropdownMenuSub")) +(def dropdown-menu-sub-content (util/ui-wrap "DropdownMenuSubContent")) +(def dropdown-menu-sub-trigger (util/ui-wrap "DropdownMenuSubTrigger")) + +;; {:id :open? false :content nil :position [0 0] :root-props nil :content-props nil} +(defonce ^:private *popups (atom [])) +(defonce ^:private *id (atom 0)) +(defonce ^:private gen-id #(reset! *id (inc @*id))) +(defonce ^:private *last-hidden (atom nil)) +(def *opened-sub-menus (atom #{})) + +(defn get-popup + [id] + (when id + (some->> (medley/indexed @*popups) + (filter #(= id (:id (second %)))) (first)))) + +(defn get-popups [] @*popups) +(defn get-last-popup [] (last @*popups)) + +(defn upsert-popup! + [config] + (when-let [id (:id config)] + (if-let [[index config'] (get-popup id)] + (swap! *popups assoc index (merge config' config)) + (swap! *popups conj config)) id)) + +(defn update-popup! + [id ks val] + (when-let [[index config] (get-popup id)] + (let [ks (if (coll? ks) ks [ks]) + config (if (nil? val) + (medley/dissoc-in config ks) + (assoc-in config ks val))] + (swap! *popups assoc index config)))) + +(defn detach-popup! + ([id] (detach-popup! id false)) + ([id skip-focus?] + (let [[index config] (get-popup id)] + (when index + (swap! *popups #(->> % (medley/remove-nth index) (vec))) + (let [{:keys [auto-focus? target trigger-id]} config] + (when (and (not skip-focus?) auto-focus? target) + (when-let [target (if trigger-id (js/document.getElementById trigger-id) target)] + (d/add-class! target "ls-popup-closed") + (.focus target)))))))) + +(declare hide!) + +(defn- same-popup-target? + [^js target ^js target'] + (and target target' + (or (= target target') + (and (instance? js/Node target') + (.-contains target) + (.contains target target'))))) + +(defn- recently-hidden-target? + [target] + (let [{hidden-target :target until :until} @*last-hidden] + (and target + hidden-target + (number? until) + (< (js/Date.now) until) + (same-popup-target? hidden-target target)))) + +(defn- element? + [target] + (and (exists? js/Element) + (instance? js/Element target))) + +(defn- native-event [^js event] + (or (some-> event (.-nativeEvent)) event)) + +(defn- event-target [^js event] + (let [^js native-event (native-event event)] + (or (some-> event (.-target)) + (some-> native-event (.-target)) + (some-> event (.-currentTarget)) + (some-> native-event (.-currentTarget))))) + +(defn- event-related-target [^js event] + (let [^js native-event (native-event event)] + (or (some-> event (.-relatedTarget)) + (some-> native-event (.-relatedTarget)) + (some-> event (.-toElement)) + (some-> native-event (.-toElement))))) + +(defn- event-composed-path [^js event] + (let [^js native-event (native-event event)] + (cond + (fn? (some-> native-event (.-composedPath))) + (array-seq (.composedPath native-event)) + + (fn? (some-> event (.-composedPath))) + (array-seq (.composedPath event))))) + +(defn- close-targets + [^js event-details] + (let [event (some-> event-details (.-event))] + (remove nil? + (concat [(some-> event-details (.-trigger)) + (event-related-target event) + (event-target event)] + (event-composed-path event))))) + +(def ^:private menu-transition-close-reasons + #{"trigger-hover" "trigger-focus" "list-navigation" "sibling-open"}) + +(defn- input-target? + [target] + (and (element? target) + (or (instance? js/HTMLInputElement target) + (instance? js/HTMLTextAreaElement target) + (instance? js/HTMLSelectElement target) + (some? (.closest target "[contenteditable='true']"))))) + +(defn- prevent-base-ui-handler! + [^js event] + (when (fn? (.-preventBaseUIHandler event)) + (.preventBaseUIHandler event))) + +(defn- menu-key-down-handler + [content-props disable-menu-handlers?] + (fn [^js event] + (some-> content-props :on-key-down (apply [event])) + (when (or disable-menu-handlers? + (input-target? (event-target event))) + (prevent-base-ui-handler! event)))) + +(defn- stop-toggle-event! [^js event] + (let [^js native-event (native-event event)] + (doseq [^js e (distinct (remove nil? [event native-event]))] + (when (fn? (.-preventDefault e)) + (.preventDefault e)) + (when (fn? (.-stopPropagation e)) + (.stopPropagation e)) + (when (fn? (.-stopImmediatePropagation e)) + (.stopImmediatePropagation e))))) + +(defn- clear-selection! + [] + (some-> js/window + (.getSelection) + (.removeAllRanges))) + +(defn- consume-toggle-event! [^js event] + (stop-toggle-event! event) + (clear-selection!)) + +(defn- close-canceled? + [handler ^js event-details] + (let [native-event (some-> event-details (.-event)) + result (when (fn? handler) (handler native-event))] + (when (or (false? result) + (some-> native-event (.-defaultPrevented)) + (some-> event-details (.-isCanceled))) + (some-> event-details (.cancel)) + true))) + +(defn show! + [^js event content & {:keys [id as-mask? as-dropdown? as-content? + focus-trigger? align root-props content-props + on-before-hide on-after-hide trigger-id] :as opts}] + (let [provided-id id + id (or provided-id (gen-id)) + ;; _ (prn :debug :show :id id) + *target (volatile! nil) + pointer-event? (or (and (exists? js/MouseEvent) + (instance? js/MouseEvent (or (.-nativeEvent event) event))) + (instance? js/goog.events.BrowserEvent event)) + position (cond + (vector? event) event + + pointer-event? + (let [event' (or (.-nativeEvent event) event)] + (vreset! *target (some->> [(.-currentTarget event) + (.-currentTarget event') + (.-target event') + (.-target event)] + (filter element?) + first)) + [(.-clientX event') (.-clientY event')]) + + (and (exists? js/Element) + (instance? js/Element event)) + (let [^js rect (.getBoundingClientRect event) + left (.-left rect) + width (.-width rect) + height (.-height rect) + bottom (.-bottom rect)] + (vreset! *target event) + [(+ left (case (keyword align) + :start 0 + :end width + (/ width 2))) + (- (- bottom height) + ;; minus default offset + (if as-mask? 6 0)) + width (if as-mask? 1 height)]) + (and (vector event) (= (count event) 2) (every? integer? event)) + event + :else [0 0]) + target @*target + opened (or (some->> (get-popups) (filter #(= id (:id %))) (first)) + (when (and (nil? provided-id) target) + (some #(when (same-popup-target? target (:target %)) %) + (get-popups))))] + (cond + (recently-hidden-target? target) + (do + (reset! *last-hidden nil) + (consume-toggle-event! event) + nil) + + opened + (do + (consume-toggle-event! event) + (if (and (number? (:ignore-opening-outside-press-until opened)) + (< (js/Date.now) (:ignore-opening-outside-press-until opened))) + (update-popup! (:id opened) :ignore-opening-outside-press-until nil) + (hide! (:id opened) 0 {:event event :skip-focus? true :suppress-next-show? true})) + (:id opened)) + + :else + (do + (when (element? target) + (d/remove-class! target "ls-popup-closed") + (d/set-attr! target "data-popup-active" (if (keyword? id) (name id) (str id)))) + (upsert-popup! + (merge opts + {:id id :target target + :trigger-id trigger-id + :ignore-opening-outside-press-until (+ (js/Date.now) 150) + :focus-trigger? focus-trigger? + :open? true :content content :position position + :as-dropdown? as-dropdown? + :as-content? as-content? + :root-props root-props + :on-before-hide on-before-hide + :on-after-hide on-after-hide + :content-props (cond-> content-props + (not (nil? align)) + (assoc :align (name align)))})) + id)))) + +(defn hide! + ([] (when-let [id (some-> (get-popups) (last) :id)] (hide! id 0))) + ([id] (hide! id 0 {})) + ([id delay] (hide! id delay {})) + ([id delay {:keys [_all? ^js event skip-focus? suppress-next-show?]}] + ;; (prn :debug :hide id) + (when-let [popup (get-popup id)] + (let [config (last popup) + target (:target config) + f (fn [] + (when suppress-next-show? + (reset! *last-hidden {:target target + :until (+ (js/Date.now) 150)})) + (detach-popup! id skip-focus?) + (some-> (:on-after-hide config) (apply [])))] + (when (not (false? (some-> (:on-before-hide config) (apply [event])))) + (when (and (not skip-focus?) + (not (false? (:focus-trigger? config))) + (some? target) + (= js/document (.-ownerDocument target)) + (.-isConnected target) + (fn? (.-focus target))) + (js/setTimeout #(.focus target) 16)) + (some-> target (d/remove-attr! "data-popup-active")) + (if (and (number? delay) (> delay 0)) + (js/setTimeout f delay) + (f))))))) + +(defn hide-all! + [] + (doseq [{:keys [id]} @*popups] + (hide! id 0 {:all? true}))) + +(defn- virtual-anchor + [target x y _width height] + (let [height (if (and (number? height) (> height 0)) height 1) + rect #js {:x x :y y :left x :top y :right (inc x) :bottom (+ y height) + :width 1 :height height} + anchor #js {:getBoundingClientRect #(js/Object.assign #js {} rect)}] + (when (element? target) + (aset anchor "contextElement" target)) + anchor)) + +(defn- anchor-props + [content-props target position] + (let [[x y width height] position] + {:anchor (virtual-anchor target x y width height) + :position-method (or (:position-method content-props) + (:positionMethod content-props) + "fixed")})) + +(defn- anchor-height + [height] + (if (and (number? height) (> height 0)) height 1)) + +(hsx/defc x-popup + [{:keys [id open? content position as-dropdown? as-content? force-popover? + auto-side? as-mask? _auto-focus? target root-props content-props + ignore-opening-outside-press-until _on-before-hide _on-after-hide] + :as _props}] + (when-let [[_x y _width height] position] + (let [use-menu? (not force-popover?) + popup-root (if use-menu? dropdown-menu popover) + popup-trigger (if use-menu? dropdown-menu-trigger popover-trigger) + popup-content (if use-menu? dropdown-menu-content popover-content) + auto-side-fn (fn [] + (let [vh js/window.innerHeight + height (anchor-height height) + [th bh] [y (- vh (+ y height))]] + (if (> bh 280) + "bottom" + (if (> (- th bh) 100) + "top" "bottom")))) + auto-side? (if (boolean? auto-side?) auto-side? true) + content-props (cond-> content-props + (and (not as-mask?) auto-side?) (assoc :side (auto-side-fn))) + handle-open-change! (fn [open? ^js e] + (when-not (false? (some-> root-props (:onOpenChange) (apply [open? e]))) + (when-not open? + (let [native-event (some-> e (.-event)) + last-popup? (= (:id (get-last-popup)) id) + reason (some-> e (.-reason)) + targets (close-targets e) + target-toggle? (some #(same-popup-target? target %) targets) + opening-outside-press? (and (= reason "outside-press") + (number? ignore-opening-outside-press-until) + (< (js/Date.now) ignore-opening-outside-press-until)) + focus-transition? (= reason "focus-out") + menu-transition? (and use-menu? + (contains? menu-transition-close-reasons reason)) + handler (case reason + "escape-key" (:onEscapeKeyDown content-props) + "outside-press" (:onPointerDownOutside content-props) + nil)] + (if (or (not last-popup?) + target-toggle? + opening-outside-press? + menu-transition? + focus-transition?) + (some-> e (.cancel)) + (when-not (close-canceled? handler e) + (hide! id 1 {:event native-event + :skip-focus? (= reason "outside-press") + :suppress-next-show? (= reason "outside-press")})))))))] + (let [disable-menu-handlers? (and use-menu? (not as-dropdown?)) + content-props (cond-> (merge content-props + (anchor-props content-props target position)) + as-mask? + (assoc :data-as-mask true) + + use-menu? + (assoc :on-key-down + (menu-key-down-handler content-props disable-menu-handlers?)) + + disable-menu-handlers? + (assoc :on-pointer-move prevent-base-ui-handler!)) + content (if (fn? content) + (content (cond-> {:id id} + as-content? + (assoc :content-props content-props))) content)] + (popup-root + (merge root-props {:open open? + :onOpenChange handle-open-change!}) + (popup-trigger + {:as-child true} + (button {:class "fixed p-0 opacity-0 pointer-events-none" + :tab-index -1 + :aria-hidden true + :style {:height 1 + :width 1 + :top -10000 + :left -10000}} + "")) + (if as-content? + content + (popup-content content-props content))))))) + +(hsx/defc install-popups + [] + (let [[popups _set-popups!] (use-atom *popups)] + [:<> + (for [config popups + :when (and (map? config) (:id config) (not (:all? config)))] + ^{:key (:id config)} + [x-popup config])])) diff --git a/deps/shui/src/logseq/shui/select/core.cljs b/deps/shui/src/logseq/shui/select/core.cljs new file mode 100644 index 0000000..c4b9fef --- /dev/null +++ b/deps/shui/src/logseq/shui/select/core.cljs @@ -0,0 +1,14 @@ +(ns logseq.shui.select.core + (:require [logseq.shui.util :as util])) + +(def select (util/ui-wrap "Select")) +(def select-group (util/ui-wrap "SelectGroup")) +(def select-value (util/ui-wrap "SelectValue")) +(def select-trigger (util/ui-wrap "SelectTrigger")) +(def select-icon (util/ui-wrap "SelectIcon")) +(def select-content (util/ui-wrap "SelectContent")) +(def select-label (util/ui-wrap "SelectLabel")) +(def select-item (util/ui-wrap "SelectItem")) +(def select-separator (util/ui-wrap "SelectSeparator")) +(def select-scroll-up-button (util/ui-wrap "SelectScrollUpButton")) +(def select-scroll-down-button (util/ui-wrap "SelectScrollDownButton")) diff --git a/deps/shui/src/logseq/shui/select/multi.cljs b/deps/shui/src/logseq/shui/select/multi.cljs new file mode 100644 index 0000000..16eba8b --- /dev/null +++ b/deps/shui/src/logseq/shui/select/multi.cljs @@ -0,0 +1,180 @@ +(ns logseq.shui.select.multi + (:require [clojure.string :as string] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.form.core :as form] + [logseq.shui.hooks :as hooks] + [logseq.shui.popup.core :as shui-popup])) + +(defn- get-k [item] + (if (map? item) + (some->> ((juxt :id :key :label) item) + (remove nil?) + (first)) + item)) + +(defn- get-v + [item] + (if (string? item) + item (or (:title item) (:value item)))) + +(hsx/defc search-input + [input-props & {:keys [on-enter valid-search-key?]}] + (let [*el (hooks/use-ref nil) + [down set-down!] (hooks/use-state 0)] + + (hooks/use-effect! + (fn [] + (when-let [^js item (and (> down 0) + (some-> (hooks/deref *el) + (.closest ".head") + (.-nextSibling)))] + (some-> (if valid-search-key? (.-nextSibling item) item) + (.focus)))) + [down]) + + [:div.search-input + {:ref *el} + (form/input + (merge {:on-key-up #(case (.-key %) + "ArrowDown" (set-down! (inc down)) + "ArrowUp" nil + "Enter" (when (fn? on-enter) (on-enter)) + :dune) + :auto-focus true} + input-props))])) + +(defn- simple-search-fn + [items q] + (let [q (some-> q (string/trim) (string/lower-case))] + (if (string/blank? q) + items + (filter #(some-> (get-v %) + (string/lower-case) + (string/includes? q)) items)))) + +(hsx/defc x-select-content + [items selected-items & {:keys [on-chosen item-render value-render + head-render foot-render open? close! + search-enabled? search-key on-search-key-change + search-input-placeholder + search-fn search-key-render + item-props content-props]}] + (let [x-content shui-popup/dropdown-menu-content + x-item shui-popup/dropdown-menu-item + *head-ref (hooks/use-ref nil) + [search-key1 set-search-key!] (hooks/use-state search-key) + search-key1' (some-> search-key1 (string/trim) (string/lower-case)) + valid-search-key? (and search-enabled? (not (string/blank? search-key1'))) + get-content-el #(some-> % (.closest ".ui__dropdown-menu-content")) + get-item-nodes #(some-> % (get-content-el) (.querySelectorAll ".ui__dropdown-menu-item") (js->clj)) + focus-search-input! (fn [^js target] + (when (and search-enabled? (not= "INPUT" (.-nodeName target))) + ;; focus search input + (some-> (get-content-el target) + (.querySelector "input") + (.focus)))) + items (if search-enabled? + (if (fn? search-fn) + (search-fn items search-key1) + (simple-search-fn items search-key1)) + items) + close1! #(when (fn? close!) (close!))] + + (hooks/use-effect! + (fn [] + (when (fn? on-search-key-change) + (on-search-key-change search-key1'))) + [search-key1']) + + (hooks/use-effect! + (fn [] + (when-let [t (when (and search-enabled? (false? open?)) + (js/setTimeout #(set-search-key! "") 500))] + #(js/clearTimeout t))) + [open?]) + + (x-content + (merge + {:onInteractOutside close1! + :onEscapeKeyDown close1! + :on-key-down (fn [^js e] + (when-let [^js target (.-target e)] + (case (.-key e) + "ArrowUp" + (when (= (some-> (get-item-nodes target) (first)) + js/document.activeElement) + (focus-search-input! target)) + "l" (when (or (.-metaKey e) (.-ctrlKey e)) + (focus-search-input! target)) + :dune))) + :class (str (:class content-props) + " ui__multi-select-content" + (when valid-search-key? " has-search-key"))} + (dissoc content-props :class)) + ;; header + (when (or search-enabled? (fn? head-render)) + [:div.head + {:ref *head-ref} + (when search-enabled? + (search-input + {:value search-key1 + :placeholder (or search-input-placeholder "") + :on-key-down (fn [^js e] + (.stopPropagation e) + (case (.-key e) + "Escape" (if (string/blank? search-key1) + (some-> (.-target e) (.closest ".ui__dropdown-menu-content") (.focus)) + (set-search-key! "")) + :dune)) + :on-change #(set-search-key! (.-value (.-target %)))} + + {:on-enter (fn [] + (when-let [head-el (and (not (string/blank? search-key1')) + (hooks/deref *head-ref))] + (when-let [^js item (.-nextSibling head-el)] + (when (contains? #{"menuitemcheckbox" "menuitem"} + (.getAttribute item "role")) + (.click item))))) + :valid-search-key? valid-search-key?})) + (when head-render (head-render))]) + ;; items + (for [item items + :let [selected? (some #(let [k (get-k item) + k' (get-k %)] + (or (= item %) + (and (not (nil? k)) + (not (nil? k')) + (= k k')))) + selected-items)]] + (if (fn? item-render) + (item-render item {:x-item x-item :selected? selected?}) + (let [k (get-k item) + v (get-v item)] + (when k + (let [opts {:selected? selected?} + on-click' (:on-click item-props) + on-click (fn [e] + ;; TODO: return value + (when (fn? on-click') (on-click' e)) + (when (fn? on-chosen) + (on-chosen item opts)))] + (x-item (merge {:data-k k :on-click on-click} item-props) + [:span.flex.items-center.gap-2.w-full + (form/checkbox {:checked selected?}) + (let [v' (if (fn? v) (v item opts) v)] + (if (fn? value-render) + (value-render v' (assoc opts :item item)) v'))])))))) + + (when (and search-enabled? + (fn? search-key-render)) + (let [exist-fn (fn [] + (and (not (string/blank? search-key1)) + (seq items) + (contains? (into #{} (map #(some-> (get-v %) (string/lower-case)) items)) + (string/lower-case search-key1))))] + (search-key-render search-key1 + {:items items :x-item x-item :exist-fn exist-fn}))) + ;; footer + (when (fn? foot-render) + [:div.foot + (foot-render)])))) diff --git a/deps/shui/src/logseq/shui/shortcut.cljs b/deps/shui/src/logseq/shui/shortcut.cljs new file mode 100644 index 0000000..94be1ee --- /dev/null +++ b/deps/shui/src/logseq/shui/shortcut.cljs @@ -0,0 +1,407 @@ +(ns logseq.shui.shortcut + "Unified keyboard shortcut component with three styles: combo, separate, and compact. + + Expected shortcut formats: + - Combo keys (simultaneous): \"shift+cmd\", [\"shift\" \"cmd\"], or [[\"shift\" \"cmd\"]] + - Separate keys (sequential): [\"cmd\" \"up\"], or [\"cmd\" \"k\"] + - Compact: same formats but with :style :compact + + Platform mapping: + - :mod or \"mod\" maps to ⌘ on macOS, Ctrl on Windows/Linux + - Other keys are mapped via print-shortcut-key function" + (:require [clojure.string :as string] + [goog.userAgent] + [io.factorhouse.hsx.core :as hsx])) + +(def mac? goog.userAgent/MAC) + +(defn print-shortcut-key + "Maps logical keys to display keys, with platform-specific handling. + Supports :mod for platform-agnostic modifier key. + Automatically uppercases single letter keys (a-z) while preserving + multi-character keys like Ctrl, Backspace, Delete, Opt, etc." + [key] + (let [result (if (coll? key) + (string/join "+" key) + (case (if (string? key) + (string/lower-case key) + key) + ("cmd" "command" "mod" "⌘") (if mac? "⌘" "Ctrl") + ("meta") (if mac? "⌘" "⊞") + ("return" "enter" "⏎") "⏎" + ("shift" "⇧") "⇧" + ("alt" "option" "opt" "⌥") (if mac? "⌥" "Alt") + ("ctrl" "control" "⌃") "Ctrl" + ("space" " ") "Space" + ("up" "↑") "↑" + ("down" "↓") "↓" + ("left" "←") "←" + ("right" "→") "→" + ("tab") "Tab" + ("open-square-bracket") "[" + ("close-square-bracket") "]" + ("dash") "-" + ("semicolon") ";" + ("equals") "=" + ("single-quote") "'" + ("backslash") "\\" + ("comma") "," + ("period") "." + ("slash") "/" + ("grave-accent") "`" + ("page-up") "PgUp" + ("page-down") "PgDn" + ("esc" "escape") "Esc" + ("backspace") "⌫" + ("delete") "Delete" + ("caps-lock" "capslock") "⇪" + (nil) "" + (name key)))] + ;; If result is a single letter (a-z), uppercase it + ;; Otherwise, capitalize only if it's a single character (for symbols) + (cond + (and (= (count result) 1) + (re-matches #"[a-z]" result)) + (string/upper-case result) + + (= (count result) 1) + result + + :else + (string/capitalize result)))) + +(defn- flatten-keys + "Recursively flattens nested collections, preserving strings." + [coll] + (mapcat (fn [x] + (if (and (coll? x) (not (string? x))) + (flatten-keys x) + [x])) + coll)) + +(defn- normalize-binding + "Normalizes a shortcut binding to a string format for data attributes. + Examples: 'cmd+k', 'shift+cmd+k', 'cmd up' + Handles 'mod' -> 'meta' on Mac, 'ctrl' on Windows/Linux for consistency with shortcut system." + [binding] + (let [normalize-key (fn [k] + (cond + (string? k) (let [lowered (string/lower-case k)] + ;; Convert 'mod' to 'meta' on Mac, 'ctrl' on Windows/Linux + ;; to match what the shortcut system uses + (if (= lowered "mod") + (if mac? "meta" "ctrl") + lowered)) + (keyword? k) (name k) + (symbol? k) (name k) + (number? k) (str k) + :else (str k)))] + (cond + (string? binding) + (let [trimmed (string/trim binding) + normalized (string/lower-case trimmed)] + ;; Normalize modifier aliases to canonical form (meta/ctrl) + (-> normalized + (string/replace #"\bmod\b" (if mac? "meta" "ctrl")) + (string/replace #"\b(?:cmd|command)\b" "meta") + (string/replace #"(?:\bopt\b|\boption\b|⌥)" "alt"))) + + (coll? binding) + (if (and (coll? (first binding)) (> (count binding) 1) (every? coll? binding)) + ;; Chord sequence: normalize each group separately, join with space + (string/join " " (map (fn [group] + (string/join "+" (map normalize-key (flatten-keys group)))) + binding)) + ;; Single combo group + (let [keys (flatten-keys binding)] + (string/join "+" (map normalize-key keys)))) + + (keyword? binding) + (name binding) + + (symbol? binding) + (name binding) + + :else + (str binding)))) + +(defn- detect-style + "Automatically detects style from shortcut format. + Returns :combo, :separate, or :compact" + [shortcut] + (cond + (string? shortcut) + (if (string/includes? shortcut "+") + :combo + :separate) + + (coll? shortcut) + (let [first-item (first shortcut)] + (cond + (coll? first-item) :combo ; nested collection means combo + (string/includes? (str first-item) "+") :combo + :else :separate)) + + :else :separate)) + +(defn- parse-shortcuts + "Parses shortcut string into structured format. + Handles ' | ' separator for multiple shortcuts." + [s] + (->> (string/split s #" \| ") + (map (fn [x] + (->> (string/split x #" ") + (map #(if (string/includes? % "+") + (string/split % #"\+") + %))))))) + +(def ^:private press-animation-ms 160) +(def ^:private row-shimmer-ms 750) + +(defn- highlight-row! + "Add or remove the row highlight class on the closest shortcut row ancestor. + On first trigger, forces a reflow to start the CSS animation. + On repeated triggers, the class stays applied (no reflow) and the + removal timer is reset so it fires after the last trigger." + [^js container add?] + (when-let [^js row (or (.closest container ".shui-shortcut-row, .shortcut-row") + (.-parentElement container))] + (if add? + (let [already? (.contains (.-classList row) "shui-shortcut-row--pressed")] + ;; Cancel any pending removal + (when-let [t (.-__sweepTimer row)] + (js/clearTimeout t) + (set! (.-__sweepTimer row) nil)) + ;; Only force reflow on first trigger to start the animation + (when-not already? + (.add (.-classList row) "shui-shortcut-row--pressed")) + ;; Schedule removal after the last trigger + (set! (.-__sweepTimer row) + (js/setTimeout + (fn [] + (.remove (.-classList row) "shui-shortcut-row--pressed") + (set! (.-__sweepTimer row) nil)) + row-shimmer-ms))) + (do + (when-let [t (.-__sweepTimer row)] + (js/clearTimeout t) + (set! (.-__sweepTimer row) nil)) + (.remove (.-classList row) "shui-shortcut-row--pressed"))))) + +(defn- animate-element! + "Add pressed class, optionally highlight row, then auto-reset after animation. + Key badge uses a simple clearTimeout+reset pattern to avoid stale removals." + [^js el ^js container highlight-row?] + (.add (.-classList el) "shui-shortcut-key-pressed") + (when highlight-row? (highlight-row! container true)) + ;; Clear any pending badge removal, then schedule a new one + (when-let [t (.-__badgeTimer el)] + (js/clearTimeout t)) + (set! (.-__badgeTimer el) + (js/setTimeout + (fn [] + (.remove (.-classList el) "shui-shortcut-key-pressed") + (set! (.-__badgeTimer el) nil)) + press-animation-ms))) + +(defn shortcut-press! + "Central helper to trigger key press animation. + For combo keys, animates the container (the whole keycap depresses). + For separate keys, animates individual kbd elements. + Optionally highlights parent row. + + Args: + - binding: normalized shortcut binding string (e.g., \"cmd+k\") + - highlight-row?: if true, also highlights parent row (default: false)" + ([binding] (shortcut-press! binding false)) + ([binding highlight-row?] + (let [normalized (normalize-binding binding) + selector (str "[data-shortcut-binding=\"" normalized "\"]") + containers (.querySelectorAll js/document selector)] + (doseq [^js container (array-seq containers)] + (if (.contains (.-classList container) "shui-shortcut-combo") + ;; Combo keys: animate the container as a unit (one keycap) + (animate-element! container container highlight-row?) + ;; Separate keys: animate each kbd individually + (let [keys (.querySelectorAll container "kbd.shui-shortcut-key")] + (if (> (.-length keys) 0) + (doseq [^js key-el (array-seq keys)] + (animate-element! key-el container highlight-row?)) + (animate-element! container container highlight-row?)))))))) + +(hsx/defc combo-keys + "Renders combo keys (simultaneous key combinations) with separator." + [keys binding {:keys [aria-label aria-hidden? glow?]}] + (let [key-elements (map print-shortcut-key keys) + normalized-binding (normalize-binding binding) + container-class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow")) + container-attrs (cond-> {:class container-class + :data-shortcut-binding normalized-binding + :style {:white-space "nowrap"}} + aria-label (assoc :aria-label aria-label) + aria-hidden? (assoc :aria-hidden "true"))] + [:div container-attrs + (for [[index key-text] (map-indexed vector key-elements)] + (list + (when (< 0 index) + [:span.shui-shortcut-separator {:key (str "sep-" index)}]) + [:kbd.shui-shortcut-key + {:key (str "combo-key-" index) + :aria-hidden (if aria-label "true" "false")} + key-text]))])) + +(hsx/defc separate-keys + "Renders separate keys (sequential key presses) with 4px gap." + [keys binding {:keys [aria-label aria-hidden? glow?]}] + (let [key-elements (map print-shortcut-key keys) + normalized-binding (normalize-binding binding) + container-class (str "shui-shortcut-separate" (when glow? " shui-shortcut-glow")) + container-attrs (cond-> {:class container-class + :data-shortcut-binding normalized-binding + :style {:white-space "nowrap" + :gap "4px"}} + aria-label (assoc :aria-label aria-label) + aria-hidden? (assoc :aria-hidden "true"))] + [:div container-attrs + (for [[index key-text] (map-indexed vector key-elements)] + [:kbd.shui-shortcut-key + {:key (str "separate-key-" index) + :aria-hidden (if aria-label "true" "false")} + key-text])])) + +(hsx/defc compact-keys + "Renders compact style (text-only, minimal styling)." + [keys binding {:keys [aria-label aria-hidden?]}] + (let [key-elements (map print-shortcut-key keys) + normalized-binding (normalize-binding binding) + container-attrs (cond-> {:class "shui-shortcut-compact" + :data-shortcut-binding normalized-binding + :style {:white-space "nowrap"}} + aria-label (assoc :aria-label aria-label) + aria-hidden? (assoc :aria-hidden "true"))] + [:div container-attrs + (for [[index key-text] (map-indexed vector key-elements)] + [:span + {:key (str "compact-key-" index) + :style {:display "inline-block" + :margin-right "2px"}} + key-text])])) + +(hsx/defc chord-sequence-keys + "Renders a chord sequence (multi-step key combinations) with 'then' separators. + E.g., [['⌘' 'c'] ['⌘' 'r']] renders as: [⌘ C] then [⌘ R]" + [groups binding {:keys [aria-label aria-hidden? glow? chord-separator] + :or {chord-separator "then"}}] + (let [normalized-binding (normalize-binding binding) + container-attrs (cond-> {:class "shui-shortcut-chord" + :data-shortcut-binding normalized-binding + :style {:white-space "nowrap" + :display "inline-flex" + :align-items "center" + :gap "8px"}} + aria-label (assoc :aria-label aria-label) + aria-hidden? (assoc :aria-hidden "true"))] + [:div container-attrs + (for [[gi group] (map-indexed vector groups)] + (list + (when (> gi 0) + [:span.shui-shortcut-chord-sep + {:key (str "chord-sep-" gi) + :style {:font-size "10px" + :opacity 0.45}} + chord-separator]) + (let [key-elements (map print-shortcut-key group)] + [:span + {:key (str "chord-group-" gi) + :class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow")) + :style {:display "inline-flex" + :align-items "center" + :white-space "nowrap"}} + (for [[ki key-text] (map-indexed vector key-elements)] + (list + (when (< 0 ki) + [:span.shui-shortcut-separator {:key (str "gsep-" gi "-" ki)}]) + [:kbd.shui-shortcut-key + {:key (str "chord-key-" gi "-" ki) + :aria-hidden (if aria-label "true" "false")} + key-text]))])))])) + +(hsx/defc root + "Main shortcut component with automatic style detection. + + Props: + - :style - :combo, :separate, :compact, or :auto (default: :auto) + - :aria-label - accessibility label for container + - :aria-hidden? - if true, hides from screen readers (default: false for decorative hints) + - :glow? - if true, adds inner glow effect to combo/separate keys (default: true) + - :raw-binding - raw binding format for data-shortcut-binding (for animation matching). + If not provided, will normalize from shortcut prop." + [shortcut & {:keys [style aria-label aria-hidden? glow? raw-binding chord-separator] + :or {style :auto + aria-hidden? false + glow? true}}] + (when (and shortcut (seq shortcut)) + (let [shortcuts (if (coll? shortcut) + (if (every? string? shortcut) + [shortcut] ; single shortcut as vector + (if (string? (first shortcut)) + [shortcut] ; single shortcut string + shortcut)) ; multiple shortcuts + (parse-shortcuts shortcut)) + opts (cond-> {:aria-label aria-label + :aria-hidden? aria-hidden? + :glow? glow?} + chord-separator (assoc :chord-separator chord-separator))] + (for [[index binding] (map-indexed vector shortcuts)] + (let [;; Chord sequence: multiple nested groups like [["⌘" "c"] ["⌘" "r"]] + chord-sequence? (and (coll? binding) + (> (count binding) 1) + (every? coll? binding)) + detected-style (if (= style :auto) + (detect-style binding) + style) + keys (cond + (string? binding) + (if (string/includes? binding "+") + (string/split binding #"\+") ; combo: "cmd+k" -> ["cmd" "k"] + (string/split binding #" ")) ; separate: "cmd k" -> ["cmd" "k"] + + (and (coll? binding) (coll? (first binding))) + (first binding) ; combo: nested collection like [["shift" "cmd"]] + + (coll? binding) + (let [flattened (mapcat (fn [item] + (cond + (coll? item) item + (and (string? item) (string/includes? item "+")) + (string/split item #"\+") ; split combo strings like "meta+caps-lock" + :else [item])) + binding)] + (if (every? string? flattened) + flattened ; separate: flat collection like ["cmd" "k"] or ["⇧" "g"] + (map str flattened))) ; convert any non-strings to strings + + :else + [(str binding)]) + ;; Use raw-binding if provided, otherwise normalize from binding + binding-for-data (cond + (nil? raw-binding) binding + (and (coll? raw-binding) (= (count raw-binding) 1)) (first raw-binding) + :else raw-binding) + render-fn (case detected-style + :combo combo-keys + :separate separate-keys + :compact compact-keys + separate-keys)] ; fallback + [:span + {:key (str "shortcut-" index) + :style {:display "inline-flex" + :align-items "center" + :white-space "nowrap"}} + (when (< 0 index) + [:span.text-gray-11.text-sm {:key (str "sep-" index) + :style {:margin "0 4px"}} "|"]) + (if chord-sequence? + (chord-sequence-keys binding binding-for-data opts) + (render-fn keys binding-for-data opts))]))))) + diff --git a/deps/shui/src/logseq/shui/table/core.cljc b/deps/shui/src/logseq/shui/table/core.cljc new file mode 100644 index 0000000..9471dad --- /dev/null +++ b/deps/shui/src/logseq/shui/table/core.cljc @@ -0,0 +1,276 @@ +(ns logseq.shui.table.core + "Table" + (:require [clojure.set :as set] + [dommy.core :refer-macros [sel1] :as dom] + [frontend.util :as util] + [goog.object :as gobj] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.hooks :as hooks] + [logseq.shui.table.impl :as impl])) + +(defn- get-head-container + [] + #_{:clj-kondo/ignore [:unresolved-namespace]} + (if (and js/window (gobj/get js/window "isCapacitorNew")) + (sel1 "ion-header") + (sel1 "#head"))) + +(defn- row-selected? + [row row-selection] + (let [id (:db/id row)] + (or + (and (:selected-all? row-selection) + ;; exclude ids + (not (contains? (:excluded-ids row-selection) id))) + (and (not (:selected-all? row-selection)) + ;; included ids + (contains? (:selected-ids row-selection) id))))) + +(defn- select-some? + [row-selection rows] + (or + (and (seq (:selected-ids row-selection)) + (some (:selected-ids row-selection) rows)) + (and (seq (:excluded-ids row-selection)) + (not= (count rows) (count (:excluded-ids row-selection)))))) + +(defn- select-all? + [row-selection rows] + (and (seq (:selected-ids row-selection)) + (set/subset? (set rows) + (:selected-ids row-selection)))) + +(defn- toggle-selected-all! + [table value set-row-selection!] + (let [group-by-property (get-in table [:state :group-by-property]) + row-selection (get-in table [:state :row-selection])] + (cond + (and group-by-property value) + (let [new-selection (update row-selection :selected-ids + (fn [ids] + (set/union (set ids) (set (:rows table)))))] + (set-row-selection! new-selection)) + + value + (set-row-selection! {:selected-all? value}) + + group-by-property + (let [new-selection (update row-selection :selected-ids + (fn [ids] + (set/difference (set ids) (set (:rows table)))))] + (set-row-selection! new-selection)) + + :else + (set-row-selection! {})))) + +(defn- set-conj + [col item] + (if (seq col) + (conj (if (set? col) col (set col)) item) + (conj #{} item))) + +(defn- row-toggle-selected! + [row value set-row-selection! row-selection] + (let [id (:db/id row) + new-selection (if (:selected-all? row-selection) + (update row-selection :excluded-ids (if value disj set-conj) id) + (update row-selection :selected-ids (if value set-conj disj) id))] + (set-row-selection! new-selection))) + +(defn- column-set-sorting! + [column set-sorting! sorting asc?] + (let [id (:id column) + existing-column (some (fn [item] (when (= (:id item) id) item)) sorting) + value (->> (if existing-column + (if (nil? asc?) + (remove (fn [item] (= (:id item) id)) sorting) + (map (fn [item] (if (= (:id item) id) (assoc item :asc? asc?) item)) sorting)) + (when-not (nil? asc?) + (into [{:id id :asc? asc?}] sorting))) + (remove nil?) + vec)] + (set-sorting! value) + value)) + +(defn get-selection-rows + [row-selection rows] + (if (:selected-all? row-selection) + (let [excluded-ids (:excluded-ids row-selection)] + (if (seq excluded-ids) + (remove #(excluded-ids %) rows) + rows)) + (let [selected-ids (:selected-ids row-selection)] + (when (seq selected-ids) + (filter #(selected-ids %) rows))))) + +(defn table-option + [{:keys [data columns state data-fns] + :as option}] + (let [{:keys [sorting row-filter row-selection visible-columns]} state + {:keys [set-sorting! set-visible-columns! set-row-selection!]} data-fns + columns' (impl/visible-columns columns visible-columns) + filtered-rows (impl/rows {:rows data + :columns columns + :sorting sorting + :row-filter row-filter})] + (assoc option + ;; visible columns + :columns columns' + ;; filtered rows + :rows filtered-rows + + ;; fns + :column-visible? (fn [column] (impl/column-visible? column visible-columns)) + :column-toggle-visibility (fn [column v] (set-visible-columns! (assoc visible-columns (impl/column-id column) v))) + :selected-all? (or (and (:selected-all? row-selection) + (nil? (seq (:excluded-ids row-selection)))) + (select-all? row-selection filtered-rows)) + :selected-some? (select-some? row-selection filtered-rows) + :row-selected? (fn [row] (row-selected? row row-selection)) + :row-toggle-selected! (fn [row-selection row value] (row-toggle-selected! row value set-row-selection! row-selection)) + :toggle-selected-all! (fn [table value] + (toggle-selected-all! table value set-row-selection!)) + :column-set-sorting! (fn [sorting column asc?] (column-set-sorting! column set-sorting! sorting asc?))))) + +(defn- get-prop-and-children + [prop-and-children] + (let [prop (when (map? (first prop-and-children)) (first prop-and-children))] + (if prop + [prop (rest prop-and-children)] + [{} prop-and-children]))) + +(hsx/defc table + [& prop-and-children] + (let [[prop children] (get-prop-and-children prop-and-children)] + (into + [:div (merge {:class "ls-table w-full caption-bottom text-sm table-fixed"} + prop)] + children))) + +(defn- remove-sticky-header + [] + (let [existing-headings (dom/sel ".ls-fixed")] + (doseq [node existing-headings] + (dom/remove-class! node "ls-fixed")))) + +(defn- use-sticky-element! + [target-ref container enabled?] + (hooks/use-effect! + (fn [] + (when enabled? + (let [^js/HTMLElement target (hooks/deref target-ref) + ^js/HTMLElement container (or (.closest target ".sidebar-item-list") container) + ^js/HTMLElement table-el (.closest target ".ls-table-rows")] + (when (and container table-el) + (let [^js/DOMTokenList target-cls (.-classList target) + ^js/HTMLElement table-footer (some-> table-el (.querySelector ".ls-table-footer")) + ^js/HTMLElement page-el (.closest target ".page-inner") + *el-top (volatile! (-> target (.getBoundingClientRect) (.-top))) + head-height (-> (get-head-container) (.-offsetHeight)) + update-target-top! (fn [] + (when (not (.contains target-cls "ls-fixed")) + (vreset! *el-top (+ (-> target (.getBoundingClientRect) (.-top)) + (.-scrollTop container))))) + update-footer! (fn [] + (let [tw (.-scrollWidth table-el)] + (when (and table-footer (number? tw) (> tw 0)) + (set! (. (.-style table-footer) -width) (str tw "px"))))) + update-target! (fn [] + (if (.contains target-cls "ls-fixed") + (let [^js/DOMRect rect (-> table-el (.getBoundingClientRect)) + width (.-clientWidth table-el) + left (.-left rect)] + (set! (. (.-style target) -width) (str width "px")) + (set! (. (.-style target) -left) (str left "px"))) + (do + (set! (. (.-style target) -width) "auto") + (set! (. (.-style target) -left) "0px"))) + ;; update scroll + (set! (. target -scrollLeft) (.-scrollLeft table-el))) + ;; target observer + target-observe! (fn [_e] + (let [first-visible-table (some #(when (util/el-visible-in-viewport? % true) %) + (dom/sel container ".ls-table-rows"))] + (when (= table-el first-visible-table) + (let [table-bottom (.-bottom (.getBoundingClientRect table-el)) + table-top (.-top (.getBoundingClientRect table-el))] + (if (and (< table-top head-height) + (> table-bottom 100)) + (do + (remove-sticky-header) + (.add target-cls "ls-fixed")) + (.remove target-cls "ls-fixed")) + (update-target!))))) + resize-observer (js/ResizeObserver. update-target!) + page-resize-observer (js/ResizeObserver. (fn [] (update-target-top!)))] + ;; events + (.observe resize-observer container) + (.observe resize-observer table-el) + (some->> page-el (.observe page-resize-observer)) + (.addEventListener container "scroll" target-observe!) + (.addEventListener table-el "scroll" update-target!) + (.addEventListener table-el "resize" update-target!) + (update-footer!) + + ;; teardown + #(do (.removeEventListener container "scroll" target-observe!) + (.disconnect resize-observer) + (.disconnect page-resize-observer))))))) + [enabled? container])) + +(defn- mobile? + [] + (when-let [user-agent js/navigator.userAgent] + (re-find #"Mobi" user-agent))) + +(hsx/defc table-header + [& prop-and-children] + (let [[prop children] (get-prop-and-children prop-and-children) + el-ref (hooks/use-ref nil) + _ (use-sticky-element! el-ref (:main-container prop) (not (mobile?)))] + (into + [:div.ls-table-header + (merge {:class "border-y transition-colors bg-gray-01" + :ref el-ref + :style {:z-index 9}} + (dissoc prop :main-container))] + children))) + +(hsx/defc table-footer + [& children] + (into [:div.ls-table-footer.fade-in.faster] children)) + +(hsx/defc table-row + [& prop-and-children] + (let [[prop children] (get-prop-and-children prop-and-children)] + (into + [:div.ls-table-row.ls-block.flex.flex-row.items-center + (merge {:class "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted bg-gray-01 items-stretch"} + prop)] + children))) + +(hsx/defc table-cell + [& prop-and-children] + (let [[prop children] (get-prop-and-children prop-and-children)] + [:div.ls-table-cell.flex.relative.h-full (dissoc prop :select? :add-property?) + (into + [:div {:class (str "flex align-middle w-full overflow-x-clip items-center" + (cond + (:select? prop) + " px-0" + (:add-property? prop) + "" + :else + " border-r px-2"))}] + children)])) + +(hsx/defc table-actions + [& prop-and-children] + (let [[prop children] (get-prop-and-children prop-and-children) + el-ref (hooks/use-ref nil)] + (into + [:div.ls-table-actions.flex.flex-row.items-center.gap-1.bg-gray-01 + (merge {:ref el-ref + :style {:z-index 101}} + prop)] + children))) diff --git a/deps/shui/src/logseq/shui/table/impl.cljc b/deps/shui/src/logseq/shui/table/impl.cljc new file mode 100644 index 0000000..30b9f86 --- /dev/null +++ b/deps/shui/src/logseq/shui/table/impl.cljc @@ -0,0 +1,22 @@ +(ns logseq.shui.table.impl + "Table impl") + +(defn column-id + [column] + (assert (some? (:id column)) "No id specified for this column") + (:id column)) + +(defn column-visible? + [column visible-columns] + (let [value (get visible-columns (column-id column))] + (not (false? value)))) + +(defn visible-columns + [columns visible-columns'] + (filter #(column-visible? % visible-columns') columns)) + +(defn rows + [{:keys [row-filter] + :as opts}] + (let [rows' (:rows opts)] + (if row-filter (filter row-filter rows') rows'))) diff --git a/deps/shui/src/logseq/shui/toaster/core.cljs b/deps/shui/src/logseq/shui/toaster/core.cljs new file mode 100644 index 0000000..2a4af6f --- /dev/null +++ b/deps/shui/src/logseq/shui/toaster/core.cljs @@ -0,0 +1,98 @@ +(ns logseq.shui.toaster.core + (:require [cljs-bean.core :as bean] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.hooks :as hooks] + [logseq.shui.util :as util])) + +(defonce ^:private Toaster (util/ui-wrap "Toaster")) +(defonce ^:private *toast (atom nil)) +(defonce ^:private *pending-toasts (atom [])) + +(defn gen-id [] + ((util/ui-get "genToastId"))) + +(defn use-toast [] + (when-let [use-toast' (util/ui-get "useToast")] + (let [^js js-toast (use-toast') + toast-fn! (.-toast js-toast) + dismiss! (.-dismiss js-toast)] + [(fn [s] + (let [^js s (bean/->js s)] + (toast-fn! s))) + dismiss!]))) + +(defn update-html-props + [v] + (update-keys v + #(case % + :class :className + :for :htmlFor + :on-dismiss :onDismiss + :on-open-change :onOpenChange + %))) + +(defn interpret-vals + [config ks & args] + (reduce (fn [config k] + (let [v (get config k) + v (if (fn? v) (apply v args) v)] + (if (vector? v) (assoc config k (hsx/create-element v)) config))) + config ks)) + +(defn toast! + ([content-or-config] (toast! content-or-config :default nil)) + ([content-or-config status] (toast! content-or-config status nil)) + ([content-or-config status opts] + (if-let [{:keys [toast dismiss]} @*toast] + (let [config (if (map? content-or-config) + content-or-config + (-> {:description content-or-config} + (merge (if (map? status) status {:variant status})))) + config (update-html-props (merge config opts)) + id (or (:id config) (gen-id)) + config (assoc config :id id) + config (interpret-vals config [:title :description :action :icon] + {:id id :dismiss! #(dismiss id) :update! #(toast! (assoc %1 :id id))})] + (js->clj (toast (clj->js config)))) + (let [config (if (map? content-or-config) + content-or-config + (-> {:description content-or-config} + (merge (if (map? status) status {:variant status})))) + config (update-html-props (merge config opts)) + id (or (:id config) (gen-id)) + config (assoc config :id id)] + (swap! *pending-toasts conj config) + {"id" id})))) + +(defn dismiss! + ([] (dismiss! nil)) + ([id] + (if id + (swap! *pending-toasts + (fn [pending-toasts] + (vec (remove #(= id (:id %)) pending-toasts)))) + (reset! *pending-toasts [])) + (when-let [{:keys [dismiss]} @*toast] + ;; dismiss all + (dismiss)))) + +(hsx/defc install-toaster + [] + (let [^js js-toast ((util/ui-get "useToast"))] + (hooks/use-effect! + (fn [] + (let [toast-api {:toast (.-toast js-toast) + :dismiss (.-dismiss js-toast) + :update (.-update js-toast)}] + (reset! *toast toast-api) + (doseq [config @*pending-toasts] + (let [id (:id config) + config (interpret-vals config [:title :description :action :icon] + {:id id + :dismiss! #((:dismiss toast-api) id) + :update! #(toast! (assoc %1 :id id))})] + ((:toast toast-api) (clj->js config)))) + (reset! *pending-toasts [])) + #()) + []) + [:<> (Toaster)])) diff --git a/deps/shui/src/logseq/shui/ui.cljs b/deps/shui/src/logseq/shui/ui.cljs new file mode 100644 index 0000000..fd428af --- /dev/null +++ b/deps/shui/src/logseq/shui/ui.cljs @@ -0,0 +1,165 @@ +(ns logseq.shui.ui + (:require [logseq.shui.base.core :as base-core] + [logseq.shui.dialog.core :as dialog-core] + [logseq.shui.form.core :as form-core] + [logseq.shui.form.password :as form-password] + [logseq.shui.icon.v2 :as icon-v2] + [logseq.shui.popup.core :as popup-core] + [logseq.shui.select.core :as select-core] + [logseq.shui.select.multi :as select-multi] + [logseq.shui.shortcut :as shui.shortcut] + [logseq.shui.table.core :as table-core] + [logseq.shui.toaster.core :as toaster-core] + [logseq.shui.util :as util])) + +(def button base-core/button) +(def button-icon base-core/button-icon) +(def button-ghost-icon base-core/button-ghost-icon) +(def button-outline-icon base-core/button-outline-icon) +(def button-secondary-icon base-core/button-secondary-icon) +(def button-group (util/ui-wrap "ButtonGroup")) +(def toolbar (util/ui-wrap "Toolbar")) +(def toolbar-group (util/ui-wrap "ToolbarGroup")) +(def toolbar-button (util/ui-wrap "ToolbarButton")) +(def toolbar-input (util/ui-wrap "ToolbarInput")) +(def toolbar-link (util/ui-wrap "ToolbarLink")) +(def toolbar-separator (util/ui-wrap "ToolbarSeparator")) +(def link base-core/link) +(def trigger-as base-core/trigger-as) +(def trigger-child-wrap base-core/trigger-child-wrap) +(def shortcut shui.shortcut/root) +(def shortcut-press! shui.shortcut/shortcut-press!) +(def ^:export tabler-icon icon-v2/root) + +(def alert (util/ui-wrap "Alert")) +(def alert-title (util/ui-wrap "AlertTitle")) +(def alert-description (util/ui-wrap "AlertDescription")) +(def slider (util/ui-wrap "Slider")) +(def slider-track (util/ui-wrap "SliderTrack")) +(def slider-range (util/ui-wrap "SliderRange")) +(def slider-thumb (util/ui-wrap "SliderThumb")) +(def separator (util/ui-wrap "Separator")) +(def badge (util/ui-wrap "Badge")) +(def skeleton (util/ui-wrap "Skeleton")) +(def calendar (util/ui-wrap "Calendar")) +(def avatar (util/ui-wrap "Avatar")) +(def avatar-image (util/ui-wrap "AvatarImage")) +(def avatar-fallback (util/ui-wrap "AvatarFallback")) +(def input form-core/input) +(def textarea form-core/textarea) +(def switch form-core/switch) +(def checkbox form-core/checkbox) +(def radio-group form-core/radio-group) +(def radio-group-item form-core/radio-group-item) +(def popover popup-core/popover) +(def popover-trigger popup-core/popover-trigger) +(def popover-content popup-core/popover-content) +(def popover-arrow popup-core/popover-arrow) + +(def tooltip (util/ui-wrap "Tooltip")) +(def tooltip-trigger (util/ui-wrap "TooltipTrigger")) +(def tooltip-portal (util/ui-wrap "TooltipPortal")) +(def tooltip-content (util/ui-wrap "TooltipContent")) +(def tooltip-provider (util/ui-wrap "TooltipProvider")) +(def tooltip-arrow (util/ui-wrap "TooltipArrow")) + +(def card (util/ui-wrap "Card")) +(def card-header (util/ui-wrap "CardHeader")) +(def card-title (util/ui-wrap "CardTitle")) +(def card-description (util/ui-wrap "CardDescription")) +(def card-content (util/ui-wrap "CardContent")) +(def card-footer (util/ui-wrap "CardFooter")) + +(def form-provider form-core/form-provider) +(def form-item form-core/form-item) +(def form-label form-core/form-label) +(def form-description form-core/form-description) +(def form-message form-core/form-message) +(def form-field form-core/form-field) +(def form-control form-core/form-control) + +(def select select-core/select) +(def select-group select-core/select-group) +(def select-value select-core/select-value) +(def select-trigger select-core/select-trigger) +(def select-icon select-core/select-icon) +(def select-content select-core/select-content) +(def select-label select-core/select-label) +(def select-item select-core/select-item) +(def select-separator select-core/select-separator) +(def select-scroll-up-button select-core/select-scroll-up-button) +(def select-scroll-down-button select-core/select-scroll-down-button) + +(def dropdown-menu popup-core/dropdown-menu) +(def dropdown-menu-trigger popup-core/dropdown-menu-trigger) +(def dropdown-menu-content popup-core/dropdown-menu-content) +(def dropdown-menu-group popup-core/dropdown-menu-group) +(def dropdown-menu-item popup-core/dropdown-menu-item) +(def dropdown-menu-checkbox-item popup-core/dropdown-menu-checkbox-item) +(def dropdown-menu-radio-group popup-core/dropdown-menu-radio-group) +(def dropdown-menu-radio-item popup-core/dropdown-menu-radio-item) +(def dropdown-menu-label popup-core/dropdown-menu-label) +(def dropdown-menu-separator popup-core/dropdown-menu-separator) +(def dropdown-menu-shortcut popup-core/dropdown-menu-shortcut) +(def dropdown-menu-sub popup-core/dropdown-menu-sub) +(def dropdown-menu-sub-content popup-core/dropdown-menu-sub-content) +(def dropdown-menu-sub-trigger popup-core/dropdown-menu-sub-trigger) + +(def context-menu (util/ui-wrap "ContextMenu")) +(def context-menu-trigger (util/ui-wrap "ContextMenuTrigger")) +(def context-menu-content (util/ui-wrap "ContextMenuContent")) +(def context-menu-item (util/ui-wrap "ContextMenuItem")) +(def context-menu-checkbox-item (util/ui-wrap "ContextMenuCheckboxItem")) +(def context-menu-radio-item (util/ui-wrap "ContextMenuRadioItem")) +(def context-menu-label (util/ui-wrap "ContextMenuLabel")) +(def context-menu-separator (util/ui-wrap "ContextMenuSeparator")) +(def context-menu-shortcut (util/ui-wrap "ContextMenuShortcut")) +(def context-menu-group (util/ui-wrap "ContextMenuGroup")) +(def context-menu-portal (util/ui-wrap "ContextMenuPortal")) +(def context-menu-sub (util/ui-wrap "ContextMenuSub")) +(def context-menu-sub-content (util/ui-wrap "ContextMenuSubContent")) +(def context-menu-sub-trigger (util/ui-wrap "ContextMenuSubTrigger")) +(def context-menu-radio-group (util/ui-wrap "ContextMenuRadioGroup")) + +;; tabs +(def tabs (util/ui-wrap "Tabs")) +(def tabs-list (util/ui-wrap "TabsList")) +(def tabs-trigger (util/ui-wrap "TabsTrigger")) +(def tabs-indicator (util/ui-wrap "TabsIndicator")) +(def tabs-content (util/ui-wrap "TabsContent")) + +(def dialog dialog-core/dialog) +(def dialog-portal dialog-core/dialog-portal) +(def dialog-overlay dialog-core/dialog-overlay) +(def dialog-close dialog-core/dialog-close) +(def dialog-trigger dialog-core/dialog-trigger) +(def dialog-content dialog-core/dialog-content) +(def dialog-header dialog-core/dialog-header) +(def dialog-footer dialog-core/dialog-footer) +(def dialog-title dialog-core/dialog-title) +(def dialog-description dialog-core/dialog-description) + +(def toast! toaster-core/toast!) +(def toast-dismiss! toaster-core/dismiss!) +(def dialog-open! dialog-core/open!) +(def dialog-alert! dialog-core/alert!) +(def dialog-confirm! dialog-core/confirm!) +(def dialog-close! dialog-core/close!) +(def dialog-close-all! dialog-core/close-all!) +(def dialog-get dialog-core/get-dialog) +(def popup-show! popup-core/show!) +(def popup-hide! popup-core/hide!) +(def popup-hide-all! popup-core/hide-all!) + +(def multi-select-content select-multi/x-select-content) + +(def table-option table-core/table-option) +(def table table-core/table) +(def table-header table-core/table-header) +(def table-footer table-core/table-footer) +(def table-row table-core/table-row) +(def table-cell table-core/table-cell) +(def table-actions table-core/table-actions) +(def table-get-selection-rows table-core/get-selection-rows) + +(def toggle-password form-password/toggle-password) diff --git a/deps/shui/src/logseq/shui/util.cljs b/deps/shui/src/logseq/shui/util.cljs new file mode 100644 index 0000000..f676db6 --- /dev/null +++ b/deps/shui/src/logseq/shui/util.cljs @@ -0,0 +1,113 @@ +(ns logseq.shui.util + (:require + ["uniqolor" :as uniqolor] + ["react" :as react] + [cljs-bean.core :as bean] + [clojure.set :refer [rename-keys]] + [clojure.string :as string] + [clojure.walk :as w] + [goog.object :as gobj] + [io.factorhouse.hsx.core :as hsx] + [logseq.shui.components :as components] + [logseq.shui.hooks :as hooks])) + +(goog-define NODETEST false) + +(defn kebab-case->camel-case + "Converts from kebab case to camel case, eg: on-click to onClick" + [input] + (let [words (string/split input #"-") + capitalize (->> (rest words) + (map #(apply str (string/upper-case (first %)) (rest %))))] + (apply str (first words) capitalize))) + +(defn map-keys->camel-case + "Stringify all the keys of a cljs hashmap and converts them + from kebab case to camel case. If :html-props option is specified, + then rename the html properties values to their dom equivalent + before conversion" + [data & {:keys [html-props]}] + (let [convert-to-camel (fn [[key value]] + (let [k (name key)] + [(if-not (or (string/starts-with? k "data-") + (string/starts-with? k "aria-")) + (kebab-case->camel-case k) k) value]))] + (w/postwalk (fn [x] + (if (map? x) + (let [new-map (if html-props + (rename-keys x {:class :className :for :htmlFor}) + x)] + (into {} (map convert-to-camel new-map))) + x)) + data))) + +(def dev? goog.DEBUG) + +(defn uuid-color + [uuid-str] + (some-> (uniqolor uuid-str + #js {:saturation #js [55, 70] + :lightness 70 + :differencePoint 60}) + (aget "color"))) + +(defn get-path + "Returns the component path." + [component-name] + (string/split (name component-name) #"\.")) + +(defn adapt-class [react-class & args] + (let [[opts children] (if (map? (first args)) + [(first args) (rest args)] + [{} args]) + children (some->> children (remove nil?)) + children (map hsx/create-element children) + + ;; convert any options key value to a React element, if + ;; a valid html element tag is used. + vector->react-elems (fn [[key val]] + (if (sequential? val) + [key (hsx/create-element val)] + [key val])) + new-options (into {} (map vector->react-elems opts)) + react-class (if dev? (react-class) react-class)] + (apply react/createElement react-class + ;; sablono html-to-dom-attrs does not work for nested hash-maps + (bean/->js (map-keys->camel-case new-options :html-props true)) + children))) + +(def use-atom hooks/use-atom) +(def use-mounted hooks/use-mounted) + +(defn- same-args? + [^js prev-props ^js next-props] + (= (.-args prev-props) + (.-args next-props))) + +(defn react->component [c static?] + (if static? + (let [class (fn [^js props] + (apply adapt-class c (.-args props))) + memo-class (if-some [memo (.-memo react)] + (memo class same-args?) + class)] + (fn [& args] + (react/createElement memo-class #js {:args args}))) + (partial adapt-class c))) + +(defn component-wrap + "Returns the component by the given component name." + [^js ns name & {:keys [static?] :or {static? false}}] + (let [path (get-path name) + ;; lazy calculating is for HMR from ts + cp #(gobj/getValueByKeys ns (clj->js path))] + (react->component (if dev? cp (cp)) static?))) + +(def ui-wrap + (partial component-wrap components/registry)) + +(defn ui-get + [name] + (if NODETEST + #js {} + (some-> components/registry (aget name)))) diff --git a/docs/Build LogSeq Desktop for windows on Ubuntu.md b/docs/Build LogSeq Desktop for windows on Ubuntu.md new file mode 100644 index 0000000..afb71af --- /dev/null +++ b/docs/Build LogSeq Desktop for windows on Ubuntu.md @@ -0,0 +1,35 @@ +# Building Logseq Desktop app for Windows on Ubuntu +## Intro +My Logseq dev machine is on Ubuntu 18.x and my production machine is running Windows 10, I needed a way to compile the Logseq desktop APP for Windows. +I tried & failed to make the "build" run on my windows machine but I did, however, succeed in letting my Ubuntu machine make Windows x64 files +## Pre-requisites +These are the steps I took to make it work on my Ubuntu machine, sharing them hoping it helps someone else. I assume you have all the basic pre-requisites for Logseq, if not you can find them at https://github.com/logseq/logseq#1-requirements +1. clone Logseq repo if you haven't already +`git clone https://github.com/logseq/logseq/` +1. Install wine +```shell +sudo dpkg --add-architecture i386 +sudo apt update +sudo apt install wine64 wine32 +``` +1. Install winetricks & install dotnet using winetricks +```shell +sudo apt install winetricks + +winetricks dotnet46 +``` +1. Install nuget and mono (N.B. I had to install mono-complete for it to work) +```shell +sudo apt install nuget + +sudo apt-get install mono-complete +``` +1. in `~/logseq/resources/package.json` line 10 `"electron:make": "electron-forge make --platform=win32 --arch=x64 --asar",` +1. Compile using +```shell +cd logseq +pnpm install +pnpm release +pnpm release-electron +``` +the executable should be in the `static/out/make/squirrel.windows/x64/` folder diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 0000000..34ac1fd --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,29 @@ +- Accessibility is a vague term, which is why it is usually misunderstood. It is not just about people with specific disabilities. You can read more about [what is accessibility](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/What_is_accessibility#so_what_is_accessibility) and [diverse abilities and barriers](https://www.w3.org/WAI/people-use-web/abilities-barriers/). +- ## Web Content Accessibility Guidelines + - [WCAG](https://www.w3.org/WAI/standards-guidelines/wcag/) (Web Content Accessibility Guidelines) is the international standard for web content accessibility, developed by [W3C](https://www.w3.org/). Logseq is a web application, so conforming with WCAG should be our first priority. In general, there is no simple way to determine if a website is accessible or not, but WCAG can help us make the tool usable by as many people as possible. +- ## Levels of conformance + - There are three levels of conformance defined by WCAG + - Level **A** is the minimum level. + - Level **AA** includes all Level A and AA requirements. + - Level **AAA** includes all Level A, AA, and AAA requirements. + - Many organizations strive to meet Level AA. The reason behind this decision, is that in some cases AAA standard is too strict. That doesn't mean that triple-A issues should be disregarded. On the contrary, all of them should be handled if possible. + - We can also provide alternative options in order to conform with AAA standards. For instance, our default themes can aim for AA, but we can provide a high-contrast theme that aims for AAA. Providing [alternative versions](https://www.w3.org/WAI/GL/2007/05/alternate-versions.html) with different levels of conformance is permitted according to WCAG, if there is an accessible way to reach those alternatives. +- ## Simple development guidelines + - Use semantically correct markup whenever possible. Every time you are about to decide which html tag you are going to use, choose the one that behaves the way you want it. For example, let's say you want to create an element that looks like plain text, but triggers an action on click. Usually, the best approach would be to create a `