Compare commits
139 Commits
7880e37c34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da13bac755 | |||
| c187108f89 | |||
| 7a3c5683e7 | |||
| d9085cf681 | |||
| 473c48937d | |||
| 28119c15b7 | |||
| 87318c8b22 | |||
| ea90167a5a | |||
| 2844ed5a47 | |||
| d87e80f96b | |||
| 3e3b2d9698 | |||
| 632822fe1b | |||
| 08a5bd192c | |||
| a01c86e383 | |||
| 4de0b6b0d1 | |||
| 0d44c21b0e | |||
| 7b8e539340 | |||
| fdf47da40a | |||
| 0753b290ad | |||
| fc039d813a | |||
| 9087096c5d | |||
| 3051331b87 | |||
| a1c04ff898 | |||
| e9f2cdb25d | |||
| bdfe57b66f | |||
| 70503c8bc2 | |||
| 2dcaf7f7f7 | |||
| 556d9c1ff4 | |||
| ba6a297d9b | |||
| 64e3847ef2 | |||
|
|
a3f2c451e3 | ||
|
|
cc520d517a | ||
|
|
5968cf47ba | ||
|
|
2bf95bfcd0 | ||
|
|
733aa9a6ff | ||
|
|
1dd8194fa8 | ||
| 2fc683d513 | |||
| 1410b97aeb | |||
| 8a6274ef6a | |||
| e28d42d345 | |||
| b8014e0a10 | |||
| 535303e8b5 | |||
| 1cdc86b9cc | |||
| 53ada2c705 | |||
| 3920d6fc70 | |||
| b0e9223158 | |||
| 89a07c885a | |||
| 1c04087471 | |||
| 1e32c7e945 | |||
| 80f291b8ea | |||
| b3bf134980 | |||
| 8e78a5fc2b | |||
| 8170076e9b | |||
| 3ac7a8bfc4 | |||
| 557497d4c2 | |||
| 2f7d752e73 | |||
| 812de13f62 | |||
|
|
71d998f68e | ||
|
|
489d3646f8 | ||
|
|
9d3a092359 | ||
|
|
1ecd7a32d2 | ||
|
|
0b4c303c89 | ||
|
|
18d96a10b8 | ||
|
|
184d6e4845 | ||
|
|
d50e538fc3 | ||
|
|
1e6c2a4e2c | ||
|
|
486a9f8019 | ||
| 08ca5f14aa | |||
|
|
eb61a03235 | ||
|
|
fdd9ddde4a | ||
| ac993d8743 | |||
| b641f6492b | |||
| d1829f0e9b | |||
| b713f7fa07 | |||
| e6fa506b28 | |||
| fe6bf3e1f4 | |||
| dc11155aac | |||
| 3795db76b0 | |||
| 5726b92c5d | |||
| 713575dd99 | |||
| 5425d83ee1 | |||
| 3aa5dcbe55 | |||
| e531d32a77 | |||
| 47d069dfac | |||
| 49adfed68f | |||
| 29cdd016b8 | |||
| 49447ef451 | |||
| b10212d47e | |||
| d922f5661f | |||
| 25fe22a3d2 | |||
| fe7dd82f33 | |||
| 1d190d33c5 | |||
| df71f1de95 | |||
| 7887d02e0b | |||
| c82b790b82 | |||
| 91d3ae475e | |||
| c80b5d680d | |||
| 2d6fcafc7e | |||
| 3739da0984 | |||
| 269e4b0e4a | |||
| 7ba2699b68 | |||
| c56550f1ad | |||
| ca7b864261 | |||
| 23981ce2ae | |||
| 45cb0cd502 | |||
| 92a942d52f | |||
| 7d20139531 | |||
| e8764262a3 | |||
| fd4fd744e0 | |||
| ac611e3f7f | |||
| fb5cae2131 | |||
| fdfae200a0 | |||
| a2abb901cd | |||
| 429f471e4e | |||
| 9dab0ca57e | |||
| 422f71c8ad | |||
| 90a9002124 | |||
| 055dd5efc5 | |||
| 9a72287fc6 | |||
| 33929fd04b | |||
| 97fd213817 | |||
| 4222c480cb | |||
| a2e2ab037d | |||
| 5c1b333cce | |||
| 943e5fa39b | |||
| eb8c2f786d | |||
| d88aaf111f | |||
| e5080b7282 | |||
| b438a99999 | |||
| 638202b96c | |||
| e9d6c70eb0 | |||
| f96e6f1df4 | |||
| 897e116db7 | |||
| 2890921f3a | |||
| ca5eb4e1ac | |||
| a727c9073d | |||
| 2e35afc731 | |||
| 16a210c0ad | |||
| a594b99e90 |
20
.cargo/config.toml
Normal file
20
.cargo/config.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[alias]
|
||||
bundle-macos = "packager --release --packages bds-ui --formats app --formats dmg"
|
||||
bundle-windows = "packager --release --packages bds-ui --formats nsis"
|
||||
bundle-linux = "packager --release --packages bds-ui --formats deb --formats appimage"
|
||||
|
||||
[target.'cfg(target_os = "macos")']
|
||||
rustflags = [
|
||||
"-C", "prefer-dynamic",
|
||||
"-C", "link-arg=-mmacosx-version-min=26.0",
|
||||
"-C", "link-arg=-Wl,-rpath,@executable_path/../Resources",
|
||||
]
|
||||
|
||||
[target.'cfg(target_os = "linux")']
|
||||
rustflags = [
|
||||
"-C", "prefer-dynamic",
|
||||
"-C", "link-arg=-Wl,-rpath,$ORIGIN/../lib/bds-ui",
|
||||
]
|
||||
|
||||
[target.'cfg(target_os = "windows")']
|
||||
rustflags = ["-C", "prefer-dynamic"]
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -1,12 +1,26 @@
|
||||
# Agents Instructions for Blogging Desktop Server (bDS)
|
||||
|
||||
This is the Rust rewrite of an existing project bDS written in Typescript and living in ../bDS - if
|
||||
in doubt about behaviour, look at the original code to verify.
|
||||
This is the Rust rewrite of an existing project bDS. The baseline implementation is bDS2, written in Elixir and living in ../bDS2 - if
|
||||
in doubt about behaviour, look at the baseline code to verify.
|
||||
|
||||
This project has an allium spec in the folder specs/ - use it to verify behaviour against expected behaviour. It is based on the typescript implementation. The command line utility is installed.
|
||||
This project has an allium spec in the folder specs/ - use it to verify behaviour against expected behaviour. It is synced from the bDS2 specs (../bDS2/specs). The command line utility is installed.
|
||||
|
||||
Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed.
|
||||
|
||||
## UI styling
|
||||
|
||||
- Before implementing or changing UI, read and follow `docs/UI_STYLE_GUIDE.md`.
|
||||
- Reuse the shared styling primitives described there so new sidebars and editor areas remain consistent with the post editor.
|
||||
|
||||
## Computer Use for the macOS app
|
||||
|
||||
- Build the real app bundle with `cargo bundle-macos`.
|
||||
- Use Computer Use against the exact app path `target/release/Blogging Desktop Server.app` (resolved to its absolute path), then raise that window before interacting with it.
|
||||
- Do not use `cargo run` for Computer Use: it starts RuDS as a child of the command runner and Computer Use cannot attach to it as a macOS app.
|
||||
- Do not run `open` on `target/debug/bds-ui`; macOS hands the executable to Ghostty instead of launching RuDS as an app.
|
||||
- Do not target only the `de.rfc1437.ruds` bundle identifier because both the installed app and the repository build may share it; always use the exact repository bundle path.
|
||||
- Do not improvise with AppleScript, Dock automation, or raw screen-capture workarounds. Use the Computer Use skill and its `node_repl` runtime.
|
||||
|
||||
## Plan Mode
|
||||
|
||||
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
||||
@@ -14,12 +28,14 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
|
||||
|
||||
## Commits
|
||||
|
||||
- our default branch is origin/master
|
||||
- our default branch is origin/main
|
||||
- commit messages are short - one sentence. do not write long articles.
|
||||
- pull requests are more verbose and especially give reasoning for changes
|
||||
|
||||
## Important facts
|
||||
|
||||
- update `README.md` whenever a user-visible feature is added, removed, or materially changed; keep it a compact overview with relevant pointers
|
||||
- work is tracked as Gitea issues (`tea` CLI, repo `hugo/RuDS`); there are no plan documents — file or update issues when scope changes
|
||||
- published posts don't have body in the database, the body content is only in the file
|
||||
- functionality you implement have to be tied to UI
|
||||
- UI you implement has to be tied to functionality
|
||||
@@ -39,10 +55,10 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
|
||||
- there are no "pre-existing" problems - you own every problem, you fix every problem
|
||||
- don't leave unused code in the codebase, remove it instead
|
||||
- after implementing / changing things, run the build and run tests to verify all works
|
||||
- run `cargo test --workspace` with permission for its loopback test servers on the first attempt; AI mock-server and preview-server tests bind localhost and otherwise fail under sandboxing, causing a pointless rerun
|
||||
- do not reference external JavaScript or CSS on CDNs, always bring it into the project
|
||||
- do not embedd CSS/JavaScript into HTML, always reference .css and .js files in the project assets
|
||||
- always make sure you follow proper i18n best practices. no untranslated string constants.
|
||||
- when creating rust source code, always follow what the allium spec is saying for that part
|
||||
- when tending the allium spec, make sure you validate the spec with the installed command line utility
|
||||
- don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there.
|
||||
|
||||
|
||||
26
BUILD.md
26
BUILD.md
@@ -1,5 +1,25 @@
|
||||
# RuDS — Build Prerequisites
|
||||
|
||||
## Native desktop packages
|
||||
|
||||
Install the pinned Cargo Packager CLI once:
|
||||
|
||||
```sh
|
||||
cargo install cargo-packager --locked --version 0.11.8
|
||||
```
|
||||
|
||||
Build packages on their native operating system from the repository root:
|
||||
|
||||
```sh
|
||||
cargo bundle-macos # Blogging Desktop Server.app and .dmg
|
||||
cargo bundle-windows # NSIS .exe installer
|
||||
cargo bundle-linux # .deb and .AppImage
|
||||
```
|
||||
|
||||
Packages are written below `target/release`. The macOS bundle uses the ICNS icon, Windows embeds the ICO in the application executable and installer, and Linux packages install the 1024×1024 PNG with their desktop entry.
|
||||
|
||||
Windows packaging requires the MSVC Rust toolchain, Visual Studio Build Tools with C++ support, and the Windows SDK. Build each package on its target operating system; these commands do not cross-package installers.
|
||||
|
||||
## macOS system requirements
|
||||
|
||||
- macOS 13 (Ventura) or later
|
||||
@@ -12,7 +32,7 @@
|
||||
|
||||
```sh
|
||||
# Debian/Ubuntu
|
||||
sudo apt install build-essential cmake pkg-config libgtk-3-dev libxdo-dev libdbus-1-dev
|
||||
sudo apt install build-essential cmake pkg-config libgtk-3-dev libxdo-dev libdbus-1-dev libwebkit2gtk-4.1-dev
|
||||
```
|
||||
|
||||
## Install Homebrew packages (macOS)
|
||||
@@ -55,8 +75,8 @@ rustup default stable
|
||||
|
||||
No additional system packages beyond the above. Key crates use bundled/vendored native code:
|
||||
|
||||
- `rusqlite` with `bundled` feature compiles SQLite from source
|
||||
- `refinery` manages SQL migrations against rusqlite
|
||||
- `diesel` provides the typed SQLite query layer
|
||||
- `diesel_migrations` embeds migrations; `libsqlite3-sys` bundles SQLite
|
||||
- `iced` uses wgpu which links to Metal (macOS) or Vulkan (Linux/Windows) at runtime
|
||||
- `muda` uses native platform menu APIs (NSMenu on macOS, GTK on Linux, Win32 on Windows)
|
||||
- `rfd` uses native platform dialog APIs (NSOpenPanel on macOS, GTK on Linux, Win32 on Windows)
|
||||
|
||||
124
CLI.md
Normal file
124
CLI.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# RuDS Command Line, Headless Server, and Terminal UI
|
||||
|
||||
## In this article
|
||||
|
||||
- [What the CLI is](#what-the-cli-is)
|
||||
- [Installing the CLI](#installing-the-cli)
|
||||
- [Global flags and output](#global-flags-and-output)
|
||||
- [Command reference](#command-reference)
|
||||
- [Creating content from the command line](#creating-content-from-the-command-line)
|
||||
- [Running the headless server](#running-the-headless-server)
|
||||
- [Using the terminal UI](#using-the-terminal-ui)
|
||||
- [How the CLI and the desktop app stay in sync](#how-the-cli-and-the-desktop-app-stay-in-sync)
|
||||
|
||||
## What the CLI is
|
||||
|
||||
`bds-cli` is the automation surface of RuDS. It runs the same engines against the same application database and the same projects as the desktop app — there is no separate state, no export/import step, and no second configuration. Anything the CLI changes shows up in an open desktop window automatically.
|
||||
|
||||
Typical uses: scripted rebuilds and site generation, publishing from CI or cron, creating posts from other tools, importing images in bulk, running utility Lua scripts, and starting the headless server or the terminal UI.
|
||||
|
||||
## Installing the CLI
|
||||
|
||||
Install the launcher from the desktop app under **Settings → Data**, or run `bds-cli install` from a packaged binary. Both place a small forwarding launcher in `~/.local/bin` that executes the packaged CLI next to the shared runtime. Make sure `~/.local/bin` is on your `PATH`.
|
||||
|
||||
## Global flags and output
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--json` | Print a stable JSON result envelope instead of human-readable text |
|
||||
| `--airplane` | Block network commands and route automatic AI work to the local (airplane) endpoint |
|
||||
|
||||
The JSON envelope always has the shape `{"ok": bool, "command": string, "message": string, "data": object, "progress": [string], "notices": [string]}`, which makes the CLI safe to drive from scripts and LLM agents. Errors exit with code 1 and a formatted message on stderr; unknown commands and invalid options do the same.
|
||||
|
||||
With `--airplane`, `upload`, `push`, and `pull` are refused, and AI-assisted steps (language detection, translation, image enrichment) use the configured airplane endpoint. When no local endpoint is configured, the CLI prints a notice and falls back to offline heuristics where they exist — the command-line equivalent of the desktop's airplane-mode toast.
|
||||
|
||||
## Command reference
|
||||
|
||||
All commands operate on the active project from the shared project registry unless stated otherwise.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `rebuild` | Rebuild the cache database from the active project's files. `--incremental` applies only detected differences and imports orphan files |
|
||||
| `repair <part>` | Run one derived-data repair: `post-links`, `media-links`, `thumbnails`, `embeddings`, or `search` |
|
||||
| `render` | Render the generated site. `--incremental` validates output and applies only differences; `--force` re-renders everything, ignoring content hashes |
|
||||
| `upload` | Upload generated HTML, thumbnails, and media using the project publishing settings, waiting for the publish job |
|
||||
| `push` | Push the project repository to origin |
|
||||
| `pull` | Fast-forward pull, then reconcile the cache database with the pulled files |
|
||||
| `post` | Create a post from flags or JSON on stdin (see below) |
|
||||
| `media <file>` | Import one image with best-effort AI enrichment and translations. `--language` overrides detection |
|
||||
| `gallery <images…>` | Create a post and import/link all supplied images through the shared gallery pipeline |
|
||||
| `config get\|set\|list` | Read or update global application settings; `list` shows effective values with secrets redacted |
|
||||
| `project list\|add\|switch` | List, add (`--name` optional), or switch projects in the shared registry |
|
||||
| `lua <script> [args…]` | Run an enabled utility Lua script from the active project in the sandboxed runtime |
|
||||
| `server` | Start the authenticated headless SSH server (see below) |
|
||||
| `tui` | Start the interactive terminal UI locally |
|
||||
| `install` | Install the forwarding launcher in `~/.local/bin` |
|
||||
|
||||
## Creating content from the command line
|
||||
|
||||
`post` accepts either flags or a JSON object on stdin:
|
||||
|
||||
```
|
||||
bds-cli post --title "Release notes" --content "…" --tags releases,news
|
||||
echo '{"title":"Release notes","content":"…","tags":["releases"]}' | bds-cli post --stdin
|
||||
```
|
||||
|
||||
Available flags: `--title`, `--content`, `--excerpt`, `--author`, `--language`, `--template`, `--tags`, `--categories`, `--no-translate`, `--stdin`. When `--language` is omitted the language is auto-detected (AI endpoint when available, offline heuristic otherwise, with a printed notice). After creation the same automatic translation cascade as the desktop app is scheduled and awaited.
|
||||
|
||||
`gallery` takes the same post flags plus image paths; every image is imported, linked to the new post, AI-enriched, and translated like the desktop gallery workflow. `media` runs the same pipeline for a single image without creating a post.
|
||||
|
||||
## Running the headless server
|
||||
|
||||
`bds-cli server` runs RuDS without a window — same binary family, same database, same engines. Launching the desktop binary with `BDS_MODE=server` is equivalent.
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--bind <ip>` | SSH listen address. Defaults to loopback; external access must be explicit |
|
||||
| `--port <port>` | SSH listen port (default 2222) |
|
||||
| `--database <path>` | Application database path override |
|
||||
| `--data-dir <path>` | Private application data directory containing the SSH key material |
|
||||
|
||||
Only the SSH port is ever exposed. On first start the server generates a restrictive RSA host key and an empty `authorized_keys` file in an `ssh/` folder inside the private application data directory. Add one public key per line — standard OpenSSH format. `authorized_keys` is re-read on every authentication, so key changes need no restart. There are no passwords.
|
||||
|
||||
Clients connect in two ways:
|
||||
|
||||
- **Terminal**: `ssh -p 2222 user@server` opens the terminal UI directly in the SSH session. Each client gets its own session; all sessions share the server's data and UI language.
|
||||
- **Desktop app**: **File → Connect to Server…** with `user@host` or `user@host:port`. The desktop uses its own generated Ed25519 identity and trust-on-first-use `known_hosts`, negotiates the native protocol over the same SSH connection, and shows the remote workspace in the window. **File → Disconnect from Server** returns to the local workspace.
|
||||
|
||||
All connected clients stay synchronized through ordered domain events: an edit in one session refreshes lists and closes deleted-entity tabs everywhere.
|
||||
|
||||
## Using the terminal UI
|
||||
|
||||
Start it locally with `bds-cli tui`, or get it automatically over `ssh` to a headless server. The status line at the bottom always shows the keys available in the current context.
|
||||
|
||||
### Views and navigation
|
||||
|
||||
| Key | View |
|
||||
|---|---|
|
||||
| `1` | Posts |
|
||||
| `2` | Media |
|
||||
| `3` | Templates |
|
||||
| `4` | Scripts |
|
||||
| `5` | Tags |
|
||||
| `6` | Settings |
|
||||
| `7` | Git |
|
||||
|
||||
`↑`/`↓` or `j`/`k` move the sidebar selection, `Enter` opens the selected entry, `Esc` goes back, `q` quits.
|
||||
|
||||
### Working with content
|
||||
|
||||
- Posts open in a syntax-highlighted, soft-wrapped editor. `n` creates a new post. `Ctrl+S` saves the draft, `Ctrl+P` saves and publishes, `Ctrl+E` toggles the rendered Markdown preview, `Ctrl+G` runs the one-shot AI post analysis (airplane-gated).
|
||||
- Templates and scripts open with HTML+Liquid and Lua highlighting.
|
||||
- Images open in the terminal image preview.
|
||||
- `/` opens a live filter for the current view: plain words search text, `tag:x` and `category:x` filter like the desktop chips, and an ISO date (`yyyy-mm-dd`) narrows to that day. Tokens combine with AND; `Enter` keeps the filter, `Esc` clears it.
|
||||
- `:` opens the command prompt with the parameterless Blog-menu commands (metadata diff, validate site, force render, rebuilds, reindex, translations, duplicates, upload, preview URL). `:?` shows the list as help. Completed metadata-diff and site-validation tasks open a report panel; `Enter` applies the whole report, `Esc` closes it.
|
||||
- `p` opens the project switcher; `o` switches to a folder prompt with bash-style tab completion to open a folder as a new project (a full rebuild is queued automatically).
|
||||
- In Tags: `n` create, `Enter` rename, `c` cycle color, `t` cycle template, `d` then `y` delete, `s` sync from posts; in Merge, `Space` marks and `m` merges.
|
||||
- In Git: `c` commit (stage all), `u` pull (fast-forward only), `s` push, `Enter` jumps the diff to a file.
|
||||
- Settings sections mirror the desktop editor; `Enter` edits a field in the status line, `Ctrl+S` saves the section.
|
||||
|
||||
## How the CLI and the desktop app stay in sync
|
||||
|
||||
Every CLI mutation writes a notification row into the shared database. A running desktop app watches for these, invalidates its caches, and refreshes the affected views within moments — no manual reload. Processed notifications are pruned automatically.
|
||||
|
||||
Because CLI, TUI, server, and desktop all use the same engines, invariants hold everywhere: published bodies live in files, metadata flushes to the filesystem, and rebuilds reconcile external changes.
|
||||
4937
Cargo.lock
generated
4937
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
40
Cargo.toml
40
Cargo.toml
@@ -5,6 +5,9 @@ members = [
|
||||
"crates/bds-editor",
|
||||
"crates/bds-ui",
|
||||
"crates/bds-cli",
|
||||
"crates/bds-mcp",
|
||||
"crates/bds-server",
|
||||
"crates/bds-package-runtime",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -15,40 +18,60 @@ license = "MIT"
|
||||
[workspace.dependencies]
|
||||
# Foundation
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
|
||||
sha2 = "0.10"
|
||||
deunicode = "1"
|
||||
md5 = "0.8.1"
|
||||
unicode-normalization = "0.1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.8"
|
||||
walkdir = "2"
|
||||
futures = "0.3"
|
||||
image = "0.25"
|
||||
hpvcd = "0.3.1"
|
||||
webp = { version = "0.3.1", default-features = false }
|
||||
rust-stemmers = "1"
|
||||
sys-locale = "0.3"
|
||||
fluent-bundle = "0.16"
|
||||
fluent-syntax = "0.12"
|
||||
dirs = "5"
|
||||
open = "5"
|
||||
pulldown-cmark = "0.13"
|
||||
liquid = "0.26"
|
||||
liquid-core = { version = "0.26", features = ["derive"] }
|
||||
quick-xml = "0.41"
|
||||
htmd = "0.5.4"
|
||||
regex = "1"
|
||||
rayon = "1.10"
|
||||
pagefind = "1.5.2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||
diesel_migrations = "2.3.2"
|
||||
libsqlite3-sys = { version = "0.37.0", features = ["bundled"] }
|
||||
mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] }
|
||||
url = "2.5"
|
||||
fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] }
|
||||
usearch = "2.26"
|
||||
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] }
|
||||
russh = "0.62.2"
|
||||
ratatui = "0.30.2"
|
||||
ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] }
|
||||
tui-markdown = { version = "0.3.8", default-features = false }
|
||||
|
||||
# UI framework
|
||||
iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] }
|
||||
iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] }
|
||||
|
||||
# Editor widget
|
||||
cosmic-text = "0.12"
|
||||
ropey = "1"
|
||||
syntect = "5"
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
|
||||
# Platform integration (cross-platform)
|
||||
muda = "0.15"
|
||||
@@ -57,3 +80,8 @@ rfd = "0.15"
|
||||
# Internal crates
|
||||
bds-core = { path = "crates/bds-core" }
|
||||
bds-editor = { path = "crates/bds-editor" }
|
||||
bds-mcp = { path = "crates/bds-mcp" }
|
||||
bds-server = { path = "crates/bds-server" }
|
||||
|
||||
[profile.release]
|
||||
strip = "symbols"
|
||||
|
||||
604
DOCUMENTATION.md
Normal file
604
DOCUMENTATION.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# RuDS User Guide
|
||||
|
||||
## In this article
|
||||
|
||||
- [Who this guide is for](#who-this-guide-is-for)
|
||||
- [How RuDS works](#how-ruds-works)
|
||||
- [Getting started](#getting-started)
|
||||
- [Understanding the interface](#understanding-the-interface)
|
||||
- [Working with posts](#working-with-posts)
|
||||
- [Working with pages](#working-with-pages)
|
||||
- [Working with media](#working-with-media)
|
||||
- [Working with translations](#working-with-translations)
|
||||
- [Finding content and duplicates](#finding-content-and-duplicates)
|
||||
- [Previewing your site](#previewing-your-site)
|
||||
- [Editing site navigation](#editing-site-navigation)
|
||||
- [Using macros](#using-macros)
|
||||
- [Using scripting](#using-scripting)
|
||||
- [Using the AI assistant](#using-the-ai-assistant)
|
||||
- [Organizing with tags](#organizing-with-tags)
|
||||
- [Using blogmarks](#using-blogmarks)
|
||||
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
|
||||
- [Using Git (Source Control)](#using-git-source-control)
|
||||
- [Configuring settings](#configuring-settings)
|
||||
- [Checking and repairing metadata](#checking-and-repairing-metadata)
|
||||
- [Managing templates](#managing-templates)
|
||||
- [Generating and publishing](#generating-and-publishing)
|
||||
- [Typical editorial workflows](#typical-editorial-workflows)
|
||||
- [Working fully offline](#working-fully-offline)
|
||||
- [Connecting external AI agents (MCP)](#connecting-external-ai-agents-mcp)
|
||||
- [Using the command line](#using-the-command-line)
|
||||
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
|
||||
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
||||
- [Team conventions](#team-conventions)
|
||||
|
||||
## Who this guide is for
|
||||
|
||||
This guide is for people who use RuDS day to day to create, edit, organize, translate, generate, and publish blog content. It is written for editors, content managers, and project owners who need reliable guidance on what each part of the application does and how to use it safely.
|
||||
|
||||
If you need implementation notes, project architecture, or development setup, use the repository README. This guide stays focused on end-user operation and editorial decisions.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- RuDS documentation should help with real editorial work, not only isolated clicks.
|
||||
- Each chapter explains purpose first, then usage.
|
||||
- Safe content handling and recoverability matter throughout the application.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## How RuDS works
|
||||
|
||||
RuDS is a local-first writing and publishing workspace. You can draft, revise, structure, preview, and publish content on your machine without depending on constant internet access. Optional remote Git synchronization and AI-assisted workflows extend that model, but they do not replace it.
|
||||
|
||||
Three states matter in day-to-day work. A draft is your in-progress state. Publishing marks a local content state as published inside your project. A Git commit creates a recoverable snapshot that can be reviewed, synchronized, and restored. These actions are related, but they are not the same operation.
|
||||
|
||||
The recommended sequence remains simple: edit in draft, publish when the content is ready, then commit immediately. That is the safest pattern for protecting work and keeping project history understandable.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- RuDS is designed for local reliability first.
|
||||
- Publish and commit are different actions and both matter.
|
||||
- The safe default lifecycle is: Draft -> Publish -> Commit.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
Before you begin editorial work, confirm that the project context is correct. Open RuDS and select the right project. If this is a new project, create it and define its identity early, including project name and description.
|
||||
|
||||
Next, open Settings and verify the project data path and Public Base URL. The data path should match your backup strategy. The Public Base URL should be set early because sitemap and feed generation depend on it.
|
||||
|
||||
Finally, define language and author defaults. These defaults reduce repetitive edits and keep output consistent when multiple contributors work in the same project.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Set project identity, data location, and Public Base URL at the beginning.
|
||||
- Configure language and author defaults before regular editing starts.
|
||||
- Early setup decisions reduce later cleanup.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Understanding the interface
|
||||
|
||||
The RuDS interface is organized around workflows rather than isolated forms. The Activity Bar on the left moves between the major areas: Posts, Pages, Media, Scripts, Templates, Tags, Chat, Import, Source Control, and Settings. The Sidebar changes with the active area and helps with filtering, selection, and navigation. The Editor area is where most work happens and supports tabbed editing for content, configuration, and analysis views. When no tab is open, the editor area shows the Dashboard with project statistics, a posting timeline, a tag cloud, and recent posts.
|
||||
|
||||
The status bar at the bottom shows the active project and holds the airplane-mode switch that decides whether online AI endpoints may be used. It also shows the applied site theme and, during AI chat, running token totals.
|
||||
|
||||
The bottom panel and status area matter during longer operations such as imports, rebuild actions, metadata scans, and media work. Toasts provide quick feedback. The Output panel provides deeper detail when something needs attention.
|
||||
|
||||
Tab behavior is optimized for quick scanning and focused editing. Single click often opens a transient tab. Double click or explicit actions pin a tab for longer work.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Use the Activity Bar for section-level context switching.
|
||||
- Use the Sidebar for finding and narrowing content.
|
||||
- Pin tabs when you move from inspection to editing.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Working with posts
|
||||
|
||||
The Posts section is for chronological content such as articles, notes, and recurring updates. In most editorial teams, Posts are the primary outward-facing stream.
|
||||
|
||||
A post combines title, body content, category, tags, excerpt, and status. Titles establish topic. Body content carries the narrative. Categories provide broad structure. Tags support finer discovery. Status should be used intentionally so collaborative workflows stay clear.
|
||||
|
||||
A reliable post workflow is: draft to completion, review structure and metadata, preview the result, publish when editorially ready, then commit immediately.
|
||||
|
||||
When you want help refining post metadata, use Quick Actions in the post editor and review AI suggestions for title, summary, and slug. Treat this as editorial assistance, not an automatic rewrite.
|
||||
|
||||
The post editor's links panel shows outgoing links to other posts and backlinks from posts that reference the current one. Use it to check context before restructuring or unpublishing, and click a linked post to open it directly.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Use Posts for date-oriented and regularly updated content.
|
||||
- Categories and tags serve different purposes: broad grouping versus precise discovery.
|
||||
- Publish only when editorially ready, then commit right away.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Working with pages
|
||||
|
||||
Pages are for durable, non-chronological content such as About, Contact, legal notices, and other structural information. Use Pages when content should stay stable in navigation and should not be interpreted as part of a time-based feed.
|
||||
|
||||
Because pages are revisited over longer periods, naming consistency matters. Keep titles and slugs predictable, avoid unnecessary structural churn, and follow your project navigation conventions.
|
||||
|
||||
The working pattern is similar to posts: draft, review, preview, publish, commit. The difference is editorial intent: pages prioritize clarity and long-term maintainability over release cadence.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Use Pages for stable structural content.
|
||||
- Keep titles and slugs consistent for maintainability.
|
||||
- Apply the same safe lifecycle: Draft -> Publish -> Commit.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Working with media
|
||||
|
||||
The Media section is where you import, describe, and maintain assets used by posts and pages. It is not only a file list; it is also where accessibility and descriptive quality are enforced through metadata.
|
||||
|
||||
When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse.
|
||||
|
||||
From the post editor you can insert already-imported media at the cursor position, drag supported image files—including HEIC and HEIF photos—onto an open post editor, or use the Gallery workflow to import several images at once. A dropped image is imported into the media library, linked to the current post, and inserted at the current cursor with a host-absolute `/media/...` URL that remains valid in rendered HTML. Multiple dropped files are processed in order with progress in the Tasks panel. Optional AI enrichment and media-metadata translation continue in the background; when the active AI endpoint is unavailable, the import still completes and RuDS reports that enrichment was skipped.
|
||||
|
||||
The Gallery workflow imports its selected images into the media library, links them to the current post, runs the same optional AI enrichment for titles and alt text, and inserts the gallery block into the post.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Media management includes metadata quality, not only file import.
|
||||
- Add alt text and captions during import, not as a postponed task.
|
||||
- Drag images onto an open post editor for a direct import, link, and cursor insertion.
|
||||
- Commit content and related media in the same change when possible.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Working with translations
|
||||
|
||||
RuDS supports translating both posts and media metadata into multiple languages. Translations are stored separately from canonical content so localized variants do not drift into unrelated records.
|
||||
|
||||
### Post translations
|
||||
|
||||
Each post has a canonical language and can have translations for additional languages. Translations keep their own title, excerpt, and content, while canonical metadata such as category, tags, slug, and publish state stays centralized.
|
||||
|
||||
The post editor shows the current language, existing translations, and missing languages. Posts marked Do Not Translate are excluded from automatic translation and from alternate language trees during site generation.
|
||||
|
||||
Published translation body content follows the same filesystem rule as published posts: the body lives in the file, not in the database.
|
||||
|
||||
### Media translations
|
||||
|
||||
Media items can have translated title, alt text, and caption values per language. The binary asset stays shared; only descriptive text varies by language.
|
||||
|
||||
### Automatic translation cascade
|
||||
|
||||
When blog languages are configured, RuDS can fill missing translations for posts and linked media. Automatic translation respects airplane mode and the configured AI runtime. If an automatic action cannot run in the current AI mode, RuDS reports that through the UI instead of silently inventing a result.
|
||||
|
||||
### Validation report
|
||||
|
||||
Use **Blog → Validate Translations** to open a completeness report across all configured languages. It lists missing and outdated translations for posts and media so you can close gaps deliberately instead of discovering them after generation.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Post translations store title, excerpt, and content separately from the canonical post.
|
||||
- Media translations store translated descriptive text while the asset stays shared.
|
||||
- Automatic translation keeps posts and linked media aligned across configured languages.
|
||||
- Do Not Translate excludes content from multi-language workflows.
|
||||
- The translation validation report shows missing languages before they become visible gaps.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Finding content and duplicates
|
||||
|
||||
The sidebar search narrows the current list as you type. Posts, pages, and media support full-text search with language-aware stemming, and the filter panel adds calendar, tag, and category filters that combine with the text query.
|
||||
|
||||
When semantic similarity is enabled in the project settings, sidebar search also ranks results by meaning, not only by matching words. The same capability powers related-post ranking in the links panel and tag suggestions in the post editor.
|
||||
|
||||
Use **Blog → Find Duplicate Posts** to review pairs of posts with very similar or identical content. The review view pages through candidate pairs, lets you open both posts, and lets you dismiss a pair once you have decided it is intentional. Dismissals are remembered.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Sidebar search combines full-text matching with calendar, tag, and category filters.
|
||||
- Semantic similarity, when enabled, improves search, related posts, and tag suggestions.
|
||||
- The duplicate review helps consolidate overlapping content deliberately.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Previewing your site
|
||||
|
||||
Preview renders through the same templates, routes, and asset files as site generation, so navigation and host-absolute links behave like the generated site. The preview server runs only on your machine at `127.0.0.1:4123` and is never exposed to the network.
|
||||
|
||||
Switch a post editor from **Markdown** to **Preview** to render that post at its normal generated-site URL inside RuDS. Use **File → Open in Browser** for the complete navigable blog: with a post editor active it starts on that post, and otherwise it starts on the home page. **Blog → Preview Post** also opens the active post in the default browser.
|
||||
|
||||
The preview includes draft and published posts on normal site routes. Draft content comes from the database, including draft translations; published content comes from the Markdown files that generation will use. Media, styles, scripts, images, and Pagefind files are served from their project filesystem locations, with packaged defaults used only when a project asset is absent.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Preview and generation share one renderer and URL structure; preview overlays draft database content.
|
||||
- The preview server is local-only.
|
||||
- Browser and embedded previews remain navigable across normal site routes.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Editing site navigation
|
||||
|
||||
The site menu is stored as an OPML file in your project (`meta/menu.opml`) and edited visually in the Menu Editor (**Blog → Edit Menu**). The Home entry always stays first; below it you can add page links, submenus, and category entries, including categories that do not exist yet.
|
||||
|
||||
Reorder entries by dragging or with the equivalent keyboard controls, indent or unindent to build submenus, and save when the structure is right. The saved menu feeds the same renderer used by preview and generation, so the navigation you build is exactly what appears on the published site.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- The menu is a filesystem artifact (`meta/menu.opml`) and therefore Git-reviewable.
|
||||
- Home is protected and always first.
|
||||
- Preview, generation, and the editor all use the same menu structure.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Using macros
|
||||
|
||||
Macros let you insert dynamic content blocks directly inside Markdown by using `[[macro_name ...]]` syntax. RuDS expands these macros during preview and generated output using local assets only.
|
||||
|
||||
Built-in macros include YouTube, Vimeo, gallery, photo archive, and tag cloud helpers. Use them when you want reusable rich blocks without dropping into raw HTML.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Macros are inserted directly in Markdown and expanded during preview and publishing.
|
||||
- Use macro parameters to control behavior without leaving the editor.
|
||||
- Built-in macros remain the first choice for common embedded content blocks.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Using scripting
|
||||
|
||||
Scripts in RuDS are Lua files stored in your project's `scripts/` directory. Published scripts are written as `.lua` files with frontmatter metadata, so they stay portable and Git-reviewable.
|
||||
|
||||
Each script has a Kind (`macro`, `transform`, or `utility`) and an Entrypoint. Utility and transform scripts typically default to `main`. Macro scripts default to `render`.
|
||||
|
||||
### Transform scripts
|
||||
|
||||
Transform scripts run during blogmark import to normalize or enrich incoming post data before the post is created. The entrypoint receives a post table and can optionally receive a context table.
|
||||
|
||||
```lua
|
||||
function main(post, context)
|
||||
local title = (post.title or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
|
||||
if title ~= "" and not title:match("^%[Clipped%]") then
|
||||
post.title = "[Clipped] " .. title
|
||||
end
|
||||
|
||||
post.categories = { "Inbox", "Research" }
|
||||
return post
|
||||
end
|
||||
```
|
||||
|
||||
`context.source` identifies the import source. `context.url` contains the original bookmarked URL when that information exists.
|
||||
|
||||
### Macro scripts
|
||||
|
||||
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and generation. The entrypoint receives a context table and the current post table.
|
||||
|
||||
```lua
|
||||
function render(context, post)
|
||||
local params = context.params or {}
|
||||
local title = (post and post.title) or "Unknown"
|
||||
local label = params.label or ""
|
||||
|
||||
return {
|
||||
html = "<p>" .. title .. ": " .. label .. "</p>"
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
Built-in macros take priority over custom Lua macros that reuse the same slug.
|
||||
|
||||
### API access
|
||||
|
||||
Lua scripts can call the application API through `bds`. The in-app API tab is rendered from the live Lua capability map, and [API.md](API.md) is generated from the same source.
|
||||
|
||||
```lua
|
||||
local result = bds.posts.get("post-id")
|
||||
```
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Scripts in RuDS are Lua files, not Python files.
|
||||
- Published scripts are stored as `.lua` files with frontmatter metadata.
|
||||
- `main` is the usual entrypoint for utility and transform scripts; `render` is the usual entrypoint for macros.
|
||||
- The scripting API is documented with Lua examples and kept in sync with the live runtime.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Using the AI assistant
|
||||
|
||||
The AI assistant is integrated into RuDS to help with editorial tasks such as search, analysis, metadata suggestions, translation, and structured content inspection.
|
||||
|
||||
The assistant works on your project data. Depending on configuration, requests can run against the configured online endpoint or the airplane-mode endpoint. Automatic AI actions remain gated by airplane mode rules in the app, and RuDS surfaces status through toasts and the Output area instead of silently bypassing that policy.
|
||||
|
||||
The assistant can present results as text, tables, cards, charts, metrics, lists, forms, and tabbed views. Ask plainly for the result you need, or request a specific presentation when that helps your workflow.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- The assistant works with your project content and metadata.
|
||||
- AI configuration can be online or airplane-mode based, depending on your setup.
|
||||
- Automatic AI actions respect airplane mode and report availability through the UI.
|
||||
- Ask for a table, chart, list, or form when a specific shape is useful.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Organizing with tags
|
||||
|
||||
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer help readers or editors. Use the Tags area to keep taxonomy useful: the tag cloud shows usage, the manage section covers create, rename, color, and template assignment, and the merge section consolidates overlapping tags.
|
||||
|
||||
When semantic similarity is enabled, the post editor suggests existing tags that fit the content you are writing, which helps keep the taxonomy consistent instead of growing new variants.
|
||||
|
||||
After significant taxonomy cleanup, create a focused commit that captures the change clearly.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Tags improve discovery only if naming stays consistent.
|
||||
- Merge and rename operations should be deliberate and reviewed.
|
||||
- Commit taxonomy changes in focused snapshots.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Using blogmarks
|
||||
|
||||
Blogmarks provide a quick way to save links from the browser directly into RuDS as new posts. Generate the bookmarklet from Settings, add it to your browser bar, and click it when you want to capture a page into the current project.
|
||||
|
||||
Transform scripts can normalize incoming blogmark posts before creation. Use them for title cleanup, default tags, or source-specific formatting.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Blogmarks turn the browser into a one-click content capture tool.
|
||||
- Generate the bookmarklet from Settings and add it to your browser bar.
|
||||
- Use transform scripts to enrich incoming posts automatically.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Importing from WordPress (WXR)
|
||||
|
||||
The Import section supports structured migration from WordPress exports. Treat import as a staged workflow: analyze first, adjust mappings, then execute. For larger sites, iterative passes are usually safer than a single rigid import.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Treat WXR import as analyze, adjust, execute.
|
||||
- Iterative passes are safer than one large import.
|
||||
- Validate representative output before committing migrated content.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Using Git (Source Control)
|
||||
|
||||
Source Control in RuDS is the foundation for reliable recovery and collaboration. Publishing marks local editorial state, but Git commits provide durable history.
|
||||
|
||||
In a normal cycle, synchronize first, complete editorial changes, publish when ready, commit with a specific message, then push when you want to share the result.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Git provides recoverable history; publishing alone does not.
|
||||
- A stable rhythm is: sync, edit, publish, commit, push.
|
||||
- Specific commit messages improve teamwork and recovery.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Configuring settings
|
||||
|
||||
Settings define how the project behaves. Project settings control identity, paths, public URL context, and render languages. Editor settings shape day-to-day working defaults. AI settings are optional and should enhance, not define, your editorial workflow.
|
||||
|
||||
Maintenance actions such as rebuilds and diff scans are repair tools for specific situations, not part of routine editing.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Settings affect long-term consistency across the project.
|
||||
- Optional integrations should not replace the core workflow.
|
||||
- Rebuild actions are corrective tools, not daily habits.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Checking and repairing metadata
|
||||
|
||||
Over time, metadata stored in the database and metadata stored on disk can drift apart, especially after external edits, merges, or file operations. The Metadata Diff tool detects these inconsistencies and lets you repair them without rebuilding everything.
|
||||
|
||||
The scan covers posts and their translations, media and their translated sidecars, scripts, templates, project metadata, and embedding state. Results are grouped by entity type, and field pills let you focus on one kind of difference at a time.
|
||||
|
||||
Use DB to File when the database is correct. Use File to DB when the filesystem is correct.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Metadata Diff compares database records against files on disk.
|
||||
- Field pills help you bulk-repair one difference type at a time.
|
||||
- Use it after external changes, not as part of routine editing.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Managing templates
|
||||
|
||||
Templates control the Liquid layout used when RuDS generates HTML pages. Template kinds determine where they are used: `post`, `list`, `not-found`, and `partial`.
|
||||
|
||||
Templates are stored as files with frontmatter metadata in the project data directory, so they are portable and Git-reviewable.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Templates define the generated HTML layout.
|
||||
- Four template kinds cover page, list, not-found, and reusable partial rendering.
|
||||
- Templates are filesystem-backed and Git-friendly.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Generating and publishing
|
||||
|
||||
Publishing in RuDS is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready.
|
||||
|
||||
Full generation builds the entire static site and skips files whose tracked content hash is unchanged. Use **Blog → Force Render Site** (Command/Ctrl+Shift+R) when every generated file must be rewritten regardless of its stored hash. Site validation detects missing, extra, and updated routes so RuDS can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
|
||||
|
||||
When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Full generation produces the complete site.
|
||||
- Force Render Site rewrites the complete site and search index without relying on stored hashes.
|
||||
- Validate and Apply is the efficient daily workflow for incremental publishing.
|
||||
- Public Base URL must be set before generation.
|
||||
- Commit generated output before deploying for recoverability.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Typical editorial workflows
|
||||
|
||||
Short link posts benefit from a lightweight workflow: create, add concise context, classify, preview once, publish, commit. Long-form articles benefit from a fuller cycle: draft thoroughly, add media, review metadata, preview carefully, publish, commit content and media together.
|
||||
|
||||
Across both patterns, the safety baseline stays the same: Draft -> Publish -> Commit.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Use a lightweight workflow for short notes and links.
|
||||
- Use a fuller workflow for long-form content with media.
|
||||
- Keep the same safety baseline in both cases.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Working fully offline
|
||||
|
||||
RuDS is designed so core editorial work can continue without network access. You can create and revise content, manage metadata, preview locally, and publish within local project state while offline.
|
||||
|
||||
When AI is involved, airplane mode determines which automatic actions are allowed and which endpoint class is used. Keep local commits frequent even when you are not pushing to a remote.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Core editing and publishing workflows work offline.
|
||||
- Local commits still matter when no remote is available.
|
||||
- Reconnect and synchronize in a controlled order.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Connecting external AI agents (MCP)
|
||||
|
||||
RuDS can act as an MCP (Model Context Protocol) server so external AI tools such as Claude Code or GitHub Copilot can read your project and propose changes. Enable it under **Settings → MCP**, which also shows the endpoint status and offers guarded one-click configuration for supported agents.
|
||||
|
||||
Reads are direct: agents can browse posts, media, tags, categories, and statistics, and search your content. Writes never happen silently — every write an agent requests becomes a pending proposal that you review and accept or reject in **Settings → MCP**. Only an accepted proposal is applied, exactly once, through the same engines the editors use.
|
||||
|
||||
The full list of resources and tools is documented in **Help → MCP Server Documentation**.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- MCP is opt-in and configured under Settings → MCP.
|
||||
- Agents read directly but can only write through proposals you approve.
|
||||
- See Help → MCP Server Documentation for the complete surface.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Using the command line
|
||||
|
||||
RuDS ships a command-line interface for automation: rebuilds, rendering, publishing, Git sync, post and media creation, utility scripts, and more. Install the `bds-cli` launcher from **Settings → Data**; it runs the same engines and the same database as the desktop app, and the app picks up CLI changes automatically.
|
||||
|
||||
The complete command reference — including how to start the headless server and the terminal UI — is available in **Help → CLI Documentation**.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- The CLI shares engines and data with the desktop app; there is no separate state.
|
||||
- Install it from Settings → Data.
|
||||
- See Help → CLI Documentation for the full reference.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Running a headless server and the terminal UI
|
||||
|
||||
RuDS can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `bds-cli server`, or launch the desktop binary with `BDS_MODE=server` — both run the same headless mode. All clients connect through one SSH port (default 2222); nothing else is exposed to the network.
|
||||
|
||||
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/bds/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
|
||||
|
||||
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, manage settings and Git, and run the one-shot AI actions. The status line at the bottom always shows the available keys. The same terminal UI also runs locally via `bds-cli tui` — see **Help → CLI Documentation** for the complete reference.
|
||||
|
||||
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace.
|
||||
|
||||
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- `bds-cli server` (or `BDS_MODE=server`) runs the same app headless; only the SSH port is exposed.
|
||||
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
|
||||
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
|
||||
- All connected clients stay synchronized and share one interface language.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting and recovery
|
||||
|
||||
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
|
||||
|
||||
If content lists or references become inconsistent after manual file changes, start with Metadata Diff. If broader inconsistency remains, use rebuild tools to realign database and filesystem state.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Most missing remote content issues are commit or push gaps.
|
||||
- Metadata Diff is the first repair tool after external file changes.
|
||||
- Frequent meaningful commits are the strongest safety net.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Team conventions
|
||||
|
||||
Shared conventions reduce ambiguity and merge friction. Teams should agree on category definitions, tag naming rules, publish-readiness criteria, and commit message patterns.
|
||||
|
||||
A practical minimum rule is simple: any content considered published should be committed promptly.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Explicit conventions improve speed and reduce avoidable conflict.
|
||||
- Start with a small rule set and enforce it consistently.
|
||||
- Minimum standard: published content should be committed promptly.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
99
MCP.md
Normal file
99
MCP.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# RuDS MCP Server
|
||||
|
||||
## In this article
|
||||
|
||||
- [What the MCP server is](#what-the-mcp-server-is)
|
||||
- [Enabling and connecting](#enabling-and-connecting)
|
||||
- [Security model](#security-model)
|
||||
- [Resources](#resources)
|
||||
- [Tools](#tools)
|
||||
- [The proposal workflow](#the-proposal-workflow)
|
||||
- [Configuring coding agents](#configuring-coding-agents)
|
||||
|
||||
## What the MCP server is
|
||||
|
||||
RuDS exposes your active project to external AI tools through the Model Context Protocol (MCP). An agent such as Claude Code or GitHub Copilot can browse posts, media, tags, categories, and statistics, run searches, and draft changes — while every write stays under your explicit control in the desktop app.
|
||||
|
||||
Two transports serve the same surface:
|
||||
|
||||
- **stdio**: the packaged `bds-mcp` executable speaks MCP over stdin/stdout. This is what coding agents launch directly.
|
||||
- **HTTP**: a stateless localhost-only endpoint at `http://127.0.0.1:4124/mcp` (POST), started by the desktop app when enabled in **Settings → MCP**, and by the headless server for its loopback clients.
|
||||
|
||||
Both operate on the shared application database and the active project.
|
||||
|
||||
## Enabling and connecting
|
||||
|
||||
Open **Settings → MCP** in the desktop app to:
|
||||
|
||||
- enable or disable the HTTP endpoint and see its status and address,
|
||||
- review pending write proposals (see below),
|
||||
- install or remove ready-made agent configuration for supported tools.
|
||||
|
||||
The stdio binary needs no enablement; running it is the connection.
|
||||
|
||||
## Security model
|
||||
|
||||
- The HTTP endpoint binds to `127.0.0.1` only and validates the `Host` and `Origin` headers against localhost, so browsers cannot be tricked into calling it cross-origin. Only `POST` and `OPTIONS` are accepted.
|
||||
- Reads are direct and read-only. Writes never take effect on their own: every write tool creates an inert, persisted proposal that a human must approve in the desktop app.
|
||||
- Agent configuration written by RuDS never contains secrets.
|
||||
|
||||
## Resources
|
||||
|
||||
Resources use the `bds://` scheme and are read-only.
|
||||
|
||||
| Resource | Content |
|
||||
|---|---|
|
||||
| `bds://project` | Active project identity and metadata |
|
||||
| `bds://posts` | Blog posts (paginated via `bds://posts{?cursor}`) |
|
||||
| `bds://media` | Media items (paginated via `bds://media{?cursor}`) |
|
||||
| `bds://tags` | All tags |
|
||||
| `bds://categories` | All categories |
|
||||
| `bds://stats` | Blog statistics |
|
||||
| `bds://posts/{id}/media` | Media linked to one post |
|
||||
| `bds://media/{id}/image` | The media image itself |
|
||||
|
||||
Post detail includes full content and metadata plus backlinks and outgoing links; published bodies are read from the canonical files.
|
||||
|
||||
## Tools
|
||||
|
||||
Read and search tools return results directly:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `check_term` | Check whether a term is a category, a tag, or both, with post counts |
|
||||
| `search_posts` | Full-text and filtered post search with pagination, backlinks, and outgoing links |
|
||||
| `count_posts` | Count filtered posts grouped by year, month, tag, category, or status |
|
||||
| `read_post_by_slug` | Read full post content and metadata, optionally in a translated language |
|
||||
| `get_post_translations` | List every translation for a post |
|
||||
| `get_media_translations` | List every translated metadata record for a media item |
|
||||
|
||||
Write tools create proposals instead of writing:
|
||||
|
||||
| Tool | Proposes |
|
||||
|---|---|
|
||||
| `draft_post` | A new post (title, content, excerpt, tags, categories, author, language) |
|
||||
| `propose_post_metadata` | Metadata changes to an existing post |
|
||||
| `propose_media_metadata` | Title, alt text, caption, or tag changes for a media item |
|
||||
| `upsert_media_translation` | Translated media metadata for one language |
|
||||
| `propose_script` | A Lua script (validated before proposing) |
|
||||
| `propose_template` | A Liquid template (validated before proposing) |
|
||||
|
||||
## The proposal workflow
|
||||
|
||||
1. An agent calls a write tool. RuDS validates the payload and stores it as a pending proposal; nothing else happens.
|
||||
2. The proposal appears under **Settings → MCP** in the desktop app with its full payload.
|
||||
3. You accept or reject it. Acceptance applies the change exactly once through the same engines the editors use — files, search indexes, embeddings, and domain events all behave like a normal edit. Rejection discards it.
|
||||
4. Pending proposals expire automatically after 30 minutes. Concurrent resolution is safe: a proposal can only be applied once, and the agent can query the outcome.
|
||||
|
||||
This keeps agents useful for drafting and bulk suggestions while a human stays the only path to persistent change.
|
||||
|
||||
## Configuring coding agents
|
||||
|
||||
**Settings → MCP** can install a guarded configuration for:
|
||||
|
||||
| Agent | What is written |
|
||||
|---|---|
|
||||
| Claude Code | An entry in `~/.claude.json` launching the packaged `bds-mcp` stdio binary |
|
||||
| GitHub Copilot | The equivalent MCP server entry in Copilot's configuration file |
|
||||
|
||||
Installation is opt-in per agent, shows what will be written, never stores secrets, and can be removed the same way.
|
||||
86
README.md
Normal file
86
README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# RuDS
|
||||
|
||||
RuDS is a native Rust blogging desktop application and the successor to bDS2. It manages local projects from authoring through preview, static-site generation, integrity checks, and publishing while preserving the existing bDS filesystem and SQLite formats.
|
||||
|
||||
The desktop is a single-window application: closing its window persists UI state and exits RuDS. Background notifications remain unobtrusive and preserve keyboard focus while editing.
|
||||
|
||||
The project is under active development. Core blogging workflows are broadly available; remaining core work and optional extensions are tracked separately.
|
||||
|
||||
## Available Features
|
||||
|
||||
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Its shared task UI keeps queued work off blocking workers, shows every active local or remote task plus recent history, and supports cooperative cancellation without oversubscribing background work.
|
||||
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
|
||||
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
||||
- SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search.
|
||||
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
|
||||
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
||||
- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries.
|
||||
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
|
||||
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
|
||||
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, effective shared settings with secret-presence redaction, projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
|
||||
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
|
||||
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
|
||||
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
|
||||
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), category-controlled list title visibility, descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, shared cached multicore full-site rendering, change-aware and forced full renders from the native Blog menu/CLI/TUI, and fast route/mtime-based incremental validation whose targeted repair refreshes affected aggregate pages through cancellable section task groups with bDS2-style per-URL progress.
|
||||
- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content.
|
||||
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing.
|
||||
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
|
||||
- SSH-agent-based SCP or rsync publishing.
|
||||
- Integrated Git workflow for each blog project's current repository, with repository initialization, read-only origin discovery, Git LFS image tracking, status and diffs, branch/file history with bDS2-compatible sync-status colors, commits, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.
|
||||
- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol.
|
||||
|
||||
RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview.
|
||||
|
||||
The packaged Apple Silicon application requires macOS 26 or newer.
|
||||
|
||||
Packaged executables share native `bds-core` and `bds-server` dynamic libraries. ONNX Runtime is statically contained in `bds-core`; the package does not ship or download a separate ONNX library. The **Install CLI** action writes a small forwarding launcher to `~/.local/bin`, so the command continues to execute the packaged CLI beside the same runtime libraries instead of copying them.
|
||||
|
||||
Local macOS packages are ad-hoc signed without hardened runtime. A Developer ID and notarization remain optional release-channel steps for downloads that should pass Gatekeeper without a user override.
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua
|
||||
- `crates/bds-editor` — reusable syntax-highlighting editor
|
||||
- `crates/bds-ui` — desktop application and platform integration
|
||||
- `crates/bds-cli` — headless automation CLI over the shared engines
|
||||
- `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine
|
||||
- `crates/bds-server` — reusable headless host, SSH transport, remote protocol, and desktop client library
|
||||
- `specs` — authoritative Allium behavior specifications
|
||||
- `fixtures` — compatibility projects and generated-site fixtures
|
||||
- `locales` — UI and native-menu translations
|
||||
|
||||
## References
|
||||
|
||||
- [Specification index](SPECIFICATION_INDEX.md)
|
||||
- [User guide](DOCUMENTATION.md)
|
||||
- [CLI, headless server, and terminal UI](CLI.md)
|
||||
- [MCP server](MCP.md)
|
||||
- [UI style guide](docs/UI_STYLE_GUIDE.md)
|
||||
- `../bDS2` — reference implementation when an Allium contract is ambiguous
|
||||
|
||||
Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Development gates
|
||||
|
||||
Install the dependency audit tools once:
|
||||
|
||||
```sh
|
||||
cargo install cargo-machete cargo-outdated
|
||||
```
|
||||
|
||||
Before committing, verify dependency usage and freshness, then build and test the complete workspace:
|
||||
|
||||
```sh
|
||||
cargo machete --with-metadata
|
||||
cargo outdated --workspace --root-deps-only --exit-code 1
|
||||
cargo build --workspace
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
The full test suite starts loopback-only mock and preview servers, so localhost binding must be permitted.
|
||||
|
||||
## Headless server
|
||||
|
||||
Run `bds-cli server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. Run `bds-cli tui` or set `BDS_MODE=tui` for the local terminal workspace; an authenticated SSH shell opens the same workspace against server-side data and locale. Press `:` for its command list and `:?` for help.
|
||||
@@ -1,98 +0,0 @@
|
||||
# bDS Rust Rewrite — Compatibility Matrix Template
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is the required inventory of persisted fields and compatibility-sensitive behaviors. Fill it before engine implementation starts, then keep it current as the rewrite proceeds.
|
||||
|
||||
One row per persisted field or compatibility-sensitive artifact.
|
||||
|
||||
## How To Use
|
||||
|
||||
1. Start with posts, translations, media, templates, project metadata, menus, and generated outputs.
|
||||
2. Record the current TypeScript behavior first.
|
||||
3. Record the intended Rust behavior only if it is identical or explicitly approved as a divergence.
|
||||
4. Link every row to tests or golden fixtures once available.
|
||||
|
||||
## Field Matrix
|
||||
|
||||
| Domain | Entity | Field / Artifact | Type | Current Source Of Truth | Persisted In | Read Path | Write Path | Rebuild From Files | Metadata Diff | Publish Impact | Generation Impact | Rust Status | Fixture / Test | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| posts | Post | id | string | DB + frontmatter | DB, frontmatter | | | | | | | not-started | | |
|
||||
| posts | Post | title | string | | | | | | | | | not-started | | |
|
||||
| posts | Post | slug | string | | | | | | | | | not-started | | |
|
||||
| posts | Post | excerpt | string? | | | | | | | | | not-started | | |
|
||||
| posts | Post | content body | markdown | published body in file; draft body per current app rules | DB?, markdown file | | | | | | | not-started | | |
|
||||
| posts | Post | status | enum | | | | | | | | | not-started | | |
|
||||
| posts | Post | author | string? | | | | | | | | | not-started | | |
|
||||
| posts | Post | language | string? | | | | | | | | | not-started | | |
|
||||
| posts | Post | do_not_translate | bool | | | | | | | | | not-started | | |
|
||||
| posts | Post | tags | list | | | | | | | | | not-started | | |
|
||||
| posts | Post | categories | list | | | | | | | | | not-started | | |
|
||||
| posts | Post | template_slug | string? | | | | | | | | | not-started | | |
|
||||
| posts | Post | checksum | string? | | | | | | | | | not-started | | |
|
||||
| translations | PostTranslation | translation_for | string | | | | | | | | | not-started | | |
|
||||
| media | Media | alt | string? | | | | | | | | | not-started | | |
|
||||
| media | Media | caption | string? | | | | | | | | | not-started | | |
|
||||
| media | MediaTranslation | translationFor | string | | DB | | | | | | | not-started | | |
|
||||
| media | MediaTranslation | language | string | | DB | | | | | | | not-started | | |
|
||||
| media | MediaTranslation | title | string? | | DB | | | | | | | not-started | | |
|
||||
| media | MediaTranslation | alt | string? | | DB | | | | | | | not-started | | |
|
||||
| media | MediaTranslation | caption | string? | | DB | | | | | | | not-started | | |
|
||||
| posts | PostLink | sourcePostId | string | | DB | | | | | | | not-started | | |
|
||||
| posts | PostLink | targetPostId | string | | DB | | | | | | | not-started | | |
|
||||
| posts | PostLink | linkText | string? | | DB | | | | | | | not-started | | |
|
||||
| templates | Template | file content | liquid | | | | | | | | | not-started | | |
|
||||
| scripts | Script | runtime | runtime | Python in current app, Lua in Rust app | app behavior | | | n/a | n/a | yes | yes | approved-divergence | | Explicit break |
|
||||
| scripts | BlogmarkTransform | transform chain | runtime | Python in current app | app behavior | | | n/a | n/a | no | no | deferred | | Deferred to extension Bucket H |
|
||||
| menus | MenuDocument | source document | opml/xml | | | | | | | | | not-started | | |
|
||||
| generation | SiteOutput | route path | path | | generated files | | | n/a | n/a | yes | yes | not-started | | |
|
||||
| generation | Feed | rss/atom output | xml | | generated files | | | n/a | n/a | yes | yes | not-started | | |
|
||||
| generation | Sitemap | sitemap output | xml | | generated files | | | n/a | n/a | yes | yes | not-started | | |
|
||||
| generation | GeneratedFileHash | content hash | string | DB | DB | | | n/a | n/a | yes | yes | not-started | | Skip-unchanged-writes optimization |
|
||||
| search | PostsFts | FTS5 index | virtual table | DB | DB (FTS5) | | | rebuild | n/a | no | no | not-started | | |
|
||||
| search | MediaFts | FTS5 index | virtual table | DB | DB (FTS5) | | | rebuild | n/a | no | no | not-started | | |
|
||||
| search | ClientSearch | search index | binary | generated files | generated files | | | n/a | n/a | yes | yes | not-started | | Determine in M0: integrate via `pagefind` crate library API |
|
||||
| media | Thumbnail | generated thumbnail | image file | filesystem | filesystem | | | regenerate | n/a | yes | yes | not-started | | `image` crate; verify size/format matches current `sharp` output |
|
||||
| macros | BuiltInMacro | gallery | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app |
|
||||
| macros | BuiltInMacro | youtube | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app |
|
||||
| macros | BuiltInMacro | vimeo | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app |
|
||||
| macros | BuiltInMacro | photo_archive | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app |
|
||||
| macros | BuiltInMacro | tag_cloud | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app |
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
Use this for compatibility-sensitive behaviors that are not a single persisted field.
|
||||
|
||||
| Area | Behavior | Current App Rule | Rust Target Rule | Allowed Divergence | Validation Method | Status | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| posts | published body storage | body not stored in DB when published | identical | no | fixture DB + file assertions | not-started | |
|
||||
| localization | UI locale | follows OS locale | identical | no | manual + integration test | not-started | |
|
||||
| localization | render language | follows project setting | identical | no | preview/generation tests | not-started | |
|
||||
| menus | native menu routing | current app uses native menus | native menus via muda (cross-platform: NSMenu, Win32, GTK) | implementation changes, behavior identical | menu integration tests | not-started | |
|
||||
| scripts | user Python scripts | supported in current app | unsupported in Rust app | yes | migration note + runtime detection tests | approved-divergence | |
|
||||
| rendering | built-in macros | JS server-side (gallery, youtube, vimeo, photo_archive, tag_cloud) | Rust-native in bds-core/render | implementation language changes, output must match | golden-generated-site comparisons | not-started | These are NOT Python macros |
|
||||
| rendering | Liquid feature subset | liquidjs 10.25 (full spec available) | Rust Liquid (scoped to used subset) | implementation may differ for unused features | template compatibility suite | not-started | Only ~35% of spec used by default templates |
|
||||
| slugs | slug generation | `transliteration` npm package | `deunicode` Rust crate | possible edge-case differences | slug corpus tests in M0 fixtures | not-started | Verify against real content |
|
||||
| editor | content editing | Milkdown WYSIWYG (default) | plain-text syntax-highlighting editor (bds-editor: ropey + syntect + cosmic-text) + live preview | yes | n/a | approved-divergence | Rich editor deferred to extension Bucket I, builds on bds-editor foundation |
|
||||
| preview | asset sourcing | local assets only | identical | no | HTML assertions | not-started | |
|
||||
| runtime | JS dependency | Electron (Chromium + Node.js) | no JavaScript anywhere — pure Rust + native APIs | yes (intentional) | build verification: no JS in dependency tree | approved-divergence | Supply-chain security constraint |
|
||||
| runtime | async executor | Node.js event loop | tokio | yes (internal) | n/a | approved-divergence | Used for preview server, publish, file watching |
|
||||
| media | thumbnail generation | `sharp` (libvips) | `image` crate | output dimensions and format must match | golden-file comparison of thumbnails | not-started | Fallback to `libvips-rs` if quality/perf insufficient |
|
||||
| generation | client-side search | determine in M0 | `pagefind` crate library API (`PagefindIndex`) | output must match | golden-generated-site comparisons | not-started | Rust library dep, no CLI binary |
|
||||
| generation | parallel rendering | single-threaded in current app | `rayon` for parallel page rendering | yes (faster, same output) | golden-generated-site comparisons | approved-divergence | Output must be identical regardless of parallelism |
|
||||
| ai | one-shot AI operations | not in current app | `reqwest` against configurable OpenAI-compatible endpoint | yes (new feature) | mocked endpoint tests | approved-divergence | Translation, alt text, title suggestion. Entirely optional — app works without endpoint configured |
|
||||
|
||||
## Status Values
|
||||
|
||||
- `not-started`
|
||||
- `in-progress`
|
||||
- `verified`
|
||||
- `approved-divergence`
|
||||
- `blocked`
|
||||
|
||||
## Sign-Off Checklist
|
||||
|
||||
- every persisted field used by core is represented
|
||||
- every approved divergence is explicit
|
||||
- every row links to a fixture or test before release
|
||||
- metadata diff, rebuild, and publish implications are filled in for all relevant rows
|
||||
@@ -1,387 +0,0 @@
|
||||
# bDS Rust Rewrite — Execution Backlog
|
||||
|
||||
## Purpose
|
||||
|
||||
This document converts the core and extension plans into execution order. It is organized by milestone first and by crate second.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Do not pull work forward from a later milestone unless it directly unblocks the current milestone.
|
||||
2. Keep tasks vertically sliced where possible: engine + UI + tests.
|
||||
3. Compatibility tasks outrank polish tasks.
|
||||
|
||||
## Milestone M0: Compatibility Baseline
|
||||
|
||||
### `bds-core`
|
||||
|
||||
- ~~create workspace and crate boundaries (bds-core, bds-editor, bds-ui, bds-cli)~~ **DONE**
|
||||
- ~~add SQLite connection management via `rusqlite` (bundled, vtab features)~~ **DONE**
|
||||
- ~~add migration loader via `refinery`~~ **DONE** (refinery `embed_migrations!` with V1__initial_schema.sql)
|
||||
- ~~define initial shared model modules with `serde` derives~~ **DONE**
|
||||
- ~~add checksum (`sha2`) and slug (`deunicode`) utilities~~ **DONE**
|
||||
- ~~establish error handling conventions: `thiserror` for bds-core, `anyhow` for bds-ui/bds-cli~~ **DONE**
|
||||
- ~~add `tokio` runtime as workspace dependency (used by bds-ui and bds-cli, not directly by bds-core engine code)~~ **DONE**
|
||||
|
||||
### `bds-editor`
|
||||
|
||||
- ~~create crate with ropey, syntect, cosmic-text dependencies~~ **DONE**
|
||||
- ~~implement basic rope buffer wrapper with edit operations~~ **DONE**
|
||||
- ~~implement syntect integration for markdown highlighting~~ **DONE**
|
||||
- ~~implement cosmic-text layout for rendering highlighted text~~ **DONE** (measured monospace font metrics via cosmic-text FontSystem/Buffer; OnceLock-cached MonoMetrics replaces hardcoded constants)
|
||||
- ~~implement basic cursor model (position, move, place by click)~~ **DONE** (up/down/left/right/home/end + click placement)
|
||||
- ~~implement basic text insertion and deletion~~ **DONE** (insert, backspace, delete forward, enter)
|
||||
- ~~implement Iced custom widget that composes buffer + highlight + layout + cursor~~ **DONE** (CodeEditor widget with gutter, text, cursor rendering + keyboard/mouse events)
|
||||
- ~~implement basic vertical scrolling with viewport-aware rendering~~ **DONE** (scroll_by, ensure_cursor_visible, mouse wheel, viewport-clipped rendering)
|
||||
- ~~verify IME composition events work through winit (early risk check)~~ **DONE** (verified: winit/Iced delivers composed chars via Key::Character; pre-edit display deferred to M3)
|
||||
|
||||
### `bds-ui`
|
||||
|
||||
- ~~create app entry point with Iced `Application` impl~~ **DONE**
|
||||
- ~~wire Iced window creation~~ **DONE**
|
||||
- ~~wire muda menu bar with skeleton App/File/Edit/View/Window/Help menus~~ **DONE** (stub)
|
||||
- ~~wire macOS lifecycle shim via objc2 (application:openFile:, application:openURLs:) behind cfg gate~~ **DONE** (stub)
|
||||
- ~~wire muda `MenuEvent` receiver as Iced `Subscription`~~ **DONE** (platform/menu.rs menu_subscription + app.rs subscription)
|
||||
|
||||
### `fixtures` and `docs`
|
||||
|
||||
- ~~collect representative fixture projects from current app~~ **DONE** (fixtures/compatibility-projects/rfc1437-sample/)
|
||||
- ~~capture golden generated output for those fixtures~~ **DONE** (fixtures/golden-generated-sites/rfc1437-sample/ — subset from live TypeScript-generated site: 3 post pages × 2 languages, structural files, assets, feeds, sitemap, category page)
|
||||
- ~~create initial compatibility inventory from the matrix template (must include `mediaTranslations`, `postLinks`, FTS5 tables)~~ **DONE** (docs/COMPATIBILITY_INVENTORY.md)
|
||||
- ~~create Liquid feature inventory from default templates~~ **DONE** (docs/LIQUID_FEATURE_INVENTORY.md)
|
||||
- ~~determine whether current app uses Pagefind or another client-side search index — if Pagefind, plan for `pagefind` crate library integration (not CLI binary)~~ **DONE** (confirmed: TypeScript app uses pagefind_extended CLI; Rust will use pagefind crate library API)
|
||||
- ~~create slug compatibility test suite comparing `deunicode` vs `transliteration` output on fixture content~~ **DONE**
|
||||
- ~~create Iced architecture patterns document (message design, subscription model, custom widget patterns)~~ **DONE** (docs/ICED_ARCHITECTURE_PATTERNS.md)
|
||||
|
||||
### Validation
|
||||
|
||||
- ~~DB readability tests (all tables including `mediaTranslations`, `postLinks`, FTS5, and AI/catalog tables)~~ **DONE** (59 unit tests + 29 fixture integration tests)
|
||||
- ~~app launch smoke test (Iced window + muda menus)~~ **DONE** (tests/app_smoke.rs — type-level tests; muda requires main thread so full launch tested via `cargo run`)
|
||||
- ~~bds-editor PoC test: renders highlighted markdown, accepts keyboard input, cursor moves~~ **DONE** (tests/editor_poc.rs — 11 integration tests covering highlight + input + cursor + scroll)
|
||||
- ~~fixture harness test~~ **DONE** (tests/fixture_readability.rs)
|
||||
- ~~slug compatibility tests~~ **DONE**
|
||||
|
||||
## Milestone M1: Data Fidelity
|
||||
|
||||
### `bds-core/db`
|
||||
|
||||
- ~~verify schema compatibility against existing projects~~ **DONE**
|
||||
- ~~add FTS5 virtual tables (`posts_fts`, `media_fts`) for in-app full-text search~~ **DONE** (multi-column schema: title/excerpt/content/tags/categories with per-language snowball stemming, 24 languages)
|
||||
- ~~verify `mediaTranslations` table read/write~~ **DONE**
|
||||
- ~~verify `postLinks` table read/write~~ **DONE**
|
||||
|
||||
### `bds-core/engine`
|
||||
|
||||
- ~~implement `ProjectEngine`~~ **DONE**
|
||||
- ~~implement `PostEngine`~~ **DONE**
|
||||
- ~~implement `MediaEngine`~~ **DONE**
|
||||
- ~~implement `PostMediaEngine`~~ **DONE**
|
||||
- ~~implement `TagEngine`~~ **DONE**
|
||||
- ~~implement `MetaEngine`~~ **DONE**
|
||||
- ~~implement metadata diff flow (posts, media, scripts, templates)~~ **DONE**
|
||||
- ~~implement rebuild-from-filesystem flow~~ **DONE**
|
||||
|
||||
### `bds-core/util`
|
||||
|
||||
- ~~frontmatter parser/writer via `serde_yaml`~~ **DONE**
|
||||
- ~~translation file parser/writer~~ **DONE**
|
||||
- ~~sidecar parser/writer~~ **DONE**
|
||||
- ~~content hashing support via `sha2`~~ **DONE**
|
||||
- ~~thumbnail generation via `image` crate (resize, WEBP encoding)~~ **DONE**
|
||||
- ~~recursive directory traversal via `walkdir` (for rebuild-from-filesystem)~~ **DONE**
|
||||
|
||||
### Validation
|
||||
|
||||
- ~~round-trip persistence tests~~ **DONE** (38 m1_validation integration tests)
|
||||
- ~~rebuild tests~~ **DONE**
|
||||
- ~~metadata diff tests~~ **DONE** (full coverage matrix: 13 post fields, 6 media, 4 template, 5 script)
|
||||
- ~~golden-file comparisons for written files~~ **DONE**
|
||||
|
||||
## Milestone M2: Native Workspace
|
||||
|
||||
### `bds-ui/platform`
|
||||
|
||||
- ~~muda menu bar: full menu tree with accelerators~~ **DONE** (platform/menu.rs with muda MenuBar, menu_subscription, accelerators)
|
||||
- ~~menu enable/disable validation synced to app state~~ **DONE** (sync_menu_state() evaluates has_project/has_tab/offline_mode after every state change)
|
||||
- ~~menu event routing as `Message` variants~~ **DONE** (MenuEvent → Message mapping in app.rs subscription)
|
||||
- ~~rfd integration for file/folder open and save dialogs~~ **DONE** (platform/dialog.rs with i18n-aware pick_folder/pick_media_files)
|
||||
- ~~macOS lifecycle shim: file-open and URL-open events forwarded as `Message` variants~~ **DONE** (objc2 BdsAppDelegate with application:openFile: and application:openURLs: → mpsc channel → Message::FileOpenRequested/UrlOpenRequested)
|
||||
|
||||
### `bds-ui/app`
|
||||
|
||||
- ~~root `Message` enum and `update()` dispatcher~~ **DONE** (Message enum with menu, lifecycle, sidebar, tab, editor variants + update dispatcher)
|
||||
- ~~app state model~~ **DONE** (AppState with project, sidebar, editor, output panel state; sidebar post list with 500-post limit)
|
||||
- ~~task surface model~~ **DONE** (engine/task.rs TaskManager with concurrency limit, FIFO queue, progress reporting, cancel support)
|
||||
|
||||
### `bds-ui/views`
|
||||
|
||||
- ~~workspace layout~~ **DONE** (three-panel layout: sidebar + editor + optional right panel)
|
||||
- ~~sidebar~~ **DONE** (post list with search, status filter, category/tag counts)
|
||||
- ~~activity bar~~ **DONE** (VS Code-style 50px bar with SVG icons for Posts/Pages/Media/Scripts/Templates/Tags/Chat/Import/Git/Settings)
|
||||
- ~~tab bar~~ **DONE** (editor tab bar with open/close/switch)
|
||||
- ~~status bar~~ **DONE** (bottom bar with project name, task progress)
|
||||
- ~~project selector~~ **DONE** (project open dialog and recent projects)
|
||||
- ~~toast notifications~~ **DONE** (overlay toast stack with auto-dismiss, 4 severity levels, i18n messages; replaces output-only notifications)
|
||||
|
||||
### Validation
|
||||
|
||||
- ~~menu event → `Message` routing integration tests~~ **DONE** (tests/menu_routing.rs — 4 tests: i18n key prefix, all-locale coverage, action count, no duplicates)
|
||||
- ~~keyboard shortcut integration tests~~ **DONE** (tests/m2_validation.rs — 15 accelerator-bound actions verified)
|
||||
- ~~rfd dialog invocation tests~~ **DONE** (tests/m2_validation.rs — dialog i18n keys in all 5 locales)
|
||||
- ~~fixture project open flow test~~ **DONE** (tests/project_flow.rs — 6 tests: create, switch, delete, directory, meta files)
|
||||
- ~~toast notification tests~~ **DONE** (tests/m2_validation.rs — toast id monotonicity, levels, expiry, message preservation, i18n keys)
|
||||
- ~~menu enable/disable rule tests~~ **DONE** (tests/m2_validation.rs — project-gated 11 items, tab-gated 4 items, offline-gated 2 items)
|
||||
|
||||
## Milestone M3: Authoring
|
||||
|
||||
### `bds-editor` (maturation)
|
||||
|
||||
- full cursor movement (arrows, word, line, page, home/end)
|
||||
- selection (shift+movement, click-and-drag, double-click word select)
|
||||
- system clipboard integration (copy/cut/paste)
|
||||
- undo/redo with edit grouping
|
||||
- line numbers gutter
|
||||
- incremental syntax rehighlighting on edits
|
||||
- IME input handling for CJK and other input methods
|
||||
- configurable soft wrap
|
||||
- additional syntax grammars: Liquid/HTML, Lua, YAML, JSON
|
||||
|
||||
### `bds-core/engine`
|
||||
|
||||
- expose editor-safe save APIs
|
||||
- expose publish/unpublish/discard flows
|
||||
- expose template validation APIs
|
||||
- expose script validation APIs
|
||||
|
||||
### `bds-ui/views`
|
||||
|
||||
- sidebar post filtering: text search box, status filter, tag/category filter dropdowns, language filter, year/month selectors, date range picker — wired to existing `PostSearchFilters` / `search_posts_filtered()` in bds-core. The 500 post limit applies AFTER filtering, not before.
|
||||
- dashboard
|
||||
- post editor (bds-editor with markdown + YAML frontmatter highlighting)
|
||||
- translation editor (bds-editor)
|
||||
- media browser
|
||||
- media editor
|
||||
- tags view
|
||||
- settings view
|
||||
- templates view and editor (bds-editor with Liquid/HTML highlighting)
|
||||
- scripts view and editor (bds-editor with Lua highlighting)
|
||||
|
||||
### `bds-ui/components`
|
||||
|
||||
- inputs
|
||||
- modals
|
||||
- reusable list rows and panels
|
||||
|
||||
### Validation
|
||||
|
||||
- entity create/edit/save flows
|
||||
- template validation flow
|
||||
- script validation flow
|
||||
- editor widget tests: cursor movement, selection, undo/redo, clipboard, IME
|
||||
|
||||
## Milestone M4: Rendering Parity
|
||||
|
||||
### `bds-core/render`
|
||||
|
||||
- markdown render pipeline via `pulldown-cmark`
|
||||
- Liquid render pipeline via `liquid` crate (scoped to the feature subset documented in the Liquid inventory: `if`/`elsif`/`else`, `for`, `assign`, `render`, whitespace stripping, plus filters: `default`, `escape`, `url_encode`, `append`; `.size` is property access on arrays, not a pipe filter)
|
||||
- custom Liquid filter: `i18n` (translation lookup by key and language)
|
||||
- custom Liquid filter: `markdown` (markdown-to-HTML with macro expansion, URL rewriting, media path canonicalization)
|
||||
- built-in macro renderers: `gallery`, `youtube`, `vimeo`, `photo_archive`, `tag_cloud` (native Rust, not Lua)
|
||||
- template resolution rules
|
||||
- URL rewriting
|
||||
- RSS/Atom feed generation via `quick-xml`
|
||||
- sitemap generation via `quick-xml`
|
||||
- generated file hash tracking (`generatedFileHashes` table for skip-unchanged-writes)
|
||||
- Pagefind search index generation via `pagefind` crate library API (`PagefindIndex::add_html_file()` + `get_files()`) — if required for parity (determine in M0 inventory)
|
||||
|
||||
### `bds-core/ai`
|
||||
|
||||
- one-shot AI client: `reqwest` + `serde_json` against OpenAI-compatible Chat Completions endpoint
|
||||
- two-endpoint configuration: online endpoint (URL, API key, model) + airplane mode endpoint (URL, model)
|
||||
- AI translate post operation (title, excerpt, content to target language)
|
||||
- AI translate media metadata operation (title, alt, caption to target language)
|
||||
- AI image description / alt text generation operation
|
||||
- AI post analysis operation (title, excerpt, slug suggestion)
|
||||
- AI taxonomy analysis operation (tag, category suggestions)
|
||||
- AI language detection operation
|
||||
- API key storage via OS keychain (macOS Keychain, Windows DPAPI, Linux libsecret)
|
||||
- airplane mode gating: block online endpoint, use airplane endpoint or show toast
|
||||
- error handling: surface failures as user-visible feedback, never silent
|
||||
|
||||
### `bds-core/engine`
|
||||
|
||||
- `TemplateEngine`
|
||||
- `PageRenderer` (parallelize rendering via `rayon`)
|
||||
- `BlogGenerationEngine`
|
||||
- `PreviewServer` (localhost HTTP via `axum` on `tokio`)
|
||||
- `SearchIndexEngine` only if required for parity
|
||||
|
||||
### `bds-ui/views`
|
||||
|
||||
- preview controls
|
||||
- generation progress display
|
||||
- render errors and diagnostics
|
||||
- AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate)
|
||||
- AI endpoint configuration in settings view (online + airplane mode endpoints)
|
||||
|
||||
### Validation
|
||||
|
||||
- golden generated-site comparisons
|
||||
- preview route coverage
|
||||
- template compatibility suite
|
||||
- one-shot AI client tests (mocked endpoint: all 6 operations)
|
||||
- AI endpoint configuration persistence tests
|
||||
- AI airplane mode gating tests
|
||||
|
||||
## Milestone M5: Operate And Ship
|
||||
|
||||
### `bds-core/engine`
|
||||
|
||||
- `PublishEngine` (SSH/SCP via `ssh2`, rsync via child process)
|
||||
- validation and integrity services
|
||||
- filesystem watcher via `notify` (if needed for detecting external changes to open content)
|
||||
|
||||
### `bds-core/scripting`
|
||||
|
||||
- `ScriptEngine`
|
||||
- `LuaRuntime` via `mlua` (Lua 5.4, vendored) — user-authored scripts only; built-in macros are native Rust in `bds-core/render`
|
||||
- `LuaApi` (expose post, media, tag, and project data to Lua scripts via `mlua::UserData` trait)
|
||||
- user-authored Lua macro execution at render time
|
||||
- user-authored transform and utility script execution
|
||||
- scripting docs generator
|
||||
|
||||
### `bds-ui/views`
|
||||
|
||||
- publish workflow screens
|
||||
- publish progress and failure surfaces
|
||||
- script docs access points if needed for core usability
|
||||
|
||||
### Validation
|
||||
|
||||
- publish end-to-end tests
|
||||
- Lua API bridge tests
|
||||
- built-in macro compatibility tests
|
||||
- docs sync tests
|
||||
|
||||
## Extension Backlog
|
||||
|
||||
### Bucket A: Git And Validation
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- `GitEngine` via `git2` (shell out for LFS operations)
|
||||
- richer site validation service support
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- Git sidebar
|
||||
- diff view
|
||||
- richer metadata diff UI
|
||||
|
||||
### Bucket B: Import
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- WXR parser
|
||||
- import analysis
|
||||
- import execution
|
||||
- import definitions
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- import wizard
|
||||
- import progress and result views
|
||||
|
||||
### Bucket C: AI Chat And Tool Use
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- streaming client extension (SSE parsing on top of core `reqwest` client)
|
||||
- tool execution framework (tool definitions, call routing, result formatting)
|
||||
- multi-turn conversation management
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- chat sidebar
|
||||
- chat panel
|
||||
- provider settings UI (extends core AI endpoint configuration)
|
||||
|
||||
### Bucket D: Embeddings And Duplicates
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- embedding engine via `ort` (ONNX Runtime)
|
||||
- vector index via `usearch` (HNSW)
|
||||
- duplicate detection logic
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- semantic search UI
|
||||
- duplicates view
|
||||
|
||||
### Bucket E: Translation QA And Docs UX
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- translation validation engine
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- translation validation view
|
||||
- documentation browser
|
||||
|
||||
### Bucket F: Menu Editing And Deep Links
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- menu editing services
|
||||
- deep-link flow support beyond core shell hooks
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- menu editor UI
|
||||
|
||||
### Bucket G: MCP And Automation
|
||||
|
||||
#### `bds-cli`
|
||||
|
||||
- headless commands
|
||||
- MCP server surface
|
||||
- CLI-to-app notification mechanism (`db_notifications` / `NotificationWatcher`)
|
||||
|
||||
### Bucket H: Blogmark And Transforms
|
||||
|
||||
#### `bds-core`
|
||||
|
||||
- `BlogmarkTransformService`
|
||||
- transform script chain execution
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- external content capture workflow
|
||||
|
||||
### Bucket I: Rich Editor
|
||||
|
||||
#### `bds-editor` (evolution)
|
||||
|
||||
- inline bold/italic/header rendering via cosmic-text mixed font styles
|
||||
- inline image preview via Iced image rendering within the custom widget
|
||||
- macro block preview (render macro output inline)
|
||||
- clickable links
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- rich editor mode toggle
|
||||
- image insert dialog from linked media
|
||||
|
||||
### Bucket J: A2UI Surfaces
|
||||
|
||||
#### `bds-ui`
|
||||
|
||||
- A2UI component renderer
|
||||
- A2UI surface manager
|
||||
- AI assistant dynamic surface integration
|
||||
|
||||
## Exit Rule
|
||||
|
||||
Do not start extension buckets until Milestone M5 is complete and the Rust app is already a credible replacement for the current authoring and publishing workflow.
|
||||
131
RUST_PLAN.md
131
RUST_PLAN.md
@@ -1,131 +0,0 @@
|
||||
# bDS Rust Rewrite Plan
|
||||
|
||||
bDS is a blogging desktop system that is currently in the following folder:
|
||||
|
||||
../bDS/
|
||||
|
||||
this is to be rewritten with Rust and this is the plan. For any implementation detail, look into the ../bDS/ folder into the typescript code there. Assume the typescript code as "the truth" about the current implementation.
|
||||
|
||||
This plan is split into multiple documents:
|
||||
|
||||
- [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md) — the shipping core. This must already be a fully functioning blogging app: create, edit, preview, generate, and publish content using the exact same on-disk and generated formats as the current app.
|
||||
- [RUST_PLAN_EXTENSION.md](RUST_PLAN_EXTENSION.md) — parity work and advanced tooling that can land after the core app is usable.
|
||||
- [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md) — implementation backlog grouped by milestone and crate.
|
||||
- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md) — template for the required persistence and compatibility inventory.
|
||||
|
||||
## Non-Negotiable Constraints
|
||||
|
||||
1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app.
|
||||
2. **No JavaScript anywhere.** No npm, no webview, no JS runtime, no Electron. This is a supply-chain security constraint. The entire app is pure Rust plus native platform APIs. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary.
|
||||
3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua.
|
||||
4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish.
|
||||
5. **Template editing is core scope.** Template rendering without template management UI is not sufficient.
|
||||
6. **Script API docs remain mandatory.** The runtime changes to Lua, but the app still ships and generates proper scripting API documentation.
|
||||
7. **Split localization is mandatory.** UI locale follows the OS. Rendered and previewed content language follows project settings.
|
||||
|
||||
## UI Technology Stack
|
||||
|
||||
The application uses the following UI and platform integration stack:
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|---|---|---|
|
||||
| UI framework | **Iced** (Elm architecture) | Application layout, views, widgets, event loop |
|
||||
| Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts |
|
||||
| Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) |
|
||||
| File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) |
|
||||
| Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks |
|
||||
|
||||
### Why this stack
|
||||
|
||||
- **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view).
|
||||
- **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one.
|
||||
- **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE.
|
||||
- The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module.
|
||||
|
||||
## Workspace Shape
|
||||
|
||||
```text
|
||||
bds-rust/
|
||||
├── Cargo.toml
|
||||
├── crates/
|
||||
│ ├── bds-core/ # engines, models, rendering, publishing
|
||||
│ ├── bds-editor/ # custom Iced editor widget (ropey + syntect + cosmic-text)
|
||||
│ ├── bds-ui/ # Iced application, views, platform integration (muda, rfd)
|
||||
│ └── bds-cli/ # later, optional automation surface
|
||||
├── migrations/
|
||||
├── locales/
|
||||
├── assets/
|
||||
└── docs/
|
||||
└── scripting/ # generated Lua API docs + guides
|
||||
```
|
||||
|
||||
### Crate responsibilities
|
||||
|
||||
- **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies.
|
||||
- **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate.
|
||||
- **bds-ui**: application shell, Iced views and components, message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor.
|
||||
- **bds-cli**: headless automation surface. Depends on bds-core only.
|
||||
|
||||
## Distribution Characteristics
|
||||
|
||||
- **Single static binary.** No external runtime dependencies (no GTK, no Electron, no Node.js). Lua and SQLite are compiled into the binary.
|
||||
- **Binary size:** ~15–25 MB (versus 150–200 MB for the current Electron app).
|
||||
- **Memory usage:** ~50–80% less RAM than the Electron app (no Chromium process).
|
||||
- **Startup:** Significantly faster (no V8/Chromium initialization).
|
||||
- **Zero install dependencies:** users download one binary. No Homebrew, no system packages.
|
||||
|
||||
## Split Rationale
|
||||
|
||||
The old single-file plan mixed three categories of work:
|
||||
|
||||
- work required to ship a real replacement for the current app
|
||||
- work required for eventual feature parity
|
||||
- work that is valuable but not on the critical path to replacing TypeScript
|
||||
|
||||
The new split keeps the shipping path narrow. Core is only the work needed to replace the current app for everyday authoring and publishing. Extensions carry the rest.
|
||||
|
||||
## Release Gates
|
||||
|
||||
The Rust rewrite is not considered successful until the core release can do all of the following with existing bDS project data:
|
||||
|
||||
1. Open a real project created by the TypeScript app.
|
||||
2. Create and edit posts, translations, media, tags, templates, and settings.
|
||||
3. Preview drafts and published content locally.
|
||||
4. Generate a complete site whose output matches the current app modulo approved normalization differences.
|
||||
5. Publish the generated output to a remote target.
|
||||
6. Rebuild the database from files and run metadata diff without losing information.
|
||||
7. Operate as a native desktop application with native menus and command handling.
|
||||
|
||||
## Verification Baseline
|
||||
|
||||
Both plan documents assume the same verification baseline:
|
||||
|
||||
1. Fixture projects exported from the current app are the compatibility corpus.
|
||||
2. Golden-file tests compare Rust-written files against TypeScript-written files.
|
||||
3. Golden-output tests compare generated sites between the current app and the Rust app.
|
||||
4. No feature is complete until both UI behavior and underlying engine behavior are covered.
|
||||
|
||||
## Recommended Repository Strategy
|
||||
|
||||
Build the Rust rewrite as a separate parallel project, not inside the current TypeScript application tree.
|
||||
|
||||
Why:
|
||||
|
||||
1. The rewrite has a different language stack, build chain, packaging model, runtime model, and test harness.
|
||||
2. The compatibility target is the current app's behavior and data, not shared source code.
|
||||
3. Keeping the rewrite isolated avoids contaminating this repo with long-lived dual-toolchain complexity.
|
||||
4. The current app remains the stable reference implementation while the Rust app catches up.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
- keep this repository as the reference implementation and fixture source
|
||||
- create a sibling repository such as `bds-rust`
|
||||
- pull compatibility fixtures from this repo into the Rust repo on a controlled cadence
|
||||
- compare generated output across repos in the Rust repo's CI
|
||||
|
||||
Only use the same repository if you explicitly want a temporary umbrella monorepo and are willing to accept:
|
||||
|
||||
- slower CI
|
||||
- mixed Node and Rust release pipelines
|
||||
- more complex contributor onboarding
|
||||
- higher risk of plan drift between the legacy app and the rewrite
|
||||
@@ -1,886 +0,0 @@
|
||||
# bDS Rust Rewrite — Core Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a native desktop Rust application (macOS primary, cross-platform ready) that fully replaces the current TypeScript app for the primary blogging workflow:
|
||||
|
||||
1. open an existing project
|
||||
2. create and edit content
|
||||
3. preview content locally
|
||||
4. generate the site
|
||||
5. publish the site
|
||||
|
||||
Core is not a toy MVP. Core must already be a production-capable replacement for the current app's content pipeline.
|
||||
|
||||
## Core Planning Rules
|
||||
|
||||
1. Critical path first. Do not start advanced parity work while the authoring and publishing path is incomplete.
|
||||
2. Compatibility before optimization. Matching data and output behavior matters more than early performance work.
|
||||
3. Engine and UI land together at milestone boundaries. No long-lived backend-only branches of product functionality.
|
||||
4. No new persistence formats during core.
|
||||
5. Every milestone ends with a runnable app, not just passing unit tests.
|
||||
|
||||
## Explicit Compatibility Contract
|
||||
|
||||
### Must stay identical
|
||||
|
||||
- SQLite schema semantics and data readability
|
||||
- post markdown files and frontmatter layout
|
||||
- translation file naming and frontmatter layout
|
||||
- media sidecars and thumbnail conventions
|
||||
- media translation records
|
||||
- post-link tracking records
|
||||
- template file formats and lookup rules
|
||||
- menu document format (OPML at `meta/menu.opml`) — both read and write
|
||||
- generated routes, feeds, sitemaps, and local preview behavior
|
||||
- metadata JSON file layout: `meta/project.json`, `meta/categories.json`, `meta/category-meta.json`, `meta/publishing.json`
|
||||
- metadata diff and rebuild-from-filesystem behavior
|
||||
- full-text search behavior (SQLite FTS5 virtual tables for posts and media, with Snowball stemmers for 24 languages via custom tokenizer)
|
||||
- slug generation behavior (verify `deunicode` output matches current `transliteration` output for the project's content corpus)
|
||||
|
||||
### May change intentionally
|
||||
|
||||
- app implementation language and architecture
|
||||
- editor technology
|
||||
- scripting runtime: Python out, Lua in
|
||||
- internal process model: no Electron main/renderer split
|
||||
- UI framework: Iced replaces Electron/web UI
|
||||
- platform integration: muda/rfd replace Electron's built-in menu and dialog APIs
|
||||
|
||||
### Compatibility truth
|
||||
|
||||
User content and project data are compatible.
|
||||
User-authored Python scripts are not compatible and are treated as legacy artifacts.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- **Single process Rust app**: no Electron, no IPC boundary.
|
||||
- **Iced (Elm architecture) for UI**: Message-driven update cycle. All user interactions produce `Message` variants; state mutations happen in a centralized `update()` function; views are pure functions that return element trees.
|
||||
- **muda for native menus**: cross-platform native menu bar from day one. NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux. Key equivalents and menu validation handled through muda's API.
|
||||
- **rfd for file dialogs**: cross-platform native open/save/folder dialogs. NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere.
|
||||
- **objc2 for macOS lifecycle shim** (cfg-gated): thin bridge for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks that muda/rfd do not cover. ~50 lines of platform code.
|
||||
- **Custom editor widget (bds-editor crate)**: syntax-highlighting markdown/Liquid/Lua editor built on ropey (rope buffer), syntect (syntax highlighting), and cosmic-text (font shaping and text layout). This is the highest-risk custom component and gets a proof-of-concept in Wave 0.
|
||||
- **rusqlite + embedded SQL migrations**: no ORM. SQL stays explicit. Migrations managed via `refinery`.
|
||||
- **tokio as the async runtime**: preview server (axum), SSH publishing, file watching, and rfd async dialogs all require an async executor. tokio is the standard choice and is used workspace-wide. Synchronous engine code in bds-core does not use tokio directly — it remains callable from both async (bds-ui) and sync (bds-cli) contexts.
|
||||
- **Plain-text markdown editor first**: no WYSIWYG in core. Live preview is required. This is an intentional regression from the current app's Milkdown WYSIWYG editor. A rich editor can be added as an extension bucket after core ships — and the bds-editor widget provides a natural foundation for it.
|
||||
- **Lua for user-authored scripting**: `mlua` with Lua 5.4. Only user-authored macros, transforms, and utility scripts run through Lua. Built-in macros are native Rust — see the macro architecture section below.
|
||||
- **One-shot AI operations are core scope**: six operations use a simple OpenAI-compatible HTTP client (`reqwest` + `serde_json`): (1) translate post, (2) translate media metadata, (3) image description / alt text, (4) post analysis (title + excerpt + slug suggestion), (5) taxonomy analysis (tag + category suggestions), (6) language detection. Two configurable endpoints: one for online use, one for airplane/offline mode (local model). These are fire-and-forget request/response calls — no streaming, no tool use, no chat history. The AI chat UI, streaming responses, and tool execution remain in Extension Bucket C.
|
||||
|
||||
## Iced Architecture Patterns
|
||||
|
||||
The Iced Elm architecture imposes a specific application structure:
|
||||
|
||||
### Message routing
|
||||
|
||||
All user interactions, menu events, file dialog results, platform lifecycle events, and async task completions are expressed as variants of a root `Message` enum. The application's `update()` method matches on these variants and mutates state accordingly. This replaces the "command dispatcher" pattern seen in imperative UI frameworks.
|
||||
|
||||
### View composition
|
||||
|
||||
Views are pure functions: `fn view(&self) -> Element<Message>`. They read application state and return a widget tree. Views never mutate state directly. This makes UI code inherently testable — you can assert on the element tree without rendering.
|
||||
|
||||
### Command and subscription model
|
||||
|
||||
Side effects (file I/O, network, timers, platform events) are expressed as `Command` or `Task` values returned from `update()`. Menu events from muda arrive via a `Subscription` that polls `MenuEvent::receiver()`. Platform lifecycle events from the objc2 shim arrive via a similar channel-based subscription.
|
||||
|
||||
### State model
|
||||
|
||||
The UI state must track at minimum:
|
||||
|
||||
- active project
|
||||
- open tabs and selected tab
|
||||
- selected entities
|
||||
- dirty editors (with undo/redo state in bds-editor)
|
||||
- task progress
|
||||
- `ui_locale`
|
||||
- project render settings including `content_language`
|
||||
- menu state (enabled/disabled/checked items synced to app state)
|
||||
|
||||
## Workspace Structure
|
||||
|
||||
```text
|
||||
bds-rust/
|
||||
├── Cargo.toml
|
||||
├── crates/
|
||||
│ ├── bds-core/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── db/
|
||||
│ │ │ ├── engine/
|
||||
│ │ │ ├── model/
|
||||
│ │ │ ├── render/
|
||||
│ │ │ ├── scripting/
|
||||
│ │ │ ├── i18n/
|
||||
│ │ │ └── util/
|
||||
│ ├── bds-editor/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── buffer.rs # ropey rope wrapper, edit operations
|
||||
│ │ │ ├── highlight.rs # syntect integration, incremental rehighlight
|
||||
│ │ │ ├── layout.rs # cosmic-text buffer/shaping/layout
|
||||
│ │ │ ├── cursor.rs # cursor model, selection, multi-cursor
|
||||
│ │ │ ├── history.rs # undo/redo operation log
|
||||
│ │ │ ├── input.rs # key event → buffer mutation mapping
|
||||
│ │ │ ├── widget.rs # Iced custom widget implementation
|
||||
│ │ │ └── lib.rs
|
||||
│ ├── bds-ui/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── app.rs # Iced Application impl, root Message enum, update()
|
||||
│ │ │ ├── platform/
|
||||
│ │ │ │ ├── mod.rs
|
||||
│ │ │ │ ├── macos.rs # objc2 lifecycle shim (cfg-gated)
|
||||
│ │ │ │ └── menu.rs # muda menu construction and event routing
|
||||
│ │ │ ├── views/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ └── i18n/
|
||||
│ └── bds-cli/
|
||||
├── migrations/
|
||||
├── fixtures/
|
||||
│ ├── compatibility-projects/
|
||||
│ └── golden-generated-sites/
|
||||
└── docs/
|
||||
└── scripting/
|
||||
```
|
||||
|
||||
## Core Feature Scope
|
||||
|
||||
### Included in core
|
||||
|
||||
- project open/create/select
|
||||
- posts and translations
|
||||
- media import and metadata editing
|
||||
- tags and categories
|
||||
- template editing and validation
|
||||
- settings and publishing preferences
|
||||
- live preview and full static generation
|
||||
- publish via SSH and rsync
|
||||
- metadata diff and rebuild-from-filesystem
|
||||
- native menus and shortcuts via muda (cross-platform)
|
||||
- native file dialogs via rfd (cross-platform)
|
||||
- macOS lifecycle hooks via objc2 shim (open-file, URL-open)
|
||||
- syntax-highlighting editor for markdown, Liquid templates, and Lua scripts via bds-editor
|
||||
- built-in macros implemented natively in Rust (gallery, youtube, vimeo, photo_archive, tag_cloud)
|
||||
- Lua runtime for user-authored macros, transforms, and utility scripts
|
||||
- generated Lua scripting API docs
|
||||
- FTS5 virtual tables for in-app post and media search
|
||||
- one-shot AI operations: translate post, translate media, image alt text, post analysis, taxonomy analysis, language detection (two configurable OpenAI-compatible endpoints: online + airplane mode, via `reqwest`)
|
||||
|
||||
### Deferred to extensions
|
||||
|
||||
- AI chat UI, streaming responses, and tool execution (Bucket C)
|
||||
- Git UI and history tools
|
||||
- WordPress import wizard
|
||||
- embeddings and duplicate detection
|
||||
- translation validation reports
|
||||
- menu editor UI
|
||||
- documentation browser UI
|
||||
- MCP and remote automation surfaces
|
||||
- Blogmark transform service (external content capture pipeline)
|
||||
- A2UI server-driven UI surfaces
|
||||
- WYSIWYG / rich markdown editor (builds on bds-editor foundation)
|
||||
|
||||
## Cross-Cutting Requirements
|
||||
|
||||
### Native platform shell
|
||||
|
||||
Core includes:
|
||||
|
||||
- native menu bar via muda (App, File, Edit, View, Window, Help menus)
|
||||
- standard key equivalents and accelerators via muda
|
||||
- menu enable/disable validation synced to application state
|
||||
- menu actions routed as `Message` variants into the Iced update cycle
|
||||
- file-open and folder-open dialogs via rfd
|
||||
- macOS: open-file from Finder and URL scheme handling via objc2 lifecycle shim
|
||||
- proper window lifecycle and recent-project tracking
|
||||
|
||||
### Split localization
|
||||
|
||||
Two independent localization domains are required:
|
||||
|
||||
- `ui_locale`: detected from the OS and used for menus, dialogs, toasts, and workspace chrome
|
||||
- `content_language`: read from project settings and used for rendering, preview, feeds, sitemaps, and generation
|
||||
|
||||
No design in core may collapse those into a single "current language" field.
|
||||
|
||||
### Editor widget requirements
|
||||
|
||||
The bds-editor crate is a custom Iced widget that provides syntax-highlighting text editing. It must support:
|
||||
|
||||
- **Buffer**: ropey `Rope` for O(log n) edits and line indexing
|
||||
- **Syntax highlighting**: syntect `HighlightLines` with incremental rehighlighting on edits. Grammars needed: Markdown, Liquid (HTML), Lua, YAML (frontmatter), JSON
|
||||
- **Text layout**: cosmic-text `Buffer` for font shaping, glyph positioning, and line layout
|
||||
- **Cursor**: position tracking, selection (shift+movement), click-to-place, click-and-drag selection
|
||||
- **Input handling**: standard key bindings (arrows, home/end, page up/down, word movement with option/ctrl), text insertion, delete/backspace, tab/indent
|
||||
- **Undo/redo**: operation log tracking edit groups, triggered by standard shortcuts
|
||||
- **Line numbers**: gutter with line numbers, synchronized scroll
|
||||
- **Scroll**: vertical and horizontal scrolling, viewport-aware rendering (only lay out visible lines)
|
||||
- **Soft wrap**: configurable per editor instance
|
||||
- **IME support**: proper handling of composition events from winit for CJK and other input methods — test early
|
||||
|
||||
The widget emits Iced `Message` variants for content changes, cursor movement, and save requests. The parent view owns the buffer state and passes it to the widget.
|
||||
|
||||
### Metadata parity matrix
|
||||
|
||||
Before any engine work begins, create a compatibility inventory for every persisted field across:
|
||||
|
||||
- database columns
|
||||
- markdown frontmatter
|
||||
- translation frontmatter
|
||||
- media sidecars
|
||||
- project metadata files
|
||||
- template files
|
||||
- generated output metadata
|
||||
|
||||
For each field record:
|
||||
|
||||
- source of truth
|
||||
- persisted locations
|
||||
- read path
|
||||
- write path
|
||||
- rebuild behavior
|
||||
- metadata diff behavior
|
||||
- publish behavior
|
||||
|
||||
This matrix is a release artifact, not a temporary note.
|
||||
|
||||
### Liquid template feature subset
|
||||
|
||||
The current default templates use a small subset of the Liquid specification. Before implementing the Liquid render pipeline, inventory the exact features used. The current templates (12 files: 3 main templates, 5 macro templates, 4 partials in `src/main/engine/templates/`) use only:
|
||||
|
||||
- **Tags:** `if`/`elsif`/`else`, `for`, `assign`, `render` (partials), whitespace stripping (`{%- -%}`)
|
||||
- **Filters (built-in):** `default`, `escape`, `url_encode`, `append`
|
||||
- **Filters (custom, must be re-implemented in Rust):** `i18n` (translation lookup by key and language), `markdown` (markdown-to-HTML with macro expansion, URL rewriting, and media path canonicalization)
|
||||
- **Operators and access:** `==`, `>`, `or`, `and`, bare truthiness, `blank` (nil/empty). Property access via dot notation (`object.property`), `.size` on arrays (this is property access, **not** a pipe filter), bracket notation for map lookups (`map[key]`).
|
||||
- **Not used:** `unless`, `case/when`, `capture`, `layout`, `include`, `comment`, `raw`, `date`, `truncate`, `split`, `join`, `where`, `group_by`, `map`, `sort`, `reverse`, `cycle`, `tablerow`, `increment`/`decrement`, `size` (as a pipe filter), and most other standard filters
|
||||
|
||||
This means the Rust Liquid implementation only needs roughly 35% of the full specification. Use `liquid-rust` or a minimal custom engine scoped to just the features above. User templates may use additional features, but parity with the current default templates is the release gate.
|
||||
|
||||
### Built-in macro architecture
|
||||
|
||||
The current app has two distinct macro systems that must not be conflated:
|
||||
|
||||
1. **Built-in macros** (`gallery`, `youtube`, `vimeo`, `photo_archive`, `tag_cloud`) — implemented in JavaScript, rendered server-side during generation. Each has a corresponding `.liquid` template in `src/main/engine/templates/macros/`. These are **not** Python scripts and never were.
|
||||
2. **User-authored script macros** — implemented in Python (Pyodide), invoked via the `PythonMacroWorkerRuntime` worker thread. These are replaced by Lua in the Rust app.
|
||||
|
||||
In the Rust app:
|
||||
|
||||
- Built-in macros become **native Rust functions** in `bds-core/render`. They are part of Wave 4 (rendering parity), not Wave 6 (Lua scripting). Rendering cannot produce correct output without them.
|
||||
- User-authored macros run through the **Lua runtime** (`mlua`). This is Wave 6 scope. During Wave 4, unknown macro placeholders should produce a visible "unsupported macro" marker rather than silently dropping content.
|
||||
|
||||
Macro invocation syntax in content: `[[macro_name param1="value1" param2="value2"]]`
|
||||
|
||||
### Slug generation compatibility
|
||||
|
||||
The current app uses the `transliteration` npm package for Unicode-to-ASCII conversion in slugs. The plan specifies `deunicode` for Rust. These libraries may produce different output for edge cases (CJK characters, Cyrillic, accented Latin, etc.). Add a slug compatibility test suite to M0 fixtures that runs both libraries against the project's actual content corpus and documents any divergences.
|
||||
|
||||
### Two search systems
|
||||
|
||||
The app has two independent search systems — do not conflate them:
|
||||
|
||||
1. **FTS5 (in-app search)**: SQLite FTS5 virtual tables (`posts_fts`, `media_fts`) power the desktop app's search UI. These require custom Snowball tokenizers registered via `rusqlite`'s `fts5_tokenizer` API (using the `rust-stemmers` crate) for 24-language stemmed search. Both indexing and query-time stemming must match the content language. This is Wave 1 scope.
|
||||
|
||||
2. **Pagefind (generated site search)**: a client-side search index bundled with the generated static site. Pagefind indexes the generated HTML files and produces JavaScript/WASM artifacts that power search on the published website. This is Wave 4 scope, added to the generation pipeline via the `pagefind` crate's Rust library API.
|
||||
|
||||
### Client-side search index (Pagefind)
|
||||
|
||||
If the current TypeScript app generates a Pagefind search index as part of site generation, the Rust app must produce the same artifact. Pagefind publishes a Rust library (`pagefind` crate) with a programmatic API (`pagefind::api::PagefindIndex`). The generation pipeline feeds rendered HTML to `PagefindIndex::add_html_file()` and writes the resulting index files via `PagefindIndex::get_files()`. No external binary, no npm — this is a pure Rust library dependency, fully compatible with the no-JavaScript constraint.
|
||||
|
||||
Determine during the compatibility inventory (M0) whether Pagefind or another client-side search solution is used. If Pagefind: add it to the generation pipeline in Wave 4 via the `pagefind` crate. If a different solution: document it and plan accordingly.
|
||||
|
||||
### Image processing
|
||||
|
||||
Media import requires thumbnail generation and potentially format conversion (e.g., to WEBP). The current TypeScript app uses `sharp` (libvips bindings). The Rust equivalent is the `image` crate for decoding/encoding and basic transforms (resize, crop). If WEBP encoding performance or advanced processing (EXIF handling, ICC profiles) proves insufficient with `image` alone, `libvips` Rust bindings (`libvips-rs`) are the fallback. Start with `image` — it covers the common cases and has no system dependencies.
|
||||
|
||||
## Planned Crate Registry
|
||||
|
||||
All crate choices for core scope, organized by subsystem. This prevents ad-hoc technology decisions during implementation.
|
||||
|
||||
### Foundation (Wave 0 onward)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `rusqlite` (bundled, vtab) | SQLite database access | Bundled compiles SQLite from source, vtab enables FTS5 |
|
||||
| `refinery` | SQL migration management | Replaces hand-rolled migration loader |
|
||||
| `uuid` (v4) | Entity identifiers | |
|
||||
| `serde` + `serde_json` | Serialization/deserialization | Used everywhere |
|
||||
| `serde_yaml` | YAML frontmatter parsing/writing | Posts, translations, media sidecars |
|
||||
| `chrono` | Date/time handling | |
|
||||
| `sha2` | Content hashing, checksums | |
|
||||
| `deunicode` | Unicode-to-ASCII for slug generation | Verify against `transliteration` output |
|
||||
| `thiserror` | Typed error definitions in library crates | bds-core, bds-editor |
|
||||
| `anyhow` | Ergonomic error handling in application crates | bds-ui, bds-cli |
|
||||
| `tokio` | Async runtime | Preview server, publish, file watching, async dialogs |
|
||||
| `rust-stemmers` | Snowball stemming for FTS5 | Custom FTS5 tokenizer for 24-language stemmed search |
|
||||
|
||||
### UI Framework (Wave 0 onward)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `iced` (wgpu, advanced, image) | Application framework | Elm architecture, GPU-accelerated via wgpu/Metal |
|
||||
| `muda` | Cross-platform native menu bar | NSMenu / Win32 / GTK |
|
||||
| `rfd` | Cross-platform native file dialogs | NSOpenPanel / Win32 / GTK |
|
||||
|
||||
### Editor Widget (Wave 0 onward, bds-editor crate)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `ropey` | Rope buffer for text storage | O(log n) edits, line indexing |
|
||||
| `syntect` | Syntax highlighting | Sublime Text grammars: Markdown, Liquid, Lua, YAML, JSON |
|
||||
| `cosmic-text` | Font shaping, text layout | Same engine as cosmic-DE |
|
||||
|
||||
### Platform Lifecycle (Wave 0 onward, macOS cfg-gated)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `objc2` | Objective-C runtime bindings | |
|
||||
| `objc2-foundation` | Foundation framework types | |
|
||||
| `objc2-app-kit` | AppKit framework (NSApplicationDelegate hooks) | ~50 lines of shim code |
|
||||
|
||||
### Data Layer (Wave 1)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `serde_yaml` | Frontmatter read/write | Already listed in foundation |
|
||||
| `notify` | Filesystem watching | Detect external file changes affecting open editors |
|
||||
| `image` | Thumbnail generation, format conversion | Start here; libvips-rs as fallback if needed |
|
||||
| `walkdir` | Recursive directory traversal | Rebuild-from-filesystem, media import |
|
||||
|
||||
### Rendering and Generation (Wave 4)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `pulldown-cmark` | Markdown → HTML | Fast, CommonMark-compliant |
|
||||
| `liquid` | Liquid template rendering | Scoped to the ~35% feature subset actually used |
|
||||
| `quick-xml` | RSS/Atom feeds, sitemaps | Also handles OPML menu documents |
|
||||
| `rayon` | Parallel site generation | Parallelize page rendering across CPU cores |
|
||||
| `axum` | Preview HTTP server | Lightweight, tokio-based |
|
||||
|
||||
### Publishing (Wave 5)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `ssh2` | SSH/SCP file upload | For publish-via-SSH targets. Authentication via SSH agent (`SSH_AUTH_SOCK`) only — no password auth, no interactive prompts. |
|
||||
| (shell out) | rsync invocation | rsync ships with macOS/Linux; invoke as child process |
|
||||
|
||||
### Client-Side Search (Wave 4)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `pagefind` | Search index generation | Rust library API: `PagefindIndex::add_html_file()` + `get_files()`. No CLI binary needed. |
|
||||
|
||||
### AI — One-Shot Operations (Wave 4–5)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `reqwest` | HTTP client for AI endpoints | OpenAI-compatible Chat Completions API. Two endpoints: online + airplane mode (local). Used for translation, alt text, taxonomy, post analysis, language detection. |
|
||||
|
||||
### Scripting (Wave 6)
|
||||
|
||||
| Crate | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `mlua` (lua54) | Lua 5.4 embedding | User-authored macros, transforms, utility scripts. Use `vendored` feature to compile Lua from source. |
|
||||
|
||||
### Extension-Only Crates (not used in core)
|
||||
|
||||
| Crate | Purpose | Bucket |
|
||||
|---|---|---|
|
||||
| `git2` | Git operations | A (Git + Validation) |
|
||||
| `ort` | ONNX inference for embeddings | D (Embeddings) |
|
||||
| `usearch` | HNSW vector index | D (Embeddings) |
|
||||
|
||||
## Critical Path
|
||||
|
||||
The hard sequence is:
|
||||
|
||||
1. compatibility inventory and fixtures
|
||||
2. exact-read and exact-write data layer
|
||||
3. native platform shell and command system (muda menus, rfd dialogs, Iced message routing)
|
||||
4. editor widget MVP (ropey + syntect + cosmic-text proof of concept)
|
||||
5. editors for posts, media, templates, scripts, and settings
|
||||
6. preview and generation parity
|
||||
7. one-shot AI operations (translate post, translate media, image alt text, post analysis, taxonomy analysis, language detection)
|
||||
8. publish and integrity tooling
|
||||
9. Lua built-ins plus generated script API docs
|
||||
|
||||
Anything outside that path is noise until the previous step is stable.
|
||||
|
||||
## Core Milestones
|
||||
|
||||
### Milestone M0: Compatibility Baseline
|
||||
|
||||
- fixture projects checked in
|
||||
- golden harness working
|
||||
- schema readable
|
||||
- native empty app launches with Iced window and muda menu bar
|
||||
- bds-editor proof-of-concept renders a markdown file with syntax highlighting
|
||||
|
||||
### Milestone M1: Data Fidelity
|
||||
|
||||
- posts, translations, media, tags, and settings round-trip
|
||||
- rebuild works
|
||||
- metadata diff works
|
||||
- no format drift on fixture writes
|
||||
|
||||
### Milestone M2: Native Workspace
|
||||
|
||||
- projects open in a real app window
|
||||
- native menus and shortcuts work via muda
|
||||
- sidebar, tabs, and message routing work
|
||||
- file dialogs work via rfd
|
||||
|
||||
### Milestone M3: Authoring
|
||||
|
||||
- post, translation, media, template, and script editing works using bds-editor
|
||||
- errors surface in the UI
|
||||
- all required authoring entities are reachable from the workspace
|
||||
|
||||
### Milestone M4: Rendering Parity
|
||||
|
||||
- preview is trustworthy
|
||||
- generation matches golden output
|
||||
- route, feed, and sitemap parity is acceptable
|
||||
- one-shot AI operations work (all 6 operations) with two configurable OpenAI-compatible endpoints (online + airplane mode)
|
||||
|
||||
### Milestone M5: Operate And Ship
|
||||
|
||||
- publish works end to end
|
||||
- integrity checks surface actionable issues
|
||||
- Lua built-ins and scripting docs are complete enough for users
|
||||
|
||||
Core release happens only after M5.
|
||||
|
||||
## Wave 0: Foundation And Compatibility Inventory
|
||||
|
||||
**Goal:** Bootstrapped workspace, exact compatibility target defined, golden fixtures checked in, and editor widget risk retired.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Cargo workspace for `bds-core`, `bds-editor`, `bds-ui`, `bds-cli`
|
||||
- SQLite connection layer, migrations loader, and base models
|
||||
- compatibility inventory document covering all persisted fields (including `mediaTranslations`, `postLinks`, and FTS5 virtual tables)
|
||||
- Liquid feature inventory documenting exactly which tags, filters, and patterns the default templates use
|
||||
- slug compatibility test suite comparing `deunicode` output against `transliteration` output for fixture content
|
||||
- fixture projects copied from the current app
|
||||
- golden-file harness for file writes and generation output comparisons
|
||||
- empty native app window: Iced window with muda-driven menu bar (App/File/Edit/View/Window/Help)
|
||||
- **bds-editor proof-of-concept**: custom Iced widget rendering a markdown file with syntax highlighting via ropey + syntect + cosmic-text. Must demonstrate: text display with highlighting, cursor placement, text insertion, basic scrolling. This retires the highest-risk component early.
|
||||
- Iced architecture patterns document covering: message design conventions, subscription model for menu events and platform hooks, custom widget patterns for bds-editor
|
||||
|
||||
### Dependencies
|
||||
|
||||
```toml
|
||||
# Core
|
||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
chrono = "0.4"
|
||||
sha2 = "0.10"
|
||||
deunicode = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
walkdir = "2"
|
||||
|
||||
# UI framework
|
||||
iced = { version = "0.13", features = ["wgpu", "advanced", "image"] }
|
||||
|
||||
# Editor widget
|
||||
cosmic-text = "0.12"
|
||||
ropey = "1"
|
||||
syntect = "5"
|
||||
|
||||
# Platform integration (cross-platform)
|
||||
muda = "0.15"
|
||||
rfd = "0.15"
|
||||
|
||||
# Platform lifecycle (macOS only)
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = "0.3"
|
||||
objc2-app-kit = "0.3"
|
||||
```
|
||||
|
||||
Not all of these are needed in Wave 0 — this is the full foundation set. Wave-specific additions (image, pulldown-cmark, liquid, axum, rayon, quick-xml, pagefind, reqwest, ssh2, mlua, notify) are added when their wave begins. See the Planned Crate Registry for the complete list.
|
||||
|
||||
### Tests
|
||||
|
||||
- open real bDS databases and verify read access across all tables (including `mediaTranslations`, `postLinks`, FTS5 tables, and AI/catalog tables that must not cause errors even though they are not used in core)
|
||||
- verify empty Rust app opens as a native window with working menu bar
|
||||
- verify bds-editor PoC renders highlighted text and accepts keyboard input
|
||||
- verify fixture loader and golden harness run in CI
|
||||
- slug compatibility tests pass for all fixture content
|
||||
|
||||
### Done when
|
||||
|
||||
- workspace builds on macOS (and ideally on Linux for CI verification)
|
||||
- fixture projects load
|
||||
- compatibility corpus exists
|
||||
- Liquid feature inventory is complete
|
||||
- bds-editor PoC works
|
||||
- Iced patterns doc is available
|
||||
- native app shell with menus exists
|
||||
- milestone M0 acceptance review passes
|
||||
|
||||
## Wave 1: Core Data Layer And Filesystem Contract
|
||||
|
||||
**Goal:** Exact-read and exact-write support for the current project data model.
|
||||
|
||||
### Key crates introduced
|
||||
|
||||
- `serde_yaml` for frontmatter parsing/writing
|
||||
- `image` for thumbnail generation and format conversion during media import
|
||||
- `walkdir` for recursive directory traversal during rebuild-from-filesystem
|
||||
- `notify` for filesystem change detection (may be deferred to Wave 5 if not needed until publish)
|
||||
|
||||
### Engines
|
||||
|
||||
- `ProjectEngine`
|
||||
- `PostEngine`
|
||||
- `MediaEngine`
|
||||
- `PostMediaEngine`
|
||||
- `TagEngine`
|
||||
- `MetaEngine`
|
||||
- `TaskManager` — max 3 concurrent tasks, FIFO queue, 250ms progress throttle, cancellation support
|
||||
- `MetadataDiffEngine`
|
||||
- `RebuildEngine` or equivalent rebuild services
|
||||
|
||||
### File and DB responsibilities
|
||||
|
||||
- draft vs published post lifecycle
|
||||
- translation files with canonical linkage
|
||||
- media sidecars and thumbnails (thumbnail generation via `image` crate)
|
||||
- tag/category sync
|
||||
- checksum and content-hash tracking
|
||||
- rebuild database from filesystem
|
||||
- metadata diff against filesystem
|
||||
|
||||
### Required behavior
|
||||
|
||||
- published posts continue storing body content in files, not in DB
|
||||
- every field persisted by the current app remains persisted in the Rust app in the same places
|
||||
- metadata writes, metadata diff, rebuild, and publish all use the same field mapping
|
||||
|
||||
### Tests
|
||||
|
||||
- round-trip tests for posts, translations, media, templates, and project metadata
|
||||
- rebuild tests using fixture projects
|
||||
- metadata diff tests proving no false negatives for known field changes
|
||||
- file-by-file golden comparisons against TypeScript output
|
||||
|
||||
### Done when
|
||||
|
||||
- Rust can read and write existing project data without format drift
|
||||
- rebuild restores the DB from files accurately
|
||||
- metadata diff catches every field covered by the matrix
|
||||
- milestone M1 acceptance review passes
|
||||
|
||||
## Wave 2: Native Platform Workspace Shell
|
||||
|
||||
**Goal:** Native-feeling desktop application shell with real menus and command plumbing.
|
||||
|
||||
### UI scope
|
||||
|
||||
- workspace window (Iced application)
|
||||
- activity bar
|
||||
- sidebar
|
||||
- tab bar
|
||||
- status bar
|
||||
- task/output panel
|
||||
- project selector
|
||||
- message routing layer (root `Message` enum with command dispatch in `update()`)
|
||||
|
||||
### Platform scope
|
||||
|
||||
- App, File, Edit, View, Window, Help menus via muda
|
||||
- accelerators and key equivalents via muda
|
||||
- menu enable/disable synced to app state (e.g., Save disabled when no dirty editor)
|
||||
- menu events received via `Subscription` polling `MenuEvent::receiver()`
|
||||
- file/folder dialogs via rfd
|
||||
- macOS: open-file from Finder and URL-open handled by objc2 lifecycle shim, forwarded as `Message` variants
|
||||
- recent project tracking (app-managed list; no platform API dependency)
|
||||
|
||||
### State model
|
||||
|
||||
The Iced application state must track at minimum:
|
||||
|
||||
- active project
|
||||
- open tabs
|
||||
- selected entities
|
||||
- dirty editors
|
||||
- task progress
|
||||
- `ui_locale`
|
||||
- project render settings including `content_language`
|
||||
|
||||
### Tests
|
||||
|
||||
- tab lifecycle
|
||||
- message dispatch from both menu events and keyboard entry points
|
||||
- app-shell integration tests for opening a fixture project
|
||||
- rfd dialog invocation tests (mocked where needed)
|
||||
|
||||
### Done when
|
||||
|
||||
- the app can be navigated by menu or keyboard alone
|
||||
- native menu labels are localized from the UI locale
|
||||
- shell behavior feels like a native desktop app
|
||||
- milestone M2 acceptance review passes
|
||||
|
||||
## Wave 3: Content Editing UI
|
||||
|
||||
**Goal:** Full editing UI for the content types required by the publishing workflow.
|
||||
|
||||
### Views
|
||||
|
||||
- dashboard
|
||||
- post editor (using bds-editor with markdown + YAML frontmatter highlighting)
|
||||
- translation editor (using bds-editor)
|
||||
- media browser and media editor
|
||||
- tags view
|
||||
- settings view
|
||||
- templates view and template editor (using bds-editor with Liquid/HTML highlighting)
|
||||
- scripts view and script editor (using bds-editor with Lua highlighting)
|
||||
- lightweight linked-media and post-links views where needed for parity
|
||||
|
||||
### Required capabilities
|
||||
|
||||
- create, edit, duplicate, publish, unpublish, discard, and delete posts
|
||||
- edit title, slug, excerpt, tags, categories, language, author, template assignment
|
||||
- import media (via rfd file dialog) and edit title, alt, caption, author, tags, language
|
||||
- create and edit templates with syntax validation
|
||||
- create and edit Lua scripts with syntax validation
|
||||
- expose errors and conflicts through dialogs and task panel output
|
||||
- undo/redo in all editor instances via bds-editor history
|
||||
|
||||
### Editor widget maturation
|
||||
|
||||
By Wave 3, bds-editor must support the full feature set documented in the editor widget requirements section:
|
||||
|
||||
- all cursor movement patterns (arrows, word, line, page, home/end)
|
||||
- selection (shift+movement, click-and-drag, double-click word select)
|
||||
- copy/cut/paste via system clipboard
|
||||
- undo/redo with edit grouping
|
||||
- line numbers gutter
|
||||
- incremental syntax rehighlighting on edits
|
||||
- IME input for non-Latin scripts
|
||||
- configurable soft wrap
|
||||
|
||||
### Tests
|
||||
|
||||
- integration tests from sidebar selection to persisted save
|
||||
- template edit and validation tests
|
||||
- script save and validation tests
|
||||
- editor widget tests for cursor movement, selection, undo/redo, and IME composition
|
||||
|
||||
### Done when
|
||||
|
||||
- every content type needed by preview/generation/publish has an editor
|
||||
- template management is reachable and usable from the UI
|
||||
- scripts are manageable from the UI even if advanced tooling is deferred
|
||||
- bds-editor handles real-world editing scenarios reliably
|
||||
- milestone M3 acceptance review passes
|
||||
|
||||
## Wave 4: Rendering, Preview, And Generation
|
||||
|
||||
**Goal:** Reproduce the current site's rendering pipeline and local preview behavior.
|
||||
|
||||
### Engines
|
||||
|
||||
- `TemplateEngine`
|
||||
- `PageRenderer`
|
||||
- `BlogGenerationEngine`
|
||||
- `PreviewServer` (axum, localhost-only HTTP server)
|
||||
- `SearchIndexEngine` if required for parity of generated sites
|
||||
|
||||
### Key crates introduced
|
||||
|
||||
- `pulldown-cmark` for Markdown → HTML
|
||||
- `liquid` for Liquid template rendering (scoped to the feature subset)
|
||||
- `quick-xml` for RSS/Atom feeds, sitemaps, and OPML menu document reading
|
||||
- `rayon` for parallel page rendering during generation
|
||||
- `axum` for the preview HTTP server (runs on tokio)
|
||||
- `pagefind` for client-side search index generation (Rust library API, not CLI binary)
|
||||
- `reqwest` for one-shot AI operations (all 6 operations: translate post/media, image alt text, post analysis, taxonomy analysis, language detection) against two configurable OpenAI-compatible endpoints (online + airplane mode)
|
||||
|
||||
### Rendering pipeline
|
||||
|
||||
1. load published posts, translations, menus, templates, and project settings
|
||||
2. resolve templates using the same lookup rules as the current app
|
||||
3. render markdown to HTML via `pulldown-cmark`
|
||||
4. expand built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) natively in Rust — these do not go through the Lua runtime
|
||||
5. for user-authored Lua macros: delegate to the Lua runtime if available, otherwise emit an "unsupported macro" placeholder
|
||||
6. apply Liquid templates with custom `i18n` and `markdown` filters
|
||||
7. rewrite URLs and media references
|
||||
8. generate archives, feeds (via `quick-xml`), sitemaps, and search artifacts required for parity
|
||||
9. feed rendered HTML pages to Pagefind via `pagefind::api::PagefindIndex::add_html_file()`, then write the search index files via `get_files()` — if client-side search index is required for parity
|
||||
10. write only changed outputs when safe to do so (track output file content hashes in a `generatedFileHashes` table, matching the current app's skip-unchanged-writes behavior)
|
||||
11. parallelize page rendering via `rayon` where safe (each page render is independent)
|
||||
|
||||
### Preview rules
|
||||
|
||||
- preview server binds to `127.0.0.1:4123` (localhost only, fixed port)
|
||||
- preview and generated HTML use local assets only
|
||||
- preview must support drafts and language-prefixed routes
|
||||
- rendered language is controlled by project settings, not by UI locale
|
||||
|
||||
### One-shot AI operations
|
||||
|
||||
Wave 4 introduces the one-shot AI client in `bds-core`. This is a minimal `reqwest`-based HTTP client that sends single request/response calls to an OpenAI-compatible Chat Completions endpoint. No streaming, no tool use, no chat history.
|
||||
|
||||
**Operations (all 6 are core scope):**
|
||||
|
||||
- **Translate post**: translate title, excerpt, and content to a target language. Used from the translation editor.
|
||||
- **Translate media metadata**: translate title, alt, and caption to a target language. Used from the media editor.
|
||||
- **Image description / alt text**: generate alt text and caption for a media item. Used from the media editor.
|
||||
- **Post analysis**: suggest title, excerpt, and slug from post content. Used from the post editor.
|
||||
- **Taxonomy analysis**: suggest tags and categories for a post. Used from the post editor.
|
||||
- **Language detection**: detect the language of a text fragment. Used internally by other operations.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- **Online endpoint**: URL, API key, model name — for cloud AI providers (OpenAI, Anthropic-via-proxy, etc.)
|
||||
- **Airplane mode endpoint**: URL, model name — for local models (Ollama, LM Studio, etc.) — no API key needed
|
||||
- When airplane mode is active, only the airplane mode endpoint is used. Cloud endpoint calls are blocked.
|
||||
- Default: no endpoints configured — AI features are opt-in.
|
||||
- API keys stored securely via OS keychain (macOS Keychain, Windows DPAPI, Linux libsecret), never in project files.
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- AI operations are entirely optional. The app is fully functional without any AI endpoint configured.
|
||||
- When no endpoint is configured, AI-related UI elements are disabled or hidden.
|
||||
- When airplane mode is active, the online endpoint is disabled. If no airplane mode endpoint is configured, AI is unavailable with a user-visible toast.
|
||||
- Error responses produce user-visible feedback (toast or inline error), never silent failures.
|
||||
- Request/response payloads use the OpenAI Chat Completions wire format.
|
||||
|
||||
### Tests
|
||||
|
||||
- fixture-based generation comparisons against current app output
|
||||
- preview route tests for posts, drafts, assets, media, and language routes
|
||||
- template compatibility tests using current `.liquid` templates
|
||||
|
||||
### Done when
|
||||
|
||||
- the same project can be generated by both apps with matching output
|
||||
- preview is accurate enough to be trusted as an authoring tool
|
||||
- milestone M4 acceptance review passes
|
||||
|
||||
## Wave 5: Publishing And Operational Integrity
|
||||
|
||||
**Goal:** Publishing and integrity tooling needed to operate the app in production.
|
||||
|
||||
### Key crates introduced
|
||||
|
||||
- `ssh2` for SSH/SCP file upload
|
||||
- rsync invoked as child process (ships with macOS/Linux)
|
||||
|
||||
### Engines and services
|
||||
|
||||
- `PublishEngine`
|
||||
- `SiteValidationDiffService` or equivalent core validation service
|
||||
- `NotificationWatcher` if needed for filesystem coherence in core workflows
|
||||
|
||||
### Required capabilities
|
||||
|
||||
- upload generated site via SCP or rsync
|
||||
- upload media and thumbnails correctly
|
||||
- SSH authentication via SSH agent (`SSH_AUTH_SOCK`) only — no password auth, no interactive prompts
|
||||
- three upload targets (html, thumbnails, media) run as parallel tasks
|
||||
- exclude `.meta` sidecar files from media uploads
|
||||
- surface publish progress and failures in the UI
|
||||
- detect and surface external file changes that affect open editors or preview accuracy
|
||||
|
||||
### Tests
|
||||
|
||||
- publish tests against mocked remote targets
|
||||
- validation tests on generated sites
|
||||
- watcher tests for externally modified content files
|
||||
|
||||
### Done when
|
||||
|
||||
- publishing works end to end from the Rust app
|
||||
- operator-visible integrity issues are surfaced before or during publish
|
||||
- milestone M5 operational review is unblocked pending Wave 6 completion
|
||||
|
||||
## Wave 6: Lua Runtime And Scripting Docs
|
||||
|
||||
**Goal:** Deliver user-authored scripting via Lua and complete scripting documentation.
|
||||
|
||||
Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are already implemented as native Rust in Wave 4. Wave 6 covers only the user-extensible scripting layer.
|
||||
|
||||
### Engines
|
||||
|
||||
- `ScriptEngine`
|
||||
- `LuaRuntime`
|
||||
- `LuaApi`
|
||||
|
||||
### Required scope
|
||||
|
||||
- user-authored Lua macros invoked at render time via `[[macro_name]]` syntax
|
||||
- user-authored transforms (post processing pipelines)
|
||||
- user-authored utility scripts
|
||||
- Lua API bridge exposing post, media, tag, and project data to scripts
|
||||
|
||||
### Documentation requirements
|
||||
|
||||
- generated Lua API documentation from source annotations or schema definitions
|
||||
- canonical data structure reference for script-visible types
|
||||
- examples for macro, transform, and utility scripts
|
||||
- docs bundled with the app and exported as markdown files in `docs/scripting/`
|
||||
|
||||
### Tests
|
||||
|
||||
- Lua execution tests
|
||||
- API bridge tests
|
||||
- macro compatibility tests for built-in macros
|
||||
- docs sync tests proving generated docs match exposed API
|
||||
|
||||
### Done when
|
||||
|
||||
- Lua scripting covers the built-ins needed by current templates and render flows
|
||||
- scripting docs are complete enough for third-party script authors
|
||||
- milestone M5 acceptance review passes
|
||||
|
||||
## Core Dependency Graph
|
||||
|
||||
```text
|
||||
Wave 0 Foundation + Editor PoC
|
||||
↓
|
||||
Wave 1 Data + Filesystem Contract
|
||||
↓
|
||||
Wave 2 Native Platform Shell (Iced + muda + rfd)
|
||||
↓
|
||||
Wave 3 Content Editing UI (bds-editor maturation)
|
||||
↓
|
||||
Wave 4 Rendering + Preview + Generation
|
||||
↓
|
||||
Wave 5 Publishing + Operational Integrity
|
||||
↓
|
||||
Wave 6 Lua Runtime + Scripting Docs
|
||||
```
|
||||
|
||||
Wave 4 depends on built-in macros (native Rust, not Lua). Wave 6 can start earlier for Lua runtime bootstrapping, but core release is not complete until user-authored scripting and docs are done.
|
||||
|
||||
## Core Release Checklist
|
||||
|
||||
Core ships only when all of the following are true:
|
||||
|
||||
1. An existing bDS project opens without manual migration.
|
||||
2. Posts, translations, media, templates, and settings can be edited in-app.
|
||||
3. Preview and generation match the current app closely enough to pass golden tests.
|
||||
4. Publishing works against supported remote targets.
|
||||
5. Metadata diff and rebuild are accurate.
|
||||
6. The app is a native desktop app with native menus and shortcuts.
|
||||
7. Lua replaces Python and has proper generated API documentation.
|
||||
|
||||
## Cross-Platform Notes
|
||||
|
||||
The core stack (Iced + muda + rfd) is cross-platform. The only platform-specific code is the macOS lifecycle shim in `bds-ui/src/platform/macos.rs`. To ship on Linux or Windows:
|
||||
|
||||
- Linux: no lifecycle shim needed (file open arrives as CLI args; URL handling via XDG). Iced, muda, and rfd work natively.
|
||||
- Windows: similar lifecycle shim for file associations and URL protocol handling via Windows APIs. Iced, muda, and rfd work natively.
|
||||
|
||||
Cross-platform packaging is not core scope, but the architecture does not accumulate macOS-only technical debt that would block it.
|
||||
|
||||
## Supporting Docs
|
||||
|
||||
- [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md)
|
||||
- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md)
|
||||
@@ -1,252 +0,0 @@
|
||||
# bDS Rust Rewrite — Extension Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver the rest of current-app parity and the advanced tooling that is valuable, but not required to ship a production-capable Rust replacement for everyday authoring and publishing.
|
||||
|
||||
Extensions begin only after the core plan is already usable end to end.
|
||||
|
||||
## Extension Principles
|
||||
|
||||
1. No extension may break the core compatibility contract.
|
||||
2. Extensions must reuse core models, engines, and persistence rules rather than invent parallel formats.
|
||||
3. UI features must still be tied to underlying functionality; no placeholder shells.
|
||||
4. AI features remain gated by offline mode and must prefer local models or provide explicit user feedback when unavailable. One-shot AI operations (6 operations: translate post/media, image alt text, post analysis, taxonomy analysis, language detection) are part of core with two configurable OpenAI-compatible endpoints (online + airplane mode), and respect the same offline gating.
|
||||
5. Extensions use the same Iced + muda + rfd platform stack as core. No additional UI frameworks.
|
||||
|
||||
## Extension Buckets
|
||||
|
||||
### Bucket A: Git And Validation Tooling
|
||||
|
||||
#### Scope
|
||||
|
||||
- `GitEngine` via `git2` crate (shell out for LFS operations — no LFS library binding)
|
||||
- Git sidebar
|
||||
- diff view
|
||||
- commit, fetch, pull, push
|
||||
- richer site validation views
|
||||
- richer metadata diff UI
|
||||
|
||||
#### Why extension
|
||||
|
||||
Helpful for operators, but not required to create, preview, generate, and publish content.
|
||||
|
||||
#### Done when
|
||||
|
||||
- users can inspect repo state and diffs from within the app
|
||||
- Git actions work reliably enough to replace the current Git tooling
|
||||
|
||||
### Bucket B: Import And Migration Tooling
|
||||
|
||||
#### Scope
|
||||
|
||||
- WXR parser
|
||||
- import analysis
|
||||
- import execution
|
||||
- saved import definitions
|
||||
|
||||
#### Why extension
|
||||
|
||||
Important onboarding feature, but not required to operate existing bDS projects.
|
||||
|
||||
#### Done when
|
||||
|
||||
- WordPress import flows are usable and recoverable
|
||||
- import results match the current app's expectations closely enough for fixture-based tests
|
||||
|
||||
### Bucket C: AI Chat And Tool Use
|
||||
|
||||
#### Scope
|
||||
|
||||
- chat UI (sidebar panel with conversation history)
|
||||
- streaming responses via `reqwest` (SSE / chunked transfer)
|
||||
- tool use against local engines (post lookup, media search, template info, etc.)
|
||||
- multi-turn conversation management
|
||||
- model and credential settings UI (extends the core AI endpoint configuration)
|
||||
|
||||
One-shot AI operations (translation, image alt text, title suggestion) are already in core scope. This bucket adds the interactive, conversational AI layer on top.
|
||||
|
||||
The AI client extends the core `reqwest` + `serde_json` client with streaming support and tool-call parsing. Works with any OpenAI-compatible endpoint: OpenAI, Anthropic-via-proxy, local Ollama, etc.
|
||||
|
||||
#### Hard constraints
|
||||
|
||||
- offline mode gates all automatic AI work
|
||||
- cloud providers are disabled when offline mode is enabled
|
||||
- local providers remain usable when allowed
|
||||
- unavailable operations produce explicit user-visible feedback
|
||||
|
||||
#### Done when
|
||||
|
||||
- AI chat is useful for content-related queries and actions without weakening the app's offline guarantees
|
||||
- Streaming and tool use work reliably with at least one OpenAI-compatible provider
|
||||
|
||||
### Bucket D: Search, Embeddings, And Duplicate Detection
|
||||
|
||||
#### Scope
|
||||
|
||||
- ONNX embeddings via `ort` (ONNX Runtime Rust bindings)
|
||||
- HNSW vector index via `usearch`
|
||||
- semantic search UI
|
||||
- duplicate detection UI
|
||||
|
||||
#### Why extension
|
||||
|
||||
Improves discovery and cleanup, but not required for core publishing flows.
|
||||
|
||||
#### Done when
|
||||
|
||||
- near-duplicate detection and semantic search are trustworthy on real projects
|
||||
|
||||
### Bucket E: Translation QA And Documentation UX
|
||||
|
||||
#### Scope
|
||||
|
||||
- translation validation engine and report view
|
||||
- in-app documentation browser
|
||||
- richer scripting docs browser and examples
|
||||
|
||||
#### Why extension
|
||||
|
||||
Operationally valuable, but the core release can ship with generated markdown docs and without dedicated browsing surfaces.
|
||||
|
||||
#### Done when
|
||||
|
||||
- translation integrity issues are discoverable before publish
|
||||
- docs are comfortably browsable in-app
|
||||
|
||||
### Bucket F: Menu Editing And Deep Links
|
||||
|
||||
#### Scope
|
||||
|
||||
- menu editor UI for OPML/menu documents
|
||||
- deep-link protocol handling beyond core app-open behaviors
|
||||
|
||||
#### Why extension
|
||||
|
||||
Core must read menu documents for rendering compatibility, but editing them can follow once the main authoring path is stable.
|
||||
|
||||
#### Done when
|
||||
|
||||
- users can inspect and edit menus from the Rust app
|
||||
- deep links cover parity flows from the current app
|
||||
|
||||
### Bucket G: MCP And Automation Surfaces
|
||||
|
||||
#### Scope
|
||||
|
||||
- headless CLI maturation
|
||||
- MCP server
|
||||
- remote automation contracts
|
||||
- `NotificationWatcher` / `db_notifications` mechanism for CLI-to-app synchronization
|
||||
|
||||
#### Why extension
|
||||
|
||||
Useful ecosystem surface, not required to replace the desktop app itself.
|
||||
|
||||
#### Done when
|
||||
|
||||
- automation consumers can drive the Rust app safely and consistently
|
||||
- CLI changes are detected and surfaced to the running desktop app
|
||||
|
||||
### Bucket H: Blogmark And Transform Pipeline
|
||||
|
||||
#### Scope
|
||||
|
||||
- `BlogmarkTransformService`
|
||||
- external content capture (bookmarklet) workflow
|
||||
- transform script execution chain
|
||||
- integration with Lua transform scripts
|
||||
|
||||
#### Why extension
|
||||
|
||||
Secondary content-capture workflow. Not required for core authoring with existing projects.
|
||||
|
||||
#### Done when
|
||||
|
||||
- external content can be captured and transformed into posts via the Blogmark pipeline
|
||||
- transform scripts execute reliably
|
||||
|
||||
### Bucket I: Rich Markdown Editor
|
||||
|
||||
#### Scope
|
||||
|
||||
- WYSIWYG or hybrid markdown editing (similar to current Milkdown editor)
|
||||
- macro syntax preview in editor
|
||||
- image insert dialog from linked media
|
||||
|
||||
#### Why extension
|
||||
|
||||
Core ships with the bds-editor syntax-highlighting plain-text editor and live preview. The current app defaults to a Milkdown WYSIWYG editor, so this is a user-facing regression that should be addressed after core stabilizes.
|
||||
|
||||
#### Architecture advantage
|
||||
|
||||
The bds-editor crate (ropey + syntect + cosmic-text) built during core provides the foundation for the rich editor. Incremental additions:
|
||||
|
||||
- inline rendering of bold/italic/headers via cosmic-text mixed font styles
|
||||
- inline image preview via Iced image rendering within the custom widget
|
||||
- macro block preview (render macro output inline in the editor)
|
||||
- clickable links
|
||||
|
||||
This is an evolution of the existing editor widget, not a separate technology decision.
|
||||
|
||||
#### Done when
|
||||
|
||||
- users can edit content with a rich editor comparable to the current app's Milkdown experience
|
||||
|
||||
### Bucket J: A2UI Server-Driven Surfaces
|
||||
|
||||
#### Scope
|
||||
|
||||
- A2UI component renderer (layout, input, display, chart, etc.)
|
||||
- A2UI surface manager for bidirectional data flow
|
||||
- integration with AI assistant outputs
|
||||
|
||||
#### Why extension
|
||||
|
||||
Tightly coupled to the AI feature set (Bucket C). Not required until AI features are active.
|
||||
|
||||
#### Done when
|
||||
|
||||
- AI-generated dynamic UI surfaces render correctly in the app
|
||||
|
||||
## Suggested Extension Ordering
|
||||
|
||||
```text
|
||||
Bucket A Git + Validation
|
||||
↓
|
||||
Bucket B Import
|
||||
↓
|
||||
Bucket C AI
|
||||
↓
|
||||
Bucket D Embeddings + Duplicates
|
||||
↓
|
||||
Bucket E Translation QA + Docs UX
|
||||
↓
|
||||
Bucket F Menu Editing + Deep Links
|
||||
↓
|
||||
Bucket G MCP + Automation
|
||||
↓
|
||||
Bucket H Blogmark + Transforms
|
||||
↓
|
||||
Bucket I Rich Editor (builds on bds-editor from core)
|
||||
↓
|
||||
Bucket J A2UI Surfaces (after Bucket C)
|
||||
```
|
||||
|
||||
The ordering is pragmatic, not mandatory. Git and validation are the closest to operational parity, so they should land first after core.
|
||||
|
||||
## Extension Verification
|
||||
|
||||
Every extension still inherits the core verification baseline plus extension-specific tests:
|
||||
|
||||
- Git fixtures for repo state and diff rendering
|
||||
- WXR fixtures for import
|
||||
- mocked SSE and provider fixtures for AI
|
||||
- embedding fixtures for semantic search and duplicate detection
|
||||
- translation fixture projects with intentional integrity failures
|
||||
- OPML fixtures for menu editing
|
||||
|
||||
## Out Of Scope For Now
|
||||
|
||||
- cross-platform packaging polish beyond what the core and extension work naturally require
|
||||
- feature work that introduces new persistence formats before full parity is reached
|
||||
@@ -39,12 +39,23 @@ Diese Datei dient als Index zu allen Allium-Spezifikationen und Schema-Inventari
|
||||
| Datei | Scope | Status | Beschreibung |
|
||||
|-------|-------|--------|--------------|
|
||||
| `git.allium` | Extension A | ✅ Existiert | Git-Operationen, LFS, Reconciliation |
|
||||
| `import.allium` | Extension B | ✨ Neu | WordPress-WXR-Analyse, Konfliktprüfung und Importausführung |
|
||||
| `mcp.allium` | Extension G | ✅ Existiert | MCP-Server (Tools, Resources) |
|
||||
| `ai.allium` | Core/Extension C | ✅ Existiert | AI One-Shot Tasks und Chat |
|
||||
| `embedding.allium` | Extension D | ✅ Existiert | Semantic Similarity (HNSW) |
|
||||
| `cli_sync.allium` | Core (Wave 5) | ✅ Existiert | CLI-zu-App Notification Sync |
|
||||
| `metadata_diff.allium` | Core (Wave 1) | ✅ Existiert | DB/Dateisystem-Diff und Rebuild |
|
||||
|
||||
### Von bDS2 übernommene Spezifikationen (Sync 2026-07)
|
||||
|
||||
| Datei | Scope | Status | Beschreibung |
|
||||
|-------|-------|--------|--------------|
|
||||
| `rendering.allium` | Core | ✨ Neu | Render-Subsystem (Assigns, Filter, Makros für Preview + Generation) |
|
||||
| `cli.allium` | Extension G | ✨ Neu | Workspace-CLI-Tool (rebuild, repair, render, upload, …) |
|
||||
| `events.allium` | Extension | ✨ Neu | Domain Event Bus (Multi-Client-Synchronisation) |
|
||||
| `server.allium` | Extension | ✨ Neu | Headless Server Mode (SSH-Transport für TUI/GUI) |
|
||||
| `tui.allium` | Extension | ✨ Neu | Terminal UI (zweiter Renderer über gemeinsamem UI-Core) |
|
||||
|
||||
## Neuerstellte Spezifikationen (diese Session)
|
||||
|
||||
### 1. `schema.allium`
|
||||
@@ -193,16 +204,6 @@ Diese Datei dient als Index zu allen Allium-Spezifikationen und Schema-Inventari
|
||||
- Korrupte Bilddateien
|
||||
- Orphan Media (nicht verlinkt)
|
||||
|
||||
## Verfügbare Rust-Plan-Dokumente
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `RUST_PLAN.md` | Haupt-Plan mit Non-Negotiable Constraints |
|
||||
| `RUST_PLAN_CORE.md` | Detaillierter Core-Plan (886 Zeilen) |
|
||||
| `RUST_PLAN_EXTENSION.md` | Extension Buckets A-J |
|
||||
| `RUST_EXECUTION_BACKLOG.md` | Milestone-basierte Aufgabenliste |
|
||||
| `RUST_COMPATIBILITY_MATRIX_TEMPLATE.md` | Vorlage für Metadata-Inventory |
|
||||
|
||||
## Spezifikations-Abdeckungs-Analyse
|
||||
|
||||
### Vollständig Abgedeckt ✅
|
||||
@@ -237,8 +238,8 @@ Alle kritischen Lücken geschlossen:
|
||||
|
||||
### Wave 0 (Foundation)
|
||||
1. Cargo-Workspace aufsetzen (bds-core, bds-editor, bds-ui, bds-cli)
|
||||
2. SQLite-Connection mit rusqlite (bundled, vtab)
|
||||
3. Refinery-Migration-Loader
|
||||
2. SQLite-Connection mit Diesel (gebündeltes SQLite)
|
||||
3. Eingebettete Diesel-Migrationen
|
||||
4. bds-editor PoC (ropey + syntect + cosmic-text)
|
||||
5. Iced App Shell mit muda-Menüs
|
||||
6. Slug-Compatibility-Tests (deunicode vs transliteration)
|
||||
|
||||
30
assets/starter-templates/macros/gallery.liquid
Normal file
30
assets/starter-templates/macros/gallery.liquid
Normal file
@@ -0,0 +1,30 @@
|
||||
<div
|
||||
class="macro-gallery gallery-cols-{{ columns }}"
|
||||
data-post-id="{{ post_id | escape }}"
|
||||
data-columns="{{ columns }}"
|
||||
data-lightbox="true"
|
||||
>
|
||||
<div class="gallery-container gallery-lightbox">
|
||||
{%- if items.size > 0 -%}
|
||||
{%- for item in items -%}
|
||||
<a
|
||||
class="gallery-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="gallery-empty">{{ no_items_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- if caption -%}
|
||||
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
33
assets/starter-templates/macros/photo-archive.liquid
Normal file
33
assets/starter-templates/macros/photo-archive.liquid
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
|
||||
<div class="photo-archive-container">
|
||||
{%- if months.size > 0 -%}
|
||||
{%- for month in months -%}
|
||||
<div class="photo-archive-month-wrapper">
|
||||
<div class="photo-archive-month">
|
||||
<div class="photo-archive-month-label">
|
||||
<span>{{ month.label | escape }}</span>
|
||||
</div>
|
||||
<div class="photo-archive-gallery">
|
||||
{%- for item in month.items -%}
|
||||
<a
|
||||
class="photo-archive-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="photo-archive-empty">{{ no_items_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
19
assets/starter-templates/macros/tag-cloud.liquid
Normal file
19
assets/starter-templates/macros/tag-cloud.liquid
Normal file
@@ -0,0 +1,19 @@
|
||||
<div
|
||||
class="macro-tag-cloud"
|
||||
data-tag-cloud="true"
|
||||
data-orientation="{{ orientation }}"
|
||||
data-color-distribution="quantile"
|
||||
data-color-easing="0.7"
|
||||
data-color-theme="pico"{%- if words_json %} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
|
||||
>
|
||||
{%- if words_json -%}
|
||||
<svg
|
||||
class="tag-cloud-canvas"
|
||||
viewBox="0 0 {{ width }} {{ height }}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label="{{ aria_label | escape }}"
|
||||
></svg>
|
||||
{%- else -%}
|
||||
<div class="tag-cloud-empty">{{ no_items_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
9
assets/starter-templates/macros/vimeo.liquid
Normal file
9
assets/starter-templates/macros/vimeo.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-vimeo">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/{{ id | escape }}"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
9
assets/starter-templates/macros/youtube.liquid
Normal file
9
assets/starter-templates/macros/youtube.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-youtube">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}" data-search-no-results="{{ 'render.search.noResults' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -35,7 +35,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}" data-search-no-results="{{ 'render.search.noResults' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,5 +6,12 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bds-core = { workspace = true }
|
||||
bds-server = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
1654
crates/bds-cli/src/lib.rs
Normal file
1654
crates/bds-cli/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,104 @@
|
||||
fn main() {
|
||||
println!("bds-cli: headless automation surface (not yet implemented)");
|
||||
use std::io::Read as _;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = match bds_cli::Cli::try_parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(error) => {
|
||||
let success = matches!(
|
||||
error.kind(),
|
||||
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
|
||||
);
|
||||
let _ = error.print();
|
||||
return if success {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::from(1)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let bds_cli::Command::Server(args) = &cli.command {
|
||||
let data_root = args
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(bds_core::util::application_data_dir);
|
||||
let database_path = args
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| data_root.join("bds.db"));
|
||||
let config = match bds_server::ServerConfig::from_environment(database_path, data_root) {
|
||||
Ok(mut config) => {
|
||||
if let Some(bind) = args.bind {
|
||||
config.bind = bind;
|
||||
}
|
||||
if let Some(port) = args.port {
|
||||
config.port = port;
|
||||
}
|
||||
config
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error:#}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
return run_headless(bds_server::boot::BootMode::Server, config);
|
||||
}
|
||||
|
||||
if matches!(cli.command, bds_cli::Command::Tui) {
|
||||
let data_root = bds_core::util::application_data_dir();
|
||||
let config = match bds_server::ServerConfig::from_environment(
|
||||
bds_core::util::application_database_path(),
|
||||
data_root,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error:#}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
return run_headless(bds_server::boot::BootMode::Tui, config);
|
||||
}
|
||||
|
||||
let json = cli.json;
|
||||
let needs_stdin = match &cli.command {
|
||||
bds_cli::Command::Post(args) => args.stdin,
|
||||
bds_cli::Command::Gallery(args) => args.post.stdin,
|
||||
_ => false,
|
||||
};
|
||||
let mut context = bds_cli::RunContext::system();
|
||||
if needs_stdin && let Err(error) = std::io::stdin().read_to_string(&mut context.stdin) {
|
||||
eprintln!("Error: could not read stdin: {error}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
match bds_cli::run(cli, context) {
|
||||
Ok(output) => {
|
||||
println!("{output}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
if json {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"ok": false, "error": format!("{error:#}")})
|
||||
);
|
||||
} else {
|
||||
eprintln!("Error: {error:#}");
|
||||
}
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_headless(mode: bds_server::boot::BootMode, config: bds_server::ServerConfig) -> ExitCode {
|
||||
match bds_server::run_headless(mode, config) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error:#}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
crates/bds-cli/tests/process.rs
Normal file
188
crates/bds-cli/tests/process.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn cli(home: &std::path::Path, args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_bds-cli"))
|
||||
.env("HOME", home)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_exit_codes_help_and_shared_state_roundtrip() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let home = root.path().join("home");
|
||||
let project = root.path().join("project");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
|
||||
let help = cli(&home, &["--help"]);
|
||||
assert!(help.status.success());
|
||||
assert!(String::from_utf8_lossy(&help.stdout).contains("rebuild"));
|
||||
|
||||
let invalid = cli(&home, &["not-a-command"]);
|
||||
assert_eq!(invalid.status.code(), Some(1));
|
||||
assert!(!invalid.stderr.is_empty());
|
||||
|
||||
let added = cli(
|
||||
&home,
|
||||
&[
|
||||
"project",
|
||||
"add",
|
||||
project.to_str().unwrap(),
|
||||
"--name",
|
||||
"Process Blog",
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
added.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&added.stderr)
|
||||
);
|
||||
assert!(
|
||||
cli(&home, &["project", "switch", "process-blog"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
|
||||
let created = Command::new(env!("CARGO_BIN_EXE_bds-cli"))
|
||||
.env("HOME", &home)
|
||||
.args(["--json", "post", "--stdin", "--no-translate"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write as _;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(br#"{"title":"Process post","content":"Body","language":"en"}"#)?;
|
||||
child.wait_with_output()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
created.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&created.stderr)
|
||||
);
|
||||
let value: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["data"]["slug"], "process-post");
|
||||
|
||||
let get_missing = cli(&home, &["config", "get", "missing"]);
|
||||
assert_eq!(get_missing.status.code(), Some(1));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_upload_push_and_pull_dispatch_successfully() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let home = root.path().join("home");
|
||||
let project = root.path().join("project");
|
||||
let remote = root.path().join("remote.git");
|
||||
let peer = root.path().join("peer");
|
||||
let fake_bin = root.path().join("bin");
|
||||
for directory in [&home, &project, &fake_bin] {
|
||||
std::fs::create_dir_all(directory).unwrap();
|
||||
}
|
||||
assert!(
|
||||
cli(
|
||||
&home,
|
||||
&[
|
||||
"project",
|
||||
"add",
|
||||
project.to_str().unwrap(),
|
||||
"--name",
|
||||
"External Blog",
|
||||
],
|
||||
)
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
cli(&home, &["project", "switch", "external-blog"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
project.join("meta/publishing.json"),
|
||||
r#"{"sshHost":"example.test","sshUser":"deploy","sshRemotePath":"/srv/blog","sshMode":"rsync"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rsync = fake_bin.join("rsync");
|
||||
std::fs::write(&rsync, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
let mut permissions = std::fs::metadata(&rsync).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&rsync, permissions).unwrap();
|
||||
let upload = Command::new(env!("CARGO_BIN_EXE_bds-cli"))
|
||||
.env("HOME", &home)
|
||||
.env("PATH", &fake_bin)
|
||||
.env("SSH_AUTH_SOCK", root.path().join("agent.sock"))
|
||||
.arg("upload")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
upload.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&upload.stderr)
|
||||
);
|
||||
|
||||
git(&project, &["init", "-b", "main"]);
|
||||
git(&project, &["config", "user.email", "cli@example.test"]);
|
||||
git(&project, &["config", "user.name", "CLI Test"]);
|
||||
git(&project, &["add", "."]);
|
||||
git(&project, &["commit", "-m", "Initial"]);
|
||||
git(root.path(), &["init", "--bare", remote.to_str().unwrap()]);
|
||||
git(
|
||||
&project,
|
||||
&["remote", "add", "origin", remote.to_str().unwrap()],
|
||||
);
|
||||
git(&project, &["push", "-u", "origin", "main"]);
|
||||
git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
|
||||
|
||||
let pushed = cli(&home, &["push"]);
|
||||
assert!(
|
||||
pushed.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&pushed.stderr)
|
||||
);
|
||||
|
||||
git(
|
||||
root.path(),
|
||||
&["clone", remote.to_str().unwrap(), peer.to_str().unwrap()],
|
||||
);
|
||||
git(&peer, &["config", "user.email", "peer@example.test"]);
|
||||
git(&peer, &["config", "user.name", "Peer Test"]);
|
||||
std::fs::write(peer.join("remote-change.txt"), "change").unwrap();
|
||||
git(&peer, &["add", "remote-change.txt"]);
|
||||
git(&peer, &["commit", "-m", "Remote change"]);
|
||||
git(&peer, &["push"]);
|
||||
|
||||
let pulled = cli(&home, &["pull"]);
|
||||
assert!(
|
||||
pulled.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&pulled.stderr)
|
||||
);
|
||||
assert!(project.join("remote-change.txt").is_file());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn git(cwd: &std::path::Path, args: &[&str]) {
|
||||
let output = Command::new("git")
|
||||
.current_dir(cwd)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {} failed:\n{}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -4,30 +4,52 @@ edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["dylib"]
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
refinery = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
deunicode = { workspace = true }
|
||||
md5 = { workspace = true }
|
||||
unicode-normalization = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
image = { workspace = true }
|
||||
hpvcd = { workspace = true }
|
||||
webp = { workspace = true }
|
||||
rust-stemmers = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
fluent-bundle = { workspace = true }
|
||||
fluent-syntax = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
liquid = { workspace = true }
|
||||
liquid-core = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
htmd = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
pagefind = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
diesel = { workspace = true }
|
||||
diesel_migrations = { workspace = true }
|
||||
libsqlite3-sys = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
url = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
fastembed = { workspace = true }
|
||||
usearch = { workspace = true }
|
||||
ort = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["libsqlite3-sys"]
|
||||
|
||||
3
crates/bds-core/build.rs
Normal file
3
crates/bds-core/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
||||
12
crates/bds-core/examples/generate_scripting_docs.rs
Normal file
12
crates/bds-core/examples/generate_scripting_docs.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let docs = root.join("docs/scripting");
|
||||
let manifest = bds_core::scripting::api_manifest();
|
||||
fs::write(docs.join("API_REFERENCE.md"), manifest.render_reference())?;
|
||||
fs::write(docs.join("TYPES.md"), manifest.render_types())?;
|
||||
fs::write(docs.join("completions.json"), manifest.render_completions())?;
|
||||
Ok(())
|
||||
}
|
||||
0
crates/bds-core/migrations/.diesel_lock
Normal file
0
crates/bds-core/migrations/.diesel_lock
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS db_notifications_prune_idx;
|
||||
DROP INDEX IF EXISTS db_notifications_unseen_cli_idx;
|
||||
ALTER TABLE db_notifications DROP COLUMN project_id;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE db_notifications ADD COLUMN project_id TEXT;
|
||||
|
||||
CREATE INDEX db_notifications_unseen_cli_idx
|
||||
ON db_notifications (from_cli, seen_at, created_at);
|
||||
|
||||
CREATE INDEX db_notifications_prune_idx
|
||||
ON db_notifications (seen_at, created_at);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE mcp_proposals;
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE mcp_proposals (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'draft_post',
|
||||
'propose_script',
|
||||
'propose_template',
|
||||
'propose_media_translation',
|
||||
'propose_media_metadata',
|
||||
'propose_post_metadata'
|
||||
)),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||
)),
|
||||
entity_id TEXT,
|
||||
data TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
resolved_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX mcp_proposals_project_status_idx
|
||||
ON mcp_proposals(project_id, status, created_at);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE ai_endpoint_models;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE ai_endpoint_models (
|
||||
kind TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
context_window INTEGER,
|
||||
max_output_tokens INTEGER,
|
||||
supports_tools INTEGER NOT NULL DEFAULT 0,
|
||||
supports_vision INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (kind, model_id)
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
DROP INDEX mcp_proposals_entity_idx;
|
||||
|
||||
CREATE TABLE mcp_proposals_old (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'draft_post',
|
||||
'propose_script',
|
||||
'propose_template',
|
||||
'propose_media_translation',
|
||||
'propose_media_metadata',
|
||||
'propose_post_metadata'
|
||||
)),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||
)),
|
||||
entity_id TEXT,
|
||||
data TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
resolved_at BIGINT
|
||||
);
|
||||
|
||||
INSERT INTO mcp_proposals_old (
|
||||
id, project_id, kind, status, entity_id, data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
)
|
||||
SELECT
|
||||
id, project_id, kind, status, entity_id, data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
FROM mcp_proposals;
|
||||
|
||||
DROP TABLE mcp_proposals;
|
||||
ALTER TABLE mcp_proposals_old RENAME TO mcp_proposals;
|
||||
|
||||
CREATE INDEX mcp_proposals_project_status_idx
|
||||
ON mcp_proposals(project_id, status, created_at);
|
||||
@@ -0,0 +1,55 @@
|
||||
CREATE TABLE mcp_proposals_new (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'draft_post',
|
||||
'propose_script',
|
||||
'propose_template',
|
||||
'propose_media_translation',
|
||||
'propose_media_metadata',
|
||||
'propose_post_metadata'
|
||||
)),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||
)),
|
||||
entity_id TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
resolved_at BIGINT
|
||||
);
|
||||
|
||||
INSERT INTO mcp_proposals_new (
|
||||
id, project_id, kind, status, entity_id, data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
)
|
||||
SELECT
|
||||
id, project_id, kind, status, COALESCE(entity_id, id), data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
FROM mcp_proposals;
|
||||
|
||||
DROP TABLE mcp_proposals;
|
||||
ALTER TABLE mcp_proposals_new RENAME TO mcp_proposals;
|
||||
|
||||
-- Keep the newest record if a pre-migration database already contains an
|
||||
-- invalid duplicate. NULL-backed proposals were assigned their own IDs above
|
||||
-- and therefore remain distinct.
|
||||
DELETE FROM mcp_proposals AS duplicate
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mcp_proposals AS keeper
|
||||
WHERE keeper.kind = duplicate.kind
|
||||
AND keeper.entity_id = duplicate.entity_id
|
||||
AND keeper.status = duplicate.status
|
||||
AND (
|
||||
keeper.created_at > duplicate.created_at
|
||||
OR (keeper.created_at = duplicate.created_at AND keeper.id > duplicate.id)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX mcp_proposals_project_status_idx
|
||||
ON mcp_proposals(project_id, status, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX mcp_proposals_entity_idx
|
||||
ON mcp_proposals(kind, entity_id, status);
|
||||
@@ -0,0 +1,22 @@
|
||||
DROP TABLE IF EXISTS db_notifications;
|
||||
DROP TABLE IF EXISTS import_definitions;
|
||||
DROP TABLE IF EXISTS dismissed_duplicate_pairs;
|
||||
DROP TABLE IF EXISTS embedding_keys;
|
||||
DROP TABLE IF EXISTS ai_catalog_meta;
|
||||
DROP TABLE IF EXISTS ai_model_modalities;
|
||||
DROP TABLE IF EXISTS ai_models;
|
||||
DROP TABLE IF EXISTS ai_providers;
|
||||
DROP TABLE IF EXISTS chat_messages;
|
||||
DROP TABLE IF EXISTS chat_conversations;
|
||||
DROP TABLE IF EXISTS generated_file_hashes;
|
||||
DROP TABLE IF EXISTS settings;
|
||||
DROP TABLE IF EXISTS post_media;
|
||||
DROP TABLE IF EXISTS post_links;
|
||||
DROP TABLE IF EXISTS scripts;
|
||||
DROP TABLE IF EXISTS templates;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS media_translations;
|
||||
DROP TABLE IF EXISTS media;
|
||||
DROP TABLE IF EXISTS post_translations;
|
||||
DROP TABLE IF EXISTS posts;
|
||||
DROP TABLE IF EXISTS projects;
|
||||
@@ -1,9 +1,8 @@
|
||||
-- ================================================================
|
||||
-- CORE ENTITIES
|
||||
-- ================================================================
|
||||
-- Generated with Diesel CLI, then completed with the constraints and defaults
|
||||
-- that SQLite's schema introspection cannot reproduce.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
@@ -14,7 +13,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
@@ -43,7 +42,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx
|
||||
ON posts(project_id, slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_translations (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
translation_for TEXT NOT NULL REFERENCES posts(id),
|
||||
language TEXT NOT NULL,
|
||||
@@ -62,7 +61,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS post_translations_translation_language_idx
|
||||
ON post_translations(translation_for, language);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
@@ -84,7 +83,7 @@ CREATE TABLE IF NOT EXISTS media (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_translations (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
translation_for TEXT NOT NULL REFERENCES media(id),
|
||||
language TEXT NOT NULL,
|
||||
@@ -99,7 +98,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS media_translations_translation_language_idx
|
||||
ON media_translations(translation_for, language);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
@@ -109,10 +108,10 @@ CREATE TABLE IF NOT EXISTS tags (
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx
|
||||
ON tags(project_id, name);
|
||||
ON tags(project_id, name COLLATE NOCASE);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
@@ -120,7 +119,7 @@ CREATE TABLE IF NOT EXISTS templates (
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
content TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -130,7 +129,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
|
||||
ON templates(project_id, slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
@@ -139,7 +138,7 @@ CREATE TABLE IF NOT EXISTS scripts (
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
content TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -148,12 +147,8 @@ CREATE TABLE IF NOT EXISTS scripts (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
|
||||
ON scripts(project_id, slug);
|
||||
|
||||
-- ================================================================
|
||||
-- RELATIONSHIP TABLES
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
source_post_id TEXT NOT NULL REFERENCES posts(id),
|
||||
target_post_id TEXT NOT NULL REFERENCES posts(id),
|
||||
link_text TEXT,
|
||||
@@ -161,7 +156,7 @@ CREATE TABLE IF NOT EXISTS post_links (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_media (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
post_id TEXT NOT NULL REFERENCES posts(id),
|
||||
media_id TEXT NOT NULL REFERENCES media(id),
|
||||
@@ -172,12 +167,8 @@ CREATE TABLE IF NOT EXISTS post_media (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx
|
||||
ON post_media(post_id, media_id);
|
||||
|
||||
-- ================================================================
|
||||
-- METADATA TABLES
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -192,12 +183,8 @@ CREATE TABLE IF NOT EXISTS generated_file_hashes (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS generated_file_hashes_project_path_idx
|
||||
ON generated_file_hashes(project_id, relative_path);
|
||||
|
||||
-- ================================================================
|
||||
-- AI / CHAT TABLES (read-only in Rust core, must not error)
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
model TEXT,
|
||||
copilot_session_id TEXT,
|
||||
@@ -206,7 +193,7 @@ CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL REFERENCES chat_conversations(id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
@@ -216,10 +203,10 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
env TEXT,
|
||||
npm TEXT,
|
||||
package_ref TEXT,
|
||||
api TEXT,
|
||||
doc TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -248,7 +235,7 @@ CREATE TABLE IF NOT EXISTS ai_models (
|
||||
max_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
interleaved TEXT,
|
||||
status TEXT,
|
||||
provider_npm TEXT,
|
||||
provider_package_ref TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (provider, model_id)
|
||||
);
|
||||
@@ -262,24 +249,23 @@ CREATE TABLE IF NOT EXISTS ai_model_modalities (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_catalog_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ================================================================
|
||||
-- EMBEDDINGS TABLES (read-only in Rust core, must not error)
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS embedding_keys (
|
||||
label INTEGER PRIMARY KEY,
|
||||
label INTEGER NOT NULL PRIMARY KEY,
|
||||
post_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
vector TEXT NOT NULL
|
||||
vector BLOB NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS embedding_keys_project_post_idx
|
||||
ON embedding_keys(project_id, post_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
post_id_a TEXT NOT NULL,
|
||||
post_id_b TEXT NOT NULL,
|
||||
@@ -289,12 +275,8 @@ CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS dismissed_pairs_idx
|
||||
ON dismissed_duplicate_pairs(project_id, post_id_a, post_id_b);
|
||||
|
||||
-- ================================================================
|
||||
-- IMPORT TABLES (read-only in Rust core, must not error)
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_definitions (
|
||||
id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
wxr_file_path TEXT,
|
||||
@@ -304,12 +286,8 @@ CREATE TABLE IF NOT EXISTS import_definitions (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ================================================================
|
||||
-- NOTIFICATION TABLES
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS db_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
@@ -0,0 +1,3 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE chat_messages DROP COLUMN token_usage_output;
|
||||
ALTER TABLE chat_messages DROP COLUMN token_usage_input;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE chat_messages ADD COLUMN token_usage_input INTEGER;
|
||||
ALTER TABLE chat_messages ADD COLUMN token_usage_output INTEGER;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
CREATE TABLE embedding_keys_old (
|
||||
label INTEGER NOT NULL PRIMARY KEY,
|
||||
post_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
vector TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO embedding_keys_old (label, post_id, project_id, content_hash, vector)
|
||||
SELECT label, post_id, project_id, content_hash, CAST(vector AS TEXT)
|
||||
FROM embedding_keys;
|
||||
|
||||
DROP TABLE embedding_keys;
|
||||
ALTER TABLE embedding_keys_old RENAME TO embedding_keys;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Your SQL goes here
|
||||
CREATE TABLE embedding_keys_new (
|
||||
label INTEGER NOT NULL PRIMARY KEY,
|
||||
post_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
vector BLOB NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO embedding_keys_new (label, post_id, project_id, content_hash, vector)
|
||||
SELECT label, post_id, project_id, content_hash, CAST(vector AS BLOB)
|
||||
FROM embedding_keys;
|
||||
|
||||
DROP TABLE embedding_keys;
|
||||
ALTER TABLE embedding_keys_new RENAME TO embedding_keys;
|
||||
|
||||
CREATE UNIQUE INDEX embedding_keys_project_post_idx
|
||||
ON embedding_keys(project_id, post_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE chat_messages DROP COLUMN cache_write_tokens;
|
||||
ALTER TABLE chat_messages DROP COLUMN cache_read_tokens;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER;
|
||||
ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE chat_conversations DROP COLUMN surface_state;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE chat_conversations ADD COLUMN surface_state TEXT;
|
||||
@@ -1,58 +0,0 @@
|
||||
-- Fix script and template default status from 'published' to 'draft'
|
||||
-- and make tag name uniqueness case-insensitive.
|
||||
--
|
||||
-- SQLite cannot ALTER COLUMN defaults, so we recreate the affected tables.
|
||||
|
||||
-- ── Scripts: default status 'published' → 'draft' ──
|
||||
|
||||
CREATE TABLE scripts_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'utility',
|
||||
entrypoint TEXT NOT NULL DEFAULT 'render',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
content TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO scripts_new SELECT * FROM scripts;
|
||||
DROP TABLE scripts;
|
||||
ALTER TABLE scripts_new RENAME TO scripts;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
|
||||
ON scripts(project_id, slug);
|
||||
|
||||
-- ── Templates: default status 'published' → 'draft' ──
|
||||
|
||||
CREATE TABLE templates_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'post',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
content TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO templates_new SELECT * FROM templates;
|
||||
DROP TABLE templates;
|
||||
ALTER TABLE templates_new RENAME TO templates;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
|
||||
ON templates(project_id, slug);
|
||||
|
||||
-- ── Tags: case-insensitive unique index ──
|
||||
|
||||
DROP INDEX IF EXISTS tags_project_name_idx;
|
||||
CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name COLLATE NOCASE);
|
||||
@@ -1,35 +1,129 @@
|
||||
use rusqlite::Connection;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
use crate::db::migrations;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("{0}")]
|
||||
Connection(#[from] diesel::ConnectionError),
|
||||
#[error("{0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
}
|
||||
|
||||
/// Shared synchronous Diesel connection used by the engine query API.
|
||||
pub struct DbConnection(RefCell<SqliteConnection>);
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct DatabasePathRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
file: String,
|
||||
}
|
||||
|
||||
impl DbConnection {
|
||||
pub fn with<T>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut SqliteConnection) -> diesel::QueryResult<T>,
|
||||
) -> diesel::QueryResult<T> {
|
||||
operation(&mut self.0.borrow_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn with_migrations<T>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut SqliteConnection) -> T,
|
||||
) -> T {
|
||||
operation(&mut self.0.borrow_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn begin_savepoint(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute("SAVEPOINT bds_operation")
|
||||
}
|
||||
|
||||
pub(crate) fn release_savepoint(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute("RELEASE bds_operation")
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_savepoint(&self) -> diesel::QueryResult<()> {
|
||||
self.0
|
||||
.borrow_mut()
|
||||
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reject_tag_template_updates_for_test(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute(
|
||||
"CREATE TRIGGER reject_template_tag_cascade \
|
||||
BEFORE UPDATE OF post_template_slug ON tags \
|
||||
BEGIN SELECT RAISE(ABORT, 'reject cascade'); END",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn insert_legacy_null_mcp_proposal_for_test(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute(
|
||||
"INSERT INTO mcp_proposals \
|
||||
(id, project_id, kind, status, entity_id, data, created_at, expires_at) \
|
||||
VALUES ('null-entity', 'p1', 'draft_post', 'pending', NULL, '{}', 3, 99)",
|
||||
)
|
||||
}
|
||||
|
||||
/// Filesystem database path for sibling surfaces that must open their own
|
||||
/// short-lived connection (gallery workers, preview servers, Lua hosts).
|
||||
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
|
||||
self.with(|conn| {
|
||||
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
|
||||
.get_result::<DatabasePathRow>(conn)
|
||||
.and_then(|row| {
|
||||
if row.file.is_empty() {
|
||||
Err(diesel::result::Error::NotFound)
|
||||
} else {
|
||||
Ok(row.file.into())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Database wrapper managing a SQLite connection.
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
conn: DbConnection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open an existing bDS project database.
|
||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?;
|
||||
Ok(Self { conn })
|
||||
pub fn open(path: &Path) -> Result<Self, DatabaseError> {
|
||||
Self::establish(path.to_string_lossy().as_ref(), true)
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for tests).
|
||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
|
||||
Ok(Self { conn })
|
||||
pub fn open_in_memory() -> Result<Self, DatabaseError> {
|
||||
Self::establish(":memory:", false)
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection.
|
||||
pub fn conn(&self) -> &Connection {
|
||||
fn establish(database_url: &str, wal: bool) -> Result<Self, DatabaseError> {
|
||||
let mut conn = SqliteConnection::establish(database_url)?;
|
||||
// SQLite connection configuration is backend-specific and not expressible in Diesel's DSL.
|
||||
conn.batch_execute(if wal {
|
||||
"PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;"
|
||||
} else {
|
||||
"PRAGMA foreign_keys=ON;"
|
||||
})?;
|
||||
Ok(Self {
|
||||
conn: DbConnection(RefCell::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn conn(&self) -> &DbConnection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Run all pending migrations via refinery.
|
||||
pub fn migrate(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
migrations::run_migrations(&mut self.conn)
|
||||
/// Run all pending embedded Diesel migrations.
|
||||
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
migrations::run_migrations(&self.conn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +134,24 @@ mod tests {
|
||||
#[test]
|
||||
fn open_in_memory() {
|
||||
let db = Database::open_in_memory().expect("should open in-memory db");
|
||||
let result: i64 = db
|
||||
let result = db
|
||||
.conn()
|
||||
.query_row("SELECT 1", [], |row| row.get(0))
|
||||
.with(|conn| {
|
||||
diesel::select(1.into_sql::<diesel::sql_types::Integer>()).get_result::<i32>(conn)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_the_disk_database_path() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.sqlite3");
|
||||
let db = Database::open(&path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.conn().database_path().unwrap().canonicalize().unwrap(),
|
||||
path.canonicalize().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,619 +0,0 @@
|
||||
use rusqlite::Row;
|
||||
|
||||
use crate::model::{
|
||||
DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction,
|
||||
NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script,
|
||||
ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus,
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn conversion_err(msg: String) -> rusqlite::Error {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_post_status(s: &str) -> rusqlite::Result<PostStatus> {
|
||||
match s {
|
||||
"draft" => Ok(PostStatus::Draft),
|
||||
"published" => Ok(PostStatus::Published),
|
||||
"archived" => Ok(PostStatus::Archived),
|
||||
_ => Err(conversion_err(format!("invalid PostStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_template_kind(s: &str) -> rusqlite::Result<TemplateKind> {
|
||||
match s {
|
||||
"post" => Ok(TemplateKind::Post),
|
||||
"list" => Ok(TemplateKind::List),
|
||||
"not_found" => Ok(TemplateKind::NotFound),
|
||||
"partial" => Ok(TemplateKind::Partial),
|
||||
_ => Err(conversion_err(format!("invalid TemplateKind: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_template_status(s: &str) -> rusqlite::Result<TemplateStatus> {
|
||||
match s {
|
||||
"draft" => Ok(TemplateStatus::Draft),
|
||||
"published" => Ok(TemplateStatus::Published),
|
||||
_ => Err(conversion_err(format!("invalid TemplateStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_script_kind(s: &str) -> rusqlite::Result<ScriptKind> {
|
||||
match s {
|
||||
"macro" => Ok(ScriptKind::Macro),
|
||||
"utility" => Ok(ScriptKind::Utility),
|
||||
"transform" => Ok(ScriptKind::Transform),
|
||||
_ => Err(conversion_err(format!("invalid ScriptKind: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_script_status(s: &str) -> rusqlite::Result<ScriptStatus> {
|
||||
match s {
|
||||
"draft" => Ok(ScriptStatus::Draft),
|
||||
"published" => Ok(ScriptStatus::Published),
|
||||
_ => Err(conversion_err(format!("invalid ScriptStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_notification_entity(s: &str) -> rusqlite::Result<NotificationEntity> {
|
||||
match s {
|
||||
"post" => Ok(NotificationEntity::Post),
|
||||
"media" => Ok(NotificationEntity::Media),
|
||||
"script" => Ok(NotificationEntity::Script),
|
||||
"template" => Ok(NotificationEntity::Template),
|
||||
_ => Err(conversion_err(format!("invalid NotificationEntity: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_notification_action(s: &str) -> rusqlite::Result<NotificationAction> {
|
||||
match s {
|
||||
"created" => Ok(NotificationAction::Created),
|
||||
"updated" => Ok(NotificationAction::Updated),
|
||||
"deleted" => Ok(NotificationAction::Deleted),
|
||||
_ => Err(conversion_err(format!("invalid NotificationAction: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_vec_string(s: &str) -> rusqlite::Result<Vec<String>> {
|
||||
serde_json::from_str(s).map_err(|e| conversion_err(format!("JSON parse error: {e}")))
|
||||
}
|
||||
|
||||
fn bool_from_i64(v: i64) -> bool {
|
||||
v != 0
|
||||
}
|
||||
|
||||
// ── enum → DB string (for INSERT / UPDATE) ───────────────────────────
|
||||
|
||||
pub fn post_status_to_str(s: &PostStatus) -> &'static str {
|
||||
match s {
|
||||
PostStatus::Draft => "draft",
|
||||
PostStatus::Published => "published",
|
||||
PostStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_kind_to_str(k: &TemplateKind) -> &'static str {
|
||||
match k {
|
||||
TemplateKind::Post => "post",
|
||||
TemplateKind::List => "list",
|
||||
TemplateKind::NotFound => "not_found",
|
||||
TemplateKind::Partial => "partial",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_status_to_str(s: &TemplateStatus) -> &'static str {
|
||||
match s {
|
||||
TemplateStatus::Draft => "draft",
|
||||
TemplateStatus::Published => "published",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_kind_to_str(k: &ScriptKind) -> &'static str {
|
||||
match k {
|
||||
ScriptKind::Macro => "macro",
|
||||
ScriptKind::Utility => "utility",
|
||||
ScriptKind::Transform => "transform",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_status_to_str(s: &ScriptStatus) -> &'static str {
|
||||
match s {
|
||||
ScriptStatus::Draft => "draft",
|
||||
ScriptStatus::Published => "published",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notification_entity_to_str(e: &NotificationEntity) -> &'static str {
|
||||
match e {
|
||||
NotificationEntity::Post => "post",
|
||||
NotificationEntity::Media => "media",
|
||||
NotificationEntity::Script => "script",
|
||||
NotificationEntity::Template => "template",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notification_action_to_str(a: &NotificationAction) -> &'static str {
|
||||
match a {
|
||||
NotificationAction::Created => "created",
|
||||
NotificationAction::Updated => "updated",
|
||||
NotificationAction::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
// ── column lists (keep in sync with from_row functions) ──────────────
|
||||
|
||||
pub const PROJECT_COLUMNS: &str =
|
||||
"id, name, slug, description, data_path, is_active, created_at, updated_at";
|
||||
|
||||
pub const POST_COLUMNS: &str = "\
|
||||
id, project_id, title, slug, excerpt, content, status, author, \
|
||||
language, do_not_translate, template_slug, file_path, checksum, \
|
||||
tags, categories, \
|
||||
published_title, published_content, published_tags, \
|
||||
published_categories, published_excerpt, \
|
||||
created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_TRANSLATION_COLUMNS: &str = "\
|
||||
id, project_id, translation_for, language, title, excerpt, content, \
|
||||
status, file_path, checksum, created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_LINK_COLUMNS: &str =
|
||||
"id, source_post_id, target_post_id, link_text, created_at";
|
||||
|
||||
pub const POST_MEDIA_COLUMNS: &str =
|
||||
"id, project_id, post_id, media_id, sort_order, created_at";
|
||||
|
||||
pub const MEDIA_COLUMNS: &str = "\
|
||||
id, project_id, filename, original_name, mime_type, size, \
|
||||
width, height, title, alt, caption, author, language, \
|
||||
file_path, sidecar_path, checksum, tags, created_at, updated_at";
|
||||
|
||||
pub const MEDIA_TRANSLATION_COLUMNS: &str =
|
||||
"id, project_id, translation_for, language, title, alt, caption, created_at, updated_at";
|
||||
|
||||
pub const TAG_COLUMNS: &str =
|
||||
"id, project_id, name, color, post_template_slug, created_at, updated_at";
|
||||
|
||||
pub const TEMPLATE_COLUMNS: &str = "\
|
||||
id, project_id, slug, title, kind, enabled, version, \
|
||||
file_path, status, content, created_at, updated_at";
|
||||
|
||||
pub const SCRIPT_COLUMNS: &str = "\
|
||||
id, project_id, slug, title, kind, entrypoint, enabled, version, \
|
||||
file_path, status, content, created_at, updated_at";
|
||||
|
||||
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
|
||||
|
||||
pub const GENERATED_FILE_HASH_COLUMNS: &str =
|
||||
"project_id, relative_path, content_hash, updated_at";
|
||||
|
||||
pub const DB_NOTIFICATION_COLUMNS: &str =
|
||||
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
|
||||
|
||||
// ── from_row functions ───────────────────────────────────────────────
|
||||
|
||||
pub fn project_from_row(row: &Row) -> rusqlite::Result<Project> {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
data_path: row.get(4)?,
|
||||
is_active: bool_from_i64(row.get(5)?),
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_from_row(row: &Row) -> rusqlite::Result<Post> {
|
||||
let status_str: String = row.get(6)?;
|
||||
let tags_json: String = row.get(13)?;
|
||||
let categories_json: String = row.get(14)?;
|
||||
Ok(Post {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
slug: row.get(3)?,
|
||||
excerpt: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
status: parse_post_status(&status_str)?,
|
||||
author: row.get(7)?,
|
||||
language: row.get(8)?,
|
||||
do_not_translate: bool_from_i64(row.get(9)?),
|
||||
template_slug: row.get(10)?,
|
||||
file_path: row.get(11)?,
|
||||
checksum: row.get(12)?,
|
||||
tags: json_to_vec_string(&tags_json)?,
|
||||
categories: json_to_vec_string(&categories_json)?,
|
||||
published_title: row.get(15)?,
|
||||
published_content: row.get(16)?,
|
||||
published_tags: row.get(17)?,
|
||||
published_categories: row.get(18)?,
|
||||
published_excerpt: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
updated_at: row.get(21)?,
|
||||
published_at: row.get(22)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_translation_from_row(row: &Row) -> rusqlite::Result<PostTranslation> {
|
||||
let status_str: String = row.get(7)?;
|
||||
Ok(PostTranslation {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
translation_for: row.get(2)?,
|
||||
language: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
excerpt: row.get(5)?,
|
||||
content: row.get(6)?,
|
||||
status: parse_post_status(&status_str)?,
|
||||
file_path: row.get(8)?,
|
||||
checksum: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
published_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_link_from_row(row: &Row) -> rusqlite::Result<PostLink> {
|
||||
Ok(PostLink {
|
||||
id: row.get(0)?,
|
||||
source_post_id: row.get(1)?,
|
||||
target_post_id: row.get(2)?,
|
||||
link_text: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_media_from_row(row: &Row) -> rusqlite::Result<PostMedia> {
|
||||
Ok(PostMedia {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
post_id: row.get(2)?,
|
||||
media_id: row.get(3)?,
|
||||
sort_order: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_from_row(row: &Row) -> rusqlite::Result<Media> {
|
||||
let tags_json: String = row.get(16)?;
|
||||
Ok(Media {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
original_name: row.get(3)?,
|
||||
mime_type: row.get(4)?,
|
||||
size: row.get(5)?,
|
||||
width: row.get(6)?,
|
||||
height: row.get(7)?,
|
||||
title: row.get(8)?,
|
||||
alt: row.get(9)?,
|
||||
caption: row.get(10)?,
|
||||
author: row.get(11)?,
|
||||
language: row.get(12)?,
|
||||
file_path: row.get(13)?,
|
||||
sidecar_path: row.get(14)?,
|
||||
checksum: row.get(15)?,
|
||||
tags: json_to_vec_string(&tags_json)?,
|
||||
created_at: row.get(17)?,
|
||||
updated_at: row.get(18)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_translation_from_row(row: &Row) -> rusqlite::Result<MediaTranslation> {
|
||||
Ok(MediaTranslation {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
translation_for: row.get(2)?,
|
||||
language: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
alt: row.get(5)?,
|
||||
caption: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tag_from_row(row: &Row) -> rusqlite::Result<Tag> {
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
color: row.get(3)?,
|
||||
post_template_slug: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn template_from_row(row: &Row) -> rusqlite::Result<Template> {
|
||||
let kind_str: String = row.get(4)?;
|
||||
let status_str: String = row.get(8)?;
|
||||
Ok(Template {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
kind: parse_template_kind(&kind_str)?,
|
||||
enabled: bool_from_i64(row.get(5)?),
|
||||
version: row.get(6)?,
|
||||
file_path: row.get(7)?,
|
||||
status: parse_template_status(&status_str)?,
|
||||
content: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn script_from_row(row: &Row) -> rusqlite::Result<Script> {
|
||||
let kind_str: String = row.get(4)?;
|
||||
let status_str: String = row.get(9)?;
|
||||
Ok(Script {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
kind: parse_script_kind(&kind_str)?,
|
||||
entrypoint: row.get(5)?,
|
||||
enabled: bool_from_i64(row.get(6)?),
|
||||
version: row.get(7)?,
|
||||
file_path: row.get(8)?,
|
||||
status: parse_script_status(&status_str)?,
|
||||
content: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setting_from_row(row: &Row) -> rusqlite::Result<Setting> {
|
||||
Ok(Setting {
|
||||
key: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
updated_at: row.get(2)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generated_file_hash_from_row(row: &Row) -> rusqlite::Result<GeneratedFileHash> {
|
||||
Ok(GeneratedFileHash {
|
||||
project_id: row.get(0)?,
|
||||
relative_path: row.get(1)?,
|
||||
content_hash: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db_notification_from_row(row: &Row) -> rusqlite::Result<DbNotification> {
|
||||
let entity_str: String = row.get(1)?;
|
||||
let action_str: String = row.get(3)?;
|
||||
Ok(DbNotification {
|
||||
id: row.get(0)?,
|
||||
entity_type: parse_notification_entity(&entity_str)?,
|
||||
entity_id: row.get(2)?,
|
||||
action: parse_notification_action(&action_str)?,
|
||||
from_cli: bool_from_i64(row.get(4)?),
|
||||
seen_at: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
// ── enum parsing ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_post_status_valid() {
|
||||
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
|
||||
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
|
||||
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_status_invalid() {
|
||||
assert!(parse_post_status("nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_kind_valid() {
|
||||
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
|
||||
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
|
||||
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
|
||||
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_kind_invalid() {
|
||||
assert!(parse_template_kind("unknown").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_status_valid() {
|
||||
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
|
||||
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_kind_valid() {
|
||||
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
|
||||
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
|
||||
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_status_valid() {
|
||||
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
|
||||
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_entity_valid() {
|
||||
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
|
||||
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
|
||||
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
|
||||
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_action_valid() {
|
||||
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
|
||||
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
|
||||
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
|
||||
}
|
||||
|
||||
// ── JSON helpers ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_roundtrip() {
|
||||
let v = json_to_vec_string(r#"["a","b","c"]"#).unwrap();
|
||||
assert_eq!(v, vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_empty() {
|
||||
let v = json_to_vec_string("[]").unwrap();
|
||||
assert!(v.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_invalid() {
|
||||
assert!(json_to_vec_string("not json").is_err());
|
||||
}
|
||||
|
||||
// ── from_row round-trips via real DB ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn project_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
|
||||
[],
|
||||
project_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(p.id, "p1");
|
||||
assert_eq!(p.name, "Blog");
|
||||
assert_eq!(p.slug, "blog");
|
||||
assert_eq!(p.description.as_deref(), Some("My blog"));
|
||||
assert_eq!(p.data_path.as_deref(), Some("/data"));
|
||||
assert!(p.is_active);
|
||||
assert_eq!(p.created_at, 1000);
|
||||
assert_eq!(p.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
tags, categories,
|
||||
published_title, published_content, published_tags, published_categories, published_excerpt,
|
||||
created_at, updated_at, published_at)
|
||||
VALUES ('x', 'p1', 'Hello', 'hello', 'sum', 'body', 'draft', 'Alice',
|
||||
'en', 1, 'tpl', 'posts/hello.md', 'abc',
|
||||
'[\"rust\"]', '[\"tech\"]',
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
1000, 2000, NULL)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
|
||||
[],
|
||||
post_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(p.id, "x");
|
||||
assert_eq!(p.project_id, "p1");
|
||||
assert_eq!(p.title, "Hello");
|
||||
assert_eq!(p.slug, "hello");
|
||||
assert_eq!(p.excerpt.as_deref(), Some("sum"));
|
||||
assert_eq!(p.content.as_deref(), Some("body"));
|
||||
assert_eq!(p.status, PostStatus::Draft);
|
||||
assert_eq!(p.author.as_deref(), Some("Alice"));
|
||||
assert_eq!(p.language.as_deref(), Some("en"));
|
||||
assert!(p.do_not_translate);
|
||||
assert_eq!(p.template_slug.as_deref(), Some("tpl"));
|
||||
assert_eq!(p.file_path, "posts/hello.md");
|
||||
assert_eq!(p.checksum.as_deref(), Some("abc"));
|
||||
assert_eq!(p.tags, vec!["rust"]);
|
||||
assert_eq!(p.categories, vec!["tech"]);
|
||||
assert!(p.published_title.is_none());
|
||||
assert_eq!(p.created_at, 1000);
|
||||
assert_eq!(p.updated_at, 2000);
|
||||
assert!(p.published_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at)
|
||||
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
|
||||
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let t = c.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
|
||||
[],
|
||||
template_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(t.kind, TemplateKind::NotFound);
|
||||
assert!(!t.enabled);
|
||||
assert_eq!(t.version, 3);
|
||||
assert_eq!(t.status, TemplateStatus::Draft);
|
||||
assert_eq!(t.content.as_deref(), Some("html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_notification_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, seen_at, created_at)
|
||||
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let n = c.query_row(
|
||||
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
|
||||
[],
|
||||
db_notification_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(n.entity_type, NotificationEntity::Media);
|
||||
assert_eq!(n.entity_id, "m1");
|
||||
assert_eq!(n.action, NotificationAction::Deleted);
|
||||
assert!(n.from_cli);
|
||||
assert_eq!(n.seen_at, Some(5000));
|
||||
assert_eq!(n.created_at, 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,45 @@
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::{BigInt, Text};
|
||||
use diesel::sqlite::Sqlite;
|
||||
use rust_stemmers::{Algorithm, Stemmer};
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||
///
|
||||
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
||||
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::schema::{media, post_translations, posts};
|
||||
use crate::util::calendar_range_unix_ms;
|
||||
|
||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer);
|
||||
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct PostIdRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
post_id: String,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct MediaIdRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
media_id: String,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct TableCountRow {
|
||||
#[diesel(sql_type = BigInt)]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct ColumnNameRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
const CREATE_FTS_TABLES: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
post_id UNINDEXED,
|
||||
title,
|
||||
excerpt,
|
||||
@@ -22,9 +54,87 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
caption,
|
||||
original_name,
|
||||
tags
|
||||
);"
|
||||
)?;
|
||||
Ok(())
|
||||
);";
|
||||
|
||||
/// Whether both application FTS5 virtual tables are present.
|
||||
pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
||||
conn.with(|c| {
|
||||
diesel::sql_query(
|
||||
"SELECT COUNT(*) AS count FROM sqlite_master \
|
||||
WHERE type = 'table' AND name IN ('posts_fts', 'media_fts')",
|
||||
)
|
||||
.get_result::<TableCountRow>(c)
|
||||
.map(|row| row.count == 2)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||
///
|
||||
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
||||
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| c.batch_execute(CREATE_FTS_TABLES))
|
||||
}
|
||||
|
||||
/// Whether the runtime-managed FTS tables have the current deployed schema.
|
||||
pub fn schema_is_current(conn: &Connection) -> QueryResult<bool> {
|
||||
conn.with(|c| {
|
||||
let post_columns =
|
||||
diesel::sql_query("SELECT name FROM pragma_table_info('posts_fts') ORDER BY cid")
|
||||
.load::<ColumnNameRow>(c)?
|
||||
.into_iter()
|
||||
.map(|row| row.name)
|
||||
.collect::<Vec<_>>();
|
||||
let media_columns =
|
||||
diesel::sql_query("SELECT name FROM pragma_table_info('media_fts') ORDER BY cid")
|
||||
.load::<ColumnNameRow>(c)?
|
||||
.into_iter()
|
||||
.map(|row| row.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(post_columns
|
||||
== [
|
||||
"post_id",
|
||||
"title",
|
||||
"excerpt",
|
||||
"content",
|
||||
"tags",
|
||||
"categories",
|
||||
]
|
||||
&& media_columns
|
||||
== [
|
||||
"media_id",
|
||||
"title",
|
||||
"alt",
|
||||
"caption",
|
||||
"original_name",
|
||||
"tags",
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the derived FTS tables without touching user-authored data.
|
||||
pub fn recreate_tables(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.batch_execute("DROP TABLE IF EXISTS posts_fts; DROP TABLE IF EXISTS media_fts;")?;
|
||||
c.batch_execute(CREATE_FTS_TABLES)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_deployed_schema_for_test(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.batch_execute(
|
||||
"DROP TABLE posts_fts;
|
||||
DROP TABLE media_fts;
|
||||
CREATE VIRTUAL TABLE posts_fts USING fts5(post_id UNINDEXED, content);
|
||||
CREATE VIRTUAL TABLE media_fts USING fts5(media_id UNINDEXED, content);",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn drop_post_index(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| c.batch_execute("DROP TABLE posts_fts"))
|
||||
}
|
||||
|
||||
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
|
||||
@@ -82,6 +192,10 @@ pub struct MediaTranslationFts {
|
||||
/// Index a post in the FTS table with separate columns per spec.
|
||||
///
|
||||
/// Translation titles go to the title column, excerpts to excerpt, content to content.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "FTS columns mirror the persisted post fields"
|
||||
)]
|
||||
pub fn index_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
@@ -92,7 +206,7 @@ pub fn index_post(
|
||||
categories: &[String],
|
||||
translations: &[PostTranslationFts],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
) -> QueryResult<()> {
|
||||
// Remove existing entry
|
||||
remove_post_from_index(conn, post_id)?;
|
||||
|
||||
@@ -127,16 +241,19 @@ pub fn index_post(
|
||||
let stemmed_tags = stem_text(&tags.join(" "), language);
|
||||
let stemmed_categories = stem_text(&categories.join(" "), language);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![post_id, stemmed_title, stemmed_excerpt, stemmed_content, stemmed_tags, stemmed_categories],
|
||||
)?;
|
||||
Ok(())
|
||||
conn.with(|c| diesel::sql_query("INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind::<Text, _>(post_id).bind::<Text, _>(stemmed_title).bind::<Text, _>(stemmed_excerpt)
|
||||
.bind::<Text, _>(stemmed_content).bind::<Text, _>(stemmed_tags).bind::<Text, _>(stemmed_categories)
|
||||
.execute(c).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Index a media item in the FTS table with separate columns per spec.
|
||||
///
|
||||
/// Translation titles go to the title column, alts to alt, captions to caption.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "FTS columns mirror the persisted media fields"
|
||||
)]
|
||||
pub fn index_media(
|
||||
conn: &Connection,
|
||||
media_id: &str,
|
||||
@@ -147,7 +264,7 @@ pub fn index_media(
|
||||
tags: &[String],
|
||||
translations: &[MediaTranslationFts],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
) -> QueryResult<()> {
|
||||
remove_media_from_index(conn, media_id)?;
|
||||
|
||||
// Title column: media title + all translation titles
|
||||
@@ -183,41 +300,41 @@ pub fn index_media(
|
||||
let stemmed_name = stem_text(original_name, language);
|
||||
let stemmed_tags = stem_text(&tags.join(" "), language);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![media_id, stemmed_title, stemmed_alt, stemmed_caption, stemmed_name, stemmed_tags],
|
||||
)?;
|
||||
Ok(())
|
||||
conn.with(|c| diesel::sql_query("INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind::<Text, _>(media_id).bind::<Text, _>(stemmed_title).bind::<Text, _>(stemmed_alt)
|
||||
.bind::<Text, _>(stemmed_caption).bind::<Text, _>(stemmed_name).bind::<Text, _>(stemmed_tags)
|
||||
.execute(c).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Remove a post from the FTS index.
|
||||
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM posts_fts WHERE post_id = ?1",
|
||||
rusqlite::params![post_id],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("DELETE FROM posts_fts WHERE post_id = ?")
|
||||
.bind::<Text, _>(post_id)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a media item from the FTS index.
|
||||
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM media_fts WHERE media_id = ?1",
|
||||
rusqlite::params![media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("DELETE FROM media_fts WHERE media_id = ?")
|
||||
.bind::<Text, _>(media_id)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Search posts by full-text query. Returns matching post IDs.
|
||||
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
||||
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> QueryResult<Vec<String>> {
|
||||
let stemmed = stem_text(query, language);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
rows.collect()
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ? ORDER BY rank")
|
||||
.bind::<Text, _>(stemmed)
|
||||
.load::<PostIdRow>(c)
|
||||
.map(|rows| rows.into_iter().map(|row| row.post_id).collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Filters for post search.
|
||||
@@ -251,162 +368,211 @@ pub fn search_posts_filtered(
|
||||
query: &str,
|
||||
language: &str,
|
||||
filters: &PostSearchFilters,
|
||||
) -> rusqlite::Result<SearchResults> {
|
||||
) -> QueryResult<SearchResults> {
|
||||
// Get FTS matches first
|
||||
let fts_ids = search_posts(conn, query, language)?;
|
||||
if fts_ids.is_empty() {
|
||||
return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) });
|
||||
return Ok(SearchResults {
|
||||
post_ids: vec![],
|
||||
total: 0,
|
||||
offset: filters.offset.unwrap_or(0),
|
||||
limit: filters.limit.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
// Apply filters by querying posts table
|
||||
let placeholders: Vec<String> = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
|
||||
let mut sql = format!(
|
||||
"SELECT id FROM posts WHERE id IN ({}) ",
|
||||
placeholders.join(",")
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
|
||||
let mut param_idx = fts_ids.len() + 1;
|
||||
|
||||
if let Some(status) = filters.status {
|
||||
sql.push_str(&format!("AND status = ?{param_idx} "));
|
||||
params.push(Box::new(status.to_string()));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(tags) = filters.tags {
|
||||
// Filter posts whose JSON tags array contains ALL specified tags (case-insensitive)
|
||||
for tag in tags {
|
||||
sql.push_str(&format!(
|
||||
"AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
|
||||
));
|
||||
params.push(Box::new(tag.clone()));
|
||||
param_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(categories) = filters.categories {
|
||||
// Filter posts whose JSON categories array contains ALL specified categories (case-insensitive)
|
||||
for cat in categories {
|
||||
sql.push_str(&format!(
|
||||
"AND EXISTS (SELECT 1 FROM json_each(posts.categories) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
|
||||
));
|
||||
params.push(Box::new(cat.clone()));
|
||||
param_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
// Filter by year from created_at (unix ms)
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
||||
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
|
||||
params.push(Box::new(start));
|
||||
params.push(Box::new(end));
|
||||
param_idx += 2;
|
||||
}
|
||||
|
||||
if let Some(month) = filters.month {
|
||||
if let Some(year) = filters.year {
|
||||
let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) };
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
||||
let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
||||
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
|
||||
params.push(Box::new(start));
|
||||
params.push(Box::new(end));
|
||||
param_idx += 2;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(lang) = filters.language {
|
||||
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) "));
|
||||
params.push(Box::new(lang.to_string()));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(missing_lang) = filters.missing_translation_language {
|
||||
sql.push_str(&format!(
|
||||
"AND NOT EXISTS (SELECT 1 FROM post_translations WHERE post_translations.translation_for = posts.id AND post_translations.language = ?{param_idx}) "
|
||||
));
|
||||
params.push(Box::new(missing_lang.to_string()));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(from_ts) = filters.from {
|
||||
sql.push_str(&format!("AND created_at >= ?{param_idx} "));
|
||||
params.push(Box::new(from_ts));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(to_ts) = filters.to {
|
||||
sql.push_str(&format!("AND created_at <= ?{param_idx} "));
|
||||
params.push(Box::new(to_ts));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
let _ = param_idx; // suppress unused warning
|
||||
|
||||
// First get total count (without LIMIT/OFFSET)
|
||||
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
|
||||
let total: usize = {
|
||||
let mut stmt = conn.prepare(&count_sql)?;
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
|
||||
};
|
||||
|
||||
sql.push_str("ORDER BY created_at DESC ");
|
||||
|
||||
let offset = filters.offset.unwrap_or(0);
|
||||
let limit = filters.limit.unwrap_or(total);
|
||||
|
||||
if filters.limit.is_some() {
|
||||
sql.push_str(&format!("LIMIT {limit} "));
|
||||
sql.push_str(&format!("OFFSET {offset} "));
|
||||
let mut post_ids = conn.with(|c| {
|
||||
let mut query = posts::table.filter(posts::id.eq_any(fts_ids)).into_boxed();
|
||||
if let Some(status) = filters.status {
|
||||
query = query.filter(posts::status.eq(status));
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
||||
row.get::<_, String>(0)
|
||||
if let Some(tags) = filters.tags {
|
||||
for tag in tags {
|
||||
query = query.filter(
|
||||
instr(
|
||||
lower(posts::tags),
|
||||
serde_json::to_string(&tag.to_lowercase()).unwrap(),
|
||||
)
|
||||
.gt(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(categories) = filters.categories {
|
||||
for category in categories {
|
||||
query = query.filter(
|
||||
instr(
|
||||
lower(posts::categories),
|
||||
serde_json::to_string(&category.to_lowercase()).unwrap(),
|
||||
)
|
||||
.gt(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||
})?;
|
||||
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(SearchResults { post_ids, total, offset, limit })
|
||||
query = query.filter(posts::created_at.ge(start).and(posts::created_at.lt(end)));
|
||||
}
|
||||
if let Some(language) = filters.language {
|
||||
query = query.filter(posts::language.eq(language).or(posts::language.is_null()));
|
||||
}
|
||||
if let Some(language) = filters.missing_translation_language {
|
||||
query = query.filter(diesel::dsl::not(diesel::dsl::exists(
|
||||
post_translations::table
|
||||
.filter(post_translations::translation_for.eq(posts::id))
|
||||
.filter(post_translations::language.eq(language)),
|
||||
)));
|
||||
}
|
||||
if let Some(from) = filters.from {
|
||||
query = query.filter(posts::created_at.ge(from));
|
||||
}
|
||||
if let Some(to) = filters.to {
|
||||
query = query.filter(posts::created_at.le(to));
|
||||
}
|
||||
query
|
||||
.order(posts::created_at.desc())
|
||||
.select(posts::id)
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let total = post_ids.len();
|
||||
let limit = filters.limit.unwrap_or(total);
|
||||
post_ids = post_ids.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(SearchResults {
|
||||
post_ids,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
/// Search media by full-text query. Returns matching media IDs.
|
||||
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
||||
pub fn search_media(conn: &Connection, query: &str, language: &str) -> QueryResult<Vec<String>> {
|
||||
let stemmed = stem_text(query, language);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("SELECT media_id FROM media_fts WHERE media_fts MATCH ? ORDER BY rank")
|
||||
.bind::<Text, _>(stemmed)
|
||||
.load::<MediaIdRow>(c)
|
||||
.map(|rows| rows.into_iter().map(|row| row.media_id).collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MediaSearchFilters<'a> {
|
||||
pub project_id: Option<&'a str>,
|
||||
pub tags: Option<&'a [String]>,
|
||||
pub year: Option<i32>,
|
||||
pub month: Option<u32>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MediaSearchResults {
|
||||
pub media_ids: Vec<String>,
|
||||
pub total: usize,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
pub fn search_media_filtered(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
language: &str,
|
||||
filters: &MediaSearchFilters<'_>,
|
||||
) -> QueryResult<MediaSearchResults> {
|
||||
let ranked_ids = search_media(conn, query, language)?;
|
||||
let offset = filters.offset.unwrap_or(0);
|
||||
if ranked_ids.is_empty() {
|
||||
return Ok(MediaSearchResults {
|
||||
media_ids: Vec::new(),
|
||||
total: 0,
|
||||
offset,
|
||||
limit: filters.limit.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
let matching_ids = conn.with(|connection| {
|
||||
let mut filtered = media::table
|
||||
.filter(media::id.eq_any(&ranked_ids))
|
||||
.into_boxed();
|
||||
if let Some(project_id) = filters.project_id {
|
||||
filtered = filtered.filter(media::project_id.eq(project_id));
|
||||
}
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||
})?;
|
||||
rows.collect()
|
||||
filtered = filtered.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
|
||||
}
|
||||
if let Some(tags) = filters.tags {
|
||||
for tag in tags {
|
||||
filtered = filtered.filter(
|
||||
instr(
|
||||
lower(media::tags),
|
||||
serde_json::to_string(&tag.to_lowercase()).unwrap(),
|
||||
)
|
||||
.gt(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
filtered.select(media::id).load::<String>(connection)
|
||||
})?;
|
||||
let matching_ids = matching_ids
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut media_ids = ranked_ids
|
||||
.into_iter()
|
||||
.filter(|id| matching_ids.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
let total = media_ids.len();
|
||||
let limit = filters.limit.unwrap_or(total);
|
||||
media_ids = media_ids.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(MediaSearchResults {
|
||||
media_ids,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn insert_test_post(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
status: PostStatus,
|
||||
timestamp: i64,
|
||||
) {
|
||||
let mut post = make_test_post(id, "p1", slug);
|
||||
post.title = title.into();
|
||||
post.status = status;
|
||||
post.created_at = timestamp;
|
||||
post.updated_at = timestamp;
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_tables_created() {
|
||||
let db = setup();
|
||||
let count: i64 = db.conn()
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('posts_fts', 'media_fts')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
assert!(search_posts(db.conn(), "nothing", "en").unwrap().is_empty());
|
||||
assert!(search_media(db.conn(), "nothing", "en").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -418,16 +584,30 @@ mod tests {
|
||||
#[test]
|
||||
fn fts_multi_column_schema() {
|
||||
let db = setup();
|
||||
// Verify posts_fts has the expected columns by inserting into each
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES ('p1', 'T', 'E', 'C', 'tg', 'ct')",
|
||||
[],
|
||||
).unwrap();
|
||||
// Verify media_fts has the expected columns
|
||||
db.conn().execute(
|
||||
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES ('m1', 'T', 'A', 'C', 'N', 'tg')",
|
||||
[],
|
||||
).unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p1",
|
||||
"T",
|
||||
Some("E"),
|
||||
Some("C"),
|
||||
&["tg".into()],
|
||||
&["ct".into()],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
index_media(
|
||||
db.conn(),
|
||||
"m1",
|
||||
Some("T"),
|
||||
Some("A"),
|
||||
Some("C"),
|
||||
"N",
|
||||
&["tg".into()],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -526,7 +706,8 @@ mod tests {
|
||||
language: "en".into(),
|
||||
}],
|
||||
"en",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_posts(db.conn(), "spider", "en").unwrap();
|
||||
assert_eq!(results, vec!["post-1"]);
|
||||
@@ -545,12 +726,57 @@ mod tests {
|
||||
&["nature".into()],
|
||||
&[],
|
||||
"en",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_media(db.conn(), "sunset", "en").unwrap();
|
||||
assert_eq!(results, vec!["media-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_media_search_honors_project_tags_and_pagination() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_test_project("p1", "one")).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p2", "two")).unwrap();
|
||||
for (id, project, tags) in [
|
||||
("m1", "p1", vec!["nature".to_string()]),
|
||||
("m2", "p1", vec!["city".to_string()]),
|
||||
("m3", "p2", vec!["nature".to_string()]),
|
||||
] {
|
||||
let mut media = make_test_media(id, project);
|
||||
media.tags = tags.clone();
|
||||
insert_media(db.conn(), &media).unwrap();
|
||||
index_media(
|
||||
db.conn(),
|
||||
id,
|
||||
Some("Shared sunset"),
|
||||
None,
|
||||
None,
|
||||
&media.original_name,
|
||||
&tags,
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let tags = vec!["nature".to_string()];
|
||||
let results = search_media_filtered(
|
||||
db.conn(),
|
||||
"sunset",
|
||||
"en",
|
||||
&MediaSearchFilters {
|
||||
project_id: Some("p1"),
|
||||
tags: Some(&tags),
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.total, 1);
|
||||
assert_eq!(results.media_ids, ["m1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_no_results() {
|
||||
let db = setup();
|
||||
@@ -561,10 +787,7 @@ mod tests {
|
||||
#[test]
|
||||
fn remove_from_index() {
|
||||
let db = setup();
|
||||
index_post(
|
||||
db.conn(), "p1", "Test", None, None,
|
||||
&[], &[], &[], "en",
|
||||
).unwrap();
|
||||
index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap();
|
||||
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
|
||||
|
||||
remove_post_from_index(db.conn(), "p1").unwrap();
|
||||
@@ -586,8 +809,13 @@ mod tests {
|
||||
let db = setup();
|
||||
// German post with English translation
|
||||
index_post(
|
||||
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
|
||||
&[], &[],
|
||||
db.conn(),
|
||||
"p1",
|
||||
"Programmierung",
|
||||
None,
|
||||
Some("Deutsche Entwicklung"),
|
||||
&[],
|
||||
&[],
|
||||
&[PostTranslationFts {
|
||||
title: "English development programming".into(),
|
||||
excerpt: None,
|
||||
@@ -595,7 +823,8 @@ mod tests {
|
||||
language: "en".into(),
|
||||
}],
|
||||
"de",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search with English stemming should find via English translation
|
||||
let results = search_posts(db.conn(), "develop", "en").unwrap();
|
||||
@@ -605,7 +834,18 @@ mod tests {
|
||||
#[test]
|
||||
fn search_by_title_field() {
|
||||
let db = setup();
|
||||
index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p1",
|
||||
"Unique Title Here",
|
||||
None,
|
||||
Some("body text"),
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search for title content
|
||||
let results = search_posts(db.conn(), "unique", "en").unwrap();
|
||||
@@ -615,7 +855,18 @@ mod tests {
|
||||
#[test]
|
||||
fn search_by_tags() {
|
||||
let db = setup();
|
||||
index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p1",
|
||||
"Post",
|
||||
None,
|
||||
None,
|
||||
&["photography".into()],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_posts(db.conn(), "photography", "en").unwrap();
|
||||
assert_eq!(results, vec!["p1"]);
|
||||
@@ -624,7 +875,18 @@ mod tests {
|
||||
#[test]
|
||||
fn search_by_categories() {
|
||||
let db = setup();
|
||||
index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p1",
|
||||
"Post",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&["article".into()],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_posts(db.conn(), "article", "en").unwrap();
|
||||
assert_eq!(results, vec!["p1"]);
|
||||
@@ -636,19 +898,47 @@ mod tests {
|
||||
// Insert a post in the posts table (needed for the filter query to join)
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post1', 'p1', 'Test Post', 'test', 'published', 1700000000000, 1700000000000)",
|
||||
[],
|
||||
).unwrap();
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post2', 'p1', 'Draft Post', 'draft-post', 'draft', 1700000000000, 1700000000000)",
|
||||
[],
|
||||
).unwrap();
|
||||
insert_test_post(
|
||||
&db,
|
||||
"post1",
|
||||
"test",
|
||||
"Test Post",
|
||||
PostStatus::Published,
|
||||
1700000000000,
|
||||
);
|
||||
insert_test_post(
|
||||
&db,
|
||||
"post2",
|
||||
"draft-post",
|
||||
"Draft Post",
|
||||
PostStatus::Draft,
|
||||
1700000000000,
|
||||
);
|
||||
|
||||
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"post1",
|
||||
"Test Post",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"post2",
|
||||
"Draft Post",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filters = PostSearchFilters {
|
||||
status: Some("published"),
|
||||
@@ -670,19 +960,47 @@ mod tests {
|
||||
// 2023-06-15 in unix ms
|
||||
let ts_2023: i64 = 1686873600000;
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
|
||||
rusqlite::params![ts_2024],
|
||||
).unwrap();
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
|
||||
rusqlite::params![ts_2023],
|
||||
).unwrap();
|
||||
insert_test_post(
|
||||
&db,
|
||||
"p2024",
|
||||
"y2024",
|
||||
"Year 2024",
|
||||
PostStatus::Draft,
|
||||
ts_2024,
|
||||
);
|
||||
insert_test_post(
|
||||
&db,
|
||||
"p2023",
|
||||
"y2023",
|
||||
"Year 2023",
|
||||
PostStatus::Draft,
|
||||
ts_2023,
|
||||
);
|
||||
|
||||
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p2024",
|
||||
"Year 2024",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p2023",
|
||||
"Year 2023",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filters = PostSearchFilters {
|
||||
year: Some(2024),
|
||||
@@ -702,12 +1020,26 @@ mod tests {
|
||||
for i in 0..5 {
|
||||
let id = format!("p{i}");
|
||||
let slug = format!("post-{i}");
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
|
||||
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
|
||||
).unwrap();
|
||||
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap();
|
||||
insert_test_post(
|
||||
&db,
|
||||
&id,
|
||||
&slug,
|
||||
"Searchable",
|
||||
PostStatus::Draft,
|
||||
1700000000000i64 - i as i64 * 1000,
|
||||
);
|
||||
index_post(
|
||||
db.conn(),
|
||||
&id,
|
||||
"Searchable",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let filters = PostSearchFilters {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
mod connection;
|
||||
pub mod from_row;
|
||||
pub mod fts;
|
||||
mod migrations;
|
||||
pub mod queries;
|
||||
pub mod schema;
|
||||
#[doc(hidden)]
|
||||
pub mod types;
|
||||
|
||||
pub use connection::Database;
|
||||
pub use connection::{Database, DatabaseError, DbConnection};
|
||||
pub use diesel::result::Error as DbQueryError;
|
||||
pub use migrations::run_migrations;
|
||||
|
||||
150
crates/bds-core/src/db/queries/chat.rs
Normal file
150
crates/bds-core/src/db/queries/chat.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::{chat_conversations, chat_messages};
|
||||
use crate::model::{ChatConversation, ChatMessage, NewChatConversation, NewChatMessage};
|
||||
|
||||
pub fn insert_conversation(
|
||||
conn: &DbConnection,
|
||||
conversation: &NewChatConversation<'_>,
|
||||
) -> QueryResult<ChatConversation> {
|
||||
conn.with(|connection| {
|
||||
diesel::insert_into(chat_conversations::table)
|
||||
.values(conversation)
|
||||
.execute(connection)?;
|
||||
chat_conversations::table
|
||||
.find(conversation.id)
|
||||
.select(ChatConversation::as_select())
|
||||
.first(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_conversation(conn: &DbConnection, id: &str) -> QueryResult<ChatConversation> {
|
||||
conn.with(|connection| {
|
||||
chat_conversations::table
|
||||
.find(id)
|
||||
.select(ChatConversation::as_select())
|
||||
.first(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_conversations(conn: &DbConnection) -> QueryResult<Vec<ChatConversation>> {
|
||||
conn.with(|connection| {
|
||||
chat_conversations::table
|
||||
.order((
|
||||
chat_conversations::updated_at.desc(),
|
||||
chat_conversations::id.desc(),
|
||||
))
|
||||
.select(ChatConversation::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rename_conversation(
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
title: &str,
|
||||
updated_at: i64,
|
||||
) -> QueryResult<ChatConversation> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(chat_conversations::table.find(id))
|
||||
.set((
|
||||
chat_conversations::title.eq(title),
|
||||
chat_conversations::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(connection)?;
|
||||
chat_conversations::table
|
||||
.find(id)
|
||||
.select(ChatConversation::as_select())
|
||||
.first(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_conversation_model(
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
model: &str,
|
||||
updated_at: i64,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(chat_conversations::table.find(id))
|
||||
.set((
|
||||
chat_conversations::model.eq(model),
|
||||
chat_conversations::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_session_id(
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
session_id: Option<&str>,
|
||||
updated_at: i64,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(chat_conversations::table.find(id))
|
||||
.set((
|
||||
chat_conversations::copilot_session_id.eq(session_id),
|
||||
chat_conversations::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_surface_state(
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
state: &str,
|
||||
updated_at: i64,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(chat_conversations::table.find(id))
|
||||
.set((
|
||||
chat_conversations::surface_state.eq(state),
|
||||
chat_conversations::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_conversation(conn: &DbConnection, id: &str) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
connection.transaction(|connection| {
|
||||
diesel::delete(chat_messages::table.filter(chat_messages::conversation_id.eq(id)))
|
||||
.execute(connection)?;
|
||||
diesel::delete(chat_conversations::table.find(id)).execute(connection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_message(
|
||||
conn: &DbConnection,
|
||||
message: &NewChatMessage<'_>,
|
||||
updated_at: i64,
|
||||
) -> QueryResult<ChatMessage> {
|
||||
conn.with(|connection| {
|
||||
connection.transaction(|connection| {
|
||||
diesel::insert_into(chat_messages::table)
|
||||
.values(message)
|
||||
.execute(connection)?;
|
||||
diesel::update(chat_conversations::table.find(message.conversation_id))
|
||||
.set(chat_conversations::updated_at.eq(updated_at))
|
||||
.execute(connection)?;
|
||||
chat_messages::table
|
||||
.order(chat_messages::id.desc())
|
||||
.select(ChatMessage::as_select())
|
||||
.first(connection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_messages(conn: &DbConnection, conversation_id: &str) -> QueryResult<Vec<ChatMessage>> {
|
||||
conn.with(|connection| {
|
||||
chat_messages::table
|
||||
.filter(chat_messages::conversation_id.eq(conversation_id))
|
||||
.order((chat_messages::created_at.asc(), chat_messages::id.asc()))
|
||||
.select(ChatMessage::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
98
crates/bds-core/src/db/queries/db_notification.rs
Normal file
98
crates/bds-core/src/db/queries/db_notification.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::db_notifications;
|
||||
use crate::model::{DbNotification, NotificationAction, NotificationEntity};
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "fields mirror the persisted notification record"
|
||||
)]
|
||||
pub fn insert_notification(
|
||||
conn: &DbConnection,
|
||||
entity_type: &NotificationEntity,
|
||||
entity_id: &str,
|
||||
action: &NotificationAction,
|
||||
from_cli: bool,
|
||||
seen_at: Option<i64>,
|
||||
created_at: i64,
|
||||
project_id: Option<&str>,
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|connection| {
|
||||
diesel::insert_into(db_notifications::table)
|
||||
.values((
|
||||
db_notifications::entity_type.eq(entity_type),
|
||||
db_notifications::entity_id.eq(entity_id),
|
||||
db_notifications::action.eq(action),
|
||||
db_notifications::from_cli.eq(i32::from(from_cli)),
|
||||
db_notifications::seen_at.eq(seen_at),
|
||||
db_notifications::created_at.eq(created_at),
|
||||
db_notifications::project_id.eq(project_id),
|
||||
))
|
||||
.execute(connection)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_notifications(conn: &DbConnection) -> QueryResult<Vec<DbNotification>> {
|
||||
conn.with(|connection| {
|
||||
db_notifications::table
|
||||
.order((
|
||||
db_notifications::created_at.asc(),
|
||||
db_notifications::id.asc(),
|
||||
))
|
||||
.select(DbNotification::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_unseen_cli_notifications(conn: &DbConnection) -> QueryResult<Vec<DbNotification>> {
|
||||
conn.with(|connection| {
|
||||
db_notifications::table
|
||||
.filter(db_notifications::from_cli.eq(1))
|
||||
.filter(db_notifications::seen_at.is_null())
|
||||
.order((
|
||||
db_notifications::created_at.asc(),
|
||||
db_notifications::id.asc(),
|
||||
))
|
||||
.select(DbNotification::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_notifications_seen(
|
||||
conn: &DbConnection,
|
||||
ids: &[i32],
|
||||
seen_at: i64,
|
||||
) -> QueryResult<usize> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
conn.with(|connection| {
|
||||
diesel::update(db_notifications::table.filter(db_notifications::id.eq_any(ids)))
|
||||
.set(db_notifications::seen_at.eq(seen_at))
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prune_processed(conn: &DbConnection, cutoff: i64) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::delete(
|
||||
db_notifications::table
|
||||
.filter(db_notifications::seen_at.is_not_null())
|
||||
.filter(db_notifications::created_at.le(cutoff)),
|
||||
)
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prune_unprocessed(conn: &DbConnection, cutoff: i64) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::delete(
|
||||
db_notifications::table
|
||||
.filter(db_notifications::seen_at.is_null())
|
||||
.filter(db_notifications::created_at.le(cutoff)),
|
||||
)
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
283
crates/bds-core/src/db/queries/embedding.rs
Normal file
283
crates/bds-core/src/db/queries/embedding.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::{dismissed_duplicate_pairs, embedding_keys};
|
||||
use crate::model::{DismissedDuplicatePair, EmbeddingKey};
|
||||
|
||||
pub fn get_key_for_post(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
) -> QueryResult<Option<EmbeddingKey>> {
|
||||
conn.with(|c| {
|
||||
embedding_keys::table
|
||||
.filter(embedding_keys::project_id.eq(project_id))
|
||||
.filter(embedding_keys::post_id.eq(post_id))
|
||||
.select(EmbeddingKey::as_select())
|
||||
.first(c)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_keys(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<EmbeddingKey>> {
|
||||
conn.with(|c| {
|
||||
embedding_keys::table
|
||||
.filter(embedding_keys::project_id.eq(project_id))
|
||||
.order(embedding_keys::label.asc())
|
||||
.select(EmbeddingKey::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn max_label(conn: &DbConnection) -> QueryResult<i64> {
|
||||
conn.with(|c| {
|
||||
embedding_keys::table
|
||||
.select(embedding_keys::label)
|
||||
.order(embedding_keys::label.desc())
|
||||
.first(c)
|
||||
.optional()
|
||||
.map(|label| label.unwrap_or(0))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_key(conn: &DbConnection, key: &EmbeddingKey) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(embedding_keys::table)
|
||||
.values(key)
|
||||
.on_conflict((embedding_keys::project_id, embedding_keys::post_id))
|
||||
.do_update()
|
||||
.set((
|
||||
embedding_keys::content_hash.eq(&key.content_hash),
|
||||
embedding_keys::vector.eq(&key.vector),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_key_for_post(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
embedding_keys::table
|
||||
.filter(embedding_keys::project_id.eq(project_id))
|
||||
.filter(embedding_keys::post_id.eq(post_id)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_stale_keys(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
live_post_ids: &[String],
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|c| {
|
||||
let query = embedding_keys::table.filter(embedding_keys::project_id.eq(project_id));
|
||||
if live_post_ids.is_empty() {
|
||||
diesel::delete(query).execute(c)
|
||||
} else {
|
||||
diesel::delete(query.filter(embedding_keys::post_id.ne_all(live_post_ids))).execute(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_dismissed_pair(
|
||||
conn: &DbConnection,
|
||||
pair: &DismissedDuplicatePair,
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(dismissed_duplicate_pairs::table)
|
||||
.values(pair)
|
||||
.on_conflict((
|
||||
dismissed_duplicate_pairs::project_id,
|
||||
dismissed_duplicate_pairs::post_id_a,
|
||||
dismissed_duplicate_pairs::post_id_b,
|
||||
))
|
||||
.do_nothing()
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_dismissed_pairs(
|
||||
conn: &DbConnection,
|
||||
pairs: &[DismissedDuplicatePair],
|
||||
) -> QueryResult<usize> {
|
||||
if pairs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(dismissed_duplicate_pairs::table)
|
||||
.values(pairs)
|
||||
.on_conflict((
|
||||
dismissed_duplicate_pairs::project_id,
|
||||
dismissed_duplicate_pairs::post_id_a,
|
||||
dismissed_duplicate_pairs::post_id_b,
|
||||
))
|
||||
.do_nothing()
|
||||
.execute(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_dismissed_pairs(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> QueryResult<Vec<DismissedDuplicatePair>> {
|
||||
conn.with(|c| {
|
||||
dismissed_duplicate_pairs::table
|
||||
.filter(dismissed_duplicate_pairs::project_id.eq(project_id))
|
||||
.select(DismissedDuplicatePair::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_orphan_dismissals(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
live_post_ids: &[String],
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|c| {
|
||||
let query = dismissed_duplicate_pairs::table
|
||||
.filter(dismissed_duplicate_pairs::project_id.eq(project_id));
|
||||
if live_post_ids.is_empty() {
|
||||
diesel::delete(query).execute(c)
|
||||
} else {
|
||||
diesel::delete(
|
||||
query.filter(
|
||||
dismissed_duplicate_pairs::post_id_a
|
||||
.ne_all(live_post_ids)
|
||||
.or(dismissed_duplicate_pairs::post_id_b.ne_all(live_post_ids)),
|
||||
),
|
||||
)
|
||||
.execute(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_dismissals_for_post(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
dismissed_duplicate_pairs::table
|
||||
.filter(dismissed_duplicate_pairs::project_id.eq(project_id))
|
||||
.filter(
|
||||
dismissed_duplicate_pairs::post_id_a
|
||||
.eq(post_id)
|
||||
.or(dismissed_duplicate_pairs::post_id_b.eq(post_id)),
|
||||
),
|
||||
)
|
||||
.execute(c)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::model::{Post, PostStatus, Project};
|
||||
|
||||
fn seeded() -> (Database, String, String) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let project_id = "embedding-project".to_string();
|
||||
crate::db::queries::project::insert_project(
|
||||
db.conn(),
|
||||
&Project {
|
||||
id: project_id.clone(),
|
||||
name: "Embedding".into(),
|
||||
slug: "embedding".into(),
|
||||
description: None,
|
||||
data_path: Some("/tmp/embedding".into()),
|
||||
is_active: true,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let post_id = "embedding-post".to_string();
|
||||
crate::db::queries::post::insert_post(
|
||||
db.conn(),
|
||||
&Post {
|
||||
id: post_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
title: "Post".into(),
|
||||
slug: "post".into(),
|
||||
excerpt: None,
|
||||
content: Some("Body".into()),
|
||||
status: PostStatus::Draft,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: "posts/post.md".into(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
(db, project_id, post_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_vector_round_trips_as_blob_and_dismissals_are_canonical() {
|
||||
let (db, project_id, post_id) = seeded();
|
||||
let key = EmbeddingKey {
|
||||
label: 1,
|
||||
post_id: post_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
content_hash: "hash".into(),
|
||||
vector: vec![0, 1, 255],
|
||||
};
|
||||
upsert_key(db.conn(), &key).unwrap();
|
||||
assert_eq!(
|
||||
get_key_for_post(db.conn(), &project_id, &post_id).unwrap(),
|
||||
Some(key)
|
||||
);
|
||||
|
||||
let replacement = EmbeddingKey {
|
||||
label: 2,
|
||||
post_id: post_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
content_hash: "new-hash".into(),
|
||||
vector: vec![3, 2, 1],
|
||||
};
|
||||
upsert_key(db.conn(), &replacement).unwrap();
|
||||
let updated = get_key_for_post(db.conn(), &project_id, &post_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.label, 1, "a post keeps its stable HNSW label");
|
||||
assert_eq!(updated.content_hash, "new-hash");
|
||||
assert_eq!(updated.vector, vec![3, 2, 1]);
|
||||
|
||||
let pair = DismissedDuplicatePair {
|
||||
id: "dismissal".into(),
|
||||
project_id: project_id.clone(),
|
||||
post_id_a: "a".into(),
|
||||
post_id_b: "b".into(),
|
||||
dismissed_at: 1,
|
||||
};
|
||||
insert_dismissed_pair(db.conn(), &pair).unwrap();
|
||||
insert_dismissed_pair(db.conn(), &pair).unwrap();
|
||||
assert_eq!(
|
||||
list_dismissed_pairs(db.conn(), &project_id).unwrap(),
|
||||
vec![pair]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,110 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::generated_file_hashes;
|
||||
use crate::model::GeneratedFileHash;
|
||||
|
||||
pub fn get_generated_file_hash(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
) -> rusqlite::Result<GeneratedFileHash> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2"
|
||||
),
|
||||
params![project_id, relative_path],
|
||||
generated_file_hash_from_row,
|
||||
) -> QueryResult<GeneratedFileHash> {
|
||||
conn.with(|c| {
|
||||
generated_file_hashes::table
|
||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||
.filter(generated_file_hashes::relative_path.eq(relative_path))
|
||||
.select(GeneratedFileHash::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_generated_file_hashes(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> QueryResult<Vec<GeneratedFileHash>> {
|
||||
conn.with(|c| {
|
||||
generated_file_hashes::table
|
||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||
.select(GeneratedFileHash::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_generated_file_hash(
|
||||
conn: &DbConnection,
|
||||
hash: &GeneratedFileHash,
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(generated_file_hashes::table)
|
||||
.values(hash.clone())
|
||||
.on_conflict((
|
||||
generated_file_hashes::project_id,
|
||||
generated_file_hashes::relative_path,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
generated_file_hashes::content_hash.eq(&hash.content_hash),
|
||||
generated_file_hashes::updated_at.eq(hash.updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_generated_file_hashes(
|
||||
conn: &DbConnection,
|
||||
hashes: &[GeneratedFileHash],
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
hashes.chunks(200).try_for_each(|chunk| {
|
||||
diesel::insert_into(generated_file_hashes::table)
|
||||
.values(chunk)
|
||||
.on_conflict((
|
||||
generated_file_hashes::project_id,
|
||||
generated_file_hashes::relative_path,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
generated_file_hashes::content_hash.eq(diesel::upsert::excluded(
|
||||
generated_file_hashes::content_hash,
|
||||
)),
|
||||
generated_file_hashes::updated_at
|
||||
.eq(diesel::upsert::excluded(generated_file_hashes::updated_at)),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn touch_generated_file_hashes(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
relative_paths: &[String],
|
||||
updated_at: i64,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|c| {
|
||||
relative_paths.chunks(500).try_fold(0, |updated, paths| {
|
||||
diesel::update(
|
||||
generated_file_hashes::table
|
||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||
.filter(generated_file_hashes::relative_path.eq_any(paths)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(project_id, relative_path)
|
||||
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
||||
params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_generated_file_hash(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2",
|
||||
params![project_id, relative_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_generated_file_hashes_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<GeneratedFileHash>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 ORDER BY relative_path"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], generated_file_hash_from_row)?;
|
||||
rows.collect()
|
||||
.set(generated_file_hashes::updated_at.eq(updated_at))
|
||||
.execute(c)
|
||||
.map(|count| updated + count)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
@@ -91,4 +137,36 @@ mod tests {
|
||||
assert_eq!(stored.content_hash, "def");
|
||||
assert_eq!(stored.updated_at, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_upsert_inserts_and_updates_hashes() {
|
||||
let db = setup();
|
||||
let mut hashes = vec![
|
||||
GeneratedFileHash {
|
||||
project_id: "p1".into(),
|
||||
relative_path: "index.html".into(),
|
||||
content_hash: "one".into(),
|
||||
updated_at: 1,
|
||||
},
|
||||
GeneratedFileHash {
|
||||
project_id: "p1".into(),
|
||||
relative_path: "rss.xml".into(),
|
||||
content_hash: "two".into(),
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
upsert_generated_file_hashes(db.conn(), &hashes).unwrap();
|
||||
hashes[0].content_hash = "changed".into();
|
||||
hashes[0].updated_at = 2;
|
||||
upsert_generated_file_hashes(db.conn(), &hashes[..1]).unwrap();
|
||||
|
||||
let stored = list_generated_file_hashes(db.conn(), "p1").unwrap();
|
||||
assert_eq!(stored.len(), 2);
|
||||
assert_eq!(
|
||||
get_generated_file_hash(db.conn(), "p1", "index.html")
|
||||
.unwrap()
|
||||
.content_hash,
|
||||
"changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
62
crates/bds-core/src/db/queries/import_definition.rs
Normal file
62
crates/bds-core/src/db/queries/import_definition.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::import_definitions;
|
||||
use crate::model::ImportDefinition;
|
||||
|
||||
pub fn insert_import_definition(
|
||||
conn: &DbConnection,
|
||||
definition: &ImportDefinition,
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|connection| {
|
||||
diesel::insert_into(import_definitions::table)
|
||||
.values(definition.clone())
|
||||
.execute(connection)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_import_definition(conn: &DbConnection, id: &str) -> QueryResult<ImportDefinition> {
|
||||
conn.with(|connection| {
|
||||
import_definitions::table
|
||||
.filter(import_definitions::id.eq(id))
|
||||
.select(ImportDefinition::as_select())
|
||||
.first(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_import_definitions(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> QueryResult<Vec<ImportDefinition>> {
|
||||
conn.with(|connection| {
|
||||
import_definitions::table
|
||||
.filter(import_definitions::project_id.eq(project_id))
|
||||
.order((
|
||||
import_definitions::updated_at.desc(),
|
||||
import_definitions::created_at.desc(),
|
||||
))
|
||||
.select(ImportDefinition::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_import_definition(
|
||||
conn: &DbConnection,
|
||||
definition: &ImportDefinition,
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(import_definitions::table.filter(import_definitions::id.eq(&definition.id)))
|
||||
.set(definition.clone())
|
||||
.execute(connection)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_import_definition(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|connection| {
|
||||
diesel::delete(import_definitions::table.filter(import_definitions::id.eq(id)))
|
||||
.execute(connection)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
185
crates/bds-core/src/db/queries/mcp_proposal.rs
Normal file
185
crates/bds-core/src/db/queries/mcp_proposal.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::mcp_proposals;
|
||||
use crate::model::{McpProposal, ProposalStatus};
|
||||
|
||||
pub fn insert_proposal(conn: &DbConnection, proposal: &McpProposal) -> QueryResult<()> {
|
||||
conn.with(|connection| {
|
||||
diesel::insert_into(mcp_proposals::table)
|
||||
.values(proposal)
|
||||
.execute(connection)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_proposal(conn: &DbConnection, id: &str) -> QueryResult<McpProposal> {
|
||||
conn.with(|connection| {
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.eq(id))
|
||||
.select(McpProposal::as_select())
|
||||
.first(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_proposals(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<McpProposal>> {
|
||||
conn.with(|connection| {
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::project_id.eq(project_id))
|
||||
.order(mcp_proposals::created_at.desc())
|
||||
.select(McpProposal::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_pending_proposals(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> QueryResult<Vec<McpProposal>> {
|
||||
conn.with(|connection| {
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::project_id.eq(project_id))
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
||||
.order(mcp_proposals::created_at.asc())
|
||||
.select(McpProposal::as_select())
|
||||
.load(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
let expiring = mcp_proposals::table
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
||||
.filter(mcp_proposals::expires_at.le(now))
|
||||
.select(McpProposal::as_select())
|
||||
.load::<McpProposal>(connection)?;
|
||||
for proposal in &expiring {
|
||||
diesel::delete(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.ne(&proposal.id))
|
||||
.filter(mcp_proposals::kind.eq(proposal.kind))
|
||||
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Expired)),
|
||||
)
|
||||
.execute(connection)?;
|
||||
}
|
||||
diesel::update(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
||||
.filter(mcp_proposals::expires_at.le(now)),
|
||||
)
|
||||
.set((
|
||||
mcp_proposals::status.eq(ProposalStatus::Expired),
|
||||
mcp_proposals::resolved_at.eq(Some(now)),
|
||||
mcp_proposals::result.eq(Some("{\"message\":\"expired\"}".to_string())),
|
||||
))
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_status_collision(
|
||||
conn: &DbConnection,
|
||||
proposal: &McpProposal,
|
||||
status: ProposalStatus,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::delete(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.ne(&proposal.id))
|
||||
.filter(mcp_proposals::kind.eq(proposal.kind))
|
||||
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
|
||||
.filter(mcp_proposals::status.eq(status)),
|
||||
)
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn claim_pending(conn: &DbConnection, id: &str, now: i64) -> QueryResult<bool> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.eq(id))
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
||||
.filter(mcp_proposals::expires_at.gt(now)),
|
||||
)
|
||||
.set(mcp_proposals::status.eq(ProposalStatus::Executing))
|
||||
.execute(connection)
|
||||
.map(|changed| changed == 1)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_claimed(
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
status: ProposalStatus,
|
||||
result: &str,
|
||||
resolved_at: i64,
|
||||
) -> QueryResult<bool> {
|
||||
debug_assert!(matches!(
|
||||
status,
|
||||
ProposalStatus::Accepted | ProposalStatus::Rejected
|
||||
));
|
||||
conn.with(|connection| {
|
||||
diesel::update(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.eq(id))
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Executing)),
|
||||
)
|
||||
.set((
|
||||
mcp_proposals::status.eq(status),
|
||||
mcp_proposals::result.eq(Some(result.to_string())),
|
||||
mcp_proposals::resolved_at.eq(Some(resolved_at)),
|
||||
))
|
||||
.execute(connection)
|
||||
.map(|changed| changed == 1)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::ProposalKind;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn proposal(id: &str, expires_at: i64) -> McpProposal {
|
||||
McpProposal {
|
||||
id: id.into(),
|
||||
project_id: "p1".into(),
|
||||
kind: ProposalKind::DraftPost,
|
||||
status: ProposalStatus::Pending,
|
||||
entity_id: id.into(),
|
||||
data: "{}".into(),
|
||||
result: None,
|
||||
created_at: 1,
|
||||
expires_at,
|
||||
resolved_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_claims_once_and_expires_pending_rows() {
|
||||
let db = setup();
|
||||
insert_proposal(db.conn(), &proposal("p1", 10)).unwrap();
|
||||
insert_proposal(db.conn(), &proposal("p2", 1)).unwrap();
|
||||
assert_eq!(expire_pending(db.conn(), 5).unwrap(), 1);
|
||||
assert!(claim_pending(db.conn(), "p1", 5).unwrap());
|
||||
assert!(!claim_pending(db.conn(), "p1", 5).unwrap());
|
||||
assert!(resolve_claimed(db.conn(), "p1", ProposalStatus::Accepted, "{}", 6).unwrap());
|
||||
assert_eq!(
|
||||
get_proposal(db.conn(), "p1").unwrap().status,
|
||||
ProposalStatus::Accepted
|
||||
);
|
||||
assert_eq!(
|
||||
get_proposal(db.conn(), "p2").unwrap().status,
|
||||
ProposalStatus::Expired
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,66 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::media;
|
||||
use crate::model::Media;
|
||||
use crate::util::calendar_range_unix_ms;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
||||
|
||||
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(media::table)
|
||||
.values(m.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media (
|
||||
id, project_id, filename, original_name, mime_type, size,
|
||||
width, height, title, alt, caption, author, language,
|
||||
file_path, sidecar_path, checksum, tags, created_at, updated_at
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6,
|
||||
?7, ?8, ?9, ?10, ?11, ?12, ?13,
|
||||
?14, ?15, ?16, ?17, ?18, ?19
|
||||
)",
|
||||
params![
|
||||
m.id,
|
||||
m.project_id,
|
||||
m.filename,
|
||||
m.original_name,
|
||||
m.mime_type,
|
||||
m.size,
|
||||
m.width,
|
||||
m.height,
|
||||
m.title,
|
||||
m.alt,
|
||||
m.caption,
|
||||
m.author,
|
||||
m.language,
|
||||
m.file_path,
|
||||
m.sidecar_path,
|
||||
m.checksum,
|
||||
tags_to_json(&m.tags),
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult<Media> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::id.eq(id))
|
||||
.select(Media::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_media_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Media> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {MEDIA_COLUMNS} FROM media WHERE id = ?1"),
|
||||
params![id],
|
||||
media_from_row,
|
||||
)
|
||||
pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Media>> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.order(media::created_at.desc())
|
||||
.select(Media::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], media_from_row)?;
|
||||
rows.collect()
|
||||
pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.count()
|
||||
.get_result(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_media_by_project_limited(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id, limit, offset], media_from_row)?;
|
||||
rows.collect()
|
||||
pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(media::table.filter(media::id.eq(&m.id)))
|
||||
.set(m.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM media WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE media SET
|
||||
filename = ?1, original_name = ?2, mime_type = ?3, size = ?4,
|
||||
width = ?5, height = ?6, title = ?7, alt = ?8, caption = ?9,
|
||||
author = ?10, language = ?11, file_path = ?12, sidecar_path = ?13,
|
||||
checksum = ?14, tags = ?15, updated_at = ?16
|
||||
WHERE id = ?17",
|
||||
params![
|
||||
m.filename,
|
||||
m.original_name,
|
||||
m.mime_type,
|
||||
m.size,
|
||||
m.width,
|
||||
m.height,
|
||||
m.title,
|
||||
m.alt,
|
||||
m.caption,
|
||||
m.author,
|
||||
m.language,
|
||||
m.file_path,
|
||||
m.sidecar_path,
|
||||
m.checksum,
|
||||
tags_to_json(&m.tags),
|
||||
m.updated_at,
|
||||
m.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_media(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM media WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_media(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(media::table.filter(media::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
// ── Filtered queries (per sidebar_views.allium MediaView) ───
|
||||
@@ -133,148 +80,94 @@ pub struct MediaFilterParams {
|
||||
|
||||
impl MediaFilterParams {
|
||||
pub fn has_active_filters(&self) -> bool {
|
||||
!self.search_query.is_empty()
|
||||
|| self.year.is_some()
|
||||
|| !self.tags.is_empty()
|
||||
!self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// List media with optional filters applied.
|
||||
pub fn list_media_filtered(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
filters: &MediaFilterParams,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut conditions = vec!["project_id = ?1".to_string()];
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
param_values.push(Box::new(project_id.to_string()));
|
||||
|
||||
) -> QueryResult<Vec<Media>> {
|
||||
conn.with(|c| {
|
||||
let mut query = media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.into_boxed();
|
||||
if !filters.search_query.is_empty() {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
||||
conditions.push(format!(
|
||||
"(COALESCE(title, original_name) LIKE ?{idx} ESCAPE '\\')"
|
||||
));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
if let Some(month) = filters.month {
|
||||
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let next_month = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
}
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(m_start));
|
||||
param_values.push(Box::new(next_month));
|
||||
} else {
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(start));
|
||||
param_values.push(Box::new(end));
|
||||
}
|
||||
}
|
||||
|
||||
for tag in &filters.tags {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
||||
conditions.push(format!("(tags LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
let idx_limit = param_values.len() + 1;
|
||||
let idx_offset = param_values.len() + 2;
|
||||
param_values.push(Box::new(limit));
|
||||
param_values.push(Box::new(offset));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE {where_clause} ORDER BY created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
||||
query = query.filter(
|
||||
media::title
|
||||
.is_not_null()
|
||||
.and(instr(media::title.assume_not_null(), &filters.search_query).gt(0))
|
||||
.or(media::title
|
||||
.is_null()
|
||||
.and(instr(media::original_name, &filters.search_query).gt(0))),
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), media_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||
})?;
|
||||
query = query.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
|
||||
}
|
||||
for tag in &filters.tags {
|
||||
query = query.filter(instr(media::tags, serde_json::to_string(tag).unwrap()).gt(0));
|
||||
}
|
||||
query
|
||||
.order(media::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(Media::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
/// Year/month counts for the media calendar archive widget.
|
||||
pub fn media_calendar_counts(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
||||
COUNT(*) AS cnt
|
||||
FROM media
|
||||
WHERE project_id = ?1
|
||||
GROUP BY y, m
|
||||
ORDER BY y DESC, m DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, usize>(2)?,
|
||||
))
|
||||
) -> QueryResult<Vec<(i32, u32, usize)>> {
|
||||
conn.with(|c| {
|
||||
let timestamps = media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.select(media::created_at)
|
||||
.load::<i64>(c)?;
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
for timestamp in timestamps {
|
||||
let date = Utc
|
||||
.timestamp_millis_opt(timestamp)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
diesel::result::Error::DeserializationError("invalid timestamp".into())
|
||||
})?;
|
||||
rows.collect()
|
||||
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), count)| (year, month, count))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across media for a project.
|
||||
pub fn distinct_media_tags(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
pub fn distinct_media_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||
let rows = conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.filter(media::tags.ne("[]"))
|
||||
.select(media::tags)
|
||||
.distinct()
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let mut all_tags = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_tags.into_iter().collect())
|
||||
}
|
||||
|
||||
@@ -307,11 +200,11 @@ pub fn make_test_media(id: &str, project_id: &str) -> Media {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,109 +1,90 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::media_translations;
|
||||
use crate::model::MediaTranslation;
|
||||
|
||||
pub fn insert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.translation_for,
|
||||
t.language,
|
||||
t.title,
|
||||
t.alt,
|
||||
t.caption,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(media_translations::table)
|
||||
.values(t.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_media_translation_by_media_and_language(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<MediaTranslation> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
||||
WHERE translation_for = ?1 AND language = ?2"
|
||||
),
|
||||
params![translation_for, language],
|
||||
media_translation_from_row,
|
||||
)
|
||||
) -> QueryResult<MediaTranslation> {
|
||||
conn.with(|c| {
|
||||
media_translations::table
|
||||
.filter(media_translations::translation_for.eq(translation_for))
|
||||
.filter(media_translations::language.eq(language))
|
||||
.select(MediaTranslation::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_media_translations_by_media(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<Vec<MediaTranslation>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
||||
WHERE translation_for = ?1 ORDER BY language"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![translation_for], media_translation_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<MediaTranslation>> {
|
||||
conn.with(|c| {
|
||||
media_translations::table
|
||||
.filter(media_translations::translation_for.eq(translation_for))
|
||||
.order(media_translations::language)
|
||||
.select(MediaTranslation::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(translation_for, language) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
alt = excluded.alt,
|
||||
caption = excluded.caption,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.translation_for,
|
||||
t.language,
|
||||
t.title,
|
||||
t.alt,
|
||||
t.caption,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(media_translations::table)
|
||||
.values(t.clone())
|
||||
.on_conflict((
|
||||
media_translations::translation_for,
|
||||
media_translations::language,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
media_translations::title.eq(&t.title),
|
||||
media_translations::alt.eq(&t.alt),
|
||||
media_translations::caption.eq(&t.caption),
|
||||
media_translations::updated_at.eq(t.updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_media_translation(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM media_translations WHERE translation_for = ?1 AND language = ?2",
|
||||
params![translation_for, language],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
media_translations::table
|
||||
.filter(media_translations::translation_for.eq(translation_for))
|
||||
.filter(media_translations::language.eq(language)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
@@ -164,8 +145,6 @@ mod tests {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
delete_media_translation(db.conn(), "m1", "de").unwrap();
|
||||
assert!(
|
||||
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
|
||||
);
|
||||
assert!(get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
pub mod project;
|
||||
pub mod post;
|
||||
pub mod post_translation;
|
||||
pub mod chat;
|
||||
pub mod db_notification;
|
||||
pub mod embedding;
|
||||
pub mod generated_file_hash;
|
||||
pub mod import_definition;
|
||||
pub mod mcp_proposal;
|
||||
pub mod media;
|
||||
pub mod media_translation;
|
||||
pub mod tag;
|
||||
pub mod post;
|
||||
pub mod post_link;
|
||||
pub mod post_media;
|
||||
pub mod template;
|
||||
pub mod post_translation;
|
||||
pub mod project;
|
||||
pub mod script;
|
||||
pub mod setting;
|
||||
pub mod generated_file_hash;
|
||||
pub mod tag;
|
||||
pub mod template;
|
||||
|
||||
@@ -1,203 +1,99 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
use diesel::sqlite::Sqlite;
|
||||
|
||||
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::posts;
|
||||
use crate::model::{Post, PostStatus};
|
||||
use crate::util::calendar_range_unix_ms;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
||||
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||
|
||||
pub fn insert_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(posts::table)
|
||||
.values(post.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO posts (
|
||||
id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
tags, categories,
|
||||
published_title, published_content, published_tags,
|
||||
published_categories, published_excerpt,
|
||||
created_at, updated_at, published_at
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
||||
?9, ?10, ?11, ?12, ?13,
|
||||
?14, ?15,
|
||||
?16, ?17, ?18, ?19, ?20,
|
||||
?21, ?22, ?23
|
||||
)",
|
||||
params![
|
||||
post.id,
|
||||
post.project_id,
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.content,
|
||||
post_status_to_str(&post.status),
|
||||
post.author,
|
||||
post.language,
|
||||
post.do_not_translate as i64,
|
||||
post.template_slug,
|
||||
post.file_path,
|
||||
post.checksum,
|
||||
tags_to_json(&post.tags),
|
||||
tags_to_json(&post.categories),
|
||||
post.published_title,
|
||||
post.published_content,
|
||||
post.published_tags,
|
||||
post.published_categories,
|
||||
post.published_excerpt,
|
||||
post.created_at,
|
||||
post.updated_at,
|
||||
post.published_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_post_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Post> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = ?1"),
|
||||
params![id],
|
||||
post_from_row,
|
||||
)
|
||||
pub fn get_post_by_id(conn: &DbConnection, id: &str) -> QueryResult<Post> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::id.eq(id))
|
||||
.select(Post::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_post_by_project_and_slug(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Post> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 AND slug = ?2"),
|
||||
params![project_id, slug],
|
||||
post_from_row,
|
||||
)
|
||||
) -> QueryResult<Post> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::slug.eq(slug))
|
||||
.select(Post::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Post>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], post_from_row)?;
|
||||
rows.collect()
|
||||
pub fn list_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Post>> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.order(posts::created_at.desc())
|
||||
.select(Post::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_posts_by_project_limited(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Post>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id, limit, offset], post_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET
|
||||
title = ?1, slug = ?2, excerpt = ?3, content = ?4, status = ?5,
|
||||
author = ?6, language = ?7, do_not_translate = ?8, template_slug = ?9,
|
||||
file_path = ?10, checksum = ?11, tags = ?12, categories = ?13,
|
||||
published_title = ?14, published_content = ?15, published_tags = ?16,
|
||||
published_categories = ?17, published_excerpt = ?18,
|
||||
created_at = ?19, updated_at = ?20, published_at = ?21
|
||||
WHERE id = ?22",
|
||||
params![
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.content,
|
||||
post_status_to_str(&post.status),
|
||||
post.author,
|
||||
post.language,
|
||||
post.do_not_translate as i64,
|
||||
post.template_slug,
|
||||
post.file_path,
|
||||
post.checksum,
|
||||
tags_to_json(&post.tags),
|
||||
tags_to_json(&post.categories),
|
||||
post.published_title,
|
||||
post.published_content,
|
||||
post.published_tags,
|
||||
post.published_categories,
|
||||
post.published_excerpt,
|
||||
post.created_at,
|
||||
post.updated_at,
|
||||
post.published_at,
|
||||
post.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(&post.id)))
|
||||
.set(post.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_post_status(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
status: &PostStatus,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![post_status_to_str(status), updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||
.set((
|
||||
posts::status.eq(status.as_str()),
|
||||
posts::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_post_content(conn: &Connection, id: &str, updated_at: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET content = NULL, updated_at = ?1 WHERE id = ?2",
|
||||
params![updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn delete_post(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(posts::table.filter(posts::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_post_file_path(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
file_path: &str,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET file_path = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![file_path, updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_published_snapshot(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
title: &str,
|
||||
content: &str,
|
||||
tags: &str,
|
||||
categories: &str,
|
||||
excerpt: Option<&str>,
|
||||
published_at: i64,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET
|
||||
published_title = ?1, published_content = ?2, published_tags = ?3,
|
||||
published_categories = ?4, published_excerpt = ?5,
|
||||
published_at = ?6, updated_at = ?7
|
||||
WHERE id = ?8",
|
||||
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_post(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM posts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
pub fn count_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.count()
|
||||
.get_result(c)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Filtered queries (per sidebar_views.allium PostsView) ───
|
||||
@@ -252,240 +148,217 @@ impl PostFilterParams {
|
||||
///
|
||||
/// Returns all matching posts (up to `limit`), ordered by created_at DESC.
|
||||
/// Caller splits into draft/published/archived sections.
|
||||
fn post_query<'a>(
|
||||
project_id: &'a str,
|
||||
filters: &'a PostFilterParams,
|
||||
apply_filters: bool,
|
||||
exclude_drafts: bool,
|
||||
) -> posts::BoxedQuery<'a, Sqlite> {
|
||||
let mut query = posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.into_boxed();
|
||||
|
||||
let page = serde_json::to_string("page").unwrap();
|
||||
if filters.pages_only {
|
||||
query = query.filter(instr(lower(posts::categories), page.clone()).gt(0));
|
||||
} else if filters.exclude_pages {
|
||||
query = query.filter(instr(lower(posts::categories), page).eq(0));
|
||||
}
|
||||
|
||||
if exclude_drafts {
|
||||
query = query.filter(posts::status.ne("draft"));
|
||||
}
|
||||
if !apply_filters {
|
||||
return query;
|
||||
}
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
query = query.filter(instr(posts::title, &filters.search_query).gt(0));
|
||||
}
|
||||
if let Some(status) = &filters.status {
|
||||
query = query.filter(posts::status.eq(status));
|
||||
}
|
||||
if let Some(language) = &filters.language {
|
||||
query = query.filter(posts::language.eq(language).or(posts::language.is_null()));
|
||||
}
|
||||
if let Some(year) = filters.year
|
||||
&& let Some((start, end)) = calendar_range_unix_ms(year, filters.month)
|
||||
{
|
||||
query = query.filter(posts::created_at.ge(start).and(posts::created_at.lt(end)));
|
||||
}
|
||||
for tag in &filters.tags {
|
||||
query = query.filter(instr(posts::tags, serde_json::to_string(tag).unwrap()).gt(0));
|
||||
}
|
||||
for category in &filters.categories {
|
||||
query =
|
||||
query.filter(instr(posts::categories, serde_json::to_string(category).unwrap()).gt(0));
|
||||
}
|
||||
if let Some(from) = filters.from {
|
||||
query = query.filter(posts::created_at.ge(from));
|
||||
}
|
||||
if let Some(to) = filters.to {
|
||||
query = query.filter(posts::created_at.le(to));
|
||||
}
|
||||
query
|
||||
}
|
||||
|
||||
pub fn list_posts_filtered(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
filters: &PostFilterParams,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Post>> {
|
||||
// Build dynamic WHERE clause
|
||||
let mut conditions = vec!["p.project_id = ?1".to_string()];
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
param_values.push(Box::new(project_id.to_string()));
|
||||
|
||||
// pages_only / exclude_pages
|
||||
if filters.pages_only {
|
||||
conditions.push("LOWER(p.categories) LIKE '%\"page\"%'".to_string());
|
||||
} else if filters.exclude_pages {
|
||||
conditions.push("LOWER(p.categories) NOT LIKE '%\"page\"%'".to_string());
|
||||
}
|
||||
|
||||
// For non-draft posts, apply filters. Drafts always pass.
|
||||
// We build this as: (status = 'draft') OR (filter conditions)
|
||||
let mut filter_conditions: Vec<String> = Vec::new();
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
||||
filter_conditions.push(format!("(p.title LIKE ?{idx} ESCAPE '\\')"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(status) = &filters.status {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.status = ?{idx})"));
|
||||
param_values.push(Box::new(status.clone()));
|
||||
}
|
||||
|
||||
if let Some(language) = &filters.language {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.language = ?{idx} OR p.language IS NULL)"));
|
||||
param_values.push(Box::new(language.clone()));
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
// created_at is unix ms; compute year range
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
if let Some(month) = filters.month {
|
||||
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let next_month = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
}
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(m_start));
|
||||
param_values.push(Box::new(next_month));
|
||||
} else {
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(start));
|
||||
param_values.push(Box::new(end));
|
||||
}
|
||||
}
|
||||
|
||||
for tag in &filters.tags {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
||||
filter_conditions.push(format!("(p.tags LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
for cat in &filters.categories {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", cat.replace('"', "\\\""));
|
||||
filter_conditions.push(format!("(p.categories LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(from) = filters.from {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.created_at >= ?{idx})"));
|
||||
param_values.push(Box::new(from));
|
||||
}
|
||||
|
||||
if let Some(to) = filters.to {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.created_at <= ?{idx})"));
|
||||
param_values.push(Box::new(to));
|
||||
}
|
||||
|
||||
// Without an explicit status filter, drafts remain visible regardless of the
|
||||
// rest of the active filters.
|
||||
if !filter_conditions.is_empty() {
|
||||
let combined = filter_conditions.join(" AND ");
|
||||
if filters.status.is_some() {
|
||||
conditions.push(format!("({combined})"));
|
||||
} else {
|
||||
conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
|
||||
}
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
let idx_limit = param_values.len() + 1;
|
||||
let idx_offset = param_values.len() + 2;
|
||||
param_values.push(Box::new(limit));
|
||||
param_values.push(Box::new(offset));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts p WHERE {where_clause} ORDER BY p.created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
||||
) -> QueryResult<Vec<Post>> {
|
||||
conn.with(|c| {
|
||||
let content_filters = filters.has_active_filters();
|
||||
let records = if filters.status.is_some() {
|
||||
post_query(project_id, filters, true, false)
|
||||
.order(posts::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(Post::as_select())
|
||||
.load(c)?
|
||||
} else if content_filters {
|
||||
let mut records: Vec<Post> = post_query(project_id, filters, false, false)
|
||||
.filter(posts::status.eq("draft"))
|
||||
.select(Post::as_select())
|
||||
.load(c)?;
|
||||
records.extend(
|
||||
post_query(project_id, filters, true, true)
|
||||
.select(Post::as_select())
|
||||
.load::<Post>(c)?,
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), post_from_row)?;
|
||||
rows.collect()
|
||||
records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at));
|
||||
records
|
||||
.into_iter()
|
||||
.skip(offset.max(0) as usize)
|
||||
.take(limit.max(0) as usize)
|
||||
.collect()
|
||||
} else {
|
||||
post_query(project_id, filters, false, false)
|
||||
.order(posts::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(Post::as_select())
|
||||
.load(c)?
|
||||
};
|
||||
Ok(records)
|
||||
})
|
||||
}
|
||||
|
||||
/// Year/month counts for the calendar archive widget.
|
||||
/// Returns (year, month, count) tuples, ordered by year DESC, month DESC.
|
||||
pub fn post_calendar_counts(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
pages_only: bool,
|
||||
exclude_pages: bool,
|
||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
||||
let page_filter = if pages_only {
|
||||
" AND LOWER(categories) LIKE '%\"page\"%'"
|
||||
} else if exclude_pages {
|
||||
" AND LOWER(categories) NOT LIKE '%\"page\"%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
||||
COUNT(*) AS cnt
|
||||
FROM posts
|
||||
WHERE project_id = ?1{page_filter}
|
||||
GROUP BY y, m
|
||||
ORDER BY y DESC, m DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, usize>(2)?,
|
||||
))
|
||||
) -> QueryResult<Vec<(i32, u32, usize)>> {
|
||||
conn.with(|c| {
|
||||
let rows = posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.select((posts::created_at, posts::categories))
|
||||
.load::<(i64, String)>(c)?;
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
for (timestamp, categories) in rows {
|
||||
let categories: Vec<String> = serde_json::from_str(&categories).unwrap_or_default();
|
||||
let is_page = categories
|
||||
.iter()
|
||||
.any(|category| category.eq_ignore_ascii_case("page"));
|
||||
if (pages_only && !is_page) || (exclude_pages && is_page) {
|
||||
continue;
|
||||
}
|
||||
let date = Utc
|
||||
.timestamp_millis_opt(timestamp)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
diesel::result::Error::DeserializationError("invalid timestamp".into())
|
||||
})?;
|
||||
rows.collect()
|
||||
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), count)| (year, month, count))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across posts for a project.
|
||||
pub fn distinct_post_tags(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
pub fn distinct_post_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||
let rows = conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::tags.ne("[]"))
|
||||
.select(posts::tags)
|
||||
.distinct()
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let mut all_tags = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_tags.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Collect all distinct category values across posts for a project.
|
||||
pub fn distinct_post_categories(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
pub fn distinct_post_categories(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||
let rows = conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::categories.ne("[]"))
|
||||
.select(posts::categories)
|
||||
.distinct()
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let mut all_cats = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_cats.extend(cats);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_cats.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn make_test_post(id: &str, project_id: &str, slug: &str) -> Post {
|
||||
Post {
|
||||
id: id.into(),
|
||||
project_id: project_id.into(),
|
||||
title: id.into(),
|
||||
slug: slug.into(),
|
||||
excerpt: None,
|
||||
content: None,
|
||||
status: PostStatus::Draft,
|
||||
author: None,
|
||||
language: None,
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: Vec::new(),
|
||||
categories: Vec::new(),
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
@@ -581,35 +454,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_content_sets_null() {
|
||||
fn malformed_persisted_types_fail_deserialization() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
clear_post_content(db.conn(), "x1", 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
db.conn()
|
||||
.with(|connection| {
|
||||
diesel::update(posts::table.filter(posts::id.eq("x1")))
|
||||
.set(posts::status.eq("unknown"))
|
||||
.execute(connection)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||
|
||||
#[test]
|
||||
fn set_file_path_updates() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_post_file_path(db.conn(), "x1", "posts/2024/01/hello.md", 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.file_path, "posts/2024/01/hello.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_snapshot() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_published_snapshot(
|
||||
db.conn(), "x1", "Pub Title", "Pub Body",
|
||||
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
|
||||
).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
||||
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
||||
assert_eq!(fetched.published_at, Some(3000));
|
||||
db.conn()
|
||||
.with(|connection| {
|
||||
diesel::update(posts::table.filter(posts::id.eq("x1")))
|
||||
.set((posts::status.eq("draft"), posts::tags.eq("not-json")))
|
||||
.execute(connection)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::{post_links, posts};
|
||||
use crate::model::PostLink;
|
||||
|
||||
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
link.id,
|
||||
link.source_post_id,
|
||||
link.target_post_id,
|
||||
link.link_text,
|
||||
link.created_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(post_links::table)
|
||||
.values(link.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_links_by_source(conn: &Connection, source_post_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_links WHERE source_post_id = ?1",
|
||||
params![source_post_id],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn delete_links_by_source(conn: &DbConnection, source_post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_links::table.filter(post_links::source_post_id.eq(source_post_id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_links_by_target(conn: &DbConnection, target_post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_links::table.filter(post_links::target_post_id.eq(target_post_id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_links_by_source(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
source_post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostLink>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE source_post_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![source_post_id], post_link_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<PostLink>> {
|
||||
conn.with(|c| {
|
||||
post_links::table
|
||||
.filter(post_links::source_post_id.eq(source_post_id))
|
||||
.order(post_links::created_at)
|
||||
.select(PostLink::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_links_by_target(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
target_post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostLink>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE target_post_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![target_post_id], post_link_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<PostLink>> {
|
||||
conn.with(|c| {
|
||||
post_links::table
|
||||
.filter(post_links::target_post_id.eq(target_post_id))
|
||||
.order(post_links::created_at)
|
||||
.select(PostLink::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_links_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<PostLink>> {
|
||||
conn.with(|c| {
|
||||
post_links::table
|
||||
.inner_join(posts::table.on(posts::id.eq(post_links::source_post_id)))
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.order(post_links::created_at)
|
||||
.select(PostLink::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("a", "p1", "a")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("b", "p1", "b")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("c", "p1", "c")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
@@ -105,6 +111,14 @@ mod tests {
|
||||
assert_eq!(links.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "b", "c")).unwrap();
|
||||
assert_eq!(list_links_by_project(db.conn(), "p1").unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_by_source() {
|
||||
let db = setup();
|
||||
|
||||
@@ -1,85 +1,109 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::post_media;
|
||||
use crate::model::PostMedia;
|
||||
|
||||
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
pm.id,
|
||||
pm.project_id,
|
||||
pm.post_id,
|
||||
pm.media_id,
|
||||
pm.sort_order,
|
||||
pm.created_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(post_media::table)
|
||||
.values(pm.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unlink_media(conn: &Connection, post_id: &str, media_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_media WHERE post_id = ?1 AND media_id = ?2",
|
||||
params![post_id, media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn unlink_media(conn: &DbConnection, post_id: &str, media_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
post_media::table
|
||||
.filter(post_media::post_id.eq(post_id))
|
||||
.filter(post_media::media_id.eq(media_id)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE post_id = ?1 ORDER BY sort_order"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![post_id], post_media_from_row)?;
|
||||
rows.collect()
|
||||
pub fn delete_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_media::table.filter(post_media::post_id.eq(post_id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResult<Vec<PostMedia>> {
|
||||
conn.with(|c| {
|
||||
post_media::table
|
||||
.filter(post_media::post_id.eq(post_id))
|
||||
.order((post_media::sort_order.asc(), post_media::media_id.asc()))
|
||||
.select(PostMedia::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_media(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
media_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE media_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![media_id], post_media_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<PostMedia>> {
|
||||
conn.with(|c| {
|
||||
post_media::table
|
||||
.filter(post_media::media_id.eq(media_id))
|
||||
.order(post_media::created_at)
|
||||
.select(PostMedia::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_project(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> QueryResult<Vec<PostMedia>> {
|
||||
conn.with(|c| {
|
||||
post_media::table
|
||||
.filter(post_media::project_id.eq(project_id))
|
||||
.order((
|
||||
post_media::post_id.asc(),
|
||||
post_media::sort_order.asc(),
|
||||
post_media::media_id.asc(),
|
||||
))
|
||||
.select(PostMedia::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_sort_order(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
post_id: &str,
|
||||
media_id: &str,
|
||||
sort_order: i32,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE post_media SET sort_order = ?1 WHERE post_id = ?2 AND media_id = ?3",
|
||||
params![sort_order, post_id, media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(
|
||||
post_media::table
|
||||
.filter(post_media::post_id.eq(post_id))
|
||||
.filter(post_media::media_id.eq(media_id)),
|
||||
)
|
||||
.set(post_media::sort_order.eq(sort_order))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(db.conn(), &make_test_post("post1", "p1", "hello")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
|
||||
db
|
||||
@@ -116,6 +140,20 @@ mod tests {
|
||||
assert_eq!(list[0].post_id, "post1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project_preserves_post_order() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 1)).unwrap();
|
||||
link_media(db.conn(), &make_pm("pm2", "m2", 0)).unwrap();
|
||||
let list = list_post_media_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(
|
||||
list.iter()
|
||||
.map(|link| link.media_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["m2", "m1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_removes_association() {
|
||||
let db = setup();
|
||||
|
||||
@@ -1,146 +1,123 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::post_translations;
|
||||
use crate::model::PostTranslation;
|
||||
|
||||
pub fn insert_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
pub fn insert_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||
if !t.status.is_valid_for_translation() {
|
||||
return Err(rusqlite::Error::InvalidParameterName(
|
||||
"translation status must be draft or published".to_string(),
|
||||
return Err(diesel::result::Error::SerializationError(
|
||||
"translation status must be draft or published".into(),
|
||||
));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO post_translations (
|
||||
id, project_id, translation_for, language, title, excerpt, content,
|
||||
status, file_path, checksum, created_at, updated_at, published_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.translation_for,
|
||||
t.language,
|
||||
t.title,
|
||||
t.excerpt,
|
||||
t.content,
|
||||
post_status_to_str(&t.status),
|
||||
t.file_path,
|
||||
t.checksum,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.published_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(post_translations::table)
|
||||
.values(t.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_post_translation_by_id(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> rusqlite::Result<PostTranslation> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations WHERE id = ?1"),
|
||||
params![id],
|
||||
post_translation_from_row,
|
||||
)
|
||||
pub fn get_post_translation_by_id(conn: &DbConnection, id: &str) -> QueryResult<PostTranslation> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::id.eq(id))
|
||||
.select(PostTranslation::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_post_translation_by_post_and_language(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<PostTranslation> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
||||
WHERE translation_for = ?1 AND language = ?2"
|
||||
),
|
||||
params![translation_for, language],
|
||||
post_translation_from_row,
|
||||
)
|
||||
) -> QueryResult<PostTranslation> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::translation_for.eq(translation_for))
|
||||
.filter(post_translations::language.eq(language))
|
||||
.select(PostTranslation::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_translations_by_post(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<Vec<PostTranslation>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
||||
WHERE translation_for = ?1 ORDER BY language"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![translation_for], post_translation_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<PostTranslation>> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::translation_for.eq(translation_for))
|
||||
.order(post_translations::language)
|
||||
.select(PostTranslation::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
pub fn list_post_translations_by_project(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> QueryResult<Vec<PostTranslation>> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::project_id.eq(project_id))
|
||||
.order((
|
||||
post_translations::translation_for,
|
||||
post_translations::language,
|
||||
))
|
||||
.select(PostTranslation::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||
if !t.status.is_valid_for_translation() {
|
||||
return Err(rusqlite::Error::InvalidParameterName(
|
||||
"translation status must be draft or published".to_string(),
|
||||
return Err(diesel::result::Error::SerializationError(
|
||||
"translation status must be draft or published".into(),
|
||||
));
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE post_translations SET
|
||||
title = ?1, excerpt = ?2, content = ?3, status = ?4,
|
||||
file_path = ?5, checksum = ?6, updated_at = ?7, published_at = ?8
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
t.title,
|
||||
t.excerpt,
|
||||
t.content,
|
||||
post_status_to_str(&t.status),
|
||||
t.file_path,
|
||||
t.checksum,
|
||||
t.updated_at,
|
||||
t.published_at,
|
||||
t.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
conn.with(|c| {
|
||||
diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id)))
|
||||
.set(t.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn delete_post_translation(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_translations::table.filter(post_translations::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_all_translations_for_post(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE translation_for = ?1",
|
||||
params![translation_for],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
post_translations::table.filter(post_translations::translation_for.eq(translation_for)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
// insert a parent post
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(db.conn(), &make_test_post("post1", "p1", "hello")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
@@ -190,6 +167,15 @@ mod tests {
|
||||
assert_eq!(list[1].language, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
|
||||
let list = list_post_translations_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_translation() {
|
||||
let db = setup();
|
||||
|
||||
@@ -1,90 +1,83 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::projects;
|
||||
use crate::model::Project;
|
||||
|
||||
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
project.id,
|
||||
project.name,
|
||||
project.slug,
|
||||
project.description,
|
||||
project.data_path,
|
||||
project.is_active as i64,
|
||||
project.created_at,
|
||||
project.updated_at,
|
||||
],
|
||||
)?;
|
||||
pub fn insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(projects::table)
|
||||
.values(project.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project_by_id(conn: &DbConnection, id: &str) -> QueryResult<Project> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.filter(projects::id.eq(id))
|
||||
.select(Project::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project_by_slug(conn: &DbConnection, slug: &str) -> QueryResult<Project> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.filter(projects::slug.eq(slug))
|
||||
.select(Project::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_active_project(conn: &DbConnection) -> QueryResult<Project> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.filter(projects::is_active.eq(1))
|
||||
.select(Project::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_active_project(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.transaction(|c| {
|
||||
diesel::update(projects::table.filter(projects::is_active.eq(1)))
|
||||
.set(projects::is_active.eq(0))
|
||||
.execute(c)?;
|
||||
diesel::update(projects::table.filter(projects::id.eq(id)))
|
||||
.set(projects::is_active.eq(1))
|
||||
.execute(c)?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"),
|
||||
params![id],
|
||||
project_from_row,
|
||||
)
|
||||
pub fn list_projects(conn: &DbConnection) -> QueryResult<Vec<Project>> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.order(projects::name)
|
||||
.select(Project::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project_by_slug(conn: &Connection, slug: &str) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE slug = ?1"),
|
||||
params![slug],
|
||||
project_from_row,
|
||||
)
|
||||
pub fn update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(projects::table.filter(projects::id.eq(&project.id)))
|
||||
.set(project.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_active_project(conn: &Connection) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE is_active = 1 LIMIT 1"),
|
||||
[],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
||||
tx.execute(
|
||||
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_projects(conn: &Connection) -> rusqlite::Result<Vec<Project>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {PROJECT_COLUMNS} FROM projects ORDER BY name"
|
||||
))?;
|
||||
let rows = stmt.query_map([], project_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, slug = ?2, description = ?3, data_path = ?4,
|
||||
is_active = ?5, updated_at = ?6
|
||||
WHERE id = ?7",
|
||||
params![
|
||||
project.name,
|
||||
project.slug,
|
||||
project.description,
|
||||
project.data_path,
|
||||
project.is_active as i64,
|
||||
project.updated_at,
|
||||
project.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_project(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(projects::table.filter(projects::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Project value (available to sibling test modules).
|
||||
@@ -108,7 +101,7 @@ mod tests {
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
@@ -1,105 +1,77 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{
|
||||
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS,
|
||||
};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::scripts;
|
||||
use crate::model::Script;
|
||||
|
||||
pub fn insert_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO scripts (
|
||||
id, project_id, slug, title, kind, entrypoint, enabled, version,
|
||||
file_path, status, content, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
s.id,
|
||||
s.project_id,
|
||||
s.slug,
|
||||
s.title,
|
||||
script_kind_to_str(&s.kind),
|
||||
s.entrypoint,
|
||||
s.enabled as i64,
|
||||
s.version,
|
||||
s.file_path,
|
||||
script_status_to_str(&s.status),
|
||||
s.content,
|
||||
s.created_at,
|
||||
s.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(scripts::table)
|
||||
.values(s.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_script_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Script> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE id = ?1"),
|
||||
params![id],
|
||||
script_from_row,
|
||||
)
|
||||
pub fn get_script_by_id(conn: &DbConnection, id: &str) -> QueryResult<Script> {
|
||||
conn.with(|c| {
|
||||
scripts::table
|
||||
.filter(scripts::id.eq(id))
|
||||
.select(Script::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_script_by_slug(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Script> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
params![project_id, slug],
|
||||
script_from_row,
|
||||
)
|
||||
) -> QueryResult<Script> {
|
||||
conn.with(|c| {
|
||||
scripts::table
|
||||
.filter(scripts::project_id.eq(project_id))
|
||||
.filter(scripts::slug.eq(slug))
|
||||
.select(Script::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_scripts_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<Script>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 ORDER BY title"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], script_from_row)?;
|
||||
rows.collect()
|
||||
pub fn list_scripts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Script>> {
|
||||
conn.with(|c| {
|
||||
scripts::table
|
||||
.filter(scripts::project_id.eq(project_id))
|
||||
.order(scripts::title)
|
||||
.select(Script::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE scripts SET
|
||||
slug = ?1, title = ?2, kind = ?3, entrypoint = ?4, enabled = ?5,
|
||||
version = ?6, file_path = ?7, status = ?8, content = ?9, updated_at = ?10
|
||||
WHERE id = ?11",
|
||||
params![
|
||||
s.slug,
|
||||
s.title,
|
||||
script_kind_to_str(&s.kind),
|
||||
s.entrypoint,
|
||||
s.enabled as i64,
|
||||
s.version,
|
||||
s.file_path,
|
||||
script_status_to_str(&s.status),
|
||||
s.content,
|
||||
s.updated_at,
|
||||
s.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn update_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(scripts::table.filter(scripts::id.eq(&s.id)))
|
||||
.set(s.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM scripts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_script(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(scripts::table.filter(scripts::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::{ScriptKind, ScriptStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,36 +1,55 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::settings;
|
||||
use crate::model::Setting;
|
||||
|
||||
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {SETTING_COLUMNS} FROM settings WHERE key = ?1"),
|
||||
params![key],
|
||||
setting_from_row,
|
||||
)
|
||||
pub fn get_setting_by_key(conn: &DbConnection, key: &str) -> QueryResult<Setting> {
|
||||
conn.with(|c| {
|
||||
settings::table
|
||||
.filter(settings::key.eq(key))
|
||||
.select(Setting::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_setting_value(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
key: &str,
|
||||
value: &str,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
params![key, value, updated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(settings::table)
|
||||
.values((
|
||||
settings::key.eq(key),
|
||||
settings::value.eq(value),
|
||||
settings::updated_at.eq(updated_at),
|
||||
))
|
||||
.on_conflict(settings::key)
|
||||
.do_update()
|
||||
.set((
|
||||
settings::value.eq(value),
|
||||
settings::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_all_settings(conn: &Connection) -> rusqlite::Result<Vec<Setting>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SETTING_COLUMNS} FROM settings ORDER BY key"
|
||||
))?;
|
||||
let rows = stmt.query_map([], setting_from_row)?;
|
||||
rows.collect()
|
||||
pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
|
||||
conn.with(|c| {
|
||||
settings::table
|
||||
.order(settings::key)
|
||||
.select(Setting::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_settings_by_prefix(conn: &DbConnection, prefix: &str) -> QueryResult<usize> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(settings::table.filter(settings::key.like(format!("{prefix}%")))).execute(c)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -39,7 +58,7 @@ mod tests {
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
@@ -80,4 +99,18 @@ mod tests {
|
||||
let db = setup();
|
||||
assert!(get_setting_by_key(db.conn(), "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_by_prefix_is_project_scoped() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "project:p1:categories", "[]", 1).unwrap();
|
||||
set_setting_value(db.conn(), "project:p2:categories", "[]", 1).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
delete_settings_by_prefix(db.conn(), "project:p1:").unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(get_setting_by_key(db.conn(), "project:p1:categories").is_err());
|
||||
assert!(get_setting_by_key(db.conn(), "project:p2:categories").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,79 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
use crate::db::from_row::{tag_from_row, TAG_COLUMNS};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::tags;
|
||||
use crate::model::Tag;
|
||||
|
||||
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, project_id, name, color, post_template_slug, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
tag.id,
|
||||
tag.project_id,
|
||||
tag.name,
|
||||
tag.color,
|
||||
tag.post_template_slug,
|
||||
tag.created_at,
|
||||
tag.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||
|
||||
pub fn insert_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(tags::table)
|
||||
.values(tag.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_tag_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Tag> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {TAG_COLUMNS} FROM tags WHERE id = ?1"),
|
||||
params![id],
|
||||
tag_from_row,
|
||||
)
|
||||
pub fn get_tag_by_id(conn: &DbConnection, id: &str) -> QueryResult<Tag> {
|
||||
conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::id.eq(id))
|
||||
.select(Tag::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_tag_by_project_and_name(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> rusqlite::Result<Tag> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 AND LOWER(name) = LOWER(?2)"
|
||||
),
|
||||
params![project_id, name],
|
||||
tag_from_row,
|
||||
)
|
||||
) -> QueryResult<Tag> {
|
||||
conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::project_id.eq(project_id))
|
||||
.filter(lower(tags::name).eq(name.to_lowercase()))
|
||||
.select(Tag::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_tags_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Tag>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 ORDER BY name"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], tag_from_row)?;
|
||||
rows.collect()
|
||||
pub fn list_tags_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Tag>> {
|
||||
conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::project_id.eq(project_id))
|
||||
.order(tags::name)
|
||||
.select(Tag::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?1, color = ?2, post_template_slug = ?3, updated_at = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
tag.name,
|
||||
tag.color,
|
||||
tag.post_template_slug,
|
||||
tag.updated_at,
|
||||
tag.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn update_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(tags::table.filter(tags::id.eq(&tag.id)))
|
||||
.set(tag.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_tag(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(tags::table.filter(tags::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,103 +1,80 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{
|
||||
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS,
|
||||
};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::schema::templates;
|
||||
use crate::model::Template;
|
||||
|
||||
pub fn insert_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO templates (
|
||||
id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.slug,
|
||||
t.title,
|
||||
template_kind_to_str(&t.kind),
|
||||
t.enabled as i64,
|
||||
t.version,
|
||||
t.file_path,
|
||||
template_status_to_str(&t.status),
|
||||
t.content,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn insert_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(templates::table)
|
||||
.values(t.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_template_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Template> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = ?1"),
|
||||
params![id],
|
||||
template_from_row,
|
||||
)
|
||||
pub fn get_template_by_id(conn: &DbConnection, id: &str) -> QueryResult<Template> {
|
||||
conn.with(|c| {
|
||||
templates::table
|
||||
.filter(templates::id.eq(id))
|
||||
.select(Template::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_template_by_slug(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Template> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
params![project_id, slug],
|
||||
template_from_row,
|
||||
)
|
||||
) -> QueryResult<Template> {
|
||||
conn.with(|c| {
|
||||
templates::table
|
||||
.filter(templates::project_id.eq(project_id))
|
||||
.filter(templates::slug.eq(slug))
|
||||
.select(Template::as_select())
|
||||
.first(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_templates_by_project(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<Template>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 ORDER BY title"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], template_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<Template>> {
|
||||
conn.with(|c| {
|
||||
templates::table
|
||||
.filter(templates::project_id.eq(project_id))
|
||||
.order(templates::title)
|
||||
.select(Template::as_select())
|
||||
.load(c)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE templates SET
|
||||
slug = ?1, title = ?2, kind = ?3, enabled = ?4, version = ?5,
|
||||
file_path = ?6, status = ?7, content = ?8, updated_at = ?9
|
||||
WHERE id = ?10",
|
||||
params![
|
||||
t.slug,
|
||||
t.title,
|
||||
template_kind_to_str(&t.kind),
|
||||
t.enabled as i64,
|
||||
t.version,
|
||||
t.file_path,
|
||||
template_status_to_str(&t.status),
|
||||
t.content,
|
||||
t.updated_at,
|
||||
t.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn update_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(templates::table.filter(templates::id.eq(&t.id)))
|
||||
.set(t.clone())
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_template(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(templates::table.filter(templates::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::{TemplateKind, TemplateStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
91
crates/bds-core/src/db/schema.patch
Normal file
91
crates/bds-core/src/db/schema.patch
Normal file
@@ -0,0 +1,91 @@
|
||||
--- schema.rs
|
||||
+++ schema.rs
|
||||
@@ -45 +45 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -57 +57 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -67,2 +67,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -80 +80 @@
|
||||
- created_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
@@ -93,2 +93,2 @@
|
||||
- seen_at -> Nullable<Integer>,
|
||||
- created_at -> Integer,
|
||||
+ seen_at -> Nullable<BigInt>,
|
||||
+ created_at -> BigInt,
|
||||
@@ -104 +104 @@
|
||||
- dismissed_at -> Integer,
|
||||
+ dismissed_at -> BigInt,
|
||||
@@ -110 +110 @@
|
||||
- label -> Integer,
|
||||
+ label -> BigInt,
|
||||
@@ -124 +124 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -136,2 +136,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -148 +148 @@
|
||||
- size -> Integer,
|
||||
+ size -> BigInt,
|
||||
@@ -157,2 +157,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -174,2 +174,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -185 +185 @@
|
||||
- created_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
@@ -196 +196 @@
|
||||
- created_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
@@ -210,3 +210,3 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
- published_at -> Nullable<Integer>,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
+ published_at -> Nullable<BigInt>,
|
||||
@@ -228,3 +228,3 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
- published_at -> Nullable<Integer>,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
+ published_at -> Nullable<BigInt>,
|
||||
@@ -254,2 +254,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -272,2 +272,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -281 +281 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -292,2 +292,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -309,2 +309,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
390
crates/bds-core/src/db/schema.rs
Normal file
390
crates/bds-core/src/db/schema.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
ai_catalog_meta (key) {
|
||||
key -> Text,
|
||||
value -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_endpoint_models (kind, model_id) {
|
||||
kind -> Text,
|
||||
model_id -> Text,
|
||||
label -> Text,
|
||||
context_window -> Nullable<Integer>,
|
||||
max_output_tokens -> Nullable<Integer>,
|
||||
supports_tools -> Integer,
|
||||
supports_vision -> Integer,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_model_modalities (rowid) {
|
||||
rowid -> Integer,
|
||||
provider -> Text,
|
||||
model_id -> Text,
|
||||
direction -> Text,
|
||||
modality -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_models (provider, model_id) {
|
||||
provider -> Text,
|
||||
model_id -> Text,
|
||||
name -> Text,
|
||||
family -> Nullable<Text>,
|
||||
attachment -> Integer,
|
||||
reasoning -> Integer,
|
||||
tool_call -> Integer,
|
||||
structured_output -> Integer,
|
||||
temperature -> Integer,
|
||||
knowledge -> Nullable<Text>,
|
||||
release_date -> Nullable<Text>,
|
||||
last_updated_date -> Nullable<Text>,
|
||||
open_weights -> Integer,
|
||||
input_price -> Nullable<Integer>,
|
||||
output_price -> Nullable<Integer>,
|
||||
cache_read_price -> Nullable<Integer>,
|
||||
cache_write_price -> Nullable<Integer>,
|
||||
context_window -> Integer,
|
||||
max_input_tokens -> Integer,
|
||||
max_output_tokens -> Integer,
|
||||
interleaved -> Nullable<Text>,
|
||||
status -> Nullable<Text>,
|
||||
provider_package_ref -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_providers (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
env -> Nullable<Text>,
|
||||
package_ref -> Nullable<Text>,
|
||||
api -> Nullable<Text>,
|
||||
doc -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
chat_conversations (id) {
|
||||
id -> Text,
|
||||
title -> Text,
|
||||
model -> Nullable<Text>,
|
||||
copilot_session_id -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
surface_state -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
chat_messages (id) {
|
||||
id -> Integer,
|
||||
conversation_id -> Text,
|
||||
role -> Text,
|
||||
content -> Nullable<Text>,
|
||||
tool_call_id -> Nullable<Text>,
|
||||
tool_calls -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
token_usage_input -> Nullable<Integer>,
|
||||
token_usage_output -> Nullable<Integer>,
|
||||
cache_read_tokens -> Nullable<Integer>,
|
||||
cache_write_tokens -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
db_notifications (id) {
|
||||
id -> Integer,
|
||||
entity_type -> Text,
|
||||
entity_id -> Text,
|
||||
action -> Text,
|
||||
from_cli -> Integer,
|
||||
seen_at -> Nullable<BigInt>,
|
||||
created_at -> BigInt,
|
||||
project_id -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
dismissed_duplicate_pairs (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
post_id_a -> Text,
|
||||
post_id_b -> Text,
|
||||
dismissed_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
embedding_keys (label) {
|
||||
label -> BigInt,
|
||||
post_id -> Text,
|
||||
project_id -> Text,
|
||||
content_hash -> Text,
|
||||
vector -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
generated_file_hashes (rowid) {
|
||||
rowid -> Integer,
|
||||
project_id -> Text,
|
||||
relative_path -> Text,
|
||||
content_hash -> Text,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
import_definitions (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
name -> Text,
|
||||
wxr_file_path -> Nullable<Text>,
|
||||
uploads_folder_path -> Nullable<Text>,
|
||||
last_analysis_result -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
mcp_proposals (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
kind -> Text,
|
||||
status -> Text,
|
||||
entity_id -> Text,
|
||||
data -> Text,
|
||||
result -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
expires_at -> BigInt,
|
||||
resolved_at -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
media (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
filename -> Text,
|
||||
original_name -> Text,
|
||||
mime_type -> Text,
|
||||
size -> BigInt,
|
||||
width -> Nullable<Integer>,
|
||||
height -> Nullable<Integer>,
|
||||
title -> Nullable<Text>,
|
||||
alt -> Nullable<Text>,
|
||||
caption -> Nullable<Text>,
|
||||
author -> Nullable<Text>,
|
||||
file_path -> Text,
|
||||
sidecar_path -> Text,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
checksum -> Nullable<Text>,
|
||||
tags -> Text,
|
||||
language -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
media_translations (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
translation_for -> Text,
|
||||
language -> Text,
|
||||
title -> Nullable<Text>,
|
||||
alt -> Nullable<Text>,
|
||||
caption -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
post_links (id) {
|
||||
id -> Text,
|
||||
source_post_id -> Text,
|
||||
target_post_id -> Text,
|
||||
link_text -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
post_media (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
post_id -> Text,
|
||||
media_id -> Text,
|
||||
sort_order -> Integer,
|
||||
created_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
post_translations (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
translation_for -> Text,
|
||||
language -> Text,
|
||||
title -> Text,
|
||||
excerpt -> Nullable<Text>,
|
||||
content -> Nullable<Text>,
|
||||
status -> Text,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
published_at -> Nullable<BigInt>,
|
||||
file_path -> Text,
|
||||
checksum -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
posts (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
title -> Text,
|
||||
slug -> Text,
|
||||
excerpt -> Nullable<Text>,
|
||||
content -> Nullable<Text>,
|
||||
status -> Text,
|
||||
author -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
published_at -> Nullable<BigInt>,
|
||||
file_path -> Text,
|
||||
checksum -> Nullable<Text>,
|
||||
tags -> Text,
|
||||
categories -> Text,
|
||||
template_slug -> Nullable<Text>,
|
||||
language -> Nullable<Text>,
|
||||
do_not_translate -> Integer,
|
||||
published_title -> Nullable<Text>,
|
||||
published_content -> Nullable<Text>,
|
||||
published_tags -> Nullable<Text>,
|
||||
published_categories -> Nullable<Text>,
|
||||
published_excerpt -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
projects (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
slug -> Text,
|
||||
description -> Nullable<Text>,
|
||||
data_path -> Nullable<Text>,
|
||||
is_active -> Integer,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
scripts (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
slug -> Text,
|
||||
title -> Text,
|
||||
kind -> Text,
|
||||
entrypoint -> Text,
|
||||
enabled -> Integer,
|
||||
version -> Integer,
|
||||
file_path -> Text,
|
||||
status -> Text,
|
||||
content -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
settings (key) {
|
||||
key -> Text,
|
||||
value -> Text,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tags (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
name -> Text,
|
||||
color -> Nullable<Text>,
|
||||
post_template_slug -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
templates (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
slug -> Text,
|
||||
title -> Text,
|
||||
kind -> Text,
|
||||
enabled -> Integer,
|
||||
version -> Integer,
|
||||
file_path -> Text,
|
||||
status -> Text,
|
||||
content -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(ai_models -> ai_providers (provider));
|
||||
diesel::joinable!(chat_messages -> chat_conversations (conversation_id));
|
||||
diesel::joinable!(dismissed_duplicate_pairs -> projects (project_id));
|
||||
diesel::joinable!(generated_file_hashes -> projects (project_id));
|
||||
diesel::joinable!(import_definitions -> projects (project_id));
|
||||
diesel::joinable!(mcp_proposals -> projects (project_id));
|
||||
diesel::joinable!(media -> projects (project_id));
|
||||
diesel::joinable!(media_translations -> media (translation_for));
|
||||
diesel::joinable!(media_translations -> projects (project_id));
|
||||
diesel::joinable!(post_media -> media (media_id));
|
||||
diesel::joinable!(post_media -> posts (post_id));
|
||||
diesel::joinable!(post_media -> projects (project_id));
|
||||
diesel::joinable!(post_translations -> posts (translation_for));
|
||||
diesel::joinable!(post_translations -> projects (project_id));
|
||||
diesel::joinable!(posts -> projects (project_id));
|
||||
diesel::joinable!(scripts -> projects (project_id));
|
||||
diesel::joinable!(tags -> projects (project_id));
|
||||
diesel::joinable!(templates -> projects (project_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
ai_catalog_meta,
|
||||
ai_endpoint_models,
|
||||
ai_model_modalities,
|
||||
ai_models,
|
||||
ai_providers,
|
||||
chat_conversations,
|
||||
chat_messages,
|
||||
db_notifications,
|
||||
dismissed_duplicate_pairs,
|
||||
embedding_keys,
|
||||
generated_file_hashes,
|
||||
import_definitions,
|
||||
mcp_proposals,
|
||||
media,
|
||||
media_translations,
|
||||
post_links,
|
||||
post_media,
|
||||
post_translations,
|
||||
posts,
|
||||
projects,
|
||||
scripts,
|
||||
settings,
|
||||
tags,
|
||||
templates,
|
||||
);
|
||||
98
crates/bds-core/src/db/types.rs
Normal file
98
crates/bds-core/src/db/types.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use diesel::deserialize::{self, FromSql, FromSqlRow};
|
||||
use diesel::expression::AsExpression;
|
||||
use diesel::serialize::{self, IsNull, Output, ToSql};
|
||||
use diesel::sql_types::{Integer, Text};
|
||||
use diesel::sqlite::{Sqlite, SqliteValue};
|
||||
|
||||
use crate::model::{
|
||||
ChatRole, NotificationAction, NotificationEntity, PostStatus, ProposalKind, ProposalStatus,
|
||||
ScriptKind, ScriptStatus, TemplateKind, TemplateStatus,
|
||||
};
|
||||
|
||||
#[derive(Debug, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
pub struct DbBool(bool);
|
||||
|
||||
impl From<bool> for DbBool {
|
||||
fn from(value: bool) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DbBool> for bool {
|
||||
fn from(value: DbBool) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql<Integer, Sqlite> for DbBool {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
|
||||
out.set_value(i32::from(self.0));
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Integer, Sqlite> for DbBool {
|
||||
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
|
||||
Ok(Self(i32::from_sql(value)? != 0))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = Text)]
|
||||
pub struct DbStringList(Vec<String>);
|
||||
|
||||
impl From<Vec<String>> for DbStringList {
|
||||
fn from(value: Vec<String>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DbStringList> for Vec<String> {
|
||||
fn from(value: DbStringList) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql<Text, Sqlite> for DbStringList {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
|
||||
out.set_value(serde_json::to_string(&self.0)?);
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Text, Sqlite> for DbStringList {
|
||||
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
|
||||
let value = <String as FromSql<Text, Sqlite>>::from_sql(value)?;
|
||||
Ok(Self(serde_json::from_str(&value)?))
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! text_enum_sql {
|
||||
($type:ty) => {
|
||||
impl ToSql<Text, Sqlite> for $type {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
|
||||
out.set_value(self.as_str().to_owned());
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Text, Sqlite> for $type {
|
||||
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
|
||||
let value = <String as FromSql<Text, Sqlite>>::from_sql(value)?;
|
||||
value.parse().map_err(Into::into)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
text_enum_sql!(PostStatus);
|
||||
text_enum_sql!(TemplateKind);
|
||||
text_enum_sql!(TemplateStatus);
|
||||
text_enum_sql!(ScriptKind);
|
||||
text_enum_sql!(ScriptStatus);
|
||||
text_enum_sql!(NotificationEntity);
|
||||
text_enum_sql!(NotificationAction);
|
||||
text_enum_sql!(ProposalKind);
|
||||
text_enum_sql!(ProposalStatus);
|
||||
text_enum_sql!(ChatRole);
|
||||
File diff suppressed because it is too large
Load Diff
631
crates/bds-core/src/engine/auto_translation.rs
Normal file
631
crates/bds-core/src/engine/auto_translation.rs
Normal file
@@ -0,0 +1,631 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::queries::{
|
||||
media as qm, media_translation as qmt, post as qp, post_media, post_translation,
|
||||
};
|
||||
use crate::engine::ai::{
|
||||
self, MediaTranslationResult, OneShotOperation, OneShotRequest, OneShotResponse,
|
||||
TranslationResult,
|
||||
};
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::i18n::{UiLocale, translate, translate_with};
|
||||
use crate::model::{Media, Post, PostStatus};
|
||||
use crate::util::frontmatter::read_post_file;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FillMissingTranslationsReport {
|
||||
pub translated_posts: usize,
|
||||
pub translated_media: usize,
|
||||
pub failed_count: usize,
|
||||
pub warned_count: usize,
|
||||
pub nothing_to_do: bool,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FillMissingTranslationsProgress {
|
||||
ScanningPublishedPosts,
|
||||
TranslatingPost { title: String, language: String },
|
||||
Complete,
|
||||
}
|
||||
|
||||
impl FillMissingTranslationsProgress {
|
||||
pub fn localized(&self, locale: UiLocale) -> String {
|
||||
match self {
|
||||
Self::ScanningPublishedPosts => {
|
||||
translate(locale, "engine.progress.scanningPublishedPosts")
|
||||
}
|
||||
Self::TranslatingPost { title, language } => translate_with(
|
||||
locale,
|
||||
"engine.progress.translatingPost",
|
||||
&[("title", title), ("language", language)],
|
||||
),
|
||||
Self::Complete => translate(locale, "engine.progress.translationBatchComplete"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configured_languages(main_language: &str, blog_languages: &[String]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
std::iter::once(main_language.to_string())
|
||||
.chain(blog_languages.iter().cloned())
|
||||
.map(|language| normalize_language(&language))
|
||||
.filter(|language| !language.is_empty() && seen.insert(language.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn missing_languages(
|
||||
conn: &Connection,
|
||||
post: &Post,
|
||||
configured: &[String],
|
||||
) -> EngineResult<Vec<String>> {
|
||||
if post.do_not_translate {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let source = normalize_language(post.language.as_deref().unwrap_or("en"));
|
||||
let existing = post_translation::list_post_translations_by_post(conn, &post.id)?
|
||||
.into_iter()
|
||||
.map(|translation| normalize_language(&translation.language))
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(configured
|
||||
.iter()
|
||||
.filter(|language| **language != source && !existing.contains(*language))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Batch maintenance path. Generated post translations are published, while
|
||||
/// per-item failures are accumulated and never abort the batch.
|
||||
pub fn fill_missing_translations(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
main_language: &str,
|
||||
blog_languages: &[String],
|
||||
offline_mode: bool,
|
||||
mut on_progress: impl FnMut(f32, &FillMissingTranslationsProgress) -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
fill_missing_translations_with(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
main_language,
|
||||
blog_languages,
|
||||
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
||||
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
|
||||
&mut on_progress,
|
||||
)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "testable translation orchestration dependencies"
|
||||
)]
|
||||
fn fill_missing_translations_with(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
main_language: &str,
|
||||
blog_languages: &[String],
|
||||
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
|
||||
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||
on_progress: &mut dyn FnMut(f32, &FillMissingTranslationsProgress) -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
let configured = configured_languages(main_language, blog_languages);
|
||||
if configured.len() <= 1 {
|
||||
return Ok(FillMissingTranslationsReport {
|
||||
nothing_to_do: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||
if !on_progress(
|
||||
0.0,
|
||||
&FillMissingTranslationsProgress::ScanningPublishedPosts,
|
||||
) {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
let mut work = Vec::new();
|
||||
for post in posts
|
||||
.into_iter()
|
||||
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
|
||||
{
|
||||
if !on_progress(
|
||||
0.0,
|
||||
&FillMissingTranslationsProgress::ScanningPublishedPosts,
|
||||
) {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
for language in missing_languages(conn, &post, &configured)? {
|
||||
work.push((post.clone(), language));
|
||||
}
|
||||
}
|
||||
if work.is_empty() {
|
||||
return Ok(FillMissingTranslationsReport {
|
||||
nothing_to_do: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut report = FillMissingTranslationsReport::default();
|
||||
for (index, (post, language)) in work.iter().enumerate() {
|
||||
if !on_progress(
|
||||
0.15 + (index as f32 / work.len() as f32) * 0.85,
|
||||
&FillMissingTranslationsProgress::TranslatingPost {
|
||||
title: post.title.clone(),
|
||||
language: language.clone(),
|
||||
},
|
||||
) {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
match translate_one_post(
|
||||
conn,
|
||||
data_dir,
|
||||
post,
|
||||
language,
|
||||
true,
|
||||
post_translator,
|
||||
media_translator,
|
||||
) {
|
||||
Ok(media_count) => {
|
||||
report.translated_posts += 1;
|
||||
report.translated_media += media_count;
|
||||
}
|
||||
Err(error) => {
|
||||
report.failed_count += 1;
|
||||
report
|
||||
.errors
|
||||
.push(format!("{} ({language}): {error}", post.title));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !on_progress(1.0, &FillMissingTranslationsProgress::Complete) {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Reactive manual-save path. Generated translations remain drafts.
|
||||
pub fn translate_missing_for_post(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post_id: &str,
|
||||
main_language: &str,
|
||||
blog_languages: &[String],
|
||||
offline_mode: bool,
|
||||
is_cancelled: impl Fn() -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
let post = qp::get_post_by_id(conn, post_id)?;
|
||||
let configured = configured_languages(main_language, blog_languages);
|
||||
let targets = missing_languages(conn, &post, &configured)?;
|
||||
let mut report = FillMissingTranslationsReport {
|
||||
nothing_to_do: targets.is_empty(),
|
||||
..Default::default()
|
||||
};
|
||||
for language in targets {
|
||||
if is_cancelled() {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
merge_reactive_translation_result(
|
||||
&mut report,
|
||||
&post,
|
||||
&language,
|
||||
translate_one_post(
|
||||
conn,
|
||||
data_dir,
|
||||
&post,
|
||||
&language,
|
||||
false,
|
||||
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
||||
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Translate one language from the current missing-language set. This is the
|
||||
/// reactive editor path: already-created or unconfigured translations are a
|
||||
/// silent no-op, and generated translations remain drafts.
|
||||
pub fn translate_missing_language_for_post(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post_id: &str,
|
||||
configured_languages: &[String],
|
||||
language: &str,
|
||||
offline_mode: bool,
|
||||
is_cancelled: impl Fn() -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
let post = qp::get_post_by_id(conn, post_id)?;
|
||||
let targets = missing_languages(conn, &post, configured_languages)?;
|
||||
if !targets.iter().any(|target| target == language) {
|
||||
return Ok(FillMissingTranslationsReport {
|
||||
nothing_to_do: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if is_cancelled() {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
let mut report = FillMissingTranslationsReport::default();
|
||||
merge_reactive_translation_result(
|
||||
&mut report,
|
||||
&post,
|
||||
language,
|
||||
translate_one_post(
|
||||
conn,
|
||||
data_dir,
|
||||
&post,
|
||||
language,
|
||||
false,
|
||||
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
||||
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
|
||||
),
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn merge_reactive_translation_result(
|
||||
report: &mut FillMissingTranslationsReport,
|
||||
post: &Post,
|
||||
language: &str,
|
||||
result: EngineResult<usize>,
|
||||
) {
|
||||
match result {
|
||||
Ok(media_count) => {
|
||||
report.translated_posts += 1;
|
||||
report.translated_media += media_count;
|
||||
}
|
||||
Err(error) => {
|
||||
report.failed_count += 1;
|
||||
report
|
||||
.errors
|
||||
.push(format!("{} ({language}): {error}", post.title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_one_post(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post: &Post,
|
||||
language: &str,
|
||||
auto_publish: bool,
|
||||
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
|
||||
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||
) -> EngineResult<usize> {
|
||||
if post.do_not_translate {
|
||||
return Ok(0);
|
||||
}
|
||||
let body = post_body(data_dir, post)?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(EngineError::Validation("no content to translate".into()));
|
||||
}
|
||||
let mut input = post.clone();
|
||||
input.content = Some(body);
|
||||
let translated = post_translator(&input, language)?;
|
||||
let translation = crate::engine::post::upsert_automatic_translation(
|
||||
conn,
|
||||
data_dir,
|
||||
&post.id,
|
||||
language,
|
||||
&translated.title,
|
||||
Some(&translated.excerpt),
|
||||
Some(&translated.content),
|
||||
)?;
|
||||
if auto_publish {
|
||||
crate::engine::post::publish_post_translation(conn, data_dir, &translation.id)?;
|
||||
}
|
||||
|
||||
let mut translated_media = 0;
|
||||
for link in post_media::list_post_media_by_post(conn, &post.id)? {
|
||||
let media = qm::get_media_by_id(conn, &link.media_id)?;
|
||||
let source = normalize_language(media.language.as_deref().unwrap_or(""));
|
||||
if source.is_empty() || source == language {
|
||||
continue;
|
||||
}
|
||||
if qmt::get_media_translation_by_media_and_language(conn, &media.id, language).is_ok() {
|
||||
continue;
|
||||
}
|
||||
let translated = media_translator(&media, language)?;
|
||||
crate::engine::media::upsert_media_translation(
|
||||
conn,
|
||||
data_dir,
|
||||
&media.id,
|
||||
language,
|
||||
Some(&translated.title),
|
||||
Some(&translated.alt),
|
||||
Some(&translated.caption),
|
||||
)?;
|
||||
translated_media += 1;
|
||||
}
|
||||
Ok(translated_media)
|
||||
}
|
||||
|
||||
fn post_body(data_dir: &Path, post: &Post) -> EngineResult<String> {
|
||||
if let Some(content) = &post.content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
if post.file_path.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let raw = fs::read_to_string(data_dir.join(&post.file_path))?;
|
||||
read_post_file(&raw)
|
||||
.map(|(_, body)| body)
|
||||
.map_err(EngineError::Parse)
|
||||
}
|
||||
|
||||
fn translate_post_ai(
|
||||
conn: &Connection,
|
||||
offline_mode: bool,
|
||||
post: &Post,
|
||||
language: &str,
|
||||
) -> EngineResult<TranslationResult> {
|
||||
match ai::run_one_shot(
|
||||
conn,
|
||||
offline_mode,
|
||||
&OneShotRequest {
|
||||
operation: OneShotOperation::TranslatePost {
|
||||
target_language: language.to_string(),
|
||||
},
|
||||
content: json!({
|
||||
"title": post.title,
|
||||
"excerpt": post.excerpt,
|
||||
"content": post.content,
|
||||
}),
|
||||
},
|
||||
)? {
|
||||
(OneShotResponse::Translation(result), _usage) => Ok(result),
|
||||
_ => Err(EngineError::Parse(
|
||||
"unexpected post translation response".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_media_ai(
|
||||
conn: &Connection,
|
||||
offline_mode: bool,
|
||||
media: &Media,
|
||||
language: &str,
|
||||
) -> EngineResult<MediaTranslationResult> {
|
||||
match ai::run_one_shot(
|
||||
conn,
|
||||
offline_mode,
|
||||
&OneShotRequest {
|
||||
operation: OneShotOperation::TranslateMedia {
|
||||
target_language: language.to_string(),
|
||||
},
|
||||
content: json!({
|
||||
"title": media.title,
|
||||
"alt": media.alt,
|
||||
"caption": media.caption,
|
||||
}),
|
||||
},
|
||||
)? {
|
||||
(OneShotResponse::MediaTranslation(result), _usage) => Ok(result),
|
||||
_ => Err(EngineError::Parse(
|
||||
"unexpected media translation response".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_language(language: &str) -> String {
|
||||
language
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::engine::post::{create_post, publish_post, upsert_translation};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn progress_events_use_the_selected_ui_locale() {
|
||||
assert_eq!(
|
||||
FillMissingTranslationsProgress::ScanningPublishedPosts.localized(UiLocale::De),
|
||||
"Veröffentlichte Beiträge werden durchsucht…"
|
||||
);
|
||||
assert_eq!(
|
||||
FillMissingTranslationsProgress::TranslatingPost {
|
||||
title: "Hallo".into(),
|
||||
language: "fr".into(),
|
||||
}
|
||||
.localized(UiLocale::Fr),
|
||||
"Traduction de Hallo → fr"
|
||||
);
|
||||
assert_eq!(
|
||||
FillMissingTranslationsProgress::Complete.localized(UiLocale::Es),
|
||||
"Lote de traducciones completado"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_translates_only_missing_languages_and_publishes() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Hello",
|
||||
Some("Body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
let mut requested = Vec::new();
|
||||
let report = fill_missing_translations_with(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"en",
|
||||
&["de".into(), "fr".into(), "de-DE".into()],
|
||||
&mut |_post, language| {
|
||||
requested.push(language.to_string());
|
||||
Ok(TranslationResult {
|
||||
title: format!("Title {language}"),
|
||||
excerpt: format!("Excerpt {language}"),
|
||||
content: format!("Body {language}"),
|
||||
})
|
||||
},
|
||||
&mut |_media, _language| unreachable!(),
|
||||
&mut |_, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(requested, vec!["de", "fr"]);
|
||||
assert_eq!(report.translated_posts, 2);
|
||||
let canonical = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||
assert_eq!(canonical.status, PostStatus::Published);
|
||||
assert!(canonical.content.is_none());
|
||||
for language in ["de", "fr"] {
|
||||
let translation = post_translation::get_post_translation_by_post_and_language(
|
||||
db.conn(),
|
||||
&post.id,
|
||||
language,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(translation.status, PostStatus::Published);
|
||||
assert!(dir.path().join(&translation.file_path).is_file());
|
||||
assert!(translation.content.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_language_translation_is_a_no_op_when_translation_exists() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Hello",
|
||||
Some("Body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
upsert_translation(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&post.id,
|
||||
"de",
|
||||
"Hallo",
|
||||
None,
|
||||
Some("Inhalt"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = translate_missing_language_for_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&post.id,
|
||||
&["en".to_string(), "de".to_string()],
|
||||
"de",
|
||||
true,
|
||||
|| false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(report.nothing_to_do);
|
||||
assert_eq!(report.translated_posts, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_do_not_translate_posts() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Private",
|
||||
Some("Body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let post = crate::engine::post::update_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&post.id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.unwrap();
|
||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
let report = fill_missing_translations_with(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"en",
|
||||
&["de".into()],
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(report.nothing_to_do);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_stops_when_progress_callback_cancels() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let result = fill_missing_translations_with(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"en",
|
||||
&["de".into()],
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| false,
|
||||
);
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Validation(message)) if message == "cancelled"));
|
||||
}
|
||||
}
|
||||
487
crates/bds-core/src/engine/blogmark.rs
Normal file
487
crates/bds-core/src/engine/blogmark.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use url::Url;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::queries::script as script_queries;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, Script, ScriptKind};
|
||||
use crate::scripting::{self, CoreHost, ExecutionControl, ExecutionKind, HostApi, UnavailableHost};
|
||||
|
||||
const MAX_TITLE_LENGTH: usize = 200;
|
||||
const MAX_URL_LENGTH: usize = 2_048;
|
||||
const MAX_TOASTS_TOTAL: usize = 20;
|
||||
const MAX_TOAST_LENGTH: usize = 300;
|
||||
const BLOGMARK_SCHEME: &str = "ruds";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BlogmarkCandidate {
|
||||
pub title: String,
|
||||
pub url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlogmarkImportResult {
|
||||
pub post: Post,
|
||||
pub toasts: Vec<String>,
|
||||
pub transform_errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse_deep_link(raw: &str) -> EngineResult<BlogmarkCandidate> {
|
||||
let parsed =
|
||||
Url::parse(raw).map_err(|_| EngineError::Validation("invalid blogmark URL".into()))?;
|
||||
if parsed.scheme() != BLOGMARK_SCHEME {
|
||||
return Err(EngineError::Validation(
|
||||
"unsupported blogmark scheme".into(),
|
||||
));
|
||||
}
|
||||
if parsed.host_str() != Some("new-post") {
|
||||
return Err(EngineError::Validation(
|
||||
"unsupported blogmark action".into(),
|
||||
));
|
||||
}
|
||||
let params = parsed
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let url = params.get("url").and_then(|value| sanitize_http_url(value));
|
||||
let fallback = url
|
||||
.as_ref()
|
||||
.and_then(|value| Url::parse(value).ok())
|
||||
.and_then(|value| value.host_str().map(str::to_string));
|
||||
let title = sanitize_title(params.get("title").map(String::as_str), fallback.as_deref());
|
||||
Ok(BlogmarkCandidate {
|
||||
title,
|
||||
url,
|
||||
content: nonempty(params.get("content")),
|
||||
tags: list_param(params.get("tags")),
|
||||
categories: list_param(params.get("categories")),
|
||||
project_id: nonempty(params.get("project_id")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bookmarklet(project_id: &str) -> String {
|
||||
let project_id =
|
||||
url::form_urlencoded::byte_serialize(project_id.as_bytes()).collect::<String>();
|
||||
format!(
|
||||
"javascript:(()=>{{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='ruds://new-post?title='+t+'&url='+u+'&project_id={project_id}';}})();"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn receive_deep_link(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
raw: &str,
|
||||
) -> EngineResult<BlogmarkImportResult> {
|
||||
let host = CoreHost::from_connection(conn, project_id, data_dir)
|
||||
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
|
||||
.unwrap_or_else(|_| Arc::new(UnavailableHost));
|
||||
receive_deep_link_with_host(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
raw,
|
||||
&ExecutionControl::default(),
|
||||
host,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn receive_deep_link_with_host(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
raw: &str,
|
||||
control: &ExecutionControl,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> EngineResult<BlogmarkImportResult> {
|
||||
let mut candidate = parse_deep_link(raw)?;
|
||||
if let Some(target) = &candidate.project_id
|
||||
&& target != project_id
|
||||
{
|
||||
return Err(EngineError::Validation(
|
||||
"blogmark targets a different project".into(),
|
||||
));
|
||||
}
|
||||
if candidate.content.is_none()
|
||||
&& let Some(url) = &candidate.url
|
||||
{
|
||||
candidate.content = Some(format!(
|
||||
"[{}]({url})",
|
||||
escape_markdown_link_text(&candidate.title)
|
||||
));
|
||||
}
|
||||
let (mut candidate, toasts, transform_errors) =
|
||||
run_transforms(conn, data_dir, project_id, candidate, control, host)?;
|
||||
if control.is_cancelled() {
|
||||
return Err(EngineError::Validation("script cancelled".into()));
|
||||
}
|
||||
if candidate.categories.is_empty() {
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||
if let Some(category) = metadata
|
||||
.blogmark_category
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
candidate.categories.push(category);
|
||||
}
|
||||
}
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||
let post = crate::engine::post::create_post(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
&candidate.title,
|
||||
candidate.content.as_deref(),
|
||||
candidate.tags,
|
||||
candidate.categories,
|
||||
metadata.default_author.as_deref(),
|
||||
metadata.main_language.as_deref(),
|
||||
None,
|
||||
)?;
|
||||
Ok(BlogmarkImportResult {
|
||||
post,
|
||||
toasts,
|
||||
transform_errors,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_transforms(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
candidate: BlogmarkCandidate,
|
||||
control: &ExecutionControl,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> EngineResult<(BlogmarkCandidate, Vec<String>, Vec<String>)> {
|
||||
let mut transforms = script_queries::list_scripts_by_project(conn, project_id)?
|
||||
.into_iter()
|
||||
.filter(|script| script.kind == ScriptKind::Transform && script.enabled)
|
||||
.collect::<Vec<_>>();
|
||||
transforms.sort_by(|left, right| {
|
||||
left.updated_at
|
||||
.cmp(&right.updated_at)
|
||||
.then_with(|| left.slug.cmp(&right.slug))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
let mut current =
|
||||
serde_json::to_value(candidate).map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let mut toasts = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for script in transforms {
|
||||
if control.is_cancelled() {
|
||||
return Err(EngineError::Validation("script cancelled".into()));
|
||||
}
|
||||
if script.entrypoint.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let source = resolved_script_content(data_dir, &script)?;
|
||||
let context = json!({
|
||||
"source": "blogmark",
|
||||
"url": current.get("url").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
match scripting::execute_many_with_host(
|
||||
&source,
|
||||
&script.entrypoint,
|
||||
&[current.clone(), context],
|
||||
ExecutionKind::Transform,
|
||||
control,
|
||||
Arc::clone(&host),
|
||||
) {
|
||||
Ok(execution) => {
|
||||
let (next, returned_toasts) = split_transform_result(execution.value, ¤t);
|
||||
current = next;
|
||||
accept_toasts(
|
||||
&mut toasts,
|
||||
execution.toasts.into_iter().chain(returned_toasts),
|
||||
);
|
||||
}
|
||||
Err(_error) if control.is_cancelled() => {
|
||||
return Err(EngineError::Validation("script cancelled".into()));
|
||||
}
|
||||
Err(error) => errors.push(format!("{}: {error}", script.slug)),
|
||||
}
|
||||
}
|
||||
let candidate = serde_json::from_value(current).map_err(|error| {
|
||||
EngineError::Validation(format!("transform returned invalid post data: {error}"))
|
||||
})?;
|
||||
Ok((candidate, toasts, errors))
|
||||
}
|
||||
|
||||
fn split_transform_result(value: Value, previous: &Value) -> (Value, Vec<String>) {
|
||||
if let Some(object) = value.as_object() {
|
||||
if let Some(data) = object.get("data").filter(|value| value.is_object()) {
|
||||
let toasts = object
|
||||
.get("toasts")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
return (data.clone(), toasts);
|
||||
}
|
||||
return (value, Vec::new());
|
||||
}
|
||||
(previous.clone(), Vec::new())
|
||||
}
|
||||
|
||||
fn accept_toasts(target: &mut Vec<String>, source: impl IntoIterator<Item = String>) {
|
||||
for message in source.into_iter().take(5) {
|
||||
if target.len() >= MAX_TOASTS_TOTAL {
|
||||
break;
|
||||
}
|
||||
target.push(message.chars().take(MAX_TOAST_LENGTH).collect());
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_script_content(data_dir: &Path, script: &Script) -> EngineResult<String> {
|
||||
if let Some(content) = &script.content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
let raw = fs::read_to_string(data_dir.join(&script.file_path))?;
|
||||
crate::util::frontmatter::read_script_file(&raw)
|
||||
.map(|(_, body)| body)
|
||||
.map_err(EngineError::Parse)
|
||||
}
|
||||
|
||||
fn sanitize_title(value: Option<&str>, fallback: Option<&str>) -> String {
|
||||
let cleaned = value
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.collect::<String>();
|
||||
let cleaned = cleaned.trim();
|
||||
let selected = if cleaned.is_empty() {
|
||||
fallback.unwrap_or_default()
|
||||
} else {
|
||||
cleaned
|
||||
};
|
||||
selected.chars().take(MAX_TITLE_LENGTH).collect()
|
||||
}
|
||||
|
||||
fn sanitize_http_url(value: &str) -> Option<String> {
|
||||
let cleaned = value
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.collect::<String>();
|
||||
let mut parsed = Url::parse(cleaned.trim()).ok()?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return None;
|
||||
}
|
||||
parsed.set_fragment(None);
|
||||
parsed.set_username("").ok()?;
|
||||
parsed.set_password(None).ok()?;
|
||||
let normalized = parsed.to_string();
|
||||
(normalized.chars().count() <= MAX_URL_LENGTH).then_some(normalized)
|
||||
}
|
||||
|
||||
fn nonempty(value: Option<&String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn list_param(value: Option<&String>) -> Vec<String> {
|
||||
value
|
||||
.map(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn escape_markdown_link_text(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
.replace('(', "\\(")
|
||||
.replace(')', "\\)")
|
||||
.replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::model::ScriptKind;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct PostsHost;
|
||||
|
||||
impl HostApi for PostsHost {
|
||||
fn call(
|
||||
&self,
|
||||
namespace: &str,
|
||||
method: &str,
|
||||
_arguments: Vec<Value>,
|
||||
) -> Result<Value, String> {
|
||||
match (namespace, method) {
|
||||
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
|
||||
_ => Err("unsupported test capability".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_hardens_blogmark_links() {
|
||||
let candidate = parse_deep_link(
|
||||
"ruds://new-post?title=%00Hello&url=https%3A%2F%2Fuser%3Apass%40example.com%2Fa%23frag&tags=one%2Ctwo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(candidate.title, "Hello");
|
||||
assert_eq!(candidate.url.as_deref(), Some("https://example.com/a"));
|
||||
assert_eq!(candidate.tags, vec!["one", "two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_schemes_and_actions() {
|
||||
assert!(parse_deep_link("bds://new-post?title=x").is_err());
|
||||
assert!(parse_deep_link("bds2://new-post?title=x").is_err());
|
||||
assert!(parse_deep_link("ruds://other?title=x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarklet_targets_only_ruds_and_the_selected_project() {
|
||||
let value = bookmarklet("project & seven");
|
||||
assert!(value.contains("ruds://new-post?"));
|
||||
assert!(value.contains("project_id=project+%26+seven"));
|
||||
assert!(!value.contains("bds2://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_source_url_never_reaches_candidate() {
|
||||
let candidate =
|
||||
parse_deep_link("ruds://new-post?title=x&url=javascript%3Aalert%281%29").unwrap();
|
||||
assert!(candidate.url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_draft_after_ordered_transform_pipeline() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
let directory = TempDir::new().unwrap();
|
||||
let project = crate::engine::project::create_project(
|
||||
db.conn(),
|
||||
"Blog",
|
||||
Some(directory.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
crate::engine::script::create_script(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
"Add tag",
|
||||
ScriptKind::Transform,
|
||||
"function main(data, context) data.tags = {context.source}; bds.app.toast('done'); return data end",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = receive_deep_link(
|
||||
db.conn(),
|
||||
directory.path(),
|
||||
&project.id,
|
||||
"ruds://new-post?title=Example&url=https%3A%2F%2Fexample.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.post.tags, vec!["blogmark"]);
|
||||
assert_eq!(
|
||||
result.post.content.as_deref(),
|
||||
Some("[Example](https://example.com/)")
|
||||
);
|
||||
assert_eq!(result.toasts, vec!["done"]);
|
||||
assert!(result.transform_errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_use_the_supplied_project_host() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
let directory = TempDir::new().unwrap();
|
||||
let project = crate::engine::project::create_project(
|
||||
db.conn(),
|
||||
"Blog",
|
||||
Some(directory.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
crate::engine::script::create_script(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
"Count posts",
|
||||
ScriptKind::Transform,
|
||||
"function main(data) data.title = tostring(#bds.posts.get_all()) .. ' posts'; return data end",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = receive_deep_link_with_host(
|
||||
db.conn(),
|
||||
directory.path(),
|
||||
&project.id,
|
||||
"ruds://new-post?title=Example",
|
||||
&ExecutionControl::default(),
|
||||
Arc::new(PostsHost),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.post.title, "2 posts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_stops_transform_import_before_post_creation() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
let directory = TempDir::new().unwrap();
|
||||
let project = crate::engine::project::create_project(
|
||||
db.conn(),
|
||||
"Blog",
|
||||
Some(directory.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
crate::engine::script::create_script(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
"Cancelled",
|
||||
ScriptKind::Transform,
|
||||
"function main(data) return data end",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let control = ExecutionControl::default();
|
||||
control.cancel();
|
||||
|
||||
assert!(
|
||||
receive_deep_link_with_host(
|
||||
db.conn(),
|
||||
directory.path(),
|
||||
&project.id,
|
||||
"ruds://new-post?title=Example",
|
||||
&control,
|
||||
Arc::new(PostsHost),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::db::queries::post::count_posts_by_project(db.conn(), &project.id).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::queries::post as post_q;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::PostStatus;
|
||||
use crate::util::{atomic_write, timestamp};
|
||||
|
||||
@@ -18,6 +18,15 @@ pub fn regenerate_calendar(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
regenerate_calendar_with_progress(conn, data_dir, project_id, |_, _, _| true)
|
||||
}
|
||||
|
||||
pub fn regenerate_calendar_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
mut on_item: impl FnMut(usize, usize, &str) -> bool,
|
||||
) -> EngineResult<()> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
|
||||
@@ -25,7 +34,11 @@ pub fn regenerate_calendar(
|
||||
let mut months: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut days: BTreeMap<String, u32> = BTreeMap::new();
|
||||
|
||||
for post in &posts {
|
||||
let total = posts.len() + 1;
|
||||
for (index, post) in posts.iter().enumerate() {
|
||||
if !on_item(index + 1, total, &post.title) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if post.status != PostStatus::Published {
|
||||
continue;
|
||||
}
|
||||
@@ -41,6 +54,10 @@ pub fn regenerate_calendar(
|
||||
*days.entry(day_key).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
if !on_item(total, total, "calendar.json") {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
let calendar = serde_json::json!({
|
||||
"years": years,
|
||||
"months": months,
|
||||
@@ -65,22 +82,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn calendar_empty_project() {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let _ = db.migrate();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = engine::project::create_project(
|
||||
db.conn(), "Test", Some(tmp.path().to_str().unwrap()),
|
||||
).unwrap();
|
||||
let p =
|
||||
engine::project::create_project(db.conn(), "Test", Some(tmp.path().to_str().unwrap()))
|
||||
.unwrap();
|
||||
|
||||
regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap();
|
||||
|
||||
let cal_path = tmp.path().join("html").join("calendar.json");
|
||||
assert!(cal_path.exists());
|
||||
|
||||
let data: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(&cal_path).unwrap()
|
||||
).unwrap();
|
||||
let data: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
|
||||
assert!(data["years"].as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_write_leaves_no_calendar() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let project =
|
||||
engine::project::create_project(db.conn(), "Test", Some(dir.path().to_str().unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
regenerate_calendar_with_progress(db.conn(), dir.path(), &project.id, |_, _, _| false);
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Cancelled)));
|
||||
assert!(!dir.path().join("html/calendar.json").exists());
|
||||
}
|
||||
}
|
||||
|
||||
1070
crates/bds-core/src/engine/chat.rs
Normal file
1070
crates/bds-core/src/engine/chat.rs
Normal file
File diff suppressed because it is too large
Load Diff
583
crates/bds-core/src/engine/chat_surfaces.rs
Normal file
583
crates/bds-core/src/engine/chat_surfaces.rs
Normal file
@@ -0,0 +1,583 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::model::ChatMessage;
|
||||
|
||||
pub const RENDER_TOOL_NAMES: [&str; 8] = [
|
||||
"render_card",
|
||||
"render_chart",
|
||||
"render_form",
|
||||
"render_list",
|
||||
"render_metric",
|
||||
"render_mindmap",
|
||||
"render_table",
|
||||
"render_tabs",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ChatSurfaceState {
|
||||
#[serde(default)]
|
||||
pub surface_data: BTreeMap<String, BTreeMap<String, Value>>,
|
||||
#[serde(default)]
|
||||
pub surface_tabs: BTreeMap<String, usize>,
|
||||
#[serde(default)]
|
||||
pub dismissed_surfaces: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SurfaceKind {
|
||||
Card,
|
||||
Chart,
|
||||
Form,
|
||||
List,
|
||||
Metric,
|
||||
Mindmap,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl SurfaceKind {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Card => "card",
|
||||
Self::Chart => "chart",
|
||||
Self::Form => "form",
|
||||
Self::List => "list",
|
||||
Self::Metric => "metric",
|
||||
Self::Mindmap => "mindmap",
|
||||
Self::Table => "table",
|
||||
Self::Tabs => "tabs",
|
||||
Self::Text => "text",
|
||||
Self::Json => "json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChartType {
|
||||
Bar,
|
||||
StackedBar,
|
||||
Line,
|
||||
Area,
|
||||
Pie,
|
||||
Donut,
|
||||
Heatmap,
|
||||
}
|
||||
|
||||
impl ChartType {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bar => "bar",
|
||||
Self::StackedBar => "stacked-bar",
|
||||
Self::Line => "line",
|
||||
Self::Area => "area",
|
||||
Self::Pie => "pie",
|
||||
Self::Donut => "donut",
|
||||
Self::Heatmap => "heatmap",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: Option<&str>) -> Self {
|
||||
match value {
|
||||
Some("stacked-bar") => Self::StackedBar,
|
||||
Some("line") => Self::Line,
|
||||
Some("area") => Self::Area,
|
||||
Some("pie") => Self::Pie,
|
||||
Some("donut") => Self::Donut,
|
||||
Some("heatmap") => Self::Heatmap,
|
||||
_ => Self::Bar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FormInputType {
|
||||
Text,
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
Date,
|
||||
Number,
|
||||
}
|
||||
|
||||
impl FormInputType {
|
||||
fn parse(value: Option<&str>) -> Self {
|
||||
match value {
|
||||
Some("textarea") => Self::Textarea,
|
||||
Some("select") => Self::Select,
|
||||
Some("checkbox") => Self::Checkbox,
|
||||
Some("date") => Self::Date,
|
||||
Some("number") => Self::Number,
|
||||
_ => Self::Text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InlineSurface {
|
||||
pub id: String,
|
||||
pub kind: SurfaceKind,
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub actions: Vec<SurfaceAction>,
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub chart_type: Option<ChartType>,
|
||||
pub series: Vec<ChartSeries>,
|
||||
pub max_value: Option<f64>,
|
||||
pub label: Option<String>,
|
||||
pub value: Option<String>,
|
||||
pub items: Vec<String>,
|
||||
pub nodes: Vec<MindmapNode>,
|
||||
pub fields: Vec<FormField>,
|
||||
pub submit_label: Option<String>,
|
||||
pub submit_action: Option<String>,
|
||||
pub tabs: Vec<TabPanel>,
|
||||
pub selected_index: Option<usize>,
|
||||
pub raw: Option<Value>,
|
||||
}
|
||||
|
||||
impl InlineSurface {
|
||||
fn empty(id: String, kind: SurfaceKind) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
title: None,
|
||||
subtitle: None,
|
||||
body: None,
|
||||
actions: vec![],
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
chart_type: None,
|
||||
series: vec![],
|
||||
max_value: None,
|
||||
label: None,
|
||||
value: None,
|
||||
items: vec![],
|
||||
nodes: vec![],
|
||||
fields: vec![],
|
||||
submit_label: None,
|
||||
submit_action: None,
|
||||
tabs: vec![],
|
||||
selected_index: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SurfaceAction {
|
||||
pub label: String,
|
||||
pub action: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChartSeries {
|
||||
pub label: String,
|
||||
pub value: f64,
|
||||
pub segments: Vec<ChartSegment>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChartSegment {
|
||||
pub label: String,
|
||||
pub value: f64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MindmapNode {
|
||||
pub id: Option<String>,
|
||||
pub label: String,
|
||||
pub children: Vec<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FormField {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub input_type: FormInputType,
|
||||
pub placeholder: Option<String>,
|
||||
pub value: Value,
|
||||
pub options: Vec<FieldOption>,
|
||||
pub required: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldOption {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TabPanel {
|
||||
pub label: String,
|
||||
pub content: Vec<InlineSurface>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SurfaceNavigation {
|
||||
pub destination: String,
|
||||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_message_surfaces(
|
||||
message: &ChatMessage,
|
||||
state: &ChatSurfaceState,
|
||||
) -> Vec<InlineSurface> {
|
||||
let Some(raw) = message.tool_calls.as_deref() else {
|
||||
return vec![];
|
||||
};
|
||||
let Ok(calls) = serde_json::from_str::<Vec<Value>>(raw) else {
|
||||
return vec![];
|
||||
};
|
||||
calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, call)| {
|
||||
let function = call.get("function").unwrap_or(call);
|
||||
let name = function.get("name")?.as_str()?;
|
||||
let raw = function
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let arguments = raw
|
||||
.as_str()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(raw);
|
||||
build_render_surface(
|
||||
name,
|
||||
&arguments,
|
||||
format!("{}-surface-{index}", message.id),
|
||||
state,
|
||||
)
|
||||
})
|
||||
.filter(|surface| !state.dismissed_surfaces.contains(&surface.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn build_render_surface(
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
id: String,
|
||||
state: &ChatSurfaceState,
|
||||
) -> Option<InlineSurface> {
|
||||
match name {
|
||||
"render_card" => Some(card(arguments, id)),
|
||||
"render_chart" => Some(chart(arguments, id)),
|
||||
"render_form" => Some(form(arguments, id, state)),
|
||||
"render_list" => Some(list(arguments, id)),
|
||||
"render_metric" => Some(metric(arguments, id)),
|
||||
"render_mindmap" => Some(mindmap(arguments, id)),
|
||||
"render_table" => Some(table(arguments, id)),
|
||||
"render_tabs" => Some(tabs(arguments, id, state)),
|
||||
unknown if unknown.starts_with("render_") => {
|
||||
let mut surface = InlineSurface::empty(id, SurfaceKind::Json);
|
||||
surface.raw = Some(arguments.clone());
|
||||
Some(surface)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_form_data(payload: Value, surface_id: &str, state: &ChatSurfaceState) -> Value {
|
||||
let mut result = payload.as_object().cloned().unwrap_or_default();
|
||||
if let Some(values) = state.surface_data.get(surface_id) {
|
||||
result.insert(
|
||||
"formData".into(),
|
||||
Value::Object(values.clone().into_iter().collect()),
|
||||
);
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
pub fn resolve_surface_action(action: &str, payload: &Value) -> Result<SurfaceNavigation, String> {
|
||||
let map = payload
|
||||
.as_object()
|
||||
.ok_or_else(|| "surface action payload must be an object".to_string())?;
|
||||
let normalized = action.replace('_', "").to_ascii_lowercase();
|
||||
let open = |destination: &str, keys: &[&str]| {
|
||||
let id = keys
|
||||
.iter()
|
||||
.find_map(|key| map.get(*key).and_then(Value::as_str))
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| format!("surface action {action} requires an identifier"))?;
|
||||
Ok(SurfaceNavigation {
|
||||
destination: destination.into(),
|
||||
entity_id: Some(id.into()),
|
||||
})
|
||||
};
|
||||
match normalized.as_str() {
|
||||
"openpost" => open("posts", &["postId", "post_id", "id"]),
|
||||
"openmedia" => open("media", &["mediaId", "media_id", "id"]),
|
||||
"openchat" => open(
|
||||
"chat",
|
||||
&[
|
||||
"conversationId",
|
||||
"conversation_id",
|
||||
"chatId",
|
||||
"chat_id",
|
||||
"id",
|
||||
],
|
||||
),
|
||||
"opensettings" => Ok(navigation("settings")),
|
||||
"switchview" | "setview" | "setactiveview" => {
|
||||
let view = map
|
||||
.get("view")
|
||||
.or_else(|| map.get("destination"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|view| {
|
||||
[
|
||||
"posts",
|
||||
"pages",
|
||||
"media",
|
||||
"templates",
|
||||
"scripts",
|
||||
"tags",
|
||||
"chat",
|
||||
"import",
|
||||
"git",
|
||||
"settings",
|
||||
]
|
||||
.contains(view)
|
||||
})
|
||||
.ok_or_else(|| format!("surface action {action} requires a valid view"))?;
|
||||
Ok(navigation(view))
|
||||
}
|
||||
"togglesidebar" => Ok(navigation("toggle_sidebar")),
|
||||
"togglepanel" | "openpanel" => Ok(navigation("toggle_panel")),
|
||||
"toggleassistantsidebar" => Ok(navigation("toggle_assistant_sidebar")),
|
||||
_ => Err(format!("unsupported surface action: {action}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn navigation(destination: &str) -> SurfaceNavigation {
|
||||
SurfaceNavigation {
|
||||
destination: destination.into(),
|
||||
entity_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn card(a: &Value, id: String) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Card);
|
||||
s.title = string(a, "title");
|
||||
s.subtitle = string(a, "subtitle");
|
||||
s.body = string(a, "body");
|
||||
s.actions = array(a, "actions")
|
||||
.iter()
|
||||
.map(|v| SurfaceAction {
|
||||
label: string(v, "label").unwrap_or_default(),
|
||||
action: string(v, "action").unwrap_or_default(),
|
||||
payload: v.get("payload").cloned().unwrap_or_else(|| json!({})),
|
||||
})
|
||||
.collect();
|
||||
s
|
||||
}
|
||||
|
||||
fn chart(a: &Value, id: String) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Chart);
|
||||
s.title = string(a, "title");
|
||||
s.chart_type = Some(ChartType::parse(
|
||||
a.get("chartType")
|
||||
.or_else(|| a.get("chart_type"))
|
||||
.and_then(Value::as_str),
|
||||
));
|
||||
s.series = array(a, "series")
|
||||
.iter()
|
||||
.map(|v| ChartSeries {
|
||||
label: string(v, "label").unwrap_or_default(),
|
||||
value: numeric(v.get("value")),
|
||||
segments: array(v, "segments")
|
||||
.iter()
|
||||
.map(|seg| ChartSegment {
|
||||
label: string(seg, "label").unwrap_or_default(),
|
||||
value: numeric(seg.get("value")),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
s.max_value = Some(s.series.iter().map(|v| v.value).fold(0.0_f64, f64::max));
|
||||
s
|
||||
}
|
||||
|
||||
fn form(a: &Value, id: String, state: &ChatSurfaceState) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id.clone(), SurfaceKind::Form);
|
||||
s.title = string(a, "title");
|
||||
s.fields = array(a, "fields")
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let key = string(v, "key").unwrap_or_else(|| "field".into());
|
||||
let default = v
|
||||
.get("defaultValue")
|
||||
.or_else(|| v.get("default_value"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
FormField {
|
||||
label: string(v, "label").unwrap_or_else(|| key.clone()),
|
||||
input_type: FormInputType::parse(
|
||||
v.get("inputType")
|
||||
.or_else(|| v.get("input_type"))
|
||||
.and_then(Value::as_str),
|
||||
),
|
||||
placeholder: string(v, "placeholder"),
|
||||
value: state
|
||||
.surface_data
|
||||
.get(&id)
|
||||
.and_then(|m| m.get(&key))
|
||||
.cloned()
|
||||
.unwrap_or(default),
|
||||
options: array(v, "options")
|
||||
.iter()
|
||||
.map(|o| FieldOption {
|
||||
label: string(o, "label").unwrap_or_default(),
|
||||
value: string(o, "value").unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
required: v.get("required").and_then(Value::as_bool).unwrap_or(false),
|
||||
key,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.submit_label = a
|
||||
.get("submitLabel")
|
||||
.or_else(|| a.get("submit_label"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
s.submit_action = a
|
||||
.get("submitAction")
|
||||
.or_else(|| a.get("submit_action"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
s
|
||||
}
|
||||
|
||||
fn list(a: &Value, id: String) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::List);
|
||||
s.title = string(a, "title");
|
||||
s.items = strings(a.get("items"));
|
||||
s
|
||||
}
|
||||
fn metric(a: &Value, id: String) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Metric);
|
||||
s.label = string(a, "label");
|
||||
s.value = scalar(a.get("value"));
|
||||
s
|
||||
}
|
||||
fn mindmap(a: &Value, id: String) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Mindmap);
|
||||
s.title = string(a, "title");
|
||||
s.nodes = array(a, "nodes")
|
||||
.iter()
|
||||
.map(|v| MindmapNode {
|
||||
id: string(v, "id"),
|
||||
label: string(v, "label").unwrap_or_default(),
|
||||
children: strings(v.get("children")),
|
||||
})
|
||||
.collect();
|
||||
s
|
||||
}
|
||||
fn table(a: &Value, id: String) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Table);
|
||||
s.title = string(a, "title");
|
||||
s.columns = strings(a.get("columns"));
|
||||
s.rows = array(a, "rows").iter().map(|v| strings(Some(v))).collect();
|
||||
s
|
||||
}
|
||||
fn tabs(a: &Value, id: String, state: &ChatSurfaceState) -> InlineSurface {
|
||||
let mut s = InlineSurface::empty(id.clone(), SurfaceKind::Tabs);
|
||||
s.title = string(a, "title");
|
||||
s.tabs = array(a, "tabs")
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ti, tab)| TabPanel {
|
||||
label: string(tab, "label").unwrap_or_default(),
|
||||
content: array(tab, "content")
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ci, v)| nested(v, format!("{id}-tab-{ti}-{ci}"), state))
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
s.selected_index = Some(
|
||||
state
|
||||
.surface_tabs
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(s.tabs.len().saturating_sub(1)),
|
||||
);
|
||||
s
|
||||
}
|
||||
fn nested(v: &Value, id: String, state: &ChatSurfaceState) -> InlineSurface {
|
||||
let Some(object) = v.as_object() else {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Text);
|
||||
s.body = Some(scalar(Some(v)).unwrap_or_default());
|
||||
return s;
|
||||
};
|
||||
match object.get("type").and_then(Value::as_str).unwrap_or("text") {
|
||||
"card" => card(v, id),
|
||||
"chart" => chart(v, id),
|
||||
"form" => form(v, id, state),
|
||||
"list" => list(v, id),
|
||||
"metric" => metric(v, id),
|
||||
"mindmap" => mindmap(v, id),
|
||||
"table" => table(v, id),
|
||||
"tabs" => tabs(v, id, state),
|
||||
"text" => {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Text);
|
||||
s.body = string(v, "body").or_else(|| string(v, "text"));
|
||||
s
|
||||
}
|
||||
_ => {
|
||||
let mut s = InlineSurface::empty(id, SurfaceKind::Json);
|
||||
s.raw = Some(Value::Object(object.clone()));
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn array<'a>(v: &'a Value, key: &str) -> &'a [Value] {
|
||||
v.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn string(v: &Value, key: &str) -> Option<String> {
|
||||
scalar(v.get(key))
|
||||
}
|
||||
fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| scalar(Some(v)))
|
||||
.collect()
|
||||
}
|
||||
fn scalar(v: Option<&Value>) -> Option<String> {
|
||||
match v? {
|
||||
Value::Null => None,
|
||||
Value::String(v) => Some(v.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
v => serde_json::to_string(v).ok(),
|
||||
}
|
||||
}
|
||||
fn numeric(v: Option<&Value>) -> f64 {
|
||||
v.and_then(|v| {
|
||||
v.as_f64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
pub fn render_tool_result(name: &str, arguments: &Value) -> Option<Value> {
|
||||
let kind = name.strip_prefix("render_")?;
|
||||
RENDER_TOOL_NAMES.contains(&name).then(|| {
|
||||
let mut values = arguments.as_object().cloned().unwrap_or_default();
|
||||
values.insert("type".into(), Value::String(kind.into()));
|
||||
Value::Object(values)
|
||||
})
|
||||
}
|
||||
978
crates/bds-core/src/engine/chat_tools.rs
Normal file
978
crates/bds-core/src/engine/chat_tools.rs
Normal file
@@ -0,0 +1,978 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine as _;
|
||||
use diesel::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::queries::{media, post, post_link, post_media, script, template};
|
||||
use crate::db::schema::ai_models;
|
||||
use crate::engine::{EngineError, EngineResult, chat_surfaces};
|
||||
use crate::util::frontmatter::read_post_file;
|
||||
|
||||
pub fn model_supports_tools(conn: &Connection, model: &str) -> EngineResult<bool> {
|
||||
let catalog_value = conn.with(|connection| {
|
||||
ai_models::table
|
||||
.filter(ai_models::model_id.eq(model))
|
||||
.select(ai_models::tool_call)
|
||||
.first::<i32>(connection)
|
||||
.optional()
|
||||
})?;
|
||||
Ok(catalog_value.map_or_else(
|
||||
|| {
|
||||
let model = model.to_ascii_lowercase();
|
||||
model.contains("gpt")
|
||||
|| model.contains("claude")
|
||||
|| model.contains("tool")
|
||||
|| model.contains("qwen")
|
||||
|| model.contains("mistral")
|
||||
},
|
||||
|value| value != 0,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn system_prompt(conn: &Connection, project_id: &str) -> EngineResult<String> {
|
||||
let posts = post::list_posts_by_project(conn, project_id)?;
|
||||
let media_count = media::count_media_by_project(conn, project_id)?;
|
||||
let tags = posts
|
||||
.iter()
|
||||
.flat_map(|post| post.tags.iter())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
let categories = posts
|
||||
.iter()
|
||||
.flat_map(|post| post.categories.iter())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
let configured = crate::engine::settings::get(conn, "ai.system_prompt")?.unwrap_or_default();
|
||||
let contract = format!(
|
||||
"You are the conversational assistant for this blog project. Use tools when facts from the project are needed. Never invent project content or identifiers. There are {} posts, {media_count} media items, {tags} tags, and {categories} categories. Keep answers concise and use GitHub-flavored Markdown when useful. Use render tools for structured data, comparisons, and forms; their payloads are native UI data, never HTML or JavaScript.",
|
||||
posts.len()
|
||||
);
|
||||
Ok(if configured.trim().is_empty() {
|
||||
contract
|
||||
} else {
|
||||
format!("{}\n\n{contract}", configured.trim())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_specs() -> Vec<Value> {
|
||||
vec![
|
||||
spec(
|
||||
"get_blog_stats",
|
||||
"Return aggregate project statistics.",
|
||||
json!({}),
|
||||
),
|
||||
spec(
|
||||
"check_term",
|
||||
"Check whether a term is used as a tag or category.",
|
||||
json!({"term": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"search_posts",
|
||||
"Search post titles, slugs, excerpts, bodies, tags, and categories.",
|
||||
json!({"query": {"type": "string"}, "language": {"type": "string"}, "limit": {"type": "integer"}}),
|
||||
),
|
||||
spec(
|
||||
"read_post",
|
||||
"Read one post by id.",
|
||||
json!({"post_id": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"read_post_by_slug",
|
||||
"Read one post by slug.",
|
||||
json!({"slug": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"list_posts",
|
||||
"List posts, optionally filtering by status, tag, or category.",
|
||||
json!({"status": {"type": "string"}, "tag": {"type": "string"}, "category": {"type": "string"}, "limit": {"type": "integer"}}),
|
||||
),
|
||||
spec(
|
||||
"count_posts",
|
||||
"Count posts and optionally group by status, tag, or category.",
|
||||
json!({"group_by": {"type": "string", "enum": ["status", "tag", "category"]}}),
|
||||
),
|
||||
spec(
|
||||
"update_post_metadata",
|
||||
"Update title, excerpt, tags, or categories on a post.",
|
||||
json!({"post_id": {"type": "string"}, "title": {"type": "string"}, "excerpt": {"type": ["string", "null"]}, "tags": {"type": "array", "items": {"type": "string"}}, "categories": {"type": "array", "items": {"type": "string"}}}),
|
||||
),
|
||||
spec(
|
||||
"list_media",
|
||||
"List project media.",
|
||||
json!({"limit": {"type": "integer"}}),
|
||||
),
|
||||
spec(
|
||||
"get_media",
|
||||
"Get one media item.",
|
||||
json!({"media_id": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"view_image",
|
||||
"Return a local image thumbnail as a data URL for visual inspection.",
|
||||
json!({"media_id": {"type": "string"}, "size": {"type": "string", "enum": ["small", "medium", "large"]}}),
|
||||
),
|
||||
spec(
|
||||
"update_media_metadata",
|
||||
"Update title, alt text, caption, or tags on media.",
|
||||
json!({"media_id": {"type": "string"}, "title": {"type": ["string", "null"]}, "alt": {"type": ["string", "null"]}, "caption": {"type": ["string", "null"]}, "tags": {"type": "array", "items": {"type": "string"}}}),
|
||||
),
|
||||
spec("list_tags", "List tags and usage counts.", json!({})),
|
||||
spec(
|
||||
"list_categories",
|
||||
"List categories and usage counts.",
|
||||
json!({}),
|
||||
),
|
||||
spec(
|
||||
"get_post_backlinks",
|
||||
"Get posts that link to a post.",
|
||||
json!({"post_id": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"get_post_outlinks",
|
||||
"Get posts linked from a post.",
|
||||
json!({"post_id": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"get_post_media",
|
||||
"Get media linked to a post.",
|
||||
json!({"post_id": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"get_media_posts",
|
||||
"Get posts that use a media item.",
|
||||
json!({"media_id": {"type": "string"}}),
|
||||
),
|
||||
spec("list_templates", "List templates.", json!({})),
|
||||
spec(
|
||||
"read_template",
|
||||
"Read a template by id.",
|
||||
json!({"template_id": {"type": "string"}}),
|
||||
),
|
||||
spec("list_scripts", "List scripts.", json!({})),
|
||||
spec(
|
||||
"read_script",
|
||||
"Read a script by id.",
|
||||
json!({"script_id": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"navigate",
|
||||
"Open a project area or entity in the application.",
|
||||
json!({
|
||||
"action": {"type": "string", "enum": ["open_post", "open_media", "open_settings", "open_chat", "switch_view", "toggle_sidebar", "toggle_panel", "toggle_assistant_sidebar"]},
|
||||
"destination": {"type": "string", "enum": ["posts", "pages", "media", "templates", "scripts", "tags", "chat", "import", "git", "settings"]},
|
||||
"entity_id": {"type": "string"},
|
||||
"value": {"type": "string"}
|
||||
}),
|
||||
),
|
||||
spec(
|
||||
"render_card",
|
||||
"Render an information card with optional allow-listed actions.",
|
||||
json!({
|
||||
"title": {"type": "string"}, "subtitle": {"type": "string"}, "body": {"type": "string"},
|
||||
"actions": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"label": {"type": "string"}, "action": {"type": "string"}, "payload": {"type": "object"}
|
||||
}, "required": ["label", "action"]}}
|
||||
}),
|
||||
),
|
||||
spec(
|
||||
"render_chart",
|
||||
"Render a native chart; heatmap and stacked-bar series use labelled segments.",
|
||||
json!({
|
||||
"chartType": {"type": "string", "enum": ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"]},
|
||||
"chart_type": {"type": "string", "enum": ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"]},
|
||||
"title": {"type": "string"},
|
||||
"series": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"label": {"type": "string"}, "value": {"type": "number"},
|
||||
"segments": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"label": {"type": "string"}, "value": {"type": "number"}
|
||||
}, "required": ["label", "value"]}}
|
||||
}, "required": ["label"]}}
|
||||
}),
|
||||
),
|
||||
spec(
|
||||
"render_form",
|
||||
"Render a native form that submits its current values with an allow-listed action.",
|
||||
json!({
|
||||
"title": {"type": "string"},
|
||||
"fields": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"key": {"type": "string"}, "label": {"type": "string"},
|
||||
"inputType": {"type": "string", "enum": ["text", "textarea", "select", "checkbox", "date", "number"]},
|
||||
"input_type": {"type": "string", "enum": ["text", "textarea", "select", "checkbox", "date", "number"]},
|
||||
"placeholder": {"type": "string"}, "defaultValue": {}, "default_value": {}, "required": {"type": "boolean"},
|
||||
"options": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"label": {"type": "string"}, "value": {"type": "string"}
|
||||
}}}
|
||||
}, "required": ["key", "label", "inputType"]}},
|
||||
"submitLabel": {"type": "string"}, "submit_label": {"type": "string"},
|
||||
"submitAction": {"type": "string"}, "submit_action": {"type": "string"}
|
||||
}),
|
||||
),
|
||||
spec(
|
||||
"render_list",
|
||||
"Render a native list.",
|
||||
json!({"title": {"type": "string"}, "items": {"type": "array", "items": {"type": "string"}}}),
|
||||
),
|
||||
spec(
|
||||
"render_metric",
|
||||
"Render a prominent metric.",
|
||||
json!({"label": {"type": "string"}, "value": {"type": "string"}}),
|
||||
),
|
||||
spec(
|
||||
"render_mindmap",
|
||||
"Render a native hierarchical mind map.",
|
||||
json!({"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"id": {"type": "string"}, "label": {"type": "string"}, "children": {"type": "array", "items": {"type": "string"}}
|
||||
}, "required": ["label"]}}}),
|
||||
),
|
||||
spec(
|
||||
"render_table",
|
||||
"Render a native data table.",
|
||||
json!({
|
||||
"title": {"type": "string"}, "columns": {"type": "array", "items": {"type": "string"}},
|
||||
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}}
|
||||
}),
|
||||
),
|
||||
spec(
|
||||
"render_tabs",
|
||||
"Render switchable tabs containing nested native surfaces or text.",
|
||||
json!({"title": {"type": "string"}, "tabs": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"label": {"type": "string"}, "content": {"type": "array", "items": {"type": "object"}}
|
||||
}, "required": ["label", "content"]}}}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
) -> EngineResult<Value> {
|
||||
match name {
|
||||
"blog_stats" | "get_blog_stats" => blog_stats(conn, project_id),
|
||||
"check_term" => check_term(conn, project_id, required_str(arguments, "term")?),
|
||||
"search_posts" => search_posts(conn, project_id, arguments),
|
||||
"read_post" => {
|
||||
let item = post::get_post_by_id(conn, required_id(arguments, "post_id", "postId")?)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
post_detail(data_dir, item)
|
||||
}
|
||||
"read_post_by_slug" => {
|
||||
let item = post::get_post_by_project_and_slug(
|
||||
conn,
|
||||
project_id,
|
||||
required_str(arguments, "slug")?,
|
||||
)?;
|
||||
post_detail(data_dir, item)
|
||||
}
|
||||
"list_posts" => list_posts(conn, project_id, arguments),
|
||||
"count_posts" => count_posts(conn, project_id, arguments),
|
||||
"update_post_metadata" => update_post_metadata(conn, data_dir, project_id, arguments),
|
||||
"list_media" => list_media(conn, project_id, arguments),
|
||||
"get_media" => {
|
||||
let item =
|
||||
media::get_media_by_id(conn, required_id(arguments, "media_id", "mediaId")?)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
Ok(json!({"success": true, "media": item}))
|
||||
}
|
||||
"view_image" => view_image(conn, data_dir, project_id, arguments),
|
||||
"update_media_metadata" => update_media_metadata(conn, data_dir, project_id, arguments),
|
||||
"list_tags" => counted_terms(conn, project_id, true),
|
||||
"list_categories" => counted_terms(conn, project_id, false),
|
||||
"get_post_backlinks" => post_links(conn, project_id, arguments, true),
|
||||
"get_post_outlinks" => post_links(conn, project_id, arguments, false),
|
||||
"get_post_media" => linked_media(conn, project_id, arguments),
|
||||
"get_media_posts" => linked_posts(conn, project_id, arguments),
|
||||
"list_templates" => Ok(json!({
|
||||
"templates": template::list_templates_by_project(conn, project_id)?,
|
||||
})),
|
||||
"read_template" => {
|
||||
let item = template::get_template_by_id(
|
||||
conn,
|
||||
required_id(arguments, "template_id", "templateId")?,
|
||||
)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
Ok(json!({"success": true, "template": item}))
|
||||
}
|
||||
"list_scripts" => Ok(json!({
|
||||
"scripts": script::list_scripts_by_project(conn, project_id)?,
|
||||
})),
|
||||
"read_script" => {
|
||||
let item =
|
||||
script::get_script_by_id(conn, required_id(arguments, "script_id", "scriptId")?)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
Ok(json!({"success": true, "script": item}))
|
||||
}
|
||||
"navigate" => navigate(arguments),
|
||||
name if chat_surfaces::RENDER_TOOL_NAMES.contains(&name) => {
|
||||
Ok(chat_surfaces::render_tool_result(name, arguments)
|
||||
.expect("render tool allow-list and result builder must stay in sync"))
|
||||
}
|
||||
_ => Ok(json!({"success": false, "error": "unknown_tool", "name": name})),
|
||||
}
|
||||
}
|
||||
|
||||
fn blog_stats(conn: &Connection, project_id: &str) -> EngineResult<Value> {
|
||||
let posts = post::list_posts_by_project(conn, project_id)?;
|
||||
let media_count = media::count_media_by_project(conn, project_id)?;
|
||||
let templates = template::list_templates_by_project(conn, project_id)?.len();
|
||||
let scripts = script::list_scripts_by_project(conn, project_id)?.len();
|
||||
let tags = posts
|
||||
.iter()
|
||||
.flat_map(|item| &item.tags)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
let categories = posts
|
||||
.iter()
|
||||
.flat_map(|item| &item.categories)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
Ok(json!({
|
||||
"posts": posts.len(), "media": media_count, "templates": templates,
|
||||
"scripts": scripts, "tags": tags, "categories": categories,
|
||||
}))
|
||||
}
|
||||
|
||||
fn check_term(conn: &Connection, project_id: &str, term: &str) -> EngineResult<Value> {
|
||||
let term = term.to_lowercase();
|
||||
let posts = post::list_posts_by_project(conn, project_id)?;
|
||||
let tag_count = posts
|
||||
.iter()
|
||||
.filter(|item| item.tags.iter().any(|value| value.to_lowercase() == term))
|
||||
.count();
|
||||
let category_count = posts
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.categories
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase() == term)
|
||||
})
|
||||
.count();
|
||||
Ok(json!({"term": term, "tag_posts": tag_count, "category_posts": category_count}))
|
||||
}
|
||||
|
||||
fn search_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
|
||||
let query = required_str(arguments, "query")?;
|
||||
let language = arguments
|
||||
.get("language")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("en");
|
||||
let limit = limit(arguments);
|
||||
let mut matches = Vec::new();
|
||||
for id in crate::db::fts::search_posts(conn, query, language)? {
|
||||
if let Ok(item) = post::get_post_by_id(conn, &id)
|
||||
&& item.project_id == project_id
|
||||
{
|
||||
matches.push(post_summary(&item));
|
||||
if matches.len() == limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"posts": matches, "count": matches.len()}))
|
||||
}
|
||||
|
||||
fn list_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
|
||||
let status = arguments.get("status").and_then(Value::as_str);
|
||||
let tag = arguments.get("tag").and_then(Value::as_str);
|
||||
let category = arguments.get("category").and_then(Value::as_str);
|
||||
let items = post::list_posts_by_project(conn, project_id)?
|
||||
.into_iter()
|
||||
.filter(|item| status.is_none_or(|value| item.status.as_str() == value))
|
||||
.filter(|item| tag.is_none_or(|value| contains_case_insensitive(&item.tags, value)))
|
||||
.filter(|item| {
|
||||
category.is_none_or(|value| contains_case_insensitive(&item.categories, value))
|
||||
})
|
||||
.take(limit(arguments))
|
||||
.map(|item| post_summary(&item))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"posts": items, "count": items.len()}))
|
||||
}
|
||||
|
||||
fn count_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
|
||||
let items = post::list_posts_by_project(conn, project_id)?;
|
||||
let Some(group_by) = arguments
|
||||
.get("group_by")
|
||||
.or_else(|| arguments.get("groupBy"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
return Ok(json!({"total_posts": items.len()}));
|
||||
};
|
||||
let mut groups = BTreeMap::<String, usize>::new();
|
||||
for item in &items {
|
||||
let values: Vec<String> = match group_by {
|
||||
"status" => vec![item.status.as_str().to_string()],
|
||||
"tag" => item.tags.clone(),
|
||||
"category" => item.categories.clone(),
|
||||
_ => {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"unsupported post grouping: {group_by}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
for value in values {
|
||||
*groups.entry(value).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"total_posts": items.len(), "group_by": group_by, "groups": groups}))
|
||||
}
|
||||
|
||||
fn update_post_metadata(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
arguments: &Value,
|
||||
) -> EngineResult<Value> {
|
||||
let id = required_id(arguments, "post_id", "postId")?;
|
||||
let existing = post::get_post_by_id(conn, id)?;
|
||||
ensure_project(&existing.project_id, project_id)?;
|
||||
if !["title", "excerpt", "tags", "categories"]
|
||||
.iter()
|
||||
.any(|key| arguments.get(key).is_some())
|
||||
{
|
||||
return Err(EngineError::Validation(
|
||||
"no post metadata updates provided".to_string(),
|
||||
));
|
||||
}
|
||||
let excerpt = optional_nullable_str(arguments, "excerpt")?;
|
||||
let item = crate::engine::post::update_post(
|
||||
conn,
|
||||
data_dir,
|
||||
id,
|
||||
arguments.get("title").and_then(Value::as_str),
|
||||
None,
|
||||
excerpt,
|
||||
None,
|
||||
optional_string_array(arguments, "tags")?,
|
||||
optional_string_array(arguments, "categories")?,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
Ok(json!({"success": true, "post": post_summary(&item)}))
|
||||
}
|
||||
|
||||
fn list_media(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
|
||||
let items = media::list_media_by_project(conn, project_id)?
|
||||
.into_iter()
|
||||
.take(limit(arguments))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"media": items, "count": items.len()}))
|
||||
}
|
||||
|
||||
fn update_media_metadata(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
arguments: &Value,
|
||||
) -> EngineResult<Value> {
|
||||
let id = required_id(arguments, "media_id", "mediaId")?;
|
||||
let existing = media::get_media_by_id(conn, id)?;
|
||||
ensure_project(&existing.project_id, project_id)?;
|
||||
if !["title", "alt", "caption", "tags"]
|
||||
.iter()
|
||||
.any(|key| arguments.get(key).is_some())
|
||||
{
|
||||
return Err(EngineError::Validation(
|
||||
"no media metadata updates provided".to_string(),
|
||||
));
|
||||
}
|
||||
let item = crate::engine::media::update_media(
|
||||
conn,
|
||||
data_dir,
|
||||
id,
|
||||
optional_nullable_str(arguments, "title")?,
|
||||
optional_nullable_str(arguments, "alt")?,
|
||||
optional_nullable_str(arguments, "caption")?,
|
||||
None,
|
||||
None,
|
||||
optional_string_array(arguments, "tags")?,
|
||||
)?;
|
||||
Ok(json!({"success": true, "media": item}))
|
||||
}
|
||||
|
||||
fn view_image(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
arguments: &Value,
|
||||
) -> EngineResult<Value> {
|
||||
let item = media::get_media_by_id(conn, required_id(arguments, "media_id", "mediaId")?)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
if !item.mime_type.starts_with("image/") {
|
||||
return Ok(json!({"success": false, "error": "not_image", "mime_type": item.mime_type}));
|
||||
}
|
||||
let size = arguments
|
||||
.get("size")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("medium");
|
||||
if !["small", "medium", "large"].contains(&size) {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"unsupported thumbnail size: {size}"
|
||||
)));
|
||||
}
|
||||
let path = data_dir.join(crate::util::thumbnail_path(&item.id, size, "webp"));
|
||||
if !path.is_file() {
|
||||
return Ok(json!({"success": false, "error": "thumbnail_not_available"}));
|
||||
}
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(fs::read(path)?);
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"media": item,
|
||||
"data_url": format!("data:image/webp;base64,{encoded}"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn post_links(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
arguments: &Value,
|
||||
incoming: bool,
|
||||
) -> EngineResult<Value> {
|
||||
let id = required_id(arguments, "post_id", "postId")?;
|
||||
let source = post::get_post_by_id(conn, id)?;
|
||||
ensure_project(&source.project_id, project_id)?;
|
||||
let links = if incoming {
|
||||
post_link::list_links_by_target(conn, id)?
|
||||
} else {
|
||||
post_link::list_links_by_source(conn, id)?
|
||||
};
|
||||
let mut items = Vec::with_capacity(links.len());
|
||||
for link in links {
|
||||
let linked_id = if incoming {
|
||||
&link.source_post_id
|
||||
} else {
|
||||
&link.target_post_id
|
||||
};
|
||||
let linked = post::get_post_by_id(conn, linked_id)?;
|
||||
ensure_project(&linked.project_id, project_id)?;
|
||||
items.push(json!({
|
||||
"post": post_summary(&linked),
|
||||
"link_text": link.link_text,
|
||||
}));
|
||||
}
|
||||
if incoming {
|
||||
Ok(json!({"success": true, "post_id": id, "linked_by": items}))
|
||||
} else {
|
||||
Ok(json!({"success": true, "post_id": id, "links_to": items}))
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_media(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
|
||||
let id = required_id(arguments, "post_id", "postId")?;
|
||||
let item = post::get_post_by_id(conn, id)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
let mut items = Vec::new();
|
||||
for link in post_media::list_post_media_by_post(conn, id)? {
|
||||
ensure_project(&link.project_id, project_id)?;
|
||||
let item = media::get_media_by_id(conn, &link.media_id)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
items.push(json!({"media": item, "sort_order": link.sort_order}));
|
||||
}
|
||||
Ok(json!({"success": true, "post_id": id, "media": items}))
|
||||
}
|
||||
|
||||
fn linked_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
|
||||
let id = required_id(arguments, "media_id", "mediaId")?;
|
||||
let item = media::get_media_by_id(conn, id)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
let mut items = Vec::new();
|
||||
for link in post_media::list_post_media_by_media(conn, id)? {
|
||||
ensure_project(&link.project_id, project_id)?;
|
||||
let item = post::get_post_by_id(conn, &link.post_id)?;
|
||||
ensure_project(&item.project_id, project_id)?;
|
||||
items.push(json!({"post": post_summary(&item), "sort_order": link.sort_order}));
|
||||
}
|
||||
Ok(json!({"success": true, "media_id": id, "posts": items}))
|
||||
}
|
||||
|
||||
fn counted_terms(conn: &Connection, project_id: &str, tags: bool) -> EngineResult<Value> {
|
||||
let mut counts = BTreeMap::<String, usize>::new();
|
||||
for item in post::list_posts_by_project(conn, project_id)? {
|
||||
for term in if tags { &item.tags } else { &item.categories } {
|
||||
*counts.entry(term.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let values = counts
|
||||
.into_iter()
|
||||
.map(|(name, count)| json!({"name": name, "count": count}))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(if tags {
|
||||
json!({"tags": values, "count": values.len()})
|
||||
} else {
|
||||
json!({"categories": values, "count": values.len()})
|
||||
})
|
||||
}
|
||||
|
||||
fn navigate(arguments: &Value) -> EngineResult<Value> {
|
||||
let action = arguments.get("action").and_then(Value::as_str);
|
||||
let value = arguments
|
||||
.get("value")
|
||||
.or_else(|| arguments.get("entity_id"))
|
||||
.or_else(|| arguments.get("entityId"))
|
||||
.and_then(Value::as_str);
|
||||
let (destination, entity_id) = match action {
|
||||
Some("open_post" | "openPost") => ("posts", required_navigation_value(value, "post")?),
|
||||
Some("open_media" | "openMedia") => ("media", required_navigation_value(value, "media")?),
|
||||
Some("open_chat" | "openChat") => ("chat", required_navigation_value(value, "chat")?),
|
||||
Some("open_settings" | "openSettings") => ("settings", None),
|
||||
Some("switch_view" | "switchView") => {
|
||||
(required_navigation_value(value, "view")?.unwrap(), None)
|
||||
}
|
||||
Some("toggle_sidebar" | "toggleSidebar") => ("toggle_sidebar", None),
|
||||
Some("toggle_panel" | "togglePanel") => ("toggle_panel", None),
|
||||
Some("toggle_assistant_sidebar" | "toggleAssistantSidebar") => {
|
||||
("toggle_assistant_sidebar", None)
|
||||
}
|
||||
Some(action) => {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"unsupported navigation action: {action}"
|
||||
)));
|
||||
}
|
||||
None => (required_str(arguments, "destination")?, value),
|
||||
};
|
||||
if ![
|
||||
"posts",
|
||||
"pages",
|
||||
"media",
|
||||
"templates",
|
||||
"scripts",
|
||||
"tags",
|
||||
"chat",
|
||||
"import",
|
||||
"git",
|
||||
"settings",
|
||||
"toggle_sidebar",
|
||||
"toggle_panel",
|
||||
"toggle_assistant_sidebar",
|
||||
]
|
||||
.contains(&destination)
|
||||
{
|
||||
return Err(EngineError::Validation(format!(
|
||||
"unsupported navigation destination: {destination}"
|
||||
)));
|
||||
}
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"navigation": {
|
||||
"destination": destination,
|
||||
"entity_id": entity_id,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn post_detail(data_dir: &Path, item: crate::model::Post) -> EngineResult<Value> {
|
||||
let body = post_body(data_dir, &item)?;
|
||||
Ok(json!({"success": true, "post": item, "body": body}))
|
||||
}
|
||||
|
||||
fn post_body(data_dir: &Path, item: &crate::model::Post) -> EngineResult<String> {
|
||||
if let Some(content) = item.content.as_deref() {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
if item.file_path.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let raw = fs::read_to_string(data_dir.join(&item.file_path))?;
|
||||
let (_, body) = read_post_file(&raw)
|
||||
.map_err(|error| EngineError::Parse(format!("invalid post file: {error}")))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn post_summary(item: &crate::model::Post) -> Value {
|
||||
json!({
|
||||
"id": item.id, "title": item.title, "slug": item.slug,
|
||||
"excerpt": item.excerpt, "status": item.status, "tags": item.tags,
|
||||
"categories": item.categories, "created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn spec(name: &str, description: &str, properties: Value) -> Value {
|
||||
let required = properties
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flat_map(|values| values.iter())
|
||||
.filter(|(_, schema)| !schema.get("type").is_some_and(Value::is_array))
|
||||
.filter(|(name, _)| {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"term"
|
||||
| "query"
|
||||
| "post_id"
|
||||
| "slug"
|
||||
| "media_id"
|
||||
| "template_id"
|
||||
| "script_id"
|
||||
| "destination"
|
||||
)
|
||||
})
|
||||
.map(|(name, _)| Value::String(name.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let required = if name == "navigate" {
|
||||
Vec::new()
|
||||
} else {
|
||||
required
|
||||
};
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn required_navigation_value<'a>(
|
||||
value: Option<&'a str>,
|
||||
target: &str,
|
||||
) -> EngineResult<Option<&'a str>> {
|
||||
value
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
EngineError::Validation(format!("navigation {target} identifier is required"))
|
||||
})
|
||||
}
|
||||
|
||||
fn required_str<'a>(arguments: &'a Value, key: &str) -> EngineResult<&'a str> {
|
||||
arguments
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| EngineError::Validation(format!("tool argument {key} is required")))
|
||||
}
|
||||
|
||||
fn required_id<'a>(arguments: &'a Value, snake: &str, camel: &str) -> EngineResult<&'a str> {
|
||||
arguments
|
||||
.get(snake)
|
||||
.or_else(|| arguments.get(camel))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| EngineError::Validation(format!("tool argument {snake} is required")))
|
||||
}
|
||||
|
||||
fn ensure_project(actual: &str, expected: &str) -> EngineResult<()> {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EngineError::NotFound("project entity".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn limit(arguments: &Value) -> usize {
|
||||
arguments
|
||||
.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(25)
|
||||
.clamp(1, 100) as usize
|
||||
}
|
||||
|
||||
fn contains_case_insensitive(values: &[String], needle: &str) -> bool {
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
fn optional_string_array(arguments: &Value, key: &str) -> EngineResult<Option<Vec<String>>> {
|
||||
let Some(value) = arguments.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let values = value
|
||||
.as_array()
|
||||
.ok_or_else(|| EngineError::Validation(format!("tool argument {key} must be an array")))?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value.as_str().map(str::to_string).ok_or_else(|| {
|
||||
EngineError::Validation(format!("tool argument {key} must contain strings"))
|
||||
})
|
||||
})
|
||||
.collect::<EngineResult<Vec<_>>>()?;
|
||||
Ok(Some(values))
|
||||
}
|
||||
|
||||
fn optional_nullable_str<'a>(
|
||||
arguments: &'a Value,
|
||||
key: &str,
|
||||
) -> EngineResult<Option<Option<&'a str>>> {
|
||||
match arguments.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(value)) => Ok(Some(Some(value))),
|
||||
Some(_) => Err(EngineError::Validation(format!(
|
||||
"tool argument {key} must be a string or null"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::post_link::insert_post_link;
|
||||
use crate::db::queries::post_media::link_media;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::{PostLink, PostMedia};
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("source", "p1", "source")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("target", "p1", "target")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("media1", "p1")).unwrap();
|
||||
insert_post_link(
|
||||
db.conn(),
|
||||
&PostLink {
|
||||
id: "link1".into(),
|
||||
source_post_id: "source".into(),
|
||||
target_post_id: "target".into(),
|
||||
link_text: Some("read next".into()),
|
||||
created_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
link_media(
|
||||
db.conn(),
|
||||
&PostMedia {
|
||||
id: "post-media1".into(),
|
||||
project_id: "p1".into(),
|
||||
post_id: "source".into(),
|
||||
media_id: "media1".into(),
|
||||
sort_order: 3,
|
||||
created_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_tools_return_project_entities() {
|
||||
let db = setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outlinks = execute(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"get_post_outlinks",
|
||||
&json!({"post_id": "source"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outlinks["links_to"][0]["post"]["id"], "target");
|
||||
assert_eq!(outlinks["links_to"][0]["link_text"], "read next");
|
||||
|
||||
let backlinks = execute(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"get_post_backlinks",
|
||||
&json!({"post_id": "target"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(backlinks["linked_by"][0]["post"]["id"], "source");
|
||||
|
||||
let post_media = execute(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"get_post_media",
|
||||
&json!({"post_id": "source"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post_media["media"][0]["media"]["id"], "media1");
|
||||
assert_eq!(post_media["media"][0]["sort_order"], 3);
|
||||
|
||||
let media_posts = execute(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"get_media_posts",
|
||||
&json!({"media_id": "media1"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(media_posts["posts"][0]["post"]["id"], "source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_image_is_bounded_to_generated_image_thumbnails() {
|
||||
let db = setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let relative = crate::util::thumbnail_path("media1", "medium", "webp");
|
||||
let path = dir.path().join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, b"thumbnail").unwrap();
|
||||
|
||||
let result = execute(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"view_image",
|
||||
&json!({"media_id": "media1", "size": "medium"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["success"], true);
|
||||
assert_eq!(result["data_url"], "data:image/webp;base64,dGh1bWJuYWls");
|
||||
|
||||
let invalid = execute(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"view_image",
|
||||
&json!({"media_id": "media1", "size": "original"}),
|
||||
);
|
||||
assert!(matches!(invalid, Err(EngineError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_render_tools_are_advertised_and_return_inert_native_data() {
|
||||
let specs = tool_specs();
|
||||
let names = specs
|
||||
.iter()
|
||||
.filter_map(|spec| spec.pointer("/function/name").and_then(Value::as_str))
|
||||
.collect::<BTreeSet<_>>();
|
||||
for name in chat_surfaces::RENDER_TOOL_NAMES {
|
||||
assert!(names.contains(name), "missing tool schema for {name}");
|
||||
}
|
||||
let chart = specs
|
||||
.iter()
|
||||
.find(|spec| {
|
||||
spec.pointer("/function/name").and_then(Value::as_str) == Some("render_chart")
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
chart
|
||||
.pointer("/function/parameters/properties/chartType")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
chart
|
||||
.pointer("/function/parameters/properties/chart_type")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let db = setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let raw = json!({"title": "<script>alert(1)</script>", "body": "<b>data</b>"});
|
||||
let result = execute(db.conn(), dir.path(), "p1", "render_card", &raw).unwrap();
|
||||
assert_eq!(result["type"], "card");
|
||||
assert_eq!(result["title"], raw["title"]);
|
||||
assert_eq!(result["body"], raw["body"]);
|
||||
assert!(result.get("html").is_none());
|
||||
assert!(result.get("javascript").is_none());
|
||||
}
|
||||
}
|
||||
112
crates/bds-core/src/engine/cli_launcher.rs
Normal file
112
crates/bds-core/src/engine/cli_launcher.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
|
||||
/// Install a recoverable launcher pointing at a packaged `bds-cli` binary.
|
||||
/// Existing unrelated files are never overwritten.
|
||||
pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<PathBuf> {
|
||||
if !executable.is_file() {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"installing the CLI requires the packaged bds-cli executable (not found at {})",
|
||||
executable.display()
|
||||
)));
|
||||
}
|
||||
let bin_dir = home_dir.join(".local/bin");
|
||||
std::fs::create_dir_all(&bin_dir)?;
|
||||
let target = bin_dir.join(if cfg!(windows) {
|
||||
"bds-cli.cmd"
|
||||
} else {
|
||||
"bds-cli"
|
||||
});
|
||||
let source = executable.canonicalize()?;
|
||||
let launcher = launcher_contents(&source);
|
||||
#[cfg(unix)]
|
||||
if target.is_symlink() && target.canonicalize().ok().as_ref() == Some(&source) {
|
||||
std::fs::remove_file(&target)?;
|
||||
}
|
||||
if target.exists() {
|
||||
if std::fs::read(&target).ok().as_deref() != Some(launcher.as_bytes()) {
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"refusing to overwrite existing launcher at {}",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
std::fs::write(&target, launcher)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mut permissions = std::fs::metadata(&target)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&target, permissions)?;
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn launcher_contents(executable: &Path) -> String {
|
||||
let quoted = executable.to_string_lossy().replace('\'', "'\"'\"'");
|
||||
format!("#!/bin/sh\nexec '{quoted}' \"$@\"\n")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launcher_contents(executable: &Path) -> String {
|
||||
let escaped = executable.to_string_lossy().replace('%', "%%");
|
||||
format!("@echo off\r\n\"{escaped}\" %*\r\n")
|
||||
}
|
||||
|
||||
/// Resolve the CLI shipped beside the desktop executable and install it.
|
||||
pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
|
||||
let app = std::env::current_exe()?;
|
||||
let cli = app.with_file_name(if cfg!(windows) {
|
||||
"bds-cli.exe"
|
||||
} else {
|
||||
"bds-cli"
|
||||
});
|
||||
install_launcher(&cli, home_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn install_is_idempotent_and_never_overwrites_an_unrelated_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let executable = root.path().join("packaged-bds-cli");
|
||||
std::fs::write(&executable, b"binary").unwrap();
|
||||
let home = root.path().join("home");
|
||||
let target = install_launcher(&executable, &home).unwrap();
|
||||
assert!(!target.is_symlink());
|
||||
let launcher = std::fs::read_to_string(&target).unwrap();
|
||||
assert!(launcher.contains(executable.canonicalize().unwrap().to_str().unwrap()));
|
||||
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
|
||||
|
||||
std::fs::remove_file(&target).unwrap();
|
||||
std::fs::write(&target, b"mine").unwrap();
|
||||
assert!(install_launcher(&executable, &home).is_err());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"mine");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn replaces_the_previous_installer_symlink_with_a_forwarding_launcher() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let executable = root.path().join("packaged-bds-cli");
|
||||
std::fs::write(&executable, b"binary").unwrap();
|
||||
let home = root.path().join("home");
|
||||
let target = home.join(".local/bin/bds-cli");
|
||||
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
std::os::unix::fs::symlink(&executable, &target).unwrap();
|
||||
|
||||
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
|
||||
assert!(!target.is_symlink());
|
||||
assert!(
|
||||
std::fs::read_to_string(target)
|
||||
.unwrap()
|
||||
.starts_with("#!/bin/sh")
|
||||
);
|
||||
}
|
||||
}
|
||||
222
crates/bds-core/src/engine/cli_sync.rs
Normal file
222
crates/bds-core/src/engine/cli_sync.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::queries::db_notification as qn;
|
||||
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||
use crate::model::{DomainEntity, DomainEvent, NotificationAction};
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
pub const PROCESSED_TTL_MS: i64 = 60 * 60 * 1_000;
|
||||
pub const UNPROCESSED_TTL_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct PruneResult {
|
||||
pub processed: usize,
|
||||
pub unprocessed: usize,
|
||||
}
|
||||
|
||||
/// Run a future CLI mutation through the same in-process event path, then
|
||||
/// persist only the successfully published events for the desktop watcher.
|
||||
pub fn run_cli_mutation<T>(
|
||||
conn: &Connection,
|
||||
operation: impl FnOnce() -> EngineResult<T>,
|
||||
) -> EngineResult<T> {
|
||||
let (result, events) = domain_events::capture_current_thread(operation)
|
||||
.map_err(|message| EngineError::Validation(message.to_string()))?;
|
||||
let mut unique = Vec::<DomainEvent>::new();
|
||||
for event in events {
|
||||
if let Some(seen) = unique
|
||||
.iter_mut()
|
||||
.find(|seen| same_notification(seen, &event))
|
||||
{
|
||||
merge_notification_action(seen, &event);
|
||||
} else {
|
||||
unique.push(event);
|
||||
}
|
||||
}
|
||||
for event in &unique {
|
||||
record_cli_event(conn, event)?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn merge_notification_action(existing: &mut DomainEvent, incoming: &DomainEvent) {
|
||||
let (
|
||||
DomainEvent::EntityChanged {
|
||||
action: existing_action,
|
||||
..
|
||||
},
|
||||
DomainEvent::EntityChanged {
|
||||
action: incoming_action,
|
||||
..
|
||||
},
|
||||
) = (existing, incoming)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if *incoming_action == NotificationAction::Deleted
|
||||
|| *existing_action != NotificationAction::Created
|
||||
{
|
||||
*existing_action = incoming_action.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn same_notification(left: &DomainEvent, right: &DomainEvent) -> bool {
|
||||
match (left, right) {
|
||||
(
|
||||
DomainEvent::EntityChanged {
|
||||
project_id: left_project,
|
||||
entity: left_entity,
|
||||
entity_id: left_id,
|
||||
..
|
||||
},
|
||||
DomainEvent::EntityChanged {
|
||||
project_id: right_project,
|
||||
entity: right_entity,
|
||||
entity_id: right_id,
|
||||
..
|
||||
},
|
||||
) => left_project == right_project && left_entity == right_entity && left_id == right_id,
|
||||
(
|
||||
DomainEvent::SettingsChanged {
|
||||
project_id: left_project,
|
||||
key: left_key,
|
||||
},
|
||||
DomainEvent::SettingsChanged {
|
||||
project_id: right_project,
|
||||
key: right_key,
|
||||
},
|
||||
) => left_project == right_project && left_key == right_key,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_cli_event(conn: &Connection, event: &DomainEvent) -> EngineResult<()> {
|
||||
record_cli_event_at(conn, event, now_unix_ms())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn record_cli_event_at(
|
||||
conn: &Connection,
|
||||
event: &DomainEvent,
|
||||
created_at: i64,
|
||||
) -> EngineResult<()> {
|
||||
let (entity, entity_id, action, project_id) = match event {
|
||||
DomainEvent::EntityChanged {
|
||||
project_id,
|
||||
entity,
|
||||
entity_id,
|
||||
action,
|
||||
} => (
|
||||
entity.clone(),
|
||||
entity_id.as_str(),
|
||||
action.clone(),
|
||||
Some(project_id.as_str()),
|
||||
),
|
||||
DomainEvent::SettingsChanged { project_id, key } => (
|
||||
DomainEntity::Setting,
|
||||
key.as_str(),
|
||||
NotificationAction::Updated,
|
||||
project_id.as_deref(),
|
||||
),
|
||||
};
|
||||
qn::insert_notification(
|
||||
conn, &entity, entity_id, &action, true, None, created_at, project_id,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn consume_cli_notifications(conn: &Connection) -> EngineResult<Vec<DomainEvent>> {
|
||||
consume_cli_notifications_at(conn, now_unix_ms())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn consume_cli_notifications_at(
|
||||
conn: &Connection,
|
||||
seen_at: i64,
|
||||
) -> EngineResult<Vec<DomainEvent>> {
|
||||
conn.begin_savepoint()?;
|
||||
let result = (|| {
|
||||
let notifications = qn::list_unseen_cli_notifications(conn)?;
|
||||
let ids = notifications.iter().map(|item| item.id).collect::<Vec<_>>();
|
||||
qn::mark_notifications_seen(conn, &ids, seen_at)?;
|
||||
let events = notifications
|
||||
.into_iter()
|
||||
.filter_map(|notification| match notification.entity_type {
|
||||
DomainEntity::Setting => Some(DomainEvent::SettingsChanged {
|
||||
project_id: notification.project_id,
|
||||
key: notification.entity_id,
|
||||
}),
|
||||
entity => notification
|
||||
.project_id
|
||||
.or_else(|| legacy_project_id(conn, &entity, ¬ification.entity_id))
|
||||
.map(|project_id| DomainEvent::EntityChanged {
|
||||
project_id,
|
||||
entity,
|
||||
entity_id: notification.entity_id,
|
||||
action: notification.action,
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Ok(events)
|
||||
})();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
conn.release_savepoint()?;
|
||||
Ok(events)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_project_id(conn: &Connection, entity: &DomainEntity, entity_id: &str) -> Option<String> {
|
||||
let resolved = match entity {
|
||||
DomainEntity::Post => crate::db::queries::post::get_post_by_id(conn, entity_id)
|
||||
.ok()
|
||||
.map(|item| item.project_id),
|
||||
DomainEntity::Media => crate::db::queries::media::get_media_by_id(conn, entity_id)
|
||||
.ok()
|
||||
.map(|item| item.project_id),
|
||||
DomainEntity::Tag => crate::db::queries::tag::get_tag_by_id(conn, entity_id)
|
||||
.ok()
|
||||
.map(|item| item.project_id),
|
||||
DomainEntity::Template => crate::db::queries::template::get_template_by_id(conn, entity_id)
|
||||
.ok()
|
||||
.map(|item| item.project_id),
|
||||
DomainEntity::Script => crate::db::queries::script::get_script_by_id(conn, entity_id)
|
||||
.ok()
|
||||
.map(|item| item.project_id),
|
||||
DomainEntity::Project => Some(entity_id.to_string()),
|
||||
DomainEntity::Setting => None,
|
||||
};
|
||||
resolved.or_else(|| {
|
||||
crate::db::queries::project::get_active_project(conn)
|
||||
.ok()
|
||||
.map(|project| project.id)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prune_notifications(conn: &Connection) -> EngineResult<PruneResult> {
|
||||
prune_notifications_at(conn, now_unix_ms())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn prune_notifications_at(conn: &Connection, now: i64) -> EngineResult<PruneResult> {
|
||||
Ok(PruneResult {
|
||||
processed: qn::prune_processed(conn, now - PROCESSED_TTL_MS)?,
|
||||
unprocessed: qn::prune_unprocessed(conn, now - UNPROCESSED_TTL_MS)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Desktop watcher poll: consume once, publish through the shared bus, then
|
||||
/// apply both retention windows.
|
||||
pub fn poll_notifications(conn: &Connection) -> EngineResult<usize> {
|
||||
let events = consume_cli_notifications(conn)?;
|
||||
let count = events.len();
|
||||
for event in events {
|
||||
domain_events::publish(event);
|
||||
}
|
||||
prune_notifications(conn)?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Shared context passed to engine operations.
|
||||
pub struct EngineContext<'a> {
|
||||
pub conn: &'a rusqlite::Connection,
|
||||
pub project_id: String,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn context_holds_references() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = EngineContext {
|
||||
conn: db.conn(),
|
||||
project_id: "p1".into(),
|
||||
data_dir: dir.path().to_path_buf(),
|
||||
};
|
||||
assert_eq!(ctx.project_id, "p1");
|
||||
assert!(ctx.data_dir.exists());
|
||||
}
|
||||
}
|
||||
175
crates/bds-core/src/engine/domain_events.rs
Normal file
175
crates/bds-core/src/engine/domain_events.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::model::{DomainEntity, DomainEvent, NotificationAction};
|
||||
|
||||
#[derive(Default)]
|
||||
struct BusState {
|
||||
next_id: u64,
|
||||
subscribers: BTreeMap<u64, Sender<DomainEvent>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BusInner {
|
||||
state: Mutex<BusState>,
|
||||
publish_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
/// Minimal deterministic in-process event bus. Delivery is synchronous and
|
||||
/// ordered, while every subscriber owns an independent unbounded queue.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct EventBus {
|
||||
inner: Arc<BusInner>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn subscribe(&self) -> EventSubscription {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let _publish = lock(&self.inner.publish_lock);
|
||||
let mut state = lock(&self.inner.state);
|
||||
let id = state.next_id;
|
||||
state.next_id = state.next_id.wrapping_add(1);
|
||||
state.subscribers.insert(id, sender);
|
||||
EventSubscription {
|
||||
id: Some(id),
|
||||
inner: Arc::clone(&self.inner),
|
||||
receiver,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish(&self, event: DomainEvent) {
|
||||
let _publish = lock(&self.inner.publish_lock);
|
||||
let subscribers = lock(&self.inner.state)
|
||||
.subscribers
|
||||
.iter()
|
||||
.map(|(id, sender)| (*id, sender.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let disconnected = subscribers
|
||||
.into_iter()
|
||||
.filter_map(|(id, sender)| sender.send(event.clone()).is_err().then_some(id))
|
||||
.collect::<Vec<_>>();
|
||||
if !disconnected.is_empty() {
|
||||
let mut state = lock(&self.inner.state);
|
||||
for id in disconnected {
|
||||
state.subscribers.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EventSubscription {
|
||||
id: Option<u64>,
|
||||
inner: Arc<BusInner>,
|
||||
receiver: Receiver<DomainEvent>,
|
||||
}
|
||||
|
||||
impl EventSubscription {
|
||||
pub fn drain(&self) -> Vec<DomainEvent> {
|
||||
self.receiver.try_iter().collect()
|
||||
}
|
||||
|
||||
pub fn unsubscribe(mut self) {
|
||||
self.detach();
|
||||
}
|
||||
|
||||
fn detach(&mut self) {
|
||||
if let Some(id) = self.id.take() {
|
||||
let _publish = lock(&self.inner.publish_lock);
|
||||
lock(&self.inner.state).subscribers.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventSubscription {
|
||||
fn drop(&mut self) {
|
||||
self.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn global_bus() -> &'static EventBus {
|
||||
static BUS: OnceLock<EventBus> = OnceLock::new();
|
||||
BUS.get_or_init(EventBus::default)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CAPTURED_EVENTS: RefCell<Option<Vec<DomainEvent>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn subscribe() -> EventSubscription {
|
||||
global_bus().subscribe()
|
||||
}
|
||||
|
||||
pub fn publish(event: DomainEvent) {
|
||||
CAPTURED_EVENTS.with(|captured| {
|
||||
if let Some(events) = captured.borrow_mut().as_mut() {
|
||||
events.push(event.clone());
|
||||
}
|
||||
});
|
||||
global_bus().publish(event);
|
||||
}
|
||||
|
||||
pub fn entity_changed(
|
||||
project_id: &str,
|
||||
entity: DomainEntity,
|
||||
entity_id: &str,
|
||||
action: NotificationAction,
|
||||
) {
|
||||
publish(DomainEvent::EntityChanged {
|
||||
project_id: project_id.to_string(),
|
||||
entity,
|
||||
entity_id: entity_id.to_string(),
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn settings_changed(project_id: Option<&str>, key: &str) {
|
||||
publish(DomainEvent::SettingsChanged {
|
||||
project_id: project_id.map(str::to_string),
|
||||
key: key.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn capture_current_thread<T>(
|
||||
operation: impl FnOnce() -> T,
|
||||
) -> Result<(T, Vec<DomainEvent>), &'static str> {
|
||||
let started = CAPTURED_EVENTS.with(|captured| {
|
||||
let mut captured = captured.borrow_mut();
|
||||
if captured.is_some() {
|
||||
false
|
||||
} else {
|
||||
*captured = Some(Vec::new());
|
||||
true
|
||||
}
|
||||
});
|
||||
if !started {
|
||||
return Err("nested CLI event capture is not supported");
|
||||
}
|
||||
let mut reset = CaptureReset::default();
|
||||
let result = operation();
|
||||
let events = CAPTURED_EVENTS.with(|captured| captured.borrow_mut().take().unwrap_or_default());
|
||||
reset.finished = true;
|
||||
Ok((result, events))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CaptureReset {
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl Drop for CaptureReset {
|
||||
fn drop(&mut self) {
|
||||
if !self.finished {
|
||||
CAPTURED_EVENTS.with(|captured| {
|
||||
captured.borrow_mut().take();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
1428
crates/bds-core/src/engine/embedding.rs
Normal file
1428
crates/bds-core/src/engine/embedding.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,33 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced by engine operations.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
Db(rusqlite::Error),
|
||||
Io(std::io::Error),
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] diesel::result::Error),
|
||||
#[error("database connection error: {0}")]
|
||||
DbConnection(#[from] diesel::ConnectionError),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("operation cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Db(e) => write!(f, "database error: {e}"),
|
||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||||
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
|
||||
Self::Validation(msg) => write!(f, "validation error: {msg}"),
|
||||
impl From<crate::db::DatabaseError> for EngineError {
|
||||
fn from(e: crate::db::DatabaseError) -> Self {
|
||||
match e {
|
||||
crate::db::DatabaseError::Connection(e) => Self::DbConnection(e),
|
||||
crate::db::DatabaseError::Query(e) => Self::Db(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Db(e) => Some(e),
|
||||
Self::Io(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for EngineError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EngineError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for EngineError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
Self::Parse(e.to_string())
|
||||
@@ -65,23 +47,3 @@ impl From<serde_yaml::Error> for EngineError {
|
||||
}
|
||||
|
||||
pub type EngineResult<T> = Result<T, EngineError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_variants() {
|
||||
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error"));
|
||||
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found"));
|
||||
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict"));
|
||||
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_io_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
|
||||
let engine_err = EngineError::from(io_err);
|
||||
assert!(matches!(engine_err, EngineError::Io(_)));
|
||||
}
|
||||
}
|
||||
|
||||
617
crates/bds-core/src/engine/gallery_import.rs
Normal file
617
crates/bds-core/src/engine/gallery_import.rs
Normal file
@@ -0,0 +1,617 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine as _;
|
||||
use rayon::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::engine::{ai, media, post_media};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportedGalleryImage {
|
||||
pub media_id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GalleryImportOutcome {
|
||||
pub path: PathBuf,
|
||||
pub result: Result<ImportedGalleryImage, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GalleryImportReport {
|
||||
pub selected_count: usize,
|
||||
pub outcomes: Vec<GalleryImportOutcome>,
|
||||
}
|
||||
|
||||
pub fn active_ai_endpoint_configured(conn: &crate::db::DbConnection, offline_mode: bool) -> bool {
|
||||
ai::active_endpoint(conn, offline_mode)
|
||||
.is_ok_and(|endpoint| !endpoint.url.trim().is_empty() && !endpoint.model.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn translation_targets(
|
||||
main_language: Option<&str>,
|
||||
blog_languages: &[String],
|
||||
source_language: &str,
|
||||
) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
main_language
|
||||
.into_iter()
|
||||
.chain(blog_languages.iter().map(String::as_str))
|
||||
.filter(|language| !language.is_empty() && *language != source_language)
|
||||
.filter(|language| seen.insert((*language).to_string()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn process_paths_concurrently<T, F>(
|
||||
paths: Vec<PathBuf>,
|
||||
concurrency: usize,
|
||||
process: F,
|
||||
) -> Result<Vec<T>, String>
|
||||
where
|
||||
T: Send,
|
||||
F: Fn(usize, PathBuf) -> T + Send + Sync,
|
||||
{
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(concurrency.clamp(1, 8))
|
||||
.build()
|
||||
.map_err(|error| error.to_string())
|
||||
.map(|pool| {
|
||||
pool.install(|| {
|
||||
paths
|
||||
.into_par_iter()
|
||||
.enumerate()
|
||||
.map(|(index, path)| process(index, path))
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn import_gallery_images(
|
||||
db_path: &Path,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
source_language: &str,
|
||||
offline_mode: bool,
|
||||
) -> GalleryImportReport {
|
||||
let selected_count = paths.len();
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir).ok();
|
||||
let concurrency = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.image_import_concurrency.clamp(1, 8) as usize)
|
||||
.unwrap_or(4);
|
||||
let targets = translation_targets(
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.main_language.as_deref()),
|
||||
metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.blog_languages.as_slice())
|
||||
.unwrap_or_default(),
|
||||
source_language,
|
||||
);
|
||||
let ai_available = Database::open(db_path)
|
||||
.ok()
|
||||
.is_some_and(|db| active_ai_endpoint_configured(db.conn(), offline_mode));
|
||||
let first_sort_order = Database::open(db_path)
|
||||
.ok()
|
||||
.and_then(|db| post_media::list_media_for_post(db.conn(), post_id).ok())
|
||||
.map(|media| media.len() as i32)
|
||||
.unwrap_or(0);
|
||||
|
||||
let process_path = |index: usize, path: PathBuf| GalleryImportOutcome {
|
||||
result: import_gallery_image(
|
||||
db_path,
|
||||
data_dir,
|
||||
project_id,
|
||||
post_id,
|
||||
&path,
|
||||
source_language,
|
||||
first_sort_order + index as i32,
|
||||
ai_available,
|
||||
offline_mode,
|
||||
&targets,
|
||||
),
|
||||
path,
|
||||
};
|
||||
let outcomes = match process_paths_concurrently(paths.clone(), concurrency, process_path) {
|
||||
Ok(outcomes) => outcomes,
|
||||
Err(error) => paths
|
||||
.into_iter()
|
||||
.map(|path| GalleryImportOutcome {
|
||||
path,
|
||||
result: Err(error.clone()),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
GalleryImportReport {
|
||||
selected_count,
|
||||
outcomes,
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "one worker receives the immutable batch context"
|
||||
)]
|
||||
fn import_gallery_image(
|
||||
db_path: &Path,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
path: &Path,
|
||||
source_language: &str,
|
||||
sort_order: i32,
|
||||
ai_available: bool,
|
||||
offline_mode: bool,
|
||||
translation_targets: &[String],
|
||||
) -> Result<ImportedGalleryImage, String> {
|
||||
let db = Database::open(db_path).map_err(|error| error.to_string())?;
|
||||
let imported = import_and_link_image(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
project_id,
|
||||
post_id,
|
||||
path,
|
||||
source_language,
|
||||
sort_order,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let title = if ai_available {
|
||||
enrich_imported_image(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
&imported,
|
||||
offline_mode,
|
||||
translation_targets,
|
||||
)
|
||||
.unwrap_or_else(|_| imported.original_name.clone())
|
||||
} else {
|
||||
imported.original_name.clone()
|
||||
};
|
||||
|
||||
Ok(ImportedGalleryImage {
|
||||
media_id: imported.id,
|
||||
title,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn import_and_link_image(
|
||||
conn: &crate::db::DbConnection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
path: &Path,
|
||||
source_language: &str,
|
||||
sort_order: i32,
|
||||
) -> crate::engine::EngineResult<crate::model::Media> {
|
||||
let original_name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
let imported = media::import_media(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
path,
|
||||
&original_name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(source_language),
|
||||
Vec::new(),
|
||||
)?;
|
||||
post_media::link_media_to_post(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
post_id,
|
||||
&imported.id,
|
||||
sort_order,
|
||||
)?;
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Apply the shared gallery AI enrichment and translation pipeline to one
|
||||
/// already-imported image. Returns the generated title when AI was available.
|
||||
pub fn enrich_imported_image(
|
||||
conn: &crate::db::DbConnection,
|
||||
data_dir: &Path,
|
||||
imported: &crate::model::Media,
|
||||
offline_mode: bool,
|
||||
translation_targets: &[String],
|
||||
) -> Result<String, String> {
|
||||
let image_data_url = build_ai_image_data_url(
|
||||
data_dir,
|
||||
&imported.id,
|
||||
&imported.file_path,
|
||||
&imported.mime_type,
|
||||
)?;
|
||||
let response = ai::run_one_shot(
|
||||
conn,
|
||||
offline_mode,
|
||||
&ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::AnalyzeImage,
|
||||
content: json!({
|
||||
"title": imported.title,
|
||||
"alt": imported.alt,
|
||||
"caption": imported.caption,
|
||||
"filename": imported.original_name,
|
||||
"mime_type": imported.mime_type,
|
||||
"image_data_url": image_data_url,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let ai::OneShotResponse::ImageAnalysis(analysis) = response.0 else {
|
||||
return Err("AI returned an unexpected response".to_string());
|
||||
};
|
||||
media::update_media(
|
||||
conn,
|
||||
data_dir,
|
||||
&imported.id,
|
||||
Some(Some(&analysis.title)),
|
||||
Some(Some(&analysis.alt)),
|
||||
Some(Some(&analysis.caption)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
for target in translation_targets {
|
||||
let Ok((ai::OneShotResponse::MediaTranslation(translation), _)) = ai::run_one_shot(
|
||||
conn,
|
||||
offline_mode,
|
||||
&ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::TranslateMedia {
|
||||
target_language: target.clone(),
|
||||
},
|
||||
content: json!({
|
||||
"title": analysis.title,
|
||||
"alt": analysis.alt,
|
||||
"caption": analysis.caption,
|
||||
}),
|
||||
},
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let _ = media::upsert_media_translation(
|
||||
conn,
|
||||
data_dir,
|
||||
&imported.id,
|
||||
target,
|
||||
Some(&translation.title),
|
||||
Some(&translation.alt),
|
||||
Some(&translation.caption),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(if analysis.title.is_empty() {
|
||||
imported.original_name.clone()
|
||||
} else {
|
||||
analysis.title
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_ai_image_data_url(
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
file_path: &str,
|
||||
mime_type: &str,
|
||||
) -> Result<String, String> {
|
||||
if !mime_type.starts_with("image/") {
|
||||
return Err("AI image analysis requires an image".to_string());
|
||||
}
|
||||
|
||||
let source_path = data_dir.join(file_path.trim_start_matches('/'));
|
||||
let thumbnail_path = data_dir.join(crate::util::thumbnail_path(media_id, "ai", "jpg"));
|
||||
if !thumbnail_path.exists() {
|
||||
crate::util::thumbnail::generate_all_thumbnails(
|
||||
&source_path,
|
||||
&data_dir.join("thumbnails"),
|
||||
media_id,
|
||||
)
|
||||
.map_err(|error| format!("failed to generate AI thumbnail: {error}"))?;
|
||||
}
|
||||
let bytes = std::fs::read(&thumbnail_path)
|
||||
.map_err(|error| format!("failed to read AI thumbnail: {error}"))?;
|
||||
Ok(format!(
|
||||
"data:image/jpeg;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::db::queries::post::insert_post;
|
||||
use crate::db::queries::post::make_test_post;
|
||||
use crate::db::queries::post_media::{list_post_media_by_post, unlink_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::{Database, fts::ensure_fts_tables};
|
||||
use crate::engine::ai::{AiEndpointConfig, AiEndpointKind, save_endpoint};
|
||||
use crate::engine::media::rebuild_media_from_filesystem;
|
||||
use crate::model::metadata::ProjectMetadata;
|
||||
|
||||
use super::{import_gallery_images, process_paths_concurrently, translation_targets};
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db = Database::open(&dir.path().join("bds.db")).unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "Gallery")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("post1", "p1", "gallery")).unwrap();
|
||||
crate::engine::meta::write_project_json(
|
||||
dir.path(),
|
||||
&ProjectMetadata {
|
||||
name: "Gallery".to_string(),
|
||||
description: None,
|
||||
public_url: None,
|
||||
main_language: Some("en".to_string()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
image_import_concurrency: 2,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec![
|
||||
"de".to_string(),
|
||||
"en".to_string(),
|
||||
"fr".to_string(),
|
||||
"de".to_string(),
|
||||
],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
fn spawn_ai_server() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming().take(3) {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 16_384];
|
||||
loop {
|
||||
let read = stream.read(&mut buffer).unwrap();
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase()
|
||||
.strip_prefix("content-length: ")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= header_end + 4 + content_length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
let content = if request.contains("Translate the media metadata into de") {
|
||||
r#"{"title":"Berg","alt":"Ein Berg","caption":"Morgenlicht"}"#
|
||||
} else if request.contains("Translate the media metadata into fr") {
|
||||
r#"{"title":"Montagne","alt":"Une montagne","caption":"Lumière du matin"}"#
|
||||
} else {
|
||||
r#"{"title":"Mountain","alt":"A mountain","caption":"Morning light"}"#
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"choices": [{"message": {"content": content}}]
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body,
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_are_unique_and_exclude_source_language() {
|
||||
assert_eq!(
|
||||
translation_targets(
|
||||
Some("en"),
|
||||
&["de".to_string(), "en".to_string(), "de".to_string()],
|
||||
"fr",
|
||||
),
|
||||
vec!["en".to_string(), "de".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_failure_keeps_successful_image_linked_and_rebuildable_without_ai() {
|
||||
let (db, dir) = setup();
|
||||
let image = dir.path().join("photo.jpg");
|
||||
fs::write(&image, b"jpeg data").unwrap();
|
||||
let invalid = dir.path().join("notes.txt");
|
||||
fs::write(&invalid, b"not an image").unwrap();
|
||||
|
||||
let report = import_gallery_images(
|
||||
&dir.path().join("bds.db"),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"post1",
|
||||
vec![image, invalid],
|
||||
"en",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(report.selected_count, 2);
|
||||
assert!(report.outcomes[0].result.is_ok());
|
||||
assert!(report.outcomes[1].result.is_err());
|
||||
|
||||
let links = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(links.len(), 1);
|
||||
let media_id = links[0].media_id.clone();
|
||||
let sidecar = fs::read_to_string(
|
||||
dir.path().join(
|
||||
crate::db::queries::media::get_media_by_id(db.conn(), &media_id)
|
||||
.unwrap()
|
||||
.sidecar_path,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sidecar.contains("linkedPostIds: [\"post1\"]"));
|
||||
|
||||
unlink_media(db.conn(), "post1", &media_id).unwrap();
|
||||
rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(
|
||||
list_post_media_by_post(db.conn(), "post1").unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_local_ai_enriches_metadata_and_unique_translation_targets() {
|
||||
let (db, dir) = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: spawn_ai_server(),
|
||||
model: "local-vision".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let image = dir.path().join("mountain.png");
|
||||
image::DynamicImage::new_rgb8(8, 8).save(&image).unwrap();
|
||||
|
||||
let report = import_gallery_images(
|
||||
&dir.path().join("bds.db"),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"post1",
|
||||
vec![image],
|
||||
"en",
|
||||
true,
|
||||
);
|
||||
|
||||
let imported = report.outcomes[0].result.as_ref().unwrap();
|
||||
assert_eq!(imported.title, "Mountain");
|
||||
let media =
|
||||
crate::db::queries::media::get_media_by_id(db.conn(), &imported.media_id).unwrap();
|
||||
assert_eq!(media.title.as_deref(), Some("Mountain"));
|
||||
assert_eq!(media.alt.as_deref(), Some("A mountain"));
|
||||
assert_eq!(media.caption.as_deref(), Some("Morning light"));
|
||||
let translations = crate::db::queries::media_translation::list_media_translations_by_media(
|
||||
db.conn(),
|
||||
&media.id,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
translations
|
||||
.iter()
|
||||
.map(|translation| translation.language.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["de", "fr"]
|
||||
);
|
||||
for language in ["de", "fr"] {
|
||||
assert!(
|
||||
dir.path()
|
||||
.join(crate::util::media_translation_sidecar_path(
|
||||
&media.file_path,
|
||||
language,
|
||||
))
|
||||
.is_file()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_failure_does_not_remove_the_post_link() {
|
||||
let (db, dir) = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: "http://127.0.0.1:9".to_string(),
|
||||
model: "unavailable-local-model".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let image = dir.path().join("linked.png");
|
||||
image::DynamicImage::new_rgb8(8, 8).save(&image).unwrap();
|
||||
|
||||
let report = import_gallery_images(
|
||||
&dir.path().join("bds.db"),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"post1",
|
||||
vec![image],
|
||||
"en",
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(report.outcomes[0].result.is_ok());
|
||||
assert_eq!(
|
||||
list_post_media_by_post(db.conn(), "post1").unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_processor_clamps_worker_count_to_one_through_eight() {
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let maximum = Arc::new(AtomicUsize::new(0));
|
||||
let paths = (0..24)
|
||||
.map(|index| PathBuf::from(format!("{index}.jpg")))
|
||||
.collect();
|
||||
let results = process_paths_concurrently(paths, 99, {
|
||||
let active = Arc::clone(&active);
|
||||
let maximum = Arc::clone(&maximum);
|
||||
move |_index, path| {
|
||||
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
maximum.fetch_max(current, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
path
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 24);
|
||||
assert!((1..=8).contains(&maximum.load(Ordering::SeqCst)));
|
||||
|
||||
assert_eq!(
|
||||
process_paths_concurrently(vec![PathBuf::from("one.jpg")], 0, |_, path| path)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1693
crates/bds-core/src/engine/git.rs
Normal file
1693
crates/bds-core/src/engine/git.rs
Normal file
File diff suppressed because it is too large
Load Diff
182
crates/bds-core/src/engine/mcp/agent_config.rs
Normal file
182
crates/bds-core/src/engine/mcp/agent_config.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
|
||||
const SERVER_NAME: &str = "bDS";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpAgent {
|
||||
ClaudeCode,
|
||||
GithubCopilot,
|
||||
}
|
||||
|
||||
impl McpAgent {
|
||||
pub const fn all() -> [Self; 2] {
|
||||
[Self::ClaudeCode, Self::GithubCopilot]
|
||||
}
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "Claude Code",
|
||||
Self::GithubCopilot => "GitHub Copilot",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "claude_code",
|
||||
Self::GithubCopilot => "github_copilot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_config_path(agent: McpAgent, home_dir: &Path) -> PathBuf {
|
||||
match agent {
|
||||
McpAgent::ClaudeCode => home_dir.join(".claude.json"),
|
||||
McpAgent::GithubCopilot => {
|
||||
#[cfg(target_os = "macos")]
|
||||
let path = home_dir.join("Library/Application Support/Code/User/mcp.json");
|
||||
#[cfg(target_os = "windows")]
|
||||
let path = home_dir.join("AppData/Roaming/Code/User/mcp.json");
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
let path = home_dir.join(".config/Code/User/mcp.json");
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn packaged_mcp_executable() -> EngineResult<PathBuf> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let sibling = executable.with_file_name(if cfg!(windows) {
|
||||
"bds-mcp.exe"
|
||||
} else {
|
||||
"bds-mcp"
|
||||
});
|
||||
if sibling.is_file() {
|
||||
Ok(sibling)
|
||||
} else {
|
||||
Err(EngineError::NotFound(format!(
|
||||
"packaged MCP executable {}",
|
||||
sibling.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_agent_configured(agent: McpAgent, home_dir: &Path) -> bool {
|
||||
read_config(&agent_config_path(agent, home_dir))
|
||||
.ok()
|
||||
.is_some_and(|config| {
|
||||
server_map(&config, agent).is_some_and(|servers| servers.contains_key(SERVER_NAME))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn install_agent_config(
|
||||
agent: McpAgent,
|
||||
home_dir: &Path,
|
||||
executable: &Path,
|
||||
) -> EngineResult<PathBuf> {
|
||||
if !executable.is_file() {
|
||||
return Err(EngineError::NotFound(format!(
|
||||
"MCP executable {}",
|
||||
executable.display()
|
||||
)));
|
||||
}
|
||||
let path = agent_config_path(agent, home_dir);
|
||||
let mut config = read_config(&path)?;
|
||||
let key = server_key(agent);
|
||||
let servers = config
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| {
|
||||
EngineError::Validation(format!("{} must contain a JSON object", path.display()))
|
||||
})?
|
||||
.entry(key)
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| {
|
||||
EngineError::Validation(format!("{key} in {} must be an object", path.display()))
|
||||
})?;
|
||||
let executable = executable.to_string_lossy();
|
||||
let server = match agent {
|
||||
McpAgent::ClaudeCode => json!({"command": executable, "args": []}),
|
||||
McpAgent::GithubCopilot => {
|
||||
json!({"type": "stdio", "command": executable, "args": []})
|
||||
}
|
||||
};
|
||||
servers.insert(SERVER_NAME.into(), server);
|
||||
write_config(&path, &config)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn remove_agent_config(agent: McpAgent, home_dir: &Path) -> EngineResult<PathBuf> {
|
||||
let path = agent_config_path(agent, home_dir);
|
||||
let mut config = read_config(&path)?;
|
||||
if let Some(servers) = config
|
||||
.as_object_mut()
|
||||
.and_then(|object| object.get_mut(server_key(agent)))
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
servers.remove(SERVER_NAME);
|
||||
}
|
||||
write_config(&path, &config)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn server_key(agent: McpAgent) -> &'static str {
|
||||
match agent {
|
||||
McpAgent::ClaudeCode => "mcpServers",
|
||||
McpAgent::GithubCopilot => "servers",
|
||||
}
|
||||
}
|
||||
|
||||
fn server_map(config: &Value, agent: McpAgent) -> Option<&Map<String, Value>> {
|
||||
config.get(server_key(agent)).and_then(Value::as_object)
|
||||
}
|
||||
|
||||
fn read_config(path: &Path) -> EngineResult<Value> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(source) => serde_json::from_str(&source)
|
||||
.map_err(|error| EngineError::Parse(format!("{}: {error}", path.display()))),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_config(path: &Path, config: &Value) -> EngineResult<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let source = serde_json::to_string_pretty(config)?;
|
||||
crate::util::atomic_write_str(path, &source)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn install_and_remove_preserve_unrelated_config_without_secrets() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let executable = root.path().join("bds-mcp");
|
||||
std::fs::write(&executable, "binary").unwrap();
|
||||
for agent in McpAgent::all() {
|
||||
let path = agent_config_path(agent, root.path());
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(&path, r#"{"unrelated":{"token":"kept"}}"#).unwrap();
|
||||
install_agent_config(agent, root.path(), &executable).unwrap();
|
||||
assert!(is_agent_configured(agent, root.path()));
|
||||
let source = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(source.contains("kept"));
|
||||
assert!(!source.contains("api_key"));
|
||||
remove_agent_config(agent, root.path()).unwrap();
|
||||
assert!(!is_agent_configured(agent, root.path()));
|
||||
assert!(std::fs::read_to_string(path).unwrap().contains("kept"));
|
||||
}
|
||||
}
|
||||
}
|
||||
234
crates/bds-core/src/engine/mcp/http.rs
Normal file
234
crates/bds-core/src/engine/mcp/http.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
|
||||
use super::McpContext;
|
||||
use super::protocol::{MCP_PROTOCOL_VERSION, error, handle_rpc};
|
||||
|
||||
pub struct McpHttpServer {
|
||||
address: SocketAddr,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl McpHttpServer {
|
||||
pub fn start(database_path: PathBuf, port: u16) -> EngineResult<Self> {
|
||||
McpContext::new(database_path.clone()).prepare()?;
|
||||
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("bds-mcp-http".into())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("MCP Tokio runtime");
|
||||
runtime.block_on(async move {
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(listener).expect("MCP loopback listener");
|
||||
let context = McpContext::new(database_path);
|
||||
let router = Router::new()
|
||||
.route("/mcp", post(post_mcp).options(options_mcp))
|
||||
.with_state(context);
|
||||
let _ = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
})?;
|
||||
Ok(Self {
|
||||
address,
|
||||
shutdown: Some(shutdown),
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn address(&self) -> SocketAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> String {
|
||||
format!("http://{}/mcp", self.address)
|
||||
}
|
||||
|
||||
pub fn stop(mut self) -> EngineResult<()> {
|
||||
self.shutdown.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| EngineError::Parse("MCP server thread panicked".into()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpHttpServer {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_mcp(
|
||||
State(context): State<McpContext>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Response {
|
||||
if let Err((status, message)) = validate_http_headers(&headers) {
|
||||
return with_cors((status, message).into_response(), &headers);
|
||||
}
|
||||
let request = match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(request) => request,
|
||||
Err(_) => {
|
||||
return with_cors(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(error(Value::Null, -32700, "Parse error")),
|
||||
)
|
||||
.into_response(),
|
||||
&headers,
|
||||
);
|
||||
}
|
||||
};
|
||||
let response = match handle_rpc(&context, &request) {
|
||||
Some(response) => (StatusCode::OK, axum::Json(response)).into_response(),
|
||||
None => StatusCode::ACCEPTED.into_response(),
|
||||
};
|
||||
with_cors(response, &headers)
|
||||
}
|
||||
|
||||
async fn options_mcp(headers: HeaderMap) -> Response {
|
||||
if let Err((status, message)) = validate_origin_and_host(&headers) {
|
||||
return with_cors((status, message).into_response(), &headers);
|
||||
}
|
||||
with_cors(StatusCode::NO_CONTENT.into_response(), &headers)
|
||||
}
|
||||
|
||||
fn validate_http_headers(headers: &HeaderMap) -> Result<(), (StatusCode, &'static str)> {
|
||||
validate_origin_and_host(headers)?;
|
||||
if let Some(version) = headers
|
||||
.get("mcp-protocol-version")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
&& !["2025-03-26", MCP_PROTOCOL_VERSION].contains(&version)
|
||||
{
|
||||
return Err((StatusCode::BAD_REQUEST, "Unsupported MCP protocol version"));
|
||||
}
|
||||
if headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_none_or(|value| !value.starts_with("application/json"))
|
||||
{
|
||||
return Err((
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"Expected application/json",
|
||||
));
|
||||
}
|
||||
if headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_none_or(|value| {
|
||||
!value.contains("application/json") || !value.contains("text/event-stream")
|
||||
})
|
||||
{
|
||||
return Err((
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"Accept must include application/json and text/event-stream",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_origin_and_host(headers: &HeaderMap) -> Result<(), (StatusCode, &'static str)> {
|
||||
let host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let host_name = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.split_once(']').map(|(host, _)| host))
|
||||
.unwrap_or_else(|| host.split(':').next().unwrap_or_default());
|
||||
if !["localhost", "127.0.0.1", "::1"].contains(&host_name) {
|
||||
return Err((StatusCode::FORBIDDEN, "Forbidden host"));
|
||||
}
|
||||
if let Some(origin) = headers
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
let local = url::Url::parse(origin).ok().is_some_and(|origin| {
|
||||
matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
|
||||
});
|
||||
if !local {
|
||||
return Err((StatusCode::FORBIDDEN, "Forbidden origin"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_cors(mut response: Response, request_headers: &HeaderMap) -> Response {
|
||||
let headers = response.headers_mut();
|
||||
let origin = request_headers
|
||||
.get(header::ORIGIN)
|
||||
.filter(|value| {
|
||||
value.to_str().ok().is_some_and(|origin| {
|
||||
url::Url::parse(origin).ok().is_some_and(|origin| {
|
||||
matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| HeaderValue::from_static("http://127.0.0.1"));
|
||||
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||
headers.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
HeaderValue::from_static("POST, OPTIONS"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
HeaderValue::from_static("content-type, accept, origin, mcp-protocol-version"),
|
||||
);
|
||||
headers.insert(header::VARY, HeaderValue::from_static("Origin"));
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_validation_rejects_dns_rebinding_and_remote_origins() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, HeaderValue::from_static("attacker.example"));
|
||||
assert!(validate_origin_and_host(&headers).is_err());
|
||||
headers.insert(header::HOST, HeaderValue::from_static("127.0.0.1:4124"));
|
||||
headers.insert(
|
||||
header::ORIGIN,
|
||||
HeaderValue::from_static("https://attacker.example"),
|
||||
);
|
||||
assert!(validate_origin_and_host(&headers).is_err());
|
||||
headers.insert(
|
||||
header::ORIGIN,
|
||||
HeaderValue::from_static("http://localhost:3000"),
|
||||
);
|
||||
assert!(validate_origin_and_host(&headers).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_only_allows_post_and_options() {
|
||||
assert_eq!(axum::http::Method::POST.as_str(), "POST");
|
||||
assert_eq!(axum::http::Method::OPTIONS.as_str(), "OPTIONS");
|
||||
}
|
||||
}
|
||||
408
crates/bds-core/src/engine/mcp/mod.rs
Normal file
408
crates/bds-core/src/engine/mcp/mod.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
mod agent_config;
|
||||
mod http;
|
||||
mod protocol;
|
||||
mod resources;
|
||||
mod tools;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::{mcp_proposal as proposal_q, project as project_q};
|
||||
use crate::db::{Database, DbConnection};
|
||||
use crate::engine::{EngineError, EngineResult, cli_sync, domain_events};
|
||||
use crate::model::DomainEvent;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
pub use crate::model::{McpProposal, ProposalKind, ProposalStatus};
|
||||
pub use agent_config::{
|
||||
McpAgent, agent_config_path, install_agent_config, is_agent_configured,
|
||||
packaged_mcp_executable, remove_agent_config,
|
||||
};
|
||||
pub use http::McpHttpServer;
|
||||
pub use protocol::{MCP_PROTOCOL_VERSION, handle_rpc};
|
||||
pub use resources::ResourceContent;
|
||||
|
||||
pub const DEFAULT_HTTP_PORT: u16 = 4124;
|
||||
pub const PROPOSAL_TTL_MS: i64 = 30 * 60 * 1_000;
|
||||
pub const PROPOSALS_EVENT_KEY: &str = "mcp.proposals";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpContext {
|
||||
database_path: PathBuf,
|
||||
}
|
||||
|
||||
impl McpContext {
|
||||
pub fn new(database_path: PathBuf) -> Self {
|
||||
Self { database_path }
|
||||
}
|
||||
|
||||
pub fn database_path(&self) -> &Path {
|
||||
&self.database_path
|
||||
}
|
||||
|
||||
/// Prepare shared storage once when a transport starts. Individual
|
||||
/// stateless read requests never run migrations or repair derived state.
|
||||
pub fn prepare(&self) -> EngineResult<()> {
|
||||
let db = Database::open(&self.database_path)?;
|
||||
db.migrate()
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
crate::engine::search::prepare_search_index(db.conn())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_resources(&self) -> Vec<Value> {
|
||||
resources::list()
|
||||
}
|
||||
|
||||
pub fn list_resource_templates(&self) -> Vec<Value> {
|
||||
resources::templates()
|
||||
}
|
||||
|
||||
pub fn read_resource(&self, uri: &str) -> EngineResult<resources::ResourceContent> {
|
||||
let db = self.open_database()?;
|
||||
resources::read(db.conn(), uri)
|
||||
}
|
||||
|
||||
pub fn list_tools(&self) -> Vec<Value> {
|
||||
tools::list()
|
||||
}
|
||||
|
||||
pub fn call_tool(&self, name: &str, params: Value) -> EngineResult<Value> {
|
||||
let db = self.open_database()?;
|
||||
tools::call(db.conn(), name, params)
|
||||
}
|
||||
|
||||
pub(crate) fn open_database(&self) -> EngineResult<Database> {
|
||||
Ok(Database::open(&self.database_path)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_proposal(conn: &DbConnection, proposal_id: &str) -> EngineResult<McpProposal> {
|
||||
proposal_q::get_proposal(conn, proposal_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("MCP proposal {proposal_id}")))
|
||||
}
|
||||
|
||||
pub fn list_proposals(conn: &DbConnection, project_id: &str) -> EngineResult<Vec<McpProposal>> {
|
||||
expire_proposals(conn)?;
|
||||
Ok(proposal_q::list_proposals(conn, project_id)?)
|
||||
}
|
||||
|
||||
pub fn list_pending_proposals(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Vec<McpProposal>> {
|
||||
expire_proposals(conn)?;
|
||||
Ok(proposal_q::list_pending_proposals(conn, project_id)?)
|
||||
}
|
||||
|
||||
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
|
||||
conn.begin_savepoint()?;
|
||||
let expired = match proposal_q::expire_pending(conn, now_unix_ms()) {
|
||||
Ok(expired) => {
|
||||
conn.release_savepoint()?;
|
||||
expired
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if expired > 0 {
|
||||
notify_proposals_changed();
|
||||
}
|
||||
Ok(expired)
|
||||
}
|
||||
|
||||
pub(crate) fn create_proposal(
|
||||
conn: &DbConnection,
|
||||
kind: ProposalKind,
|
||||
project_id: &str,
|
||||
entity_id: Option<&str>,
|
||||
data: &Value,
|
||||
) -> EngineResult<McpProposal> {
|
||||
expire_proposals(conn)?;
|
||||
let now = now_unix_ms();
|
||||
let entity_id = entity_id
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let proposal = McpProposal {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
kind,
|
||||
status: ProposalStatus::Pending,
|
||||
entity_id,
|
||||
data: serde_json::to_string(data)?,
|
||||
result: None,
|
||||
created_at: now,
|
||||
expires_at: now + PROPOSAL_TTL_MS,
|
||||
resolved_at: None,
|
||||
};
|
||||
if let Err(error) = proposal_q::insert_proposal(conn, &proposal) {
|
||||
if matches!(
|
||||
error,
|
||||
diesel::result::Error::DatabaseError(
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
||||
_
|
||||
)
|
||||
) {
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"proposal already pending for {} entity {}",
|
||||
proposal.kind.as_str(),
|
||||
proposal.entity_id
|
||||
)));
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
cli_sync::record_cli_event(
|
||||
conn,
|
||||
&DomainEvent::SettingsChanged {
|
||||
project_id: None,
|
||||
key: PROPOSALS_EVENT_KEY.to_string(),
|
||||
},
|
||||
)?;
|
||||
Ok(proposal)
|
||||
}
|
||||
|
||||
pub fn accept_proposal(
|
||||
conn: &DbConnection,
|
||||
data_dir: &Path,
|
||||
proposal_id: &str,
|
||||
) -> EngineResult<McpProposal> {
|
||||
resolve_proposal(conn, data_dir, proposal_id, true)
|
||||
}
|
||||
|
||||
pub fn reject_proposal(
|
||||
conn: &DbConnection,
|
||||
data_dir: &Path,
|
||||
proposal_id: &str,
|
||||
) -> EngineResult<McpProposal> {
|
||||
resolve_proposal(conn, data_dir, proposal_id, false)
|
||||
}
|
||||
|
||||
fn resolve_proposal(
|
||||
conn: &DbConnection,
|
||||
data_dir: &Path,
|
||||
proposal_id: &str,
|
||||
accept: bool,
|
||||
) -> EngineResult<McpProposal> {
|
||||
expire_proposals(conn)?;
|
||||
conn.begin_savepoint()?;
|
||||
let outcome = (|| {
|
||||
let now = now_unix_ms();
|
||||
if !proposal_q::claim_pending(conn, proposal_id, now)? {
|
||||
let current = get_proposal(conn, proposal_id)?;
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"MCP proposal {} is {}",
|
||||
current.id,
|
||||
current.status.as_str()
|
||||
)));
|
||||
}
|
||||
let proposal = get_proposal(conn, proposal_id)?;
|
||||
let result = if accept {
|
||||
execute_proposal(conn, data_dir, &proposal)?
|
||||
} else {
|
||||
serde_json::json!({"message": "rejected"})
|
||||
};
|
||||
let status = if accept {
|
||||
ProposalStatus::Accepted
|
||||
} else {
|
||||
ProposalStatus::Rejected
|
||||
};
|
||||
proposal_q::delete_status_collision(conn, &proposal, status)?;
|
||||
if !proposal_q::resolve_claimed(
|
||||
conn,
|
||||
proposal_id,
|
||||
status,
|
||||
&serde_json::to_string(&result)?,
|
||||
now_unix_ms(),
|
||||
)? {
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"MCP proposal {proposal_id} was resolved concurrently"
|
||||
)));
|
||||
}
|
||||
get_proposal(conn, proposal_id)
|
||||
})();
|
||||
match outcome {
|
||||
Ok(proposal) => {
|
||||
conn.release_savepoint()?;
|
||||
notify_proposals_changed();
|
||||
Ok(proposal)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_proposal(
|
||||
conn: &DbConnection,
|
||||
data_dir: &Path,
|
||||
proposal: &McpProposal,
|
||||
) -> EngineResult<Value> {
|
||||
let data: Value = serde_json::from_str(&proposal.data)?;
|
||||
match proposal.kind {
|
||||
ProposalKind::DraftPost => {
|
||||
let post = crate::engine::post::create_post(
|
||||
conn,
|
||||
data_dir,
|
||||
&proposal.project_id,
|
||||
required_string(&data, "title")?,
|
||||
Some(required_string(&data, "content")?),
|
||||
string_array(&data, "tags"),
|
||||
string_array(&data, "categories"),
|
||||
optional_string(&data, "author"),
|
||||
optional_string(&data, "language"),
|
||||
None,
|
||||
)?;
|
||||
let post = if let Some(excerpt) = optional_string(&data, "excerpt") {
|
||||
crate::engine::post::update_post(
|
||||
conn,
|
||||
data_dir,
|
||||
&post.id,
|
||||
None,
|
||||
None,
|
||||
Some(Some(excerpt)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
post
|
||||
};
|
||||
let post = crate::engine::post::publish_post(conn, data_dir, &post.id)?;
|
||||
Ok(serde_json::to_value(post)?)
|
||||
}
|
||||
ProposalKind::ProposeScript => {
|
||||
let kind = required_string(&data, "kind")?
|
||||
.parse()
|
||||
.map_err(EngineError::Validation)?;
|
||||
let script = crate::engine::script::create_script(
|
||||
conn,
|
||||
&proposal.project_id,
|
||||
required_string(&data, "title")?,
|
||||
kind,
|
||||
required_string(&data, "content")?,
|
||||
optional_string(&data, "entrypoint"),
|
||||
)?;
|
||||
let script = crate::engine::script::publish_script(conn, data_dir, &script.id)?;
|
||||
Ok(serde_json::to_value(script)?)
|
||||
}
|
||||
ProposalKind::ProposeTemplate => {
|
||||
let kind = required_string(&data, "kind")?
|
||||
.parse()
|
||||
.map_err(EngineError::Validation)?;
|
||||
let template = crate::engine::template::create_template(
|
||||
conn,
|
||||
&proposal.project_id,
|
||||
required_string(&data, "title")?,
|
||||
kind,
|
||||
required_string(&data, "content")?,
|
||||
)?;
|
||||
let template = crate::engine::template::publish_template(conn, data_dir, &template.id)?;
|
||||
Ok(serde_json::to_value(template)?)
|
||||
}
|
||||
ProposalKind::ProposeMediaTranslation => {
|
||||
let translation = crate::engine::media::upsert_media_translation(
|
||||
conn,
|
||||
data_dir,
|
||||
required_string(&data, "mediaId")?,
|
||||
required_string(&data, "language")?,
|
||||
optional_string(&data, "title"),
|
||||
optional_string(&data, "alt"),
|
||||
optional_string(&data, "caption"),
|
||||
)?;
|
||||
Ok(serde_json::to_value(translation)?)
|
||||
}
|
||||
ProposalKind::ProposeMediaMetadata => {
|
||||
let media = crate::engine::media::update_media(
|
||||
conn,
|
||||
data_dir,
|
||||
required_string(&data, "mediaId")?,
|
||||
optional_optional_string(&data, "title"),
|
||||
optional_optional_string(&data, "alt"),
|
||||
optional_optional_string(&data, "caption"),
|
||||
None,
|
||||
None,
|
||||
data.get("tags")
|
||||
.is_some()
|
||||
.then(|| string_array(&data, "tags")),
|
||||
)?;
|
||||
Ok(serde_json::to_value(media)?)
|
||||
}
|
||||
ProposalKind::ProposePostMetadata => {
|
||||
let post = crate::engine::post::update_post(
|
||||
conn,
|
||||
data_dir,
|
||||
required_string(&data, "postId")?,
|
||||
optional_string(&data, "title"),
|
||||
None,
|
||||
optional_optional_string(&data, "excerpt"),
|
||||
None,
|
||||
data.get("tags")
|
||||
.is_some()
|
||||
.then(|| string_array(&data, "tags")),
|
||||
data.get("categories")
|
||||
.is_some()
|
||||
.then(|| string_array(&data, "categories")),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
Ok(serde_json::to_value(post)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn active_project(
|
||||
conn: &DbConnection,
|
||||
) -> EngineResult<(crate::model::Project, PathBuf)> {
|
||||
let project = project_q::get_active_project(conn)
|
||||
.map_err(|_| EngineError::NotFound("active project".into()))?;
|
||||
let data_dir = project
|
||||
.data_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| EngineError::Validation("active project has no data path".into()))?;
|
||||
Ok((project, data_dir))
|
||||
}
|
||||
|
||||
pub(crate) fn required_string<'a>(value: &'a Value, key: &str) -> EngineResult<&'a str> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| EngineError::Validation(format!("{key} is required")))
|
||||
}
|
||||
|
||||
pub(crate) fn optional_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||
value.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn optional_optional_string<'a>(value: &'a Value, key: &str) -> Option<Option<&'a str>> {
|
||||
value.get(key).map(|value| value.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn string_array(value: &Value, key: &str) -> Vec<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notify_proposals_changed() {
|
||||
domain_events::settings_changed(None, PROPOSALS_EVENT_KEY);
|
||||
}
|
||||
130
crates/bds-core/src/engine/mcp/protocol.rs
Normal file
130
crates/bds-core/src/engine/mcp/protocol.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
|
||||
use super::McpContext;
|
||||
|
||||
pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
|
||||
pub fn handle_rpc(context: &McpContext, request: &Value) -> Option<Value> {
|
||||
let id = request.get("id").cloned();
|
||||
let Some(method) = request.get("method").and_then(Value::as_str) else {
|
||||
return Some(error(id.unwrap_or(Value::Null), -32600, "Invalid Request"));
|
||||
};
|
||||
if request.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
|
||||
return Some(error(id.unwrap_or(Value::Null), -32600, "Invalid Request"));
|
||||
}
|
||||
let id = id?;
|
||||
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let result = match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": negotiated_version(¶ms),
|
||||
"capabilities": {
|
||||
"tools": {"listChanged": false},
|
||||
"resources": {"subscribe": false, "listChanged": false}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "Blogging Desktop Server",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": context.list_tools()})),
|
||||
"tools/call" => call_tool(context, ¶ms),
|
||||
"resources/list" => Ok(json!({"resources": context.list_resources()})),
|
||||
"resources/templates/list" => Ok(json!({
|
||||
"resourceTemplates": context.list_resource_templates()
|
||||
})),
|
||||
"resources/read" => read_resource(context, ¶ms),
|
||||
_ => return Some(error(id, -32601, "Method not found")),
|
||||
};
|
||||
Some(match result {
|
||||
Ok(result) => success(id, result),
|
||||
Err(EngineError::NotFound(message)) => error(id, -32004, &message),
|
||||
Err(EngineError::Validation(message) | EngineError::Conflict(message)) => {
|
||||
error(id, -32602, &message)
|
||||
}
|
||||
Err(error_value) => error(id, -32000, &error_value.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn negotiated_version(params: &Value) -> &str {
|
||||
match params.get("protocolVersion").and_then(Value::as_str) {
|
||||
Some("2025-03-26") => "2025-03-26",
|
||||
Some("2025-06-18") => "2025-06-18",
|
||||
_ => MCP_PROTOCOL_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
fn call_tool(context: &McpContext, params: &Value) -> EngineResult<Value> {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| EngineError::Validation("tool name is required".into()))?;
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = context.call_tool(name, arguments)?;
|
||||
Ok(json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string(&result)?
|
||||
}],
|
||||
"structuredContent": result,
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_resource(context: &McpContext, params: &Value) -> EngineResult<Value> {
|
||||
let uri = params
|
||||
.get("uri")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|uri| !uri.is_empty())
|
||||
.ok_or_else(|| EngineError::Validation("resource URI is required".into()))?;
|
||||
let content = context.read_resource(uri)?;
|
||||
let content = if let Some(blob) = content.blob {
|
||||
json!({
|
||||
"uri": content.uri,
|
||||
"mimeType": content.mime_type,
|
||||
"blob": blob
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"uri": content.uri,
|
||||
"mimeType": content.mime_type,
|
||||
"text": content.text.unwrap_or_default()
|
||||
})
|
||||
};
|
||||
Ok(json!({"contents": [content]}))
|
||||
}
|
||||
|
||||
fn success(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"result":result})
|
||||
}
|
||||
|
||||
pub(crate) fn error(id: Value, code: i64, message: &str) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notifications_have_no_response_and_invalid_requests_are_rejected() {
|
||||
let context = McpContext::new("missing.sqlite".into());
|
||||
assert!(
|
||||
handle_rpc(
|
||||
&context,
|
||||
&json!({"jsonrpc":"2.0","method":"notifications/initialized"})
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
handle_rpc(&context, &json!({"jsonrpc":"2.0","id":1})).unwrap()["error"]["code"],
|
||||
-32600
|
||||
);
|
||||
}
|
||||
}
|
||||
382
crates/bds-core/src/engine/mcp/resources.rs
Normal file
382
crates/bds-core/src/engine/mcp/resources.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::queries::{media as media_q, post as post_q, post_link, post_media, tag as tag_q};
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Media, Post};
|
||||
|
||||
use super::active_project;
|
||||
|
||||
const PAGE_SIZE: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResourceContent {
|
||||
pub uri: String,
|
||||
pub mime_type: String,
|
||||
pub text: Option<String>,
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
impl ResourceContent {
|
||||
fn json(uri: &str, value: &Value) -> EngineResult<Self> {
|
||||
Ok(Self {
|
||||
uri: uri.to_string(),
|
||||
mime_type: "application/json".into(),
|
||||
text: Some(serde_json::to_string(value)?),
|
||||
blob: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list() -> Vec<Value> {
|
||||
[
|
||||
("project", "Active project", "bds://project"),
|
||||
("posts", "Blog posts", "bds://posts"),
|
||||
("media", "Media", "bds://media"),
|
||||
("tags", "Tags", "bds://tags"),
|
||||
("categories", "Categories", "bds://categories"),
|
||||
("stats", "Blog statistics", "bds://stats"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, title, uri)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"uri": uri,
|
||||
"mimeType": "application/json"
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn templates() -> Vec<Value> {
|
||||
[
|
||||
("posts", "Paginated blog posts", "bds://posts{?cursor}"),
|
||||
("media", "Paginated media", "bds://media{?cursor}"),
|
||||
(
|
||||
"post media",
|
||||
"Media linked to a post",
|
||||
"bds://posts/{id}/media",
|
||||
),
|
||||
(
|
||||
"media image",
|
||||
"Original media bytes",
|
||||
"bds://media/{id}/image",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, title, uri_template)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"uriTemplate": uri_template
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn read(conn: &DbConnection, uri: &str) -> EngineResult<ResourceContent> {
|
||||
let url = url::Url::parse(uri)
|
||||
.map_err(|_| EngineError::Validation("invalid MCP resource URI".into()))?;
|
||||
if url.scheme() != "bds" {
|
||||
return Err(EngineError::NotFound(uri.into()));
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| EngineError::NotFound(uri.into()))?;
|
||||
let path = url.path().trim_matches('/');
|
||||
let (project, data_dir) = active_project(conn)?;
|
||||
let value = match (host, path) {
|
||||
("project", "") => project_resource(&project, &data_dir),
|
||||
("posts", "") => posts_page(conn, &project.id, cursor_offset(&url)?),
|
||||
("media", "") => media_page(conn, &project.id, cursor_offset(&url)?),
|
||||
("tags", "") => tags(conn, &project.id),
|
||||
("categories", "") => categories(conn, &project.id, &data_dir),
|
||||
("stats", "") => stats(conn, &project.id, &data_dir),
|
||||
("posts", path) => {
|
||||
let parts = path.split('/').collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[post_id] => post_detail_by_id(conn, &project.id, &data_dir, post_id),
|
||||
[post_id, "media"] => post_media_items(conn, &project.id, post_id),
|
||||
_ => return Err(EngineError::NotFound(uri.into())),
|
||||
}
|
||||
}
|
||||
("media", path) => {
|
||||
let parts = path.split('/').collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[media_id] => media_detail_by_id(conn, &project.id, media_id),
|
||||
[media_id, "image"] => {
|
||||
return media_image(conn, &project.id, &data_dir, media_id, uri);
|
||||
}
|
||||
_ => return Err(EngineError::NotFound(uri.into())),
|
||||
}
|
||||
}
|
||||
_ => return Err(EngineError::NotFound(uri.into())),
|
||||
}?;
|
||||
ResourceContent::json(uri, &value)
|
||||
}
|
||||
|
||||
fn project_resource(project: &crate::model::Project, data_dir: &Path) -> EngineResult<Value> {
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir).ok();
|
||||
Ok(json!({
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"slug": project.slug,
|
||||
"description": project.description,
|
||||
"public_url": metadata.as_ref().and_then(|value| value.public_url.clone()),
|
||||
"main_language": metadata.as_ref().and_then(|value| value.main_language.clone()),
|
||||
"blog_languages": metadata.map(|value| value.blog_languages).unwrap_or_default()
|
||||
}))
|
||||
}
|
||||
|
||||
fn cursor_offset(url: &url::Url) -> EngineResult<usize> {
|
||||
let Some(cursor) = url
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "cursor").then(|| value.into_owned()))
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
if cursor.is_empty() {
|
||||
return Err(EngineError::Validation("invalid cursor".into()));
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| EngineError::Validation("invalid cursor".into()))?;
|
||||
let value: Value = serde_json::from_slice(&decoded)
|
||||
.map_err(|_| EngineError::Validation("invalid cursor".into()))?;
|
||||
value["offset"]
|
||||
.as_u64()
|
||||
.map(|offset| offset as usize)
|
||||
.ok_or_else(|| EngineError::Validation("invalid cursor".into()))
|
||||
}
|
||||
|
||||
fn encode_cursor(offset: usize) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&json!({"offset": offset})).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn page(items: Vec<Value>, total: usize, offset: usize) -> Value {
|
||||
let mut value = json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": PAGE_SIZE
|
||||
});
|
||||
let next = offset.saturating_add(PAGE_SIZE);
|
||||
if next < total {
|
||||
value["nextCursor"] = Value::String(encode_cursor(next));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn posts_page(conn: &DbConnection, project_id: &str, offset: usize) -> EngineResult<Value> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let total = posts.len();
|
||||
let items = posts
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(PAGE_SIZE)
|
||||
.map(|post| post_summary(conn, &post))
|
||||
.collect::<EngineResult<Vec<_>>>()?;
|
||||
Ok(page(items, total, offset))
|
||||
}
|
||||
|
||||
fn media_page(conn: &DbConnection, project_id: &str, offset: usize) -> EngineResult<Value> {
|
||||
let media = media_q::list_media_by_project(conn, project_id)?;
|
||||
let total = media.len();
|
||||
let items = media
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(PAGE_SIZE)
|
||||
.map(|item| media_summary(&item))
|
||||
.collect();
|
||||
Ok(page(items, total, offset))
|
||||
}
|
||||
|
||||
pub(crate) fn post_summary(conn: &DbConnection, post: &Post) -> EngineResult<Value> {
|
||||
Ok(json!({
|
||||
"id": post.id,
|
||||
"title": post.title,
|
||||
"slug": post.slug,
|
||||
"status": post.status,
|
||||
"tags": post.tags,
|
||||
"categories": post.categories,
|
||||
"created_at": post.created_at,
|
||||
"backlinks": linked_posts(conn, &post.id, false)?,
|
||||
"linksTo": linked_posts(conn, &post.id, true)?
|
||||
}))
|
||||
}
|
||||
|
||||
fn linked_posts(conn: &DbConnection, post_id: &str, outgoing: bool) -> EngineResult<Vec<Value>> {
|
||||
let links = if outgoing {
|
||||
post_link::list_links_by_source(conn, post_id)?
|
||||
.into_iter()
|
||||
.map(|link| link.target_post_id)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
post_link::list_links_by_target(conn, post_id)?
|
||||
.into_iter()
|
||||
.map(|link| link.source_post_id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
Ok(links
|
||||
.into_iter()
|
||||
.filter_map(|id| post_q::get_post_by_id(conn, &id).ok())
|
||||
.map(|post| json!({"id": post.id, "title": post.title, "slug": post.slug}))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn post_detail(
|
||||
conn: &DbConnection,
|
||||
data_dir: &Path,
|
||||
post: &Post,
|
||||
) -> EngineResult<Value> {
|
||||
let mut value = serde_json::to_value(post)?;
|
||||
value["content"] = Value::String(post_body(data_dir, post));
|
||||
value["backlinks"] = Value::Array(linked_posts(conn, &post.id, false)?);
|
||||
value["linksTo"] = Value::Array(linked_posts(conn, &post.id, true)?);
|
||||
let translations =
|
||||
crate::db::queries::post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
let mut languages = post.language.clone().into_iter().collect::<Vec<_>>();
|
||||
languages.extend(
|
||||
translations
|
||||
.into_iter()
|
||||
.map(|translation| translation.language),
|
||||
);
|
||||
languages.sort();
|
||||
languages.dedup();
|
||||
value["availableLanguages"] = serde_json::to_value(languages)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn post_body(data_dir: &Path, post: &Post) -> String {
|
||||
if let Some(content) = &post.content {
|
||||
return content.clone();
|
||||
}
|
||||
if post.file_path.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
std::fs::read_to_string(data_dir.join(&post.file_path))
|
||||
.ok()
|
||||
.and_then(|source| {
|
||||
crate::util::frontmatter::read_post_file(&source)
|
||||
.ok()
|
||||
.map(|(_, body)| body)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn post_detail_by_id(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
) -> EngineResult<Value> {
|
||||
let post = post_q::get_post_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("post {id}")))?;
|
||||
ensure_project(project_id, &post.project_id, "post", id)?;
|
||||
post_detail(conn, data_dir, &post)
|
||||
}
|
||||
|
||||
pub(crate) fn media_summary(media: &Media) -> Value {
|
||||
json!({
|
||||
"id": media.id,
|
||||
"filename": media.filename,
|
||||
"title": media.title,
|
||||
"alt": media.alt,
|
||||
"caption": media.caption,
|
||||
"tags": media.tags
|
||||
})
|
||||
}
|
||||
|
||||
fn media_detail_by_id(conn: &DbConnection, project_id: &str, id: &str) -> EngineResult<Value> {
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
ensure_project(project_id, &media.project_id, "media", id)?;
|
||||
Ok(serde_json::to_value(media)?)
|
||||
}
|
||||
|
||||
fn post_media_items(conn: &DbConnection, project_id: &str, post_id: &str) -> EngineResult<Value> {
|
||||
let post = post_q::get_post_by_id(conn, post_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("post {post_id}")))?;
|
||||
ensure_project(project_id, &post.project_id, "post", post_id)?;
|
||||
let items = post_media::list_post_media_by_post(conn, post_id)?
|
||||
.into_iter()
|
||||
.filter_map(|link| media_q::get_media_by_id(conn, &link.media_id).ok())
|
||||
.map(|media| media_summary(&media))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"items": items}))
|
||||
}
|
||||
|
||||
fn media_image(
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
uri: &str,
|
||||
) -> EngineResult<ResourceContent> {
|
||||
let media = media_q::get_media_by_id(conn, media_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {media_id}")))?;
|
||||
ensure_project(project_id, &media.project_id, "media", media_id)?;
|
||||
let bytes = std::fs::read(data_dir.join(&media.file_path))
|
||||
.map_err(|_| EngineError::NotFound(format!("media file {media_id}")))?;
|
||||
Ok(ResourceContent {
|
||||
uri: uri.to_string(),
|
||||
mime_type: media.mime_type,
|
||||
text: None,
|
||||
blob: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
})
|
||||
}
|
||||
|
||||
fn tags(conn: &DbConnection, project_id: &str) -> EngineResult<Value> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let items = tag_q::list_tags_by_project(conn, project_id)?
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
let count = posts
|
||||
.iter()
|
||||
.filter(|post| post.tags.iter().any(|name| name == &tag.name))
|
||||
.count();
|
||||
json!({"name": tag.name, "color": tag.color, "post_count": count})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"items": items}))
|
||||
}
|
||||
|
||||
fn categories(conn: &DbConnection, project_id: &str, data_dir: &Path) -> EngineResult<Value> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let names = crate::engine::meta::read_categories_json(data_dir)
|
||||
.unwrap_or_else(|_| post_q::distinct_post_categories(conn, project_id).unwrap_or_default());
|
||||
let items = names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let count = posts
|
||||
.iter()
|
||||
.filter(|post| post.categories.iter().any(|value| value == &name))
|
||||
.count();
|
||||
json!({"name": name, "post_count": count})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"items": items}))
|
||||
}
|
||||
|
||||
fn stats(conn: &DbConnection, project_id: &str, data_dir: &Path) -> EngineResult<Value> {
|
||||
let categories = categories(conn, project_id, data_dir)?;
|
||||
Ok(json!({
|
||||
"post_count": post_q::count_posts_by_project(conn, project_id)?,
|
||||
"media_count": media_q::count_media_by_project(conn, project_id)?,
|
||||
"tag_count": tag_q::list_tags_by_project(conn, project_id)?.len(),
|
||||
"category_count": categories["items"].as_array().map_or(0, Vec::len)
|
||||
}))
|
||||
}
|
||||
|
||||
fn ensure_project(expected: &str, actual: &str, entity: &str, id: &str) -> EngineResult<()> {
|
||||
if expected == actual {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EngineError::NotFound(format!("{entity} {id}")))
|
||||
}
|
||||
}
|
||||
646
crates/bds-core/src/engine/mcp/tools.rs
Normal file
646
crates/bds-core/src/engine/mcp/tools.rs
Normal file
@@ -0,0 +1,646 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{Datelike, TimeZone as _, Utc};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, ProposalKind};
|
||||
|
||||
use super::resources::{post_detail, post_summary};
|
||||
use super::{active_project, create_proposal, optional_string, required_string, string_array};
|
||||
|
||||
const MAX_PAGE_SIZE: usize = 50;
|
||||
|
||||
pub fn list() -> Vec<Value> {
|
||||
[
|
||||
tool(
|
||||
"check_term",
|
||||
"Check Term",
|
||||
"Check whether a term is a category, a tag, or both, with post counts.",
|
||||
object_schema(json!({"term": string_schema("Term to check")}), &["term"]),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"search_posts",
|
||||
"Search Posts",
|
||||
"Full-text and filtered post search with pagination, backlinks, and outgoing links.",
|
||||
post_query_schema(false),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"count_posts",
|
||||
"Count Posts",
|
||||
"Count filtered posts grouped by year, month, tag, category, or status.",
|
||||
count_schema(),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"read_post_by_slug",
|
||||
"Read Post By Slug",
|
||||
"Read full post content and metadata, optionally in a translated language.",
|
||||
object_schema(
|
||||
json!({
|
||||
"slug": string_schema("Post slug"),
|
||||
"language": string_schema("Optional translation language")
|
||||
}),
|
||||
&["slug"],
|
||||
),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_post_translations",
|
||||
"Get Post Translations",
|
||||
"List every translation for a post.",
|
||||
id_schema("postId", "Post ID"),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_media_translations",
|
||||
"Get Media Translations",
|
||||
"List every translated metadata record for a media item.",
|
||||
id_schema("mediaId", "Media ID"),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"upsert_media_translation",
|
||||
"Propose Media Translation",
|
||||
"Propose translated media metadata for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"mediaId": string_schema("Media ID"),
|
||||
"language": string_schema("Language code"),
|
||||
"title": string_schema("Translated title"),
|
||||
"alt": string_schema("Translated alt text"),
|
||||
"caption": string_schema("Translated caption")
|
||||
}),
|
||||
&["mediaId", "language"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"draft_post",
|
||||
"Draft Post",
|
||||
"Propose a post. No post is created until explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"title": string_schema("Post title"),
|
||||
"content": string_schema("Markdown body"),
|
||||
"excerpt": string_schema("Excerpt"),
|
||||
"tags": string_array_schema("Tags"),
|
||||
"categories": string_array_schema("Categories"),
|
||||
"author": string_schema("Author"),
|
||||
"language": string_schema("Language")
|
||||
}),
|
||||
&["title", "content"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_script",
|
||||
"Propose Script",
|
||||
"Validate and propose a Lua script for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"title": string_schema("Script title"),
|
||||
"kind": {"type":"string","enum":["macro","utility","transform"]},
|
||||
"content": string_schema("Lua source"),
|
||||
"entrypoint": string_schema("Entrypoint function")
|
||||
}),
|
||||
&["title", "kind", "content"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_template",
|
||||
"Propose Template",
|
||||
"Validate and propose a Liquid template for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"title": string_schema("Template title"),
|
||||
"kind": {"type":"string","enum":["post","list","not-found","partial"]},
|
||||
"content": string_schema("Liquid source")
|
||||
}),
|
||||
&["title", "kind", "content"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_media_metadata",
|
||||
"Propose Media Metadata",
|
||||
"Propose media metadata changes for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"mediaId": string_schema("Media ID"),
|
||||
"title": nullable_string_schema("Title"),
|
||||
"alt": nullable_string_schema("Alt text"),
|
||||
"caption": nullable_string_schema("Caption"),
|
||||
"tags": string_array_schema("Tags")
|
||||
}),
|
||||
&["mediaId"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_post_metadata",
|
||||
"Propose Post Metadata",
|
||||
"Propose post metadata changes for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"postId": string_schema("Post ID"),
|
||||
"title": string_schema("Title"),
|
||||
"excerpt": nullable_string_schema("Excerpt"),
|
||||
"tags": string_array_schema("Tags"),
|
||||
"categories": string_array_schema("Categories")
|
||||
}),
|
||||
&["postId"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn call(conn: &DbConnection, name: &str, params: Value) -> EngineResult<Value> {
|
||||
if !params.is_object() {
|
||||
return Err(EngineError::Validation(
|
||||
"tool arguments must be an object".into(),
|
||||
));
|
||||
}
|
||||
match name {
|
||||
"check_term" => check_term(conn, ¶ms),
|
||||
"search_posts" => search_posts(conn, ¶ms),
|
||||
"count_posts" => count_posts(conn, ¶ms),
|
||||
"read_post_by_slug" => read_post_by_slug(conn, ¶ms),
|
||||
"get_post_translations" => get_post_translations(conn, ¶ms),
|
||||
"get_media_translations" => get_media_translations(conn, ¶ms),
|
||||
"upsert_media_translation" => propose_media_translation(conn, ¶ms),
|
||||
"draft_post" => propose(conn, ProposalKind::DraftPost, None, ¶ms),
|
||||
"propose_script" => propose_script(conn, ¶ms),
|
||||
"propose_template" => propose_template(conn, ¶ms),
|
||||
"propose_media_metadata" => propose_media_metadata(conn, ¶ms),
|
||||
"propose_post_metadata" => propose_post_metadata(conn, ¶ms),
|
||||
_ => Err(EngineError::NotFound(format!("MCP tool {name}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, description: &str, schema: Value, read_only: bool) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"inputSchema": schema,
|
||||
"annotations": {
|
||||
"readOnlyHint": read_only,
|
||||
"destructiveHint": false,
|
||||
"openWorldHint": false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn string_schema(description: &str) -> Value {
|
||||
json!({"type":"string","description":description})
|
||||
}
|
||||
|
||||
fn nullable_string_schema(description: &str) -> Value {
|
||||
json!({"type":["string","null"],"description":description})
|
||||
}
|
||||
|
||||
fn string_array_schema(description: &str) -> Value {
|
||||
json!({"type":"array","items":{"type":"string"},"description":description})
|
||||
}
|
||||
|
||||
fn object_schema(properties: Value, required: &[&str]) -> Value {
|
||||
let mut schema = json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": false
|
||||
});
|
||||
if !required.is_empty() {
|
||||
schema["required"] = serde_json::to_value(required).unwrap_or_default();
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
fn id_schema(field: &str, description: &str) -> Value {
|
||||
let mut properties = Map::new();
|
||||
properties.insert(field.into(), string_schema(description));
|
||||
object_schema(Value::Object(properties), &[field])
|
||||
}
|
||||
|
||||
fn post_query_schema(query_required: bool) -> Value {
|
||||
object_schema(
|
||||
json!({
|
||||
"query": string_schema("Full-text query"),
|
||||
"category": string_schema("Category filter"),
|
||||
"tags": string_array_schema("All required tags"),
|
||||
"language": string_schema("Available language"),
|
||||
"missingTranslationLanguage": string_schema("Missing translation language"),
|
||||
"year": {"type":"integer"},
|
||||
"month": {"type":"integer","minimum":1,"maximum":12},
|
||||
"status": {"type":"string","enum":["draft","published","archived"]},
|
||||
"offset": {"type":"integer","minimum":0},
|
||||
"limit": {"type":"integer","minimum":1,"maximum":MAX_PAGE_SIZE}
|
||||
}),
|
||||
if query_required { &["query"] } else { &[] },
|
||||
)
|
||||
}
|
||||
|
||||
fn count_schema() -> Value {
|
||||
let mut schema = post_query_schema(false);
|
||||
schema["properties"]["groupBy"] = json!({"type":"array","items":{"type":"string","enum":["year","month","tag","category","status"]},"minItems":1});
|
||||
schema["required"] = json!(["groupBy"]);
|
||||
schema
|
||||
}
|
||||
|
||||
fn check_term(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let term = required_string(params, "term")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let posts = post_q::list_posts_by_project(conn, &project.id)?;
|
||||
let normalized = term.to_lowercase();
|
||||
let tag_count = posts
|
||||
.iter()
|
||||
.filter(|post| post.tags.iter().any(|tag| tag.to_lowercase() == normalized))
|
||||
.count();
|
||||
let category_count = posts
|
||||
.iter()
|
||||
.filter(|post| {
|
||||
post.categories
|
||||
.iter()
|
||||
.any(|category| category.to_lowercase() == normalized)
|
||||
})
|
||||
.count();
|
||||
Ok(json!({
|
||||
"is_category": category_count > 0,
|
||||
"category_post_count": category_count,
|
||||
"is_tag": tag_count > 0,
|
||||
"tag_post_count": tag_count
|
||||
}))
|
||||
}
|
||||
|
||||
fn filtered_posts(conn: &DbConnection, params: &Value) -> EngineResult<Vec<Post>> {
|
||||
let (project, _) = active_project(conn)?;
|
||||
let mut posts = post_q::list_posts_by_project(conn, &project.id)?;
|
||||
let query = optional_string(params, "query")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let fts_matches = if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let language = optional_string(params, "language").unwrap_or("en");
|
||||
Some(
|
||||
crate::db::fts::search_posts(conn, &query, language)?
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>(),
|
||||
)
|
||||
};
|
||||
let category = optional_string(params, "category").map(str::to_lowercase);
|
||||
let tags = string_array(params, "tags")
|
||||
.into_iter()
|
||||
.map(|tag| tag.to_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
let language = optional_string(params, "language").map(str::to_lowercase);
|
||||
let missing_language =
|
||||
optional_string(params, "missingTranslationLanguage").map(str::to_lowercase);
|
||||
let status = optional_string(params, "status");
|
||||
let year = integer(params, "year")?.map(|value| value as i32);
|
||||
let month = integer(params, "month")?.map(|value| value as u32);
|
||||
if month.is_some() && year.is_none() {
|
||||
return Err(EngineError::Validation("month requires year".into()));
|
||||
}
|
||||
posts.retain(|post| {
|
||||
if fts_matches
|
||||
.as_ref()
|
||||
.is_some_and(|matches| !matches.contains(&post.id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if category.as_ref().is_some_and(|wanted| {
|
||||
!post
|
||||
.categories
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase() == *wanted)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if tags.iter().any(|wanted| {
|
||||
!post
|
||||
.tags
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase() == *wanted)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if status.is_some_and(|wanted| post.status.as_str() != wanted) {
|
||||
return false;
|
||||
}
|
||||
if let Some(wanted) = &language {
|
||||
let canonical = post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(wanted));
|
||||
let translated =
|
||||
post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted)
|
||||
.is_ok();
|
||||
if !canonical && !translated {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(wanted) = &missing_language {
|
||||
let canonical = post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(wanted));
|
||||
let translated =
|
||||
post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted)
|
||||
.is_ok();
|
||||
if canonical || translated {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(wanted_year) = year {
|
||||
let Some(date) = Utc.timestamp_millis_opt(post.created_at).single() else {
|
||||
return false;
|
||||
};
|
||||
if date.year() != wanted_year || month.is_some_and(|wanted| date.month() != wanted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
fn search_posts(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let posts = filtered_posts(conn, params)?;
|
||||
let total = posts.len();
|
||||
let offset = unsigned(params, "offset")?.unwrap_or(0);
|
||||
let limit = unsigned(params, "limit")?.unwrap_or(MAX_PAGE_SIZE);
|
||||
if limit == 0 || limit > MAX_PAGE_SIZE {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"limit must be between 1 and {MAX_PAGE_SIZE}"
|
||||
)));
|
||||
}
|
||||
let posts = posts
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|post| post_summary(conn, &post))
|
||||
.collect::<EngineResult<Vec<_>>>()?;
|
||||
Ok(json!({
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"hasMore": offset.saturating_add(limit) < total,
|
||||
"posts": posts
|
||||
}))
|
||||
}
|
||||
|
||||
fn count_posts(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let group_by = string_array(params, "groupBy");
|
||||
if group_by.is_empty()
|
||||
|| group_by.iter().any(|dimension| {
|
||||
!["year", "month", "tag", "category", "status"].contains(&dimension.as_str())
|
||||
})
|
||||
{
|
||||
return Err(EngineError::Validation("invalid groupBy".into()));
|
||||
}
|
||||
let posts = filtered_posts(conn, params)?;
|
||||
let total = posts.len();
|
||||
let mut counts = BTreeMap::<String, (Map<String, Value>, usize)>::new();
|
||||
for post in posts {
|
||||
for row in group_rows(&post, &group_by) {
|
||||
let key = serde_json::to_string(&row)?;
|
||||
counts
|
||||
.entry(key)
|
||||
.and_modify(|(_, count)| *count += 1)
|
||||
.or_insert((row, 1));
|
||||
}
|
||||
}
|
||||
let groups = counts
|
||||
.into_values()
|
||||
.map(|(mut row, count)| {
|
||||
row.insert("count".into(), json!(count));
|
||||
Value::Object(row)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"groups": groups, "totalPosts": total}))
|
||||
}
|
||||
|
||||
fn group_rows(post: &Post, dimensions: &[String]) -> Vec<Map<String, Value>> {
|
||||
let Some((dimension, rest)) = dimensions.split_first() else {
|
||||
return vec![Map::new()];
|
||||
};
|
||||
let values = match dimension.as_str() {
|
||||
"year" => Utc
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.map(|date| vec![json!(date.year())])
|
||||
.unwrap_or_else(|| vec![Value::Null]),
|
||||
"month" => Utc
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.map(|date| vec![json!(date.month())])
|
||||
.unwrap_or_else(|| vec![Value::Null]),
|
||||
"tag" => values_or_null(&post.tags),
|
||||
"category" => values_or_null(&post.categories),
|
||||
"status" => vec![json!(post.status.as_str())],
|
||||
_ => vec![Value::Null],
|
||||
};
|
||||
values
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
group_rows(post, rest).into_iter().map({
|
||||
let dimension = dimension.clone();
|
||||
move |mut row| {
|
||||
row.insert(dimension.clone(), value.clone());
|
||||
row
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn values_or_null(values: &[String]) -> Vec<Value> {
|
||||
if values.is_empty() {
|
||||
vec![Value::Null]
|
||||
} else {
|
||||
values.iter().map(|value| json!(value)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_post_by_slug(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let slug = required_string(params, "slug")?;
|
||||
let (project, data_dir) = active_project(conn)?;
|
||||
let post = post_q::get_post_by_project_and_slug(conn, &project.id, slug)
|
||||
.map_err(|_| EngineError::NotFound(format!("post slug {slug}")))?;
|
||||
let Some(language) = optional_string(params, "language") else {
|
||||
return Ok(json!({"post": post_detail(conn, &data_dir, &post)?}));
|
||||
};
|
||||
if post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|canonical| canonical.eq_ignore_ascii_case(language))
|
||||
{
|
||||
return Ok(json!({"post": post_detail(conn, &data_dir, &post)?}));
|
||||
}
|
||||
let translation =
|
||||
post_translation::get_post_translation_by_post_and_language(conn, &post.id, language)
|
||||
.map_err(|_| EngineError::NotFound(format!("post translation {language}")))?;
|
||||
let mut detail = post_detail(conn, &data_dir, &post)?;
|
||||
detail["title"] = json!(translation.title);
|
||||
detail["excerpt"] = json!(translation.excerpt);
|
||||
detail["content"] = json!(translation_content(&data_dir, &translation));
|
||||
detail["language"] = json!(translation.language);
|
||||
detail["canonicalLanguage"] = json!(post.language);
|
||||
Ok(json!({"post": detail}))
|
||||
}
|
||||
|
||||
fn translation_content(
|
||||
data_dir: &std::path::Path,
|
||||
translation: &crate::model::PostTranslation,
|
||||
) -> String {
|
||||
if let Some(content) = &translation.content {
|
||||
return content.clone();
|
||||
}
|
||||
std::fs::read_to_string(data_dir.join(&translation.file_path))
|
||||
.ok()
|
||||
.and_then(|source| {
|
||||
crate::util::frontmatter::read_translation_file(&source)
|
||||
.ok()
|
||||
.map(|(_, body)| body)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_post_translations(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "postId")?;
|
||||
let (project, data_dir) = active_project(conn)?;
|
||||
let post = post_q::get_post_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("post {id}")))?;
|
||||
if post.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("post {id}")));
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, id)?
|
||||
.into_iter()
|
||||
.map(|translation| {
|
||||
let mut value = serde_json::to_value(&translation)?;
|
||||
value["content"] = json!(translation_content(&data_dir, &translation));
|
||||
Ok(value)
|
||||
})
|
||||
.collect::<EngineResult<Vec<_>>>()?;
|
||||
Ok(json!({"translations": translations}))
|
||||
}
|
||||
|
||||
fn get_media_translations(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "mediaId")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
if media.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("media {id}")));
|
||||
}
|
||||
Ok(json!({
|
||||
"translations": media_translation::list_media_translations_by_media(conn, id)?
|
||||
}))
|
||||
}
|
||||
|
||||
fn propose_media_translation(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "mediaId")?;
|
||||
required_string(params, "language")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
if media.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("media {id}")));
|
||||
}
|
||||
propose(
|
||||
conn,
|
||||
ProposalKind::ProposeMediaTranslation,
|
||||
Some(id),
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
fn propose_script(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
required_string(params, "title")?;
|
||||
let content = required_string(params, "content")?;
|
||||
required_string(params, "kind")?
|
||||
.parse::<crate::model::ScriptKind>()
|
||||
.map_err(EngineError::Validation)?;
|
||||
crate::engine::script::validate_script_syntax(content).map_err(EngineError::Validation)?;
|
||||
propose(conn, ProposalKind::ProposeScript, None, params)
|
||||
}
|
||||
|
||||
fn propose_template(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
required_string(params, "title")?;
|
||||
let content = required_string(params, "content")?;
|
||||
required_string(params, "kind")?
|
||||
.parse::<crate::model::TemplateKind>()
|
||||
.map_err(EngineError::Validation)?;
|
||||
crate::engine::template::validate_template(content).map_err(EngineError::Validation)?;
|
||||
propose(conn, ProposalKind::ProposeTemplate, None, params)
|
||||
}
|
||||
|
||||
fn propose_media_metadata(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "mediaId")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
if media.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("media {id}")));
|
||||
}
|
||||
propose(conn, ProposalKind::ProposeMediaMetadata, Some(id), params)
|
||||
}
|
||||
|
||||
fn propose_post_metadata(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "postId")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let post = post_q::get_post_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("post {id}")))?;
|
||||
if post.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("post {id}")));
|
||||
}
|
||||
propose(conn, ProposalKind::ProposePostMetadata, Some(id), params)
|
||||
}
|
||||
|
||||
fn propose(
|
||||
conn: &DbConnection,
|
||||
kind: ProposalKind,
|
||||
entity_id: Option<&str>,
|
||||
params: &Value,
|
||||
) -> EngineResult<Value> {
|
||||
if kind == ProposalKind::DraftPost {
|
||||
required_string(params, "title")?;
|
||||
required_string(params, "content")?;
|
||||
}
|
||||
let (project, _) = active_project(conn)?;
|
||||
let proposal = create_proposal(conn, kind, &project.id, entity_id, params)?;
|
||||
Ok(json!({
|
||||
"proposalId": proposal.id,
|
||||
"status": proposal.status,
|
||||
"expiresAt": proposal.expires_at,
|
||||
"message": "Pending explicit approval in RuDS Settings"
|
||||
}))
|
||||
}
|
||||
|
||||
fn integer(value: &Value, key: &str) -> EngineResult<Option<i64>> {
|
||||
match value.get(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => value
|
||||
.as_i64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| EngineError::Validation(format!("{key} must be an integer"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsigned(value: &Value, key: &str) -> EngineResult<Option<usize>> {
|
||||
integer(value, key)?.map_or(Ok(None), |value| {
|
||||
usize::try_from(value)
|
||||
.map(Some)
|
||||
.map_err(|_| EngineError::Validation(format!("{key} cannot be negative")))
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,14 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::{Reader, Writer, XmlVersion, escape::escape};
|
||||
|
||||
use crate::engine::EngineError;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::model::Project;
|
||||
use crate::util::atomic_write_str;
|
||||
use crate::util::timestamp::unix_ms_to_iso;
|
||||
|
||||
/// A navigation menu item per menu.allium.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -27,7 +33,7 @@ impl MenuItemKind {
|
||||
Self::Home => "home",
|
||||
Self::Page => "page",
|
||||
Self::Submenu => "submenu",
|
||||
Self::CategoryArchive => "category_archive",
|
||||
Self::CategoryArchive => "category-archive",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +41,7 @@ impl MenuItemKind {
|
||||
match s {
|
||||
"home" => Self::Home,
|
||||
"submenu" => Self::Submenu,
|
||||
"category_archive" => Self::CategoryArchive,
|
||||
"category-archive" | "category_archive" => Self::CategoryArchive,
|
||||
_ => Self::Page,
|
||||
}
|
||||
}
|
||||
@@ -53,37 +59,38 @@ pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
|
||||
}]);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(parse_opml(&content))
|
||||
parse_opml(&content)
|
||||
}
|
||||
|
||||
/// Write the navigation menu to meta/menu.opml.
|
||||
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
|
||||
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
|
||||
pub fn write_menu(data_dir: &Path, project: &Project, items: &[MenuItem]) -> EngineResult<()> {
|
||||
let normalized = normalize_menu(items);
|
||||
let opml = serialize_opml(&normalized);
|
||||
let timestamp = if project.updated_at > 0 {
|
||||
project.updated_at
|
||||
} else {
|
||||
project.created_at
|
||||
};
|
||||
let opml = serialize_opml(&project.name, timestamp, &normalized)?;
|
||||
let path = data_dir.join("meta").join("menu.opml");
|
||||
atomic_write_str(&path, &opml)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the default menu OPML for new projects.
|
||||
pub fn default_menu_opml() -> String {
|
||||
pub fn default_menu_opml(project_name: &str, timestamp: i64) -> String {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
serialize_opml(&items)
|
||||
serialize_opml(project_name, timestamp, &items).expect("writing menu XML to memory cannot fail")
|
||||
}
|
||||
|
||||
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
|
||||
fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
|
||||
let without_home: Vec<MenuItem> = items
|
||||
.iter()
|
||||
.filter(|i| i.kind != MenuItemKind::Home)
|
||||
.cloned()
|
||||
.collect();
|
||||
let without_home: Vec<_> = items.iter().filter_map(normalize_non_home).collect();
|
||||
let mut result = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
@@ -94,212 +101,227 @@ fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_non_home(item: &MenuItem) -> Option<MenuItem> {
|
||||
if item.kind == MenuItemKind::Home {
|
||||
return None;
|
||||
}
|
||||
Some(MenuItem {
|
||||
kind: item.kind.clone(),
|
||||
label: item.label.clone(),
|
||||
slug: match item.kind {
|
||||
MenuItemKind::Page | MenuItemKind::CategoryArchive => item.slug.clone(),
|
||||
MenuItemKind::Home | MenuItemKind::Submenu => None,
|
||||
},
|
||||
children: if item.kind == MenuItemKind::Submenu {
|
||||
item.children
|
||||
.iter()
|
||||
.filter_map(normalize_non_home)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse OPML 2.0 XML into menu items.
|
||||
fn parse_opml(content: &str) -> Vec<MenuItem> {
|
||||
// Simple XML parsing using quick-xml-style manual parsing.
|
||||
// OPML structure: <opml><body><outline .../></body></opml>
|
||||
fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
|
||||
let mut reader = Reader::from_str(content);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut items = Vec::new();
|
||||
parse_outlines(content, &mut items);
|
||||
let mut outlines: Vec<Option<MenuItem>> = Vec::new();
|
||||
let mut elements: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
// Ensure HomeAlwaysPresent
|
||||
if items.is_empty() || items[0].kind != MenuItemKind::Home {
|
||||
let without_home: Vec<MenuItem> = items
|
||||
.into_iter()
|
||||
.filter(|i| i.kind != MenuItemKind::Home)
|
||||
.collect();
|
||||
let mut normalized = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
normalized.extend(without_home);
|
||||
return normalized;
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(event)) => {
|
||||
let name = event.name().as_ref().to_vec();
|
||||
if name == b"outline" {
|
||||
let collect = collect_outline(&elements, &outlines);
|
||||
outlines.push(collect.then(|| parse_outline(&event)).transpose()?);
|
||||
}
|
||||
items
|
||||
elements.push(name);
|
||||
}
|
||||
Ok(Event::Empty(event)) if event.name().as_ref() == b"outline" => {
|
||||
if collect_outline(&elements, &outlines) {
|
||||
attach_outline(parse_outline(&event)?, &mut outlines, &mut items);
|
||||
}
|
||||
}
|
||||
Ok(Event::End(event)) => {
|
||||
let name = event.name().as_ref().to_vec();
|
||||
let open = elements
|
||||
.pop()
|
||||
.ok_or_else(|| EngineError::Parse("unexpected closing element".to_string()))?;
|
||||
if open != name {
|
||||
return Err(EngineError::Parse("mismatched closing element".to_string()));
|
||||
}
|
||||
if name == b"outline" {
|
||||
let item = outlines
|
||||
.pop()
|
||||
.ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
|
||||
if let Some(mut item) = item {
|
||||
if item.kind != MenuItemKind::Submenu {
|
||||
item.children.clear();
|
||||
}
|
||||
attach_outline(item, &mut outlines, &mut items);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(error) => return Err(EngineError::Parse(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
if !outlines.is_empty() || !elements.is_empty() {
|
||||
return Err(EngineError::Parse("unclosed <outline>".to_string()));
|
||||
}
|
||||
Ok(normalize_menu(&items))
|
||||
}
|
||||
|
||||
/// Simple OPML outline parser.
|
||||
/// Parses <outline text="..." type="..." htmlUrl="..."> elements.
|
||||
fn parse_outlines(xml: &str, items: &mut Vec<MenuItem>) {
|
||||
// Find all outline elements at the body level
|
||||
let body_start = xml.find("<body>");
|
||||
let body_end = xml.find("</body>");
|
||||
if body_start.is_none() || body_end.is_none() {
|
||||
return;
|
||||
}
|
||||
let body = &xml[body_start.unwrap() + 6..body_end.unwrap()];
|
||||
parse_outline_children(body, items);
|
||||
fn collect_outline(elements: &[Vec<u8>], outlines: &[Option<MenuItem>]) -> bool {
|
||||
elements == [b"opml".as_slice(), b"body".as_slice()]
|
||||
|| (elements.last().is_some_and(|name| name == b"outline")
|
||||
&& outlines.last().is_some_and(Option::is_some))
|
||||
}
|
||||
|
||||
fn parse_outline_children(content: &str, items: &mut Vec<MenuItem>) {
|
||||
let mut pos = 0;
|
||||
while pos < content.len() {
|
||||
// Find next <outline
|
||||
let Some(start) = content[pos..].find("<outline") else {
|
||||
break;
|
||||
};
|
||||
let abs_start = pos + start;
|
||||
let tag_start = abs_start;
|
||||
fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
|
||||
let mut label = String::new();
|
||||
let mut kind_value = None;
|
||||
let mut legacy_kind = None;
|
||||
let mut page_slug = None;
|
||||
let mut category_name = None;
|
||||
let mut legacy_slug = None;
|
||||
let mut html_url = None;
|
||||
|
||||
// Find the end of the opening tag
|
||||
let rest = &content[tag_start..];
|
||||
let is_self_closing;
|
||||
let tag_end;
|
||||
|
||||
if let Some(sc) = rest.find("/>") {
|
||||
if let Some(gt) = rest.find('>') {
|
||||
if sc < gt {
|
||||
is_self_closing = true;
|
||||
tag_end = tag_start + sc + 2;
|
||||
} else {
|
||||
is_self_closing = false;
|
||||
tag_end = tag_start + gt + 1;
|
||||
}
|
||||
} else {
|
||||
is_self_closing = true;
|
||||
tag_end = tag_start + sc + 2;
|
||||
}
|
||||
} else if let Some(gt) = rest.find('>') {
|
||||
is_self_closing = false;
|
||||
tag_end = tag_start + gt + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
let tag_content = &content[tag_start..tag_end];
|
||||
|
||||
// Extract attributes
|
||||
let label = extract_attr(tag_content, "text")
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let kind_str = extract_attr(tag_content, "type")
|
||||
.unwrap_or_else(|| "page".to_string());
|
||||
let slug = extract_attr(tag_content, "htmlUrl");
|
||||
let kind = MenuItemKind::from_str(&kind_str);
|
||||
|
||||
let mut children = Vec::new();
|
||||
let after_tag;
|
||||
|
||||
if is_self_closing {
|
||||
after_tag = tag_end;
|
||||
} else {
|
||||
// Find matching </outline>
|
||||
let inner = &content[tag_end..];
|
||||
if let Some(close_idx) = find_closing_outline(inner) {
|
||||
let inner_content = &inner[..close_idx];
|
||||
parse_outline_children(inner_content, &mut children);
|
||||
after_tag = tag_end + close_idx + "</outline>".len();
|
||||
} else {
|
||||
after_tag = tag_end;
|
||||
for attribute in event.attributes() {
|
||||
let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let value = attribute
|
||||
.decoded_and_normalized_value(XmlVersion::Implicit1_0, event.decoder())
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||
.into_owned();
|
||||
match attribute.key.as_ref() {
|
||||
b"text" => label = value,
|
||||
b"type" => kind_value = Some(value),
|
||||
b"kind" => legacy_kind = Some(value),
|
||||
b"pageSlug" => page_slug = Some(value),
|
||||
b"categoryName" => category_name = Some(value),
|
||||
b"slug" => legacy_slug = Some(value),
|
||||
b"htmlUrl" => html_url = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
items.push(MenuItem {
|
||||
let kind = kind_value
|
||||
.or(legacy_kind)
|
||||
.as_deref()
|
||||
.map(MenuItemKind::from_str)
|
||||
.unwrap_or(MenuItemKind::Page);
|
||||
let slug = match kind {
|
||||
MenuItemKind::Home | MenuItemKind::Submenu => None,
|
||||
MenuItemKind::Page => page_slug.or(legacy_slug).or(html_url),
|
||||
MenuItemKind::CategoryArchive => category_name.or(legacy_slug).or(html_url),
|
||||
}
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
Ok(MenuItem {
|
||||
kind,
|
||||
label,
|
||||
slug,
|
||||
children,
|
||||
});
|
||||
pos = after_tag;
|
||||
}
|
||||
children: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_closing_outline(content: &str) -> Option<usize> {
|
||||
let mut depth = 0i32;
|
||||
let mut pos = 0;
|
||||
while pos < content.len() {
|
||||
if content[pos..].starts_with("<outline") {
|
||||
if let Some(sc) = content[pos..].find("/>") {
|
||||
if let Some(gt) = content[pos..].find('>') {
|
||||
if sc < gt {
|
||||
pos += sc + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
depth += 1;
|
||||
pos += 8; // skip past "<outline"
|
||||
} else if content[pos..].starts_with("</outline>") {
|
||||
if depth == 0 {
|
||||
return Some(pos);
|
||||
}
|
||||
depth -= 1;
|
||||
pos += 10;
|
||||
fn attach_outline(item: MenuItem, parents: &mut [Option<MenuItem>], items: &mut Vec<MenuItem>) {
|
||||
if let Some(Some(parent)) = parents.last_mut() {
|
||||
parent.children.push(item);
|
||||
} else {
|
||||
pos += 1;
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_attr(tag: &str, name: &str) -> Option<String> {
|
||||
let pattern = format!("{name}=\"");
|
||||
let start = tag.find(&pattern)?;
|
||||
let value_start = start + pattern.len();
|
||||
let rest = &tag[value_start..];
|
||||
let end = rest.find('"')?;
|
||||
let value = &rest[..end];
|
||||
// Unescape XML entities
|
||||
Some(
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serialize menu items to OPML 2.0 format.
|
||||
fn serialize_opml(items: &[MenuItem]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
out.push_str("<opml version=\"2.0\">\n");
|
||||
out.push_str(" <head><title>Blog Menu</title></head>\n");
|
||||
out.push_str(" <body>\n");
|
||||
fn serialize_opml(project_name: &str, timestamp: i64, items: &[MenuItem]) -> EngineResult<String> {
|
||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
|
||||
writer.config_mut().add_space_before_slash_in_empty_elements = true;
|
||||
writer
|
||||
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let mut opml = BytesStart::new("opml");
|
||||
opml.push_attribute(("version", "2.0"));
|
||||
writer
|
||||
.write_event(Event::Start(opml))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
writer
|
||||
.write_event(Event::Start(BytesStart::new("head")))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
write_text_element(&mut writer, "title", project_name)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let timestamp = unix_ms_to_iso(timestamp);
|
||||
write_text_element(&mut writer, "dateCreated", ×tamp)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
write_text_element(&mut writer, "dateModified", ×tamp)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
writer
|
||||
.write_event(Event::End(BytesEnd::new("head")))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
writer
|
||||
.write_event(Event::Start(BytesStart::new("body")))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
for item in items {
|
||||
serialize_outline(&mut out, item, 2);
|
||||
write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
}
|
||||
out.push_str(" </body>\n");
|
||||
out.push_str("</opml>\n");
|
||||
out
|
||||
writer
|
||||
.write_event(Event::End(BytesEnd::new("body")))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
writer
|
||||
.write_event(Event::End(BytesEnd::new("opml")))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let mut rendered = String::from_utf8(writer.into_inner())
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
rendered.push('\n');
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn serialize_outline(out: &mut String, item: &MenuItem, indent: usize) {
|
||||
let pad = " ".repeat(indent);
|
||||
out.push_str(&pad);
|
||||
out.push_str("<outline");
|
||||
out.push_str(&format!(
|
||||
" text=\"{}\"",
|
||||
xml_escape(&item.label)
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" type=\"{}\"",
|
||||
item.kind.as_str()
|
||||
));
|
||||
if let Some(ref slug) = item.slug {
|
||||
out.push_str(&format!(
|
||||
" htmlUrl=\"{}\"",
|
||||
xml_escape(slug)
|
||||
));
|
||||
fn write_text_element(
|
||||
writer: &mut Writer<Vec<u8>>,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> quick_xml::Result<()> {
|
||||
writer.write_event(Event::Start(BytesStart::new(name)))?;
|
||||
writer.write_event(Event::Text(BytesText::new(value)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new(name)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
|
||||
let label = escape(&item.label);
|
||||
let mut outline = BytesStart::new("outline");
|
||||
outline.push_attribute(("text", label.as_ref()));
|
||||
outline.push_attribute(("type", item.kind.as_str()));
|
||||
match item.kind {
|
||||
MenuItemKind::Home => outline.push_attribute(("pageSlug", "home")),
|
||||
MenuItemKind::Page => {
|
||||
if let Some(slug) = item.slug.as_deref().map(escape) {
|
||||
outline.push_attribute(("pageSlug", slug.as_ref()));
|
||||
}
|
||||
}
|
||||
MenuItemKind::CategoryArchive => {
|
||||
if let Some(slug) = item.slug.as_deref().map(escape) {
|
||||
outline.push_attribute(("categoryName", slug.as_ref()));
|
||||
}
|
||||
}
|
||||
MenuItemKind::Submenu => {}
|
||||
}
|
||||
if item.children.is_empty() {
|
||||
out.push_str("/>\n");
|
||||
writer.write_event(Event::Empty(outline))?;
|
||||
} else {
|
||||
out.push_str(">\n");
|
||||
writer.write_event(Event::Start(outline))?;
|
||||
for child in &item.children {
|
||||
serialize_outline(out, child, indent + 1);
|
||||
write_outline(writer, child)?;
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</outline>\n");
|
||||
writer.write_event(Event::End(BytesEnd::new("outline")))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -308,7 +330,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_opml_has_home() {
|
||||
let opml = default_menu_opml();
|
||||
let opml = default_menu_opml("Test Blog", 1_711_833_600_000);
|
||||
assert!(opml.contains("type=\"home\""));
|
||||
assert!(opml.contains("text=\"Home\""));
|
||||
}
|
||||
@@ -332,18 +354,16 @@ mod tests {
|
||||
kind: MenuItemKind::Submenu,
|
||||
label: "Categories".into(),
|
||||
slug: None,
|
||||
children: vec![
|
||||
MenuItem {
|
||||
children: vec![MenuItem {
|
||||
kind: MenuItemKind::CategoryArchive,
|
||||
label: "Tech".into(),
|
||||
slug: Some("/category/tech/".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
];
|
||||
let opml = serialize_opml(&items);
|
||||
let parsed = parse_opml(&opml);
|
||||
let opml = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap();
|
||||
let parsed = parse_opml(&opml).unwrap();
|
||||
assert_eq!(parsed.len(), 3);
|
||||
assert_eq!(parsed[0].kind, MenuItemKind::Home);
|
||||
assert_eq!(parsed[1].label, "About");
|
||||
@@ -354,14 +374,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn normalize_prepends_home() {
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "About".into(),
|
||||
slug: Some("/about".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
];
|
||||
}];
|
||||
let normalized = normalize_menu(&items);
|
||||
assert_eq!(normalized.len(), 2);
|
||||
assert_eq!(normalized[0].kind, MenuItemKind::Home);
|
||||
@@ -381,15 +399,27 @@ mod tests {
|
||||
fn write_and_read_menu() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "Blog".into(),
|
||||
slug: Some("/blog".into()),
|
||||
children: Vec::new(),
|
||||
}];
|
||||
write_menu(
|
||||
dir.path(),
|
||||
&Project {
|
||||
id: "project-1".into(),
|
||||
name: "Test Blog".into(),
|
||||
slug: "test-blog".into(),
|
||||
description: None,
|
||||
data_path: None,
|
||||
is_active: true,
|
||||
created_at: 1_711_833_600_000,
|
||||
updated_at: 1_711_833_600_000,
|
||||
},
|
||||
];
|
||||
write_menu(dir.path(), &items).unwrap();
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
let read = read_menu(dir.path()).unwrap();
|
||||
// Home is always prepended
|
||||
assert_eq!(read.len(), 2);
|
||||
@@ -398,8 +428,113 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_special_chars() {
|
||||
let escaped = xml_escape("Tom & Jerry <3 \"quotes\"");
|
||||
assert_eq!(escaped, "Tom & Jerry <3 "quotes"");
|
||||
fn reads_single_quoted_xml_attributes() {
|
||||
let parsed = parse_opml(
|
||||
"<opml version='2.0'><body><outline text='About' type='page' htmlUrl='/about'/></body></opml>",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parsed[1].label, "About");
|
||||
assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bds2_attributes_round_trip_without_legacy_output() {
|
||||
let parsed = parse_opml(
|
||||
"<opml version='2.0'><body><outline text='Home' type='home' pageSlug='home'/><outline text='Sections' type='submenu'><outline text='About' type='page' pageSlug='about'/><outline text='Notes' type='category-archive' categoryName='notes'/></outline></body></opml>",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(parsed[1].children[0].slug.as_deref(), Some("about"));
|
||||
assert_eq!(parsed[1].children[1].kind, MenuItemKind::CategoryArchive);
|
||||
assert_eq!(parsed[1].children[1].slug.as_deref(), Some("notes"));
|
||||
|
||||
let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &parsed).unwrap();
|
||||
assert!(serialized.contains("type=\"home\" pageSlug=\"home\""));
|
||||
assert!(serialized.contains("type=\"page\" pageSlug=\"about\""));
|
||||
assert!(serialized.contains("type=\"category-archive\" categoryName=\"notes\""));
|
||||
assert!(!serialized.contains("htmlUrl"));
|
||||
assert!(!serialized.contains("category_archive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializer_matches_bds2_head_spacing_and_trailing_newline() {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".into(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
|
||||
"<opml version=\"2.0\">\n",
|
||||
" <head>\n",
|
||||
" <title>Test Blog</title>\n",
|
||||
" <dateCreated>2024-03-30T21:20:00.000Z</dateCreated>\n",
|
||||
" <dateModified>2024-03-30T21:20:00.000Z</dateModified>\n",
|
||||
" </head>\n",
|
||||
" <body>\n",
|
||||
" <outline text=\"Home\" type=\"home\" pageSlug=\"home\" />\n",
|
||||
" </body>\n",
|
||||
"</opml>\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() {
|
||||
let parsed = parse_opml(
|
||||
"<opml><head><outline text='Head'/></head><body><section><outline text='Foreign'/></section><outline text='Page' type='page' pageSlug='page'><outline text='Dropped' type='page' pageSlug='dropped'/></outline><outline text='Kept' type='submenu'><outline text='Child' type='page' pageSlug='child'/></outline></body></opml>",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 3);
|
||||
assert_eq!(parsed[1].label, "Page");
|
||||
assert!(parsed[1].children.is_empty());
|
||||
assert_eq!(parsed[2].label, "Kept");
|
||||
assert_eq!(parsed[2].children[0].label, "Child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_removes_home_entries_at_every_depth() {
|
||||
let normalized = normalize_menu(&[
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Duplicate".into(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Submenu,
|
||||
label: "Sections".into(),
|
||||
slug: None,
|
||||
children: vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Nested Home".into(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(normalized[0].kind, MenuItemKind::Home);
|
||||
assert_eq!(normalized[0].label, "Home");
|
||||
assert!(normalized[1].children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_menu_rejects_malformed_xml() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("meta/menu.opml"),
|
||||
"<opml><body><outline></body></opml>",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(read_menu(dir.path()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,99 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::engine::EngineResult;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
use crate::model::PublishingPreferences;
|
||||
use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences};
|
||||
use crate::util::atomic_write_str;
|
||||
|
||||
const PROJECT_SETTING_SUFFIX: &str = "project";
|
||||
const CATEGORIES_SETTING_SUFFIX: &str = "categories";
|
||||
const CATEGORY_META_SETTING_SUFFIX: &str = "category_meta";
|
||||
const PUBLISHING_SETTING_SUFFIX: &str = "publishing";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CategoriesSnapshot {
|
||||
categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CategoryMetaSnapshot {
|
||||
categories: HashMap<String, CategorySettings>,
|
||||
}
|
||||
|
||||
fn metadata_setting_key(project_id: &str, suffix: &str) -> String {
|
||||
format!("project:{project_id}:{suffix}")
|
||||
}
|
||||
|
||||
fn persist_snapshot<T: Serialize>(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
suffix: &str,
|
||||
value: &T,
|
||||
) -> EngineResult<()> {
|
||||
crate::db::queries::setting::set_setting_value(
|
||||
conn,
|
||||
&metadata_setting_key(project_id, suffix),
|
||||
&serde_json::to_string(value)?,
|
||||
crate::util::now_unix_ms(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_snapshot<T: DeserializeOwned>(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
suffix: &str,
|
||||
) -> EngineResult<Option<T>> {
|
||||
match crate::db::queries::setting::get_setting_by_key(
|
||||
conn,
|
||||
&metadata_setting_key(project_id, suffix),
|
||||
) {
|
||||
Ok(setting) => Ok(Some(serde_json::from_str(&setting.value)?)),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(error) => Err(EngineError::Db(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_project_metadata_snapshot(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Option<ProjectMetadata>> {
|
||||
load_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX)
|
||||
}
|
||||
|
||||
pub fn read_categories_snapshot(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Option<Vec<String>>> {
|
||||
Ok(
|
||||
load_snapshot::<CategoriesSnapshot>(conn, project_id, CATEGORIES_SETTING_SUFFIX)?
|
||||
.map(|snapshot| snapshot.categories),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_category_meta_snapshot(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Option<HashMap<String, CategorySettings>>> {
|
||||
Ok(
|
||||
load_snapshot::<CategoryMetaSnapshot>(conn, project_id, CATEGORY_META_SETTING_SUFFIX)?
|
||||
.map(|snapshot| snapshot.categories),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_publishing_snapshot(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Option<PublishingPreferences>> {
|
||||
load_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX)
|
||||
}
|
||||
|
||||
// ── project.json ────────────────────────────────────────────────────
|
||||
|
||||
/// Read and parse meta/project.json.
|
||||
@@ -25,6 +112,155 @@ pub fn write_project_json(data_dir: &Path, meta: &ProjectMetadata) -> EngineResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the shared project row and its filesystem metadata, then publish
|
||||
/// the normal project-updated domain event.
|
||||
pub fn update_project_metadata(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project: &Project,
|
||||
metadata: &ProjectMetadata,
|
||||
) -> EngineResult<ProjectMetadata> {
|
||||
if project.id.is_empty() {
|
||||
return Err(crate::engine::EngineError::Validation(
|
||||
"project id is required".to_string(),
|
||||
));
|
||||
}
|
||||
metadata
|
||||
.validate()
|
||||
.map_err(crate::engine::EngineError::Validation)?;
|
||||
|
||||
let mut persisted_project = project.clone();
|
||||
persisted_project.name = metadata.name.clone();
|
||||
persisted_project.description = metadata.description.clone();
|
||||
persisted_project.updated_at = crate::util::now_unix_ms();
|
||||
|
||||
let category_metadata = read_category_meta_json(data_dir)?;
|
||||
persist_snapshot(conn, &project.id, PROJECT_SETTING_SUFFIX, metadata)?;
|
||||
write_project_json(data_dir, metadata)?;
|
||||
write_category_meta_json(data_dir, &category_metadata)?;
|
||||
crate::db::queries::project::update_project(conn, &persisted_project)?;
|
||||
crate::engine::domain_events::entity_changed(
|
||||
&project.id,
|
||||
DomainEntity::Project,
|
||||
&project.id,
|
||||
NotificationAction::Updated,
|
||||
);
|
||||
Ok(metadata.clone())
|
||||
}
|
||||
|
||||
/// Replace all database metadata snapshots with the portable filesystem state.
|
||||
pub fn sync_metadata_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let project = read_project_json(data_dir)?;
|
||||
project.validate().map_err(EngineError::Validation)?;
|
||||
let categories = read_categories_json(data_dir)?;
|
||||
let category_meta = read_category_meta_json(data_dir)?;
|
||||
let publishing = read_publishing_json(data_dir)?;
|
||||
|
||||
conn.begin_savepoint()?;
|
||||
let result = (|| {
|
||||
let mut project_row = crate::db::queries::project::get_project_by_id(conn, project_id)?;
|
||||
project_row.name = project.name.clone();
|
||||
project_row.description = project.description.clone();
|
||||
project_row.updated_at = crate::util::now_unix_ms();
|
||||
crate::db::queries::project::update_project(conn, &project_row)?;
|
||||
persist_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX, &project)?;
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORIES_SETTING_SUFFIX,
|
||||
&CategoriesSnapshot { categories },
|
||||
)?;
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORY_META_SETTING_SUFFIX,
|
||||
&CategoryMetaSnapshot {
|
||||
categories: category_meta,
|
||||
},
|
||||
)?;
|
||||
persist_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX, &publishing)
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => conn.release_savepoint().map_err(Into::into),
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed database snapshots for pre-snapshot projects without overwriting
|
||||
/// deliberate database/file divergence on later activations.
|
||||
pub fn initialize_metadata_snapshots(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
if read_project_metadata_snapshot(conn, project_id)?.is_none() {
|
||||
let value = read_project_json(data_dir)?;
|
||||
persist_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX, &value)?;
|
||||
}
|
||||
if read_categories_snapshot(conn, project_id)?.is_none() {
|
||||
let categories = read_categories_json(data_dir)?;
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORIES_SETTING_SUFFIX,
|
||||
&CategoriesSnapshot { categories },
|
||||
)?;
|
||||
}
|
||||
if read_category_meta_snapshot(conn, project_id)?.is_none() {
|
||||
let categories = read_category_meta_json(data_dir)?;
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORY_META_SETTING_SUFFIX,
|
||||
&CategoryMetaSnapshot { categories },
|
||||
)?;
|
||||
}
|
||||
if read_publishing_snapshot(conn, project_id)?.is_none() {
|
||||
let value = read_publishing_json(data_dir)?;
|
||||
persist_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX, &value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush_metadata_to_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let project = read_project_metadata_snapshot(conn, project_id)?.ok_or_else(|| {
|
||||
EngineError::NotFound(format!(
|
||||
"project metadata snapshot for project {project_id}"
|
||||
))
|
||||
})?;
|
||||
let categories = read_categories_snapshot(conn, project_id)?.ok_or_else(|| {
|
||||
EngineError::NotFound(format!(
|
||||
"categories metadata snapshot for project {project_id}"
|
||||
))
|
||||
})?;
|
||||
let category_meta = read_category_meta_snapshot(conn, project_id)?.ok_or_else(|| {
|
||||
EngineError::NotFound(format!(
|
||||
"category metadata snapshot for project {project_id}"
|
||||
))
|
||||
})?;
|
||||
let publishing = read_publishing_snapshot(conn, project_id)?.ok_or_else(|| {
|
||||
EngineError::NotFound(format!(
|
||||
"publishing metadata snapshot for project {project_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
write_project_json(data_dir, &project)?;
|
||||
write_categories_json(data_dir, &categories)?;
|
||||
write_category_meta_json(data_dir, &category_meta)?;
|
||||
write_publishing_json(data_dir, &publishing)
|
||||
}
|
||||
|
||||
// ── categories.json ─────────────────────────────────────────────────
|
||||
|
||||
/// Read meta/categories.json as a sorted array of strings.
|
||||
@@ -38,19 +274,36 @@ pub fn read_categories_json(data_dir: &Path) -> EngineResult<Vec<String>> {
|
||||
/// Sort categories, then atomic write to meta/categories.json.
|
||||
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
|
||||
let mut sorted = categories.to_vec();
|
||||
sorted.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||
sorted.sort();
|
||||
let path = data_dir.join("meta").join("categories.json");
|
||||
let json = serde_json::to_string_pretty(&sorted)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_categories(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
categories: &[String],
|
||||
) -> EngineResult<()> {
|
||||
let mut sorted = categories.to_vec();
|
||||
sorted.sort();
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORIES_SETTING_SUFFIX,
|
||||
&CategoriesSnapshot {
|
||||
categories: sorted.clone(),
|
||||
},
|
||||
)?;
|
||||
write_categories_json(data_dir, &sorted)
|
||||
}
|
||||
|
||||
// ── category-meta.json ──────────────────────────────────────────────
|
||||
|
||||
/// Read meta/category-meta.json.
|
||||
pub fn read_category_meta_json(
|
||||
data_dir: &Path,
|
||||
) -> EngineResult<HashMap<String, CategorySettings>> {
|
||||
pub fn read_category_meta_json(data_dir: &Path) -> EngineResult<HashMap<String, CategorySettings>> {
|
||||
let path = data_dir.join("meta").join("category-meta.json");
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
|
||||
@@ -63,11 +316,68 @@ pub fn write_category_meta_json(
|
||||
meta: &HashMap<String, CategorySettings>,
|
||||
) -> EngineResult<()> {
|
||||
let path = data_dir.join("meta").join("category-meta.json");
|
||||
let json = serde_json::to_string_pretty(meta)?;
|
||||
let sorted = meta.iter().collect::<BTreeMap<_, _>>();
|
||||
let json = serde_json::to_string_pretty(&sorted)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_category_meta(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
meta: &HashMap<String, CategorySettings>,
|
||||
) -> EngineResult<()> {
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORY_META_SETTING_SUFFIX,
|
||||
&CategoryMetaSnapshot {
|
||||
categories: meta.clone(),
|
||||
},
|
||||
)?;
|
||||
write_category_meta_json(data_dir, meta)
|
||||
}
|
||||
|
||||
pub fn set_categories_and_meta(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
categories: &[String],
|
||||
meta: &HashMap<String, CategorySettings>,
|
||||
) -> EngineResult<()> {
|
||||
let mut sorted = categories.to_vec();
|
||||
sorted.sort();
|
||||
conn.begin_savepoint()?;
|
||||
let result = (|| {
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORIES_SETTING_SUFFIX,
|
||||
&CategoriesSnapshot {
|
||||
categories: sorted.clone(),
|
||||
},
|
||||
)?;
|
||||
persist_snapshot(
|
||||
conn,
|
||||
project_id,
|
||||
CATEGORY_META_SETTING_SUFFIX,
|
||||
&CategoryMetaSnapshot {
|
||||
categories: meta.clone(),
|
||||
},
|
||||
)
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => conn.release_savepoint()?,
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
write_categories_json(data_dir, &sorted)?;
|
||||
write_category_meta_json(data_dir, meta)
|
||||
}
|
||||
|
||||
// ── publishing.json ─────────────────────────────────────────────────
|
||||
|
||||
/// Read meta/publishing.json.
|
||||
@@ -79,16 +389,23 @@ pub fn read_publishing_json(data_dir: &Path) -> EngineResult<PublishingPreferenc
|
||||
}
|
||||
|
||||
/// Atomic write to meta/publishing.json.
|
||||
pub fn write_publishing_json(
|
||||
data_dir: &Path,
|
||||
prefs: &PublishingPreferences,
|
||||
) -> EngineResult<()> {
|
||||
pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> EngineResult<()> {
|
||||
let path = data_dir.join("meta").join("publishing.json");
|
||||
let json = serde_json::to_string_pretty(prefs)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_publishing_preferences(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
prefs: &PublishingPreferences,
|
||||
) -> EngineResult<()> {
|
||||
persist_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX, prefs)?;
|
||||
write_publishing_json(data_dir, prefs)
|
||||
}
|
||||
|
||||
// ── tags.json ───────────────────────────────────────────────────────
|
||||
|
||||
/// Read meta/tags.json.
|
||||
@@ -102,7 +419,7 @@ pub fn read_tags_json(data_dir: &Path) -> EngineResult<Vec<TagEntry>> {
|
||||
/// Sort by name case-insensitive, then atomic write to meta/tags.json.
|
||||
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
|
||||
let mut sorted = tags.to_vec();
|
||||
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
sorted.sort_by_key(|a| a.name.to_lowercase());
|
||||
let path = data_dir.join("meta").join("tags.json");
|
||||
let json = serde_json::to_string_pretty(&sorted)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
@@ -112,11 +429,15 @@ pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
|
||||
// ── category helpers ────────────────────────────────────────────────
|
||||
|
||||
/// Add a category to categories.json and initialize it in category-meta.json.
|
||||
pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
||||
pub fn add_category(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
category: &str,
|
||||
) -> EngineResult<()> {
|
||||
let mut cats = read_categories_json(data_dir)?;
|
||||
if !cats.iter().any(|c| c.eq_ignore_ascii_case(category)) {
|
||||
cats.push(category.to_string());
|
||||
write_categories_json(data_dir, &cats)?;
|
||||
}
|
||||
|
||||
let mut meta = read_category_meta_json(data_dir)?;
|
||||
@@ -124,85 +445,30 @@ pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
||||
meta.insert(
|
||||
category.to_string(),
|
||||
CategorySettings {
|
||||
title: None,
|
||||
titles: BTreeMap::new(),
|
||||
render_in_lists: true,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
);
|
||||
write_category_meta_json(data_dir, &meta)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
set_categories_and_meta(conn, data_dir, project_id, &cats, &meta)
|
||||
}
|
||||
|
||||
/// Remove a category from both categories.json and category-meta.json.
|
||||
pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
||||
pub fn remove_category(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
category: &str,
|
||||
) -> EngineResult<()> {
|
||||
let mut cats = read_categories_json(data_dir)?;
|
||||
cats.retain(|c| !c.eq_ignore_ascii_case(category));
|
||||
write_categories_json(data_dir, &cats)?;
|
||||
|
||||
let mut meta = read_category_meta_json(data_dir)?;
|
||||
meta.remove(category);
|
||||
write_category_meta_json(data_dir, &meta)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── update helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Update the blog_languages list in project.json.
|
||||
/// Per metadata.allium UpdateProjectMetadata: writes ProjectJsonWritten.
|
||||
pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineResult<()> {
|
||||
let mut meta = read_project_json(data_dir)?;
|
||||
meta.blog_languages = languages;
|
||||
write_project_json(data_dir, &meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update arbitrary fields of project metadata.
|
||||
/// Per metadata.allium UpdateProjectMetadata rule.
|
||||
pub fn update_project_metadata(
|
||||
data_dir: &Path,
|
||||
changes: &serde_json::Value,
|
||||
) -> EngineResult<()> {
|
||||
let mut meta = read_project_json(data_dir)?;
|
||||
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
|
||||
meta.name = name.to_string();
|
||||
}
|
||||
if let Some(desc) = changes.get("description") {
|
||||
meta.description = desc.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(url) = changes.get("publicUrl") {
|
||||
meta.public_url = url.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(lang) = changes.get("mainLanguage") {
|
||||
meta.main_language = lang.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(author) = changes.get("defaultAuthor") {
|
||||
meta.default_author = author.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) {
|
||||
meta.max_posts_per_page = max as i32;
|
||||
}
|
||||
if let Some(cat) = changes.get("blogmarkCategory") {
|
||||
meta.blogmark_category = cat.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(theme) = changes.get("picoTheme") {
|
||||
meta.pico_theme = theme.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(enabled) = changes.get("semanticSimilarityEnabled").and_then(|v| v.as_bool()) {
|
||||
meta.semantic_similarity_enabled = enabled;
|
||||
}
|
||||
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
|
||||
meta.blog_languages = langs
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
meta.validate().map_err(|e| crate::engine::EngineError::Validation(e))?;
|
||||
write_project_json(data_dir, &meta)?;
|
||||
Ok(())
|
||||
set_categories_and_meta(conn, data_dir, project_id, &cats, &meta)
|
||||
}
|
||||
|
||||
// ── startup sync ────────────────────────────────────────────────────
|
||||
@@ -222,6 +488,7 @@ pub fn startup_sync(data_dir: &Path) -> EngineResult<()> {
|
||||
main_language: None,
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
image_import_concurrency: 4,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
@@ -249,7 +516,7 @@ pub fn startup_sync(data_dir: &Path) -> EngineResult<()> {
|
||||
|
||||
// Ensure publishing.json exists
|
||||
if !meta_dir.join("publishing.json").exists() {
|
||||
atomic_write_str(&meta_dir.join("publishing.json"), "{}")?;
|
||||
write_publishing_json(data_dir, &PublishingPreferences::default())?;
|
||||
}
|
||||
|
||||
// Ensure tags.json exists
|
||||
@@ -259,7 +526,9 @@ pub fn startup_sync(data_dir: &Path) -> EngineResult<()> {
|
||||
|
||||
// Ensure menu.opml exists
|
||||
if !meta_dir.join("menu.opml").exists() {
|
||||
let opml = crate::engine::menu::default_menu_opml();
|
||||
let project = read_project_json(data_dir)?;
|
||||
let opml =
|
||||
crate::engine::menu::default_menu_opml(&project.name, crate::util::now_unix_ms());
|
||||
atomic_write_str(&meta_dir.join("menu.opml"), &opml)?;
|
||||
}
|
||||
|
||||
@@ -290,6 +559,7 @@ mod tests {
|
||||
main_language: Some("en".into()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 25,
|
||||
image_import_concurrency: 4,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
@@ -302,6 +572,45 @@ mod tests {
|
||||
assert_eq!(read.description.as_deref(), Some("A blog"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_json_matches_bds2_canonical_key_order() {
|
||||
let dir = setup();
|
||||
let meta = ProjectMetadata {
|
||||
name: "Test".into(),
|
||||
description: Some("A blog".into()),
|
||||
public_url: Some("https://example.com".into()),
|
||||
main_language: Some("en".into()),
|
||||
default_author: Some("Writer".into()),
|
||||
max_posts_per_page: 25,
|
||||
image_import_concurrency: 3,
|
||||
blogmark_category: Some("links".into()),
|
||||
pico_theme: Some("amber".into()),
|
||||
semantic_similarity_enabled: true,
|
||||
blog_languages: vec!["en".into(), "de".into()],
|
||||
};
|
||||
write_project_json(dir.path(), &meta).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/project.json")).unwrap(),
|
||||
r#"{
|
||||
"blogLanguages": [
|
||||
"en",
|
||||
"de"
|
||||
],
|
||||
"blogmarkCategory": "links",
|
||||
"defaultAuthor": "Writer",
|
||||
"description": "A blog",
|
||||
"imageImportConcurrency": 3,
|
||||
"mainLanguage": "en",
|
||||
"maxPostsPerPage": 25,
|
||||
"name": "Test",
|
||||
"picoTheme": "amber",
|
||||
"publicUrl": "https://example.com",
|
||||
"semanticSimilarityEnabled": true
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
// ── categories.json ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -313,6 +622,17 @@ mod tests {
|
||||
assert_eq!(read, vec!["article", "aside", "picture"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_json_uses_bds2_case_sensitive_sort() {
|
||||
let dir = setup();
|
||||
write_categories_json(dir.path(), &["alpha".into(), "Zebra".into(), "Beta".into()])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
read_categories_json(dir.path()).unwrap(),
|
||||
vec!["Beta", "Zebra", "alpha"]
|
||||
);
|
||||
}
|
||||
|
||||
// ── category-meta.json ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -322,6 +642,8 @@ mod tests {
|
||||
meta.insert(
|
||||
"article".to_string(),
|
||||
CategorySettings {
|
||||
title: None,
|
||||
titles: BTreeMap::new(),
|
||||
render_in_lists: true,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
@@ -334,6 +656,82 @@ mod tests {
|
||||
assert!(read["article"].render_in_lists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_meta_json_preserves_bds2_title() {
|
||||
let dir = setup();
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(
|
||||
"news".to_string(),
|
||||
CategorySettings {
|
||||
title: Some("News Archive".to_string()),
|
||||
titles: BTreeMap::from([("de".into(), "Nachrichten".into())]),
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
post_template_slug: Some("article".to_string()),
|
||||
list_template_slug: Some("listing".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
write_category_meta_json(dir.path(), &meta).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||
assert_eq!(json["news"]["title"], "News Archive");
|
||||
assert_eq!(json["news"]["titles"]["de"], "Nachrichten");
|
||||
assert_eq!(json["news"]["renderInLists"], false);
|
||||
assert_eq!(json["news"]["showTitle"], true);
|
||||
|
||||
let read = read_category_meta_json(dir.path()).unwrap();
|
||||
assert_eq!(read["news"].title.as_deref(), Some("News Archive"));
|
||||
assert_eq!(read["news"].title_for("de", "en"), Some("Nachrichten"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_meta_json_matches_bds2_deterministic_key_order() {
|
||||
let dir = setup();
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(
|
||||
"zebra".to_string(),
|
||||
CategorySettings {
|
||||
title: Some("Zebra".into()),
|
||||
titles: BTreeMap::new(),
|
||||
render_in_lists: true,
|
||||
show_title: false,
|
||||
post_template_slug: Some("post".into()),
|
||||
list_template_slug: Some("list".into()),
|
||||
},
|
||||
);
|
||||
meta.insert(
|
||||
"alpha".to_string(),
|
||||
CategorySettings {
|
||||
title: None,
|
||||
titles: BTreeMap::new(),
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
);
|
||||
write_category_meta_json(dir.path(), &meta).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap(),
|
||||
r#"{
|
||||
"alpha": {
|
||||
"renderInLists": false,
|
||||
"showTitle": true
|
||||
},
|
||||
"zebra": {
|
||||
"listTemplateSlug": "list",
|
||||
"postTemplateSlug": "post",
|
||||
"renderInLists": true,
|
||||
"showTitle": false,
|
||||
"title": "Zebra"
|
||||
}
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
// ── publishing.json ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -351,6 +749,77 @@ mod tests {
|
||||
assert_eq!(read.ssh_mode, SshMode::Rsync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishing_json_matches_bds2_canonical_key_order() {
|
||||
let dir = setup();
|
||||
let prefs = PublishingPreferences {
|
||||
ssh_host: Some("example.com".into()),
|
||||
ssh_user: Some("deploy".into()),
|
||||
ssh_remote_path: Some("/var/www".into()),
|
||||
ssh_mode: SshMode::Rsync,
|
||||
};
|
||||
write_publishing_json(dir.path(), &prefs).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/publishing.json")).unwrap(),
|
||||
r#"{
|
||||
"sshHost": "example.com",
|
||||
"sshMode": "rsync",
|
||||
"sshRemotePath": "/var/www",
|
||||
"sshUser": "deploy"
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_empty_metadata_collections_and_default_publishing_are_compact() {
|
||||
let dir = setup();
|
||||
write_project_json(
|
||||
dir.path(),
|
||||
&ProjectMetadata {
|
||||
name: "Minimal".into(),
|
||||
description: None,
|
||||
public_url: None,
|
||||
main_language: None,
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
image_import_concurrency: 4,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
||||
write_tags_json(dir.path(), &[]).unwrap();
|
||||
write_publishing_json(dir.path(), &PublishingPreferences::default()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/project.json")).unwrap(),
|
||||
concat!(
|
||||
"{\n",
|
||||
" \"blogLanguages\": [],\n",
|
||||
" \"imageImportConcurrency\": 4,\n",
|
||||
" \"maxPostsPerPage\": 50,\n",
|
||||
" \"name\": \"Minimal\",\n",
|
||||
" \"semanticSimilarityEnabled\": false\n",
|
||||
"}"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap(),
|
||||
"{}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/tags.json")).unwrap(),
|
||||
"[]"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/publishing.json")).unwrap(),
|
||||
"{\n \"sshMode\": \"scp\"\n}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── tags.json ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -374,16 +843,41 @@ mod tests {
|
||||
assert_eq!(read[1].name, "Zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_json_matches_bds2_entry_order_and_omits_blank_optional_values() {
|
||||
let dir = setup();
|
||||
write_tags_json(
|
||||
dir.path(),
|
||||
&[TagEntry {
|
||||
name: "rust".into(),
|
||||
color: Some("".into()),
|
||||
post_template_slug: Some("article".into()),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("meta/tags.json")).unwrap(),
|
||||
r#"[
|
||||
{
|
||||
"name": "rust",
|
||||
"postTemplateSlug": "article"
|
||||
}
|
||||
]"#
|
||||
);
|
||||
}
|
||||
|
||||
// ── add / remove category ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn add_category_creates_entries() {
|
||||
let dir = setup();
|
||||
let db = crate::db::Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
// Seed files
|
||||
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
|
||||
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
||||
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
||||
|
||||
add_category(dir.path(), "page").unwrap();
|
||||
add_category(db.conn(), dir.path(), "p1", "page").unwrap();
|
||||
|
||||
let cats = read_categories_json(dir.path()).unwrap();
|
||||
assert!(cats.contains(&"page".to_string()));
|
||||
@@ -395,10 +889,12 @@ mod tests {
|
||||
#[test]
|
||||
fn add_category_idempotent() {
|
||||
let dir = setup();
|
||||
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
|
||||
let db = crate::db::Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
||||
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
|
||||
|
||||
add_category(dir.path(), "article").unwrap();
|
||||
add_category(db.conn(), dir.path(), "p1", "article").unwrap();
|
||||
let cats = read_categories_json(dir.path()).unwrap();
|
||||
assert_eq!(cats.iter().filter(|c| *c == "article").count(), 1);
|
||||
}
|
||||
@@ -406,11 +902,15 @@ mod tests {
|
||||
#[test]
|
||||
fn remove_category_deletes_entries() {
|
||||
let dir = setup();
|
||||
write_categories_json(dir.path(), &vec!["article".into(), "page".into()]).unwrap();
|
||||
let db = crate::db::Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap();
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(
|
||||
"article".to_string(),
|
||||
CategorySettings {
|
||||
title: None,
|
||||
titles: BTreeMap::new(),
|
||||
render_in_lists: true,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
@@ -419,7 +919,7 @@ mod tests {
|
||||
);
|
||||
write_category_meta_json(dir.path(), &meta).unwrap();
|
||||
|
||||
remove_category(dir.path(), "article").unwrap();
|
||||
remove_category(db.conn(), dir.path(), "p1", "article").unwrap();
|
||||
|
||||
let cats = read_categories_json(dir.path()).unwrap();
|
||||
assert!(!cats.contains(&"article".to_string()));
|
||||
@@ -428,4 +928,26 @@ mod tests {
|
||||
let meta = read_category_meta_json(dir.path()).unwrap();
|
||||
assert!(!meta.contains_key("article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_initialization_does_not_hide_later_filesystem_drift() {
|
||||
let dir = setup();
|
||||
let db = crate::db::Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
crate::db::queries::project::insert_project(
|
||||
db.conn(),
|
||||
&crate::db::queries::project::make_test_project("p1", "blog"),
|
||||
)
|
||||
.unwrap();
|
||||
startup_sync(dir.path()).unwrap();
|
||||
initialize_metadata_snapshots(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
write_categories_json(dir.path(), &["filesystem-only".into()]).unwrap();
|
||||
initialize_metadata_snapshots(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read_categories_snapshot(db.conn(), "p1").unwrap().unwrap(),
|
||||
vec!["article", "aside", "page", "picture"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user