Compare commits

..

10 Commits

Author SHA1 Message Date
b339cbb3b7 Rename product to Synapse; add Gitea CI, VPS deploy templates, RPM build fixes
Rebrands user-facing product identity from Logseq to Synapse (package
metadata, Electron appId/protocol scheme, Android/iOS display names, HTML
titles, and the English UI dictionary), authored by Darío Sevilla
<dario@dariosevilla.es>. Internal Clojure namespaces, :logseq.class/*
and :logseq.property/* DB schema idents, and deps.edn's upstream git
dependency coordinates are intentionally left untouched -- those are
either persisted data in existing graphs or point at real upstream repos,
and renaming them has no user-visible benefit.

Also:
- .gitea/workflows/build.yml: Linux (AppImage/deb/rpm), Windows (nsis),
  and Android (debug APK) build jobs.
- deploy/: systemd unit + Caddyfile templates for a self-hosted VPS
  (daemon on loopback, Caddy reverse-proxies /v1/*, /mcp, /healthz and
  serves the static web client with TLS).
- gulpfile.js / prepare-desktop-runtime-js.mjs: LOGSEQ_SKIP_CLI_BUNDLE
  skips the cli/ OCaml package (no toolchain in this environment) instead
  of failing the build; LOGSEQ_ELECTRON_BUILDER_ARGS lets CI override the
  packaged Linux targets independently of the local default.
- resources/electron-builder.yml: drop the extraResources entry for a
  SKILL.md that doesn't exist in this fork, and the author.email fix
  needed for the rpm/deb targets' Packager/Maintainer metadata.
2026-08-27 12:03:03 +02:00
d8996bd5e1 Fase B8+B9: stateful code kernel and live notes bound to external URLs
B8 - executable code blocks with a real kernel:
- worker/handler/kernel.cljs: one persistent SCI interpreter session per
  page, reused across block evaluations so defs/state survive between runs.
  Deliberately scoped to the same privileges the existing client-side
  single-shot evaluator already had (no shell/fs access, no call-api).
- Run button + result display wired into block.cljs's code-block UI.
- New :logseq.property.code/result and /executed-at properties (65.38).

B9 - live notes: pages tagged #LiveNote that fetch external content on a
refresh schedule and render it via the render-resource pipeline:
- worker/handler/live_notes.cljs: per-block credential storage, scheduled
  refresh (Node-only), thread-apis for manual refresh and auth.
- worker/handler/render_resource/live_note.cljs: reads back fetched content,
  watching [:entity block-uuid] for reactive invalidation.
- New :logseq.class/LiveNote + :logseq.property.livenote/* properties (65.39).

Bugs found and fixed along the way while actually exercising page creation
through page-handler/create! for the first time in a test (previously only
pure-fn tests existed for CalDAV/Event/Contact):
- logseq-property-namespaces (property.cljs) was missing the citation/event/
  contact/caldav/livenote namespaces, causing auto-create-ontology to treat
  already-built-in properties as new user idents.
- outliner/page.cljs's build-page-tx always added the generic #Page tag even
  when the caller tagged with a built-in class that extends #Page, which
  malli-schema rejects as conflicting; it also lost the property type when
  building ref-typed property values for new pages, breaking non-:default
  types like :number.
- entity-util/page? never recognized Event/Contact/LiveNote as pages (only
  Journal/Whiteboard/Tag/Property/Page had dedicated recognition), so pages
  tagged with those classes alone weren't treated as pages anywhere in the
  app; added built-in-page-child? alongside the existing per-class checks.
- caldav.cljs's upsert-page! passed a bare keyword as a tag where an entity
  was required, and two error logs passed raw error objects to glogi that
  triggered runaway recursion in this test environment; both fixed.
2026-08-26 22:41:23 +02:00
0158430489 Fase B7: CalDAV/CardDAV sync with Nextcloud, and a Calendar view type
- New built-in classes Event and Contact (schema 65.37), plus a
  Calendar View view type alongside table/list/gallery, backed by a new
  fixed month-grid calendar-view component in components/views.cljs
  reusing the existing scheduled/deadline properties.
- New worker/handler/caldav.cljs: a deliberately-scoped CalDAV
  (VEVENT/VTODO) + CardDAV (VCARD) client against a single Nextcloud
  calendar/addressbook collection, with its own minimal iCalendar/vCard
  encoder-decoder and namespace-agnostic multistatus XML extraction
  (no external ical/rrule/xml dependency exists in this project).
- RRULE <-> :logseq.property.repeat/* translation covers only the
  `plus` (advance-from-scheduled) repeat mode with a bare FREQ(;INTERVAL)
  rule -- dotted-plus/double-plus and anything with BYDAY/COUNT/UNTIL
  are left untranslated on purpose rather than mistranslated.
- Credentials live in the platform keychain (save-secret-text!), never
  in the graph. Sync runs Node-only, every 15 minutes per open repo,
  started/stopped alongside the repo's lifecycle in db_core.cljs.
  Conflicts are last-write-wins via ETag: a 412 on push is skipped and
  retried after the next pull refreshes the stored ETag.
2026-08-26 21:11:00 +02:00
dadeca7165 Fase B6: temporal relevance decay and vector-based "similar pages"
- combine-results now applies a bounded (never below 0.5x) exponential
  decay based on :block/updated-at, so more recently touched blocks/pages
  rank slightly higher for otherwise-equal matches.
- New search/similar-blocks reuses the existing zvec index: fetch a
  block's own already-indexed embedding (new :fetch capability on
  open-vector-index, via zvec's fetchSync) and re-query the index with
  it instead of embedding a fresh query string.
- Exposed as a :similar-pages render-resource (worker/handler/render-resource/similar.cljs),
  consistent with how linked/unlinked references are already wired, plus
  a raw :thread-api/search-similar-blocks for non-UI callers.
- New "Similar pages" section on the page view, above the existing
  linked/unlinked references.
2026-08-26 20:21:28 +02:00
d59aec181e Fase B5: temporal/linear graph view mode
Adds a third :temporal view mode alongside :tags-and-objects and
:all-pages. The backend reuses build-all-pages-graph's node/link shape
unchanged (every node already carries :block/created-at); only the
client-side pixi layout differs, arranging nodes along a fixed
chronological timeline (oldest first, staggered across a few lanes)
instead of running the force simulation, following the existing
grid-layout fixed-position precedent.
2026-08-26 19:03:21 +02:00
b67f7e9f78 Fase B3+B4: built-in Project/Area tags and Citation class
B3: registers :logseq.class/Project and :logseq.class/Area as
built-in page-like classes (the TODO in class.cljs already asked for
this), so users can tag pages/journals with them out of the box.
Fixed a markdown-mirror test whose "Custom Tag" fixture used to be
titled "Project" and collided with the new built-in class.

B4: adds :logseq.class/Citation plus /target and /context properties,
following the :logseq.class/Comments pattern. Since target is a
:node-typed ref, citation blocks automatically show up in the
existing block/citation reference graph without any changes there.
2026-08-26 19:03:14 +02:00
fcf41c7b41 Fase B2: give Contents root semantics alongside Library
Adds a shared common-config/contents-page-name constant and
ldb/contents?/get-contents-page, mirroring the existing Library
helpers. Contents already qualifies as an internal page, so the
existing namespace-title split mechanism (e.g. "Contents/Proyectos")
already parents pages under it via :block/parent, coexisting with
Library rather than replacing it as the default root.
2026-08-26 19:02:49 +02:00
c50511a7b5 Fase B1: chain journal pages with previous/next graph links
Adds :logseq.property.journal/previous and /next (built-in, hidden,
single-cardinality node refs) and maintains them in
logseq.outliner.page/create whenever a new journal page is created.
Filling a gap between two already-linked journals re-links both
neighbors around the new page instead of just appending. A migration
backfills the chain across all existing journal pages ordered by day.
2026-08-26 19:02:44 +02:00
db7f1fac7d Fase 9 (2/N): daemon-hosted MCP server, retire electron/mcp_server.cljs
Completes Fase 9's "consolidar MCP" half: the daemon now serves its own
MCP endpoint at /mcp, exposing every semantic-routes operation as an MCP
tool against the daemon's authoritative graph, and the old Electron-local
MCP server is retired since it's now redundant.

New: src/main/frontend/worker/mcp_server.cljs
The CF-Worker's own MCP endpoint (deps/db-sync/worker/entry.mjs) generates
its tools from the OpenAPI document via @cloudflare/codemode's
openApiMcpServer, a package that only exists inside Cloudflare Workers,
plus a pile of ChatGPT-App-specific tooling (asset image resources,
upload_asset, Cognito protected-resource metadata) this daemon has no use
for -- none of that ports to Node. So this registers one tool per
operation by hand instead, using @modelcontextprotocol/sdk directly, 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 operation-docs
(now public) instead of writing 35 one-off registrations by hand, so the
MCP tool surface can't drift from the HTTP routes or the OpenAPI document.
Each tool's Zod input shape is assembled from path params (auto-derived
from the route's :param segments) plus a per-operation-id map of the
extra query/body fields; a handful of shared fragments (pagination,
time filters, the recursive block-tree shape, property-value/task-status
selectors) cover the repetition across list/create/update operations.

Each tool's handler makes a loopback HTTP call to this same daemon's own
/v1/semantic/* route, forwarding the /mcp request's own Authorization
header -- reusing the already-tested HTTP-layer logic (repo-error
validation, lock-owner assertion, response shaping) instead of a second
request path directly against the thread-api. Session/transport plumbing
(handle-post/get/delete-request, the transports-by-session-id map) is
adapted from electron/mcp_server.cljs's Fastify-based version: that one
gets req/res already unwrapped-to-Node-primitives via `.raw` with the
body pre-parsed by Fastify; this one is called straight from the daemon's
own raw http.createServer callback, so req/res already are what
StreamableHTTPServerTransport wants, and the body needs parsing here
instead. No separate auth check either -- the daemon's existing
bearer-token check on every route (request-authorized? in
db_worker_node.cljs) already gates /mcp before this is reached.

Wires POST/GET/DELETE /mcp into db_worker_node.cljs's make-server cond
chain, alongside the existing /v1/semantic/* branch.

Dependencies: adds @modelcontextprotocol/sdk and zod (zod/v3 -- the SDK
doesn't work with zod 4's default export, same constraint
electron/mcp_server.cljs already worked around) to the root package.json;
previously only resources/package.json (Electron's own bundle) had them.

Retired: electron/mcp_server.cljs and its wiring (electron/server.cljs's
initialize-mcp-routes, its 3 POST/GET/DELETE /mcp route registrations,
the :mcp-enabled? config field; components/server.cljs's "copy MCP URL"
popup link; components/settings.cljs's entire AI settings tab, which had
nothing in it besides the MCP-server toggle). Electron already talks to
the daemon (Fase 3) rather than driving itself locally, so a second,
separately-configured MCP surface bolted onto Electron's own local
automation API server (which otherwise stays -- /api is a distinct,
still-used concern) was redundant with the daemon's new one. Removed the
4 associated i18n keys (server/mcp-url-copied, settings/ai,
settings.ai/enable-mcp-server[-desc]) from all ~26 locale dicts.

New: src/test/frontend/worker/mcp_server_test.cljs
Connects a real @modelcontextprotocol/sdk client over HTTP to a live
daemon's /mcp endpoint: lists all registered tools, round-trips
createPage/getPage and a recursive captureToToday block tree, and checks
a tool call against a nonexistent page surfaces as MCP error content
rather than throwing. 4 tests, 11 assertions.

Verification: all 5 shadow-cljs targets compile with 0 warnings. Full
`bb dev:test` run: the only failures are the same 7 pre-existing,
sandboxed-network-related persist_db_test.cljs failures already
documented in the Fase 8 and Fase 9 (1/N) commits -- identical test names,
identical ECONNREFUSED/Invalid URL errors. Every other shard, including
all three daemon-HTTP worker test files sharing one test.js process
(db_worker_node_test.cljs, semantic_test.cljs, mcp_server_test.cljs),
passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 17:07:27 +02:00
196de21731 Fase 9 (1/N): re-host the semantic API onto the daemon
Ports the 38-operation semantic API from deps/db-sync's Cloudflare Worker
handler (handler/semantic.cljs + routes/semantic.cljs) onto the daemon's
own authoritative conn, per the plan's Fase 9. deps/db-sync itself is
untouched -- this only adds the daemon-side equivalent.

New:
- worker/semantic_routes.cljs: route table + OpenAPI 3.1.0 document
  generator, adapted from the CF-Worker's routes/semantic.cljs. Drops the
  :graph-id URL segment and listGraphs operation entirely -- the daemon is
  bound to a single repo per process (see db_worker_node.cljs's
  bound-repo), unlike the CF-Worker's per-graph Durable Objects, so every
  request implicitly targets the daemon's own bound repo. Security scheme
  is a bearer token (the daemon's actual session auth) instead of OAuth2.
- worker/handler/semantic.cljs: the 38 operations themselves, ported from
  the CF-Worker's handler/semantic.cljs. Adaptations from the original:
  * ensure-conn! (lazy SQLite conn open) -> worker.state/get-datascript-conn
    (the daemon already holds the authoritative conn for its bound repo).
  * broadcast-change! calls dropped entirely -- any transact! against the
    conn is already picked up by the listener worker.db-listener installs,
    which broadcasts to every connected client, so nothing here needs to
    push manually.
  * HTTP body reads (p/let + body-clj) dropped -- bodies arrive already
    JSON-parsed from the HTTP layer, and every operation is synchronous
    (DataScript itself is synchronous), so none of this needs promises.
  * http/bad-request, http/not-found, js/Response error helpers -> ex-info
    with :status/:code, matching every other thread-api in this codebase.
  * R2 asset storage -> platform/asset-write-bytes! (local filesystem),
    using the <uuid>.<type> naming convention components/block/asset.cljs
    already establishes. R2 signed URLs -> server-relative
    /v1/assets/:repo/:name paths (already served, with ETag caching, by
    the existing Fase 5 asset routes) since bearer-token auth already
    gates those with the same ?token= fallback semantic GET routes need.
  Registered as a single :thread-api/semantic-request thread-api
  dispatched by handler keyword, mirroring how the CF-Worker original
  dispatched via one big `case` in `handle`.
- db_worker_node.cljs: wires a /v1/semantic/* branch (JSON body parsed
  for most operations; raw bytes read directly for assets-create,
  mirroring the existing /v1/assets/:repo/:name PUT route) plus
  /v1/semantic/openapi.json. Unlike /v1/invoke -- an internal RPC
  transport whose transit-encoded results Logseq's own clients already
  know how to decode -- /v1/semantic/* is the public HTTP API documented
  by the OpenAPI doc and meant for arbitrary HTTP/MCP clients, so results
  are decoded server-side into plain JSON, the same way the existing
  /v1/assets/:repo/:name GET route already does for the other route with
  external HTTP consumers.
- src/test/frontend/worker/semantic_test.cljs: exercises pages,
  blocks/properties, capture, tags/tasks, search, the OpenAPI document,
  401-without-auth, and 404-on-unknown-route against a live daemon over
  real HTTP. 7 tests, 31 assertions.

Verification: all 5 shadow-cljs targets compile with 0 warnings. Full
`bb dev:test` run has zero regressions -- the only failures are the same
7 pre-existing, sandboxed-network-related persist_db_test.cljs failures
(ECONNREFUSED / Invalid URL) already documented as environmental in the
Fase 8 commit.

Found and fixed in-session: semantic_test.cljs originally set its own
LOGSEQ_DAEMON_USERNAME/PASSWORD env vars at namespace load time, colliding
with db_worker_node_test.cljs doing the same (both get bundled into the
same test.js process) -- whichever namespace loaded last won the race and
locked the other file's daemons out of their own bootstrapped credential,
cascading into ~150 unrelated failures. Fixed by reusing the same literal
credential constants instead of introducing a second one.

Known follow-up, not yet done: the plan's "consolidar MCP" half of Fase 9
-- an MCP JSON-RPC endpoint on the daemon (the CF-Worker's equivalent,
deps/db-sync/worker/entry.mjs, leans on @cloudflare/codemode's
openApiMcpServer plus ChatGPT-app-specific tooling that doesn't port to
Node) and the decision on electron/mcp_server.cljs's fate are still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:34:14 +02:00
94 changed files with 4890 additions and 789 deletions

139
.gitea/workflows/build.yml Normal file
View 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
View File

@@ -35,6 +35,7 @@ cli/_opam/
*.log
.vscode/
.idea/
.claude/
# Python sidecar
sidecar/.venv/

238
README.md
View File

@@ -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.
![logseq-demo](https://user-images.githubusercontent.com/25513724/221387376-4dc419c2-0d0a-460c-a920-2d211e78b456.gif)
<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).

View File

@@ -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>

View File

@@ -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
View 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
}

View 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

View File

@@ -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")

View File

@@ -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]

View File

@@ -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")]

View File

@@ -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

View File

@@ -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))))

View File

@@ -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"

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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"}

View File

@@ -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}

View File

@@ -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))))

View File

@@ -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',
})
cp.execSync('pnpm cli:release', {
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

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 .",

View File

@@ -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,
},
];

View File

@@ -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 "://"))

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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;
}

View File

@@ -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,34 +437,37 @@
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)))]]
(layout-slider
{:label (t :graph/layout-depth)
:value depth
:min 1
:max 5
:step 1
:disabled? depth-disabled?
:on-change #(set-settings! (fn [settings]
(assoc settings :depth (valid-depth %))))})
(layout-slider
{:label (t :graph/layout-link-distance)
:value link-distance
:min 36
:max 180
:step 6
:on-change #(set-settings! (fn [settings]
(assoc settings :link-distance (valid-link-distance %))))})
(when-not temporal?
[:<>
(layout-slider
{:label (t :graph/layout-depth)
:value depth
:min 1
:max 5
:step 1
:disabled? depth-disabled?
:on-change #(set-settings! (fn [settings]
(assoc settings :depth (valid-depth %))))})
(layout-slider
{:label (t :graph/layout-link-distance)
:value link-distance
:min 36
:max 180
:step 6
:on-change #(set-settings! (fn [settings]
(assoc settings :link-distance (valid-link-distance %))))})])
(when (= view-mode :tags-and-objects)
[:<>
(layout-toggle

View 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))])])))

View File

@@ -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

View File

@@ -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)))))

View File

@@ -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?

View File

@@ -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)]]]))

View 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)])]])))

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))))))

View File

@@ -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)

View File

@@ -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})))

View 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 "&lt;" "<")
(string/replace "&gt;" ">")
(string/replace "&quot;" "\"")
(string/replace "&apos;" "'")
(string/replace "&amp;" "&")))
(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))

View 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})

View 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}))

View File

@@ -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]

View File

@@ -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)})

View File

@@ -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)})

View File

@@ -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)]

View 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"))))

View 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")))))

View File

@@ -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!)

View File

@@ -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,19 +788,20 @@ DROP TRIGGER IF EXISTS blocks_au;
vector-match-score (if use-rrf?
(vector-term-match-score q result block)
0)
combined-score (+ base-score
vector-match-score
(* source-score-tie-break-weight
(+ keyword-score vector-score))
(if-not use-rrf?
(cond
page?
0.02
(:block/tags block)
0.01
:else
0)
0))]
combined-score (* (+ base-score
vector-match-score
(* source-score-tie-break-weight
(+ keyword-score vector-score))
(if-not use-rrf?
(cond
page?
0.02
(:block/tags block)
0.01
:else
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)]

View 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)})

View File

@@ -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"

View File

@@ -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 "انقر لتوسيع مراجع الكتل تلقائياً"

View File

@@ -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"

View File

@@ -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í"

View File

@@ -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."

View File

@@ -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"
}

View File

@@ -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."

View File

@@ -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 "برای باز کردن خودکار مراجع بلوک کلیک کنید"

View File

@@ -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."

View File

@@ -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."

View File

@@ -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."

View File

@@ -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 "このオプションは、ズームインしたときにブロック参照を展開するかどうかを指定します。"

View File

@@ -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 "이 설정은 블록을 확대할 때 블록 참조를 자동으로 펼칠지를 결정합니다."

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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."

View File

@@ -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"

View File

@@ -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 "Автоматически раскрывать ссылки на блоки при нажатии"

View File

@@ -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í"

View File

@@ -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"

View File

@@ -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 "Автоматически раскрывать ссылки на блоки при нажатии"

View File

@@ -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 "此选项控制是否在页面加载时展开块引用"

View File

@@ -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 "此選項控制是否在頁面載入時展開區塊引用"

View File

@@ -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

View 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))))))

View File

@@ -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

View 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>&quot;abc123&quot;</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR&#13;
BEGIN:VEVENT&#13;
UID:evt-1&#13;
SUMMARY:Team &amp; sync&#13;
END:VEVENT&#13;
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>&quot;def456&quot;</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR&#13;
BEGIN:VEVENT&#13;
UID:evt-2&#13;
SUMMARY:Retro&#13;
END:VEVENT&#13;
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))))))))

View 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])"))))))))

View 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)))

View File

@@ -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))))

View 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))))

View File

@@ -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"

View 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))))