Compare commits
10 Commits
1e7e9a3c13
...
b339cbb3b7
| Author | SHA1 | Date | |
|---|---|---|---|
| b339cbb3b7 | |||
| d8996bd5e1 | |||
| 0158430489 | |||
| dadeca7165 | |||
| d59aec181e | |||
| b67f7e9f78 | |||
| fcf41c7b41 | |||
| c50511a7b5 | |||
| db7f1fac7d | |||
| 196de21731 |
139
.gitea/workflows/build.yml
Normal file
139
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,139 @@
|
||||
name: Build packages
|
||||
|
||||
# Runs only on demand or when a release tag is pushed -- these builds are
|
||||
# long (cljs release compile + webpack + native packaging per platform) so
|
||||
# they don't run on every push.
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
# The `cli/` OCaml/dune package (a separate CLI companion tool) isn't
|
||||
# bundled: this fork doesn't have the opam/dune toolchain wired into CI,
|
||||
# and the `.agents/skills/logseq-cli/SKILL.md` doc it also depends on
|
||||
# doesn't exist in this repo yet. Both the desktop-runtime staging script
|
||||
# and gulpfile.js's prepareElectronMaker respect this flag by skipping the
|
||||
# CLI-only steps instead of failing. Unset (or set to '0') once the CLI
|
||||
# toolchain and skill doc are added.
|
||||
LOGSEQ_SKIP_CLI_BUNDLE: "1"
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux (AppImage, deb, rpm)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install rpmbuild
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- uses: DeLaGuardo/setup-clojure@13.1
|
||||
with:
|
||||
cli: 1.12.5.1664
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build unsigned AppImage/deb/rpm
|
||||
env:
|
||||
LOGSEQ_ELECTRON_BUILDER_ARGS: "--linux AppImage deb rpm"
|
||||
run: pnpm release-electron:unsigned
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: logseq-linux
|
||||
path: |
|
||||
static/dist/*.AppImage
|
||||
static/dist/*.deb
|
||||
static/dist/*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
windows:
|
||||
name: Windows (nsis)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- uses: DeLaGuardo/setup-clojure@13.1
|
||||
with:
|
||||
cli: 1.12.5.1664
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build unsigned nsis installer
|
||||
run: pnpm release-electron:unsigned
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: logseq-windows
|
||||
path: |
|
||||
static/dist/*.exe
|
||||
static/dist/*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
android:
|
||||
name: Android (debug APK)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK platform/build-tools
|
||||
run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
- uses: DeLaGuardo/setup-clojure@13.1
|
||||
with:
|
||||
cli: 1.12.5.1664
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build web assets and sync into the Android project
|
||||
run: pnpm sync-android-release
|
||||
|
||||
- name: Assemble debug APK
|
||||
working-directory: android
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: logseq-android
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
if-no-files-found: error
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ cli/_opam/
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
|
||||
# Python sidecar
|
||||
sidecar/.venv/
|
||||
|
||||
238
README.md
238
README.md
@@ -1,228 +1,24 @@
|
||||
<!-- logo -->
|
||||
<p align="center">
|
||||
<a href="https://logseq.com" alt="Logseq Logo">
|
||||
<img src="https://user-images.githubusercontent.com/25513724/220608753-f33db466-af72-4611-b603-411440c15ed0.png?sanatize=true" height="173"/></a>
|
||||
</p>
|
||||
# Synapse
|
||||
|
||||
<h1 align="center"> Logseq </h1>
|
||||
A privacy-first, personal knowledge management platform.
|
||||
|
||||
<h4 align="center">
|
||||
A privacy-first, open-source platform for knowledge management and collaboration
|
||||
</h4>
|
||||
Synapse is a personal fork of [Logseq](https://github.com/logseq/logseq) (DB-graph version),
|
||||
maintained by Darío Sevilla (dario@dariosevilla.es). It keeps Logseq's outliner, DB-graph
|
||||
model, and plugin API, reworked around a self-hosted server-client architecture: a single
|
||||
daemon (`db-worker-node`) holds the authoritative graph and serves thin web, desktop, and
|
||||
mobile clients over HTTP/WebSocket, with no cloud sync/E2EE layer.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://logseq.com">Home Page</a> |
|
||||
<a href="https://blog.logseq.com/">Blog</a> |
|
||||
<a href="https://docs.logseq.com/">Documentation</a> |
|
||||
<a href="https://logseq.io/p/NX4mc_ggEV">Roadmap</a>
|
||||
</div>
|
||||
<br></br>
|
||||
This is not affiliated with, endorsed by, or published on behalf of the Logseq project or
|
||||
Logseq Inc. All credit for the original design and the vast majority of the codebase goes to
|
||||
the [Logseq](https://logseq.com) team and its contributors.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/logseq/logseq/releases/latest/">
|
||||
<img src="https://img.shields.io/badge/Download_Logseq-100000?style=for-the-badge&logo=flatpak&logoColor=white&labelColor=002b36&color=85c8c8"
|
||||
alt="Download Logseq"/></a>
|
||||
</p>
|
||||
## Development
|
||||
|
||||
<!-- social badges -->
|
||||
<p align="center">
|
||||
<a href="https://discuss.logseq.com">
|
||||
<img src="https://img.shields.io/badge/forum-Logseq-blue.svg?&color=%2385c8c8&logo=discourse&style=for-the-badge"
|
||||
alt="forum"></a>
|
||||
<a href="https://discord.gg/KpN4eHY">
|
||||
<img src="https://img.shields.io/discord/725182569297215569?color=%2385c8c8&label=Discord&logo=discord&style=for-the-badge"
|
||||
alt="chat on Discord"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=logseq">
|
||||
<img src="https://img.shields.io/badge/twitter-%40logseq-blue.svg?&color=%2385c8c8&logo=twitter&style=for-the-badge"
|
||||
alt="follow on Twitter"></a>
|
||||
</p>
|
||||
See [docs/develop-logseq.md](docs/develop-logseq.md) (macOS/Linux) and
|
||||
[docs/develop-logseq-on-windows.md](docs/develop-logseq-on-windows.md) (Windows) for setting
|
||||
up a development environment. The `docs/` folder also has guides for mobile development,
|
||||
translations, and the CLI.
|
||||
|
||||
<!-- dev badges -->
|
||||
<p align="center">
|
||||
<a href="https://github.com/logseq/logseq/graphs/contributors" alt="Contributors">
|
||||
<img src="https://img.shields.io/github/contributors/logseq/logseq?color=%2385c8c8&style=for-the-badge"/></a>
|
||||
<a href="#-backers" alt="Backers on Open Collective">
|
||||
<img src="https://img.shields.io/opencollective/backers/logseq?color=%2385c8c8&style=for-the-badge"/></a>
|
||||
<a href="#-sponsors" alt="Sponsors on Open Collective">
|
||||
<img src="https://img.shields.io/opencollective/sponsors/logseq?color=%2385c8c8&style=for-the-badge"/></a>
|
||||
<a href="https://github.com/logseq/logseq/blob/master/LICENSE.md" alt="Activity">
|
||||
<img src="https://img.shields.io/github/license/logseq/logseq?color=%2385c8c8&style=for-the-badge"/></a>
|
||||
<a href="https://github.com/logseq/logseq/releases">
|
||||
<img src="https://img.shields.io/github/v/release/logseq/logseq?color=%2385c8c8&style=for-the-badge"
|
||||
alt="latest release version"></a>
|
||||
</p>
|
||||
## License
|
||||
|
||||
## Table of Contents
|
||||
|
||||
* [<g-emoji class="g-emoji" alias="database" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png">🚀</g-emoji> Database Version](#-database-version)
|
||||
* [<g-emoji class="g-emoji" alias="thinking" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f914.png">🤔</g-emoji> Why Logseq?](#-why-logseq)
|
||||
* [<g-emoji class="g-emoji" alias="eyes" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f440.png">👀</g-emoji> How can I use it?](#-how-can-i-use-it)
|
||||
* [<g-emoji class="g-emoji" alias="books" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4da.png">📚</g-emoji> Learn more](#-learn-more)
|
||||
* [🫶 Support Logseq Development](#-support-logseq-development)
|
||||
* [<g-emoji class="g-emoji" alias="bulb" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a1.png">💡</g-emoji> Feature requests](#-feature-requests)
|
||||
* [<g-emoji class="g-emoji" alias="electric_plug" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f50c.png">🔌</g-emoji> Plugin API](#-plugin-api)
|
||||
* [<g-emoji class="g-emoji" alias="star2" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f31f.png">🌟</g-emoji> Contributing to Logseq](#-contributing-to-logseq)
|
||||
* [<g-emoji class="g-emoji" alias="hammer_and_wrench" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f6e0.png">🛠️</g-emoji> Setting Up a Development Environment](#️-setting-up-a-development-environment)
|
||||
* [<g-emoji class="g-emoji" alias="sparkles" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/2728.png">✨</g-emoji> Inspiration](#-inspiration)
|
||||
* [<g-emoji class="g-emoji" alias="pray" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f64f.png">🙏</g-emoji> Thank You](#-thank-you)
|
||||
|
||||
## 🚀 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.
|
||||
|
||||

|
||||
|
||||
<a href="https://github.com/logseq/logseq/releases/latest/">
|
||||
<img src="https://img.shields.io/badge/Download_Logseq-100000?style=for-the-badge&logo=flatpak&logoColor=white&labelColor=002b36&color=85c8c8"
|
||||
align="right"
|
||||
alt="Download Logseq"/></a>
|
||||
|
||||
## 👀 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
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opencollective.com/logseq#sponsor"> [Become a sponsor]</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://opencollective.com/logseq" alt="Sponsors on Open Collective">
|
||||
<img src="https://opencollective.com/logseq/tiers/sponsors.svg?avatarHeight=42&width=600"/></a>
|
||||
</p>
|
||||
|
||||
## 🌟 Contributors
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/logseq/logseq/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=logseq/logseq&max=300&columns=14" width="600"/></a>
|
||||
</p>
|
||||
|
||||
<!-- JetBrains Logo -->
|
||||
<p align="center">
|
||||
<a href="https://jetbrains.com" alt="JetBrains">
|
||||
<img src="docs/assets/jetbrains.svg"/></a>
|
||||
</p>
|
||||
|
||||
<!-- ProductHunt Review Button -->
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/posts/logseq?utm_source=badge-review&utm_medium=badge&utm_souce=badge-logseq#discussion-body"
|
||||
target="_blank"><img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/review.svg?post_id=298158&theme=dark"
|
||||
align="center"
|
||||
alt="Logseq - Your joyful, private digital garden | Product Hunt" style="width: 250px; height: 54px;"
|
||||
width="250" height="54"/></a>
|
||||
</p>
|
||||
AGPL-3.0, inherited from upstream Logseq. See [LICENSE.md](LICENSE.md).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Logseq</string>
|
||||
<string name="title_activity_main">Logseq</string>
|
||||
<string name="app_name">Synapse</string>
|
||||
<string name="title_activity_main">Synapse</string>
|
||||
<string name="package_name">com.logseq.app</string>
|
||||
<string name="custom_url_scheme">com.logseq.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,8 +5,10 @@ import * as fs from 'fs'
|
||||
const version = fs.readFileSync('static/package.json', 'utf8').match(/"version": "(.*?)"/)?.at(1) ?? '0.0.0'
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
// appId intentionally left as-is: it must match android/'s applicationId
|
||||
// and ios/'s bundle id, which haven't been renamed yet.
|
||||
appId: 'com.logseq.app',
|
||||
appName: 'Logseq',
|
||||
appName: 'Synapse',
|
||||
webDir: 'static/mobile',
|
||||
loggingBehavior: 'debug',
|
||||
server: {
|
||||
|
||||
11
deploy/Caddyfile
Normal file
11
deploy/Caddyfile
Normal file
@@ -0,0 +1,11 @@
|
||||
your-domain.example {
|
||||
# Everything the db-worker-node daemon serves: RPC, SSE/WS events,
|
||||
# session auth, assets, the semantic API, and the MCP endpoint.
|
||||
@daemon path /healthz /v1/* /mcp
|
||||
reverse_proxy @daemon 127.0.0.1:3300
|
||||
|
||||
# Everything else is the static web client build.
|
||||
root * /opt/logseq/static
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
25
deploy/logseq-daemon.service
Normal file
25
deploy/logseq-daemon.service
Normal file
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=Logseq db-worker-node daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=logseq
|
||||
Group=logseq
|
||||
WorkingDirectory=/opt/logseq
|
||||
EnvironmentFile=-/opt/logseq/daemon.env
|
||||
ExecStart=/usr/bin/node /opt/logseq/dist/db-worker-node.js \
|
||||
--root-dir /opt/logseq/data \
|
||||
--repo logseq_db_main \
|
||||
--host 127.0.0.1 \
|
||||
--port 3300 \
|
||||
--log-level info
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/logseq/data
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
1
deps/common/src/logseq/common/config.cljs
vendored
1
deps/common/src/logseq/common/config.cljs
vendored
@@ -59,6 +59,7 @@
|
||||
(defonce favorites-page-name "$$$favorites")
|
||||
(defonce views-page-name "$$$views")
|
||||
(defonce library-page-name "Library")
|
||||
(defonce contents-page-name "Contents")
|
||||
(defonce quick-add-page-name "Quick add")
|
||||
(defonce recycle-page-name "Recycle")
|
||||
|
||||
|
||||
5
deps/db/src/logseq/db.cljs
vendored
5
deps/db/src/logseq/db.cljs
vendored
@@ -577,6 +577,11 @@
|
||||
[db]
|
||||
(get-built-in-page db common-config/library-page-name))
|
||||
|
||||
(def contents? db-db/contents?)
|
||||
(defn get-contents-page
|
||||
[db]
|
||||
(get-built-in-page db common-config/contents-page-name))
|
||||
|
||||
(defn get-case-page
|
||||
"Case sensitive version of get-page. For use with DB graphs"
|
||||
[db page-name-or-uuid]
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
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")
|
||||
pages-datoms (let [contents-id (get-first-page-by-title db common-config/contents-page-name)
|
||||
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")]
|
||||
|
||||
42
deps/db/src/logseq/db/frontend/class.cljs
vendored
42
deps/db/src/logseq/db/frontend/class.cljs
vendored
@@ -51,6 +51,12 @@
|
||||
{:title "Comment"
|
||||
:properties {:logseq.property.class/hide-from-node true}}
|
||||
|
||||
:logseq.class/Citation
|
||||
{:title "Citation"
|
||||
:properties {:logseq.property/icon {:type :tabler-icon :id "quote"}}
|
||||
:schema {:properties [:logseq.property.citation/target :logseq.property.citation/context]
|
||||
:required-properties [:logseq.property.citation/target]}}
|
||||
|
||||
:logseq.class/Query
|
||||
{:title "Query"
|
||||
:properties {:logseq.property/icon {:type :tabler-icon :id "search"}}
|
||||
@@ -101,7 +107,41 @@
|
||||
{:title "Template"
|
||||
:schema {:properties [:logseq.property/template-applied-to]}}
|
||||
|
||||
;; TODO: Add more classes such as :book, :paper, :movie, :music, :project)
|
||||
:logseq.class/Project
|
||||
{:title "Project"
|
||||
:properties {:logseq.property.class/extends :logseq.class/Page}}
|
||||
|
||||
:logseq.class/Area
|
||||
{:title "Area"
|
||||
:properties {:logseq.property.class/extends :logseq.class/Page}}
|
||||
|
||||
;; :logseq.property/scheduled and :logseq.property/deadline (the actual
|
||||
;; event start/end) are pre-existing baseline properties, not declared
|
||||
;; here in :schema :properties -- a migrated-in class can only resolve
|
||||
;; :schema :properties against properties added in that SAME migration
|
||||
;; step (see `build-initial-classes*`), so a page can still be tagged
|
||||
;; Event and given scheduled/deadline like any other public property.
|
||||
:logseq.class/Event
|
||||
{:title "Event"
|
||||
:properties {:logseq.property.class/extends :logseq.class/Page
|
||||
:logseq.property/icon {:type :tabler-icon :id "calendar-event"}}
|
||||
:schema {:properties [:logseq.property.event/location]}}
|
||||
|
||||
:logseq.class/Contact
|
||||
{:title "Contact"
|
||||
:properties {:logseq.property.class/extends :logseq.class/Page
|
||||
:logseq.property/icon {:type :tabler-icon :id "user"}}
|
||||
:schema {:properties [:logseq.property.contact/email :logseq.property.contact/phone
|
||||
:logseq.property.contact/organization]}}
|
||||
|
||||
:logseq.class/LiveNote
|
||||
{:title "Live Note"
|
||||
:properties {:logseq.property.class/extends :logseq.class/Page
|
||||
:logseq.property/icon {:type :tabler-icon :id "refresh"}}
|
||||
:schema {:properties [:logseq.property.livenote/url :logseq.property.livenote/refresh-minutes]
|
||||
:required-properties [:logseq.property.livenote/url]}}
|
||||
|
||||
;; TODO: Add more classes such as :book, :paper, :movie, :music)
|
||||
)))
|
||||
|
||||
(def page-children-classes
|
||||
|
||||
8
deps/db/src/logseq/db/frontend/db.cljs
vendored
8
deps/db/src/logseq/db/frontend/db.cljs
vendored
@@ -139,3 +139,11 @@
|
||||
[page]
|
||||
(and (entity-util/built-in? page)
|
||||
(= common-config/library-page-name (:block/title page))))
|
||||
|
||||
(defn contents?
|
||||
"Whether `page` is the built-in Contents page, the other hierarchical
|
||||
root pages can be explicitly organized under (coexists with Library,
|
||||
which remains the default root for namespaced pages)."
|
||||
[page]
|
||||
(and (entity-util/built-in? page)
|
||||
(= common-config/contents-page-name (:block/title page))))
|
||||
|
||||
17
deps/db/src/logseq/db/frontend/entity_util.cljs
vendored
17
deps/db/src/logseq/db/frontend/entity_util.cljs
vendored
@@ -42,12 +42,27 @@
|
||||
[entity]
|
||||
(has-tag? entity :logseq.class/Journal))
|
||||
|
||||
;; Built-in classes that extend :logseq.class/Page (logseq.db.frontend.class/
|
||||
;; page-children-classes) but, like Journal, are recognized as pages by their
|
||||
;; own specific tag alone -- they must not also carry the generic #Page tag
|
||||
;; (malli-schema's block-tags rule forbids combining #Page with another
|
||||
;; built-in page tag). Can't derive this set from class.cljs's
|
||||
;; page-children-classes here: class.cljs requires this namespace, so the
|
||||
;; reverse require would be circular.
|
||||
(def ^:private built-in-page-child-tags
|
||||
#{:logseq.class/Event :logseq.class/Contact :logseq.class/LiveNote})
|
||||
|
||||
(defn built-in-page-child?
|
||||
[entity]
|
||||
(some (fn [tag-ident] (has-tag? entity tag-ident)) built-in-page-child-tags))
|
||||
|
||||
(defn page?
|
||||
[entity]
|
||||
(or (internal-page? entity)
|
||||
(journal? entity)
|
||||
(class? entity)
|
||||
(property? entity)))
|
||||
(property? entity)
|
||||
(built-in-page-child? entity)))
|
||||
|
||||
(defn asset?
|
||||
"Given an entity or map, check if it is an asset block"
|
||||
|
||||
81
deps/db/src/logseq/db/frontend/property.cljs
vendored
81
deps/db/src/logseq/db/frontend/property.cljs
vendored
@@ -151,6 +151,16 @@
|
||||
:hide? true
|
||||
:view-context :block}
|
||||
:queryable? true}
|
||||
:logseq.property.code/result {:title "Code result"
|
||||
:schema {:type :string
|
||||
:public? false
|
||||
:hide? true
|
||||
:view-context :block}}
|
||||
:logseq.property.code/executed-at {:title "Code executed at"
|
||||
:schema {:type :datetime
|
||||
:public? false
|
||||
:hide? true
|
||||
:view-context :block}}
|
||||
:logseq.property/default-value {:title "Default value"
|
||||
:schema {:type :entity
|
||||
:public? false
|
||||
@@ -265,11 +275,24 @@
|
||||
:public? false
|
||||
:hide? true}}
|
||||
|
||||
:logseq.property.citation/target {:title "Cited page or block"
|
||||
:schema {:type :node}}
|
||||
:logseq.property.citation/context {:title "Citation context"
|
||||
:schema {:type :string}}
|
||||
|
||||
;; Journal props
|
||||
:logseq.property.journal/title-format {:title "Title Format"
|
||||
:schema
|
||||
{:type :string
|
||||
:public? false}}
|
||||
:logseq.property.journal/previous {:title "Previous journal"
|
||||
:schema {:type :node
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.journal/next {:title "Next journal"
|
||||
:schema {:type :node
|
||||
:hide? true
|
||||
:public? false}}
|
||||
|
||||
:logseq.property/choice-checkbox-state {:title "Choice checkbox state"
|
||||
:schema {:type :checkbox
|
||||
@@ -409,6 +432,57 @@
|
||||
|
||||
;; TODO: Add more props :Estimate, :Cycle, :Project
|
||||
|
||||
:logseq.property.event/location {:title "Location"
|
||||
:schema {:type :string
|
||||
:public? true}
|
||||
:queryable? true}
|
||||
:logseq.property.contact/email {:title "Email"
|
||||
:schema {:type :string
|
||||
:public? true}
|
||||
:queryable? true}
|
||||
:logseq.property.contact/phone {:title "Phone"
|
||||
:schema {:type :string
|
||||
:public? true}
|
||||
:queryable? true}
|
||||
:logseq.property.contact/organization {:title "Organization"
|
||||
:schema {:type :string
|
||||
:public? true}
|
||||
:queryable? true}
|
||||
;; CalDAV/CardDAV sync bookkeeping, not user-facing
|
||||
:logseq.property.caldav/uid {:title "CalDAV UID"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.caldav/href {:title "CalDAV Href"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.caldav/etag {:title "CalDAV ETag"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
|
||||
:logseq.property.livenote/url {:title "Source URL"
|
||||
:schema {:type :url
|
||||
:public? true}}
|
||||
:logseq.property.livenote/refresh-minutes {:title "Refresh every (minutes)"
|
||||
:schema {:type :number
|
||||
:public? true}
|
||||
:properties {:logseq.property/default-value 60}}
|
||||
;; Live note bookkeeping, not user-facing
|
||||
:logseq.property.livenote/content {:title "Live note content"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.livenote/last-fetched-at {:title "Live note last fetched at"
|
||||
:schema {:type :datetime
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.livenote/last-error {:title "Live note last error"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
|
||||
:logseq.property/icon {:title "Icon"
|
||||
:schema {:type :map}}
|
||||
:logseq.property/publishing-public? {:title "Publishing Public?"
|
||||
@@ -442,7 +516,8 @@
|
||||
: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"]])
|
||||
[:logseq.property.view/type.gallery "Gallery View" "layout-grid"]
|
||||
[:logseq.property.view/type.calendar "Calendar View" "calendar"]])
|
||||
:properties {:logseq.property/default-value :logseq.property.view/type.table}
|
||||
:queryable? true}
|
||||
|
||||
@@ -760,7 +835,9 @@
|
||||
"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"})
|
||||
"logseq.property.recycle" "logseq.property.comments" "logseq.property.agent"
|
||||
"logseq.property.citation" "logseq.property.event" "logseq.property.contact"
|
||||
"logseq.property.caldav" "logseq.property.livenote"})
|
||||
|
||||
(defn logseq-property?
|
||||
"Determines if keyword is a logseq property"
|
||||
|
||||
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
@@ -30,7 +30,7 @@
|
||||
(map (juxt :major :minor)
|
||||
[(parse-schema-version x) (parse-schema-version y)])))
|
||||
|
||||
(def version (parse-schema-version "65.33"))
|
||||
(def version (parse-schema-version "65.39"))
|
||||
|
||||
(defn major-version
|
||||
"Return a number.
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
(def built-in-pages-names
|
||||
#{common-config/library-page-name
|
||||
common-config/quick-add-page-name
|
||||
"Contents"})
|
||||
common-config/contents-page-name})
|
||||
|
||||
(defn- validate-tx-for-duplicate-idents [tx]
|
||||
(when-let [conflicting-idents
|
||||
|
||||
21
deps/db/test/logseq/db/frontend/class_test.cljs
vendored
21
deps/db/test/logseq/db/frontend/class_test.cljs
vendored
@@ -7,6 +7,27 @@
|
||||
(defn- parent-class-id [db]
|
||||
(:db/id (d/entity db :user.class/Parent)))
|
||||
|
||||
(deftest project-and-area-are-built-in-page-classes
|
||||
(testing "Project and Area are registered as built-in classes that behave like pages"
|
||||
(is (contains? db-class/built-in-classes :logseq.class/Project))
|
||||
(is (contains? db-class/built-in-classes :logseq.class/Area))
|
||||
(is (contains? db-class/page-children-classes :logseq.class/Project))
|
||||
(is (contains? db-class/page-children-classes :logseq.class/Area))
|
||||
(is (not (contains? db-class/private-tags :logseq.class/Project)))
|
||||
(is (not (contains? db-class/private-tags :logseq.class/Area)))))
|
||||
|
||||
(deftest event-and-contact-are-built-in-page-classes
|
||||
(testing "Event and Contact are registered as built-in classes that behave like pages"
|
||||
(is (contains? db-class/built-in-classes :logseq.class/Event))
|
||||
(is (contains? db-class/built-in-classes :logseq.class/Contact))
|
||||
(is (contains? db-class/page-children-classes :logseq.class/Event))
|
||||
(is (contains? db-class/page-children-classes :logseq.class/Contact))
|
||||
(is (not (contains? db-class/private-tags :logseq.class/Event)))
|
||||
(is (not (contains? db-class/private-tags :logseq.class/Contact))))
|
||||
(testing "Event's schema properties only reference properties migrated in the same step"
|
||||
(is (= [:logseq.property.event/location]
|
||||
(get-in db-class/built-in-classes [:logseq.class/Event :schema :properties])))))
|
||||
|
||||
(deftest get-class-objects-dedupes-inherited-tags-test
|
||||
(let [conn (db-test/create-conn-with-blocks
|
||||
{:classes {:Parent {:block/title "Parent"}
|
||||
|
||||
70
deps/outliner/src/logseq/outliner/page.cljs
vendored
70
deps/outliner/src/logseq/outliner/page.cljs
vendored
@@ -154,10 +154,33 @@
|
||||
(ldb/transact! conn tx-data tx-meta'))
|
||||
true))))))
|
||||
|
||||
(defn- tag-ident
|
||||
[tag]
|
||||
(cond (de/entity? tag) (:db/ident tag)
|
||||
(keyword? tag) tag
|
||||
(map? tag) (:db/ident tag)))
|
||||
|
||||
(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))
|
||||
;; A page already tagged with a built-in class that extends #Page
|
||||
;; (Journal, Whiteboard, Event, Contact, Project, Area, ...) must
|
||||
;; not also carry the generic #Page tag -- malli-schema rejects
|
||||
;; that combination as conflicting built-in page tags.
|
||||
already-page-tagged? (and (= type-tag :logseq.class/Page)
|
||||
(some (fn [tag]
|
||||
(let [ident (tag-ident tag)]
|
||||
(and ident
|
||||
(or (= ident :logseq.class/Page)
|
||||
(contains? db-class/page-children-classes ident)))))
|
||||
tags))
|
||||
tags' (if (or (:block/journal-day page) already-page-tagged?) tags (conj tags type-tag))
|
||||
;; page-name->map may have already seeded :block/tags with the
|
||||
;; generic #Page tag -- strip it back out here too, or it survives
|
||||
;; the merge below even though tags' no longer re-adds it.
|
||||
page (cond-> page
|
||||
already-page-tagged?
|
||||
(update :block/tags (fn [existing] (remove #{:logseq.class/Page} existing))))
|
||||
page' (update page :block/tags
|
||||
(fnil into [])
|
||||
(mapv (fn [tag]
|
||||
@@ -181,10 +204,10 @@
|
||||
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])))
|
||||
[{:db/ident k
|
||||
:logseq.property/type (get-in db-property/built-in-properties [k :schema :type])}
|
||||
v])))
|
||||
(into {})))]
|
||||
(cond-> (if class?
|
||||
[(merge (db-class/build-new-class db page' {:ident-namespace class-ident-namespace})
|
||||
@@ -299,6 +322,40 @@
|
||||
[page])
|
||||
(remove nil?))))
|
||||
|
||||
(defn- journal-neighbors
|
||||
"Returns [prev-entity next-entity], the closest existing journal pages
|
||||
immediately before/after journal-day (by day order, not necessarily
|
||||
adjacent calendar days)."
|
||||
[db journal-day]
|
||||
(let [entries (->> (d/datoms db :avet :block/journal-day)
|
||||
(map (fn [datom] [(:v datom) (:e datom)]))
|
||||
(remove (fn [[day]] (= day journal-day))))
|
||||
prev (->> entries (filter #(< (first %) journal-day)) (sort-by first) last)
|
||||
next (->> entries (filter #(> (first %) journal-day)) (sort-by first) first)]
|
||||
[(some->> prev second (d/entity db))
|
||||
(some->> next second (d/entity db))]))
|
||||
|
||||
(defn- journal-relink-tx
|
||||
"Tx-data linking a newly created journal page (identified by its uuid) to
|
||||
its nearest existing journal neighbors by day. Re-links around the new
|
||||
page when it fills a gap between two already-linked journals."
|
||||
[db page-uuid journal-day]
|
||||
(let [[prev next] (journal-neighbors db journal-day)
|
||||
page-ref [:block/uuid page-uuid]]
|
||||
(concat
|
||||
(when prev
|
||||
(cond-> [[:db/add page-ref :logseq.property.journal/previous (:db/id prev)]
|
||||
[:db/add (:db/id prev) :logseq.property.journal/next page-ref]]
|
||||
(:logseq.property.journal/next prev)
|
||||
(conj [:db/retract (:db/id prev) :logseq.property.journal/next
|
||||
(:db/id (:logseq.property.journal/next prev))])))
|
||||
(when next
|
||||
(cond-> [[:db/add page-ref :logseq.property.journal/next (:db/id next)]
|
||||
[:db/add (:db/id next) :logseq.property.journal/previous page-ref]]
|
||||
(:logseq.property.journal/previous next)
|
||||
(conj [:db/retract (:db/id next) :logseq.property.journal/previous
|
||||
(:db/id (:logseq.property.journal/previous next))]))))))
|
||||
|
||||
(defn ^:large-vars/cleanup-todo ^:api create
|
||||
"Pure function without side effects"
|
||||
[db title*
|
||||
@@ -403,10 +460,13 @@
|
||||
(: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]))
|
||||
journal-link-txs (when-let [journal-day (:block/journal-day page)]
|
||||
(journal-relink-tx db page-uuid journal-day))
|
||||
txs (concat
|
||||
;; transact doesn't support entities
|
||||
(remove de/entity? parents')
|
||||
page-txs)
|
||||
page-txs
|
||||
journal-link-txs)
|
||||
tx-meta (cond-> (outliner-tx-meta/ensure-outliner-ops
|
||||
{:persist-op? persist-op?
|
||||
:outliner-op :create-page}
|
||||
|
||||
@@ -204,3 +204,48 @@
|
||||
"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")))
|
||||
|
||||
(deftest create-journal-links-previous-and-next
|
||||
(let [conn (db-test/create-conn)
|
||||
[_ day1-uuid] (outliner-page/create! conn "Dec 16th, 2024" {})
|
||||
[_ day2-uuid] (outliner-page/create! conn "Dec 17th, 2024" {})
|
||||
day1 (d/entity @conn [:block/uuid day1-uuid])
|
||||
day2 (d/entity @conn [:block/uuid day2-uuid])]
|
||||
(is (nil? (:logseq.property.journal/previous day1))
|
||||
"First journal has no previous")
|
||||
(is (= day2-uuid (:block/uuid (:logseq.property.journal/next day1)))
|
||||
"First journal links forward to the second")
|
||||
(is (= day1-uuid (:block/uuid (:logseq.property.journal/previous day2)))
|
||||
"Second journal links back to the first")
|
||||
(is (nil? (:logseq.property.journal/next day2))
|
||||
"Second journal has no next yet")))
|
||||
|
||||
(deftest create-journal-fills-gap-relinks-neighbors
|
||||
(let [conn (db-test/create-conn)
|
||||
[_ day1-uuid] (outliner-page/create! conn "Dec 16th, 2024" {})
|
||||
[_ day3-uuid] (outliner-page/create! conn "Dec 18th, 2024" {})
|
||||
[_ day2-uuid] (outliner-page/create! conn "Dec 17th, 2024" {})
|
||||
day1 (d/entity @conn [:block/uuid day1-uuid])
|
||||
day2 (d/entity @conn [:block/uuid day2-uuid])
|
||||
day3 (d/entity @conn [:block/uuid day3-uuid])]
|
||||
(is (= day2-uuid (:block/uuid (:logseq.property.journal/next day1)))
|
||||
"Day1 now points to the newly created day2, not day3")
|
||||
(is (= day1-uuid (:block/uuid (:logseq.property.journal/previous day2)))
|
||||
"Day2 points back to day1")
|
||||
(is (= day3-uuid (:block/uuid (:logseq.property.journal/next day2)))
|
||||
"Day2 points forward to day3")
|
||||
(is (= day2-uuid (:block/uuid (:logseq.property.journal/previous day3)))
|
||||
"Day3 now points back to day2, not day1")))
|
||||
|
||||
(deftest create-namespace-page-under-contents
|
||||
(let [conn (db-test/create-conn)
|
||||
[_ child-uuid] (outliner-page/create! conn (str common-config/contents-page-name "/rama") {:split-namespace? true})
|
||||
child-page (d/entity @conn [:block/uuid child-uuid])
|
||||
contents (ldb/get-contents-page @conn)
|
||||
library (ldb/get-built-in-page @conn common-config/library-page-name)]
|
||||
(is (= "rama" (:block/title child-page)))
|
||||
(is (= (:db/id contents) (:db/id (:block/parent child-page)))
|
||||
"Contents can be used as an explicit parent root, alongside Library")
|
||||
(is (empty? (:block/_parent library))
|
||||
"Library is untouched when a page is nested under Contents instead")
|
||||
(is (ldb/contents? contents))))
|
||||
|
||||
10
gulpfile.js
10
gulpfile.js
@@ -266,6 +266,8 @@ exports.electron = () => {
|
||||
}
|
||||
|
||||
const prepareElectronMaker = async () => {
|
||||
const skipCliBundle = process.env.LOGSEQ_SKIP_CLI_BUNDLE === '1'
|
||||
|
||||
cp.execSync('pnpm cljs:release-electron', {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
@@ -275,9 +277,11 @@ const prepareElectronMaker = async () => {
|
||||
cp.execSync('pnpm webpack-app-build', {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (!skipCliBundle) {
|
||||
cp.execSync('pnpm cli:release', {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
cp.execSync('pnpm desktop:prepare-runtime-js', {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
@@ -306,8 +310,8 @@ const prepareElectronMaker = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const runStaticScript = (script) => {
|
||||
cp.execSync(`pnpm ${script}`, {
|
||||
const runStaticScript = (script, extraArgs = '') => {
|
||||
cp.execSync(`pnpm ${script}${extraArgs ? ' -- ' + extraArgs : ''}`, {
|
||||
cwd: outputPath,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
@@ -320,7 +324,7 @@ exports.electronMaker = async () => {
|
||||
|
||||
exports.electronMakerUnsigned = async () => {
|
||||
await prepareElectronMaker()
|
||||
runStaticScript('electron:make-unsigned')
|
||||
runStaticScript('electron:make-unsigned', process.env.LOGSEQ_ELECTRON_BUILDER_ARGS || '')
|
||||
}
|
||||
|
||||
exports.cap = common.runCapWithLocalDevServerEntry
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Logseq</string>
|
||||
<string>Synapse</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
|
||||
12
package.json
12
package.json
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "logseq",
|
||||
"name": "synapse",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "Darío Sevilla",
|
||||
"email": "dario@dariosevilla.es"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"main": "static/electron.js",
|
||||
"bin": {
|
||||
"logseq": "dist/logseq.js"
|
||||
"synapse": "dist/logseq.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
@@ -151,6 +155,7 @@
|
||||
"@js-joda/timezone": "2.5.0",
|
||||
"@logseq/react-tweet-embed": "1.3.1-1",
|
||||
"@logseq/simple-wave-record": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sentry/react": "^10.51.0",
|
||||
"@sqlite.org/sqlite-wasm": "3.51.2-build9",
|
||||
"@tabler/icons-react": "3.44.0",
|
||||
@@ -198,7 +203,8 @@
|
||||
"uniqolor": "1.1.1",
|
||||
"url": "^0.11.4",
|
||||
"util": "^0.12.5",
|
||||
"yup": "^1.7.1"
|
||||
"yup": "^1.7.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@zvec/bindings-darwin-arm64": "0.5.0",
|
||||
|
||||
505
pnpm-lock.yaml
generated
505
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -11,25 +11,13 @@
|
||||
<link href="/static/img/logo.png" rel="shortcut icon" type="image/png">
|
||||
<link href="/static/img/logo.png" rel="shortcut icon" sizes="192x192">
|
||||
<link href="/static/img/logo.png" rel="apple-touch-icon">
|
||||
<meta content="Logseq" name="apple-mobile-web-app-title">
|
||||
<meta content="Synapse" 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">
|
||||
<meta content="summary" name="twitter:card">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="twitter:description">
|
||||
<meta content="@logseq" name="twitter:site">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="twitter:title">
|
||||
<meta content="https://asset.logseq.com/static/img/logo.png" name="twitter:image:src">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="twitter:image:alt">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." property="og:title">
|
||||
<meta content="site" property="og:type">
|
||||
<meta content="https://logseq.com" property="og:url">
|
||||
<meta content="https://asset.logseq.com/static/img/logo.png" property="og:image">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." property="og:description">
|
||||
<title>Logseq: "A privacy-first platform for knowledge management and collaboration."</title>
|
||||
<meta content="logseq" property="og:site_name">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="description">
|
||||
<title>Synapse: A privacy-first, personal knowledge management platform</title>
|
||||
<meta content="A privacy-first, personal knowledge management platform." name="description">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
appId: com.logseq.logseq
|
||||
appId: es.dariosevilla.synapse
|
||||
electronUpdaterCompatibility: ">=2.16"
|
||||
|
||||
directories:
|
||||
@@ -37,18 +37,11 @@ toolsets:
|
||||
appimage: "1.0.2"
|
||||
|
||||
protocols:
|
||||
- name: logseq
|
||||
- name: synapse
|
||||
schemes:
|
||||
- logseq
|
||||
|
||||
publish:
|
||||
- provider: github
|
||||
owner: logseq
|
||||
repo: logseq
|
||||
- synapse
|
||||
|
||||
extraResources:
|
||||
- from: .agents/skills/logseq-cli/SKILL.md
|
||||
to: .agents/skills/logseq-cli/SKILL.md
|
||||
- from: ../sidecar
|
||||
to: sidecar
|
||||
|
||||
@@ -83,7 +76,6 @@ linux:
|
||||
icon: icons/logseq.png
|
||||
category: Office
|
||||
target:
|
||||
- AppImage
|
||||
- zip
|
||||
- rpm
|
||||
mimeTypes:
|
||||
- x-scheme-handler/logseq
|
||||
- x-scheme-handler/synapse
|
||||
|
||||
@@ -9,25 +9,13 @@
|
||||
<link href="./img/logo.png" rel="shortcut icon" type="image/png">
|
||||
<link href="./img/logo.png" rel="shortcut icon" sizes="192x192">
|
||||
<link href="./img/logo.png" rel="apple-touch-icon">
|
||||
<meta content="Logseq" name="apple-mobile-web-app-title">
|
||||
<meta content="Synapse" 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">
|
||||
<meta content="summary" name="twitter:card">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="twitter:description">
|
||||
<meta content="@logseq" name="twitter:site">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="twitter:title">
|
||||
<meta content="https://asset.logseq.com/static/img/logo.png" name="twitter:image:src">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="twitter:image:alt">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." property="og:title">
|
||||
<meta content="site" property="og:type">
|
||||
<meta content="https://logseq.com" property="og:url">
|
||||
<meta content="https://asset.logseq.com/static/img/logo.png" property="og:image">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." property="og:description">
|
||||
<title>Logseq: A privacy-first platform for knowledge management and collaboration</title>
|
||||
<meta content="logseq" property="og:site_name">
|
||||
<meta content="A privacy-first, open-source platform for knowledge management and collaboration." name="description">
|
||||
<title>Synapse: A privacy-first, personal knowledge management platform</title>
|
||||
<meta content="A privacy-first, personal knowledge management platform." name="description">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||
<link href="./css/style.css" rel="stylesheet" type="text/css">
|
||||
<title>Logseq: A privacy-first platform for knowledge management and collaboration</title>
|
||||
<title>Synapse: A privacy-first, personal knowledge management platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "Logseq",
|
||||
"productName": "Logseq",
|
||||
"name": "Synapse",
|
||||
"productName": "Synapse",
|
||||
"version": "2.0.1",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"main": "electron.js",
|
||||
"author": "Logseq",
|
||||
"author": {
|
||||
"name": "Darío Sevilla",
|
||||
"email": "dario@dariosevilla.es"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A privacy-first, open-source platform for knowledge management and collaboration.",
|
||||
"repository": "https://github.com/logseq/logseq",
|
||||
"description": "A privacy-first, personal knowledge management platform.",
|
||||
"repository": "ssh://gitea@git.dariosevilla.es:2222/dasemu/Synapse.git",
|
||||
"scripts": {
|
||||
"electron:dev": "electron .",
|
||||
"electron:debug": "electron --inspect=9229 .",
|
||||
|
||||
@@ -17,6 +17,7 @@ const cliBundleRelativePath = "cli/_build/default/dist/logseq-cli.js";
|
||||
const stagedCliRelativePath = "static/logseq-cli.js";
|
||||
const desktopCliRelativePath = "static/js/logseq-cli.js";
|
||||
const cliBundlePath = path.join(repoRoot, ...cliBundleRelativePath.split("/"));
|
||||
const skipCliBundle = process.env.LOGSEQ_SKIP_CLI_BUNDLE === "1";
|
||||
|
||||
const copyPairs = [
|
||||
{
|
||||
@@ -24,6 +25,7 @@ const copyPairs = [
|
||||
to: path.join(repoRoot, ...desktopCliRelativePath.split("/")),
|
||||
generatedFrom: cliBundlePath,
|
||||
refreshCommand: "pnpm cli:release",
|
||||
optional: skipCliBundle,
|
||||
},
|
||||
{
|
||||
from: path.join(staticDir, "logseq-cli.js.map"),
|
||||
@@ -39,6 +41,7 @@ const copyPairs = [
|
||||
{
|
||||
from: path.join(skillSourceDir, "SKILL.md"),
|
||||
to: path.join(stagedSkillDir, "SKILL.md"),
|
||||
optional: skipCliBundle,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Keep same as main/frontend.util.url
|
||||
(defonce LSP_SCHEME "logseq")
|
||||
(defonce LSP_SCHEME "synapse")
|
||||
(defonce FILE_LSP_SCHEME "lsp")
|
||||
(defonce FILE_ASSETS_SCHEME "assets")
|
||||
(defonce LSP_PROTOCOL (str FILE_LSP_SCHEME "://"))
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
(ns electron.mcp-server
|
||||
"MCP server routes for the desktop API server."
|
||||
(:require ["@modelcontextprotocol/sdk/server/mcp.js" :refer [McpServer]]
|
||||
["@modelcontextprotocol/sdk/server/streamableHttp.js" :refer [StreamableHTTPServerTransport]]
|
||||
["@modelcontextprotocol/sdk/types.js" :refer [isInitializeRequest]]
|
||||
["zod/v3" :as z] ;; zod 4 doesn't work w/ mcp - https://github.com/modelcontextprotocol/typescript-sdk/issues/925
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Server util fns
|
||||
;; ===============
|
||||
;; "Stores transports by session ID"
|
||||
(defonce ^:private transports
|
||||
(atom {}))
|
||||
|
||||
(declare create-mcp-api-server)
|
||||
|
||||
;; See https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
|
||||
;; for how to respond to different MCP requests
|
||||
(defn handle-post-request [api-fn {:keys [port host]} req res]
|
||||
(let [session-id (aget (.-headers req) "mcp-session-id")]
|
||||
(js/console.log "POST /mcp request" session-id (pr-str (.-body req)))
|
||||
(cond
|
||||
(and session-id (@transports session-id))
|
||||
(let [^js transport (@transports session-id)]
|
||||
(.handleRequest transport (.-raw req) (.-raw res) (.-body req)))
|
||||
|
||||
(and (not session-id)
|
||||
(isInitializeRequest (.-body req)))
|
||||
(let [transport (StreamableHTTPServerTransport.
|
||||
#js {:sessionIdGenerator (comp str random-uuid)
|
||||
:enableDnsRebindingProtection true
|
||||
:allowedHosts #js [(str host ":" port)]})
|
||||
mcp-server (create-mcp-api-server api-fn)]
|
||||
(set! (.-onclose transport)
|
||||
(fn []
|
||||
(js/console.log "Transport closed" (.-sessionId transport))
|
||||
(swap! transports dissoc (.-sessionId transport))))
|
||||
(.connect mcp-server transport)
|
||||
(.handleRequest transport (.-raw req) (.-raw res) (.-body req))
|
||||
(js/console.log "Initialize sessionId" (.-sessionId transport))
|
||||
(if (.-sessionId transport)
|
||||
(swap! transports assoc (.-sessionId transport) transport)
|
||||
(js/console.error "No sessionId to initialize!"))
|
||||
res)
|
||||
|
||||
:else
|
||||
(do
|
||||
(.code res 400)
|
||||
(.send res #js {:jsonrpc "2.0"
|
||||
:error #js {:code -32000
|
||||
:message "Bad Request: No valid session ID provided"}
|
||||
:id nil})))))
|
||||
|
||||
(defn handle-get-request
|
||||
[req res]
|
||||
(let [session-id (aget (.-headers req) "mcp-session-id")]
|
||||
(js/console.log "GET /mcp" session-id)
|
||||
(if-let [transport (and session-id (@transports session-id))]
|
||||
(.handleRequest ^js transport (.-raw req) (.-raw res))
|
||||
(-> res (.code 400) (.send "Invalid or missing session ID")))))
|
||||
|
||||
(defn handle-delete-request
|
||||
[req res]
|
||||
(let [session-id (aget (.-headers req) "mcp-session-id")]
|
||||
(js/console.log "DELETE /mcp" session-id)
|
||||
(if-let [transport (and session-id (@transports session-id))]
|
||||
(do
|
||||
(.close transport)
|
||||
(-> res (.code 200) (.send #js {:ok true})))
|
||||
(-> res (.code 400) (.send "Invalid or missing session ID")))))
|
||||
|
||||
(defn mcp-error-response [msg]
|
||||
#js {:content
|
||||
#js [#js {:type "text"
|
||||
:text msg}]})
|
||||
|
||||
(defn mcp-success-response [data]
|
||||
(clj->js {:content
|
||||
[{:type "text"
|
||||
:text (js/JSON.stringify (clj->js data))}]}))
|
||||
|
||||
;; API tool fns
|
||||
;; ============
|
||||
(defn- unexpected-api-error [error]
|
||||
#js {:content
|
||||
#js [#js {:type "text"
|
||||
:text (str "Unexpected API error: " (.-message error))}]})
|
||||
|
||||
(defn- api-tool
|
||||
"Calls API method w/ args and returns a MCP response"
|
||||
[api-fn api-method method-args]
|
||||
(-> (p/let [body (api-fn api-method method-args)]
|
||||
(if-let [error (and body (aget body "error"))]
|
||||
(mcp-error-response (str "API Error: " error))
|
||||
(mcp-success-response body)))
|
||||
(p/catch unexpected-api-error)))
|
||||
|
||||
(defn- api-get-page
|
||||
[call-api-fn args]
|
||||
(call-api-fn "logseq.cli.getPageData" [(aget args "pageName")]))
|
||||
|
||||
(defn- api-list-pages
|
||||
[call-api-fn args]
|
||||
(call-api-fn "logseq.cli.listPages" [#js {:expand (aget args "expand")}]))
|
||||
|
||||
(defn- api-list-tags
|
||||
[call-api-fn args]
|
||||
(call-api-fn "logseq.cli.listTags" [#js {:expand (aget args "expand")}]))
|
||||
|
||||
(defn- api-list-properties
|
||||
[call-api-fn args]
|
||||
(call-api-fn "logseq.cli.listProperties" [#js {:expand (aget args "expand")}]))
|
||||
|
||||
(defn- api-search-blocks
|
||||
[call-api-fn args]
|
||||
(call-api-fn "logseq.app.search" [(aget args "searchTerm") #js {:enable-snippet? false}]))
|
||||
|
||||
(defn- api-upsert-nodes
|
||||
[call-api-fn args]
|
||||
(call-api-fn "logseq.cli.upsertNodes" [(aget args "operations") #js {:dry-run (aget args "dry-run")}]))
|
||||
|
||||
(def ^:large-vars/data-var api-tools
|
||||
"MCP Tools when calling API server"
|
||||
{:listPages
|
||||
{:fn api-list-pages
|
||||
:config #js {:title "List Pages"
|
||||
:description "List all pages in a graph"
|
||||
:inputSchema
|
||||
#js {:expand (-> (z/boolean) .optional (.describe "Provide additional detail on each page"))}}}
|
||||
:getPage
|
||||
{:fn api-get-page
|
||||
:config #js {:title "Get Page"
|
||||
:description "Get a page's content including its blocks. A property and a tag are pages."
|
||||
:inputSchema #js {:pageName (-> (z/string) (.describe "The page's name or uuid"))}}}
|
||||
:upsertNodes
|
||||
{:fn api-upsert-nodes
|
||||
:config
|
||||
#js {:title "Upsert Nodes"
|
||||
:description
|
||||
"This tool must be called at most once per user request. Never re-call it unless explicitly asked.
|
||||
It takes an object with field :operations, which is an array of operation objects.
|
||||
Each operation creates or edits a page, block, tag or property. Each operation is a object
|
||||
that must have :operation, :entityType and :data fields. More about fields in an operation object:
|
||||
* :operation - Either :add or :edit
|
||||
* :entityType - What type of node, e.g. :block, :page, :tag or :property
|
||||
* :id - For :edit, this _must_ be a string uuid. For :add, use a temporary unique string if the new page is referenced by later operations e.g. add blocks
|
||||
* :data - A map of fields to set or update. This map can have the following keys:
|
||||
* :title - A page/tag/property's name or a block's content
|
||||
* :page-id - A page string uuid of a block. Required when adding a block.
|
||||
* :tags - A list of tags as string uuids
|
||||
* :property-type - A property's type
|
||||
* :property-cardinality - A property's cardinality. Must be :one or :many
|
||||
* :property-classes - A property's list of allowed tags, each being a uuid string or a tag's name
|
||||
* :class-extends - List of parent tags, each being a uuid string or a tag's name
|
||||
* :class-properties - A tag's list of properties, each eing a uuid string or a property's name
|
||||
|
||||
Example inputs with their prompt, description and data as clojure EDN:
|
||||
|
||||
Description: This input adds a new block to page with id '119268a6-704f-4e9e-8c34-36dfc6133729' and update the title of a page with uuid '119268a6-704f-4e9e-8c34-36dfc6133729':
|
||||
|
||||
{:operations
|
||||
[{:operation :add
|
||||
:entityType :block
|
||||
:id nil
|
||||
:data {:page-id \"119268a6-704f-4e9e-8c34-36dfc6133729\"
|
||||
:title \"New block text\"}}
|
||||
{:operation :edit
|
||||
:entity :page
|
||||
:id \"119268a6-704f-4e9e-8c34-36dfc6133729\"
|
||||
:data {:title \"Revised page title\"}}]}
|
||||
|
||||
Prompt: Add task 't1' to new page 'Inbox'
|
||||
Description: This input creates a page 'Inbox' and adds a 't1' block with tag \"00000002-1282-1814-5700-000000000000\" (task) to it:
|
||||
|
||||
{:operations
|
||||
[{:operation :add
|
||||
:entityType :page
|
||||
:id \"temp-Inbox\"
|
||||
:data {:title \"Inbox\"}}
|
||||
{:operation :add
|
||||
:entityType :block
|
||||
:data {:page-id \"temp-Inbox\"
|
||||
:title \"t1\"
|
||||
:tags [\"00000002-1282-1814-5700-000000000000\"]}}]}
|
||||
|
||||
Additional advice for building operations:
|
||||
* Before creating any page, tag or property, check that it exists with getPage"
|
||||
:inputSchema
|
||||
#js {:operations
|
||||
(z/array
|
||||
(z/object
|
||||
#js {:operation (z/enum #js ["add" "edit"])
|
||||
:entityType (z/enum #js ["block" "page" "tag" "property"])
|
||||
:id (.optional (z/union #js [(z/string) (z/number) (z/null)]))
|
||||
:data (-> (z/object #js {}) (.passthrough))}))
|
||||
:dry-run (-> (z/boolean) .optional (.describe "Pretend to do batch update. Does everything except actually commit change to db e.g. validation."))}}}
|
||||
:searchBlocks
|
||||
{:fn api-search-blocks
|
||||
:config #js {:title "Search Blocks"
|
||||
:description "Search graph for blocks containing search term"
|
||||
:inputSchema #js {:searchTerm (z/string)}}}
|
||||
:listTags
|
||||
{:fn api-list-tags
|
||||
:config #js {:title "List Tags"
|
||||
:description "List all tags in a graph"
|
||||
:inputSchema
|
||||
#js {:expand (-> (z/boolean) .optional (.describe "Provide additional detail on each tag e.g. their parents (extends) and tag properties"))}}}
|
||||
:listProperties
|
||||
{:fn api-list-properties
|
||||
:config #js {:title "List Properties"
|
||||
:description "List all properties in a graph"
|
||||
:inputSchema
|
||||
#js {:expand (-> (z/boolean) .optional (.describe "Provide additional detail on each property e.g. property type, cardinality"))}}}})
|
||||
|
||||
(defn call-api-tool [tool-fn api-fn args]
|
||||
(tool-fn (partial api-tool api-fn) args))
|
||||
|
||||
;; Server fns
|
||||
;; ==========
|
||||
(defn create-mcp-server []
|
||||
(McpServer. #js {:name "Logseq MCP Server"
|
||||
:version "0.1.0"}))
|
||||
|
||||
(defn create-mcp-api-server [api-fn]
|
||||
(let [mcp-server (create-mcp-server)]
|
||||
(doseq [[k v] api-tools]
|
||||
(.registerTool mcp-server
|
||||
(name k)
|
||||
(:config v)
|
||||
(partial call-api-tool (:fn v) api-fn)))
|
||||
mcp-server))
|
||||
@@ -9,7 +9,6 @@
|
||||
[clojure.string :as string]
|
||||
[electron.configs :as cfgs]
|
||||
[electron.logger :as logger]
|
||||
[electron.mcp-server :as desktop-mcp-server]
|
||||
[electron.utils :as utils]
|
||||
[electron.window :as window]
|
||||
[promesa.core :as p]))
|
||||
@@ -36,8 +35,7 @@
|
||||
:host (get-host)
|
||||
:port (get-port)
|
||||
:tokens (normalize-tokens (cfgs/get-item :server/tokens))
|
||||
:autostart (cfgs/get-item :server/autostart)
|
||||
:mcp-enabled? (cfgs/get-item :server/mcp-enabled?)}))
|
||||
:autostart (cfgs/get-item :server/autostart)}))
|
||||
|
||||
(defn- set-status!
|
||||
([status] (set-status! status nil))
|
||||
@@ -141,18 +139,6 @@
|
||||
(p/catch (fn [^js e]
|
||||
(set-status! :running e))))))
|
||||
|
||||
(defn- initialize-mcp-routes [^js server]
|
||||
(let [api-fn (fn api-fn [meth args]
|
||||
(if-let [meth' (resolve-real-api-method meth)]
|
||||
(invoke-logseq-api! meth' args)
|
||||
#js {:error (str "No method found for " (pr-str meth))}))]
|
||||
(logger/debug "[server] MCP routes initialized")
|
||||
(.post server "/mcp"
|
||||
#(desktop-mcp-server/handle-post-request api-fn {:port (get-port)
|
||||
:host (get-host)} %1 %2))
|
||||
(.get server "/mcp" desktop-mcp-server/handle-get-request)
|
||||
(.delete server "/mcp" desktop-mcp-server/handle-delete-request)))
|
||||
|
||||
(defn start!
|
||||
[]
|
||||
(-> (p/let [_ (close!)
|
||||
@@ -175,8 +161,6 @@
|
||||
(string/replace-first "${PORT}" PORT))]
|
||||
(doto rep (.type "text/html")
|
||||
(.send html))))))
|
||||
_ (when (:mcp-enabled? @*state)
|
||||
(initialize-mcp-routes s))
|
||||
;; listen port
|
||||
_ (.listen s (bean/->js (select-keys @*state [:host :port])))]
|
||||
(reset! *server s)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:i18n-key :electron/link-open-failed-no-graph
|
||||
:i18n-args [graph-identifier]})
|
||||
(send-to-renderer "notification" {:type "error"
|
||||
:payload "Failed to open link. Missing graph identifier after `logseq://graph/`."
|
||||
:payload "Failed to open link. Missing graph identifier after `synapse://graph/`."
|
||||
:i18n-key :electron/link-open-failed-missing-graph})))
|
||||
|
||||
(defn local-url-handler
|
||||
|
||||
@@ -4714,6 +4714,34 @@
|
||||
(on-select! lang e)))
|
||||
(shui/popup-hide!))}))))
|
||||
|
||||
(hsx/defc kernel-run-button
|
||||
[block]
|
||||
(let [[running? set-running!] (hooks/use-state false)]
|
||||
(shui/button
|
||||
{:variant :text
|
||||
:size :sm
|
||||
:disabled running?
|
||||
:on-click
|
||||
(fn [^js e]
|
||||
(util/stop-propagation e)
|
||||
(let [repo (state/get-current-repo)
|
||||
page-uuid (or (:block/uuid (:block/page block)) (:block/uuid block))
|
||||
^js cm (util/get-cm-instance (util/rec-get-node (.-target e) "ls-block"))]
|
||||
(when (and repo page-uuid cm)
|
||||
(set-running! true)
|
||||
(-> (state/<invoke-db-worker :thread-api/kernel-eval!
|
||||
repo page-uuid (:block/uuid block) (.getValue cm))
|
||||
(p/finally #(set-running! false))))))}
|
||||
(ui/icon (if running? "loader-2" "player-play") {:class (when running? "animate-spin")})
|
||||
(t (if running? :editor/running-code :editor/run-code)))))
|
||||
|
||||
(hsx/defc kernel-result
|
||||
[block]
|
||||
(when-let [result (:logseq.property.code/result block)]
|
||||
[:div.kernel-results.mt-1
|
||||
[:code (t :editor/kernel-results)]
|
||||
[:pre.code (str result)]]))
|
||||
|
||||
(hsx/defc src-cp
|
||||
[config options]
|
||||
(let [block (or (:code-block config) (:block config))
|
||||
@@ -4779,11 +4807,15 @@
|
||||
(util/copy-to-clipboard! (.getValue cm))
|
||||
(notification/show! (t :notification/copied) :success)))}
|
||||
(ui/icon "copy")
|
||||
(t :ui/copy))]
|
||||
(t :ui/copy))
|
||||
(when (= language "clojure")
|
||||
(kernel-run-button block))]
|
||||
(lazy-editor/editor config editor-id attr code options)
|
||||
(let [options (:options options) block (:block config)]
|
||||
(when (and (= language "clojure") (contains? (set options) ":results"))
|
||||
(sci/eval-result code block)))]]))))))
|
||||
(sci/eval-result code block)))
|
||||
(when (= language "clojure")
|
||||
(kernel-result block))]]))))))
|
||||
|
||||
(defn ^:large-vars/cleanup-todo markup-element-cp
|
||||
[{:keys [html-export?] :as config} item]
|
||||
|
||||
@@ -241,3 +241,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ls-calendar-grid {
|
||||
@apply grid grid-cols-7 gap-px rounded-md border border-border bg-border overflow-hidden;
|
||||
}
|
||||
|
||||
.ls-calendar-day {
|
||||
@apply flex flex-col gap-1 bg-background p-1 min-h-[80px];
|
||||
}
|
||||
|
||||
.ls-calendar-day-number {
|
||||
@apply text-xs text-muted-foreground;
|
||||
}
|
||||
|
||||
.ls-calendar-day-items {
|
||||
@apply flex flex-col gap-0.5;
|
||||
}
|
||||
|
||||
.ls-calendar-day-item {
|
||||
@apply text-xs truncate rounded bg-secondary px-1 py-0.5;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@
|
||||
|
||||
(defn- valid-view-mode
|
||||
[view-mode]
|
||||
(if (= view-mode :all-pages)
|
||||
:all-pages
|
||||
(case view-mode
|
||||
:all-pages :all-pages
|
||||
:temporal :temporal
|
||||
:tags-and-objects))
|
||||
|
||||
(defn- clamp-number
|
||||
@@ -335,7 +336,13 @@
|
||||
:class "graph-mode-tab"}
|
||||
(view-mode-tab-label
|
||||
(t :graph/view-mode-all-pages)
|
||||
(and loading? (= view-mode :all-pages))))))}))
|
||||
(and loading? (= view-mode :all-pages))))
|
||||
(shui/tabs-trigger
|
||||
{:value "temporal"
|
||||
:class "graph-mode-tab"}
|
||||
(view-mode-tab-label
|
||||
(t :graph/view-mode-temporal)
|
||||
(and loading? (= view-mode :temporal))))))}))
|
||||
|
||||
(defn- tags-group
|
||||
[settings set-settings! available-tags selected-tag-ids tag-query set-tag-query!]
|
||||
@@ -430,17 +437,20 @@
|
||||
link-distance (valid-link-distance (:link-distance settings))
|
||||
grid-layout? (true? (:grid-layout? settings))
|
||||
show-journals? (not (false? (:show-journals? settings)))
|
||||
depth-disabled? (depth-control-disabled? selected-nodes)]
|
||||
depth-disabled? (depth-control-disabled? selected-nodes)
|
||||
temporal? (= view-mode :temporal)]
|
||||
(settings-group
|
||||
{:settings settings
|
||||
:set-settings! set-settings!
|
||||
:id :layout
|
||||
:title (t :graph/layout)
|
||||
:meta (t :graph/layout-force)
|
||||
:meta (if temporal? (t :graph/layout-temporal) (t :graph/layout-force))
|
||||
:children [:div.graph-layout-controls
|
||||
[:div.graph-layout-stats
|
||||
[:span (t :graph/node-count (count (:nodes graph-data)))]
|
||||
[:span (t :graph/link-count (count (:links graph-data)))]]
|
||||
(when-not temporal?
|
||||
[:<>
|
||||
(layout-slider
|
||||
{:label (t :graph/layout-depth)
|
||||
:value depth
|
||||
@@ -457,7 +467,7 @@
|
||||
:max 180
|
||||
:step 6
|
||||
:on-change #(set-settings! (fn [settings]
|
||||
(assoc settings :link-distance (valid-link-distance %))))})
|
||||
(assoc settings :link-distance (valid-link-distance %))))})])
|
||||
(when (= view-mode :tags-and-objects)
|
||||
[:<>
|
||||
(layout-toggle
|
||||
|
||||
44
src/main/frontend/components/live_note.cljs
Normal file
44
src/main/frontend/components/live_note.cljs
Normal file
@@ -0,0 +1,44 @@
|
||||
(ns frontend.components.live-note
|
||||
"Displays fetched content for a page tagged :logseq.class/LiveNote, backed
|
||||
by the :live-note-content render-resource (worker/handler/live_notes.cljs
|
||||
fetches and transacts the content on a refresh schedule; this component
|
||||
only reads it back reactively)."
|
||||
(:require [frontend.context.i18n :refer [t]]
|
||||
[frontend.db.hooks :as db-hooks]
|
||||
[frontend.handler.notification :as notification]
|
||||
[frontend.state :as state]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.hooks :as hooks]))
|
||||
|
||||
(defn- format-timestamp
|
||||
[ms]
|
||||
(when (number? ms)
|
||||
(.toLocaleString (js/Date. ms))))
|
||||
|
||||
(hsx/defc live-note
|
||||
[page-uuid]
|
||||
(let [{:keys [url content last-fetched-at last-error]} (db-hooks/use-resource [:live-note-content page-uuid])
|
||||
[refreshing? set-refreshing!] (hooks/use-state false)]
|
||||
(when url
|
||||
[:div.live-note.fade-in.delay {:key "page-live-note"}
|
||||
[:div.flex.items-center.justify-between.mb-2
|
||||
[:div.text-sm.font-medium.text-muted-foreground (t :live-note/title)]
|
||||
[:button.text-sm.text-muted-foreground.hover:text-foreground
|
||||
{:disabled refreshing?
|
||||
:on-click (fn []
|
||||
(set-refreshing! true)
|
||||
(-> (state/<invoke-db-worker :thread-api/live-note-refresh-now!
|
||||
(state/get-current-repo) page-uuid)
|
||||
(.finally (fn []
|
||||
(set-refreshing! false)
|
||||
(when last-error
|
||||
(notification/show! last-error :error))))))}
|
||||
(if refreshing? (t :live-note/refreshing) (t :live-note/refresh-now))]]
|
||||
(when last-error
|
||||
[:div.text-sm.text-error.mb-2 last-error])
|
||||
(if content
|
||||
[:div.live-note-content.whitespace-pre-wrap.text-sm content]
|
||||
[:div.text-sm.text-muted-foreground (t :live-note/not-fetched-yet)])
|
||||
(when last-fetched-at
|
||||
[:div.text-xs.text-muted-foreground.mt-2
|
||||
(str (t :live-note/last-fetched) " " (format-timestamp last-fetched-at))])])))
|
||||
@@ -15,6 +15,8 @@
|
||||
[frontend.components.recycle :as recycle]
|
||||
[frontend.components.reference :as reference]
|
||||
[frontend.components.scheduled-deadlines :as scheduled]
|
||||
[frontend.components.live-note :as live-note]
|
||||
[frontend.components.similar-pages :as similar-pages]
|
||||
[frontend.config :as config]
|
||||
[frontend.context.i18n :refer [t]]
|
||||
[frontend.date :as date]
|
||||
@@ -513,6 +515,9 @@
|
||||
(when (entity/class? page)
|
||||
(class-component/class-children page))
|
||||
|
||||
(when (some #(= :logseq.class/LiveNote (:db/ident %)) (:block/tags page))
|
||||
(live-note/live-note (:block/uuid page)))
|
||||
|
||||
;; referenced blocks
|
||||
(when (and (not tag-dialog?)
|
||||
(not linked-refs?))
|
||||
@@ -529,7 +534,15 @@
|
||||
home?
|
||||
class-page? property-page?)
|
||||
[:div.fade-in.delay {:key "page-unlinked-references"}
|
||||
(reference/unlinked-references (:block/uuid page) {:sidebar? sidebar?})])])]))
|
||||
(reference/unlinked-references (:block/uuid page) {:sidebar? sidebar?})])
|
||||
|
||||
(when (and (entity/page? page)
|
||||
(not block?)
|
||||
(not sidebar?)
|
||||
(not tag-dialog?)
|
||||
(not home?)
|
||||
(not class-page?) (not property-page?))
|
||||
(similar-pages/similar-pages (:block/uuid page)))])]))
|
||||
[:div.opacity-75 (t :page/not-found)])))
|
||||
|
||||
(defn- page-resource-key
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[frontend.undo-redo.debug-ui :as undo-redo-debug-ui]
|
||||
[frontend.util :as util]
|
||||
[frontend.util.entity :as entity]
|
||||
[logseq.common.config :as common-config]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.ui :as shui]
|
||||
[medley.core :as medley]
|
||||
@@ -170,7 +171,7 @@
|
||||
(db-async/<get-block repo db-id {:children? false
|
||||
:block-metadata? true}))
|
||||
contents-page (when (= :contents (keyword block-type))
|
||||
(db-async/<get-block repo "Contents" {:children? false}))]
|
||||
(db-async/<get-block repo common-config/contents-page-name {:children? false}))]
|
||||
(build-sidebar-item repo idx db-id block-type *db-id init-key entity contents-page))
|
||||
(p/catch (fn [error]
|
||||
(js/console.error error)))))
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
#(js/clearTimeout t))))
|
||||
[])
|
||||
|
||||
(let [{:keys [status error mcp-enabled?]} server-state
|
||||
(let [{:keys [status error]} server-state
|
||||
status (keyword (util/safe-lower-case status))
|
||||
running? (= :running status)
|
||||
href (and running? (str "http://" (:host server-state) ":" (:port server-state)))]
|
||||
@@ -192,14 +192,7 @@
|
||||
(server-status-label status)
|
||||
[:span.flex.flex-col.gap-1.text-xs.font-mono
|
||||
[:a.hover:underline.flex.items-center {:href href}
|
||||
href (shui/tabler-icon "external-link" {:size 12 :class "inline-block ml-1 pt-[1px]"})]
|
||||
(when mcp-enabled?
|
||||
[:a.hover:underline.flex.items-center
|
||||
{:on-click (fn []
|
||||
(util/copy-to-clipboard! (str href "/mcp"))
|
||||
(notification/show! (t :server/mcp-url-copied) :success))}
|
||||
(str href "/mcp")
|
||||
(shui/tabler-icon "copy" {:size 12 :class "inline-block ml-1 mt-[1px]"})])])]]
|
||||
href (shui/tabler-icon "external-link" {:size 12 :class "inline-block ml-1 pt-[1px]"})]])]]
|
||||
(for [{:keys [hr? title options icon]} items]
|
||||
(cond
|
||||
hr?
|
||||
|
||||
@@ -1222,42 +1222,6 @@
|
||||
|
||||
[:<>])
|
||||
|
||||
(hsx/defc mcp-server-row
|
||||
[t]
|
||||
(let [server-state (rfx/use-sub [:electron/server])
|
||||
[checked set-checked!] (hooks/use-state false)]
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(let [initial (:mcp-enabled? server-state)]
|
||||
(set-checked! initial)))
|
||||
[server-state])
|
||||
|
||||
(let [on-toggle (fn []
|
||||
(let [new-val (not checked)]
|
||||
(set-checked! new-val)
|
||||
;; Enable HTTP server to simplify starting MCP
|
||||
(when (and new-val (not (storage/get ::storage-spec/http-server-enabled)))
|
||||
(storage/set ::storage-spec/http-server-enabled true))
|
||||
(-> (ipc/ipc :server/set-config {:mcp-enabled? new-val})
|
||||
;; Don't start server if it's not running
|
||||
(p/then #(when (= "running" (state/get-state [:electron/server :status]))
|
||||
(p/let [_ (p/delay 1000)]
|
||||
(ipc/ipc :server/do :restart))))
|
||||
(p/catch #(notification/show! (str %) :error)))))]
|
||||
(toggle "mcp-server"
|
||||
(t :settings.ai/enable-mcp-server)
|
||||
checked
|
||||
on-toggle
|
||||
[:span.text-sm.opacity-50
|
||||
(t :settings.ai/enable-mcp-server-desc)]))))
|
||||
|
||||
(hsx/defc settings-ai
|
||||
[]
|
||||
[:div.panel-wrap
|
||||
(when (util/electron?)
|
||||
(mcp-server-row t))])
|
||||
|
||||
(hsx/defc ^:large-vars/cleanup-todo settings
|
||||
[_active-tab]
|
||||
(let [current-repo (rfx/use-sub [:git/current-repo])
|
||||
@@ -1270,11 +1234,8 @@
|
||||
(fn []
|
||||
(state/load-app-user-cfgs)
|
||||
(state/load-electron-app-base-info)
|
||||
(let [active-tab (if (and (= _active-tab :ai) (not (util/electron?)))
|
||||
:advanced
|
||||
_active-tab)]
|
||||
(when (keyword? active-tab)
|
||||
(reset! *active [active-tab nil])))
|
||||
(when (keyword? _active-tab)
|
||||
(reset! *active [_active-tab nil]))
|
||||
#(state/close-settings!))
|
||||
[])
|
||||
|
||||
@@ -1291,9 +1252,6 @@
|
||||
[:general (t :settings/general) (ui/icon "adjustments")]
|
||||
[:editor (t :settings/editor) (ui/icon "writing")]
|
||||
[:keymap (t :settings/keymap) (ui/icon "keyboard")]
|
||||
(when (util/electron?)
|
||||
[:ai (t :settings/ai) (ui/icon "wand")])
|
||||
|
||||
[:advanced (t :settings/advanced) (ui/icon "bulb")]
|
||||
[:features (t :settings/features) (ui/icon "app-feature")]
|
||||
|
||||
@@ -1348,9 +1306,4 @@
|
||||
:features
|
||||
(settings-features)
|
||||
|
||||
:ai
|
||||
(if (util/electron?)
|
||||
(settings-ai)
|
||||
(settings-advanced))
|
||||
|
||||
nil)]]]))
|
||||
|
||||
19
src/main/frontend/components/similar_pages.cljs
Normal file
19
src/main/frontend/components/similar_pages.cljs
Normal file
@@ -0,0 +1,19 @@
|
||||
(ns frontend.components.similar-pages
|
||||
"\"Similar pages\" section shown on a page, backed by the vector search
|
||||
index's nearest neighbors to the page's own embedding."
|
||||
(:require [frontend.components.block :as block]
|
||||
[frontend.context.i18n :refer [t]]
|
||||
[frontend.db.hooks :as db-hooks]
|
||||
[io.factorhouse.hsx.core :as hsx]))
|
||||
|
||||
(hsx/defc similar-pages
|
||||
[page-uuid]
|
||||
(let [results (db-hooks/use-resource [:similar-pages page-uuid])]
|
||||
(when (seq results)
|
||||
[:div.similar-pages.fade-in.delay {:key "page-similar-pages"}
|
||||
[:div.text-sm.font-medium.text-muted-foreground.mb-2 (t :page/similar-pages)]
|
||||
[:div.flex.flex-col.gap-1
|
||||
(for [{:block/keys [uuid] :as result} results]
|
||||
^{:key (str uuid)}
|
||||
[:div.similar-page-item
|
||||
(block/page-cp {} result)])]])))
|
||||
@@ -2342,6 +2342,57 @@
|
||||
:outliner? false
|
||||
:view-parent (:logseq.property/view-for (:view-entity table))}))])))
|
||||
|
||||
(defn- calendar-row-date-ms
|
||||
"The date a row sits on in the calendar: whichever of scheduled/deadline is set."
|
||||
[row]
|
||||
(or (:logseq.property/scheduled row) (:logseq.property/deadline row)))
|
||||
|
||||
(defn- calendar-month-bounds
|
||||
[cursor]
|
||||
(let [month-start (t/first-day-of-the-month cursor)
|
||||
month-end (t/plus month-start (t/months 1))]
|
||||
[(tc/to-long month-start) (tc/to-long month-end)]))
|
||||
|
||||
(hsx/defc calendar-view
|
||||
[_option _table _view-entity blocks]
|
||||
(let [[cursor set-cursor!] (hooks/use-state #(t/today))
|
||||
[start-ms end-ms] (calendar-month-bounds cursor)
|
||||
days-in-month (t/number-of-days-in-the-month cursor)
|
||||
page-cp (state/get-component :block/page-cp)
|
||||
by-day (->> blocks
|
||||
(keep (fn [row]
|
||||
(when-let [ms (calendar-row-date-ms row)]
|
||||
(when (and (>= ms start-ms) (< ms end-ms))
|
||||
[(tf/unparse yyyy-MM-dd-formatter (tc/from-long ms)) row]))))
|
||||
(reduce (fn [m [k row]] (update m k (fnil conj []) row)) {}))]
|
||||
[:div.ls-calendar-view
|
||||
[:div.flex.items-center.justify-between.mb-2
|
||||
[:div.font-medium (tf/unparse (tf/formatter "MMMM yyyy") cursor)]
|
||||
[:div.flex.gap-1
|
||||
(shui/button {:variant "ghost" :size :sm
|
||||
:on-click #(set-cursor! (t/minus cursor (t/months 1)))}
|
||||
(ui/icon "chevron-left"))
|
||||
(shui/button {:variant "ghost" :size :sm :on-click #(set-cursor! (t/today))}
|
||||
(t :calendar/today))
|
||||
(shui/button {:variant "ghost" :size :sm
|
||||
:on-click #(set-cursor! (t/plus cursor (t/months 1)))}
|
||||
(ui/icon "chevron-right"))]]
|
||||
[:div.ls-calendar-grid
|
||||
(for [day (range 1 (inc days-in-month))]
|
||||
(let [d (t/date-time (t/year cursor) (t/month cursor) day)
|
||||
k (tf/unparse yyyy-MM-dd-formatter d)
|
||||
rows (get by-day k)]
|
||||
^{:key k}
|
||||
[:div.ls-calendar-day
|
||||
[:div.ls-calendar-day-number (str day)]
|
||||
[:div.ls-calendar-day-items
|
||||
(for [row rows]
|
||||
^{:key (str (:block/uuid row))}
|
||||
[:div.ls-calendar-day-item
|
||||
(if (fn? page-cp)
|
||||
(page-cp {:disable-preview? true} row)
|
||||
(:block/title row))])]]))]]))
|
||||
|
||||
(hsx/defc gallery-view
|
||||
[{:keys [config view-parent view-feature-type] :as option} table view-entity blocks _row-selection *scroller-ref]
|
||||
(let [config' (assoc config :container-id (view-container-id config))
|
||||
@@ -2571,6 +2622,9 @@
|
||||
:logseq.property.view/type.gallery
|
||||
(gallery-view option table view-entity (:rows table) row-selection *scroller-ref)
|
||||
|
||||
:logseq.property.view/type.calendar
|
||||
(calendar-view option table view-entity (:rows table))
|
||||
|
||||
(table-view table option row-selection *scroller-ref))]))
|
||||
|
||||
(defn- view-with-display-type
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
(case view-mode
|
||||
:all-pages :all-pages
|
||||
:page :page
|
||||
:temporal :temporal
|
||||
:tags-and-objects))
|
||||
|
||||
(defn labels-visible-by-default?
|
||||
@@ -1000,6 +1001,26 @@
|
||||
(vec (concat linked-nodes
|
||||
(place-isolated-ring-nodes isolated degree dark? isolated-start-radius)))))
|
||||
|
||||
(def ^:private temporal-lane-count 6)
|
||||
(def ^:private temporal-node-gap 46)
|
||||
(def ^:private temporal-lane-gap 30)
|
||||
|
||||
(defn- temporal-layout-nodes
|
||||
"Lays out nodes along a fixed, linear timeline ordered by :block/created-at
|
||||
(oldest first), instead of running the force simulation. Nodes without a
|
||||
timestamp sort first. Staggered across a few lanes on the y-axis so nodes
|
||||
created close together don't fully overlap."
|
||||
[nodes degree dark?]
|
||||
(->> nodes
|
||||
(sort-by (fn [node] [(or (:block/created-at node) 0) (str (:id node))]))
|
||||
(map-indexed
|
||||
(fn [idx node]
|
||||
(let [lane (mod idx temporal-lane-count)
|
||||
x (* idx temporal-node-gap)
|
||||
y (* lane temporal-lane-gap)]
|
||||
(decorate-node node degree dark? x y))))
|
||||
vec))
|
||||
|
||||
(defn- all-pages-fast-layout-nodes
|
||||
[nodes degree dark?]
|
||||
(let [node-count (count nodes)
|
||||
@@ -1383,8 +1404,17 @@
|
||||
mode (layout-mode (count nodes) view-mode)
|
||||
fast-all-pages? (and (= :fast mode)
|
||||
(= view-mode :all-pages))]
|
||||
(if fast-all-pages?
|
||||
(cond
|
||||
(= view-mode :temporal)
|
||||
(let [node-id-set (set (map :id nodes))
|
||||
filtered-links (keep-links-with-nodes links node-id-set)
|
||||
degree (build-degree-map filtered-links)]
|
||||
(temporal-layout-nodes nodes degree dark?))
|
||||
|
||||
fast-all-pages?
|
||||
(all-pages-fast-layout-nodes nodes (build-fast-degree-map nodes links) dark?)
|
||||
|
||||
:else
|
||||
(let [node-id-set (set (map :id nodes))
|
||||
filtered-links (keep-links-with-nodes links node-id-set)
|
||||
degree (build-degree-map filtered-links)
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
[frontend.worker-common.util :as worker-util]
|
||||
[logseq.common.config :as common-config]
|
||||
[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.frontend.class :as db-class]
|
||||
[logseq.db.frontend.property :as db-property]
|
||||
[logseq.db.frontend.property.build :as db-property-build]
|
||||
[logseq.db.frontend.schema :as db-schema]
|
||||
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
|
||||
[logseq.db.sqlite.util :as sqlite-util]
|
||||
@@ -120,6 +122,33 @@
|
||||
[[:db/retract (:db/id class) :block/order (:block/order class)]]))))
|
||||
[:logseq.class/Comments :logseq.class/Comment]))))
|
||||
|
||||
(defn- link-existing-journals
|
||||
"Backfill :logseq.property.journal/previous and /next across all existing
|
||||
journal pages, ordered by :block/journal-day."
|
||||
[db]
|
||||
(let [journal-pages (->> (d/datoms db :avet :block/journal-day)
|
||||
(map (fn [datom] (d/entity db (:e datom))))
|
||||
(sort-by :block/journal-day))]
|
||||
(->> (partition 2 1 journal-pages)
|
||||
(mapcat (fn [[a b]]
|
||||
[[:db/add (:db/id a) :logseq.property.journal/next (:db/id b)]
|
||||
[:db/add (:db/id b) :logseq.property.journal/previous (:db/id a)]])))))
|
||||
|
||||
(defn- add-calendar-view-type-closed-value
|
||||
"Backfill the new `calendar` closed value onto the existing
|
||||
:logseq.property.view/type property (its own migration ran in an earlier
|
||||
version, so it won't pick up newly-added closed values on its own)."
|
||||
[db]
|
||||
(when (and (d/entity db :logseq.property.view/type)
|
||||
(not (d/entity db :logseq.property.view/type.calendar)))
|
||||
[(db-property-build/build-closed-value-block
|
||||
(common-uuid/gen-uuid :db-ident-block-uuid :logseq.property.view/type.calendar)
|
||||
:default
|
||||
"Calendar View"
|
||||
{:db/ident :logseq.property.view/type}
|
||||
{:db-ident :logseq.property.view/type.calendar
|
||||
:icon {:type :tabler-icon :id "calendar"}})]))
|
||||
|
||||
(def schema-version->updates
|
||||
"A vec of tuples defining datascript migrations. Each tuple consists of the
|
||||
schema version integer and a migration map. A migration map can have keys of :properties, :classes
|
||||
@@ -162,7 +191,28 @@
|
||||
:logseq.property.view/gallery-display-properties
|
||||
:logseq.property.view/gallery-card-size
|
||||
:logseq.property.view/gallery-card-width
|
||||
:logseq.property.view/gallery-card-height]}]])
|
||||
:logseq.property.view/gallery-card-height]}]
|
||||
["65.34" {:properties [:logseq.property.journal/previous :logseq.property.journal/next]
|
||||
:fix link-existing-journals}]
|
||||
["65.35" {:classes [:logseq.class/Project :logseq.class/Area]}]
|
||||
["65.36" {:classes [:logseq.class/Citation]
|
||||
:properties [:logseq.property.citation/target :logseq.property.citation/context]}]
|
||||
["65.37" {:classes [:logseq.class/Event :logseq.class/Contact]
|
||||
:properties [:logseq.property.event/location
|
||||
:logseq.property.contact/email
|
||||
:logseq.property.contact/phone
|
||||
:logseq.property.contact/organization
|
||||
:logseq.property.caldav/uid
|
||||
:logseq.property.caldav/href
|
||||
:logseq.property.caldav/etag]
|
||||
:fix add-calendar-view-type-closed-value}]
|
||||
["65.38" {:properties [:logseq.property.code/result :logseq.property.code/executed-at]}]
|
||||
["65.39" {:classes [:logseq.class/LiveNote]
|
||||
:properties [:logseq.property.livenote/url
|
||||
:logseq.property.livenote/refresh-minutes
|
||||
:logseq.property.livenote/content
|
||||
:logseq.property.livenote/last-fetched-at
|
||||
:logseq.property.livenote/last-error]}]])
|
||||
|
||||
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
|
||||
schema-version->updates)))]
|
||||
@@ -185,7 +235,7 @@
|
||||
(= "logseq.kv" (some-> (:db/ident data) namespace))
|
||||
nil
|
||||
|
||||
(= (:block/title data) "Contents")
|
||||
(= (:block/title data) common-config/contents-page-name)
|
||||
nil
|
||||
|
||||
(:file/path data)
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
[frontend.worker.db.fix :as db-fix]
|
||||
[frontend.worker.db.migrate :as db-migrate]
|
||||
[frontend.worker.db.validate :as worker-db-validate]
|
||||
[frontend.worker.handler.caldav :as caldav-handler]
|
||||
[frontend.worker.handler.cli]
|
||||
[frontend.worker.handler.comments]
|
||||
[frontend.worker.handler.export]
|
||||
[frontend.worker.handler.flashcard]
|
||||
[frontend.worker.handler.graph]
|
||||
[frontend.worker.handler.kernel :as kernel-handler]
|
||||
[frontend.worker.handler.live-notes :as live-notes-handler]
|
||||
[frontend.worker.handler.markdown]
|
||||
[frontend.worker.handler.maintenance]
|
||||
[frontend.worker.handler.page]
|
||||
@@ -24,6 +27,7 @@
|
||||
[frontend.worker.handler.query]
|
||||
[frontend.worker.handler.render-resource.engine]
|
||||
[frontend.worker.handler.search :as search-handler]
|
||||
[frontend.worker.handler.semantic]
|
||||
[frontend.worker.handler.transaction :as transaction-handler]
|
||||
[frontend.worker.handler.undo-redo]
|
||||
[frontend.worker.handler.view]
|
||||
@@ -363,6 +367,9 @@
|
||||
(when-let [timer (get @*client-ops-cleanup-timers repo)]
|
||||
(js/clearInterval timer))
|
||||
(swap! *client-ops-cleanup-timers dissoc repo)
|
||||
(caldav-handler/stop-sync! repo)
|
||||
(kernel-handler/clear-repo-sessions! repo)
|
||||
(live-notes-handler/stop-refresh! repo)
|
||||
(swap! *sqlite-conns dissoc repo)
|
||||
(when-let [vector-index (worker-state/get-vector-index repo)]
|
||||
(when-let [close-fn (:close! vector-index)]
|
||||
@@ -647,6 +654,9 @@
|
||||
|
||||
(db-listener/listen-db-changes! repo conn)
|
||||
|
||||
(caldav-handler/maybe-start-sync! repo)
|
||||
(live-notes-handler/maybe-start-refresh! repo)
|
||||
|
||||
nil))))))
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
[frontend.worker.db-worker-node-auth :as auth]
|
||||
[frontend.worker.db-worker-node-lock :as db-lock]
|
||||
[frontend.worker.db-worker-node-ws :as db-ws]
|
||||
[frontend.worker.mcp-server :as mcp-server]
|
||||
[frontend.worker.platform.node :as platform-node]
|
||||
[frontend.worker.semantic-routes :as semantic-routes]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
@@ -432,6 +434,52 @@
|
||||
[^js buf]
|
||||
(str "\"" (-> (crypto/createHash "sha1") (.update buf) (.digest "hex")) "\""))
|
||||
|
||||
(defn- query-params-map
|
||||
"Ring/bidi-style :page-id params come from semantic-routes' reitit router
|
||||
already keyword-keyed; the query string doesn't, so build it the same way
|
||||
by hand from URLSearchParams -- there's no js->clj for it."
|
||||
[^js parsed-url]
|
||||
(into {}
|
||||
(map (fn [pair] [(keyword (aget pair 0)) (aget pair 1)]))
|
||||
(js/Array.from (.entries (.-searchParams parsed-url)))))
|
||||
|
||||
(defn- <read-json-body-if-present
|
||||
[req method]
|
||||
(if (contains? #{"GET" "DELETE"} method)
|
||||
(p/resolved nil)
|
||||
(p/let [body (<read-body req)]
|
||||
(when-not (string/blank? body)
|
||||
(js->clj (js/JSON.parse body) :keywordize-keys true)))))
|
||||
|
||||
(defn- semantic-request-handler
|
||||
"/v1/semantic/* -- unlike /v1/invoke (an internal RPC transport that hands
|
||||
transit-encoded results straight back to Logseq's own clients, which
|
||||
already know how to decode them), this is the public HTTP API documented
|
||||
by semantic-routes/openapi-document and consumed by arbitrary HTTP/MCP
|
||||
clients, so results are decoded server-side into plain JSON matching the
|
||||
OpenAPI schemas -- the same shape the /v1/assets/:repo/:name GET route
|
||||
already produces for the one other route with external HTTP consumers."
|
||||
[^js req ^js res bound-repo proxy parsed-url request-path method]
|
||||
(if-let [{:keys [handler success-status path-params]} (semantic-routes/match method request-path)]
|
||||
(let [query-params (query-params-map parsed-url)
|
||||
method-kw :thread-api/semantic-request
|
||||
method-str (normalize-method-str method-kw)]
|
||||
(-> (p/let [body (if (= handler :semantic/assets-create)
|
||||
(<read-body-buffer req)
|
||||
(<read-json-body-if-present req method))
|
||||
args [bound-repo handler path-params query-params body]]
|
||||
(if-let [{:keys [status error]} (repo-error method-kw args bound-repo)]
|
||||
(send-json! res status {:ok false :error error})
|
||||
(p/let [_ (let [{:keys [path lock]} @*lock-info]
|
||||
(db-lock/assert-lock-owner! path lock))
|
||||
result-transit (<invoke! proxy method-str method-kw args)
|
||||
result (ldb/read-transit-str result-transit)]
|
||||
(if (nil? result)
|
||||
(send-no-content! res)
|
||||
(send-json! res success-status result)))))
|
||||
(p/catch (fn [error] (log-invoke-error! res error handler)))))
|
||||
(send-json! res 404 {:ok false :error {:code :not-found :message "not found"}})))
|
||||
|
||||
(defn- <process-batch-invoke!
|
||||
"One entry of a /v1/invoke-batch request, run through the same
|
||||
repo-check/lock/invoke path as /v1/invoke but never rejecting -- a failed
|
||||
@@ -623,6 +671,21 @@
|
||||
(log-invoke-error! res error :thread-api/asset-write-bytes))))
|
||||
(send-json! res 400 {:ok false :error {:code :invalid-request :message "invalid asset path"}}))
|
||||
|
||||
(= request-path "/v1/semantic/openapi.json")
|
||||
(send-json! res 200 (semantic-routes/openapi-document {:repo bound-repo}))
|
||||
|
||||
(string/starts-with? request-path "/v1/semantic/")
|
||||
(semantic-request-handler req res bound-repo proxy parsed-url request-path method)
|
||||
|
||||
(and (= request-path "/mcp") (= method "POST"))
|
||||
(mcp-server/handle-post-request {:host host :port (effective-port port)} req res)
|
||||
|
||||
(and (= request-path "/mcp") (= method "GET"))
|
||||
(mcp-server/handle-get-request req res)
|
||||
|
||||
(and (= request-path "/mcp") (= method "DELETE"))
|
||||
(mcp-server/handle-delete-request req res)
|
||||
|
||||
(= request-path "/v1/invoke-batch")
|
||||
(if (= method "POST")
|
||||
(-> (p/let [body (<read-body req)
|
||||
|
||||
@@ -48,8 +48,9 @@
|
||||
|
||||
(defn- normalize-view-mode
|
||||
[view-mode]
|
||||
(if (= view-mode :all-pages)
|
||||
:all-pages
|
||||
(case view-mode
|
||||
:all-pages :all-pages
|
||||
:temporal :temporal
|
||||
:tags-and-objects))
|
||||
|
||||
(def ^:private large-all-pages-fast-threshold 10000)
|
||||
@@ -923,7 +924,11 @@
|
||||
result (case view-mode
|
||||
:tags-and-objects
|
||||
(build-tags-and-objects-graph db)
|
||||
:all-pages
|
||||
(:all-pages :temporal)
|
||||
;; :temporal reuses the same node/link shape as :all-pages
|
||||
;; (every node already carries :block/created-at); only the
|
||||
;; client-side layout differs, arranging nodes on a fixed
|
||||
;; timeline instead of running the force simulation.
|
||||
(build-all-pages-graph db opts))]
|
||||
(assoc result :meta {:view-mode view-mode})))
|
||||
|
||||
|
||||
576
src/main/frontend/worker/handler/caldav.cljs
Normal file
576
src/main/frontend/worker/handler/caldav.cljs
Normal file
@@ -0,0 +1,576 @@
|
||||
(ns frontend.worker.handler.caldav
|
||||
"Nextcloud CalDAV (events, tasks) and CardDAV (contacts) sync.
|
||||
|
||||
Deliberately scoped, not a full RFC 4791/6352 client:
|
||||
* one calendar collection + one addressbook collection per graph
|
||||
* basic auth (Nextcloud app password), no OAuth
|
||||
* VEVENT/VTODO/VCARD only, no VALARM/VTIMEZONE/recurrence overrides
|
||||
* dates are read/written in UTC only
|
||||
* RRULE support covers a single FREQ=DAILY|WEEKLY|MONTHLY|YEARLY(;INTERVAL=n)
|
||||
rule, matching only the `plus` (advance-from-scheduled) repeat mode --
|
||||
`dotted-plus` (advance-from-completion) and `double-plus` (skip-to-future)
|
||||
depend on runtime state RRULE can't express, so those tasks are pushed
|
||||
without a recurrence rule and incoming events always come back as `plus`.
|
||||
* conflict handling is last-write-wins via ETag: a 412 on push is skipped
|
||||
for that cycle and naturally retried after the next pull refreshes the
|
||||
stored ETag -- there is no merge UI."
|
||||
(:require [clojure.string :as string]
|
||||
[datascript.core :as d]
|
||||
[frontend.common.thread-api :refer [def-thread-api]]
|
||||
[frontend.worker.handler.page :as page-handler]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.db :as ldb]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Credentials
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- credential-secret-key
|
||||
[repo]
|
||||
(str "caldav:" repo))
|
||||
|
||||
(defn <save-credentials!
|
||||
[repo config]
|
||||
(platform/save-secret-text! (platform/current) (credential-secret-key repo)
|
||||
(js/JSON.stringify (clj->js config))))
|
||||
|
||||
(defn <read-credentials
|
||||
[repo]
|
||||
(p/let [text (platform/read-secret-text (platform/current) (credential-secret-key repo))]
|
||||
(when text
|
||||
(js->clj (js/JSON.parse text) :keywordize-keys true))))
|
||||
|
||||
(defn <delete-credentials!
|
||||
[repo]
|
||||
(platform/delete-secret-text! (platform/current) (credential-secret-key repo)))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; RRULE <-> :logseq.property.repeat/* translation (pure)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(def ^:private recur-unit->rrule-freq
|
||||
{:logseq.property.repeat/recur-unit.day "DAILY"
|
||||
:logseq.property.repeat/recur-unit.week "WEEKLY"
|
||||
:logseq.property.repeat/recur-unit.month "MONTHLY"
|
||||
:logseq.property.repeat/recur-unit.year "YEARLY"})
|
||||
|
||||
(def ^:private rrule-freq->recur-unit
|
||||
(into {} (map (fn [[k v]] [v k])) recur-unit->rrule-freq))
|
||||
|
||||
(defn repeat->rrule
|
||||
"Only the `plus` (advance-from-scheduled) repeat type maps onto RRULE;
|
||||
anything else (or an unsupported unit) returns nil, meaning: push this
|
||||
task/event without a recurrence rule rather than mistranslate it."
|
||||
[{:keys [repeat-type recur-unit recur-frequency]}]
|
||||
(when (and (= repeat-type :logseq.property.repeat/repeat-type.plus)
|
||||
(contains? recur-unit->rrule-freq recur-unit))
|
||||
(let [freq (get recur-unit->rrule-freq recur-unit)
|
||||
interval (or recur-frequency 1)]
|
||||
(if (> interval 1)
|
||||
(str "FREQ=" freq ";INTERVAL=" interval)
|
||||
(str "FREQ=" freq)))))
|
||||
|
||||
(defn rrule->repeat
|
||||
"Only a bare single FREQ (+ optional INTERVAL) is understood. Anything else
|
||||
(BYDAY, COUNT, UNTIL, multiple rules...) returns nil so the caller leaves
|
||||
the incoming event/task non-recurring instead of silently mistranslating it."
|
||||
[rrule-value]
|
||||
(when (string? rrule-value)
|
||||
(let [parts (into {}
|
||||
(keep (fn [part]
|
||||
(let [[k v] (string/split part #"=" 2)]
|
||||
(when (and k v) [k v]))))
|
||||
(string/split rrule-value #";"))
|
||||
freq (get parts "FREQ")]
|
||||
(when (and freq
|
||||
(contains? rrule-freq->recur-unit freq)
|
||||
(every? #{"FREQ" "INTERVAL"} (keys parts)))
|
||||
{:repeat-type :logseq.property.repeat/repeat-type.plus
|
||||
:recur-unit (get rrule-freq->recur-unit freq)
|
||||
:recur-frequency (some-> (get parts "INTERVAL") parse-long)}))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; iCalendar / vCard text encoding (pure)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- ical-escape
|
||||
[s]
|
||||
(-> (str s)
|
||||
(string/replace "\\" "\\\\")
|
||||
(string/replace ";" "\\;")
|
||||
(string/replace "," "\\,")
|
||||
(string/replace "\n" "\\n")))
|
||||
|
||||
(defn- ical-unescape
|
||||
[s]
|
||||
(-> (str s)
|
||||
(string/replace "\\n" "\n")
|
||||
(string/replace "\\," ",")
|
||||
(string/replace "\\;" ";")
|
||||
(string/replace "\\\\" "\\")))
|
||||
|
||||
(defn- epoch-ms->ical-utc
|
||||
[ms]
|
||||
(when (number? ms)
|
||||
(-> (js/Date. ms)
|
||||
.toISOString
|
||||
(string/replace #"[-:]" "")
|
||||
(string/replace #"\.\d+Z$" "Z"))))
|
||||
|
||||
(defn- ical-utc->epoch-ms
|
||||
"\"YYYYMMDDTHHMMSSZ\" -> epoch ms. Anything else (local time, dates without
|
||||
a time component, non-UTC) is out of scope and returns nil."
|
||||
[value]
|
||||
(when (and (string? value) (re-matches #"\d{8}T\d{6}Z" value))
|
||||
(let [y (subs value 0 4) mo (subs value 4 6) d (subs value 6 8)
|
||||
h (subs value 9 11) mi (subs value 11 13) s (subs value 13 15)]
|
||||
(.getTime (js/Date. (str y "-" mo "-" d "T" h ":" mi ":" s "Z"))))))
|
||||
|
||||
(defn- ical-lines
|
||||
[& lines]
|
||||
(->> lines flatten (remove nil?) (string/join "\r\n")))
|
||||
|
||||
(defn block->vevent
|
||||
[{:keys [uid summary description location dtstart dtend rrule]}]
|
||||
(ical-lines
|
||||
"BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//LogDarioSeq//EN"
|
||||
"BEGIN:VEVENT"
|
||||
(str "UID:" uid)
|
||||
(str "SUMMARY:" (ical-escape summary))
|
||||
(when dtstart (str "DTSTART:" (epoch-ms->ical-utc dtstart)))
|
||||
(when dtend (str "DTEND:" (epoch-ms->ical-utc dtend)))
|
||||
(when rrule (str "RRULE:" rrule))
|
||||
(when description (str "DESCRIPTION:" (ical-escape description)))
|
||||
(when location (str "LOCATION:" (ical-escape location)))
|
||||
"END:VEVENT" "END:VCALENDAR"))
|
||||
|
||||
(def ^:private status->ical-vtodo-status
|
||||
{:logseq.property/status.done "COMPLETED"
|
||||
:logseq.property/status.canceled "CANCELLED"
|
||||
:logseq.property/status.doing "IN-PROCESS"})
|
||||
|
||||
(defn block->vtodo
|
||||
[{:keys [uid summary description dtstart due rrule status-ident]}]
|
||||
(ical-lines
|
||||
"BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//LogDarioSeq//EN"
|
||||
"BEGIN:VTODO"
|
||||
(str "UID:" uid)
|
||||
(str "SUMMARY:" (ical-escape summary))
|
||||
(when dtstart (str "DTSTART:" (epoch-ms->ical-utc dtstart)))
|
||||
(when due (str "DUE:" (epoch-ms->ical-utc due)))
|
||||
(when rrule (str "RRULE:" rrule))
|
||||
(str "STATUS:" (get status->ical-vtodo-status status-ident "NEEDS-ACTION"))
|
||||
(when description (str "DESCRIPTION:" (ical-escape description)))
|
||||
"END:VTODO" "END:VCALENDAR"))
|
||||
|
||||
(defn contact->vcard
|
||||
[{:keys [uid full-name email phone organization]}]
|
||||
(ical-lines
|
||||
"BEGIN:VCARD" "VERSION:3.0"
|
||||
(str "UID:" uid)
|
||||
(str "FN:" (ical-escape full-name))
|
||||
(when email (str "EMAIL:" email))
|
||||
(when phone (str "TEL:" phone))
|
||||
(when organization (str "ORG:" (ical-escape organization)))
|
||||
"END:VCARD"))
|
||||
|
||||
(defn- unfold-ical-lines
|
||||
[text]
|
||||
(-> (str text)
|
||||
(string/replace "\r\n" "\n")
|
||||
(string/replace #"\n[ \t]" "")
|
||||
(string/split #"\n")))
|
||||
|
||||
(def ^:private ical-line-re #"([A-Za-z0-9-]+)(;[^:]*)?:(.*)")
|
||||
|
||||
(defn parse-ical-component
|
||||
"Parses the first VEVENT or VTODO in an iCalendar text blob into a flat map.
|
||||
Deliberately minimal: understands only the property lines this namespace
|
||||
itself writes, ignores VALARM/VTIMEZONE/any other component or parameter."
|
||||
[text]
|
||||
(let [lines (unfold-ical-lines text)
|
||||
component-type (some #(second (re-matches #"BEGIN:(VEVENT|VTODO)" %)) lines)]
|
||||
(when component-type
|
||||
(reduce
|
||||
(fn [m line]
|
||||
(if-let [[_ k _params v] (re-matches ical-line-re line)]
|
||||
(case k
|
||||
"UID" (assoc m :uid v)
|
||||
"SUMMARY" (assoc m :summary (ical-unescape v))
|
||||
"DESCRIPTION" (assoc m :description (ical-unescape v))
|
||||
"LOCATION" (assoc m :location (ical-unescape v))
|
||||
"DTSTART" (assoc m :dtstart (ical-utc->epoch-ms v))
|
||||
"DTEND" (assoc m :dtend (ical-utc->epoch-ms v))
|
||||
"DUE" (assoc m :due (ical-utc->epoch-ms v))
|
||||
"RRULE" (assoc m :rrule v)
|
||||
"STATUS" (assoc m :status v)
|
||||
m)
|
||||
m))
|
||||
{:type component-type}
|
||||
lines))))
|
||||
|
||||
(defn parse-vcard
|
||||
[text]
|
||||
(let [lines (unfold-ical-lines text)]
|
||||
(when (some #{"BEGIN:VCARD"} lines)
|
||||
(reduce
|
||||
(fn [m line]
|
||||
(if-let [[_ k _params v] (re-matches ical-line-re line)]
|
||||
(case k
|
||||
"UID" (assoc m :uid v)
|
||||
"FN" (assoc m :full-name (ical-unescape v))
|
||||
"EMAIL" (assoc m :email v)
|
||||
"TEL" (assoc m :phone v)
|
||||
"ORG" (assoc m :organization (ical-unescape v))
|
||||
m)
|
||||
m))
|
||||
{}
|
||||
lines))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Minimal multistatus XML extraction (pure, namespace-prefix agnostic)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- xml-unescape
|
||||
[s]
|
||||
(-> (str s)
|
||||
(string/replace #"&#x([0-9a-fA-F]+);" (fn [[_ hex]] (js/String.fromCharCode (js/parseInt hex 16))))
|
||||
(string/replace #"&#(\d+);" (fn [[_ dec]] (js/String.fromCharCode (js/parseInt dec 10))))
|
||||
(string/replace "<" "<")
|
||||
(string/replace ">" ">")
|
||||
(string/replace """ "\"")
|
||||
(string/replace "'" "'")
|
||||
(string/replace "&" "&")))
|
||||
|
||||
(defn- xml-tag-values
|
||||
[tag xml]
|
||||
(->> (re-seq (re-pattern (str "<(?:[A-Za-z0-9]+:)?" tag "[^>]*>([\\s\\S]*?)</(?:[A-Za-z0-9]+:)?" tag ">"))
|
||||
xml)
|
||||
(map (comp xml-unescape second))))
|
||||
|
||||
(defn parse-multistatus
|
||||
"Extracts {:href :etag :data} per <response> in a CalDAV/CardDAV multistatus
|
||||
body. `data-tag` is \"calendar-data\" or \"address-data\"."
|
||||
[data-tag multistatus-xml]
|
||||
(->> (xml-tag-values "response" multistatus-xml)
|
||||
(keep (fn [response-xml]
|
||||
(let [href (first (xml-tag-values "href" response-xml))
|
||||
etag (first (xml-tag-values "getetag" response-xml))
|
||||
data (first (xml-tag-values data-tag response-xml))]
|
||||
(when (and href data)
|
||||
{:href href :etag etag :data data}))))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; HTTP (Node runtime only; injectable fetch-fn for testing)
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- join-url
|
||||
[base path]
|
||||
(str (string/replace base #"/$" "") "/" (string/replace path #"^/" "")))
|
||||
|
||||
(defn- basic-auth-header
|
||||
[{:keys [username app-password]}]
|
||||
(str "Basic " (js/btoa (str username ":" app-password))))
|
||||
|
||||
(defn- <dav-request
|
||||
[fetch-fn config path {:keys [method headers body]}]
|
||||
(-> (fetch-fn (join-url (:base-url config) path)
|
||||
(clj->js (merge {:method (or method "GET")
|
||||
:headers (merge {"Authorization" (basic-auth-header config)}
|
||||
headers)}
|
||||
(when body {:body body}))))
|
||||
(p/then (fn [resp]
|
||||
(p/let [text (.text resp)]
|
||||
{:status (.-status resp)
|
||||
:etag (some-> (.-headers resp) (.get "ETag"))
|
||||
:body text})))))
|
||||
|
||||
(def ^:private events-query-body
|
||||
(ical-lines
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
|
||||
"<d:prop><d:getetag/><c:calendar-data/></d:prop>"
|
||||
"<c:filter><c:comp-filter name=\"VCALENDAR\"><c:comp-filter name=\"VEVENT\"/></c:comp-filter></c:filter>"
|
||||
"</c:calendar-query>"))
|
||||
|
||||
(def ^:private tasks-query-body
|
||||
(ical-lines
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
|
||||
"<d:prop><d:getetag/><c:calendar-data/></d:prop>"
|
||||
"<c:filter><c:comp-filter name=\"VCALENDAR\"><c:comp-filter name=\"VTODO\"/></c:comp-filter></c:filter>"
|
||||
"</c:calendar-query>"))
|
||||
|
||||
(def ^:private contacts-query-body
|
||||
(ical-lines
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<c:addressbook-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:carddav\">"
|
||||
"<d:prop><d:getetag/><c:address-data/></d:prop>"
|
||||
"</c:addressbook-query>"))
|
||||
|
||||
(defn- <report
|
||||
[fetch-fn config path body data-tag]
|
||||
(-> (<dav-request fetch-fn config path
|
||||
{:method "REPORT"
|
||||
:headers {"Content-Type" "application/xml; charset=utf-8" "Depth" "1"}
|
||||
:body body})
|
||||
(p/then (fn [{:keys [status body]}]
|
||||
(if (#{207} status)
|
||||
(parse-multistatus data-tag body)
|
||||
(do (log/warn :caldav/report-failed {:path path :status status})
|
||||
[]))))))
|
||||
|
||||
(defn- <put!
|
||||
[fetch-fn config href text content-type etag]
|
||||
(<dav-request fetch-fn config href
|
||||
{:method "PUT"
|
||||
:headers (cond-> {"Content-Type" content-type}
|
||||
etag (assoc "If-Match" etag))
|
||||
:body text}))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Pull: upsert graph pages from remote components
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- find-page-by-caldav-uid
|
||||
[db uid]
|
||||
(when-let [eid (d/q '[:find ?e . :in $ ?uid
|
||||
:where [?e :logseq.property.caldav/uid ?uid]]
|
||||
db uid)]
|
||||
(d/entity db eid)))
|
||||
|
||||
(defn- clean-properties
|
||||
[m]
|
||||
(into {} (remove (comp nil? val)) m))
|
||||
|
||||
(defn- caldav-diff-value
|
||||
"Compares a datascript entity's current attribute value against an incoming
|
||||
value that may be a `[:db/ident kw]` lookup ref (closed-value properties
|
||||
like status), unwrapping both sides to their ident before comparing."
|
||||
[existing-val new-val]
|
||||
(let [existing-ident (:db/ident existing-val)
|
||||
new-ident (when (and (vector? new-val) (= :db/ident (first new-val)))
|
||||
(second new-val))]
|
||||
(if (or existing-ident new-ident)
|
||||
(not= existing-ident new-ident)
|
||||
(not= existing-val new-val))))
|
||||
|
||||
(defn- upsert-page!
|
||||
[conn tag-ident title properties]
|
||||
(let [db @conn
|
||||
uid (:logseq.property.caldav/uid properties)
|
||||
existing (find-page-by-caldav-uid db uid)]
|
||||
(if existing
|
||||
(let [tx (->> properties
|
||||
(keep (fn [[k v]]
|
||||
(when (caldav-diff-value (get existing k) v)
|
||||
[:db/add (:db/id existing) k v]))))]
|
||||
(when (seq tx) (ldb/transact! conn tx))
|
||||
(:block/uuid existing))
|
||||
(let [[_title page-uuid] (page-handler/create! conn title
|
||||
{:tags [(d/entity db [:db/ident tag-ident])]
|
||||
:properties (clean-properties properties)})]
|
||||
page-uuid))))
|
||||
|
||||
(defn- <pull-events!
|
||||
[conn fetch-fn config]
|
||||
(p/let [items (<report fetch-fn config (:calendar-path config) events-query-body "calendar-data")]
|
||||
(doseq [{:keys [href etag data]} items]
|
||||
(when-let [{:keys [uid summary dtstart dtend]} (parse-ical-component data)]
|
||||
(upsert-page! conn :logseq.class/Event (or summary uid)
|
||||
{:logseq.property.caldav/uid uid
|
||||
:logseq.property.caldav/href href
|
||||
:logseq.property.caldav/etag etag
|
||||
:logseq.property/scheduled dtstart
|
||||
:logseq.property/deadline dtend})))
|
||||
(count items)))
|
||||
|
||||
(def ^:private vtodo-status->status-ident
|
||||
{"COMPLETED" [:db/ident :logseq.property/status.done]
|
||||
"CANCELLED" [:db/ident :logseq.property/status.canceled]
|
||||
"IN-PROCESS" [:db/ident :logseq.property/status.doing]})
|
||||
|
||||
(defn- <pull-tasks!
|
||||
[conn fetch-fn config]
|
||||
(p/let [items (<report fetch-fn config (:calendar-path config) tasks-query-body "calendar-data")]
|
||||
(doseq [{:keys [href etag data]} items]
|
||||
(when-let [{:keys [uid summary dtstart due status]} (parse-ical-component data)]
|
||||
(upsert-page! conn :logseq.class/Task (or summary uid)
|
||||
(cond-> {:logseq.property.caldav/uid uid
|
||||
:logseq.property.caldav/href href
|
||||
:logseq.property.caldav/etag etag
|
||||
:logseq.property/scheduled dtstart
|
||||
:logseq.property/deadline due}
|
||||
(get vtodo-status->status-ident status)
|
||||
(assoc :logseq.property/status (get vtodo-status->status-ident status))))))
|
||||
(count items)))
|
||||
|
||||
(defn- <pull-contacts!
|
||||
[conn fetch-fn config]
|
||||
(p/let [items (<report fetch-fn config (:addressbook-path config) contacts-query-body "address-data")]
|
||||
(doseq [{:keys [href etag data]} items]
|
||||
(when-let [{:keys [uid full-name email phone organization]} (parse-vcard data)]
|
||||
(upsert-page! conn :logseq.class/Contact (or full-name uid)
|
||||
{:logseq.property.caldav/uid uid
|
||||
:logseq.property.caldav/href href
|
||||
:logseq.property.caldav/etag etag
|
||||
:logseq.property.contact/email email
|
||||
:logseq.property.contact/phone phone
|
||||
:logseq.property.contact/organization organization})))
|
||||
(count items)))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Push: send local changes for graph pages that carry (or need) a caldav uid
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(defn- pushable-pages
|
||||
[db tag-ident]
|
||||
(->> (d/datoms db :avet :block/tags tag-ident)
|
||||
(map (fn [datom] (d/entity db (:e datom))))
|
||||
(remove #(or (:logseq.property/deleted-at %) (ldb/hidden? %)))))
|
||||
|
||||
(defn- ensure-uid!
|
||||
[conn page]
|
||||
(or (:logseq.property.caldav/uid page)
|
||||
(let [uid (str (random-uuid))]
|
||||
(ldb/transact! conn [[:db/add (:db/id page) :logseq.property.caldav/uid uid]])
|
||||
uid)))
|
||||
|
||||
(defn- <push-page!
|
||||
[conn fetch-fn config collection-path page ->text content-type]
|
||||
(p/let [uid (ensure-uid! conn page)
|
||||
href (or (:logseq.property.caldav/href page) (join-url collection-path (str uid ".ics")))
|
||||
text (->text (assoc page :uid uid))
|
||||
{:keys [status etag]} (<put! fetch-fn config href text content-type
|
||||
(:logseq.property.caldav/etag page))]
|
||||
(cond
|
||||
(#{200 201 204} status)
|
||||
(ldb/transact! conn (remove nil?
|
||||
[[:db/add (:db/id page) :logseq.property.caldav/href href]
|
||||
(when etag [:db/add (:db/id page) :logseq.property.caldav/etag etag])]))
|
||||
|
||||
(= 412 status)
|
||||
(log/info :caldav/push-conflict {:href href :page-uuid (:block/uuid page)})
|
||||
|
||||
:else
|
||||
(log/warn :caldav/push-failed {:href href :status status}))))
|
||||
|
||||
(defn- event-page->vevent-input
|
||||
[page]
|
||||
{:summary (:block/title page)
|
||||
:location (:logseq.property.event/location page)
|
||||
:dtstart (:logseq.property/scheduled page)
|
||||
:dtend (:logseq.property/deadline page)})
|
||||
|
||||
(defn- task-page->vtodo-input
|
||||
[page]
|
||||
{:summary (:block/title page)
|
||||
:dtstart (:logseq.property/scheduled page)
|
||||
:due (:logseq.property/deadline page)
|
||||
:rrule (repeat->rrule {:repeat-type (:db/ident (:logseq.property.repeat/repeat-type page))
|
||||
:recur-unit (:db/ident (:logseq.property.repeat/recur-unit page))
|
||||
:recur-frequency (:logseq.property.repeat/recur-frequency page)})
|
||||
:status-ident (:db/ident (:logseq.property/status page))})
|
||||
|
||||
(defn- contact-page->vcard-input
|
||||
[page]
|
||||
{:full-name (:block/title page)
|
||||
:email (:logseq.property.contact/email page)
|
||||
:phone (:logseq.property.contact/phone page)
|
||||
:organization (:logseq.property.contact/organization page)})
|
||||
|
||||
(defn- <push-all!
|
||||
[conn fetch-fn config]
|
||||
(let [db @conn]
|
||||
(p/all
|
||||
(concat
|
||||
(map #(<push-page! conn fetch-fn config (:calendar-path config) %
|
||||
(comp block->vevent event-page->vevent-input) "text/calendar; charset=utf-8")
|
||||
(pushable-pages db :logseq.class/Event))
|
||||
(map #(<push-page! conn fetch-fn config (:calendar-path config) %
|
||||
(comp block->vtodo task-page->vtodo-input) "text/calendar; charset=utf-8")
|
||||
(pushable-pages db :logseq.class/Task))
|
||||
(map #(<push-page! conn fetch-fn config (:addressbook-path config) %
|
||||
(comp contact->vcard contact-page->vcard-input) "text/vcard; charset=utf-8")
|
||||
(pushable-pages db :logseq.class/Contact))))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Orchestration
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(def ^:private sync-interval-ms (* 15 60 1000))
|
||||
(defonce ^:private *sync-intervals (atom {}))
|
||||
(defonce ^:private *sync-status (atom {}))
|
||||
|
||||
(defn- <sync!
|
||||
([repo] (<sync! repo js/fetch))
|
||||
([repo fetch-fn]
|
||||
(p/let [config (<read-credentials repo)]
|
||||
(if-not config
|
||||
(p/resolved {:status :not-connected})
|
||||
(let [conn (worker-state/get-datascript-conn repo)]
|
||||
(-> (p/let [events (<pull-events! conn fetch-fn config)
|
||||
tasks (<pull-tasks! conn fetch-fn config)
|
||||
contacts (<pull-contacts! conn fetch-fn config)
|
||||
_ (<push-all! conn fetch-fn config)]
|
||||
(swap! *sync-status assoc repo {:last-sync-at (js/Date.now()) :last-sync-error nil})
|
||||
{:status :ok :events events :tasks tasks :contacts contacts})
|
||||
(p/catch (fn [error]
|
||||
(swap! *sync-status assoc repo {:last-sync-at (js/Date.now())
|
||||
:last-sync-error (str error)})
|
||||
(log/error :caldav/sync-failed {:repo repo :error (str error)})
|
||||
{:status :error :error (str error)}))))))))
|
||||
|
||||
(defn- node-runtime?
|
||||
[]
|
||||
(= :node (platform/env-flag (platform/current) :runtime)))
|
||||
|
||||
(defn stop-sync!
|
||||
[repo]
|
||||
(when-let [interval-id (get @*sync-intervals repo)]
|
||||
(js/clearInterval interval-id))
|
||||
(swap! *sync-intervals dissoc repo))
|
||||
|
||||
(defn maybe-start-sync!
|
||||
"Starts the periodic CalDAV/CardDAV sync for `repo` if credentials are
|
||||
configured. Node-only: the daemon is the only runtime with outbound
|
||||
fetch and keychain access."
|
||||
[repo]
|
||||
(when (node-runtime?)
|
||||
(-> (<read-credentials repo)
|
||||
(p/then (fn [config]
|
||||
(when config
|
||||
(stop-sync! repo)
|
||||
(<sync! repo)
|
||||
(swap! *sync-intervals assoc repo
|
||||
(js/setInterval #(<sync! repo) sync-interval-ms)))))
|
||||
(p/catch (fn [error]
|
||||
(log/error :caldav/maybe-start-sync-failed {:repo repo :error (str error)}))))))
|
||||
|
||||
(def-thread-api :thread-api/caldav-connect!
|
||||
[repo config]
|
||||
(p/let [_ (<save-credentials! repo config)]
|
||||
(maybe-start-sync! repo)
|
||||
{:status :connected}))
|
||||
|
||||
(def-thread-api :thread-api/caldav-disconnect!
|
||||
[repo]
|
||||
(stop-sync! repo)
|
||||
(swap! *sync-status dissoc repo)
|
||||
(p/let [_ (<delete-credentials! repo)]
|
||||
{:status :disconnected}))
|
||||
|
||||
(def-thread-api :thread-api/caldav-status
|
||||
[repo]
|
||||
(p/let [config (<read-credentials repo)]
|
||||
(merge {:connected? (some? config)
|
||||
:base-url (:base-url config)
|
||||
:username (:username config)}
|
||||
(get @*sync-status repo))))
|
||||
|
||||
(def-thread-api :thread-api/caldav-sync-now!
|
||||
[repo]
|
||||
(<sync! repo))
|
||||
99
src/main/frontend/worker/handler/kernel.cljs
Normal file
99
src/main/frontend/worker/handler/kernel.cljs
Normal file
@@ -0,0 +1,99 @@
|
||||
(ns frontend.worker.handler.kernel
|
||||
"A stateful SCI kernel for executable code blocks: one interpreter context
|
||||
per page, reused across block evaluations on that page so `def`s and
|
||||
other side effects persist between runs -- unlike the plain, stateless
|
||||
per-call eval in frontend.extensions.sci that the block editor already
|
||||
does for inline `:results` display.
|
||||
|
||||
Isolation boundary, chosen deliberately instead of guessed at: this does
|
||||
NOT expand what code can do beyond what SCI already exposed to the
|
||||
client-side single-shot evaluator -- no shell/process execution, no
|
||||
filesystem access, no arbitrary JS interop beyond the small curated
|
||||
`user` namespace below (a strict subset of frontend.extensions.sci's,
|
||||
with the browser-only `call-api` binding dropped). Running the kernel in
|
||||
the daemon rather than the browser tab is what makes results consistent
|
||||
across clients and lets a session survive a page staying open elsewhere;
|
||||
it is not a new sandbox. A real subprocess-based sandbox (per the plan's
|
||||
own \"definir el limite antes de implementar\") is future work, not
|
||||
something to fake here."
|
||||
(:require [datascript.core :as d]
|
||||
[frontend.common.thread-api :refer [def-thread-api]]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.db :as ldb]
|
||||
[sci.core :as sci]))
|
||||
|
||||
(def ^:private sum (partial apply +))
|
||||
|
||||
(defn- average
|
||||
[coll]
|
||||
(/ (reduce + coll) (count coll)))
|
||||
|
||||
(def ^:private default-user-namespace
|
||||
{'sum sum
|
||||
'average average
|
||||
'parseFloat js/parseFloat
|
||||
'isNaN js/isNaN
|
||||
'log js/console.log
|
||||
'pprint pr-str})
|
||||
|
||||
(defonce ^:private *sessions (atom {}))
|
||||
|
||||
(defn- session-key
|
||||
[repo page-uuid]
|
||||
[repo page-uuid])
|
||||
|
||||
(defn- get-or-create-session!
|
||||
[repo page-uuid]
|
||||
(let [k (session-key repo page-uuid)]
|
||||
(or (get @*sessions k)
|
||||
(let [ctx (sci/init {:namespaces {'user default-user-namespace}})]
|
||||
(swap! *sessions assoc k ctx)
|
||||
ctx))))
|
||||
|
||||
(defn restart-session!
|
||||
[repo page-uuid]
|
||||
(swap! *sessions dissoc (session-key repo page-uuid)))
|
||||
|
||||
(defn clear-repo-sessions!
|
||||
[repo]
|
||||
(swap! *sessions
|
||||
(fn [sessions]
|
||||
(into {} (remove (fn [[[r _page-uuid] _ctx]] (= r repo))) sessions))))
|
||||
|
||||
(defn- eval-code
|
||||
[ctx code]
|
||||
(try
|
||||
{:value (sci/eval-string* ctx code)}
|
||||
(catch :default e
|
||||
{:error (or (.-message e) (str e))})))
|
||||
|
||||
(defn eval-block!
|
||||
"Evaluates `code` in the persistent kernel session for `page-uuid`, then
|
||||
persists the formatted result onto `block-uuid` as
|
||||
:logseq.property.code/result (prefixed with \"Error: \" on failure)."
|
||||
[repo page-uuid block-uuid code]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(let [ctx (get-or-create-session! repo page-uuid)
|
||||
{:keys [value error]} (eval-code ctx code)
|
||||
result-text (if error (str "Error: " error) (pr-str value))
|
||||
block (d/entity @conn [:block/uuid block-uuid])]
|
||||
(if block
|
||||
(do
|
||||
(ldb/transact! conn
|
||||
[[:db/add (:db/id block) :logseq.property.code/result result-text]
|
||||
[:db/add (:db/id block) :logseq.property.code/executed-at (js/Date.now())]])
|
||||
{:result result-text :error? (boolean error)})
|
||||
(do
|
||||
(log/warn :kernel/eval-missing-block {:repo repo :block-uuid block-uuid})
|
||||
{:result nil :error? true})))
|
||||
{:result nil :error? true}))
|
||||
|
||||
(def-thread-api :thread-api/kernel-eval!
|
||||
[repo page-uuid block-uuid code]
|
||||
(eval-block! repo page-uuid block-uuid code))
|
||||
|
||||
(def-thread-api :thread-api/kernel-restart!
|
||||
[repo page-uuid]
|
||||
(restart-session! repo page-uuid)
|
||||
{:status :restarted})
|
||||
149
src/main/frontend/worker/handler/live_notes.cljs
Normal file
149
src/main/frontend/worker/handler/live_notes.cljs
Normal file
@@ -0,0 +1,149 @@
|
||||
(ns frontend.worker.handler.live-notes
|
||||
"Live notes: pages tagged :logseq.class/LiveNote whose content is fetched
|
||||
from an external URL on a refresh schedule.
|
||||
|
||||
Native, not a plugin: the plugin API's fenced-code-renderer + exper_request
|
||||
pattern only runs in Electron and has no refresh scheduler or secure
|
||||
credential store. This follows the render-resource guidance instead --
|
||||
a background poller `transact!`s fetched content into the graph as a
|
||||
normal property, and the existing render-resource/affected-keys pipeline
|
||||
(see render_resource/live_note.cljs) picks up the change and pushes it to
|
||||
clients with zero bespoke plumbing on the read side.
|
||||
|
||||
Node-only, like search embeddings and CalDAV: outbound fetch and the
|
||||
platform keychain only exist in the daemon runtime."
|
||||
(:require [datascript.core :as d]
|
||||
[frontend.common.thread-api :refer [def-thread-api]]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.db :as ldb]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private default-refresh-minutes 60)
|
||||
(def ^:private min-refresh-minutes 5)
|
||||
(def ^:private max-content-length 8000)
|
||||
(def ^:private tick-interval-ms (* 5 60 1000))
|
||||
|
||||
(defn- node-runtime?
|
||||
[]
|
||||
(= :node (platform/env-flag (platform/current) :runtime)))
|
||||
|
||||
(defn- auth-secret-key
|
||||
[repo block-uuid]
|
||||
(str "live-note:" repo ":" block-uuid))
|
||||
|
||||
(defn <save-auth!
|
||||
[repo block-uuid auth-header]
|
||||
(platform/save-secret-text! (platform/current) (auth-secret-key repo block-uuid) auth-header))
|
||||
|
||||
(defn <read-auth
|
||||
[repo block-uuid]
|
||||
(platform/read-secret-text (platform/current) (auth-secret-key repo block-uuid)))
|
||||
|
||||
(defn <delete-auth!
|
||||
[repo block-uuid]
|
||||
(platform/delete-secret-text! (platform/current) (auth-secret-key repo block-uuid)))
|
||||
|
||||
(defn effective-refresh-minutes
|
||||
[refresh-minutes]
|
||||
(max min-refresh-minutes (or refresh-minutes default-refresh-minutes)))
|
||||
|
||||
(defn due?
|
||||
"Whether a live note is due for a refresh: never fetched, or its own
|
||||
refresh interval has elapsed since the last fetch."
|
||||
[now-ms last-fetched-at refresh-minutes]
|
||||
(or (not (number? last-fetched-at))
|
||||
(>= (- now-ms last-fetched-at)
|
||||
(* (effective-refresh-minutes refresh-minutes) 60 1000))))
|
||||
|
||||
(defn truncate-content
|
||||
[text]
|
||||
(when (string? text)
|
||||
(if (> (count text) max-content-length)
|
||||
(str (subs text 0 max-content-length) "\n...(truncated)")
|
||||
text)))
|
||||
|
||||
(defn- <fetch-content!
|
||||
[fetch-fn url auth-header]
|
||||
(-> (fetch-fn url (clj->js (cond-> {:method "GET"}
|
||||
auth-header (assoc :headers {"Authorization" auth-header}))))
|
||||
(p/then (fn [resp]
|
||||
(if (.-ok resp)
|
||||
(.text resp)
|
||||
(p/rejected (ex-info (str "HTTP " (.-status resp)) {:status (.-status resp)})))))))
|
||||
|
||||
(defn- <refresh-page!
|
||||
[conn fetch-fn repo page]
|
||||
(let [url (:logseq.property.livenote/url page)]
|
||||
(-> (p/let [auth-header (<read-auth repo (:block/uuid page))
|
||||
text (<fetch-content! fetch-fn url auth-header)]
|
||||
(ldb/transact! conn
|
||||
[[:db/add (:db/id page) :logseq.property.livenote/content (truncate-content text)]
|
||||
[:db/add (:db/id page) :logseq.property.livenote/last-fetched-at (js/Date.now())]
|
||||
[:db/retract (:db/id page) :logseq.property.livenote/last-error]]))
|
||||
(p/catch (fn [error]
|
||||
(log/warn :live-notes/refresh-failed
|
||||
{:page-uuid (str (:block/uuid page)) :error (str error)})
|
||||
(ldb/transact! conn
|
||||
[[:db/add (:db/id page) :logseq.property.livenote/last-fetched-at (js/Date.now())]
|
||||
[:db/add (:db/id page) :logseq.property.livenote/last-error (str error)]]))))))
|
||||
|
||||
(defn- live-note-pages
|
||||
"Guarded on the class existing: an empty graph (skips built-in initial
|
||||
data) or a graph created before this migration step has no
|
||||
:logseq.class/LiveNote ident, and an :avet lookup on a non-existent ident
|
||||
throws rather than returning no results."
|
||||
[db]
|
||||
(when (d/entity db :logseq.class/LiveNote)
|
||||
(->> (d/datoms db :avet :block/tags :logseq.class/LiveNote)
|
||||
(map (fn [datom] (d/entity db (:e datom))))
|
||||
(remove #(or (:logseq.property/deleted-at %) (ldb/hidden? %)))
|
||||
(filter :logseq.property.livenote/url))))
|
||||
|
||||
(defn- due-pages
|
||||
[now-ms pages]
|
||||
(filter (fn [page]
|
||||
(due? now-ms
|
||||
(:logseq.property.livenote/last-fetched-at page)
|
||||
(:logseq.property.livenote/refresh-minutes page)))
|
||||
pages))
|
||||
|
||||
(defn <tick!
|
||||
([repo] (<tick! repo js/fetch))
|
||||
([repo fetch-fn]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(let [due (due-pages (js/Date.now()) (live-note-pages @conn))]
|
||||
(p/all (map #(<refresh-page! conn fetch-fn repo %) due)))
|
||||
(p/resolved []))))
|
||||
|
||||
(defonce ^:private *intervals (atom {}))
|
||||
|
||||
(defn stop-refresh!
|
||||
[repo]
|
||||
(when-let [interval-id (get @*intervals repo)]
|
||||
(js/clearInterval interval-id))
|
||||
(swap! *intervals dissoc repo))
|
||||
|
||||
(defn maybe-start-refresh!
|
||||
[repo]
|
||||
(when (node-runtime?)
|
||||
(stop-refresh! repo)
|
||||
(<tick! repo)
|
||||
(swap! *intervals assoc repo (js/setInterval #(<tick! repo) tick-interval-ms))))
|
||||
|
||||
(def-thread-api :thread-api/live-note-refresh-now!
|
||||
[repo block-uuid]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(if-let [page (d/entity @conn [:block/uuid block-uuid])]
|
||||
(p/let [_ (<refresh-page! conn js/fetch repo page)]
|
||||
{:status :ok})
|
||||
(p/resolved {:status :not-found}))
|
||||
(p/resolved {:status :not-found})))
|
||||
|
||||
(def-thread-api :thread-api/live-note-set-auth!
|
||||
[repo block-uuid auth-header]
|
||||
(p/let [_ (if (seq auth-header)
|
||||
(<save-auth! repo block-uuid auth-header)
|
||||
(<delete-auth! repo block-uuid))]
|
||||
{:status :ok}))
|
||||
@@ -4,8 +4,10 @@
|
||||
[frontend.worker.handler.block :as block-handler]
|
||||
[frontend.worker.handler.render-resource.basic :as basic]
|
||||
[frontend.worker.handler.render-resource.common :as common]
|
||||
[frontend.worker.handler.render-resource.live-note :as live-note]
|
||||
[frontend.worker.handler.render-resource.property :as property]
|
||||
[frontend.worker.handler.render-resource.query :as query]
|
||||
[frontend.worker.handler.render-resource.similar :as similar]
|
||||
[frontend.worker.handler.render-resource.view :as view]
|
||||
[frontend.worker.state :as worker-state]))
|
||||
|
||||
@@ -14,6 +16,8 @@
|
||||
property/resource-renderers
|
||||
view/resource-renderers
|
||||
query/resource-renderers
|
||||
similar/resource-renderers
|
||||
live-note/resource-renderers
|
||||
{:block-sync-conflicts
|
||||
(common/renderer nil
|
||||
(fn [_db resource-key _runtime]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
(ns frontend.worker.handler.render-resource.live-note
|
||||
"Live note content renderer resource. The content itself is fetched and
|
||||
transacted by frontend.worker.handler.live-notes; this renderer only
|
||||
reads it back out and declares the watch key that makes it reactive --
|
||||
see render_affected_keys.cljs's generic per-entity [:entity uuid] key,
|
||||
emitted whenever any of that entity's attributes change in a tx."
|
||||
(:require [frontend.worker.handler.render-resource.common :as common]))
|
||||
|
||||
(defn- live-note-content
|
||||
[db resource-key _runtime]
|
||||
(let [block-uuid (second resource-key)
|
||||
page (common/entity-by-uuid! db :block-uuid block-uuid)]
|
||||
[#{[:entity block-uuid]}
|
||||
{:url (:logseq.property.livenote/url page)
|
||||
:content (:logseq.property.livenote/content page)
|
||||
:last-fetched-at (:logseq.property.livenote/last-fetched-at page)
|
||||
:last-error (:logseq.property.livenote/last-error page)}]))
|
||||
|
||||
(def resource-renderers
|
||||
{:live-note-content (common/renderer 2 live-note-content)})
|
||||
@@ -0,0 +1,22 @@
|
||||
(ns frontend.worker.handler.render-resource.similar
|
||||
"\"Similar to this\" renderer resource, backed by the vector search index."
|
||||
(:require [frontend.worker.handler.render-resource.common :as common]
|
||||
[frontend.worker.search :as search]
|
||||
[frontend.worker.state :as worker-state]))
|
||||
|
||||
(def ^:private max-similar-pages 5)
|
||||
|
||||
(defn- similar-pages
|
||||
[db resource-key {:keys [repo]}]
|
||||
(let [block-uuid (second resource-key)]
|
||||
(common/entity-by-uuid! db :block-uuid block-uuid)
|
||||
(let [vector-index (worker-state/get-vector-index repo)
|
||||
results (when vector-index
|
||||
(search/similar-blocks (atom db) vector-index block-uuid
|
||||
{:limit max-similar-pages}))]
|
||||
[#{} (mapv #(select-keys % [:db/id :block/uuid :block/title
|
||||
:block.temp/unique-title :page? :block/page])
|
||||
(or results []))])))
|
||||
|
||||
(def resource-renderers
|
||||
{:similar-pages (common/renderer 2 similar-pages)})
|
||||
@@ -286,6 +286,11 @@
|
||||
[repo q option]
|
||||
(<search-blocks repo q option))
|
||||
|
||||
(def-thread-api :thread-api/search-similar-blocks
|
||||
[repo block-uuid option]
|
||||
(when-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(search/similar-blocks conn (worker-state/get-vector-index repo) block-uuid option)))
|
||||
|
||||
(def-thread-api :thread-api/search-upsert-blocks
|
||||
[repo blocks]
|
||||
(when-let [db (get-search-db repo)]
|
||||
|
||||
945
src/main/frontend/worker/handler/semantic.cljs
Normal file
945
src/main/frontend/worker/handler/semantic.cljs
Normal file
@@ -0,0 +1,945 @@
|
||||
(ns frontend.worker.handler.semantic
|
||||
"Semantic API operations (Fase 9), ported from
|
||||
deps/db-sync/src/logseq/db_sync/worker/handler/semantic.cljs. That version
|
||||
opened a DataScript conn over a Cloudflare Durable Object's own SQLite and
|
||||
broadcast changes to WS clients explicitly; the daemon already has the
|
||||
authoritative conn for its bound repo (worker.state/get-datascript-conn)
|
||||
and any transact! against it is picked up by the conn listener installed
|
||||
in worker.db-listener, which already broadcasts to every connected client
|
||||
-- so unlike the CF-Worker version, nothing here calls broadcast
|
||||
explicitly. Bodies arrive here already JSON-parsed by the HTTP layer (see
|
||||
db_worker_node.cljs's semantic-request branch), so there is no async body
|
||||
read either: every operation below is synchronous, matching how DataScript
|
||||
itself is synchronous.
|
||||
|
||||
Registers a single :thread-api/semantic-request, dispatched over by
|
||||
handler keyword (the same handler keywords semantic-routes/match
|
||||
resolves), rather than one def-thread-api per operation -- the CF-Worker
|
||||
original used one big `case` in the same way, and callers already go
|
||||
through the route table to get the handler keyword."
|
||||
(:require [clojure.string :as string]
|
||||
[datascript.core :as d]
|
||||
[frontend.common.thread-api :refer [def-thread-api]]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[logseq.common.util :as common-util]
|
||||
[logseq.common.util.date-time :as date-time-util]
|
||||
[logseq.common.util.page-ref :as page-ref]
|
||||
[logseq.db :as ldb]
|
||||
[logseq.db.frontend.asset :as db-asset]
|
||||
[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.type :as db-property-type]
|
||||
[logseq.outliner.core :as outliner-core]
|
||||
[logseq.outliner.page :as outliner-page]
|
||||
[logseq.outliner.property :as outliner-property]
|
||||
[logseq.outliner.tree :as otree]))
|
||||
|
||||
(def ^:private default-limit 50)
|
||||
(def ^:private max-limit 200)
|
||||
(def ^:private max-asset-size (* 100 1024 1024))
|
||||
|
||||
(defn- api-error
|
||||
([status code message] (api-error status code message nil))
|
||||
([status code message data]
|
||||
(ex-info message (merge {:status status :code code} data))))
|
||||
|
||||
(defn- bad-request [message] (throw (api-error 400 :bad-request message)))
|
||||
(defn- not-found [] (throw (api-error 404 :not-found "not found")))
|
||||
(defn- api-error! [message status] (throw (api-error status :semantic-error message)))
|
||||
|
||||
(defn- uuid-string [value]
|
||||
(when value (str value)))
|
||||
|
||||
(defn- page? [entity]
|
||||
(or (ldb/internal-page? entity) (ldb/journal? entity)))
|
||||
|
||||
(defn- asset? [entity]
|
||||
(string? (:logseq.property.asset/type entity)))
|
||||
|
||||
(defn- entity-ident [entity]
|
||||
(when-let [ident (:db/ident entity)]
|
||||
(str (namespace ident) "/" (name ident))))
|
||||
|
||||
(defn- tag? [entity]
|
||||
(ldb/class? entity))
|
||||
|
||||
(defn- property? [entity]
|
||||
(ldb/property? entity))
|
||||
|
||||
(defn- block-kind [entity]
|
||||
(cond
|
||||
(asset? entity) "asset"
|
||||
(property? entity) "property"
|
||||
(tag? entity) "tag"
|
||||
(page? entity) "page"
|
||||
:else "block"))
|
||||
|
||||
(defn- block-response [block]
|
||||
(cond-> {: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- asset-file-name [entity]
|
||||
(str (:block/uuid entity) "." (:logseq.property.asset/type 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 [{:keys [limit]}]
|
||||
(let [parsed (when limit (js/parseInt limit 10))]
|
||||
(cond
|
||||
(nil? limit) 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 [query-params]
|
||||
{:limit (parse-limit query-params)
|
||||
:raw-cursor (:cursor query-params)
|
||||
:cursor (decode-cursor (:cursor query-params))})
|
||||
|
||||
(defn- parse-timestamp [raw]
|
||||
(cond
|
||||
(nil? raw) nil
|
||||
(not (re-matches #"\d+" raw)) ::invalid
|
||||
:else (js/parseInt raw 10)))
|
||||
|
||||
(defn- time-filter-options [query-params]
|
||||
(let [created-after (parse-timestamp (:created-after query-params))
|
||||
updated-after (parse-timestamp (:updated-after query-params))]
|
||||
{: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
|
||||
([query-params response-key items]
|
||||
(paginated-response query-params response-key items identity))
|
||||
([query-params response-key items response-fn]
|
||||
(paginated-response query-params response-key items response-fn paginate))
|
||||
([query-params response-key items response-fn paginate-fn]
|
||||
(let [{:keys [limit raw-cursor cursor]} (pagination-options query-params)
|
||||
time-filters (time-filter-options query-params)]
|
||||
(cond
|
||||
(nil? limit) (bad-request "invalid limit")
|
||||
(and raw-cursor (nil? cursor)) (bad-request "invalid cursor")
|
||||
(:invalid? time-filters) (bad-request "invalid time filter")
|
||||
:else (let [items (filter #(after-time-filters? % time-filters) items)
|
||||
{:keys [items next-cursor]} (paginate-fn items limit cursor)]
|
||||
(cond-> {response-key (mapv response-fn items)}
|
||||
next-cursor (assoc :next-cursor next-cursor)))))))
|
||||
|
||||
(defn- indexed-reference-response
|
||||
([db query-params response-key attribute target-id]
|
||||
(indexed-reference-response db query-params response-key attribute target-id {}))
|
||||
([db query-params 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 query-params)
|
||||
time-filters (time-filter-options query-params)
|
||||
cursor-e (when (= "entity-id" (first cursor))
|
||||
(js/parseInt (second cursor) 10))]
|
||||
(cond
|
||||
(nil? limit) (bad-request "invalid limit")
|
||||
(and raw-cursor (or (nil? cursor) (js/isNaN cursor-e))) (bad-request "invalid cursor")
|
||||
(:invalid? time-filters) (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)]
|
||||
(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)))
|
||||
|
||||
(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-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?))))
|
||||
|
||||
(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 query-params]
|
||||
(let [{:keys [limit raw-cursor cursor]} (pagination-options query-params)
|
||||
time-filters (time-filter-options query-params)]
|
||||
(cond
|
||||
(nil? limit) (bad-request "invalid limit")
|
||||
(and raw-cursor (nil? cursor)) (bad-request "invalid cursor")
|
||||
(:invalid? time-filters) (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)]
|
||||
(cond-> {:blocks (mapv #(page-block-tree db %) roots)}
|
||||
next-cursor (assoc :next-cursor next-cursor))))))
|
||||
|
||||
(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 [conn db handler path-params query-params body]}]
|
||||
(case handler
|
||||
:semantic/pages-list
|
||||
(paginated-response query-params :blocks (list-pages db) block-response)
|
||||
|
||||
:semantic/pages-create
|
||||
(if-not (seq (:title body))
|
||||
(bad-request "missing title")
|
||||
(let [[title page-id] (outliner-page/create! conn (:title body) {})]
|
||||
{:uuid (str page-id) :kind "page" :title title}))
|
||||
|
||||
:semantic/pages-blocks
|
||||
(if-let [page (find-visible-entity db (:page-id path-params))]
|
||||
(if-not (page? page)
|
||||
(bad-request "block is not a page")
|
||||
(paginated-page-blocks-response db page query-params))
|
||||
(not-found))
|
||||
|
||||
:semantic/pages-references
|
||||
(if-let [page (find-visible-entity db (:page-id path-params))]
|
||||
(if (page? page)
|
||||
(indexed-reference-response db query-params :references :block/refs (:db/id page))
|
||||
(not-found))
|
||||
(not-found))
|
||||
|
||||
:semantic/pages-get
|
||||
(if-let [page (find-visible-entity db (:page-id path-params))]
|
||||
(if (page? page) (block-response page) (not-found))
|
||||
(not-found))
|
||||
|
||||
:semantic/pages-update
|
||||
(let [page (find-entity db (:page-id path-params))]
|
||||
(if (or (not (page? page)) (not (seq (:title body))))
|
||||
(bad-request "invalid page-id or title")
|
||||
(do
|
||||
(outliner-core/save-block! conn {:block/uuid (:block/uuid page) :block/title (:title body)})
|
||||
(assoc (block-response page) :title (:title body)))))
|
||||
|
||||
:semantic/pages-delete
|
||||
(if-let [page (find-entity db (:page-id path-params))]
|
||||
(if-not (page? page)
|
||||
(bad-request "block is not a page")
|
||||
(do (outliner-page/delete! conn (:block/uuid page)) nil))
|
||||
(not-found))))
|
||||
|
||||
(defn- handle-blocks [{:keys [conn db handler path-params body]}]
|
||||
(case handler
|
||||
|
||||
:semantic/blocks-get
|
||||
(if-let [block (find-visible-entity db (:block-id path-params))]
|
||||
(block-response block)
|
||||
(not-found))
|
||||
|
||||
:semantic/blocks-update
|
||||
(let [block (find-entity db (:block-id path-params))]
|
||||
(if (or (nil? block) (not (seq (:title body))))
|
||||
(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)))
|
||||
{: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] {}))
|
||||
nil)
|
||||
(not-found))
|
||||
|
||||
:semantic/blocks-move
|
||||
(let [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))))
|
||||
(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)
|
||||
{:uuids (mapv #(str (:block/uuid %)) blocks) :moved true})))
|
||||
|
||||
:semantic/blocks-insert-children
|
||||
(let [target (find-entity db (:block-id path-params))]
|
||||
(if (or (nil? target) (not (contains? #{"append" "prepend"} (:position body)))
|
||||
(not (seq (:blocks body))))
|
||||
(bad-request "invalid target, position, or blocks")
|
||||
{:blocks (insert-tree! conn target (:blocks body) (:position body))}))
|
||||
|
||||
:semantic/blocks-insert-tree
|
||||
(let [target (find-entity db (:target-id body))]
|
||||
(if (or (nil? target) (not (seq (:blocks body))))
|
||||
(bad-request "invalid target or blocks")
|
||||
{:blocks (insert-tree! conn target (:blocks body) (or (:position body) "append"))}))))
|
||||
|
||||
(defn- handle-block-properties [{:keys [conn db handler path-params body]}]
|
||||
(case handler
|
||||
|
||||
:semantic/blocks-set-property
|
||||
(let [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) (bad-request "block not found")
|
||||
(nil? property-ident) (bad-request "property not found")
|
||||
(not (contains? body :value)) (bad-request "missing property value")
|
||||
(:error prepared) (bad-request (:error prepared))
|
||||
:else
|
||||
(do (set-prepared-property! conn (:db/id block) property-ident (:value prepared) true)
|
||||
{: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))
|
||||
(bad-request "invalid block or property")
|
||||
(do
|
||||
(outliner-property/remove-block-property! conn (:db/id block) property-ident)
|
||||
nil)))
|
||||
|
||||
:semantic/blocks-batch-set-property
|
||||
(let [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))
|
||||
(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))))))
|
||||
{:updated (count resolved)})))
|
||||
|
||||
:semantic/blocks-batch-delete-property
|
||||
(let [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))
|
||||
(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))))
|
||||
{:deleted (count resolved)})))))
|
||||
|
||||
(defn- handle-capture-and-tags [{:keys [conn db handler path-params query-params body]}]
|
||||
(case handler
|
||||
|
||||
:semantic/capture
|
||||
(if-not (seq (:blocks body))
|
||||
(bad-request "missing blocks")
|
||||
(let [today (ensure-today-page! conn)
|
||||
inserted (insert-tree! conn today (:blocks body) "append")]
|
||||
{:page-id (str (:block/uuid today)) :blocks inserted}))
|
||||
|
||||
:semantic/tags-list
|
||||
(paginated-response query-params :tags (entities-by-avet db :block/tags :logseq.class/Tag) tag-response)
|
||||
|
||||
:semantic/tags-create
|
||||
(if-not (seq (:title body))
|
||||
(bad-request "missing title")
|
||||
(let [[title tag-id] (outliner-page/create! conn (:title body) {:class? true})]
|
||||
{:uuid (str tag-id) :title title}))
|
||||
|
||||
:semantic/tags-get
|
||||
(if-let [tag (find-visible-entity db (:tag-id path-params))]
|
||||
(if (tag? tag) (tag-response tag) (not-found))
|
||||
(not-found))
|
||||
|
||||
:semantic/tags-objects
|
||||
(if-let [tag (find-visible-entity db (:tag-id path-params))]
|
||||
(if (tag? tag)
|
||||
(indexed-reference-response db query-params :objects :block/tags (:db/id tag))
|
||||
(not-found))
|
||||
(not-found))
|
||||
|
||||
:semantic/tags-update
|
||||
(let [tag (find-entity db (:tag-id path-params))]
|
||||
(if (or (not (tag? tag)) (not (seq (:title body))))
|
||||
(bad-request "invalid tag-id or title")
|
||||
(do
|
||||
(outliner-core/save-block! conn {:block/uuid (:block/uuid tag) :block/title (:title body)})
|
||||
(assoc (tag-response tag) :title (:title body)))))
|
||||
|
||||
:semantic/tags-delete
|
||||
(if-let [tag (find-entity db (:tag-id path-params))]
|
||||
(if-not (tag? tag)
|
||||
(not-found)
|
||||
(do (outliner-page/delete! conn (:block/uuid tag)) nil))
|
||||
(not-found))))
|
||||
|
||||
(defn- handle-tasks [{:keys [conn db handler query-params body]}]
|
||||
(case handler
|
||||
:semantic/tasks-list
|
||||
(let [status (:status query-params)
|
||||
priority (:priority query-params)
|
||||
content (some-> (:content query-params) 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)) (bad-request "invalid task status")
|
||||
(and priority (nil? priority-choice)) (bad-request "invalid task priority")
|
||||
:else
|
||||
(indexed-reference-response
|
||||
db query-params :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
|
||||
(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))) (bad-request "missing task title")
|
||||
(nil? status-choice) (bad-request "invalid task status")
|
||||
(and priority (nil? priority-choice)) (bad-request "invalid task priority")
|
||||
(and (:page-id body) (not (page? target))) (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)))))
|
||||
(task-response (d/entity @conn [:block/uuid @task-id])))))))
|
||||
|
||||
(defn- handle-assets
|
||||
[{:keys [repo conn db handler path-params query-params body]}]
|
||||
(case handler
|
||||
:semantic/assets-list
|
||||
(paginated-response query-params :assets
|
||||
(entities-by-avet db :logseq.property.asset/type)
|
||||
asset-response)
|
||||
|
||||
:semantic/assets-create
|
||||
(let [{:keys [file-name size checksum encoding page-id title]} query-params
|
||||
size (when (and size (re-matches #"\d+" (str size))) (js/parseInt size 10))
|
||||
checksum (some-> checksum string/lower-case)
|
||||
asset-type (when (string? file-name) (db-asset/asset-path->type file-name))
|
||||
target (when page-id (find-entity db page-id))]
|
||||
(cond
|
||||
(or (not (seq file-name)) (not (seq asset-type))) (bad-request "invalid asset file-name")
|
||||
(nil? size) (bad-request "invalid asset size")
|
||||
(> size max-asset-size) (api-error! "asset too large" 413)
|
||||
(not (and (string? checksum) (re-matches #"[0-9a-f]{64}" checksum)))
|
||||
(bad-request "invalid asset checksum")
|
||||
(and encoding (not= "base64" encoding)) (bad-request "invalid asset encoding")
|
||||
(and page-id (not (page? target))) (bad-request "invalid asset page-id")
|
||||
(seq (d/datoms db :avet :logseq.property.asset/checksum checksum))
|
||||
(api-error! "asset checksum already exists" 409)
|
||||
:else
|
||||
(let [asset-id (random-uuid)
|
||||
file-name* (str asset-id "." asset-type)
|
||||
title (or title (db-asset/asset-name->title file-name))
|
||||
payload (if (= "base64" encoding)
|
||||
(js/Buffer.from (str body) "base64")
|
||||
body)]
|
||||
(platform/asset-write-bytes! (platform/current) repo file-name* payload)
|
||||
(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}]
|
||||
(outliner-core/insert-blocks! conn [block] target
|
||||
{:sibling? false :bottom? true :keep-uuid? true})
|
||||
(asset-response (d/entity @conn [:block/uuid asset-id]))))))
|
||||
|
||||
:semantic/assets-get
|
||||
(if-let [asset (find-visible-entity db (:asset-block-id path-params))]
|
||||
(if-not (asset? asset)
|
||||
(bad-request "block is not an asset")
|
||||
(assoc (asset-response asset)
|
||||
:url (str "/v1/assets/" (js/encodeURIComponent repo) "/"
|
||||
(js/encodeURIComponent (asset-file-name asset)))))
|
||||
(not-found))))
|
||||
|
||||
(defn- handle-properties-and-search
|
||||
[{:keys [conn db handler path-params query-params body]}]
|
||||
(case handler
|
||||
|
||||
:semantic/properties-list
|
||||
(paginated-response query-params :properties (->> (db-db/get-all-properties db)
|
||||
(remove entity-util/hidden?))
|
||||
property-response)
|
||||
|
||||
:semantic/properties-create
|
||||
(if (or (not (seq (:title body))) (not (valid-property-schema? body)))
|
||||
(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)})]
|
||||
(property-response property)))
|
||||
|
||||
:semantic/properties-get
|
||||
(if-let [property (resolve-property db (:property-id path-params))]
|
||||
(if (property? property) (property-response property) (not-found))
|
||||
(not-found))
|
||||
|
||||
:semantic/properties-update
|
||||
(let [property (resolve-property db (:property-id path-params))]
|
||||
(if (or (not (property? property)) (empty? body) (not (valid-property-schema? body)))
|
||||
(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))))]
|
||||
(property-response updated))))
|
||||
|
||||
:semantic/properties-delete
|
||||
(if-let [property (resolve-property db (:property-id path-params))]
|
||||
(if-not (property? property)
|
||||
(not-found)
|
||||
(if (outliner-page/delete! conn (:block/uuid property))
|
||||
nil
|
||||
(bad-request "property cannot be deleted")))
|
||||
(not-found))
|
||||
|
||||
:semantic/search
|
||||
(let [query (:q query-params)]
|
||||
(if-not (seq query)
|
||||
(bad-request "missing q")
|
||||
(paginated-response query-params :results
|
||||
(search-results db query (:types query-params))
|
||||
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- dispatch [{:keys [handler] :as context}]
|
||||
(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 (not-found)))
|
||||
|
||||
(def-thread-api :thread-api/semantic-request
|
||||
[repo handler path-params query-params body]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(dispatch {:repo repo
|
||||
:conn conn
|
||||
:db @conn
|
||||
:handler handler
|
||||
:path-params (or path-params {})
|
||||
:query-params (or query-params {})
|
||||
:body body})
|
||||
(throw (api-error 404 :repo-not-found "repo not found"))))
|
||||
280
src/main/frontend/worker/mcp_server.cljs
Normal file
280
src/main/frontend/worker/mcp_server.cljs
Normal file
@@ -0,0 +1,280 @@
|
||||
(ns frontend.worker.mcp-server
|
||||
"MCP server for the daemon (Fase 9), exposing the semantic API
|
||||
(semantic-routes/operations) as MCP tools. The CF-Worker's equivalent
|
||||
(deps/db-sync/worker/entry.mjs) auto-generates its tools from the OpenAPI
|
||||
document via @cloudflare/codemode's openApiMcpServer, a package that only
|
||||
exists inside Cloudflare Workers -- not portable to Node -- plus a pile of
|
||||
ChatGPT-App-specific tooling (asset image resources, upload_asset,
|
||||
Cognito protected-resource metadata) this daemon has no use for. So this
|
||||
registers one tool per operation by hand instead, the same pattern
|
||||
electron/mcp_server.cljs already used for its own 6 tools (registerTool
|
||||
with an explicit title/description/inputSchema/handler) -- just generated
|
||||
from semantic-routes' operation table and docs instead of writing 34
|
||||
one-off registrations, so the MCP tool surface can't drift from the HTTP
|
||||
routes and the OpenAPI document.
|
||||
|
||||
Each tool's handler makes a loopback HTTP call to this same daemon's own
|
||||
/v1/semantic/* route (see db_worker_node.cljs), forwarding the
|
||||
Authorization header the /mcp request itself carried -- reusing the
|
||||
already-tested HTTP-layer logic (repo-error validation, lock-owner
|
||||
assertion, response shaping) rather than re-implementing a second
|
||||
request path directly against the thread-api."
|
||||
(:require ["@modelcontextprotocol/sdk/server/mcp.js" :refer [McpServer]]
|
||||
["@modelcontextprotocol/sdk/server/streamableHttp.js" :refer [StreamableHTTPServerTransport]]
|
||||
["@modelcontextprotocol/sdk/types.js" :refer [isInitializeRequest]]
|
||||
["zod/v3" :as z]
|
||||
[clojure.string :as string]
|
||||
[frontend.worker.semantic-routes :as semantic-routes]
|
||||
[lambdaisland.glogi :as log]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Shared Zod field fragments
|
||||
;; ==========================
|
||||
(def ^:private pagination-fields
|
||||
{:limit (-> (z/number) .int .optional (.describe "Max items per page (default 50, max 200)"))
|
||||
:cursor (-> (z/string) .optional (.describe "Opaque pagination cursor from a previous response's next-cursor"))})
|
||||
|
||||
(def ^:private time-filter-fields
|
||||
{:created-after (-> (z/number) .int .optional (.describe "Unix epoch ms; only entities created strictly after this"))
|
||||
:updated-after (-> (z/number) .int .optional (.describe "Unix epoch ms; only entities updated strictly after this"))})
|
||||
|
||||
(def ^:private listing-fields (merge pagination-fields time-filter-fields))
|
||||
|
||||
(def ^:private block-tree-schema
|
||||
"Self-recursive, so it needs z/lazy -- the thunk closes over this same var,
|
||||
resolved once def completes, the standard cljs recursive-def idiom."
|
||||
(z/lazy (fn []
|
||||
(z/object #js {:title (z/string)
|
||||
:children (-> (z/array block-tree-schema) .optional)}))))
|
||||
|
||||
(def ^:private property-value-schema
|
||||
(-> (z/union #js [(z/string) (z/number) (z/boolean)
|
||||
(z/array (z/union #js [(z/string) (z/number)]))])
|
||||
(.describe (str "For ref properties, an existing entity's UUID, ident, or title. Status/Priority also "
|
||||
"accept built-in aliases like TODO or HIGH. Class properties like Tags accept one "
|
||||
"selector or an array of them. Scalar properties take their JSON value directly."))))
|
||||
|
||||
(def ^:private task-status-field
|
||||
(-> (z/string) .optional
|
||||
(.describe "todo|doing|in-review|done|canceled|backlog, or an existing Status choice's UUID/ident/title")))
|
||||
|
||||
(def ^:private task-priority-field
|
||||
(-> (z/string) .optional
|
||||
(.describe "low|medium|high|urgent, or an existing Priority choice's UUID/ident/title")))
|
||||
|
||||
(def ^:private property-type-field
|
||||
(-> (z/enum #js ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset" "map" "json" "string"])
|
||||
.optional))
|
||||
|
||||
(def ^:private property-cardinality-field
|
||||
(-> (z/enum #js ["db.cardinality/one" "db.cardinality/many"]) .optional))
|
||||
|
||||
;; Per-operation extra fields (beyond the path params every operation gets
|
||||
;; automatically -- see path-arg-fields below)
|
||||
;; ============================================================================
|
||||
(def ^:private extra-fields
|
||||
{"listPages" listing-fields
|
||||
"createPage" {:title (z/string)}
|
||||
"listPageBlocks" listing-fields
|
||||
"listPageReferences" listing-fields
|
||||
"updatePage" {:title (z/string)}
|
||||
"updateBlock" {:title (z/string)}
|
||||
"moveBlocks" {:block-ids (z/array (z/string))
|
||||
:target-id (z/string)
|
||||
:position (z/enum #js ["before" "after" "first-child" "last-child"])}
|
||||
"insertBlockChildren" {:position (z/enum #js ["append" "prepend"])
|
||||
:blocks (z/array block-tree-schema)}
|
||||
"insertBlockTree" {:target-id (z/string)
|
||||
:position (-> (z/enum #js ["append" "prepend"]) .optional)
|
||||
:blocks (z/array block-tree-schema)}
|
||||
"setBlockProperty" {:value property-value-schema}
|
||||
"batchSetBlockProperty" {:isResetExistingValues (-> (z/boolean) .optional)
|
||||
:entries (z/array (z/object #js {:block-id (z/string)
|
||||
:property-id (z/string)
|
||||
:value property-value-schema}))}
|
||||
"batchDeleteBlockProperty" {:entries (z/array (z/object #js {:block-id (z/string) :property-id (z/string)}))}
|
||||
"captureToToday" {:blocks (z/array block-tree-schema)}
|
||||
"listTasks" (merge listing-fields
|
||||
{:status task-status-field
|
||||
:priority task-priority-field
|
||||
:content (-> (z/string) .optional (.describe "Case-insensitive task title filter"))})
|
||||
"createTask" {:title (z/string)
|
||||
:page-id (-> (z/string) .optional (.describe "Destination page UUID; omit for today's journal"))
|
||||
:status task-status-field
|
||||
:priority task-priority-field
|
||||
:scheduled (-> (z/number) .int .optional (.describe "Scheduled date as YYYYMMDD"))
|
||||
:deadline (-> (z/number) .int .optional (.describe "Deadline date as YYYYMMDD"))}
|
||||
"listTags" listing-fields
|
||||
"createTag" {:title (z/string)}
|
||||
"listTagObjects" listing-fields
|
||||
"updateTag" {:title (z/string)}
|
||||
"listProperties" listing-fields
|
||||
"createProperty" {:title (z/string) :type property-type-field :cardinality property-cardinality-field}
|
||||
"updateProperty" {:title (-> (z/string) .optional) :type property-type-field :cardinality property-cardinality-field}
|
||||
"listAssets" listing-fields
|
||||
"createAsset" {:file-name (z/string)
|
||||
:size (-> (z/number) .int (.describe "Exact decoded byte size of the file, max 100MB"))
|
||||
:checksum (-> (z/string) (.regex (js/RegExp. "^[0-9a-fA-F]{64}$"))
|
||||
(.describe "SHA-256 hex digest of the decoded file"))
|
||||
:title (-> (z/string) .optional)
|
||||
:page-id (-> (z/string) .optional (.describe "Destination page UUID; omit for today's journal"))
|
||||
:content (-> (z/string) (.describe "Base64-encoded file contents"))}
|
||||
"searchGraph" (merge listing-fields
|
||||
{:q (z/string)
|
||||
:types (-> (z/string) .optional
|
||||
(.describe "Comma-separated subset of blocks,tags,properties,assets"))})})
|
||||
|
||||
;; Request construction
|
||||
;; ====================
|
||||
(defn- path-param-names [path]
|
||||
(mapv second (re-seq #":([^/]+)" path)))
|
||||
|
||||
(defn- path-arg-fields [path]
|
||||
(into {}
|
||||
(map (fn [param]
|
||||
[(keyword param) (-> (z/string) (.describe (str "The " param)))]))
|
||||
(path-param-names path)))
|
||||
|
||||
(defn- build-path [path args]
|
||||
(reduce (fn [p param]
|
||||
(string/replace p (str ":" param) (js/encodeURIComponent (str (get args (keyword param))))))
|
||||
path
|
||||
(path-param-names path)))
|
||||
|
||||
(defn- query-string [args]
|
||||
(let [pairs (->> args
|
||||
(remove (fn [[_ v]] (nil? v)))
|
||||
(map (fn [[k v]] (str (js/encodeURIComponent (name k)) "=" (js/encodeURIComponent (str v))))))]
|
||||
(if (seq pairs) (str "?" (string/join "&" pairs)) "")))
|
||||
|
||||
(defn- request-for
|
||||
[{:keys [operation-id method path]} args]
|
||||
(let [path-names (set (map keyword (path-param-names path)))
|
||||
rest-args (apply dissoc args path-names)
|
||||
url-path (build-path path args)]
|
||||
(cond
|
||||
(= operation-id "createAsset")
|
||||
{:method method
|
||||
:path (str url-path (query-string (assoc (dissoc rest-args :content) :encoding "base64")))
|
||||
:body (:content rest-args)
|
||||
:content-type "text/plain"}
|
||||
|
||||
(contains? #{"GET" "DELETE"} method)
|
||||
{:method method :path (str url-path (query-string rest-args)) :body nil :content-type nil}
|
||||
|
||||
:else
|
||||
{:method method :path url-path :body (js/JSON.stringify (clj->js rest-args)) :content-type "application/json"})))
|
||||
|
||||
(defn- call-semantic-operation!
|
||||
[{:keys [host port authorization]} operation args]
|
||||
(let [{:keys [method path body content-type]} (request-for operation args)
|
||||
url (str "http://" host ":" port path)]
|
||||
(-> (js/fetch url (clj->js (cond-> {:method method
|
||||
:headers (cond-> {}
|
||||
authorization (assoc "Authorization" authorization)
|
||||
content-type (assoc "Content-Type" content-type))}
|
||||
body (assoc :body body))))
|
||||
(p/then (fn [^js res]
|
||||
(p/let [text (.text res)]
|
||||
{:status (.-status res) :ok (.-ok res) :text text}))))))
|
||||
|
||||
(defn- mcp-result [{:keys [ok text]}]
|
||||
#js {:content #js [#js {:type "text" :text (if (string/blank? text) "{}" text)}]
|
||||
:isError (not ok)})
|
||||
|
||||
(defn- mcp-error-result [error]
|
||||
#js {:content #js [#js {:type "text" :text (str "Unexpected MCP tool error: " (or (.-message error) error))}]
|
||||
:isError true})
|
||||
|
||||
;; Server construction
|
||||
;; ===================
|
||||
(defn- register-operation-tool!
|
||||
[^js mcp-server ctx operation]
|
||||
(let [{:keys [operation-id]} operation
|
||||
[title description] (get semantic-routes/operation-docs operation-id)
|
||||
input-shape (clj->js (merge (path-arg-fields (:path operation)) (get extra-fields operation-id {})))]
|
||||
(.registerTool mcp-server operation-id
|
||||
#js {:title title
|
||||
:description description
|
||||
:inputSchema input-shape}
|
||||
(fn [args]
|
||||
(-> (call-semantic-operation! ctx operation (js->clj args :keywordize-keys true))
|
||||
(p/then mcp-result)
|
||||
(p/catch mcp-error-result))))))
|
||||
|
||||
(defn create-mcp-api-server
|
||||
[ctx]
|
||||
(let [mcp-server (McpServer. #js {:name "Logseq Daemon Semantic API" :version "1.0.0"})]
|
||||
(doseq [operation semantic-routes/operations]
|
||||
(register-operation-tool! mcp-server ctx operation))
|
||||
mcp-server))
|
||||
|
||||
;; HTTP glue -- adapted from electron/mcp_server.cljs's Fastify-based version:
|
||||
;; that one gets req/res already wrapped (`.raw` unwraps back to the
|
||||
;; underlying Node primitives, `.body` is pre-parsed by Fastify's body
|
||||
;; parser); the daemon calls this straight from its own raw
|
||||
;; http.createServer callback, so req/res ARE already the Node primitives
|
||||
;; StreamableHTTPServerTransport wants, and the body needs parsing here.
|
||||
;; Auth is the daemon's existing bearer-token check on every route (see
|
||||
;; db_worker_node.cljs's request-authorized?) -- already done by the time
|
||||
;; this is reached, so unlike the CF-Worker/Electron versions there's no
|
||||
;; separate auth check in here.
|
||||
(defonce ^:private *transports (atom {}))
|
||||
|
||||
(defn- <read-body [^js req]
|
||||
(p/create
|
||||
(fn [resolve reject]
|
||||
(let [chunks (array)]
|
||||
(.on req "data" (fn [chunk] (.push chunks chunk)))
|
||||
(.on req "end" (fn [] (resolve (.toString (js/Buffer.concat chunks) "utf8"))))
|
||||
(.on req "error" reject)))))
|
||||
|
||||
(defn handle-post-request
|
||||
[{:keys [host port]} ^js req ^js res]
|
||||
(let [session-id (aget (.-headers req) "mcp-session-id")
|
||||
authorization (.. req -headers -authorization)]
|
||||
(p/let [body-text (<read-body req)
|
||||
body (when-not (string/blank? body-text) (js/JSON.parse body-text))]
|
||||
(cond
|
||||
(and session-id (@*transports session-id))
|
||||
(let [^js transport (@*transports session-id)]
|
||||
(.handleRequest transport req res body))
|
||||
|
||||
(and (not session-id) (isInitializeRequest body))
|
||||
(let [transport (StreamableHTTPServerTransport.
|
||||
#js {:sessionIdGenerator (comp str random-uuid)
|
||||
:enableDnsRebindingProtection true
|
||||
:allowedHosts #js [(str host ":" port)]})
|
||||
mcp-server (create-mcp-api-server {:host host :port port :authorization authorization})]
|
||||
(set! (.-onclose transport)
|
||||
(fn [] (swap! *transports dissoc (.-sessionId transport))))
|
||||
(p/do!
|
||||
(.connect mcp-server transport)
|
||||
(.handleRequest transport req res body)
|
||||
(if (.-sessionId transport)
|
||||
(swap! *transports assoc (.-sessionId transport) transport)
|
||||
(log/error :mcp-server-no-session-id-after-initialize nil))))
|
||||
|
||||
:else
|
||||
(do
|
||||
(.writeHead res 400 #js {"Content-Type" "application/json"})
|
||||
(.end res (js/JSON.stringify
|
||||
#js {:jsonrpc "2.0"
|
||||
:error #js {:code -32000 :message "Bad Request: No valid session ID provided"}
|
||||
:id nil})))))))
|
||||
|
||||
(defn handle-get-request
|
||||
[^js req ^js res]
|
||||
(let [session-id (aget (.-headers req) "mcp-session-id")]
|
||||
(if-let [^js transport (and session-id (@*transports session-id))]
|
||||
(.handleRequest transport req res)
|
||||
(do (.writeHead res 400) (.end res "Invalid or missing session ID")))))
|
||||
|
||||
(defn handle-delete-request
|
||||
[^js req ^js res]
|
||||
(let [session-id (aget (.-headers req) "mcp-session-id")]
|
||||
(if-let [^js transport (and session-id (@*transports session-id))]
|
||||
(do (.close transport)
|
||||
(.writeHead res 200 #js {"Content-Type" "application/json"})
|
||||
(.end res (js/JSON.stringify #js {:ok true})))
|
||||
(do (.writeHead res 400) (.end res "Invalid or missing session ID")))))
|
||||
@@ -418,6 +418,19 @@
|
||||
(or (nil? page)
|
||||
(>= (count results) limit)))
|
||||
|
||||
(defn- fetch-zvec-doc
|
||||
[collection ids]
|
||||
(let [result (.fetchSync ^js collection (clj->js (vec ids)))]
|
||||
(into {}
|
||||
(keep (fn [id]
|
||||
(when-let [doc (gobj/get result id)]
|
||||
(let [vectors (gobj/get doc "vectors")
|
||||
fields (gobj/get doc "fields")]
|
||||
[id {:embedding (js->clj (gobj/get vectors zvec-vector-field))
|
||||
:page (gobj/get fields zvec-page-field)
|
||||
:vector-title (gobj/get fields zvec-title-field)}]))))
|
||||
ids)))
|
||||
|
||||
(defn- open-vector-index
|
||||
[{:keys [path dimension]}]
|
||||
(p/let [_ (ensure-dir! (node-path/dirname path))]
|
||||
@@ -449,6 +462,8 @@
|
||||
:delete! (fn [ids]
|
||||
(.deleteSync ^js @collection* (clj->js (vec ids)))
|
||||
nil)
|
||||
:fetch (fn [ids]
|
||||
(fetch-zvec-doc @collection* ids))
|
||||
:truncate! (fn []
|
||||
(.destroySync ^js @collection*)
|
||||
(reopen!)
|
||||
|
||||
@@ -158,6 +158,9 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
(def ^:private title-term-match-boost 0.002)
|
||||
(def ^:private context-term-match-boost 0.0005)
|
||||
(def ^:private max-vector-term-match-boost 0.005)
|
||||
(def ^:private temporal-half-life-ms (* 60 24 60 60 1000)) ;; 60 days
|
||||
(def ^:private min-temporal-decay 0.5) ;; never discount a strong match by more than half
|
||||
(def ^:private max-similar-block-results 10)
|
||||
|
||||
(defn- query->terms
|
||||
[q]
|
||||
@@ -602,6 +605,7 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
'[:db/id
|
||||
:block/uuid
|
||||
:block/title
|
||||
:block/updated-at
|
||||
{:block/page [:block/uuid :block/name :block/title]}
|
||||
{:block/parent [:db/id :block/uuid :block/title :logseq.property/built-in?
|
||||
:logseq.property/hide? :logseq.property/deleted-at]}
|
||||
@@ -743,6 +747,16 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
(min max-vector-term-match-boost score))
|
||||
0))
|
||||
|
||||
(defn- temporal-decay
|
||||
"Recently updated blocks are ranked slightly higher. Bounded at
|
||||
`min-temporal-decay` so an old-but-strong match never gets crushed by a
|
||||
recent-but-weak one, and unset `:block/updated-at` decays to no-op (1.0)."
|
||||
[now updated-at]
|
||||
(if (number? updated-at)
|
||||
(let [age-ms (max 0 (- now updated-at))]
|
||||
(max min-temporal-decay (js/Math.pow 0.5 (/ age-ms temporal-half-life-ms))))
|
||||
1.0))
|
||||
|
||||
;; Combine and re-rank keyword and vector results
|
||||
(defn combine-results
|
||||
([db keyword-results]
|
||||
@@ -753,6 +767,7 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
(let [keyword-results (vec (or keyword-results []))
|
||||
vector-results (vec (or vector-results []))
|
||||
use-rrf? (seq vector-results)
|
||||
now (js/Date.now)
|
||||
fused-score-by-id (when use-rrf?
|
||||
(rrf-score-by-id [keyword-results vector-results]
|
||||
[keyword-rrf-weight vector-rrf-weight]))
|
||||
@@ -773,7 +788,7 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
vector-match-score (if use-rrf?
|
||||
(vector-term-match-score q result block)
|
||||
0)
|
||||
combined-score (+ base-score
|
||||
combined-score (* (+ base-score
|
||||
vector-match-score
|
||||
(* source-score-tie-break-weight
|
||||
(+ keyword-score vector-score))
|
||||
@@ -785,7 +800,8 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
0.01
|
||||
:else
|
||||
0)
|
||||
0))]
|
||||
0))
|
||||
(temporal-decay now (:block/updated-at block)))]
|
||||
(conj acc
|
||||
(assoc result
|
||||
search-result-block-key block
|
||||
@@ -1022,6 +1038,28 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
:matched-count matched-count}
|
||||
(take limit result))))))
|
||||
|
||||
(defn similar-blocks
|
||||
"Blocks/pages whose already-indexed embedding is closest to the given
|
||||
block's own embedding, i.e. \"similar to this\" rather than a fresh query."
|
||||
[conn vector-index block-uuid option]
|
||||
(when-let [fetch-fn (:fetch vector-index)]
|
||||
(let [id (str block-uuid)
|
||||
source (get (fetch-fn [id]) id)]
|
||||
(when (seq (:embedding source))
|
||||
(let [limit (min max-similar-block-results (or (:limit option) max-similar-block-results))
|
||||
raw-result (vector-search-blocks vector-index
|
||||
{:limit (inc limit)
|
||||
:page (:page option)
|
||||
:query-embedding (:embedding source)})
|
||||
candidates (remove #(= (:id %) id) raw-result)
|
||||
combined-result (combine-results @conn nil candidates)]
|
||||
(->> combined-result
|
||||
(common-util/distinct-by :id)
|
||||
(keep #(search-result->block-result conn nil nil option %))
|
||||
(remove #(= (:block/uuid %) block-uuid))
|
||||
(take limit)
|
||||
vec))))))
|
||||
|
||||
(defn upsert-vector-blocks!
|
||||
[vector-index blocks]
|
||||
(when-let [upsert-fn (:upsert! vector-index)]
|
||||
|
||||
400
src/main/frontend/worker/semantic_routes.cljs
Normal file
400
src/main/frontend/worker/semantic_routes.cljs
Normal file
@@ -0,0 +1,400 @@
|
||||
(ns frontend.worker.semantic-routes
|
||||
"Route table and OpenAPI document for the semantic API served by the
|
||||
daemon (Fase 9). Ported from deps/db-sync/.../routes/semantic.cljs: that
|
||||
version routes by :graph-id under a Cloudflare Worker Durable Object per
|
||||
graph. This daemon is bound to a single repo per process (see
|
||||
db_worker_node.cljs's `bound-repo`), so the :graph-id path segment and the
|
||||
listGraphs operation are dropped -- every request implicitly targets the
|
||||
daemon's own bound repo. OAuth2 is likewise dropped in favor of the
|
||||
daemon's actual bearer-token session auth (see db-worker-node-auth)."
|
||||
(:require [clojure.string :as string]
|
||||
[flatland.ordered.map :refer [ordered-map]]
|
||||
[reitit.core :as r]))
|
||||
|
||||
(def operations
|
||||
[{:method "GET" :path "/v1/semantic/pages"
|
||||
:handler :semantic/pages-list :operation-id "listPages" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/pages"
|
||||
:handler :semantic/pages-create :operation-id "createPage" :success-status 201}
|
||||
{:method "GET" :path "/v1/semantic/pages/:page-id/blocks"
|
||||
:handler :semantic/pages-blocks :operation-id "listPageBlocks" :success-status 200}
|
||||
{:method "GET" :path "/v1/semantic/pages/:page-id/references"
|
||||
:handler :semantic/pages-references :operation-id "listPageReferences" :success-status 200}
|
||||
{:method "DELETE" :path "/v1/semantic/pages/:page-id"
|
||||
:handler :semantic/pages-delete :operation-id "deletePage" :success-status 204}
|
||||
{:method "GET" :path "/v1/semantic/pages/:page-id"
|
||||
:handler :semantic/pages-get :operation-id "getPage" :success-status 200}
|
||||
{:method "PATCH" :path "/v1/semantic/pages/:page-id"
|
||||
:handler :semantic/pages-update :operation-id "updatePage" :success-status 200}
|
||||
{:method "GET" :path "/v1/semantic/blocks/:block-id"
|
||||
:handler :semantic/blocks-get :operation-id "getBlock" :success-status 200}
|
||||
{:method "PATCH" :path "/v1/semantic/blocks/:block-id"
|
||||
:handler :semantic/blocks-update :operation-id "updateBlock" :success-status 200}
|
||||
{:method "DELETE" :path "/v1/semantic/blocks/:block-id"
|
||||
:handler :semantic/blocks-delete :operation-id "deleteBlock" :success-status 204}
|
||||
{:method "POST" :path "/v1/semantic/block-moves"
|
||||
:handler :semantic/blocks-move :operation-id "moveBlocks" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/blocks/:block-id/children"
|
||||
:handler :semantic/blocks-insert-children :operation-id "insertBlockChildren" :success-status 201}
|
||||
{:method "POST" :path "/v1/semantic/block-trees"
|
||||
:handler :semantic/blocks-insert-tree :operation-id "insertBlockTree" :success-status 201}
|
||||
{:method "PUT" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
|
||||
:handler :semantic/blocks-set-property :operation-id "setBlockProperty" :success-status 200}
|
||||
{:method "DELETE" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
|
||||
:handler :semantic/blocks-delete-property :operation-id "deleteBlockProperty" :success-status 204}
|
||||
{:method "POST" :path "/v1/semantic/block-properties/batch-set"
|
||||
:handler :semantic/blocks-batch-set-property :operation-id "batchSetBlockProperty" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/block-properties/batch-delete"
|
||||
:handler :semantic/blocks-batch-delete-property :operation-id "batchDeleteBlockProperty" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/capture"
|
||||
:handler :semantic/capture :operation-id "captureToToday" :success-status 201}
|
||||
{:method "GET" :path "/v1/semantic/tasks"
|
||||
:handler :semantic/tasks-list :operation-id "listTasks" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/tasks"
|
||||
:handler :semantic/tasks-create :operation-id "createTask" :success-status 201}
|
||||
{:method "GET" :path "/v1/semantic/tags"
|
||||
:handler :semantic/tags-list :operation-id "listTags" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/tags"
|
||||
:handler :semantic/tags-create :operation-id "createTag" :success-status 201}
|
||||
{:method "GET" :path "/v1/semantic/tags/:tag-id"
|
||||
:handler :semantic/tags-get :operation-id "getTag" :success-status 200}
|
||||
{:method "GET" :path "/v1/semantic/tags/:tag-id/objects"
|
||||
:handler :semantic/tags-objects :operation-id "listTagObjects" :success-status 200}
|
||||
{:method "PATCH" :path "/v1/semantic/tags/:tag-id"
|
||||
:handler :semantic/tags-update :operation-id "updateTag" :success-status 200}
|
||||
{:method "DELETE" :path "/v1/semantic/tags/:tag-id"
|
||||
:handler :semantic/tags-delete :operation-id "deleteTag" :success-status 204}
|
||||
{:method "GET" :path "/v1/semantic/properties"
|
||||
:handler :semantic/properties-list :operation-id "listProperties" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/properties"
|
||||
:handler :semantic/properties-create :operation-id "createProperty" :success-status 201}
|
||||
{:method "GET" :path "/v1/semantic/properties/:property-id"
|
||||
:handler :semantic/properties-get :operation-id "getProperty" :success-status 200}
|
||||
{:method "PATCH" :path "/v1/semantic/properties/:property-id"
|
||||
:handler :semantic/properties-update :operation-id "updateProperty" :success-status 200}
|
||||
{:method "DELETE" :path "/v1/semantic/properties/:property-id"
|
||||
:handler :semantic/properties-delete :operation-id "deleteProperty" :success-status 204}
|
||||
{:method "GET" :path "/v1/semantic/assets"
|
||||
:handler :semantic/assets-list :operation-id "listAssets" :success-status 200}
|
||||
{:method "POST" :path "/v1/semantic/assets"
|
||||
:handler :semantic/assets-create :operation-id "createAsset" :success-status 201}
|
||||
{:method "GET" :path "/v1/semantic/assets/:asset-block-id"
|
||||
:handler :semantic/assets-get :operation-id "getAsset" :success-status 200}
|
||||
{:method "GET" :path "/v1/semantic/search"
|
||||
:handler :semantic/search :operation-id "searchGraph" :success-status 200}])
|
||||
|
||||
(def operation-docs
|
||||
"Public so worker.mcp-server can source MCP tool titles/descriptions from
|
||||
the same place the OpenAPI document does, instead of a second copy that
|
||||
could drift."
|
||||
{"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 the daemon's local asset storage and creates its Asset-class block. Requires a client-computed SHA-256 checksum."]
|
||||
"getAsset" ["Get an asset" "Returns asset metadata and a server-relative URL for the asset. Resolve the URL against the daemon's own base URL; it requires the same bearer token as every other route (or a ?token= query fallback for direct navigation)."]
|
||||
"searchGraph" ["Search graph resources" "Searches blocks, tags, properties, and assets by title and returns cursor-paginated results."]})
|
||||
|
||||
(defn- operation-routes []
|
||||
(->> operations
|
||||
(group-by :path)
|
||||
(mapv (fn [[path path-operations]]
|
||||
[path {:methods (into {} (map (juxt :method identity)) path-operations)}]))))
|
||||
|
||||
(def ^:private router
|
||||
(r/router (operation-routes)))
|
||||
|
||||
(defn match [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- 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. Uploads larger than 100MB are rejected."
|
||||
: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. The server decodes it as a stream before writing to disk; 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? #{"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)
|
||||
(= "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 server-relative URL")
|
||||
{:description "Success"})
|
||||
"400" {:description "Invalid request"}
|
||||
"401" {:description "Missing or invalid bearer token"}
|
||||
"404" {:description "Not found"}}
|
||||
(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
|
||||
[{:keys [repo]}]
|
||||
{:openapi "3.1.0"
|
||||
:info {:title "Logseq Daemon Semantic API" :version "1.0.0"
|
||||
:description (str "Operates on the graph bound to this daemon instance"
|
||||
(when repo (str " (" repo ")")) ".")}
|
||||
: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"}}}
|
||||
: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
|
||||
{:bearerAuth {:type "http" :scheme "bearer"
|
||||
:description "Session token from POST /v1/auth/login."}}}
|
||||
:security [{:bearerAuth []}]
|
||||
:paths
|
||||
(reduce (fn [paths {:keys [method path operation-id]}]
|
||||
(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-id operation-id :path path})
|
||||
:responses (operation-responses operation-id)}
|
||||
(request-body operation-id)
|
||||
(assoc :requestBody (request-body operation-id))))))
|
||||
(ordered-map)
|
||||
operations)})
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nuwe opsie: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "MCP-URL gekopieerd"
|
||||
:server/start "Begin"
|
||||
:server/stop "Stop"
|
||||
:server/title "HTTP API-server"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Rekening"
|
||||
:settings/advanced "Gevorderd"
|
||||
:settings/ai "KI"
|
||||
:settings/collaboration "Samewerking"
|
||||
:settings/editor "Redigeerder"
|
||||
:settings/encryption "Enkripsie"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Deaktiveer ongelukrapportering"
|
||||
:settings.advanced/network-proxy "Netwerkvolmag"
|
||||
|
||||
:settings.ai/enable-mcp-server "Aktiveer MCP-bediener"
|
||||
:settings.ai/enable-mcp-server-desc "MCP-server aktiveer vir AI-integratie"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Blokreferenties outomaties uitklappen"
|
||||
:settings.editor/auto-expand-block-refs-tip "Klik om blokreferenties outomaties uit te klappen"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ خيار جديد: {1}"
|
||||
|
||||
:server/error-notification "[الخادم] {1}"
|
||||
:server/mcp-url-copied "تم نسخ رابط MCP"
|
||||
:server/start "بدء"
|
||||
:server/stop "إيقاف"
|
||||
:server/title "خادم HTTP API"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "الحساب"
|
||||
:settings/advanced "متقدم"
|
||||
:settings/ai "الذكاء الاصطناعي"
|
||||
:settings/collaboration "التعاون"
|
||||
:settings/editor "المحرر"
|
||||
:settings/encryption "التشفير"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "تعطيل تقارير الأعطال"
|
||||
:settings.advanced/network-proxy "وكيل الشبكة"
|
||||
|
||||
:settings.ai/enable-mcp-server "تفعيل خادم MCP"
|
||||
:settings.ai/enable-mcp-server-desc "تفعيل خادم MCP لتكامل الذكاء الاصطناعي"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "توسيع مراجع الكتل تلقائياً"
|
||||
:settings.editor/auto-expand-block-refs-tip "انقر لتوسيع مراجع الكتل تلقائياً"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Opció nova: {1}"
|
||||
|
||||
:server/error-notification "[Servidor] {1}"
|
||||
:server/mcp-url-copied "URL MCP copiada"
|
||||
:server/start "Iniciar"
|
||||
:server/stop "Aturar"
|
||||
:server/title "Servidor d'API HTTP"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Compte"
|
||||
:settings/advanced "Avançat"
|
||||
:settings/ai "IA"
|
||||
:settings/collaboration "Col·laboració"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Encriptació"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc " Logseq mai recol·lectarà la seva base de dades de grafs local o vendrà les seves dades."
|
||||
:settings.advanced/network-proxy "Proxy de xarxa"
|
||||
|
||||
:settings.ai/enable-mcp-server "Activar servidor MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Activar servidor MCP per integració amb IA"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Expandir referències de blocs automàticament"
|
||||
:settings.editor/auto-expand-block-refs-tip "Expandir referències de blocs automàticament en fer clic"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nová možnost: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "URL MCP zkopírováno"
|
||||
:server/start "Spustit"
|
||||
:server/stop "Zastavit"
|
||||
:server/title "HTTP API server"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Účet"
|
||||
:settings/advanced "Pokročilé"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Spolupráce"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Šifrování"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Společnost Logseq nikdy nebude shromažďovat vaši místní databázi grafů ani prodávat vaše data."
|
||||
:settings.advanced/network-proxy "Síťový proxy server"
|
||||
|
||||
:settings.ai/enable-mcp-server "Zapnout server MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Zapnout server MCP pro integraci s AI"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Automaticky rozbalit odkazy na bloky"
|
||||
:settings.editor/auto-expand-block-refs-tip "Automaticky rozbalit odkazy na bloky při kliknutí"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Neue Option: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "MCP-URL kopiert"
|
||||
:server/start "Starten"
|
||||
:server/stop "Stoppen"
|
||||
:server/title "HTTP-API-Server"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Konto"
|
||||
:settings/advanced "Erweitert"
|
||||
:settings/ai "KI"
|
||||
:settings/collaboration "Zusammenarbeit"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Verschlüsselung"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq wird niemals Ihre lokale Graphdatenbank sammeln oder Ihre Daten verkaufen."
|
||||
:settings.advanced/network-proxy "Netzwerk-Proxy"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP-Server aktivieren"
|
||||
:settings.ai/enable-mcp-server-desc "MCP-Server für KI-Integration aktivieren"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Automatisch beim Heranzoomen Blockreferenzen erweitern..."
|
||||
:settings.editor/auto-expand-block-refs-tip "Diese Option steuert, ob Blockreferenzen beim Heranzoomen automatisch ausgeklappt werden."
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
:account/auth-error-username-exists "This username is already taken."
|
||||
:account/authentication "Authentication"
|
||||
:account/back-to-login "Back to login"
|
||||
:account/benefits-desc "With a Logseq account, you can access cloud-based services like Logseq Sync and alpha/beta features."
|
||||
:account/benefits-desc "With a Synapse account, you can access cloud-based services like Synapse Sync and alpha/beta features."
|
||||
:account/billing "Billing"
|
||||
:account/billing-expired-on-label "Pro plan expired on: {1}"
|
||||
:account/billing-expires-on-label "Pro plan expires on: {1}"
|
||||
@@ -23,7 +23,7 @@
|
||||
:account/code-sent-to-email "We have sent a numeric verification code to your email address at {1}."
|
||||
:account/confirm "Confirm"
|
||||
:account/confirm-password "Confirm Password"
|
||||
:account/core-features "Access to core Logseq features"
|
||||
:account/core-features "Access to core Synapse features"
|
||||
:account/create-account "Create Account"
|
||||
:account/current-plan "Current plan"
|
||||
:account/delete-account "Delete Account"
|
||||
@@ -61,7 +61,7 @@
|
||||
:account/sync-assets-limit "Sync assets up to 100MB per file"
|
||||
:account/synced-status "Synced"
|
||||
:account/unlimited-unsynced-graphs "Unlimited unsynced graphs"
|
||||
:account/upcoming-cloud-features "Upcoming cloud-based features, including Logseq Publish"
|
||||
:account/upcoming-cloud-features "Upcoming cloud-based features, including Synapse Publish"
|
||||
:account/upgrade-plan "Upgrade plan"
|
||||
:account/username "Username"
|
||||
|
||||
@@ -184,12 +184,14 @@
|
||||
:bug-report.inspector/tip "Something wrong? No problem, click to go back to the previous step."
|
||||
:bug-report.inspector/title "Clipboard data inspector"
|
||||
|
||||
:bug-report.issue/action-desc "Help Make Logseq Better!"
|
||||
:bug-report.issue/action-desc "Help Make Synapse Better!"
|
||||
:bug-report.issue/action-title "Submit a bug report"
|
||||
:bug-report.issue/desc "If there are no tools available for you to gather additional information, please report the bug directly."
|
||||
:bug-report.issue/report-link "Report issue"
|
||||
:bug-report.issue/title "Or..."
|
||||
|
||||
:calendar/today "Today"
|
||||
|
||||
:class/add-property "Add tag property"
|
||||
:class/tag-properties-desc "Tag properties are inherited by all nodes using the tag. For example, each #Task node inherits 'Status' and 'Priority'."
|
||||
:class/tagged-nodes "Tagged nodes"
|
||||
@@ -495,6 +497,7 @@
|
||||
:editor/display-tag-inline-hint "to display this tag inline instead of at the end of this node."
|
||||
:editor/expand-block-children "Expand all"
|
||||
:editor/heading "Heading {1}"
|
||||
:editor/kernel-results "Kernel results"
|
||||
:editor/moving-blocks-count (fn [n] (str "Moving " n (if (> n 1) " blocks" " block")))
|
||||
:editor/new-page "New page"
|
||||
:editor/new-tag "New tag"
|
||||
@@ -502,6 +505,8 @@
|
||||
:editor/paste "Paste"
|
||||
:editor/reference-node-use-page-ref "To reference a node, please use `[[]]`."
|
||||
:editor/remove-heading "Remove heading"
|
||||
:editor/run-code "Run"
|
||||
:editor/running-code "Running..."
|
||||
:editor/search-for-node "Search for a node"
|
||||
:editor/search-for-tag "Search for a tag"
|
||||
:editor/search-template-placeholder "Search for a template"
|
||||
@@ -569,14 +574,14 @@
|
||||
:editor.slash/upload-asset-desc "Upload file types like image, PDF, DOCX, etc."
|
||||
:editor.slash/yesterday-desc "Insert the date of yesterday"
|
||||
|
||||
:electron/about "About Logseq"
|
||||
:electron/about "About Synapse"
|
||||
:electron/add-to-dictionary "Add to dictionary"
|
||||
:electron/block-not-exist "Open link failed. Block-id `{1}` doesn't exist in the graph."
|
||||
:electron/cancel "Cancel"
|
||||
:electron/cli-install-failed "Failed to install Logseq CLI.\n{1}"
|
||||
:electron/cli-install-failed "Failed to install Synapse CLI.\n{1}"
|
||||
:electron/copy-image "Copy Image"
|
||||
:electron/link-open-confirm "Are you sure you want to open this link? \n{1}"
|
||||
:electron/link-open-failed-missing-graph "Failed to open link. Missing graph identifier after `logseq://graph/`."
|
||||
:electron/link-open-failed-missing-graph "Failed to open link. Missing graph identifier after `synapse://graph/`."
|
||||
:electron/link-open-failed-no-graph "Failed to open link. Cannot match graph identifier `{1}` to any linked graph."
|
||||
:electron/link-open-failed-no-target "Failed to open link. Cannot match `{1}` to any target."
|
||||
:electron/look-up "Look up selection"
|
||||
@@ -734,7 +739,7 @@
|
||||
:graph/creating "Creating graph"
|
||||
:graph/db-worker-recovery-failed-error "Unable to reconnect to the database worker for graph \"{1}\". Please try again or reopen the graph."
|
||||
:graph/delete-local-action "Delete local graph"
|
||||
:graph/delete-local-confirm-desc "Are you sure you want to permanently delete the graph \"{1}\" from Logseq?"
|
||||
:graph/delete-local-confirm-desc "Are you sure you want to permanently delete the graph \"{1}\" from Synapse?"
|
||||
:graph/delete-server-action "Delete from server"
|
||||
:graph/delete-server-confirm-desc "Are you sure you want to permanently delete the graph \"{1}\" from our server?"
|
||||
:graph/delete-warning "⚠️ Notice that we can't recover this graph after being deleted. Make sure you have backups before deleting it."
|
||||
@@ -750,6 +755,7 @@
|
||||
:graph/layout-force "Force graph"
|
||||
:graph/layout-grid-layout "Grid layout"
|
||||
:graph/layout-link-distance "Link distance"
|
||||
:graph/layout-temporal "Timeline"
|
||||
:graph/layout-show-journals "Show journals"
|
||||
:graph/leave-action "Leave this graph"
|
||||
:graph/leave-confirm-desc "Are you sure you want to leave this graph?"
|
||||
@@ -768,7 +774,7 @@
|
||||
:graph/remote-graphs "Remote graphs:"
|
||||
:graph/removed "Removed graph \"{1}\""
|
||||
:graph/removed-and-redirecting "Removed graph \"{1}\". Redirecting to graph \"{2}\""
|
||||
:graph/removed-from-sync "This graph has been removed from Logseq Sync."
|
||||
:graph/removed-from-sync "This graph has been removed from Synapse Sync."
|
||||
:graph/reset-view "Reset graph view"
|
||||
:graph/retry "Retry"
|
||||
:graph/search-tags "Search tags"
|
||||
@@ -781,12 +787,13 @@
|
||||
:graph/time-travel-now "Now"
|
||||
:graph/toggle-tag "Toggle tag {1}"
|
||||
:graph/updated-switching "Graph updated! Switching to graph ..."
|
||||
:graph/upload-local-confirm-desc "Upload graph \"{1}\" to Logseq Server?"
|
||||
:graph/use-sync-beta "Use Logseq Sync (Beta testing)"
|
||||
:graph/use-sync-label "Use Logseq Sync?"
|
||||
:graph/upload-local-confirm-desc "Upload graph \"{1}\" to Synapse Server?"
|
||||
:graph/use-sync-beta "Use Synapse Sync (Beta testing)"
|
||||
:graph/use-sync-label "Use Synapse Sync?"
|
||||
:graph/view-mode "View mode"
|
||||
:graph/view-mode-all-pages "All pages"
|
||||
:graph/view-mode-tags "Tags"
|
||||
:graph/view-mode-temporal "Timeline"
|
||||
|
||||
:graph.diagnostics/checksum-failed-error "Failed to compute graph checksum diagnostics."
|
||||
:graph.diagnostics/checksum-recomputed-success "Checksum recomputed. Recomputed: {1}, local: {2}, remote: {3}. Downloaded {4}.edn with {5} blocks and checksum attributes {6}."
|
||||
@@ -835,7 +842,7 @@
|
||||
:header/quit-highlight-recent-blocks "Quit highlight recent blocks"
|
||||
:header/toggle-left-sidebar "Toggle left sidebar"
|
||||
|
||||
:help/about "About Logseq"
|
||||
:help/about "About Synapse"
|
||||
:help/about-title "About"
|
||||
:help/ask-community "Ask the community"
|
||||
:help/awesome-logseq "Awesome Logseq"
|
||||
@@ -923,7 +930,7 @@
|
||||
:import/empty-graph-name "Empty graph name."
|
||||
:import/extract-inline-code-snippets "Extract inline code snippets as child blocks"
|
||||
:import/file-finished "Import finished!"
|
||||
:import/file-to-db-desc "Import a file-based Logseq graph folder into a new DB graph"
|
||||
:import/file-to-db-desc "Import a file-based Synapse graph folder into a new DB graph"
|
||||
:import/file-to-db-title "File to DB graph"
|
||||
:import/graph-name-conflict "Please specify another name as another graph with this name already exists!"
|
||||
:import/graph-name-placeholder "Graph name"
|
||||
@@ -934,7 +941,7 @@
|
||||
:import/invalid-blocks-detected "Import detected {1} invalid block(s). These blocks may be buggy when you interact with them. See the javascript console for more."
|
||||
:import/invalid-edn-file "The given EDN file is not valid EDN. Please fix and try again."
|
||||
:import/loading "Importing"
|
||||
:import/logseq-config-missing "Import failed as the file 'logseq/config.edn' was not found for a Logseq graph."
|
||||
:import/logseq-config-missing "Import failed as the file 'logseq/config.edn' was not found for a Synapse graph."
|
||||
:import/new-graph-name "New graph name"
|
||||
:import/notes "Import existing notes"
|
||||
:import/org-files-imported "Imported {1} org file(s) as markdown. Support for org files will be added later."
|
||||
@@ -1008,8 +1015,14 @@
|
||||
:library/add-pages "Add pages"
|
||||
:library/title "Library"
|
||||
|
||||
:mobile/camera-access-denied "Camera access is denied. Enable it in Settings > Logseq."
|
||||
:mobile/microphone-access-denied "Microphone access is denied. Enable it in Settings > Logseq."
|
||||
:live-note/last-fetched "Last fetched:"
|
||||
:live-note/not-fetched-yet "Not fetched yet"
|
||||
:live-note/refresh-now "Refresh now"
|
||||
:live-note/refreshing "Refreshing…"
|
||||
:live-note/title "Live note"
|
||||
|
||||
:mobile/camera-access-denied "Camera access is denied. Enable it in Settings > Synapse."
|
||||
:mobile/microphone-access-denied "Microphone access is denied. Enable it in Settings > Synapse."
|
||||
|
||||
:mobile.header/actions "Actions"
|
||||
:mobile.header/create-graph "Create new graph"
|
||||
@@ -1094,15 +1107,15 @@
|
||||
|
||||
:notification/copied "Copied!"
|
||||
|
||||
:onboarding.import/desc "If they are in an EDN or Markdown format Logseq can work with them."
|
||||
:onboarding.import/sqlite-desc "Import a SQLite DB Export of your Logseq graph into a new DB graph"
|
||||
:onboarding.import/desc "If they are in an EDN or Markdown format Synapse can work with them."
|
||||
:onboarding.import/sqlite-desc "Import a SQLite DB Export of your Synapse graph into a new DB graph"
|
||||
:onboarding.import/title "Do you already have notes that you want to import?"
|
||||
|
||||
:onboarding.import-option/desc "You can also do this later in the app."
|
||||
:onboarding.import-option/title "Import existing notes"
|
||||
|
||||
:onboarding.setup/desc "First you need to choose a folder where Logseq will store your thoughts, ideas, notes."
|
||||
:onboarding.setup/title (fn [] ["Welcome to " [:strong "Logseq!"]])
|
||||
:onboarding.setup/desc "First you need to choose a folder where Synapse will store your thoughts, ideas, notes."
|
||||
:onboarding.setup/title (fn [] ["Welcome to " [:strong "Synapse!"]])
|
||||
|
||||
:page/add-to-favorites "Add to Favorites"
|
||||
:page/added-tag-to-node "Added tag \"{1}\" to \"{2}\""
|
||||
@@ -1121,7 +1134,7 @@
|
||||
:page/go-back-home "Go back home"
|
||||
:page/hide-properties "Hide properties"
|
||||
:page/local-storage "local storage"
|
||||
:page/logseq-is-having-a-problem "Logseq is having a problem. To try to get it back to a working state, please try the following safe steps in order:"
|
||||
:page/logseq-is-having-a-problem "Synapse is having a problem. To try to get it back to a working state, please try the following safe steps in order:"
|
||||
:page/make-private "Make it private"
|
||||
:page/make-public "Make it public for publishing"
|
||||
:page/moved-to-recycle "Node has been moved to Recycle"
|
||||
@@ -1139,7 +1152,8 @@
|
||||
:page/relaunch-desc "Quit and reopen the app."
|
||||
:page/scheduled-and-deadline "Scheduled and Deadline"
|
||||
:page/search-index "search index"
|
||||
:page/send-db-for-debugging "You can send it to help@logseq.com for debugging."
|
||||
:page/send-db-for-debugging "You can send it to dario@dariosevilla.es for debugging."
|
||||
:page/similar-pages "Similar pages"
|
||||
:page/something-went-wrong "Something went wrong"
|
||||
:page/step "Step {1}"
|
||||
:page/the-app "the app"
|
||||
@@ -1257,7 +1271,7 @@
|
||||
:plugin/installed-plugin "Installed plugin: {1}"
|
||||
:plugin/installing "Installing"
|
||||
:plugin/invalid-github-repo-url "Invalid GitHub repo url"
|
||||
:plugin/invalid-package "Illegal Logseq plugin package."
|
||||
:plugin/invalid-package "Illegal Synapse plugin package."
|
||||
:plugin/invalid-plugins-edn "Invalid plugins.edn provided. See javascript console for specific errors"
|
||||
:plugin/list-of-updates "Plugin Updates: "
|
||||
:plugin/load-from-web-url "Load plugin from web url"
|
||||
@@ -1288,7 +1302,7 @@
|
||||
:plugin/refresh-lists "Refresh lists"
|
||||
:plugin/reload "Reload"
|
||||
:plugin/remote-error "Remote error: {1}"
|
||||
:plugin/report-modal-desc "If any plugin is unavailable or you think it contains malicious code, please email {1}. Mention the name of the plugin and the URL of its GitHub repository. The Logseq team usually responds within a business day."
|
||||
:plugin/report-modal-desc "If any plugin is unavailable or you think it contains malicious code, please email {1}. Mention the name of the plugin and the URL of its GitHub repository. The Synapse team usually responds within a business day."
|
||||
:plugin/report-security "Report plugin"
|
||||
:plugin/restart "Restart App"
|
||||
:plugin/search-plugin "Search plugins"
|
||||
@@ -1320,7 +1334,7 @@
|
||||
:plugin.install-from-web-url/supports-note "URLs support both GitHub repositories and local development servers. (For Examples: {1}, {2})"
|
||||
:plugin.install-from-web-url/theme-label "theme"
|
||||
|
||||
:plugin.package-config/detach-desc "Removing it only detaches the plugin from Logseq and keeps the source folder untouched."
|
||||
:plugin.package-config/detach-desc "Removing it only detaches the plugin from Synapse and keeps the source folder untouched."
|
||||
:plugin.package-config/parse-error "Failed to parse the plugin package config."
|
||||
:plugin.package-config/remove-error "Failed to remove the broken plugin."
|
||||
:plugin.package-config/remove-external-success "Removed the broken plugin from the plugin list."
|
||||
@@ -1336,7 +1350,7 @@
|
||||
:plugin.settings/exit-code-mode "Exit code mode"
|
||||
:plugin.settings/title "Plugins"
|
||||
|
||||
:plugin.themes/default-desc "Logseq default {1} theme."
|
||||
:plugin.themes/default-desc "Synapse default {1} theme."
|
||||
:plugin.themes/default-name "Default {1} Theme"
|
||||
:plugin.themes/group "{1} themes"
|
||||
:plugin.themes/light-and-dark "light & dark themes"
|
||||
@@ -1658,7 +1672,6 @@
|
||||
:select/new-option "+ New option: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "MCP URL copied to clipboard!"
|
||||
:server/start "Start server"
|
||||
:server/stop "Stop server"
|
||||
:server/title "HTTP API server"
|
||||
@@ -1685,7 +1698,6 @@
|
||||
|
||||
:settings/account "Account"
|
||||
:settings/advanced "Advanced"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Collaboration"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Encryption"
|
||||
@@ -1701,13 +1713,11 @@
|
||||
:settings.advanced/auto-chmod-desc "Disable to allow editing by multiple users with permissions granted by group membership."
|
||||
:settings.advanced/auto-updater "Auto updater"
|
||||
:settings.advanced/developer-mode "Developer mode"
|
||||
:settings.advanced/developer-mode-desc "Developer mode helps contributors and extension developers test their integrations with Logseq more efficiently."
|
||||
:settings.advanced/disable-sentry "Send usage data and diagnostics to Logseq"
|
||||
:settings.advanced/disable-sentry-desc "Logseq will never collect your local graph database or sell your data."
|
||||
:settings.advanced/developer-mode-desc "Developer mode helps contributors and extension developers test their integrations with Synapse more efficiently."
|
||||
:settings.advanced/disable-sentry "Send usage data and diagnostics to Synapse"
|
||||
:settings.advanced/disable-sentry-desc "Synapse will never collect your local graph database or sell your data."
|
||||
:settings.advanced/network-proxy "Network proxy"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP Server"
|
||||
:settings.ai/enable-mcp-server-desc "Enable MCP Server to allow commmunication with local AI applications. MCP Server depends on HTTP API server."
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Expand block references automatically when zoom-in"
|
||||
:settings.editor/auto-expand-block-refs-tip "This option controls whether to expand the block references automatically when zoom-in."
|
||||
@@ -1728,7 +1738,7 @@
|
||||
:settings.features/enable-flashcards "Flashcards"
|
||||
:settings.features/home-default-page "Set the default home page"
|
||||
:settings.features/home-default-page-update-success "Home default page updated successfully!"
|
||||
:settings.features/login-prompt "To access new features before anyone else you must be an Open Collective Sponsor or Backer of Logseq and therefore log in first."
|
||||
:settings.features/login-prompt "To access new features before anyone else you must be an Open Collective Sponsor or Backer of Synapse and therefore log in first."
|
||||
:settings.features/markdown-mirror "Markdown Mirror"
|
||||
:settings.features/markdown-mirror-desc "Write a derived Markdown copy of edited pages to the graph's mirror/markdown folder. Desktop only."
|
||||
:settings.features/markdown-mirror-regenerate "Regenerate full mirror"
|
||||
@@ -1742,7 +1752,7 @@
|
||||
|
||||
:settings.general/accent-color "Accent color"
|
||||
:settings.general/accent-color-alert "Choosing an accent color may override any theme you have selected."
|
||||
:settings.general/accent-color-logseq "Logseq classic color"
|
||||
:settings.general/accent-color-logseq "Synapse classic color"
|
||||
:settings.general/accent-color-none-desc "Cancel accent color. This is currently in beta stage and mainly used for compatibility with custom themes."
|
||||
:settings.general/changelog "What's new?"
|
||||
:settings.general/check-for-updates "Check for updates"
|
||||
@@ -1768,17 +1778,17 @@
|
||||
:settings.general/theme-light "light"
|
||||
:settings.general/theme-system "system"
|
||||
|
||||
:settings.sync-server/clear-success "Sync server URL cleared. Using official Logseq Sync."
|
||||
:settings.sync-server/clear-success "Sync server URL cleared. Using official Synapse Sync."
|
||||
:settings.sync-server/reset "Reset to default"
|
||||
:settings.sync-server/save-success "Sync server URL saved."
|
||||
:settings.sync-server/url "Sync Server URL"
|
||||
:settings.sync-server/url-desc "Set a custom HTTPS sync server URL for self-hosted sync. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq Sync."
|
||||
:settings.sync-server/url-desc "Set a custom HTTPS sync server URL for self-hosted sync. Your Synapse authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Synapse Sync."
|
||||
:settings.sync-server/url-invalid-error "URL must start with https:// or http://"
|
||||
|
||||
:settings-page/publish-server-url "Publish Server URL"
|
||||
:settings-page/publish-server-url-cleared "Publish server URL cleared. Using official Logseq Publish."
|
||||
:settings-page/publish-server-url-default "Logseq Publish"
|
||||
:settings-page/publish-server-url-desc "Set a custom HTTPS publish server URL for self-hosted single-page publishing. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq Publish service."
|
||||
:settings-page/publish-server-url-cleared "Publish server URL cleared. Using official Synapse Publish."
|
||||
:settings-page/publish-server-url-default "Synapse Publish"
|
||||
:settings-page/publish-server-url-desc "Set a custom HTTPS publish server URL for self-hosted single-page publishing. Your Synapse authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Synapse Publish service."
|
||||
:settings-page/publish-server-url-reset "Reset to default"
|
||||
:settings-page/publish-server-url-saved "Publish server URL saved."
|
||||
|
||||
@@ -1853,7 +1863,7 @@
|
||||
:sync/uploading "Uploading..."
|
||||
:sync/user-doesnt-exist-yet "User doesn't exist yet."
|
||||
|
||||
:theme/logseq-default "Logseq Default theme"
|
||||
:theme/logseq-default "Synapse Default theme"
|
||||
:theme/switch-to "Switch to {1} theme"
|
||||
|
||||
:ui/all-done "All Done!"
|
||||
@@ -2004,7 +2014,7 @@
|
||||
:youtube/timestamps-not-available-mobile "YouTube timestamps aren't available on mobile yet."
|
||||
|
||||
:zotero/attachments "Attachments"
|
||||
:zotero/imported-file-warning "This is a Zotero imported file, set Zotero data directory to open the file in Logseq."
|
||||
:zotero/linked-file-warning "This is a Zotero linked file, set Zotero linked attachment base directory to open the file in Logseq."
|
||||
:zotero/imported-file-warning "This is a Zotero imported file, set Zotero data directory to open the file in Synapse."
|
||||
:zotero/linked-file-warning "This is a Zotero linked file, set Zotero linked attachment base directory to open the file in Synapse."
|
||||
:zotero/notes "Notes"
|
||||
}
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nueva opción: {1}"
|
||||
|
||||
:server/error-notification "[Servidor] {1}"
|
||||
:server/mcp-url-copied "URL MCP copiada"
|
||||
:server/start "Iniciar"
|
||||
:server/stop "Detener"
|
||||
:server/title "Servidor API HTTP"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Cuenta"
|
||||
:settings/advanced "Avanzado"
|
||||
:settings/ai "IA"
|
||||
:settings/collaboration "Colaboración"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Cifrado"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc " Logseq nunca va a recolectar tu base de datos de grafos local o vender tus datos."
|
||||
:settings.advanced/network-proxy "Proxy de red"
|
||||
|
||||
:settings.ai/enable-mcp-server "Habilitar servidor MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Habilitar servidor MCP para integración con IA"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Expandir referencias de bloque automáticamente al hacer un acercamiento"
|
||||
:settings.editor/auto-expand-block-refs-tip "Esta opción controla si expandir el bloque de referencias automáticamente al hacer un acercamiento."
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ گزینه جدید: {1}"
|
||||
|
||||
:server/error-notification "[سرور] {1}"
|
||||
:server/mcp-url-copied "آدرس MCP کپی شد"
|
||||
:server/start "شروع"
|
||||
:server/stop "توقف"
|
||||
:server/title "سرور HTTP API"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "حساب"
|
||||
:settings/advanced "پیشرفته"
|
||||
:settings/ai "هوش مصنوعی"
|
||||
:settings/collaboration "همکاری"
|
||||
:settings/editor "ویرایشگر"
|
||||
:settings/encryption "رمزگذاری"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "غیرفعالسازی گزارش خرابی"
|
||||
:settings.advanced/network-proxy "پروکسی شبکه"
|
||||
|
||||
:settings.ai/enable-mcp-server "فعالسازی سرور MCP"
|
||||
:settings.ai/enable-mcp-server-desc "فعالسازی سرور MCP برای ادغام هوش مصنوعی"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "باز کردن خودکار مراجع بلوک"
|
||||
:settings.editor/auto-expand-block-refs-tip "برای باز کردن خودکار مراجع بلوک کلیک کنید"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nouvelle option : {1}"
|
||||
|
||||
:server/error-notification "[Serveur] {1}"
|
||||
:server/mcp-url-copied "URL MCP copiée"
|
||||
:server/start "Démarrer"
|
||||
:server/stop "Arrêter"
|
||||
:server/title "Serveur API HTTP"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Compte"
|
||||
:settings/advanced "Avancé"
|
||||
:settings/ai "IA"
|
||||
:settings/collaboration "Collaboration"
|
||||
:settings/editor "Éditeur"
|
||||
:settings/encryption "Chiffrement"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq ne collectera jamais votre base de données de graphes locale ni ne vendra vos données."
|
||||
:settings.advanced/network-proxy "Proxy réseau"
|
||||
|
||||
:settings.ai/enable-mcp-server "Activer le serveur MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Activer le serveur MCP pour l'intégration IA"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Étendre automatiquement les références de bloc lors du zoom"
|
||||
:settings.editor/auto-expand-block-refs-tip "Cette option contrôle si les références de bloc sont développées automatiquement lors du zoom."
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Opsi baru: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "URL MCP disalin"
|
||||
:server/start "Mulai"
|
||||
:server/stop "Berhenti"
|
||||
:server/title "Server HTTP API"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Akun"
|
||||
:settings/advanced "Lanjutan"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Kolaborasi"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Enkripsi"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq tidak akan pernah mengumpulkan database grafik lokal Anda atau menjual data Anda."
|
||||
:settings.advanced/network-proxy "Proxy jaringan"
|
||||
|
||||
:settings.ai/enable-mcp-server "Aktifkan server MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Aktifkan server MCP untuk integrasi AI"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Perluas referensi blok secara otomatis saat zoom-in"
|
||||
:settings.editor/auto-expand-block-refs-tip "Opsi ini mengontrol apakah referensi blok akan diperluas secara otomatis saat zoom-in."
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nuova opzione: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "URL MCP copiato"
|
||||
:server/start "Avvia"
|
||||
:server/stop "Ferma"
|
||||
:server/title "Server API HTTP"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Account"
|
||||
:settings/advanced "Avanzate"
|
||||
:settings/ai "IA"
|
||||
:settings/collaboration "Collaborazione"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Crittografia"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq non raccoglierà mai il database del grafo locale e non venderà mai i tuoi dati."
|
||||
:settings.advanced/network-proxy "Proxy di rete"
|
||||
|
||||
:settings.ai/enable-mcp-server "Abilita server MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Abilita il server MCP per l'integrazione AI"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Espandi riferimenti ai blocchi automaticamente quando si ingrandisce"
|
||||
:settings.editor/auto-expand-block-refs-tip "Questa opzione controlla se espandere i riferimenti ai blocchi automaticamente quando si ingrandisce."
|
||||
|
||||
@@ -1529,7 +1529,6 @@
|
||||
:select/new-option "+ 新しいオプション: {1}"
|
||||
|
||||
:server/error-notification "[サーバー] {1}"
|
||||
:server/mcp-url-copied "MCP URLをコピーしました"
|
||||
:server/start "開始"
|
||||
:server/stop "停止"
|
||||
:server/title "HTTP APIサーバー"
|
||||
@@ -1556,7 +1555,6 @@
|
||||
|
||||
:settings/account "アカウント"
|
||||
:settings/advanced "高度な設定"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "コラボレーション"
|
||||
:settings/editor "エディタ"
|
||||
:settings/encryption "暗号化"
|
||||
@@ -1577,8 +1575,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseqはあなたのローカルなグラフを決して取得しませんし、あなたのデータを売ることも決してありません。 "
|
||||
:settings.advanced/network-proxy "ネットワークプロキシ"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCPサーバーを有効化"
|
||||
:settings.ai/enable-mcp-server-desc "MCPサーバーを有効にしてAI連携を可能にします"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "ブロック参照を自動展開"
|
||||
:settings.editor/auto-expand-block-refs-tip "このオプションは、ズームインしたときにブロック参照を展開するかどうかを指定します。"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ 새 옵션: {1}"
|
||||
|
||||
:server/error-notification "[서버] {1}"
|
||||
:server/mcp-url-copied "MCP URL이 복사되었습니다"
|
||||
:server/start "시작"
|
||||
:server/stop "중지"
|
||||
:server/title "HTTP API 서버"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "계정"
|
||||
:settings/advanced "고급"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "협업"
|
||||
:settings/editor "편집기"
|
||||
:settings/encryption "암호화"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq는 사용자의 로컬 그래프 데이터베이스 정보를 수집하지 않습니다."
|
||||
:settings.advanced/network-proxy "네트워크 프록시"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP 서버 활성화"
|
||||
:settings.ai/enable-mcp-server-desc "MCP 서버를 활성화하여 AI 연동을 가능하게 합니다"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "블록 참조 자동 확장"
|
||||
:settings.editor/auto-expand-block-refs-tip "이 설정은 블록을 확대할 때 블록 참조를 자동으로 펼칠지를 결정합니다."
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nytt alternativ: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "MCP-URL kopiert"
|
||||
:server/start "Start"
|
||||
:server/stop "Stopp"
|
||||
:server/title "HTTP API-server"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Konto"
|
||||
:settings/advanced "Avansert"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Samarbeid"
|
||||
:settings/editor "Redigering"
|
||||
:settings/encryption "Kryptering"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Deaktiver krasjrapportering"
|
||||
:settings.advanced/network-proxy "Nettverksproxy"
|
||||
|
||||
:settings.ai/enable-mcp-server "Aktiver MCP-server"
|
||||
:settings.ai/enable-mcp-server-desc "Aktiver MCP-server for AI-integrasjon"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Utvid blokkreferanser automatisk"
|
||||
:settings.editor/auto-expand-block-refs-tip "Klikk for å utvide blokkreferanser automatisk"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nieuwe optie: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "MCP-URL gekopieerd"
|
||||
:server/start "Starten"
|
||||
:server/stop "Stoppen"
|
||||
:server/title "HTTP API-server"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Account"
|
||||
:settings/advanced "Geavanceerd"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Samenwerking"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Versleuteling"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Crashrapportage uitschakelen"
|
||||
:settings.advanced/network-proxy "Netwerk proxy"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP-server inschakelen"
|
||||
:settings.ai/enable-mcp-server-desc "MCP-server inschakelen voor AI-integratie"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Blokreferenties automatisch uitklappen"
|
||||
:settings.editor/auto-expand-block-refs-tip "Klik om blokreferenties automatisch uit te klappen"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nowa opcja: {1}"
|
||||
|
||||
:server/error-notification "[Serwer] {1}"
|
||||
:server/mcp-url-copied "URL MCP skopiowany"
|
||||
:server/start "Uruchom"
|
||||
:server/stop "Zatrzymaj"
|
||||
:server/title "Serwer HTTP API"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Konto"
|
||||
:settings/advanced "Zaawansowane"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Współpraca"
|
||||
:settings/editor "Edytor"
|
||||
:settings/encryption "Szyfrowanie"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq nigdy nie zbiera danych Twoich lokalnych grafów i nie sprzedaje twoich danych."
|
||||
:settings.advanced/network-proxy "Ustawienia proxy"
|
||||
|
||||
:settings.ai/enable-mcp-server "Włącz serwer MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Włącz serwer MCP do integracji z AI"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Automatycznie rozwijaj odniesienia do bloków"
|
||||
:settings.editor/auto-expand-block-refs-tip "Automatycznie rozwijaj odniesienia do bloków po kliknięciu"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nova opção: {1}"
|
||||
|
||||
:server/error-notification "[Servidor] {1}"
|
||||
:server/mcp-url-copied "URL MCP copiada"
|
||||
:server/start "Iniciar"
|
||||
:server/stop "Parar"
|
||||
:server/title "Servidor de API HTTP"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Conta"
|
||||
:settings/advanced "Avançado"
|
||||
:settings/ai "IA"
|
||||
:settings/collaboration "Colaboração"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Criptografia"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "O Logseq nunca coletará o banco de dados do seu grafo local nem venderá seus dados."
|
||||
:settings.advanced/network-proxy "Proxy de rede"
|
||||
|
||||
:settings.ai/enable-mcp-server "Habilitar servidor MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Habilitar servidor MCP para integração com IA"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Expandir automaticamente as referências de bloco ao dar zoom"
|
||||
:settings.editor/auto-expand-block-refs-tip "Esta opção controla se as referências de bloco são expandidas automaticamente ao dar zoom."
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nova opção: {1}"
|
||||
|
||||
:server/error-notification "[Servidor] {1}"
|
||||
:server/mcp-url-copied "URL MCP copiada"
|
||||
:server/start "Iniciar"
|
||||
:server/stop "Parar"
|
||||
:server/title "Servidor de API HTTP"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Conta"
|
||||
:settings/advanced "Avançado"
|
||||
:settings/ai "IA"
|
||||
:settings/collaboration "Colaboração"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Encriptação"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "A Logseq nunca irá recolher informações sobre a sua base de dados de grafos local ou vender os seus dados."
|
||||
:settings.advanced/network-proxy "Proxy de rede"
|
||||
|
||||
:settings.ai/enable-mcp-server "Ativar servidor MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Ativar servidor MCP para integração com IA"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Expandir referências de blocos automaticamente"
|
||||
:settings.editor/auto-expand-block-refs-tip "Expandir referências de blocos automaticamente ao clicar"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Новая опция: {1}"
|
||||
|
||||
:server/error-notification "[Сервер] {1}"
|
||||
:server/mcp-url-copied "URL MCP скопирован"
|
||||
:server/start "Запустить"
|
||||
:server/stop "Остановить"
|
||||
:server/title "HTTP API сервер"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Аккаунт"
|
||||
:settings/advanced "Продвинутые"
|
||||
:settings/ai "ИИ"
|
||||
:settings/collaboration "Совместная работа"
|
||||
:settings/editor "Редактор"
|
||||
:settings/encryption "Шифрование"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq никогда не будет собирать вашу локальную базу данных графов или продавать ваши данные"
|
||||
:settings.advanced/network-proxy "Прокси-сервер"
|
||||
|
||||
:settings.ai/enable-mcp-server "Включить сервер MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Включить сервер MCP для интеграции с ИИ"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Автораскрытие ссылок на блоки"
|
||||
:settings.editor/auto-expand-block-refs-tip "Автоматически раскрывать ссылки на блоки при нажатии"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Nová možnosť: {1}"
|
||||
|
||||
:server/error-notification "[Server] {1}"
|
||||
:server/mcp-url-copied "URL MCP skopírované"
|
||||
:server/start "Spustiť"
|
||||
:server/stop "Zastaviť"
|
||||
:server/title "HTTP API server"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Účet"
|
||||
:settings/advanced "Pokročilé"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "Spolupráca"
|
||||
:settings/editor "Editor"
|
||||
:settings/encryption "Šifrovanie"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq nebude nikdy zhromažďovať vašu lokálnu databázu grafov ani predávať vaše údaje."
|
||||
:settings.advanced/network-proxy "Sieťová proxy"
|
||||
|
||||
:settings.ai/enable-mcp-server "Zapnúť server MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Zapnout server MCP pro integraci s AI"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Automaticky rozbalit odkazy na bloky"
|
||||
:settings.editor/auto-expand-block-refs-tip "Automaticky rozbalit odkazy na bloky při kliknutí"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Yeni seçenek: {1}"
|
||||
|
||||
:server/error-notification "[Sunucu] {1}"
|
||||
:server/mcp-url-copied "MCP URL'si kopyalandı"
|
||||
:server/start "Başlat"
|
||||
:server/stop "Durdur"
|
||||
:server/title "HTTP API sunucusu"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Hesap"
|
||||
:settings/advanced "Gelişmiş"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "İş birliği"
|
||||
:settings/editor "Editör"
|
||||
:settings/encryption "Şifreleme"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq asla yerel graf veritabanınızı toplamayacak veya verilerinizi satmayacaktır."
|
||||
:settings.advanced/network-proxy "Ağ ara sunucusu"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP sunucusunu etkinleştir"
|
||||
:settings.ai/enable-mcp-server-desc "AI entegrasyonu için MCP sunucusunu etkinleştir"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Blok referanslarını otomatik genişlet"
|
||||
:settings.editor/auto-expand-block-refs-tip "Tıklandığında blok referanslarını otomatik genişlet"
|
||||
|
||||
@@ -1516,7 +1516,6 @@
|
||||
:select/new-option "+ Нова опція: {1}"
|
||||
|
||||
:server/error-notification "[Сервер] {1}"
|
||||
:server/mcp-url-copied "URL MCP скопирован"
|
||||
:server/start "Запустити"
|
||||
:server/stop "Зупинити"
|
||||
:server/title "HTTP API сервер"
|
||||
@@ -1543,7 +1542,6 @@
|
||||
|
||||
:settings/account "Обліковий запис"
|
||||
:settings/advanced "Розширення"
|
||||
:settings/ai "ШІ"
|
||||
:settings/collaboration "Спільна робота"
|
||||
:settings/editor "Редактор"
|
||||
:settings/encryption "Шифрування"
|
||||
@@ -1564,8 +1562,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq ніколи не збиратиме вашу локальну базу даних графів і не відправлятиме ваші дані."
|
||||
:settings.advanced/network-proxy "Мережевий проксі"
|
||||
|
||||
:settings.ai/enable-mcp-server "Увімкнути сервер MCP"
|
||||
:settings.ai/enable-mcp-server-desc "Увімкнути сервер MCP для интеграции с ИИ"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "Автораскрытие ссылок на блоки"
|
||||
:settings.editor/auto-expand-block-refs-tip "Автоматически раскрывать ссылки на блоки при нажатии"
|
||||
|
||||
@@ -1658,7 +1658,6 @@
|
||||
:select/new-option "+ 新建选项:{1}"
|
||||
|
||||
:server/error-notification "[服务器] {1}"
|
||||
:server/mcp-url-copied "MCP URL 已复制到剪贴板!"
|
||||
:server/start "启动服务器"
|
||||
:server/stop "停止服务器"
|
||||
:server/title "HTTP API 服务器"
|
||||
@@ -1685,7 +1684,6 @@
|
||||
|
||||
:settings/account "账户"
|
||||
:settings/advanced "高级"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "协作"
|
||||
:settings/editor "编辑器"
|
||||
:settings/encryption "加密"
|
||||
@@ -1706,8 +1704,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq 从来不会收集或出售你的图谱数据。"
|
||||
:settings.advanced/network-proxy "网络代理"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP 服务器"
|
||||
:settings.ai/enable-mcp-server-desc "启用 MCP 服务器,允许与本地 AI 应用通信"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "自动展开聚焦块的引用"
|
||||
:settings.editor/auto-expand-block-refs-tip "此选项控制是否在页面加载时展开块引用"
|
||||
|
||||
@@ -1529,7 +1529,6 @@
|
||||
:select/new-option "+ 新選項: {1}"
|
||||
|
||||
:server/error-notification "[伺服器] {1}"
|
||||
:server/mcp-url-copied "MCP URL 已複製到剪貼簿!"
|
||||
:server/start "啟動伺服器"
|
||||
:server/stop "停止伺服器"
|
||||
:server/title "HTTP API 伺服器"
|
||||
@@ -1556,7 +1555,6 @@
|
||||
|
||||
:settings/account "帳戶"
|
||||
:settings/advanced "進階功能"
|
||||
:settings/ai "AI"
|
||||
:settings/collaboration "協作"
|
||||
:settings/editor "編輯器"
|
||||
:settings/encryption "加密"
|
||||
@@ -1577,8 +1575,6 @@
|
||||
:settings.advanced/disable-sentry-desc "Logseq 永遠不會收集你的圖譜資料或出售你的數據。"
|
||||
:settings.advanced/network-proxy "網路代理"
|
||||
|
||||
:settings.ai/enable-mcp-server "MCP 伺服器"
|
||||
:settings.ai/enable-mcp-server-desc "啟用 MCP 伺服器,允許與本地 AI 應用程式通訊"
|
||||
|
||||
:settings.editor/auto-expand-block-refs "自動展開聚焦 block 的引用"
|
||||
:settings.editor/auto-expand-block-refs-tip "此選項控制是否在頁面載入時展開區塊引用"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
(ns frontend.components.views-test
|
||||
(:require ["react" :as react]
|
||||
["react-dom/server" :as react-dom-server]
|
||||
[cljs-time.coerce :as tc]
|
||||
[cljs-time.core :as t]
|
||||
[cljs.test :refer [async deftest is use-fixtures]]
|
||||
[clojure.string :as string]
|
||||
[datascript.impl.entity :as de]
|
||||
@@ -484,6 +486,16 @@
|
||||
"An unmounted reaction resource starts no reload.")
|
||||
(unmount! mounted-view))))))))
|
||||
|
||||
(deftest calendar-row-date-prefers-scheduled-over-deadline
|
||||
(is (= 100 (#'views/calendar-row-date-ms {:logseq.property/scheduled 100 :logseq.property/deadline 200})))
|
||||
(is (= 200 (#'views/calendar-row-date-ms {:logseq.property/deadline 200})))
|
||||
(is (nil? (#'views/calendar-row-date-ms {}))))
|
||||
|
||||
(deftest calendar-month-bounds-spans-exactly-one-month
|
||||
(let [[start end] (#'views/calendar-month-bounds (t/date-time 2026 2 15))]
|
||||
(is (= (tc/to-long (t/date-time 2026 2 1)) start))
|
||||
(is (= (tc/to-long (t/date-time 2026 3 1)) end))))
|
||||
|
||||
(deftest group-by-column-should-exclude-name-and-include-many-properties
|
||||
(is (views/group-by-column? {:id :block/page}))
|
||||
(is (not (views/group-by-column? {:id :block/title
|
||||
|
||||
17
src/test/frontend/extensions/graph/pixi/logic_test.cljs
Normal file
17
src/test/frontend/extensions/graph/pixi/logic_test.cljs
Normal file
@@ -0,0 +1,17 @@
|
||||
(ns frontend.extensions.graph.pixi.logic-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[frontend.extensions.graph.pixi.logic :as logic]))
|
||||
|
||||
(deftest temporal-view-mode-lays-out-nodes-on-a-fixed-timeline
|
||||
(let [nodes [{:id "a" :kind "page" :block/created-at 300}
|
||||
{:id "b" :kind "page" :block/created-at 100}
|
||||
{:id "c" :kind "page" :block/created-at 200}]
|
||||
links []
|
||||
result (logic/layout-nodes nodes links :temporal false)
|
||||
by-id (into {} (map (juxt :id identity) result))]
|
||||
(testing "nodes are ordered chronologically along the x-axis, oldest first"
|
||||
(is (< (:x (by-id "b")) (:x (by-id "c")) (:x (by-id "a")))))
|
||||
(testing "every node gets a fixed position and radius, no force simulation"
|
||||
(is (every? #(and (number? (:x %)) (number? (:y %)) (number? (:radius %))) result)))
|
||||
(testing "re-running layout for the same input is deterministic"
|
||||
(is (= result (logic/layout-nodes nodes links :temporal false))))))
|
||||
@@ -364,6 +364,21 @@
|
||||
(is (contains? (set (:links result))
|
||||
{:source hub-id :target page-id}))))
|
||||
|
||||
(deftest block-graph-follows-citation-target-ref
|
||||
(let [conn (db-test/create-conn-with-blocks
|
||||
{:pages-and-blocks
|
||||
[{:page {:block/title "Citing page"}
|
||||
:blocks [{:block/title "a quote"
|
||||
:build/tags [:logseq.class/Citation]}]}
|
||||
{:page {:block/title "Cited page"}}]})
|
||||
citation-block (d/entity @conn (d/q '[:find ?b . :where [?b :block/title "a quote"]] @conn))
|
||||
cited-page (d/entity @conn (d/q '[:find ?b . :where [?b :block/title "Cited page"]] @conn))
|
||||
_ (d/transact! conn [[:db/add (:db/id citation-block) :logseq.property.citation/target (:db/id cited-page)]
|
||||
[:db/add (:db/id citation-block) :block/refs (:db/id cited-page)]])
|
||||
result (graph-view/build-graph @conn {:type :block :block/uuid (:block/uuid citation-block)})]
|
||||
(is (= #{"a quote" "Cited page"} (node-labels result))
|
||||
"The citation graph follows the node-typed citation target ref, same as any other reference")))
|
||||
|
||||
(deftest tags-and-objects-graph-skips-large-unrelated-page-set-quickly
|
||||
(let [conn (db-test/create-conn-with-blocks
|
||||
{:pages-and-blocks
|
||||
|
||||
137
src/test/frontend/worker/handler/caldav_test.cljs
Normal file
137
src/test/frontend/worker/handler/caldav_test.cljs
Normal file
@@ -0,0 +1,137 @@
|
||||
(ns frontend.worker.handler.caldav-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[frontend.worker.handler.caldav :as caldav]))
|
||||
|
||||
(deftest repeat->rrule-only-translates-the-plus-repeat-type
|
||||
(testing "advance-from-scheduled with a daily unit and no explicit interval"
|
||||
(is (= "FREQ=DAILY"
|
||||
(caldav/repeat->rrule {:repeat-type :logseq.property.repeat/repeat-type.plus
|
||||
:recur-unit :logseq.property.repeat/recur-unit.day}))))
|
||||
(testing "an interval > 1 is included"
|
||||
(is (= "FREQ=WEEKLY;INTERVAL=2"
|
||||
(caldav/repeat->rrule {:repeat-type :logseq.property.repeat/repeat-type.plus
|
||||
:recur-unit :logseq.property.repeat/recur-unit.week
|
||||
:recur-frequency 2}))))
|
||||
(testing "dotted-plus (advance from completion) has no RRULE equivalent"
|
||||
(is (nil? (caldav/repeat->rrule {:repeat-type :logseq.property.repeat/repeat-type.dotted-plus
|
||||
:recur-unit :logseq.property.repeat/recur-unit.day}))))
|
||||
(testing "double-plus (skip to future) has no RRULE equivalent"
|
||||
(is (nil? (caldav/repeat->rrule {:repeat-type :logseq.property.repeat/repeat-type.double-plus
|
||||
:recur-unit :logseq.property.repeat/recur-unit.day})))))
|
||||
|
||||
(deftest rrule->repeat-only-understands-a-bare-freq-and-interval
|
||||
(testing "a plain FREQ round-trips to the plus repeat type"
|
||||
(is (= {:repeat-type :logseq.property.repeat/repeat-type.plus
|
||||
:recur-unit :logseq.property.repeat/recur-unit.month
|
||||
:recur-frequency nil}
|
||||
(caldav/rrule->repeat "FREQ=MONTHLY"))))
|
||||
(testing "FREQ;INTERVAL round-trips with the interval preserved"
|
||||
(is (= {:repeat-type :logseq.property.repeat/repeat-type.plus
|
||||
:recur-unit :logseq.property.repeat/recur-unit.year
|
||||
:recur-frequency 3}
|
||||
(caldav/rrule->repeat "FREQ=YEARLY;INTERVAL=3"))))
|
||||
(testing "anything with BYDAY/COUNT/UNTIL or another unsupported part is left untranslated"
|
||||
(is (nil? (caldav/rrule->repeat "FREQ=WEEKLY;BYDAY=MO,WE,FR")))
|
||||
(is (nil? (caldav/rrule->repeat "FREQ=DAILY;COUNT=5")))
|
||||
(is (nil? (caldav/rrule->repeat "FREQ=DAILY;UNTIL=20260101T000000Z"))))
|
||||
(testing "an unrecognized FREQ value is left untranslated"
|
||||
(is (nil? (caldav/rrule->repeat "FREQ=SECONDLY"))))
|
||||
(testing "nil/blank input is left untranslated"
|
||||
(is (nil? (caldav/rrule->repeat nil)))))
|
||||
|
||||
(deftest block->vevent-round-trips-through-parse-ical-component
|
||||
(let [ms (.getTime (js/Date. "2026-03-15T10:00:00Z"))
|
||||
ical (caldav/block->vevent {:uid "evt-1"
|
||||
:summary "Team sync"
|
||||
:description "Weekly ; check-in, notes\nwith a line break"
|
||||
:location "Room A"
|
||||
:dtstart ms
|
||||
:dtend (+ ms 3600000)
|
||||
:rrule "FREQ=WEEKLY"})
|
||||
parsed (caldav/parse-ical-component ical)]
|
||||
(is (= "VEVENT" (:type parsed)))
|
||||
(is (= "evt-1" (:uid parsed)))
|
||||
(is (= "Team sync" (:summary parsed)))
|
||||
(is (= "Weekly ; check-in, notes\nwith a line break" (:description parsed)))
|
||||
(is (= "Room A" (:location parsed)))
|
||||
(is (= ms (:dtstart parsed)))
|
||||
(is (= (+ ms 3600000) (:dtend parsed)))
|
||||
(is (= "FREQ=WEEKLY" (:rrule parsed)))))
|
||||
|
||||
(deftest block->vtodo-round-trips-through-parse-ical-component
|
||||
(let [ms (.getTime (js/Date. "2026-03-15T10:00:00Z"))
|
||||
ical (caldav/block->vtodo {:uid "task-1"
|
||||
:summary "Ship the release"
|
||||
:dtstart ms
|
||||
:due (+ ms 86400000)
|
||||
:status-ident :logseq.property/status.done})
|
||||
parsed (caldav/parse-ical-component ical)]
|
||||
(is (= "VTODO" (:type parsed)))
|
||||
(is (= "task-1" (:uid parsed)))
|
||||
(is (= "Ship the release" (:summary parsed)))
|
||||
(is (= ms (:dtstart parsed)))
|
||||
(is (= (+ ms 86400000) (:due parsed)))
|
||||
(is (= "COMPLETED" (:status parsed)))))
|
||||
|
||||
(deftest parse-ical-component-returns-nil-for-non-ical-text
|
||||
(is (nil? (caldav/parse-ical-component "not an ics file at all"))))
|
||||
|
||||
(deftest contact->vcard-round-trips-through-parse-vcard
|
||||
(let [vcard (caldav/contact->vcard {:uid "contact-1"
|
||||
:full-name "Ada, Lovelace"
|
||||
:email "ada@example.com"
|
||||
:phone "+1 555 0100"
|
||||
:organization "Analytical Engines Ltd"})
|
||||
parsed (caldav/parse-vcard vcard)]
|
||||
(is (= "contact-1" (:uid parsed)))
|
||||
(is (= "Ada, Lovelace" (:full-name parsed)))
|
||||
(is (= "ada@example.com" (:email parsed)))
|
||||
(is (= "+1 555 0100" (:phone parsed)))
|
||||
(is (= "Analytical Engines Ltd" (:organization parsed)))))
|
||||
|
||||
(deftest parse-vcard-returns-nil-for-non-vcard-text
|
||||
(is (nil? (caldav/parse-vcard "BEGIN:VEVENT\nEND:VEVENT"))))
|
||||
|
||||
(def ^:private sample-multistatus
|
||||
"<?xml version=\"1.0\"?>
|
||||
<d:multistatus xmlns:d=\"DAV:\" xmlns:cal=\"urn:ietf:params:xml:ns:caldav\">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/calendars/dario/personal/evt-1.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"abc123"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:evt-1
|
||||
SUMMARY:Team & sync
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/calendars/dario/personal/evt-2.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"def456"</d:getetag>
|
||||
<cal:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:evt-2
|
||||
SUMMARY:Retro
|
||||
END:VEVENT
|
||||
END:VCALENDAR</cal:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>")
|
||||
|
||||
(deftest parse-multistatus-extracts-href-etag-and-data-per-response
|
||||
(let [items (caldav/parse-multistatus "calendar-data" sample-multistatus)]
|
||||
(is (= 2 (count items)))
|
||||
(is (= "/remote.php/dav/calendars/dario/personal/evt-1.ics" (:href (first items))))
|
||||
(is (= "\"abc123\"" (:etag (first items))))
|
||||
(is (= "evt-1" (:uid (caldav/parse-ical-component (:data (first items))))))
|
||||
(is (= "Team & sync" (:summary (caldav/parse-ical-component (:data (first items))))))
|
||||
(is (= "evt-2" (:uid (caldav/parse-ical-component (:data (second items))))))))
|
||||
86
src/test/frontend/worker/handler/kernel_test.cljs
Normal file
86
src/test/frontend/worker/handler/kernel_test.cljs
Normal file
@@ -0,0 +1,86 @@
|
||||
(ns frontend.worker.handler.kernel-test
|
||||
(:require [cljs.test :refer [deftest is testing use-fixtures]]
|
||||
[frontend.worker.handler.kernel :as kernel]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[logseq.db.test.helper :as db-test]))
|
||||
|
||||
(def ^:private test-repo "kernel-test-repo")
|
||||
|
||||
(defn- with-test-conn
|
||||
[f]
|
||||
(let [conn (db-test/create-conn-with-blocks
|
||||
{:pages-and-blocks
|
||||
[{:page {:block/title "Kernel page"}
|
||||
:blocks [{:block/title "code block"}]}]})
|
||||
prev-conns @worker-state/*datascript-conns]
|
||||
(swap! worker-state/*datascript-conns assoc test-repo conn)
|
||||
(try
|
||||
(f conn)
|
||||
(finally
|
||||
(reset! worker-state/*datascript-conns prev-conns)
|
||||
(kernel/clear-repo-sessions! test-repo)))))
|
||||
|
||||
(use-fixtures :each
|
||||
{:after #(kernel/clear-repo-sessions! test-repo)})
|
||||
|
||||
(deftest eval-block-persists-result-onto-the-block
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))
|
||||
result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(+ 1 2)")]
|
||||
(is (= "3" (:result result)))
|
||||
(is (false? (:error? result)))
|
||||
(let [updated (db-test/find-block-by-content @conn "code block")]
|
||||
(is (= "3" (:logseq.property.code/result updated)))
|
||||
(is (number? (:logseq.property.code/executed-at updated))))))))
|
||||
|
||||
(deftest eval-block-formats-errors-with-a-prefix
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))
|
||||
result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(throw (ex-info \"boom\" {}))")]
|
||||
(is (true? (:error? result)))
|
||||
(is (re-find #"^Error: " (:result result)))))))
|
||||
|
||||
(deftest kernel-session-state-persists-across-evaluations-on-the-same-page
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))]
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0)")
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter (inc counter))")
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter (inc counter))")
|
||||
(let [result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "counter")]
|
||||
(is (= "2" (:result result))))))))
|
||||
|
||||
(deftest kernel-sessions-are-isolated-per-page
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-a (random-uuid)
|
||||
page-b (random-uuid)]
|
||||
(kernel/eval-block! test-repo page-a (:block/uuid block) "(def shared 1)")
|
||||
(let [result (kernel/eval-block! test-repo page-b (:block/uuid block) "(def shared 2) shared")]
|
||||
(is (= "2" (:result result))))
|
||||
(let [result (kernel/eval-block! test-repo page-a (:block/uuid block) "shared")]
|
||||
(is (= "1" (:result result))))))))
|
||||
|
||||
(deftest restart-session-clears-persisted-state
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))]
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0)")
|
||||
(kernel/restart-session! test-repo page-uuid)
|
||||
(let [result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0) counter")]
|
||||
(is (= "0" (:result result))))))))
|
||||
|
||||
(deftest kernel-namespace-exposes-the-same-helpers-as-the-client-evaluator
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))]
|
||||
(is (= "6" (:result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(sum [1 2 3])"))))
|
||||
(is (= "2" (:result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(average [1 2 3])"))))))))
|
||||
150
src/test/frontend/worker/handler/live_notes_test.cljs
Normal file
150
src/test/frontend/worker/handler/live_notes_test.cljs
Normal file
@@ -0,0 +1,150 @@
|
||||
(ns frontend.worker.handler.live-notes-test
|
||||
(:require [cljs.test :refer [async deftest is testing]]
|
||||
[datascript.core :as d]
|
||||
[frontend.worker.handler.live-notes :as live-notes]
|
||||
[frontend.worker.handler.page :as page-handler]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[logseq.db.test.helper :as db-test]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(deftest effective-refresh-minutes-clamps-to-a-minimum
|
||||
(is (= 60 (live-notes/effective-refresh-minutes nil)))
|
||||
(is (= 60 (live-notes/effective-refresh-minutes 60)))
|
||||
(is (= 5 (live-notes/effective-refresh-minutes 1)))
|
||||
(is (= 30 (live-notes/effective-refresh-minutes 30))))
|
||||
|
||||
(deftest due?-is-true-when-never-fetched
|
||||
(is (true? (live-notes/due? 1000000 nil 60)))
|
||||
(is (true? (live-notes/due? 1000000 "not-a-number" 60))))
|
||||
|
||||
(deftest due?-compares-elapsed-time-against-the-refresh-interval
|
||||
(let [now 10000000
|
||||
one-hour-ms (* 60 60 1000)]
|
||||
(testing "not yet due"
|
||||
(is (false? (live-notes/due? now (- now (/ one-hour-ms 2)) 60))))
|
||||
(testing "exactly due"
|
||||
(is (true? (live-notes/due? now (- now one-hour-ms) 60))))
|
||||
(testing "overdue"
|
||||
(is (true? (live-notes/due? now (- now (* 2 one-hour-ms)) 60))))))
|
||||
|
||||
(deftest truncate-content-leaves-short-text-untouched
|
||||
(is (= "short" (live-notes/truncate-content "short")))
|
||||
(is (nil? (live-notes/truncate-content nil))))
|
||||
|
||||
(deftest truncate-content-truncates-long-text-with-a-marker
|
||||
(let [long-text (apply str (repeat 9000 "a"))
|
||||
result (live-notes/truncate-content long-text)]
|
||||
(is (< (count result) (count long-text)))
|
||||
(is (re-find #"\(truncated\)$" result))))
|
||||
|
||||
(def ^:private test-repo "live-notes-test-repo")
|
||||
|
||||
;; :url is a ref-typed property (its value is wrapped in a property-value
|
||||
;; entity, per db-property-build/build-properties-with-ref-values), so it
|
||||
;; can't be set with a raw [:db/add eid attr string] tuple, and :build/tags
|
||||
;; + :build/properties on create-conn-with-blocks' page map only recognizes
|
||||
;; property/class namespaces curated into logseq-property-namespaces/
|
||||
;; db-class/logseq-class? -- going through page-handler/create! (the same
|
||||
;; path caldav.cljs's upsert-page! uses to create tagged pages with
|
||||
;; properties) exercises the real ref-value-building pipeline instead.
|
||||
(defn- with-test-conn
|
||||
[f]
|
||||
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
|
||||
_ (page-handler/create! conn "Weather"
|
||||
{:tags [(d/entity @conn [:db/ident :logseq.class/LiveNote])]
|
||||
:properties {:logseq.property.livenote/url "https://example.com/weather"
|
||||
:logseq.property.livenote/refresh-minutes 60}})
|
||||
_ (page-handler/create! conn "Plain page" {})
|
||||
prev-conns @worker-state/*datascript-conns]
|
||||
(swap! worker-state/*datascript-conns assoc test-repo conn)
|
||||
(try
|
||||
(f conn)
|
||||
(finally
|
||||
(reset! worker-state/*datascript-conns prev-conns)
|
||||
(live-notes/stop-refresh! test-repo)))))
|
||||
|
||||
(defn- fake-fetch
|
||||
[response-text]
|
||||
(fn [_url _opts]
|
||||
(js/Promise.resolve
|
||||
#js {:ok true
|
||||
:text (fn [] (js/Promise.resolve response-text))})))
|
||||
|
||||
(defn- failing-fetch
|
||||
[_url _opts]
|
||||
(js/Promise.resolve #js {:ok false :status 500 :text (fn [] (js/Promise.resolve ""))}))
|
||||
|
||||
(def ^:private test-platform
|
||||
{:env {:publishing? false :runtime :node}
|
||||
:storage {}
|
||||
:kv {:get (fn [_] nil) :set! (fn [_ _] nil)}
|
||||
:broadcast {:post-message! (fn [& _] nil)}
|
||||
:websocket {:connect (fn [_] #js {})}
|
||||
:sqlite {}
|
||||
:crypto {:save-secret-text! (fn [_key _text] (p/resolved nil))
|
||||
:read-secret-text (fn [_key] (p/resolved nil))
|
||||
:delete-secret-text! (fn [_key] (p/resolved nil))}
|
||||
:timers {:set-interval! (fn [_ _] nil)}})
|
||||
|
||||
;; live-notes/<read-auth calls (platform/current), which throws unless the
|
||||
;; platform adapter is installed for this test process. with-redefs only
|
||||
;; holds for the SYNCHRONOUS extent of its body -- <tick! resolves through
|
||||
;; several .then callbacks that run after this fn returns, by which point a
|
||||
;; with-redefs binding would already be gone (and platform/current back to
|
||||
;; throwing) -- so install a real platform via set-platform! instead, kept
|
||||
;; live until the returned promise settles, matching the async-safe
|
||||
;; save/restore pattern in db_worker_test.cljs's restoring-worker-state.
|
||||
(defn- with-stub-platform
|
||||
"Installs test-platform for the async extent of (f), then restores the
|
||||
prior platform and calls done -- in that order, so the next async test
|
||||
can't install its own platform before this one's cleanup runs (a race
|
||||
that previously clobbered the next test's platform back to nil)."
|
||||
[f done]
|
||||
(let [platform-prev @@#'platform/*platform]
|
||||
(platform/set-platform! test-platform)
|
||||
(-> (f)
|
||||
(p/catch (fn [e] (is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(reset! @#'platform/*platform platform-prev)
|
||||
(done))))))
|
||||
|
||||
(deftest tick-fetches-due-live-notes-and-transacts-content
|
||||
(async done
|
||||
(with-stub-platform
|
||||
(fn []
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(-> (live-notes/<tick! test-repo (fake-fetch "Sunny, 22C"))
|
||||
(p/then (fn [_]
|
||||
(let [page (db-test/find-page-by-title @conn "Weather")]
|
||||
(is (= "Sunny, 22C" (:logseq.property.livenote/content page)))
|
||||
(is (number? (:logseq.property.livenote/last-fetched-at page)))
|
||||
(is (nil? (:logseq.property.livenote/last-error page))))))))))
|
||||
done)))
|
||||
|
||||
(deftest tick-ignores-pages-that-are-not-live-notes
|
||||
(async done
|
||||
(with-stub-platform
|
||||
(fn []
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(-> (live-notes/<tick! test-repo (fake-fetch "irrelevant"))
|
||||
(p/then (fn [_]
|
||||
(let [page (db-test/find-page-by-title @conn "Plain page")]
|
||||
(is (nil? (:logseq.property.livenote/content page))))))))))
|
||||
done)))
|
||||
|
||||
(deftest tick-records-an-error-when-the-fetch-fails
|
||||
(async done
|
||||
(with-stub-platform
|
||||
(fn []
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(-> (live-notes/<tick! test-repo failing-fetch)
|
||||
(p/then (fn [_]
|
||||
(let [page (db-test/find-page-by-title @conn "Weather")]
|
||||
(is (nil? (:logseq.property.livenote/content page)))
|
||||
(is (string? (:logseq.property.livenote/last-error page)))
|
||||
(is (number? (:logseq.property.livenote/last-fetched-at page))))))))))
|
||||
done)))
|
||||
@@ -673,9 +673,9 @@
|
||||
{:page {:block/title "Built In"
|
||||
:build/properties {:logseq.property/built-in? true}}
|
||||
:blocks [{:block/title "system"}]}
|
||||
{:page {:block/title "Project"
|
||||
{:page {:block/title "Custom Tag"
|
||||
:block/tags #{:logseq.class/Tag}
|
||||
:db/ident :user.class/Project}
|
||||
:db/ident :user.class/CustomTag}
|
||||
:blocks [{:block/title "class"}]}
|
||||
{:page {:block/title "rating"
|
||||
:block/tags #{:logseq.class/Property}
|
||||
@@ -686,16 +686,16 @@
|
||||
(is (not= ::missing-mirror-repo-fn result))
|
||||
(let [page-a (db-test/find-page-by-title @conn "Page A")
|
||||
journal (db-test/find-journal-by-journal-day @conn 20240508)
|
||||
project (db-test/find-page-by-title @conn "Project")]
|
||||
custom-tag (db-test/find-page-by-title @conn "Custom Tag")]
|
||||
(is (= (str (page-marker (:block/uuid page-a)) "\n\n"
|
||||
"- alpha")
|
||||
(get @files (page-path "pages/Page A.md"))))
|
||||
(is (= (str (page-marker (:block/uuid journal)) "\n\n"
|
||||
"- journal")
|
||||
(get @files (page-path "journals/2024_05_08.md"))))
|
||||
(is (= (str (page-marker (:block/uuid project)) "\n\n"
|
||||
(is (= (str (page-marker (:block/uuid custom-tag)) "\n\n"
|
||||
"- class")
|
||||
(get @files (page-path "pages/Project.md")))))
|
||||
(get @files (page-path "pages/Custom Tag.md")))))
|
||||
(is (nil? (get @files (page-path "pages/Built In.md"))))
|
||||
(is (nil? (get @files (page-path "pages/rating.md"))))))
|
||||
(p/catch (fn [e] (is false (str "unexpected error: " e))))
|
||||
|
||||
120
src/test/frontend/worker/mcp_server_test.cljs
Normal file
120
src/test/frontend/worker/mcp_server_test.cljs
Normal file
@@ -0,0 +1,120 @@
|
||||
(ns frontend.worker.mcp-server-test
|
||||
"Exercises the daemon's /mcp endpoint (Fase 9) with a real MCP client SDK
|
||||
connection over HTTP against a live daemon -- the same live-daemon
|
||||
pattern db_worker_node_test.cljs and semantic_test.cljs use."
|
||||
(:require ["@modelcontextprotocol/sdk/client/index.js" :refer [Client]]
|
||||
["@modelcontextprotocol/sdk/client/streamableHttp.js" :refer [StreamableHTTPClientTransport]]
|
||||
[cljs.test :refer [async deftest is]]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[frontend.worker.db-worker-node :as db-worker-node]
|
||||
[frontend.worker.semantic-routes :as semantic-routes]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Same literal credential db_worker_node_test.cljs and semantic_test.cljs
|
||||
;; bootstrap with -- see semantic_test.cljs for why this must not be a
|
||||
;; separate value (all three namespaces share one test.js process).
|
||||
(def ^:private test-daemon-username "db-worker-node-test-user")
|
||||
(def ^:private test-daemon-password "db-worker-node-test-password")
|
||||
|
||||
(aset (.-env js/process) "LOGSEQ_DAEMON_USERNAME" test-daemon-username)
|
||||
(aset (.-env js/process) "LOGSEQ_DAEMON_PASSWORD" test-daemon-password)
|
||||
|
||||
(defn- start-daemon!
|
||||
[opts]
|
||||
(db-worker-node/start-daemon! (update opts :log-level #(or % "error"))))
|
||||
|
||||
(defn- <token!
|
||||
[host port]
|
||||
(p/let [^js res (js/fetch (str "http://" host ":" port "/v1/auth/login")
|
||||
#js {:method "POST"
|
||||
:headers #js {"Content-Type" "application/json"}
|
||||
:body (js/JSON.stringify
|
||||
#js {:username test-daemon-username
|
||||
:password test-daemon-password
|
||||
:deviceLabel "mcp-test"})})
|
||||
^js body (.json res)]
|
||||
(.-token body)))
|
||||
|
||||
(defn- <connected-client!
|
||||
^js
|
||||
[host port token]
|
||||
(p/let [transport (StreamableHTTPClientTransport.
|
||||
(js/URL. (str "http://" host ":" port "/mcp"))
|
||||
#js {:requestInit #js {:headers #js {"Authorization" (str "Bearer " token)}}})
|
||||
^js client (Client. #js {:name "semantic-mcp-test" :version "1.0.0"})
|
||||
_ (.connect client transport)]
|
||||
client))
|
||||
|
||||
(defn- tool-text
|
||||
[^js call-result]
|
||||
(.-text (aget (.-content call-result) 0)))
|
||||
|
||||
(defn- with-mcp-client!
|
||||
"Starts a fresh daemon over a fresh repo, connects an MCP client to /mcp,
|
||||
runs (f client), and stops the daemon afterwards regardless of outcome."
|
||||
[f]
|
||||
(let [daemon (atom nil)
|
||||
data-dir (node-helper/create-tmp-dir "mcp-server")
|
||||
repo (str "logseq_db_mcp_" (subs (str (random-uuid)) 0 8))]
|
||||
(-> (p/let [{:keys [host port stop!]} (start-daemon! {:root-dir data-dir :repo repo})
|
||||
_ (reset! daemon {:stop! stop!})
|
||||
token (<token! host port)
|
||||
client (<connected-client! host port token)]
|
||||
(f client))
|
||||
(p/catch (fn [e]
|
||||
(println "[mcp-server-test] error:" e)
|
||||
(is false (str e))))
|
||||
(p/finally (fn []
|
||||
(when-let [stop! (:stop! @daemon)]
|
||||
(stop!)))))))
|
||||
|
||||
(deftest mcp-lists-all-semantic-tools
|
||||
(async done
|
||||
(-> (with-mcp-client!
|
||||
(fn [^js client]
|
||||
(p/let [^js result (.listTools client)]
|
||||
(let [tools (js->clj (.-tools result) :keywordize-keys true)]
|
||||
(is (= (count semantic-routes/operations) (count tools)))
|
||||
(is (some #(= "createPage" (:name %)) tools))
|
||||
(is (some #(= "searchGraph" (:name %)) tools))
|
||||
(is (not (some #(= "listGraphs" (:name %)) tools)))))))
|
||||
(p/finally done))))
|
||||
|
||||
(deftest mcp-create-and-get-page-round-trip
|
||||
(async done
|
||||
(-> (with-mcp-client!
|
||||
(fn [^js client]
|
||||
(p/let [^js create-result (.callTool client #js {:name "createPage"
|
||||
:arguments #js {:title "MCP Test Page"}})
|
||||
page (js->clj (js/JSON.parse (tool-text create-result)) :keywordize-keys true)
|
||||
^js get-result (.callTool client #js {:name "getPage"
|
||||
:arguments #js {:page-id (:uuid page)}})
|
||||
got (js->clj (js/JSON.parse (tool-text get-result)) :keywordize-keys true)]
|
||||
(is (not (.-isError create-result)))
|
||||
(is (= "MCP Test Page" (:title page)))
|
||||
(is (= "MCP Test Page" (:title got))))))
|
||||
(p/finally done))))
|
||||
|
||||
(deftest mcp-capture-block-tree-round-trip
|
||||
(async done
|
||||
(-> (with-mcp-client!
|
||||
(fn [^js client]
|
||||
(p/let [^js result (.callTool
|
||||
client #js {:name "captureToToday"
|
||||
:arguments #js {:blocks #js [#js {:title "MCP root"
|
||||
:children #js [#js {:title "MCP child"}]}]}})
|
||||
body (js->clj (js/JSON.parse (tool-text result)) :keywordize-keys true)
|
||||
root (first (:blocks body))]
|
||||
(is (not (.-isError result)))
|
||||
(is (= "MCP root" (:title root)))
|
||||
(is (= "MCP child" (:title (first (:children root))))))))
|
||||
(p/finally done))))
|
||||
|
||||
(deftest mcp-unknown-page-surfaces-as-error-content
|
||||
(async done
|
||||
(-> (with-mcp-client!
|
||||
(fn [^js client]
|
||||
(p/let [^js result (.callTool client #js {:name "getPage"
|
||||
:arguments #js {:page-id (str (random-uuid))}})]
|
||||
(is (true? (.-isError result))))))
|
||||
(p/finally done))))
|
||||
@@ -1245,6 +1245,92 @@
|
||||
score (search-benchmark/score-results result [keyword-id] 1)]
|
||||
(is (= 1 (:recall-at-1 score))))))))
|
||||
|
||||
(deftest combine-results-applies-temporal-decay-to-recency
|
||||
(testing "temporal decay favors the more recently updated block among otherwise-equal candidates"
|
||||
(let [old-id (test-uuid-string 950)
|
||||
fresh-id (test-uuid-string 951)
|
||||
page-id (test-uuid-string 952)
|
||||
now (js/Date.now)
|
||||
one-year-ms (* 365 24 60 60 1000)
|
||||
blocks {old-id {:db/id 950
|
||||
:block/uuid (uuid old-id)
|
||||
:block/title "old block"
|
||||
:block/updated-at (- now one-year-ms)
|
||||
:block/page {:block/uuid (uuid page-id)}}
|
||||
fresh-id {:db/id 951
|
||||
:block/uuid (uuid fresh-id)
|
||||
:block/title "fresh block"
|
||||
:block/updated-at now
|
||||
:block/page {:block/uuid (uuid page-id)}}}]
|
||||
(with-redefs [d/pull-many (fn [_db _selector lookup-refs]
|
||||
(mapv (fn [[_attr id]]
|
||||
(get blocks (str id)))
|
||||
lookup-refs))
|
||||
ldb/hidden? (constantly false)
|
||||
ldb/page? (constantly false)]
|
||||
(let [result (search/combine-results
|
||||
:db
|
||||
[{:id old-id :keyword-score 1.0 :title "old block"}
|
||||
{:id fresh-id :keyword-score 1.0 :title "fresh block"}])]
|
||||
(is (= "fresh block" (:title (first result))))
|
||||
(is (< (:combined-score (second result)) (:combined-score (first result)))))))))
|
||||
|
||||
(deftest combine-results-never-discounts-a-strong-hit-below-half
|
||||
(testing "temporal decay is bounded so an ancient-but-exact match keeps most of its score"
|
||||
(let [old-id (test-uuid-string 953)
|
||||
blocks {old-id {:db/id 953
|
||||
:block/uuid (uuid old-id)
|
||||
:block/title "ancient but exact"
|
||||
:block/updated-at 0}}]
|
||||
(with-redefs [d/pull-many (fn [_db _selector lookup-refs]
|
||||
(mapv (fn [[_attr id]]
|
||||
(get blocks (str id)))
|
||||
lookup-refs))
|
||||
ldb/hidden? (constantly false)
|
||||
ldb/page? (constantly false)]
|
||||
(let [result (search/combine-results
|
||||
:db
|
||||
[{:id old-id :keyword-score 4.0 :title "ancient but exact"}])]
|
||||
(is (>= (:combined-score (first result)) 2.0)))))))
|
||||
|
||||
(deftest similar-blocks-returns-nearest-neighbors-excluding-self
|
||||
(testing "similar-blocks fetches the source embedding and re-queries the vector index, dropping the source itself"
|
||||
(let [source-id (test-uuid-string 960)
|
||||
neighbor-id (test-uuid-string 961)
|
||||
page-id (test-uuid-string 962)
|
||||
blocks {source-id {:db/id 960
|
||||
:block/uuid (uuid source-id)
|
||||
:block/title "source block"
|
||||
:block/page {:block/uuid (uuid page-id)}}
|
||||
neighbor-id {:db/id 961
|
||||
:block/uuid (uuid neighbor-id)
|
||||
:block/title "neighbor block"
|
||||
:block/page {:block/uuid (uuid page-id)}}}
|
||||
fetch-calls (atom [])
|
||||
query-calls (atom [])
|
||||
vector-index {:fetch (fn [ids]
|
||||
(swap! fetch-calls conj ids)
|
||||
(when (= ids [source-id])
|
||||
{source-id {:embedding [0.1 0.2 0.3]}}))
|
||||
:query (fn [embedding limit page]
|
||||
(swap! query-calls conj {:embedding embedding :limit limit :page page})
|
||||
[{:id source-id :page page-id :vector-score 1.0}
|
||||
{:id neighbor-id :page page-id :vector-score 0.95}])}]
|
||||
(with-redefs [d/entity (fn [_db [_attr id]] (get blocks (str id)))
|
||||
d/pull-many (fn [_db _selector lookup-refs]
|
||||
(mapv (fn [[_attr id]] (get blocks (str id))) lookup-refs))
|
||||
ldb/hidden? (constantly false)
|
||||
ldb/page? (constantly false)
|
||||
ldb/built-in? (constantly false)]
|
||||
(let [result (search/similar-blocks (atom :db) vector-index (uuid source-id) {})]
|
||||
(is (= ["neighbor block"] (mapv :block/title result)))
|
||||
(is (= 1 (count @fetch-calls)))
|
||||
(is (= [0.1 0.2 0.3] (:embedding (first @query-calls)))))))))
|
||||
|
||||
(deftest similar-blocks-returns-nil-without-a-fetch-capable-vector-index
|
||||
(testing "gracefully no-ops when the vector index has no :fetch capability (e.g. no index configured)"
|
||||
(is (nil? (search/similar-blocks (atom :db) {} (uuid (test-uuid-string 970)) {})))))
|
||||
|
||||
(deftest search-result-keeps-page-title-when-alias-matches
|
||||
(testing "alias matches annotate the page result without replacing its title"
|
||||
(let [page-id #uuid "00000000-0000-0000-0000-000000000236"
|
||||
|
||||
222
src/test/frontend/worker/semantic_test.cljs
Normal file
222
src/test/frontend/worker/semantic_test.cljs
Normal file
@@ -0,0 +1,222 @@
|
||||
(ns frontend.worker.semantic-test
|
||||
"Exercises the semantic API (Fase 9) over real HTTP against a live daemon,
|
||||
the same way db_worker_node_test.cljs exercises /v1/invoke -- these
|
||||
operations were ported from deps/db-sync/.../handler/semantic.cljs onto
|
||||
frontend.worker.handler.semantic, re-hosted on the daemon's own
|
||||
authoritative conn instead of a Cloudflare Durable Object's SQLite."
|
||||
(:require ["http" :as http]
|
||||
[cljs.test :refer [async deftest is]]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[frontend.worker.db-worker-node :as db-worker-node]))
|
||||
|
||||
;; Same literal credential db_worker_node_test.cljs bootstraps with -- both
|
||||
;; namespaces get `require`d into the same test.js bundle, and this is a
|
||||
;; process-wide env var, so setting it to a different value here would win
|
||||
;; the race against whichever namespace loads later and lock the other
|
||||
;; file's daemons out of their own bootstrapped credential.
|
||||
(def ^:private test-daemon-username "db-worker-node-test-user")
|
||||
(def ^:private test-daemon-password "db-worker-node-test-password")
|
||||
|
||||
(aset (.-env js/process) "LOGSEQ_DAEMON_USERNAME" test-daemon-username)
|
||||
(aset (.-env js/process) "LOGSEQ_DAEMON_PASSWORD" test-daemon-password)
|
||||
|
||||
(defn- http-request
|
||||
[opts body]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(let [req (.request http (clj->js opts)
|
||||
(fn [^js res]
|
||||
(let [chunks (array)]
|
||||
(.on res "data" (fn [chunk] (.push chunks chunk)))
|
||||
(.on res "end" (fn []
|
||||
(resolve {:status (.-statusCode res)
|
||||
:body (.toString (js/Buffer.concat chunks) "utf8")}))))))]
|
||||
(.on req "error" reject)
|
||||
(when body (.write req body))
|
||||
(.end req)))))
|
||||
|
||||
(defn- json-body [{:keys [body]}]
|
||||
(when-not (= "" body)
|
||||
(js->clj (js/JSON.parse body) :keywordize-keys true)))
|
||||
|
||||
(defn- auth-headers!
|
||||
[host port]
|
||||
(.then (http-request {:hostname host
|
||||
:port port
|
||||
:path "/v1/auth/login"
|
||||
:method "POST"
|
||||
:headers {"Content-Type" "application/json"}}
|
||||
(js/JSON.stringify
|
||||
(clj->js {:username test-daemon-username
|
||||
:password test-daemon-password
|
||||
:deviceLabel "test"})))
|
||||
(fn [result]
|
||||
{"Content-Type" "application/json"
|
||||
"Authorization" (str "Bearer " (:token (json-body result)))})))
|
||||
|
||||
(defn- semantic-request
|
||||
([host port method path] (semantic-request host port method path nil))
|
||||
([host port method path body]
|
||||
(.then (auth-headers! host port)
|
||||
(fn [headers]
|
||||
(http-request {:hostname host
|
||||
:port port
|
||||
:path path
|
||||
:method method
|
||||
:headers headers}
|
||||
(when body (js/JSON.stringify (clj->js body))))))))
|
||||
|
||||
(defn- start-daemon!
|
||||
[opts]
|
||||
(db-worker-node/start-daemon! (update opts :log-level #(or % "error"))))
|
||||
|
||||
(defn- with-daemon!
|
||||
"Starts a fresh daemon over a fresh repo, runs (f host port), and stops the
|
||||
daemon afterwards regardless of outcome. Returns a promise for `async`."
|
||||
[f]
|
||||
(let [daemon (atom nil)
|
||||
data-dir (node-helper/create-tmp-dir "semantic-api")
|
||||
repo (str "logseq_db_semantic_" (subs (str (random-uuid)) 0 8))]
|
||||
(-> (.then (start-daemon! {:root-dir data-dir :repo repo})
|
||||
(fn [{:keys [host port stop!]}]
|
||||
(reset! daemon {:stop! stop!})
|
||||
(f host port)))
|
||||
(.catch (fn [e]
|
||||
(println "[semantic-test] error:" e)
|
||||
(is false (str e))))
|
||||
(.finally (fn []
|
||||
(when-let [stop! (:stop! @daemon)]
|
||||
(stop!)))))))
|
||||
|
||||
(deftest semantic-pages-create-get-list-and-delete
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (semantic-request host port "POST" "/v1/semantic/pages" {:title "Semantic Test Page"})
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 201 status))
|
||||
(let [page (json-body result)]
|
||||
(is (= "Semantic Test Page" (:title page)))
|
||||
(is (= "page" (:kind page)))
|
||||
(.then (semantic-request host port "GET" (str "/v1/semantic/pages/" (:uuid page)))
|
||||
(fn [{:keys [status] :as get-result}]
|
||||
(is (= 200 status))
|
||||
(is (= "Semantic Test Page" (:title (json-body get-result))))
|
||||
page)))))
|
||||
(.then (fn [page]
|
||||
(.then (semantic-request host port "GET" "/v1/semantic/pages")
|
||||
(fn [{:keys [status] :as list-result}]
|
||||
(is (= 200 status))
|
||||
(is (some #(= (:uuid page) (:uuid %)) (:blocks (json-body list-result))))
|
||||
page))))
|
||||
(.then (fn [page]
|
||||
(.then (semantic-request host port "DELETE" (str "/v1/semantic/pages/" (:uuid page)))
|
||||
(fn [{:keys [status]}]
|
||||
(is (= 204 status))
|
||||
(semantic-request host port "GET" (str "/v1/semantic/pages/" (:uuid page)))))))
|
||||
(.then (fn [{:keys [status]}]
|
||||
(is (= 404 status)))))))
|
||||
(.finally done))))
|
||||
|
||||
(deftest semantic-blocks-property-and-capture-flow
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (semantic-request host port "POST" "/v1/semantic/capture" {:blocks [{:title "Captured root"
|
||||
:children [{:title "child"}]}]})
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 201 status))
|
||||
(let [body (json-body result)
|
||||
root (first (:blocks body))]
|
||||
(is (= "Captured root" (:title root)))
|
||||
(is (= 1 (count (:children root))))
|
||||
root)))
|
||||
(.then (fn [root]
|
||||
(.then (semantic-request host port "POST" "/v1/semantic/properties"
|
||||
{:title "semantic-test-prop" :type "string"
|
||||
:cardinality "db.cardinality/one"})
|
||||
(fn [{:keys [status] :as property-result}]
|
||||
(is (= 201 status))
|
||||
[root (json-body property-result)]))))
|
||||
(.then (fn [[root property]]
|
||||
(.then (semantic-request host port "PUT"
|
||||
(str "/v1/semantic/blocks/" (:uuid root)
|
||||
"/properties/" (:uuid property))
|
||||
{:value "hello"})
|
||||
(fn [{:keys [status] :as set-result}]
|
||||
(is (= 200 status))
|
||||
(is (:updated (json-body set-result)))
|
||||
[root property]))))
|
||||
(.then (fn [[root property]]
|
||||
(semantic-request host port "DELETE"
|
||||
(str "/v1/semantic/blocks/" (:uuid root)
|
||||
"/properties/" (:uuid property)))))
|
||||
(.then (fn [{:keys [status]}]
|
||||
(is (= 204 status)))))))
|
||||
(.finally done))))
|
||||
|
||||
(deftest semantic-tags-and-tasks-flow
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (semantic-request host port "POST" "/v1/semantic/tags" {:title "Semantic Tag"})
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 201 status))
|
||||
(is (= "Semantic Tag" (:title (json-body result))))))
|
||||
(.then (fn [_]
|
||||
(semantic-request host port "POST" "/v1/semantic/tasks"
|
||||
{:title "Semantic Task" :status "doing" :priority "high"})))
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 201 status))
|
||||
(let [task (json-body result)]
|
||||
(is (= "logseq.property/status.doing" (get-in task [:status :ident])))
|
||||
(is (= "logseq.property/priority.high" (get-in task [:priority :ident]))))))
|
||||
(.then (fn [_]
|
||||
(semantic-request host port "GET" "/v1/semantic/tasks?status=doing")))
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 200 status))
|
||||
(is (some #(= "Semantic Task" (:title %)) (:tasks (json-body result)))))))))
|
||||
(.finally done))))
|
||||
|
||||
(deftest semantic-search-finds-created-page
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (semantic-request host port "POST" "/v1/semantic/pages" {:title "Findable Semantic Page"})
|
||||
(.then (fn [_]
|
||||
(semantic-request host port "GET" "/v1/semantic/search?q=Findable")))
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 200 status))
|
||||
(is (some #(= "Findable Semantic Page" (:title %)) (:results (json-body result)))))))))
|
||||
(.finally done))))
|
||||
|
||||
(deftest semantic-request-requires-auth
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (http-request {:hostname host :port port :path "/v1/semantic/pages" :method "GET"} nil)
|
||||
(.then (fn [{:keys [status]}]
|
||||
(is (= 401 status)))))))
|
||||
(.finally done))))
|
||||
|
||||
(deftest semantic-unknown-route-is-not-found
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (semantic-request host port "GET" "/v1/semantic/not-a-real-route")
|
||||
(.then (fn [{:keys [status]}]
|
||||
(is (= 404 status)))))))
|
||||
(.finally done))))
|
||||
|
||||
(deftest semantic-openapi-document-lists-operations
|
||||
(async done
|
||||
(-> (with-daemon!
|
||||
(fn [host port]
|
||||
(-> (semantic-request host port "GET" "/v1/semantic/openapi.json")
|
||||
(.then (fn [{:keys [status] :as result}]
|
||||
(is (= 200 status))
|
||||
(let [doc (json-body result)]
|
||||
(is (= "3.1.0" (:openapi doc)))
|
||||
(is (contains? (:paths doc) (keyword "/v1/semantic/pages")))
|
||||
(is (not (contains? (:paths doc) (keyword "/v1/graphs/{graph-id}/semantic/pages"))))))))))
|
||||
(.finally done))))
|
||||
Reference in New Issue
Block a user