Compare commits

..

124 Commits

Author SHA1 Message Date
Hermes Agent
58596c8de0 Use the ORT rc.11 library location variable.
All checks were successful
Tagged release / prepare-release (push) Successful in 3m3s
Tagged release / build-linux-x64 (push) Successful in 36m54s
Tagged release / build-macos (push) Successful in 57m53s
Tagged release / build-linux-arm64 (push) Successful in 34m37s
Tagged release / build-windows (push) Successful in 34m26s
Tagged release / publish-release (push) Successful in 3m0s
2026-08-15 05:03:19 +00:00
Hermes Agent
d3bfc13e67 Support Intel macOS with the last upstream runtime.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m0s
Tagged release / build-linux-x64 (push) Successful in 35m32s
Tagged release / build-macos (push) Failing after 38m56s
Tagged release / build-windows (push) Has been cancelled
Tagged release / publish-release (push) Has been cancelled
Tagged release / build-linux-arm64 (push) Has been cancelled
2026-08-15 03:42:22 +00:00
Hermes Agent
0c32f9fdee Use Zig-compatible Apple symbol lookup.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m59s
Tagged release / build-linux-x64 (push) Successful in 35m59s
Tagged release / build-macos (push) Failing after 39m13s
Tagged release / build-linux-arm64 (push) Successful in 33m55s
Tagged release / publish-release (push) Has been cancelled
Tagged release / build-windows (push) Has been cancelled
2026-08-15 01:43:14 +00:00
Hermes Agent
6a4cf3fc2b Package Windows with statically linked ONNX Runtime.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m59s
Tagged release / build-linux-x64 (push) Successful in 35m33s
Tagged release / build-macos (push) Failing after 22m50s
Tagged release / build-windows (push) Has been cancelled
Tagged release / publish-release (push) Has been cancelled
Tagged release / build-linux-arm64 (push) Has been cancelled
2026-08-15 00:33:56 +00:00
Hermes Agent
2bc42c7483 Allow newer CoreML symbols in cross builds. 2026-08-14 23:31:12 +00:00
Hermes Agent
612f4f71df Package statically linked ONNX runtimes correctly.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m1s
Tagged release / build-linux-x64 (push) Successful in 36m15s
Tagged release / build-macos (push) Failing after 24m59s
Tagged release / build-linux-arm64 (push) Successful in 34m54s
Tagged release / build-windows (push) Failing after 30m33s
Tagged release / publish-release (push) Has been skipped
2026-08-14 22:18:59 +00:00
Hermes Agent
f59fd03ad2 Disable Pagefind's unused serving stack.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m59s
Tagged release / build-linux-x64 (push) Failing after 33m14s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 21:29:35 +00:00
Hermes Agent
f30c295e1f Override the exact x64 compiler variables.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m1s
Tagged release / build-linux-x64 (push) Failing after 29m22s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 20:33:28 +00:00
Hermes Agent
21c6daea9b Keep PIC wrappers target specific.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m1s
Tagged release / build-linux-x64 (push) Failing after 29m31s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 19:57:43 +00:00
Hermes Agent
35eec1d000 Force PIC as the final x64 compiler flag.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m0s
Tagged release / build-linux-x64 (push) Failing after 12m8s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 19:40:11 +00:00
Hermes Agent
cfe3aa5c2c Build x64 zstd from PIC sources.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m4s
Tagged release / build-linux-x64 (push) Failing after 29m8s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 19:02:11 +00:00
Hermes Agent
f21aab5910 Pass PIC directly to x64 cross compilers.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m1s
Tagged release / build-linux-x64 (push) Failing after 29m28s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 18:27:11 +00:00
Hermes Agent
4c3e260961 Apply PIC flags to all x64 native builds.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m0s
Tagged release / build-linux-x64 (push) Failing after 28m43s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 17:51:18 +00:00
Hermes Agent
5a2a98b89f Compile x64 native dependencies as position independent code.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m2s
Tagged release / build-linux-x64 (push) Failing after 28m35s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 17:11:57 +00:00
Hermes Agent
11c39e3f38 Materialize legacy libc paths in the x64 sysroot.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m2s
Tagged release / build-linux-x64 (push) Failing after 28m40s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 16:37:03 +00:00
Hermes Agent
7f12cd053b Recreate usrmerge links in the x64 sysroot.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m0s
Tagged release / build-linux-x64 (push) Failing after 28m51s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 15:57:34 +00:00
Hermes Agent
f00dd177de Resolve x64 libc links through the sysroot.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m1s
Tagged release / build-linux-x64 (push) Failing after 28m41s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 15:21:41 +00:00
Hermes Agent
83fe50657d Include architecture-neutral metadata in the x64 sysroot.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m57s
Tagged release / build-linux-x64 (push) Failing after 28m54s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 14:46:01 +00:00
Hermes Agent
b0e10f52e7 Build Linux x64 against an isolated sysroot.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m54s
Tagged release / build-linux-x64 (push) Failing after 6m6s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 14:34:42 +00:00
Hermes Agent
dfba7efdac Refresh native apt indexes before cross-build setup.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m55s
Tagged release / build-linux-x64 (push) Failing after 39s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 14:27:25 +00:00
Hermes Agent
0e697750e7 Bundle native inference runtimes in releases.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m53s
Tagged release / build-linux-x64 (push) Failing after 6s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 14:20:18 +00:00
Hermes Agent
f924830176 Satisfy Linux multiarch Python tooling. 2026-08-14 13:18:14 +00:00
Hermes Agent
e59e92afa5 Link CoreML for macOS builds. 2026-08-14 12:43:47 +00:00
Hermes Agent
3506538752 Enable WebP SIMD for Windows builds.
Some checks failed
Tagged release / prepare-release (push) Successful in 2m53s
Tagged release / build-macos (push) Failing after 25m20s
Tagged release / build-linux-arm64 (push) Successful in 35m45s
Tagged release / build-linux-x64 (push) Failing after 16s
Tagged release / build-windows (push) Failing after 26m8s
Tagged release / publish-release (push) Has been skipped
2026-08-14 12:11:46 +00:00
Hermes Agent
87d2cc2a1f Isolate Linux cross-build dependencies. 2026-08-14 11:53:36 +00:00
Hermes Agent
7fa37ddf6e Fix ORT linking for macOS cross-builds. 2026-08-14 11:14:49 +00:00
Hermes Agent
0d1f63e77f Handle explicit release rebuilds.
Some checks failed
Tagged release / prepare-release (push) Successful in 3m6s
Tagged release / build-macos (push) Failing after 22m42s
Tagged release / build-linux-arm64 (push) Successful in 37m56s
Tagged release / build-linux-x64 (push) Failing after 15s
Tagged release / build-windows (push) Failing after 19m27s
Tagged release / publish-release (push) Has been skipped
2026-08-14 10:44:13 +00:00
Hermes Agent
f3a30bf19e Install NASM for Windows releases.
Some checks failed
Tagged release / prepare-release (push) Failing after 3m7s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-linux-x64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 10:32:30 +00:00
Hermes Agent
aee456cd93 Fix Ubuntu x64 package sources. 2026-08-14 10:15:03 +00:00
Hermes Agent
8402981d94 Reduce release helper build time. 2026-08-14 10:12:08 +00:00
Hermes Agent
1424d59845 Use portable USearch on macOS. 2026-08-14 09:45:02 +00:00
Hermes Agent
27470465a7 Bootstrap Rust in release jobs.
Some checks failed
Tagged release / prepare-release (push) Successful in 9m41s
Tagged release / build-macos (push) Failing after 15m9s
Tagged release / build-linux-arm64 (push) Successful in 38m44s
Tagged release / build-linux-x64 (push) Failing after 10s
Tagged release / build-windows (push) Failing after 17m47s
Tagged release / publish-release (push) Has been skipped
2026-08-14 09:09:29 +00:00
Hermes Agent
917947a421 Add multi-platform release builds for 0.9.0 (closes #138).
Some checks failed
Tagged release / prepare-release (push) Failing after 5s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-linux-x64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
2026-08-14 09:03:42 +00:00
20f9d15b46 Temporarily ignore unavoidable OSV advisories.
All checks were successful
Weekly OSV dependency audit / dependency-audit (push) Successful in 6s
2026-08-09 17:58:30 +02:00
520e1d86ea Update vulnerable dependencies and migrate to Iced 0.14. 2026-08-09 17:16:26 +02:00
c757314c91 Add weekly OSV dependency audit. 2026-08-09 16:23:59 +02:00
5709c80209 Add complete keyboard navigation. 2026-08-07 17:00:49 +02:00
87a161058f Cache compiled Lua scripts in memory. 2026-08-07 15:50:26 +02:00
41d5f1c014 Preserve sidebar state for blogmarks. 2026-08-07 15:40:06 +02:00
0417aaf749 Keep TUI sidebar selections visible. 2026-08-07 15:32:00 +02:00
358648378e Add in-window menus for Linux and Windows. 2026-08-07 15:23:12 +02:00
3b1a65359c Install the CLI with an overwriting symbolic link. 2026-08-03 11:04:02 +02:00
36cbaa0cc5 Add live CLI progress for long-running jobs 2026-08-03 09:24:44 +02:00
Georg Bauer
c96fd9c041 feat: linux package builds 2026-08-02 20:25:02 +02:00
3b1d239863 Restore bDS2 task panel styling 2026-08-01 20:24:33 +02:00
454089ade1 Restore icons for Git network actions 2026-08-01 20:03:09 +02:00
a74165fae5 Preserve cursor position for modal insertions 2026-08-01 19:50:00 +02:00
932b73cd97 Fix automatic translation request handling 2026-08-01 19:18:01 +02:00
bda3c400ef Fix post autosave blocking editor interactions 2026-08-01 18:56:11 +02:00
a961cea207 Keep task capacity until terminal results settle. 2026-08-01 13:12:28 +02:00
9a937675b4 Localize the post published toast. 2026-08-01 12:25:59 +02:00
c4e5dbb8e6 Retry failed automatic translations once. 2026-08-01 12:18:54 +02:00
b25691ddac Open blogmark drafts before semantic indexing finishes. 2026-08-01 11:29:10 +02:00
2fed5371ca Refresh runtime settings without opening Preferences. 2026-08-01 10:44:53 +02:00
81382b45ce Restore translation actions and flags after launch. 2026-08-01 10:34:32 +02:00
45f68422ed Open Preferences as a complete settings workspace. 2026-08-01 10:22:27 +02:00
085a3e8175 Show the project description on home pages. 2026-08-01 10:11:31 +02:00
da13bac755 fix: more work on background tasks 2026-07-24 11:43:11 +02:00
c187108f89 Localize category labels in blog menus. 2026-07-24 09:20:12 +02:00
7a3c5683e7 feat: reworked the category editing 2026-07-24 09:00:12 +02:00
d9085cf681 fix: still working on rendering speed 2026-07-24 07:10:57 +02:00
473c48937d fix: align rendering process with bds2 2026-07-24 06:22:45 +02:00
28119c15b7 fix: display fix for tasks with long titles 2026-07-23 23:07:41 +02:00
87318c8b22 fix: hopefully fixed the background task progress situation 2026-07-23 22:48:53 +02:00
ea90167a5a fix: fixed rendering from validate-apply 2026-07-23 21:51:12 +02:00
2844ed5a47 Add forced full-site rendering 2026-07-23 10:23:02 +02:00
d87e80f96b Match bDS2 zero-byte route validation 2026-07-23 09:55:19 +02:00
3e3b2d9698 Color-code Git commit sync status 2026-07-23 09:46:50 +02:00
632822fe1b Remove remote editing from the Git sidebar 2026-07-23 09:38:40 +02:00
08a5bd192c Preserve input focus across passive overlays 2026-07-23 09:28:16 +02:00
a01c86e383 Exit when the desktop window closes 2026-07-23 09:21:44 +02:00
4de0b6b0d1 fix: website validation 2026-07-23 08:54:07 +02:00
0d44c21b0e Localize sidebar dates and calendar months 2026-07-22 20:28:40 +02:00
7b8e539340 Match bDS2 canonical file serialization 2026-07-22 20:19:21 +02:00
fdf47da40a Restore project workspace state 2026-07-22 19:57:26 +02:00
0753b290ad Complete forced template deletion 2026-07-22 19:26:59 +02:00
fc039d813a Update the persistence schema specification 2026-07-22 19:17:50 +02:00
9087096c5d Localize engine progress messages 2026-07-22 19:09:10 +02:00
3051331b87 Match post tag names exactly 2026-07-22 18:58:29 +02:00
a1c04ff898 Default script entrypoints by kind 2026-07-22 18:54:27 +02:00
e9f2cdb25d Support HEIC media imports 2026-07-22 18:49:27 +02:00
bdfe57b66f Encode WebP thumbnails at quality 80 2026-07-22 18:33:20 +02:00
70503c8bc2 Enforce unique MCP proposal identities 2026-07-22 18:25:19 +02:00
2dcaf7f7f7 Limit automatic translation to manual saves 2026-07-22 18:11:09 +02:00
556d9c1ff4 Classify localized page routes as core 2026-07-22 18:06:06 +02:00
ba6a297d9b Use configured languages in generation validation 2026-07-22 18:02:04 +02:00
64e3847ef2 chore: fix/cleanup stuff related to previous unchecked commits 2026-07-22 17:54:33 +02:00
Bauer, Georg
a3f2c451e3 fix: small findings with regard to deletes from sidebar 2026-07-22 15:35:22 +02:00
Bauer, Georg
cc520d517a Add hover-revealed delete button to script sidebar rows, routed through the confirm-delete modal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:20:47 +02:00
Bauer, Georg
5968cf47ba Drop the media translation delete button from the spec; scripting API remains the only delete path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:15:47 +02:00
Bauer, Georg
2bf95bfcd0 Spec script deletion as confirm_delete_modal and cover it with a test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:15:31 +02:00
Bauer, Georg
733aa9a6ff Formalize tier-1 comment-based specs into allium constructs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:58:22 +02:00
Bauer, Georg
1dd8194fa8 chore: cleaned up trigger problems in allium specs 2026-07-22 14:33:42 +02:00
2fc683d513 Add portable project metadata diff coverage. 2026-07-21 23:54:37 +02:00
1410b97aeb Reopen published code only for content changes. 2026-07-21 23:25:11 +02:00
8a6274ef6a Normalize template and script slug changes. 2026-07-21 23:18:12 +02:00
e28d42d345 Cascade template slug changes to references. 2026-07-21 23:05:29 +02:00
b8014e0a10 Align legacy post snapshots with bDS2. 2026-07-21 22:54:06 +02:00
535303e8b5 Restore discarded posts from canonical files. 2026-07-21 22:45:59 +02:00
1cdc86b9cc Resync media sidecars after post deletion. 2026-07-21 22:38:53 +02:00
53ada2c705 Remove stale post files after path changes. 2026-07-21 22:31:01 +02:00
3920d6fc70 Keep post link graphs synchronized. 2026-07-21 22:24:28 +02:00
b0e9223158 Reopen canonical drafts after manual translation edits. 2026-07-21 22:13:36 +02:00
89a07c885a Preserve file-only content when archiving. 2026-07-21 22:04:03 +02:00
1c04087471 Add explicit post unarchive. 2026-07-21 21:49:06 +02:00
1e32c7e945 Rewrite published posts after template changes. 2026-07-21 21:32:28 +02:00
80f291b8ea Reopen published posts only for changes. 2026-07-21 21:25:16 +02:00
b3bf134980 Align checksums with bDS2. 2026-07-21 21:18:47 +02:00
8e78a5fc2b fix: syntax highlighting was broken in bundled app 2026-07-21 21:05:24 +02:00
8170076e9b Render built-in macros from Liquid templates. 2026-07-21 20:44:03 +02:00
3ac7a8bfc4 Enforce the supported Liquid subset. 2026-07-21 20:21:35 +02:00
557497d4c2 Persist status-bar airplane mode. 2026-07-21 20:11:33 +02:00
2f7d752e73 Fix online one-shot AI compatibility. 2026-07-21 20:03:26 +02:00
812de13f62 fix: clean up some problems from the implementations 2026-07-21 19:46:57 +02:00
Georg Bauer
71d998f68e Merge pull request #5 from rfc1437/codex/vollstandigen-test-der-losung-durchfuhren
Bypass proxy for local AI/chat endpoints and improve test API key handling
2026-07-21 19:06:28 +02:00
Georg Bauer
489d3646f8 Fix loopback test networking. 2026-07-21 19:02:07 +02:00
Georg Bauer
9d3a092359 Merge pull request #4 from rfc1437/codex/run-full-test-suite-after-fixing-internet
Fix workspace test build on latest Rust
2026-07-21 13:47:07 +02:00
Georg Bauer
1ecd7a32d2 Merge pull request #3 from rfc1437/codex/thread-data_dir-in-fts-index-paths
Index published post bodies from files
2026-07-21 13:46:47 +02:00
Georg Bauer
0b4c303c89 Merge pull request #2 from rfc1437/codex/uberprufe-dateianderungen-von-ruds
Preserve category archive titles in metadata writes
2026-07-21 13:46:26 +02:00
Georg Bauer
18d96a10b8 Fix search indexing data directory 2026-07-21 13:27:09 +02:00
Georg Bauer
184d6e4845 Fix workspace test build warnings 2026-07-21 12:42:48 +02:00
Georg Bauer
d50e538fc3 Index published post bodies from files 2026-07-21 11:21:03 +02:00
Georg Bauer
1e6c2a4e2c Preserve category archive titles. 2026-07-21 11:20:07 +02:00
Bauer, Georg
486a9f8019 Update default branch to main in AGENTS.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:20:34 +02:00
199 changed files with 26344 additions and 6638 deletions

View File

@@ -7,13 +7,14 @@ bundle-linux = "packager --release --packages bds-ui --formats deb --formats app
rustflags = [
"-C", "prefer-dynamic",
"-C", "link-arg=-mmacosx-version-min=26.0",
"-C", "link-arg=-Wl,-rpath,@executable_path",
"-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",
"-C", "link-arg=-Wl,-rpath,$ORIGIN:$ORIGIN/../lib/bds-ui",
]
[target.'cfg(target_os = "windows")']

View File

@@ -0,0 +1,115 @@
#!/bin/sh
set -eu
target=${1:?usage: install-linux-build-dependencies.sh arm64|x64}
install_packages() {
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"
}
case "$target" in
arm64)
sudo apt-get update
install_packages \
build-essential \
cmake \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
pkg-config
;;
x64)
if [ "$(dpkg --print-architecture)" != "arm64" ]; then
echo "the x64 release build expects an arm64 Ubuntu runner" >&2
exit 1
fi
if [ ! -r /etc/os-release ]; then
echo "cannot determine the Ubuntu release" >&2
exit 1
fi
. /etc/os-release
if [ "${ID:-}" != "ubuntu" ] || [ -z "${VERSION_CODENAME:-}" ]; then
echo "the x64 release build requires an Ubuntu runner" >&2
exit 1
fi
native_sources=/etc/apt/sources.list.d/ubuntu.sources
if [ ! -f "$native_sources" ]; then
echo "the x64 release build requires Ubuntu's deb822 package sources" >&2
exit 1
fi
sudo sed -i \
'/^Architectures:/d; /^Types: deb$/a Architectures: arm64' \
"$native_sources"
sudo apt-get update
install_packages build-essential cmake gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config
sudo dpkg --add-architecture amd64
{
printf '%s\n' \
'Types: deb' \
'URIs: http://archive.ubuntu.com/ubuntu' \
"Suites: $VERSION_CODENAME ${VERSION_CODENAME}-updates" \
'Components: main universe restricted multiverse' \
'Architectures: amd64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
'' \
'Types: deb' \
'URIs: http://security.ubuntu.com/ubuntu' \
"Suites: ${VERSION_CODENAME}-security" \
'Components: main universe restricted multiverse' \
'Architectures: amd64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg'
} | sudo tee /etc/apt/sources.list.d/ruds-amd64.sources >/dev/null
sudo apt-get update
# Foreign development packages contain architecture-specific helper
# programs whose maintainer scripts cannot run on the arm64 host.
# Download them without installing and extract a link-only sysroot.
sysroot=/opt/ruds-x64-sysroot
archives=/tmp/ruds-x64-archives
sudo mkdir -p "$archives/partial" "$sysroot"
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
--download-only \
-o Dir::Cache::archives="$archives" \
libgtk-3-dev:amd64 \
libwebkit2gtk-4.1-dev:amd64 \
libxdo-dev:amd64
for package in "$archives"/*.deb; do
sudo dpkg-deb --extract "$package" "$sysroot"
done
# libc's development linker scripts use legacy absolute paths even
# though Noble stores their targets below /usr. Materialize those few
# aliases so GNU ld can resolve them inside the extracted sysroot.
legacy_lib="$sysroot/lib/x86_64-linux-gnu"
sudo mkdir -p "$legacy_lib" "$sysroot/lib64"
for library in libc.so.6 libm.so.6 libmvec.so.1; do
source="$sysroot/usr/lib/x86_64-linux-gnu/$library"
if [ ! -f "$source" ]; then
echo "the downloaded amd64 libc is missing $library" >&2
exit 1
fi
sudo cp -a "$sysroot/usr/lib/x86_64-linux-gnu/$library" "$legacy_lib/"
done
loader="$sysroot/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"
if [ ! -f "$loader" ]; then
echo "the downloaded amd64 libc is missing its dynamic loader" >&2
exit 1
fi
sudo cp -a "$sysroot/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2" \
"$sysroot/lib64/"
# Architecture-independent development packages may already be
# installed on the host, so apt does not download them again. Their
# pkg-config files contain no host binaries and are safe to reuse with
# PKG_CONFIG_SYSROOT_DIR, which redirects every path into this sysroot.
sudo mkdir -p "$sysroot/usr/share/pkgconfig"
sudo cp -a /usr/share/pkgconfig/. "$sysroot/usr/share/pkgconfig/"
;;
*)
echo "unsupported Linux build target: $target" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec x86_64-linux-gnu-g++ "$@" -fPIC

View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec x86_64-linux-gnu-gcc "$@" -fPIC

View File

@@ -0,0 +1,21 @@
name: Weekly OSV dependency audit
on:
schedule:
# Runs every Monday. Change the minute to stagger repositories.
- cron: "29 3 * * 1"
workflow_dispatch:
jobs:
dependency-audit:
runs-on: linux-arm64
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Scan dependencies for known vulnerabilities
uses: docker://ghcr.io/google/osv-scanner:v2
with:
args: scan source --recursive .

View File

@@ -0,0 +1,261 @@
name: Tagged release
on:
push:
tags:
- "*"
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.97.1
jobs:
prepare-release:
runs-on: linux-arm64
outputs:
release_id: ${{ steps.release.outputs.release_id }}
steps:
- name: Check out the tag
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the release Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
cache: false
rustflags: ""
- name: Test release tooling
run: cargo +${RUST_TOOLCHAIN} test --locked --package bds-release
- name: Create draft release
id: release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REBUILD_PUBLISHED_RELEASE: "true"
run: cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- prepare
build-macos:
needs: [prepare-release, build-linux-x64]
runs-on: linux-arm64
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
env:
CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS: -Lnative=target/macos-linker -C link-arg=-mmacosx-version-min=26.0 -C link-arg=-Wl,-rpath,@executable_path -C link-arg=-Wl,-rpath,@executable_path/../Resources -C link-arg=-Wl,-headerpad,0x1000 -C link-arg=-Wl,-undefined,dynamic_lookup
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS: -Lnative=target/macos-linker -C link-arg=-mmacosx-version-min=26.0 -C link-arg=-Wl,-rpath,@executable_path -C link-arg=-Wl,-rpath,@executable_path/../Resources -C link-arg=-Wl,-headerpad,0x1000 -C link-arg=-Wl,-undefined,dynamic_lookup
steps:
- name: Check out the tag
env:
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git init
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
git checkout --detach FETCH_HEAD
- name: Install release Rust targets
run: >-
rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal
--target aarch64-apple-darwin
--target x86_64-apple-darwin
- name: Build, package, and upload macOS releases
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- \
macos-linker-alias target/macos-linker
for build in \
'aarch64-apple-darwin|aarch64-apple-darwin|darwin-arm64' \
'x86_64-apple-darwin|x86_64-apple-darwin|darwin-x64'
do
target=${build%%|*}
rest=${build#*|}
build_target=${rest%%|*}
platform=${rest#*|}
if [ "$target" = x86_64-apple-darwin ]; then
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- \
macos-x64-runtime "target/$target/release"
ORT_LIB_LOCATION="$PWD/target/$target/release" \
ORT_PREFER_DYNAMIC_LINK=1 \
cargo +${RUST_TOOLCHAIN} zigbuild --release --locked --target "$build_target" \
--package bds-ui --package bds-cli --package bds-mcp
else
cargo +${RUST_TOOLCHAIN} zigbuild --release --locked --target "$build_target" \
--package bds-ui --package bds-cli --package bds-mcp
fi
rust_lib_dir=$(rustc +${RUST_TOOLCHAIN} --target "$target" --print target-libdir)
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" "$platform" dist --upload
done
build-linux-arm64:
needs: [prepare-release, build-linux-x64]
runs-on: linux-arm64
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-Wl,-rpath,$ORIGIN
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Install native Linux desktop dependencies
run: sh .gitea/scripts/install-linux-build-dependencies.sh arm64
- name: Install the release Rust target
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
target: aarch64-unknown-linux-gnu
cache: false
rustflags: ""
- name: Build, package, and upload Linux arm64 release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
target=aarch64-unknown-linux-gnu
cargo +${RUST_TOOLCHAIN} build --release --locked --target "$target" \
--package bds-ui --package bds-cli --package bds-mcp
rust_lib_dir=$(rustc +${RUST_TOOLCHAIN} --target "$target" --print target-libdir)
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" linux-arm64 dist --upload
build-linux-x64:
needs: prepare-release
runs-on: linux-arm64
env:
AR_x86_64_unknown_linux_gnu: x86_64-linux-gnu-ar
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=--sysroot=/opt/ruds-x64-sysroot -C link-arg=-Wl,-rpath,$ORIGIN -L native=/opt/ruds-x64-sysroot/usr/lib/x86_64-linux-gnu -L native=/opt/ruds-x64-sysroot/lib/x86_64-linux-gnu
PKG_CONFIG_ALLOW_CROSS: "1"
PKG_CONFIG_LIBDIR: /opt/ruds-x64-sysroot/usr/lib/x86_64-linux-gnu/pkgconfig:/opt/ruds-x64-sysroot/usr/lib/pkgconfig:/opt/ruds-x64-sysroot/usr/share/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /opt/ruds-x64-sysroot
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Install x64 Linux desktop dependencies and cross-linker
run: sh .gitea/scripts/install-linux-build-dependencies.sh x64
- name: Install the release Rust target
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
target: x86_64-unknown-linux-gnu
cache: false
rustflags: ""
- name: Build, package, and upload Linux x64 release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
target=x86_64-unknown-linux-gnu
unset ZSTD_SYS_USE_PKG_CONFIG
env \
"CC_x86_64-unknown-linux-gnu=$PWD/.gitea/scripts/x86_64-linux-gnu-gcc-pic" \
"CXX_x86_64-unknown-linux-gnu=$PWD/.gitea/scripts/x86_64-linux-gnu-g++-pic" \
cargo +${RUST_TOOLCHAIN} build --release --locked --target "$target" \
--package bds-ui --package bds-cli --package bds-mcp
rust_lib_dir=$(rustc +${RUST_TOOLCHAIN} --target "$target" --print target-libdir)
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" linux-x64 dist --upload
build-windows:
needs: [prepare-release, build-linux-x64]
runs-on: linux-arm64
container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0
env:
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS: -Lnative=target/x86_64-pc-windows-msvc/release
steps:
- name: Check out the tag
env:
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git init
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
git checkout --detach FETCH_HEAD
- name: Install the release Rust target
run: rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --target x86_64-pc-windows-msvc
- name: Install the Windows assembly toolchain
run: |
apt-get update
apt-get install --no-install-recommends --yes nasm
- name: Build, package, and upload Windows release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
target=x86_64-pc-windows-msvc
cargo +${RUST_TOOLCHAIN} xwin cache xwin
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- \
windows-runtime "target/$target/release" \
/root/.cache/cargo-xwin/xwin/sdk/lib/um/x86_64
cargo +${RUST_TOOLCHAIN} xwin build --release --locked --target "$target" \
--package bds-ui --package bds-cli --package bds-mcp
rust_lib_dir=$(rustc +${RUST_TOOLCHAIN} --target "$target" --print target-libdir)
cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- package \
"$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" windows-x64 dist --upload
publish-release:
needs:
- prepare-release
- build-macos
- build-linux-arm64
- build-linux-x64
- build-windows
runs-on: linux-arm64
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Install the release Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
cache: false
rustflags: ""
- name: Publish complete release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- publish

View File

@@ -28,7 +28,7 @@ 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

View File

@@ -20,9 +20,15 @@ Packages are written below `target/release`. The macOS bundle uses the ICNS icon
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.
## Automated tagged releases
Pushing a tag runs `.gitea/workflows/release.yml` on Linux arm64. The workflow builds Linux arm64 natively, cross-links Linux x64 with Ubuntu's amd64 development libraries and GNU cross-toolchain, uses Cargo Zigbuild for macOS, Cargo Xwin for Windows x64, and `bds-release` for every packaging and Gitea API operation. It does not require host installations of Python, `curl`, `tar`, `hdiutil`, or NSIS.
The workflow publishes CLI/MCP and desktop artifacts for Linux and macOS on arm64 and x64, plus Windows x64. Linux desktop binaries dynamically use the target system's GTK 3, WebKitGTK 4.1, and libxdo runtime packages. Every archive includes its downloaded ONNX Runtime shared library; Windows archives also include the checksum-pinned Microsoft DirectML redistributable and license. macOS desktop artifacts are ad-hoc-signed `.dmg` files, Unix artifacts are `.tar.gz`, and Windows artifacts are `.zip` files containing the native `.exe` application and its runtime DLLs. The Gitea release remains a draft unless every build and upload succeeds.
## macOS system requirements
- macOS 13 (Ventura) or later
- macOS 26 or later
- Apple Silicon or Intel Mac with Metal or Vulkan support (required by Iced's wgpu backend)
## Linux system requirements (optional, for CI or cross-platform development)

6
CLI.md
View File

@@ -19,7 +19,7 @@ Typical uses: scripted rebuilds and site generation, publishing from CI or cron,
## 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`.
Install the command from the desktop app under **Settings → Data**, or run `bds-cli install` from a packaged binary. Both replace `~/.local/bin/bds-cli` with a symbolic link to that package's CLI, keeping the command on the selected package and beside its shared runtime. Make sure `~/.local/bin` is on your `PATH`.
## Global flags and output
@@ -30,6 +30,8 @@ Install the launcher from the desktop app under **Settings → Data**, or run `b
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.
Long-running rebuild and render commands show live progress while comparing metadata, validating generated output, and rendering pages. An interactive terminal updates one Hugging Face-style progress bar in place; redirected output receives one stable line per update. `--json` never emits interim output and includes the ordered progress history in the final envelope.
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
@@ -52,7 +54,7 @@ All commands operate on the active project from the shared project registry unle
| `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` |
| `install` | Link `~/.local/bin/bds-cli` to this package's CLI, replacing the existing entry |
## Creating content from the command line

3566
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,14 @@ members = [
"crates/bds-mcp",
"crates/bds-server",
"crates/bds-package-runtime",
"crates/bds-release",
]
[workspace.package]
edition = "2024"
version = "0.1.0"
version = "0.9.0"
license = "MIT"
authors = ["Georg Bauer <gb@rfc1437.de>"]
[workspace.dependencies]
# Foundation
@@ -22,8 +24,9 @@ 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"
md5 = "0.8.1"
unicode-normalization = "0.1"
thiserror = "2"
anyhow = "1"
@@ -33,6 +36,9 @@ axum = "0.8"
walkdir = "2"
futures = "0.3"
image = "0.25"
hpvcd = "0.3.1"
webp = { version = "0.3.1", default-features = false }
libwebp-sys = { version = "0.9.6", default-features = false }
rust-stemmers = "1"
sys-locale = "0.3"
fluent-bundle = "0.16"
@@ -46,32 +52,32 @@ quick-xml = "0.41"
htmd = "0.5.4"
regex = "1"
rayon = "1.10"
pagefind = "1.5.2"
pagefind = { version = "1.5.2", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "vendored"] }
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"
fastembed = { version = "5.12.0", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] }
usearch = { version = "2.26", default-features = false }
ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", "copy-dylibs"] }
russh = "0.62.5"
ratatui = "0.30.2"
ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] }
tui-markdown = "0.3.8"
tui-markdown = { version = "0.3.8", default-features = false }
# UI framework
iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] }
iced = { version = "0.14", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] }
# Editor widget
cosmic-text = "0.12"
cosmic-text = "0.15"
ropey = "1"
syntect = "5"
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
# Platform integration (cross-platform)
muda = "0.15"
muda = "0.19.3"
rfd = "0.15"
# Internal crates
@@ -80,5 +86,16 @@ bds-editor = { path = "crates/bds-editor" }
bds-mcp = { path = "crates/bds-mcp" }
bds-server = { path = "crates/bds-server" }
# Portable release packaging
apple-bundles = "0.21"
apple-codesign = { version = "0.29", default-features = false }
apple-dmg = "0.5"
flate2 = "1"
icns = "0.4"
plist = "1"
simple-file-manifest = "0.11"
tar = "0.4"
zip = { version = "8.6", default-features = false, features = ["deflate"] }
[profile.release]
strip = "symbols"

View File

@@ -150,7 +150,7 @@ The Media section is where you import, describe, and maintain assets used by pos
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 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.
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.
@@ -468,13 +468,14 @@ Templates are stored as files with frontmatter metadata in the project data dire
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. 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.
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.

View File

@@ -2,40 +2,45 @@
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; errors stay visible until dismissed.
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, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
- Native Iced desktop workspace with a localized native macOS menu and in-window Linux/Windows menu bar, 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, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Every enabled control participates in Tab/Shift-Tab focus traversal and Enter/Space activation; holding Control+Option on macOS or Control+Alt elsewhere reveals direct shortcuts plus stable two/three-letter codes for visible generic controls, filters those codes as they are typed, and lets Escape cancel while Option/Alt alone remains available for character entry. The in-window menu supports Alt mnemonics and arrow-key navigation. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, 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, cursor-preserving link and media insertion, 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 and explicit syntax-check feedback, 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 frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible NFD slug generation, and FTS5 search.
- 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, publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, and bounded process-local compiled Lua reuse with a fresh sandbox per invocation, 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, inert write proposals, 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.
- Project-scoped typed domain events synchronize desktop views and cached runtime settings with shared-engine and CLI mutations even when Preferences is closed; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
- Headless `bds-cli` automation for rebuild/repair/render, with live terminal progress through metadata comparison, site validation, and page rendering; 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 package-linked CLI 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 selection-following scrollable sidebars, 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 native macros, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), project-description homepage headings, 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 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 status-bar airplane-mode routing.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations are available immediately after restart and run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Automatic translation rejects blank AI results, retries one failed request with a visible warning, reports a failed retry, and refreshes open editors without discarding newer edits. 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 with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, 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 save or publish, which queues one task per still-missing language. bDS2 keeps its separate `bds2://` bookmarklet protocol.
- 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 as soon as the draft exists without changing the current or restored sidebar state or waiting for on-device semantic indexing, 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.
The packaged macOS applications require 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.
Packaged executables share native `bds-core` and `bds-server` dynamic libraries. ONNX Runtime is linked statically where supported; Intel macOS carries Microsoft's final x64 runtime and license because current upstream releases no longer provide that architecture, while Windows carries Microsoft's DirectML redistributable and license for GPU-accelerated local embeddings. The **Install CLI** action replaces `~/.local/bin/bds-cli` with a symbolic link to the packaged CLI, so the command executes beside the same runtime libraries and always follows the package selected by the latest installation.
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.
Pushing a tag creates a draft Gitea release, builds native artifacts on the Linux arm64 runner, and publishes only after every target succeeds. Releases contain CLI/MCP and desktop downloads for Linux arm64/x64, macOS arm64/x64, and Windows x64. Linux desktop binaries are dynamically linked to the GTK 3, WebKitGTK 4.1, and libxdo packages supplied by the target Ubuntu architecture. macOS desktop downloads are ad-hoc-signed DMGs; Unix archives use `.tar.gz` and Windows uses `.zip`. All release packaging and Gitea API work is performed by the workspace's Rust release utility.
## Repository Map
- `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua
@@ -44,6 +49,7 @@ Local macOS packages are ad-hoc signed without hardened runtime. A Developer ID
- `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
- `crates/bds-release` — portable tagged-release packaging and Gitea publishing utility
- `specs` — authoritative Allium behavior specifications
- `fixtures` — compatibility projects and generated-site fixtures
- `locales` — UI and native-menu translations

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

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

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

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

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

View File

@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, anyhow, bail};
use bds_core::db::{Database, DbConnection};
use bds_core::engine::{self, cli_sync, domain_events};
use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language, translate, translate_with};
use bds_core::model::{DomainEntity, NotificationAction, Project, ScriptKind};
use bds_core::scripting::{CoreHost, ExecutionControl, ExecutionKind, execute_many_with_host};
use clap::{Args, Parser, Subcommand, ValueEnum};
@@ -194,6 +195,112 @@ impl RunContext {
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CliProgress {
pub value: f32,
pub message: String,
}
pub type ProgressSink = Arc<dyn Fn(CliProgress) + Send + Sync>;
#[derive(Clone)]
struct ProgressReporter {
history: Arc<Mutex<Vec<String>>>,
sink: Option<ProgressSink>,
locale: UiLocale,
}
impl ProgressReporter {
fn new(sink: Option<ProgressSink>) -> Self {
Self {
history: Arc::new(Mutex::new(Vec::new())),
sink,
locale: detect_os_locale(),
}
}
fn with_locale(mut self, locale: UiLocale) -> Self {
self.locale = locale;
self
}
fn report(&self, value: f32, message: String) {
let value = if value.is_finite() {
value.clamp(0.0, 1.0)
} else {
0.0
};
let line = format!("[{:>3}%] {message}", (value * 100.0).round() as i32);
let mut history = self
.history
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if history.last() == Some(&line) {
return;
}
history.push(line);
drop(history);
if let Some(sink) = &self.sink {
sink(CliProgress { value, message });
}
}
fn history(&self) -> Vec<String> {
self.history
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
}
pub struct HumanProgress {
terminal: bool,
active: bool,
}
impl HumanProgress {
pub fn new(terminal: bool) -> Self {
Self {
terminal,
active: false,
}
}
pub fn write(
&mut self,
progress: &CliProgress,
mut writer: impl std::io::Write,
) -> std::io::Result<()> {
let percent = (progress.value * 100.0).round() as usize;
if self.terminal {
const WIDTH: usize = 24;
let filled = percent * WIDTH / 100;
write!(
writer,
"\r\x1b[2K{}: {:>3}%|{}{}|",
progress.message,
percent,
"".repeat(filled),
" ".repeat(WIDTH - filled),
)?;
writer.flush()?;
} else {
writeln!(writer, "[{:>3}%] {}", percent, progress.message)?;
}
self.active = true;
Ok(())
}
pub fn finish(&mut self, mut writer: impl std::io::Write) -> std::io::Result<()> {
if self.terminal && self.active {
writeln!(writer)?;
writer.flush()?;
}
self.active = false;
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct CommandOutput {
pub command: &'static str,
@@ -265,14 +372,29 @@ where
}
pub fn run(cli: Cli, context: RunContext) -> Result<CommandOutput> {
run_with_progress(cli, context, None)
}
pub fn run_with_progress(
cli: Cli,
context: RunContext,
progress_sink: Option<ProgressSink>,
) -> Result<CommandOutput> {
let command_name = command_name(&cli.command);
let mut output = execute(cli.command, &context, cli.airplane)?;
let progress = ProgressReporter::new(progress_sink);
let mut output = execute(cli.command, &context, cli.airplane, &progress)?;
output.command = command_name;
output.json = cli.json;
output.progress = progress.history();
Ok(output)
}
fn execute(command: Command, context: &RunContext, airplane: bool) -> Result<CommandOutput> {
fn execute(
command: Command,
context: &RunContext,
airplane: bool,
progress: &ProgressReporter,
) -> Result<CommandOutput> {
if matches!(command, Command::Install) {
return install_launcher(context);
}
@@ -281,13 +403,18 @@ fn execute(command: Command, context: &RunContext, airplane: bool) -> Result<Com
}
let db = open_database(&context.database_path)?;
let locale = engine::settings::ui_language(db.conn())
.ok()
.flatten()
.map_or_else(detect_os_locale, |language| normalize_language(&language));
let progress = progress.clone().with_locale(locale);
match command {
Command::Rebuild { incremental } => rebuild(&db, incremental),
Command::Rebuild { incremental } => rebuild(&db, incremental, &progress),
Command::Repair { part } => repair(&db, part),
Command::Render { incremental, force } => render(&db, incremental, force),
Command::Render { incremental, force } => render(&db, incremental, force, &progress),
Command::Upload => upload(&db, airplane),
Command::Push => git_push(&db, airplane),
Command::Pull => git_pull(&db, airplane),
Command::Pull => git_pull(&db, airplane, &progress),
Command::Post(args) => create_post(&db, args, &context.stdin, airplane),
Command::Media { file, language } => import_media(&db, &file, language, airplane),
Command::Gallery(args) => create_gallery(&db, args, &context.stdin, airplane),
@@ -332,11 +459,21 @@ fn active_project(db: &Database) -> Result<(Project, PathBuf)> {
Ok((project, data_dir))
}
fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
fn rebuild(db: &Database, incremental: bool, progress: &ProgressReporter) -> Result<CommandOutput> {
let (project, data_dir) = active_project(db)?;
if incremental {
let message = translate(progress.locale, "metadataDiff.running");
progress.report(0.0, message.clone());
let report = cli_sync::run_cli_mutation(db.conn(), || {
let report = engine::rebuild::rebuild_incremental(db.conn(), &data_dir, &project.id)?;
let report = engine::rebuild::rebuild_incremental_with_progress(
db.conn(),
&data_dir,
&project.id,
|value| {
progress.report(value, message.clone());
true
},
)?;
if report.differences_applied > 0 || report.orphans_imported > 0 {
emit_bulk(&project.id);
}
@@ -352,29 +489,21 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
));
}
let progress = Arc::new(Mutex::new(Vec::new()));
let progress_sink = Arc::clone(&progress);
let (report, thumbnails) = cli_sync::run_cli_mutation(db.conn(), || {
let report = cli_sync::run_cli_mutation(db.conn(), || {
let progress = progress.clone();
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
db.conn(),
&data_dir,
&project.id,
Some(Arc::new(move |value, message| {
let line = format!("[{:>3}%] {message}", (value * 100.0).round() as i32);
let mut progress = progress_sink
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if progress.last() != Some(&line) {
progress.push(line);
}
Some(Arc::new(move |value, event| {
progress.report(value, event.localized(progress.locale));
true
})),
)?;
let thumbnails =
engine::media::regenerate_missing_thumbnails(db.conn(), &data_dir, &project.id)?;
emit_bulk(&project.id);
Ok((report, thumbnails))
Ok(report)
})?;
let mut result = output(
Ok(output(
"Rebuild complete",
json!({
"posts_created": report.posts_created,
@@ -387,14 +516,10 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
"templates_updated": report.templates_updated,
"scripts_created": report.scripts_created,
"scripts_updated": report.scripts_updated,
"thumbnails_generated": thumbnails.thumbnails_generated,
"thumbnails_generated": report.thumbnails_generated,
"thumbnail_media_failed": report.thumbnail_media_failed,
}),
);
result.progress = progress
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
Ok(result)
))
}
fn repair(db: &Database, part: RepairPart) -> Result<CommandOutput> {
@@ -446,7 +571,12 @@ fn repair(db: &Database, part: RepairPart) -> Result<CommandOutput> {
})?;
Ok(output(
"Missing thumbnails regenerated",
json!({"media_repaired": report.media_repaired, "thumbnails_generated": report.thumbnails_generated}),
json!({
"media_processed": report.media_processed,
"media_repaired": report.media_repaired,
"media_failed": report.media_failed,
"thumbnails_generated": report.thumbnails_generated,
}),
))
}
RepairPart::Search => {
@@ -490,45 +620,104 @@ fn repair(db: &Database, part: RepairPart) -> Result<CommandOutput> {
}
}
fn render(db: &Database, incremental: bool, force: bool) -> Result<CommandOutput> {
fn render(
db: &Database,
incremental: bool,
force: bool,
progress: &ProgressReporter,
) -> Result<CommandOutput> {
let (project, data_dir) = active_project(db)?;
let metadata = engine::meta::read_project_json(&data_dir)?;
let posts = published_sources(db.conn(), &data_dir, &project.id)?;
let output_dir = data_dir.join("html");
std::fs::create_dir_all(&output_dir)?;
if incremental {
let validation = engine::validate_site::validate_site(db.conn(), &data_dir, &project.id)?;
let sections = engine::generation::sections_from_validation_report(&validation);
let report = engine::generation::apply_validation_sections(
let validating = translate(progress.locale, "siteValidation.running");
progress.report(0.0, validating.clone());
let validation = engine::validate_site::validate_site_with_progress(
db.conn(),
&data_dir,
&project.id,
|current, total| {
progress.report(
0.25 * current as f32 / total.max(1) as f32,
validating.clone(),
);
true
},
)?;
let sections = engine::generation::sections_from_validation_report(&validation, &metadata);
let report = engine::generation::apply_validation_sections_with_progress(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
&validation,
&sections,
|current, total, url| report_render_progress(progress, current, total, url, 0.25, 1.0),
)?;
progress.report(1.0, translate(progress.locale, "engine.generationComplete"));
return Ok(output(
"Validation differences applied",
json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len()}),
));
}
if force {
engine::generation::clear_generation_cache(db.conn(), &project.id)?;
}
let report = engine::generation::generate_starter_site(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
metadata.main_language.as_deref().unwrap_or("en"),
)?;
let language = metadata.main_language.as_deref().unwrap_or("en");
progress.report(
0.0,
translate(progress.locale, "engine.generateSiteStarted"),
);
let report = if force {
engine::generation::generate_starter_site_forced_with_progress(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
language,
|current, total, url| report_render_progress(progress, current, total, url, 0.0, 1.0),
)?
} else {
engine::generation::generate_starter_site_with_progress(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
language,
|current, total, url| report_render_progress(progress, current, total, url, 0.0, 1.0),
)?
};
progress.report(1.0, translate(progress.locale, "engine.generationComplete"));
Ok(output(
"Site rendered",
json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len(), "force": force}),
))
}
fn report_render_progress(
progress: &ProgressReporter,
current: usize,
total: usize,
url: &str,
start: f32,
end: f32,
) {
progress.report(
start + (end - start) * current as f32 / total.max(1) as f32,
translate_with(
progress.locale,
"engine.renderingPage",
&[
("url", url),
("current", &current.to_string()),
("total", &total.to_string()),
],
),
);
}
fn published_sources(
conn: &DbConnection,
data_dir: &Path,
@@ -576,13 +765,13 @@ fn git_push(db: &Database, airplane: bool) -> Result<CommandOutput> {
Ok(output("Pushed", json!({"output": result.output})))
}
fn git_pull(db: &Database, airplane: bool) -> Result<CommandOutput> {
fn git_pull(db: &Database, airplane: bool, progress: &ProgressReporter) -> Result<CommandOutput> {
if airplane {
bail!("git pull is unavailable in airplane mode");
}
let (_project, data_dir) = active_project(db)?;
let result = engine::git::GitEngine::new(data_dir).pull(|| false, |_| {})?;
let mut rebuilt = rebuild(db, true)?;
let mut rebuilt = rebuild(db, true, progress)?;
rebuilt.message = "Pulled and reconciled the cache database".into();
rebuilt.data["git_output"] = Value::String(result.output);
Ok(rebuilt)
@@ -1231,6 +1420,19 @@ mod tests {
run(cli, self.context(stdin))
}
fn run_with_progress(&self, args: &[&str]) -> Result<(CommandOutput, Vec<CliProgress>)> {
let cli = Cli::try_parse_from(std::iter::once("bds-cli").chain(args.iter().copied()))?;
let updates = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&updates);
let output = super::run_with_progress(
cli,
self.context(""),
Some(Arc::new(move |update| sink.lock().unwrap().push(update))),
)?;
let updates = updates.lock().unwrap().clone();
Ok((output, updates))
}
fn image(&self, name: &str) -> PathBuf {
let path = self._root.path().join(name);
std::fs::write(
@@ -1274,6 +1476,29 @@ mod tests {
assert!(Cli::try_parse_from(["bds-cli", "repair", "unknown"]).is_err());
}
#[test]
fn human_progress_uses_a_live_bar_only_for_terminals() {
let update = CliProgress {
value: 0.5,
message: "Rendering".into(),
};
let mut terminal = HumanProgress::new(true);
let mut terminal_output = Vec::new();
terminal.write(&update, &mut terminal_output).unwrap();
terminal.finish(&mut terminal_output).unwrap();
let terminal_output = String::from_utf8(terminal_output).unwrap();
assert!(terminal_output.contains("Rendering: 50%|████████████"));
assert!(terminal_output.starts_with("\r\x1b[2K"));
let mut redirected = HumanProgress::new(false);
let mut redirected_output = Vec::new();
redirected.write(&update, &mut redirected_output).unwrap();
assert_eq!(
String::from_utf8(redirected_output).unwrap(),
"[ 50%] Rendering\n"
);
}
#[test]
fn config_and_project_families_dispatch_success_and_failure() {
let fixture = Fixture::new(false);
@@ -1529,7 +1754,29 @@ mod tests {
metadata.semantic_similarity_enabled = false;
engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap();
fixture.run(&["render"], "").unwrap();
let db = open_database(&fixture.database_path).unwrap();
let (project, _) = active_project(&db).unwrap();
bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(
db.conn(),
&bds_core::model::GeneratedFileHash {
project_id: project.id.clone(),
relative_path: "legacy-output.txt".into(),
content_hash: "legacy".into(),
updated_at: 42,
},
)
.unwrap();
drop(db);
fixture.run(&["render", "--force"], "").unwrap();
let db = open_database(&fixture.database_path).unwrap();
assert!(
bds_core::db::queries::generated_file_hash::get_generated_file_hash(
db.conn(),
&project.id,
"legacy-output.txt",
)
.is_ok()
);
fixture.run(&["render", "--incremental"], "").unwrap();
assert!(fixture.project_dir.join("html/index.html").is_file());
@@ -1539,6 +1786,33 @@ mod tests {
assert!(no_project.run(&["render"], "").is_err());
}
#[test]
fn long_running_commands_stream_progress_while_preserving_result_history() {
let fixture = Fixture::new(true);
for args in [
&["rebuild"][..],
&["rebuild", "--incremental"][..],
&["render"][..],
&["render", "--incremental"][..],
] {
let (output, updates) = fixture.run_with_progress(args).unwrap();
assert!(
!updates.is_empty(),
"{} did not stream progress",
args.join(" ")
);
assert_eq!(updates.last().unwrap().value, 1.0);
assert!(
updates
.windows(2)
.all(|pair| pair[0].value <= pair[1].value),
"{} progress moved backwards: {updates:?}",
args.join(" ")
);
assert_eq!(output.progress.len(), updates.len());
}
}
#[test]
fn lua_family_runs_only_enabled_utility_scripts() {
let fixture = Fixture::new(true);
@@ -1585,11 +1859,15 @@ mod tests {
assert!(fixture.run(&["install"], "").is_err());
}
#[cfg(unix)]
#[test]
fn launcher_install_is_idempotent_and_refuses_overwrite() {
fn launcher_install_is_idempotent_and_overwrites_existing_file() {
let fixture = Fixture::new(false);
let executable = fixture._root.path().join("packaged-bds-cli");
std::fs::write(&executable, b"binary").unwrap();
let target = fixture.home_dir.join(".local/bin/bds-cli");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, b"old launcher").unwrap();
let context = RunContext {
executable_path: executable.clone(),
..fixture.context("")
@@ -1604,12 +1882,10 @@ mod tests {
context,
)
.unwrap();
let target = fixture.home_dir.join(".local/bin/bds-cli");
assert!(!target.is_symlink());
assert!(
std::fs::read_to_string(target)
.unwrap()
.contains(executable.canonicalize().unwrap().to_str().unwrap())
assert!(target.is_symlink());
assert_eq!(
std::fs::read_link(target).unwrap(),
executable.canonicalize().unwrap()
);
}
}

View File

@@ -1,5 +1,6 @@
use std::io::Read as _;
use std::io::{IsTerminal as _, Read as _};
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use clap::Parser;
@@ -74,8 +75,33 @@ fn main() -> ExitCode {
return ExitCode::from(1);
}
match bds_cli::run(cli, context) {
Ok(output) => {
let progress = (!json).then(|| {
Arc::new(Mutex::new(bds_cli::HumanProgress::new(
std::io::stdout().is_terminal(),
)))
});
let sink = progress.as_ref().map(|progress| {
let progress = Arc::clone(progress);
Arc::new(move |update: bds_cli::CliProgress| {
let _ = progress
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.write(&update, std::io::stdout().lock());
}) as bds_cli::ProgressSink
});
let result = bds_cli::run_with_progress(cli, context, sink);
if let Some(progress) = &progress {
let _ = progress
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.finish(std::io::stdout().lock());
}
match result {
Ok(mut output) => {
if !json {
output.progress.clear();
}
println!("{output}");
ExitCode::SUCCESS
}

View File

@@ -45,6 +45,19 @@ fn process_exit_codes_help_and_shared_state_roundtrip() {
.success()
);
let rebuilt = cli(&home, &["rebuild"]);
assert!(rebuilt.status.success());
assert!(String::from_utf8_lossy(&rebuilt.stdout).contains("%]"));
let rebuilt_json = cli(&home, &["--json", "rebuild", "--incremental"]);
assert!(rebuilt_json.status.success());
let rebuilt_json: serde_json::Value = serde_json::from_slice(&rebuilt_json.stdout).unwrap();
assert!(
rebuilt_json["progress"]
.as_array()
.is_some_and(|items| !items.is_empty())
);
let created = Command::new(env!("CARGO_BIN_EXE_bds-cli"))
.env("HOME", &home)
.args(["--json", "post", "--stdin", "--no-translate"])

View File

@@ -14,10 +14,13 @@ serde_json = { workspace = true }
serde_yaml = { workspace = true }
chrono = { workspace = true }
sha2 = { 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 }
@@ -42,11 +45,23 @@ url = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }
fastembed = { workspace = true }
usearch = { workspace = true }
ort = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
ort = { workspace = true, features = ["coreml"] }
usearch = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
# libwebp's MSVC autodetection enables SSE 4.1 sources, so clang-cl must
# compile them with the matching target feature during cargo-xwin builds.
libwebp-sys = { workspace = true, features = ["sse41"] }
ort = { workspace = true, features = ["directml"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
usearch = { workspace = true, features = ["numkong"] }
[dev-dependencies]
tempfile = "3"
[package.metadata.cargo-machete]
ignored = ["libsqlite3-sys"]
ignored = ["libsqlite3-sys", "libwebp-sys"]

View File

@@ -1,3 +1,6 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
println!("cargo:rustc-link-lib=framework=CoreML");
}
}

View File

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

View File

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

View File

@@ -53,6 +53,24 @@ impl DbConnection {
.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> {

View File

@@ -36,7 +36,132 @@ mod tests {
let applied = db
.conn()
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
assert_eq!(applied, 8);
assert_eq!(applied, 9);
}
#[test]
fn mcp_proposal_upgrade_normalizes_entities_and_enforces_uniqueness() {
use crate::db::queries::{mcp_proposal, project};
use crate::model::{McpProposal, ProposalKind, ProposalStatus};
let db = Database::open_in_memory().unwrap();
db.conn().with_migrations(|conn| {
for _ in 0..8 {
conn.run_next_migration(MIGRATIONS).unwrap();
}
});
project::insert_project(db.conn(), &project::make_test_project("p1", "blog")).unwrap();
let targeted = |id: &str, created_at| McpProposal {
id: id.into(),
project_id: "p1".into(),
kind: ProposalKind::ProposePostMetadata,
status: ProposalStatus::Pending,
entity_id: "post-1".into(),
data: "{}".into(),
result: None,
created_at,
expires_at: 99,
resolved_at: None,
};
mcp_proposal::insert_proposal(db.conn(), &targeted("older", 1)).unwrap();
mcp_proposal::insert_proposal(db.conn(), &targeted("newer", 2)).unwrap();
db.conn()
.insert_legacy_null_mcp_proposal_for_test()
.unwrap();
run_migrations(db.conn()).unwrap();
let proposals = mcp_proposal::list_proposals(db.conn(), "p1").unwrap();
assert_eq!(proposals.len(), 2);
assert!(proposals.iter().any(|proposal| proposal.id == "newer"));
assert_eq!(
proposals
.iter()
.find(|proposal| proposal.id == "null-entity")
.unwrap()
.entity_id,
"null-entity"
);
assert!(matches!(
mcp_proposal::insert_proposal(db.conn(), &targeted("duplicate", 4)),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_
))
));
}
#[test]
fn mcp_proposal_enum_constraints_round_trip_and_reject_unknown_values() {
use crate::db::queries::{mcp_proposal, project};
use crate::model::{McpProposal, ProposalKind, ProposalStatus};
let db = migrated_database();
project::insert_project(db.conn(), &project::make_test_project("p1", "blog")).unwrap();
let kinds = [
ProposalKind::DraftPost,
ProposalKind::ProposeScript,
ProposalKind::ProposeTemplate,
ProposalKind::ProposeMediaTranslation,
ProposalKind::ProposeMediaMetadata,
ProposalKind::ProposePostMetadata,
];
let statuses = [
ProposalStatus::Pending,
ProposalStatus::Executing,
ProposalStatus::Accepted,
ProposalStatus::Rejected,
ProposalStatus::Expired,
];
for (index, kind) in kinds.into_iter().enumerate() {
let status = statuses[index % statuses.len()];
let id = format!("proposal-{index}");
let proposal = McpProposal {
id: id.clone(),
project_id: "p1".into(),
kind,
status,
entity_id: id.clone(),
data: "{}".into(),
result: None,
created_at: 1,
expires_at: 2,
resolved_at: None,
};
mcp_proposal::insert_proposal(db.conn(), &proposal).unwrap();
let stored = mcp_proposal::get_proposal(db.conn(), &id).unwrap();
assert_eq!(stored.kind, kind);
assert_eq!(stored.status, status);
}
for (id, kind, status) in [
("invalid-kind", "unknown", "pending"),
("invalid-status", "draft_post", "unknown"),
] {
let result = db.conn().with(|connection| {
diesel::insert_into(mcp_proposals::table)
.values((
mcp_proposals::id.eq(id),
mcp_proposals::project_id.eq("p1"),
mcp_proposals::kind.eq(kind),
mcp_proposals::status.eq(status),
mcp_proposals::entity_id.eq(id),
mcp_proposals::data.eq("{}"),
mcp_proposals::created_at.eq(1_i64),
mcp_proposals::expires_at.eq(2_i64),
))
.execute(connection)
});
assert!(matches!(
result,
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::CheckViolation,
_
))
));
}
}
#[test]

View File

@@ -18,6 +18,18 @@ pub fn get_generated_file_hash(
})
}
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,
@@ -39,6 +51,52 @@ pub fn upsert_generated_file_hash(
})
}
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)),
)
.set(generated_file_hashes::updated_at.eq(updated_at))
.execute(c)
.map(|count| updated + count)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -79,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"
);
}
}

View File

@@ -48,6 +48,21 @@ pub fn list_pending_proposals(
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))
@@ -62,6 +77,23 @@ pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
})
}
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(
@@ -123,7 +155,7 @@ mod tests {
project_id: "p1".into(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
entity_id: id.into(),
data: "{}".into(),
result: None,
created_at: 1,

View File

@@ -79,75 +79,6 @@ pub fn update_post_status(
})
}
pub fn clear_post_content(conn: &DbConnection, id: &str, updated_at: i64) -> QueryResult<()> {
conn.with(|c| {
diesel::update(posts::table.filter(posts::id.eq(id)))
.set((
posts::content.eq(None::<String>),
posts::updated_at.eq(updated_at),
))
.execute(c)
.map(|_| ())
})
}
pub fn set_post_file_path(
conn: &DbConnection,
id: &str,
file_path: &str,
updated_at: i64,
) -> QueryResult<()> {
conn.with(|c| {
diesel::update(posts::table.filter(posts::id.eq(id)))
.set((
posts::file_path.eq(file_path),
posts::updated_at.eq(updated_at),
))
.execute(c)
.map(|_| ())
})
}
pub fn set_post_checksum(conn: &DbConnection, id: &str, checksum: Option<&str>) -> QueryResult<()> {
conn.with(|c| {
diesel::update(posts::table.filter(posts::id.eq(id)))
.set(posts::checksum.eq(checksum))
.execute(c)
.map(|_| ())
})
}
#[expect(
clippy::too_many_arguments,
reason = "arguments mirror the published snapshot columns"
)]
pub fn set_published_snapshot(
conn: &DbConnection,
id: &str,
title: &str,
content: &str,
tags: &str,
categories: &str,
excerpt: Option<&str>,
published_at: i64,
updated_at: i64,
) -> QueryResult<()> {
conn.with(|c| {
diesel::update(posts::table.filter(posts::id.eq(id)))
.set((
posts::published_title.eq(title),
posts::published_content.eq(content),
posts::published_tags.eq(tags),
posts::published_categories.eq(categories),
posts::published_excerpt.eq(excerpt),
posts::published_at.eq(published_at),
posts::updated_at.eq(updated_at),
))
.execute(c)
.map(|_| ())
})
}
pub fn delete_post(conn: &DbConnection, id: &str) -> QueryResult<()> {
conn.with(|c| {
diesel::delete(posts::table.filter(posts::id.eq(id)))
@@ -545,46 +476,6 @@ mod tests {
assert!(get_post_by_id(db.conn(), "x1").is_err());
}
#[test]
fn clear_content_sets_null() {
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());
}
#[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));
}
#[test]
fn delete_removes_post() {
let db = setup();

View File

@@ -1,7 +1,7 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::schema::post_links;
use crate::db::schema::{post_links, posts};
use crate::model::PostLink;
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
@@ -55,6 +55,17 @@ pub fn list_links_by_target(
})
}
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::*;
@@ -100,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();

View File

@@ -37,7 +37,7 @@ pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResul
conn.with(|c| {
post_media::table
.filter(post_media::post_id.eq(post_id))
.order(post_media::sort_order)
.order((post_media::sort_order.asc(), post_media::media_id.asc()))
.select(PostMedia::as_select())
.load(c)
})
@@ -56,6 +56,23 @@ pub fn list_post_media_by_media(
})
}
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: &DbConnection,
post_id: &str,
@@ -123,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();

View File

@@ -54,6 +54,22 @@ pub fn list_post_translations_by_post(
})
}
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(diesel::result::Error::SerializationError(
@@ -151,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();

View File

@@ -46,6 +46,12 @@ pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
})
}
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)]
mod tests {
use super::*;
@@ -93,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());
}
}

View File

@@ -161,7 +161,7 @@ diesel::table! {
project_id -> Text,
kind -> Text,
status -> Text,
entity_id -> Nullable<Text>,
entity_id -> Text,
data -> Text,
result -> Nullable<Text>,
created_at -> BigInt,

View File

@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
@@ -14,6 +15,7 @@ use crate::util::now_unix_ms;
const KEYRING_SERVICE: &str = "RuDS";
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 16_384;
static TEST_API_KEYS: LazyLock<Mutex<BTreeMap<String, String>>> =
LazyLock::new(|| Mutex::new(BTreeMap::new()));
@@ -119,6 +121,42 @@ pub struct OneShotRequest {
pub content: Value,
}
pub fn post_translation_request(
title: &str,
excerpt: Option<&str>,
content: &str,
target_language: &str,
) -> OneShotRequest {
OneShotRequest {
operation: OneShotOperation::TranslatePost {
target_language: target_language.to_string(),
},
content: json!({
"title": title,
"excerpt": excerpt.unwrap_or_default(),
"content": content,
}),
}
}
pub fn media_translation_request(
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
target_language: &str,
) -> OneShotRequest {
OneShotRequest {
operation: OneShotOperation::TranslateMedia {
target_language: target_language.to_string(),
},
content: json!({
"title": title.unwrap_or_default(),
"alt": alt.unwrap_or_default(),
"caption": caption.unwrap_or_default(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaxonomySuggestion {
pub tags: Vec<String>,
@@ -427,7 +465,7 @@ pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
validate_endpoint_access(endpoint)?;
let client = build_http_client()?;
let client = build_http_client_for_endpoint(&endpoint.url)?;
let request = client.get(models_url(&endpoint.url));
let response = with_auth(request, endpoint).send()?.error_for_status()?;
let body: Value = response.json()?;
@@ -494,7 +532,7 @@ pub fn test_chat(endpoint: &AiEndpointConfig, model: &str) -> EngineResult<()> {
"stream": false,
});
with_auth(
build_http_client()?
build_http_client_for_endpoint(&endpoint.url)?
.post(chat_completions_url(&endpoint.url))
.json(&payload),
endpoint,
@@ -513,7 +551,6 @@ pub fn run_one_shot(
let endpoint = active_endpoint(conn, offline_mode)?;
let model = select_model(&settings, &endpoint, &request.operation)?;
let user_content = build_one_shot_user_content(request)?;
let schema = response_schema(&request.operation);
let payload = json!({
"model": model,
"messages": [
@@ -526,16 +563,10 @@ pub fn run_one_shot(
"content": user_content,
}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": schema.0,
"schema": schema.1,
"strict": true
}
}
"max_tokens": DEFAULT_MAX_OUTPUT_TOKENS,
"stream": false,
});
let client = build_http_client()?;
let client = build_http_client_for_endpoint(&endpoint.url)?;
let response = with_auth(
client
.post(chat_completions_url(&endpoint.url))
@@ -575,8 +606,30 @@ fn parse_token_usage(body: &Value) -> TokenUsage {
}
}
fn build_http_client() -> EngineResult<Client> {
Ok(Client::builder().timeout(Duration::from_secs(5)).build()?)
fn build_http_client_for_endpoint(endpoint_url: &str) -> EngineResult<Client> {
let builder = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(120));
let builder = if should_bypass_proxy(endpoint_url) {
builder.no_proxy()
} else {
builder
};
Ok(builder.build()?)
}
fn should_bypass_proxy(endpoint_url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(endpoint_url) else {
return false;
};
let Some(host) = url.host_str() else {
return false;
};
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<IpAddr>()
.map(|address| address.is_loopback())
.unwrap_or(false)
}
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
@@ -792,133 +845,73 @@ fn build_image_analysis_user_content(content: &Value) -> EngineResult<Value> {
]))
}
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
match operation {
OneShotOperation::AnalyzeTaxonomy => (
"taxonomy_suggestion",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"tags": { "type": "array", "items": { "type": "string" } },
"categories": { "type": "array", "items": { "type": "string" } }
},
"required": ["tags", "categories"]
}),
),
OneShotOperation::MapImportTaxonomy => (
"import_taxonomy_mapping",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"category_mappings": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"tag_mappings": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["category_mappings", "tag_mappings"]
}),
),
OneShotOperation::AnalyzePost => (
"post_analysis",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"excerpt": { "type": "string" },
"slug": { "type": "string" }
},
"required": ["title", "excerpt", "slug"]
}),
),
OneShotOperation::DetectLanguage => (
"language_detection",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"language_code": { "type": "string" }
},
"required": ["language_code"]
}),
),
OneShotOperation::TranslatePost { .. } => (
"post_translation",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"excerpt": { "type": "string" },
"content": { "type": "string" }
},
"required": ["title", "excerpt", "content"]
}),
),
OneShotOperation::AnalyzeImage => (
"image_analysis",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"alt": { "type": "string" },
"caption": { "type": "string" }
},
"required": ["title", "alt", "caption"]
}),
),
OneShotOperation::TranslateMedia { .. } => (
"media_translation",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"alt": { "type": "string" },
"caption": { "type": "string" }
},
"required": ["title", "alt", "caption"]
}),
),
}
}
fn parse_one_shot_response(
request: &OneShotRequest,
content: &str,
) -> EngineResult<OneShotResponse> {
let content = decode_json_content(content)?;
Ok(match request.operation {
OneShotOperation::AnalyzeTaxonomy => {
OneShotResponse::Taxonomy(serde_json::from_str(content)?)
OneShotResponse::Taxonomy(serde_json::from_value(content)?)
}
OneShotOperation::MapImportTaxonomy => {
OneShotResponse::ImportTaxonomyMapping(serde_json::from_str(content)?)
OneShotResponse::ImportTaxonomyMapping(serde_json::from_value(content)?)
}
OneShotOperation::AnalyzePost => {
OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
OneShotResponse::PostAnalysis(serde_json::from_value(content)?)
}
OneShotOperation::DetectLanguage => {
OneShotResponse::LanguageDetection(serde_json::from_str(content)?)
OneShotResponse::LanguageDetection(serde_json::from_value(content)?)
}
OneShotOperation::TranslatePost { .. } => {
OneShotResponse::Translation(serde_json::from_str(content)?)
OneShotResponse::Translation(serde_json::from_value(content)?)
}
OneShotOperation::AnalyzeImage => {
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?)
OneShotResponse::ImageAnalysis(serde_json::from_value(content)?)
}
OneShotOperation::TranslateMedia { .. } => {
OneShotResponse::MediaTranslation(serde_json::from_str(content)?)
OneShotResponse::MediaTranslation(serde_json::from_value(content)?)
}
})
}
fn decode_json_content(content: &str) -> EngineResult<Value> {
if let Some(value) = parse_json_object(content) {
return Ok(value);
}
if let Some(fence_start) = content.find("```") {
let fenced = &content[fence_start + 3..];
if let Some(fence_end) = fenced.find("```") {
let fenced = fenced[..fence_end].trim();
let fenced = fenced
.get(..4)
.filter(|prefix| prefix.eq_ignore_ascii_case("json"))
.map_or(fenced, |_| fenced[4..].trim());
if let Some(value) = parse_json_object(fenced).or_else(|| parse_embedded_object(fenced))
{
return Ok(value);
}
}
}
parse_embedded_object(content).ok_or_else(|| {
EngineError::Parse("one-shot response did not contain a JSON object".to_string())
})
}
fn parse_json_object(content: &str) -> Option<Value> {
serde_json::from_str(content.trim())
.ok()
.filter(Value::is_object)
}
fn parse_embedded_object(content: &str) -> Option<Value> {
let start = content.find('{')?;
let end = content.rfind('}')?;
(end > start)
.then(|| &content[start..=end])
.and_then(parse_json_object)
}
fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
format!("{}.{}", kind.settings_prefix(), suffix)
}
@@ -936,7 +929,7 @@ fn store_endpoint_api_key(kind: AiEndpointKind, api_key: &str) -> EngineResult<(
TEST_API_KEYS
.lock()
.map_err(|error| EngineError::Validation(error.to_string()))?
.insert(kind.as_str().to_string(), api_key.to_string());
.insert(test_api_key_name(kind), api_key.to_string());
return Ok(());
}
endpoint_keyring_entry(kind)?
@@ -950,7 +943,7 @@ fn read_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
.lock()
.map_err(|error| EngineError::Validation(error.to_string()))
.map(|keys| {
keys.get(kind.as_str())
keys.get(&test_api_key_name(kind))
.cloned()
.filter(|key| !key.trim().is_empty())
});
@@ -968,7 +961,7 @@ fn delete_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<()> {
TEST_API_KEYS
.lock()
.map_err(|error| EngineError::Validation(error.to_string()))?
.remove(kind.as_str());
.remove(&test_api_key_name(kind));
return Ok(());
}
match endpoint_keyring_entry(kind)?.delete_credential() {
@@ -989,6 +982,10 @@ fn cargo_test_process() -> bool {
})
}
fn test_api_key_name(kind: AiEndpointKind) -> String {
format!("{:?}:{}", std::thread::current().id(), kind.as_str())
}
fn keyring_error(error: keyring::Error) -> EngineError {
EngineError::Validation(error.to_string())
}
@@ -1310,6 +1307,8 @@ mod tests {
request.contains("authorization: Bearer secret-token")
|| request.contains("Authorization: Bearer secret-token")
);
assert!(!request.contains("\"response_format\""));
assert!(request.contains("\"max_tokens\":16384"));
http_ok(
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
)
@@ -1385,6 +1384,19 @@ mod tests {
);
}
#[test]
fn decodes_fenced_and_embedded_json_objects() {
assert_eq!(
decode_json_content("```JSON\n{\"language_code\":\"de\"}\n```").unwrap(),
json!({"language_code": "de"})
);
assert_eq!(
decode_json_content("Sure! {\"title\":\"Herbst\"} Hope this helps.").unwrap(),
json!({"title": "Herbst"})
);
assert!(decode_json_content("[1, 2, 3]").is_err());
}
#[test]
fn run_one_shot_supports_taxonomy_analysis_via_airplane_endpoint() {
let response = run_airplane_one_shot(
@@ -1501,6 +1513,33 @@ mod tests {
);
}
#[test]
fn translation_requests_normalize_missing_fields() {
let post = post_translation_request("Hello", None, "Body", "de");
assert_eq!(
post.operation,
OneShotOperation::TranslatePost {
target_language: "de".to_string()
}
);
assert_eq!(
post.content,
json!({"title": "Hello", "excerpt": "", "content": "Body"})
);
let media = media_translation_request(None, None, None, "fr");
assert_eq!(
media.operation,
OneShotOperation::TranslateMedia {
target_language: "fr".to_string()
}
);
assert_eq!(
media.content,
json!({"title": "", "alt": "", "caption": ""})
);
}
#[test]
fn run_one_shot_supports_image_analysis_via_airplane_endpoint() {
let response = run_airplane_one_shot(

View File

@@ -2,17 +2,13 @@ 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::ai::{self, MediaTranslationResult, 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;
@@ -26,6 +22,29 @@ pub struct FillMissingTranslationsReport {
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())
@@ -64,7 +83,7 @@ pub fn fill_missing_translations(
main_language: &str,
blog_languages: &[String],
offline_mode: bool,
mut on_progress: impl FnMut(f32, &str) -> bool,
mut on_progress: impl FnMut(f32, &FillMissingTranslationsProgress) -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
fill_missing_translations_with(
conn,
@@ -90,7 +109,7 @@ fn fill_missing_translations_with(
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, &str) -> bool,
on_progress: &mut dyn FnMut(f32, &FillMissingTranslationsProgress) -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
let configured = configured_languages(main_language, blog_languages);
if configured.len() <= 1 {
@@ -100,7 +119,10 @@ fn fill_missing_translations_with(
});
}
let posts = qp::list_posts_by_project(conn, project_id)?;
if !on_progress(0.0, "Scanning published posts") {
if !on_progress(
0.0,
&FillMissingTranslationsProgress::ScanningPublishedPosts,
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
let mut work = Vec::new();
@@ -108,7 +130,10 @@ fn fill_missing_translations_with(
.into_iter()
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
{
if !on_progress(0.0, "Scanning published posts") {
if !on_progress(
0.0,
&FillMissingTranslationsProgress::ScanningPublishedPosts,
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
for language in missing_languages(conn, &post, &configured)? {
@@ -126,7 +151,10 @@ fn fill_missing_translations_with(
for (index, (post, language)) in work.iter().enumerate() {
if !on_progress(
0.15 + (index as f32 / work.len() as f32) * 0.85,
&format!("{}{language}", post.title),
&FillMissingTranslationsProgress::TranslatingPost {
title: post.title.clone(),
language: language.clone(),
},
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
@@ -151,7 +179,7 @@ fn fill_missing_translations_with(
}
}
}
if !on_progress(1.0, "Translation batch complete") {
if !on_progress(1.0, &FillMissingTranslationsProgress::Complete) {
return Err(EngineError::Validation("cancelled".to_string()));
}
Ok(report)
@@ -196,9 +224,9 @@ pub fn translate_missing_for_post(
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.
/// Translate one language from the configured target set. This is the reactive
/// editor path: generated translations remain drafts, and a retry after the
/// post translation was saved resumes any still-missing media translations.
pub fn translate_missing_language_for_post(
conn: &Connection,
data_dir: &Path,
@@ -207,10 +235,42 @@ pub fn translate_missing_language_for_post(
language: &str,
offline_mode: bool,
is_cancelled: impl Fn() -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
translate_missing_language_for_post_with(
conn,
data_dir,
post_id,
configured_languages,
language,
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
is_cancelled,
)
}
#[expect(
clippy::too_many_arguments,
reason = "testable translation orchestration dependencies"
)]
fn translate_missing_language_for_post_with(
conn: &Connection,
data_dir: &Path,
post_id: &str,
configured_languages: &[String],
language: &str,
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
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) {
let language = normalize_language(language);
let source = normalize_language(post.language.as_deref().unwrap_or("en"));
if post.do_not_translate
|| language == source
|| !configured_languages
.iter()
.any(|configured| normalize_language(configured) == language)
{
return Ok(FillMissingTranslationsReport {
nothing_to_do: true,
..Default::default()
@@ -219,21 +279,36 @@ pub fn translate_missing_language_for_post(
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
let translation_exists =
post_translation::get_post_translation_by_post_and_language(conn, &post.id, &language)
.is_ok();
let mut report = FillMissingTranslationsReport::default();
merge_reactive_translation_result(
&mut report,
&post,
language,
let result = if translation_exists {
translate_missing_media(conn, data_dir, &post, &language, media_translator)
} else {
translate_one_post(
conn,
data_dir,
&post,
language,
&language,
false,
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
),
);
post_translator,
media_translator,
)
};
match result {
Ok(media_count) => {
report.translated_posts += usize::from(!translation_exists);
report.translated_media += media_count;
report.nothing_to_do = translation_exists && media_count == 0;
}
Err(error) => {
report.failed_count += 1;
report
.errors
.push(format!("{} ({language}): {error}", post.title));
}
}
Ok(report)
}
@@ -276,7 +351,26 @@ fn translate_one_post(
let mut input = post.clone();
input.content = Some(body);
let translated = post_translator(&input, language)?;
let translation = crate::engine::post::upsert_translation(
for (field, source, translated_value) in [
("title", post.title.as_str(), translated.title.as_str()),
(
"excerpt",
post.excerpt.as_deref().unwrap_or(""),
translated.excerpt.as_str(),
),
(
"content",
input.content.as_deref().unwrap_or(""),
translated.content.as_str(),
),
] {
if !source.trim().is_empty() && translated_value.trim().is_empty() {
return Err(EngineError::Validation(format!(
"post translation returned empty {field}"
)));
}
}
let translation = crate::engine::post::upsert_automatic_translation(
conn,
data_dir,
&post.id,
@@ -289,6 +383,16 @@ fn translate_one_post(
crate::engine::post::publish_post_translation(conn, data_dir, &translation.id)?;
}
translate_missing_media(conn, data_dir, post, language, media_translator)
}
fn translate_missing_media(
conn: &Connection,
data_dir: &Path,
post: &Post,
language: &str,
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
) -> EngineResult<usize> {
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)?;
@@ -336,16 +440,12 @@ fn translate_post_ai(
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,
}),
},
&ai::post_translation_request(
&post.title,
post.excerpt.as_deref(),
post.content.as_deref().unwrap_or_default(),
language,
),
)? {
(OneShotResponse::Translation(result), _usage) => Ok(result),
_ => Err(EngineError::Parse(
@@ -363,16 +463,12 @@ fn translate_media_ai(
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,
}),
},
&ai::media_translation_request(
media.title.as_deref(),
media.alt.as_deref(),
media.caption.as_deref(),
language,
),
)? {
(OneShotResponse::MediaTranslation(result), _usage) => Ok(result),
_ => Err(EngineError::Parse(
@@ -395,10 +491,32 @@ mod tests {
use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project};
use crate::engine::post::{create_post, publish_post, upsert_translation};
use crate::model::PostMedia;
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();
@@ -442,6 +560,9 @@ mod tests {
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(),
@@ -455,6 +576,100 @@ mod tests {
}
}
#[test]
fn blank_ai_translation_is_rejected_without_creating_a_record() {
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();
let result = translate_one_post(
db.conn(),
dir.path(),
&post,
"de",
false,
&mut |_post, _language| {
Ok(TranslationResult {
title: String::new(),
excerpt: String::new(),
content: String::new(),
})
},
&mut |_media, _language| unreachable!(),
);
assert!(result.is_err_and(|error| error.to_string().contains("empty")));
assert!(
post_translation::get_post_translation_by_post_and_language(db.conn(), &post.id, "de")
.is_err()
);
}
#[test]
fn automatic_translation_notifies_open_post_consumers() {
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();
let events = crate::engine::domain_events::subscribe();
let translated_media = translate_one_post(
db.conn(),
dir.path(),
&post,
"de",
false,
&mut |_post, _language| {
Ok(TranslationResult {
title: "Hallo".into(),
excerpt: "".into(),
content: "Inhalt".into(),
})
},
&mut |_media, _language| unreachable!(),
)
.unwrap();
assert_eq!(translated_media, 0);
assert!(events.drain().iter().any(|event| matches!(
event,
crate::model::DomainEvent::EntityChanged {
project_id,
entity: crate::model::DomainEntity::Post,
entity_id,
action: crate::model::NotificationAction::Updated,
} if project_id == "p1" && entity_id == &post.id
)));
}
#[test]
fn reactive_language_translation_is_a_no_op_when_translation_exists() {
let db = Database::open_in_memory().unwrap();
@@ -501,6 +716,83 @@ mod tests {
assert_eq!(report.translated_posts, 0);
}
#[test]
fn reactive_retry_resumes_missing_media_after_post_translation_was_saved() {
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 mut media = make_test_media("media-1", "p1");
media.language = Some("en".to_string());
media.file_path = "media/media-1.jpg".to_string();
media.sidecar_path = "media/media-1.jpg.meta".to_string();
insert_media(db.conn(), &media).unwrap();
post_media::link_media(
db.conn(),
&PostMedia {
id: "link-1".to_string(),
project_id: "p1".to_string(),
post_id: post.id.clone(),
media_id: media.id.clone(),
sort_order: 0,
created_at: 1,
},
)
.unwrap();
let mut media_attempts = 0;
let report = translate_missing_language_for_post_with(
db.conn(),
dir.path(),
&post.id,
&["en".to_string(), "de".to_string()],
"de",
&mut |_, _| panic!("the existing post translation must not be regenerated"),
&mut |_, _| {
media_attempts += 1;
Ok(MediaTranslationResult {
title: "Foto".to_string(),
alt: "Bild".to_string(),
caption: "Beschreibung".to_string(),
})
},
|| false,
)
.unwrap();
assert_eq!(media_attempts, 1);
assert_eq!(report.translated_posts, 0);
assert_eq!(report.translated_media, 1);
assert!(!report.nothing_to_do);
assert!(
qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_ok()
);
}
#[test]
fn skips_do_not_translate_posts() {
let db = Database::open_in_memory().unwrap();

View File

@@ -102,6 +102,20 @@ pub fn receive_deep_link_with_host(
raw: &str,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> EngineResult<BlogmarkImportResult> {
receive_deep_link_with_host_and_created(conn, data_dir, project_id, raw, control, host, |_| {})
}
/// Import a blogmark while reporting the created draft before secondary
/// embedding work completes.
pub fn receive_deep_link_with_host_and_created(
conn: &Connection,
data_dir: &Path,
project_id: &str,
raw: &str,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
on_created: impl FnOnce(&Post),
) -> EngineResult<BlogmarkImportResult> {
let mut candidate = parse_deep_link(raw)?;
if let Some(target) = &candidate.project_id
@@ -134,7 +148,7 @@ pub fn receive_deep_link_with_host(
}
}
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let post = crate::engine::post::create_post(
let post = crate::engine::post::create_post_with_created_callback(
conn,
data_dir,
project_id,
@@ -145,6 +159,7 @@ pub fn receive_deep_link_with_host(
metadata.default_author.as_deref(),
metadata.main_language.as_deref(),
None,
on_created,
)?;
Ok(BlogmarkImportResult {
post,

View File

@@ -6,7 +6,7 @@ use std::path::Path;
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,
@@ -82,4 +99,20 @@ mod tests {
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());
}
}

View File

@@ -1,5 +1,6 @@
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::net::IpAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, mpsc};
use std::time::Duration;
@@ -836,16 +837,39 @@ pub fn build_context(
Ok(result)
}
fn chat_http_client(endpoint_url: &str) -> EngineResult<Client> {
let builder = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(300));
let builder = if should_bypass_proxy(endpoint_url) {
builder.no_proxy()
} else {
builder
};
Ok(builder.build()?)
}
fn should_bypass_proxy(endpoint_url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(endpoint_url) else {
return false;
};
let Some(host) = url.host_str() else {
return false;
};
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<IpAddr>()
.map(|address| address.is_loopback())
.unwrap_or(false)
}
fn request_completion(
endpoint: &AiEndpointConfig,
payload: &Value,
cancelled: &AtomicBool,
mut on_content: impl FnMut(&str),
) -> EngineResult<AssembledResponse> {
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(300))
.build()?;
let client = chat_http_client(&endpoint.url)?;
let mut request = client
.post(chat_completions_url(&endpoint.url))
.json(payload);

View File

@@ -2,8 +2,7 @@ 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.
/// Link the user-facing command to a packaged `bds-cli` binary.
pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<PathBuf> {
if !executable.is_file() {
return Err(EngineError::Validation(format!(
@@ -14,49 +13,29 @@ pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<Path
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"
"bds-cli.exe"
} 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)]
if let Err(error) = std::fs::remove_file(&target)
&& error.kind() != std::io::ErrorKind::NotFound
{
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)?;
return Err(error.into());
}
#[cfg(unix)]
std::os::unix::fs::symlink(source, &target)?;
#[cfg(windows)]
std::os::windows::fs::symlink_file(source, &target)?;
#[cfg(not(any(unix, windows)))]
return Err(EngineError::Validation(
"installing the CLI is not supported on this platform".to_string(),
));
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()?;
@@ -72,41 +51,50 @@ pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
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() {
fn install_replaces_existing_launcher_with_symlink() {
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();
std::fs::write(&target, b"old launcher").unwrap();
let target = install_launcher(&executable, &home).unwrap();
assert!(target.is_symlink());
assert_eq!(
std::fs::read_link(&target).unwrap(),
executable.canonicalize().unwrap()
);
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
let replacement = root.path().join("replacement-bds-cli");
std::fs::write(&replacement, b"new binary").unwrap();
assert_eq!(install_launcher(&replacement, &home).unwrap(), target);
assert_eq!(
std::fs::read_link(target).unwrap(),
replacement.canonicalize().unwrap()
);
}
#[cfg(unix)]
#[test]
fn install_replaces_broken_symlink() {
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(root.path().join("missing-bds-cli"), &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")
assert!(target.is_symlink());
assert_eq!(
std::fs::read_link(target).unwrap(),
executable.canonicalize().unwrap()
);
}
}

View File

@@ -15,6 +15,8 @@ pub enum EngineError {
Conflict(String),
#[error("validation error: {0}")]
Validation(String),
#[error("operation cancelled")]
Cancelled,
}
impl From<crate::db::DatabaseError> for EngineError {

File diff suppressed because it is too large Load Diff

View File

@@ -502,22 +502,6 @@ impl GitEngine {
})
}
pub fn set_remote(&self, remote_url: &str) -> Result<(), GitError> {
let remote_url = remote_url.trim();
if remote_url.is_empty() {
return Err(GitError::Validation("remote URL is required".into()));
}
let args = if self
.optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)?
.is_some()
{
["remote", "set-url", "origin", remote_url]
} else {
["remote", "add", "origin", remote_url]
};
self.run_local(&args, GitOperation::Remote).map(|_| ())
}
pub fn commit_all(&self, message: &str) -> Result<NetworkResult, GitError> {
let message = message.trim();
if message.is_empty() {
@@ -916,6 +900,8 @@ fn terminate_child(child: &mut std::process::Child) {
}
fn prepend_tool_paths(command: &mut Command) {
#[cfg(not(target_os = "macos"))]
let _ = command;
#[cfg(target_os = "macos")]
{
let mut paths = vec![

View File

@@ -98,7 +98,17 @@ pub fn list_pending_proposals(
}
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
let expired = proposal_q::expire_pending(conn, now_unix_ms())?;
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();
}
@@ -114,19 +124,37 @@ pub(crate) fn create_proposal(
) -> 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: entity_id.map(str::to_string),
entity_id,
data: serde_json::to_string(data)?,
result: None,
created_at: now,
expires_at: now + PROPOSAL_TTL_MS,
resolved_at: None,
};
proposal_q::insert_proposal(conn, &proposal)?;
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 {
@@ -182,6 +210,7 @@ fn resolve_proposal(
} else {
ProposalStatus::Rejected
};
proposal_q::delete_status_collision(conn, &proposal, status)?;
if !proposal_q::resolve_claimed(
conn,
proposal_id,

View File

@@ -20,7 +20,7 @@ use crate::util::thumbnail::{
mime_from_extension,
};
use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
atomic_write_str, media_dir_path, media_file_hash, media_sidecar_path,
media_translation_sidecar_path, now_unix_ms,
};
@@ -41,7 +41,9 @@ pub struct MediaLinkRebuildReport {
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ThumbnailRepairReport {
pub media_processed: usize,
pub media_repaired: usize,
pub media_failed: usize,
pub thumbnails_generated: usize,
}
@@ -51,9 +53,18 @@ pub fn rebuild_media_links(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
rebuild_media_links_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_media_links_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
conn.begin_savepoint()?;
match rebuild_media_links_inner(conn, data_dir, project_id) {
match rebuild_media_links_inner(conn, data_dir, project_id, on_item.as_ref()) {
Ok(report) => {
conn.release_savepoint()?;
Ok(report)
@@ -69,9 +80,16 @@ fn rebuild_media_links_inner(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<&ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
let mut report = MediaLinkRebuildReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
let items = qm::list_media_by_project(conn, project_id)?;
for (index, item) in items.iter().enumerate() {
if let Some(callback) = on_item
&& !callback(index + 1, items.len(), &item.original_name)
{
return Err(EngineError::Cancelled);
}
let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
.map_err(EngineError::Parse)?;
for link in qpm::list_post_media_by_media(conn, &item.id)? {
@@ -108,12 +126,26 @@ pub fn regenerate_missing_thumbnails(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ThumbnailRepairReport> {
regenerate_missing_thumbnails_with_progress(conn, data_dir, project_id, |_, _, _| true)
}
pub fn regenerate_missing_thumbnails_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_item: impl FnMut(usize, usize, &str) -> bool,
) -> EngineResult<ThumbnailRepairReport> {
let mut report = ThumbnailRepairReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
if !item.mime_type.starts_with("image/") {
let items = qm::list_media_by_project(conn, project_id)?;
for (index, item) in items.iter().enumerate() {
if !on_item(index + 1, items.len(), &item.original_name) {
return Err(EngineError::Cancelled);
}
if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") {
continue;
}
report.media_processed += 1;
let prefix = &item.id[..2.min(item.id.len())];
let missing = THUMBNAIL_SIZES
.iter()
@@ -132,14 +164,17 @@ pub fn regenerate_missing_thumbnails(
if missing == 0 {
continue;
}
generate_all_thumbnails(
match generate_all_thumbnails(
&data_dir.join(&item.file_path),
&data_dir.join("thumbnails"),
&item.id,
)
.map_err(EngineError::Parse)?;
report.media_repaired += 1;
report.thumbnails_generated += missing;
) {
Ok(_) => {
report.media_repaired += 1;
report.thumbnails_generated += missing;
}
Err(_) => report.media_failed += 1,
}
}
Ok(report)
}
@@ -193,6 +228,7 @@ pub fn import_media(
author,
language,
tags,
None,
now_unix_ms(),
)
}
@@ -216,6 +252,7 @@ pub(crate) fn import_media_at(
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
checksum: Option<&str>,
created_at: i64,
) -> EngineResult<Media> {
// Validate file type per spec
@@ -254,10 +291,6 @@ pub(crate) fn import_media_at(
// Compute sidecar path
let sidecar_rel = media_sidecar_path(&rel_file_path);
// Compute checksum of the copied file
let file_bytes = fs::read(&abs_file_path)?;
let checksum = content_hash(&file_bytes);
// Generate thumbnails (silently ignore errors for non-image files)
let thumbnails_dir = data_dir.join("thumbnails");
let _ = generate_all_thumbnails(&abs_file_path, &thumbnails_dir, &id);
@@ -278,7 +311,7 @@ pub(crate) fn import_media_at(
language: language.map(|s| s.to_string()),
file_path: rel_file_path,
sidecar_path: sidecar_rel.clone(),
checksum: Some(checksum),
checksum: checksum.map(str::to_owned),
tags,
created_at,
updated_at: created_at,
@@ -340,12 +373,7 @@ pub fn update_media(
media.updated_at = now_unix_ms();
qm::update_media(conn, &media)?;
// Rewrite sidecar (need linked_post_ids from post_media table)
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
let linked_post_ids: Vec<String> = linked.iter().map(|pm| pm.post_id.clone()).collect();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
let abs_sidecar = data_dir.join(&media.sidecar_path);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
sync_media_sidecar(conn, data_dir, media_id)?;
// Re-index FTS
fts_index_media(conn, &media)?;
@@ -355,6 +383,21 @@ pub fn update_media(
Ok(media)
}
/// Rewrite a media item's canonical sidecar from its current database state.
pub fn sync_media_sidecar(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id).map_err(|error| match error {
diesel::result::Error::NotFound => EngineError::NotFound(format!("media {media_id}")),
error => EngineError::Db(error),
})?;
let linked_post_ids = qpm::list_post_media_by_media(conn, media_id)?
.into_iter()
.map(|link| link.post_id)
.collect::<Vec<_>>();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?;
Ok(())
}
/// Replace a media item's binary while preserving its identity, public path,
/// metadata, translations, and post links. Returns `None` when the new file is
/// byte-for-byte identical to the stored binary.
@@ -373,7 +416,7 @@ pub fn replace_media_file(
)));
}
let replacement_checksum = crate::util::file_hash(new_source_path)?;
let replacement_checksum = media_file_hash(new_source_path)?;
if media.checksum.as_deref() == Some(replacement_checksum.as_str()) {
return Ok(None);
}
@@ -434,10 +477,7 @@ pub fn replace_media_file(
let apply_result = (|| -> EngineResult<()> {
conn.begin_savepoint()?;
qm::update_media(conn, &media)?;
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
let linked_post_ids: Vec<String> = linked.iter().map(|item| item.post_id.clone()).collect();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?;
sync_media_sidecar(conn, data_dir, media_id)?;
fts_index_media(conn, &media)?;
for staged in &staged_paths {
@@ -642,7 +682,7 @@ pub fn rebuild_media_from_filesystem(
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Like `rebuild_media_from_filesystem` but with optional per-item progress.
pub fn rebuild_media_from_filesystem_with_progress(
@@ -688,7 +728,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
for (i, path) in canonical_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(i + 1, total, name);
if !cb(i + 1, total, name) {
return Err(EngineError::Cancelled);
}
}
match rebuild_canonical_media(conn, data_dir, project_id, path) {
Ok(created) => {
@@ -709,7 +751,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
for (i, path) in translation_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(offset + i + 1, total, name);
if !cb(offset + i + 1, total, name) {
return Err(EngineError::Cancelled);
}
}
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
Ok(created) => {
@@ -963,6 +1007,12 @@ mod tests {
path
}
fn heic_fixture() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("testdata")
.join("close.heic")
}
#[test]
fn import_media_creates_artifacts() {
let (db, dir) = setup();
@@ -994,7 +1044,7 @@ mod tests {
assert_eq!(from_db.mime_type, "image/png");
assert_eq!(from_db.width, Some(100));
assert_eq!(from_db.height, Some(80));
assert!(from_db.checksum.is_some());
assert_eq!(from_db.checksum, None);
assert!(from_db.size > 0);
// Verify binary file exists
@@ -1027,6 +1077,103 @@ mod tests {
}
}
#[test]
fn import_heic_and_heif_create_dimensions_and_thumbnails() {
let (db, dir) = setup();
for (original_name, mime_type) in
[("photo.heic", "image/heic"), ("photo.heif", "image/heif")]
{
let media = import_media(
db.conn(),
dir.path(),
"p1",
&heic_fixture(),
original_name,
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
assert_eq!(media.mime_type, mime_type);
assert_eq!((media.width, media.height), (Some(27), Some(27)));
for size in THUMBNAIL_SIZES {
let extension = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
assert!(
dir.path()
.join("thumbnails")
.join(&media.id[..2])
.join(format!("{}-{}.{}", media.id, size.name, extension))
.is_file(),
"missing {} thumbnail",
size.name
);
}
}
}
#[test]
fn regenerate_missing_thumbnails_reports_undecodable_media_and_continues() {
let (db, dir) = setup();
let good = import_media(
db.conn(),
dir.path(),
"p1",
&create_test_png(dir.path()),
"good.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let bad_source = dir.path().join("bad.png");
fs::write(&bad_source, "not an image").unwrap();
let bad = import_media(
db.conn(),
dir.path(),
"p1",
&bad_source,
"bad.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let good_small = dir
.path()
.join("thumbnails")
.join(&good.id[..2])
.join(format!("{}-small.webp", good.id));
fs::remove_file(good_small).unwrap();
let report = regenerate_missing_thumbnails(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.media_processed, 2);
assert_eq!(report.media_repaired, 1);
assert_eq!(report.media_failed, 1);
assert_eq!(report.thumbnails_generated, 1);
assert!(
!dir.path()
.join("thumbnails")
.join(&bad.id[..2])
.join(format!("{}-small.webp", bad.id))
.exists()
);
}
#[test]
fn update_media_rewrites_sidecar() {
let (db, dir) = setup();
@@ -1234,7 +1381,12 @@ mod tests {
)
.unwrap();
let stored = dir.path().join(&media.file_path);
let updated_at = media.updated_at;
let duplicate = dir.path().join("duplicate.png");
fs::copy(&stored, &duplicate).unwrap();
let established = replace_media_file(db.conn(), dir.path(), &media.id, &duplicate)
.unwrap()
.unwrap();
let updated_at = established.updated_at;
assert!(
replace_media_file(db.conn(), dir.path(), &media.id, &stored)
@@ -1249,6 +1401,32 @@ mod tests {
);
}
#[test]
fn import_media_at_preserves_caller_supplied_bds2_checksum() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let checksum = crate::util::media_file_hash(&source).unwrap();
let media = import_media_at(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
Some(&checksum),
now_unix_ms(),
)
.unwrap();
assert_eq!(media.checksum.as_deref(), Some(checksum.as_str()));
}
#[test]
fn delete_media_removes_everything() {
let (db, dir) = setup();

View File

@@ -6,7 +6,9 @@ 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)]
@@ -62,23 +64,28 @@ pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
/// 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).expect("writing menu XML to memory cannot fail")
serialize_opml(project_name, timestamp, &items).expect("writing menu XML to memory cannot fail")
}
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
@@ -233,8 +240,9 @@ fn attach_outline(item: MenuItem, parents: &mut [Option<MenuItem>], items: &mut
}
/// Serialize menu items to OPML 2.0 format.
fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
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()))?;
@@ -246,14 +254,12 @@ fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
writer
.write_event(Event::Start(BytesStart::new("head")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("title")))
write_text_element(&mut writer, "title", project_name)
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Text(BytesText::new("Blog Menu")))
let timestamp = unix_ms_to_iso(timestamp);
write_text_element(&mut writer, "dateCreated", &timestamp)
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("title")))
write_text_element(&mut writer, "dateModified", &timestamp)
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("head")))
@@ -270,7 +276,21 @@ fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
writer
.write_event(Event::End(BytesEnd::new("opml")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
String::from_utf8(writer.into_inner()).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 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<()> {
@@ -310,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\""));
}
@@ -342,7 +362,7 @@ mod tests {
}],
},
];
let opml = serialize_opml(&items).unwrap();
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);
@@ -385,7 +405,21 @@ mod tests {
slug: Some("/blog".into()),
children: Vec::new(),
}];
write_menu(dir.path(), &items).unwrap();
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,
},
&items,
)
.unwrap();
let read = read_menu(dir.path()).unwrap();
// Home is always prepended
assert_eq!(read.len(), 2);
@@ -415,7 +449,7 @@ mod tests {
assert_eq!(parsed[1].children[1].kind, MenuItemKind::CategoryArchive);
assert_eq!(parsed[1].children[1].slug.as_deref(), Some("notes"));
let serialized = serialize_opml(&parsed).unwrap();
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\""));
@@ -423,6 +457,33 @@ mod tests {
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(

View File

@@ -1,13 +1,99 @@
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::Path;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::db::DbConnection as Connection;
use crate::engine::EngineResult;
use crate::engine::{EngineError, EngineResult};
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
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.
@@ -49,6 +135,7 @@ pub fn update_project_metadata(
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)?;
@@ -61,25 +148,119 @@ pub fn update_project_metadata(
Ok(metadata.clone())
}
/// Copy the project fields persisted in both representations from project.json
/// into the machine-local project row.
pub fn sync_project_from_file(
/// 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 metadata = read_project_json(data_dir)?;
metadata
.validate()
.map_err(crate::engine::EngineError::Validation)?;
let mut project = crate::db::queries::project::get_project_by_id(conn, project_id)?;
project.name = metadata.name;
project.description = metadata.description;
project.updated_at = crate::util::now_unix_ms();
crate::db::queries::project::update_project(conn, &project)?;
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.
@@ -93,13 +274,32 @@ 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_key(|a| a.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.
@@ -116,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.
@@ -139,6 +396,16 @@ pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) ->
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.
@@ -162,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)?;
@@ -174,29 +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(())
set_categories_and_meta(conn, data_dir, project_id, &cats, &meta)
}
// ── startup sync ────────────────────────────────────────────────────
@@ -244,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
@@ -254,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)?;
}
@@ -298,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]
@@ -309,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]
@@ -318,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,
@@ -330,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]
@@ -347,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]
@@ -370,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(), &["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()));
@@ -391,10 +889,12 @@ mod tests {
#[test]
fn add_category_idempotent() {
let dir = setup();
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);
}
@@ -402,11 +902,15 @@ mod tests {
#[test]
fn remove_category_deletes_entries() {
let dir = setup();
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,
@@ -415,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()));
@@ -424,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"]
);
}
}

View File

@@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::Path;
@@ -17,8 +17,7 @@ use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation, Post, PostTranslation, Script, Template};
use crate::util::frontmatter::{
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file,
read_translation_file, write_post_file, write_script_file, write_template_file,
write_translation_file,
read_translation_file, write_script_file, write_template_file, write_translation_file,
};
use crate::util::sidecar::{
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
@@ -71,17 +70,50 @@ pub fn compute_metadata_diff(
data_dir: &Path,
project_id: &str,
) -> EngineResult<DiffReport> {
compute_metadata_diff_with_progress(conn, data_dir, project_id, |_, _| true)
}
pub fn compute_metadata_diff_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_progress: impl FnMut(usize, usize) -> bool,
) -> EngineResult<DiffReport> {
const PHASES: usize = 8;
let mut phase = 0;
let mut next_phase = || -> EngineResult<()> {
if !on_progress(phase, PHASES) {
return Err(crate::engine::EngineError::Cancelled);
}
phase += 1;
Ok(())
};
let mut report = DiffReport::default();
next_phase()?;
if let Ok(project) = qproject::get_project_by_id(conn, project_id) {
match diff_project(data_dir, &project) {
match diff_project(conn, data_dir, &project) {
Ok(Some(diff)) => report.diffs.push(diff),
Ok(None) => {}
Err(error) => report.errors.push(format!("project {project_id}: {error}")),
}
for result in [
diff_categories(conn, data_dir, project_id),
diff_category_meta(conn, data_dir, project_id),
diff_publishing(conn, data_dir, project_id),
] {
match result {
Ok(Some(diff)) => report.diffs.push(diff),
Ok(None) => {}
Err(error) => report
.errors
.push(format!("project metadata {project_id}: {error}")),
}
}
}
// 1. Diff posts
next_phase()?;
let posts = qp::list_posts_by_project(conn, project_id)?;
for post in &posts {
if post.file_path.is_empty() {
@@ -95,6 +127,7 @@ pub fn compute_metadata_diff(
}
// 2. Diff translations
next_phase()?;
for post in &posts {
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
for t in &translations {
@@ -110,6 +143,7 @@ pub fn compute_metadata_diff(
}
// 3. Diff media
next_phase()?;
let media_items = qm::list_media_by_project(conn, project_id)?;
for m in &media_items {
if m.sidecar_path.is_empty() {
@@ -134,6 +168,7 @@ pub fn compute_metadata_diff(
}
// 4. Diff templates
next_phase()?;
let templates = qtpl::list_templates_by_project(conn, project_id)?;
for t in &templates {
if t.file_path.is_empty() {
@@ -147,6 +182,7 @@ pub fn compute_metadata_diff(
}
// 5. Diff scripts
next_phase()?;
let scripts = qs::list_scripts_by_project(conn, project_id)?;
for s in &scripts {
if s.file_path.is_empty() {
@@ -160,6 +196,7 @@ pub fn compute_metadata_diff(
}
// 6. Detect orphans
next_phase()?;
if crate::engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
@@ -203,9 +240,14 @@ pub fn compute_metadata_diff(
}
// 7. Detect orphans
next_phase()?;
let orphans = detect_orphan_files(conn, data_dir, project_id)?;
report.orphans = orphans;
if !on_progress(PHASES, PHASES) {
return Err(crate::engine::EngineError::Cancelled);
}
Ok(report)
}
@@ -221,7 +263,9 @@ pub fn repair_metadata_diff_item(
RepairDirection::FileToDatabase => {
let path = data_dir.join(&item.file_path);
match item.entity_type.as_str() {
"project" => sync_project_from_file(conn, data_dir, project_id)?,
"project" | "categories" | "category_meta" | "publishing" => {
crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?
}
"post" => {
crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?;
}
@@ -257,7 +301,9 @@ pub fn repair_metadata_diff_item(
}
}
RepairDirection::DatabaseToFile => match item.entity_type.as_str() {
"project" => rewrite_project_from_database(conn, data_dir, project_id)?,
"project" | "categories" | "category_meta" | "publishing" => {
crate::engine::meta::flush_metadata_to_filesystem(conn, data_dir, project_id)?
}
"post" => rewrite_post_from_database(conn, data_dir, &item.entity_id)?,
"post_translation" => {
rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)?
@@ -336,18 +382,93 @@ pub fn import_orphan_file(
}
fn diff_project(
conn: &Connection,
data_dir: &Path,
project: &crate::model::Project,
) -> EngineResult<Option<EntityDiff>> {
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let mut fields = Vec::new();
compare_field(&mut fields, "name", &project.name, &metadata.name);
let snapshot = crate::engine::meta::read_project_metadata_snapshot(conn, &project.id)?;
let database = snapshot.as_ref();
compare_field(
&mut fields,
"name",
database.map_or(project.name.as_str(), |value| value.name.as_str()),
&metadata.name,
);
compare_field(
&mut fields,
"description",
project.description.as_deref().unwrap_or(""),
database.map_or_else(
|| project.description.as_deref().unwrap_or(""),
|value| value.description.as_deref().unwrap_or(""),
),
metadata.description.as_deref().unwrap_or(""),
);
if let Some(database) = database {
compare_optional_field(
&mut fields,
"public_url",
&database.public_url,
&metadata.public_url,
);
compare_optional_field(
&mut fields,
"main_language",
&database.main_language,
&metadata.main_language,
);
compare_optional_field(
&mut fields,
"default_author",
&database.default_author,
&metadata.default_author,
);
compare_field(
&mut fields,
"max_posts_per_page",
&database.max_posts_per_page.to_string(),
&metadata.max_posts_per_page.to_string(),
);
compare_field(
&mut fields,
"image_import_concurrency",
&database.image_import_concurrency.to_string(),
&metadata.image_import_concurrency.to_string(),
);
compare_optional_field(
&mut fields,
"blogmark_category",
&database.blogmark_category,
&metadata.blogmark_category,
);
compare_optional_field(
&mut fields,
"pico_theme",
&database.pico_theme,
&metadata.pico_theme,
);
compare_field(
&mut fields,
"semantic_similarity_enabled",
if database.semantic_similarity_enabled {
"true"
} else {
"false"
},
if metadata.semantic_similarity_enabled {
"true"
} else {
"false"
},
);
compare_field(
&mut fields,
"blog_languages",
&json_value(&database.blog_languages)?,
&json_value(&metadata.blog_languages)?,
);
}
Ok((!fields.is_empty()).then_some(EntityDiff {
entity_type: "project".into(),
entity_id: project.id.clone(),
@@ -356,24 +477,96 @@ fn diff_project(
}))
}
fn sync_project_from_file(
fn diff_categories(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
crate::engine::meta::sync_project_from_file(conn, data_dir, project_id)
) -> EngineResult<Option<EntityDiff>> {
let Some(database) = crate::engine::meta::read_categories_snapshot(conn, project_id)? else {
return Ok(None);
};
let filesystem = crate::engine::meta::read_categories_json(data_dir)?;
let mut fields = Vec::new();
compare_field(
&mut fields,
"categories",
&json_value(&database)?,
&json_value(&filesystem)?,
);
Ok((!fields.is_empty()).then_some(EntityDiff {
entity_type: "categories".into(),
entity_id: project_id.into(),
file_path: "meta/categories.json".into(),
fields,
}))
}
fn rewrite_project_from_database(
fn diff_category_meta(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
let project = qproject::get_project_by_id(conn, project_id)?;
let mut metadata = crate::engine::meta::read_project_json(data_dir)?;
metadata.name = project.name;
metadata.description = project.description;
crate::engine::meta::write_project_json(data_dir, &metadata)
) -> EngineResult<Option<EntityDiff>> {
let Some(database) = crate::engine::meta::read_category_meta_snapshot(conn, project_id)? else {
return Ok(None);
};
let filesystem = crate::engine::meta::read_category_meta_json(data_dir)?;
let database = database.into_iter().collect::<BTreeMap<_, _>>();
let filesystem = filesystem.into_iter().collect::<BTreeMap<_, _>>();
let mut fields = Vec::new();
compare_field(
&mut fields,
"category_settings",
&json_value(&database)?,
&json_value(&filesystem)?,
);
Ok((!fields.is_empty()).then_some(EntityDiff {
entity_type: "category_meta".into(),
entity_id: project_id.into(),
file_path: "meta/category-meta.json".into(),
fields,
}))
}
fn diff_publishing(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<Option<EntityDiff>> {
let Some(database) = crate::engine::meta::read_publishing_snapshot(conn, project_id)? else {
return Ok(None);
};
let filesystem = crate::engine::meta::read_publishing_json(data_dir)?;
let mut fields = Vec::new();
compare_optional_field(
&mut fields,
"ssh_host",
&database.ssh_host,
&filesystem.ssh_host,
);
compare_optional_field(
&mut fields,
"ssh_user",
&database.ssh_user,
&filesystem.ssh_user,
);
compare_optional_field(
&mut fields,
"ssh_remote_path",
&database.ssh_remote_path,
&filesystem.ssh_remote_path,
);
compare_field(
&mut fields,
"ssh_mode",
ssh_mode_name(&database.ssh_mode),
ssh_mode_name(&filesystem.ssh_mode),
);
Ok((!fields.is_empty()).then_some(EntityDiff {
entity_type: "publishing".into(),
entity_id: project_id.into(),
file_path: "meta/publishing.json".into(),
fields,
}))
}
fn unsupported_repair<T>(entity_type: &str) -> EngineResult<T> {
@@ -388,13 +581,7 @@ fn rewrite_post_from_database(
entity_id: &str,
) -> EngineResult<()> {
let post = qp::get_post_by_id(conn, entity_id)?;
let path = data_dir.join(&post.file_path);
let body = fs::read_to_string(&path)
.ok()
.and_then(|content| read_post_file(&content).ok().map(|(_, body)| body))
.unwrap_or_default();
atomic_write_str(&path, &write_post_file(&post, &body))?;
Ok(())
crate::engine::post::rewrite_post_file_from_database(data_dir, &post)
}
fn rewrite_post_translation_from_database(
@@ -543,6 +730,31 @@ fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val
}
}
fn compare_optional_field(
fields: &mut Vec<DiffField>,
name: &str,
db_val: &Option<String>,
file_val: &Option<String>,
) {
compare_field(
fields,
name,
db_val.as_deref().unwrap_or(""),
file_val.as_deref().unwrap_or(""),
);
}
fn json_value(value: &impl serde::Serialize) -> EngineResult<String> {
Ok(serde_json::to_string(value)?)
}
fn ssh_mode_name(mode: &crate::model::SshMode) -> &'static str {
match mode {
crate::model::SshMode::Scp => "scp",
crate::model::SshMode::Rsync => "rsync",
}
}
fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&post.file_path);
if !abs_path.exists() {
@@ -1042,10 +1254,10 @@ mod tests {
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script::insert_script;
use crate::db::queries::template::insert_template;
use crate::engine::post::{create_post, publish_post};
use crate::engine::post::{archive_post, create_post, publish_post};
use crate::model::{
Media, Post, PostStatus, ProjectMetadata, Script, ScriptKind, ScriptStatus, Template,
TemplateKind, TemplateStatus,
Media, Post, PostStatus, ProjectMetadata, PublishingPreferences, Script, ScriptKind,
ScriptStatus, SshMode, Template, TemplateKind, TemplateStatus,
};
use crate::util::frontmatter::{
ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
@@ -1102,6 +1314,58 @@ mod tests {
assert!(report.orphans.is_empty(), "orphans: {:?}", report.orphans);
}
#[test]
fn archived_published_post_reports_only_bds2_frontmatter_drift() {
let (db, dir) = setup();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Archived Post",
Some("body text"),
vec!["rust".into()],
vec!["tech".into()],
Some("Alice"),
Some("en"),
None,
)
.unwrap();
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
let path = dir.path().join(&published.file_path);
let original_file = fs::read(&path).unwrap();
archive_post(db.conn(), dir.path(), &post.id).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(fs::read(path).unwrap(), original_file);
assert!(
report
.errors
.iter()
.all(|error| !error.starts_with("post ")),
"post diff errors: {:?}",
report.errors
);
assert!(report.orphans.is_empty());
let diff = report
.diffs
.iter()
.find(|diff| diff.entity_type == "post" && diff.entity_id == post.id)
.expect("archived database status must differ from untouched published file");
assert!(diff.fields.iter().any(|field| {
field.field_name == "status"
&& field.db_value == "archived"
&& field.file_value == "published"
}));
assert!(
diff.fields
.iter()
.all(|field| matches!(field.field_name.as_str(), "status" | "updatedAt")),
"unexpected archived-post drift: {:?}",
diff.fields
);
}
#[test]
fn detects_title_drift_in_post() {
let (db, dir) = setup();
@@ -1178,6 +1442,137 @@ mod tests {
}));
}
fn seed_portable_metadata(db: &Database, dir: &TempDir) {
crate::engine::meta::startup_sync(dir.path()).unwrap();
crate::engine::meta::sync_metadata_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
crate::engine::meta::set_categories(
db.conn(),
dir.path(),
"p1",
&["article".into(), "news".into()],
)
.unwrap();
let mut category_meta = std::collections::HashMap::new();
category_meta.insert(
"news".to_string(),
crate::model::metadata::CategorySettings {
title: Some("News".into()),
titles: std::collections::BTreeMap::new(),
render_in_lists: false,
show_title: true,
post_template_slug: Some("article".into()),
list_template_slug: None,
},
);
crate::engine::meta::set_category_meta(db.conn(), dir.path(), "p1", &category_meta)
.unwrap();
crate::engine::meta::set_publishing_preferences(
db.conn(),
dir.path(),
"p1",
&PublishingPreferences {
ssh_host: Some("example.net".into()),
ssh_user: Some("deploy".into()),
ssh_remote_path: Some("/srv/blog".into()),
ssh_mode: SshMode::Rsync,
},
)
.unwrap();
}
#[test]
fn detects_categories_category_meta_and_publishing_drift() {
let (db, dir) = setup();
seed_portable_metadata(&db, &dir);
crate::engine::meta::write_categories_json(dir.path(), &["article".into()]).unwrap();
crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap();
crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default())
.unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
for entity_type in ["categories", "category_meta", "publishing"] {
assert!(
report
.diffs
.iter()
.any(|item| item.entity_type == entity_type),
"missing {entity_type} diff: {:?}",
report.diffs
);
}
}
#[test]
fn repairs_portable_metadata_in_both_directions() {
let (db, dir) = setup();
seed_portable_metadata(&db, &dir);
crate::engine::meta::write_categories_json(dir.path(), &["filesystem".into()]).unwrap();
crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap();
crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default())
.unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let categories = report
.diffs
.iter()
.find(|item| item.entity_type == "categories")
.unwrap();
repair_metadata_diff_item(
db.conn(),
dir.path(),
"p1",
RepairDirection::DatabaseToFile,
categories,
)
.unwrap();
assert_eq!(
crate::engine::meta::read_categories_json(dir.path()).unwrap(),
vec!["article", "news"]
);
assert!(
crate::engine::meta::read_category_meta_json(dir.path())
.unwrap()
.contains_key("news")
);
assert_eq!(
crate::engine::meta::read_publishing_json(dir.path())
.unwrap()
.ssh_host
.as_deref(),
Some("example.net")
);
crate::engine::meta::write_categories_json(dir.path(), &["filesystem".into()]).unwrap();
crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap();
crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default())
.unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let categories = report
.diffs
.iter()
.find(|item| item.entity_type == "categories")
.unwrap();
repair_metadata_diff_item(
db.conn(),
dir.path(),
"p1",
RepairDirection::FileToDatabase,
categories,
)
.unwrap();
assert!(
compute_metadata_diff(db.conn(), dir.path(), "p1")
.unwrap()
.diffs
.iter()
.all(|item| !matches!(
item.entity_type.as_str(),
"categories" | "category_meta" | "publishing"
))
);
}
#[test]
fn ignores_python_scripts_during_orphan_scan() {
let (db, dir) = setup();
@@ -1198,12 +1593,38 @@ mod tests {
#[test]
fn repairs_one_post_diff_from_file_to_database() {
let (db, dir) = setup();
let database_target = create_post(
db.conn(),
dir.path(),
"p1",
"Database Target",
None,
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let filesystem_target = create_post(
db.conn(),
dir.path(),
"p1",
"Filesystem Target",
None,
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Database Title",
Some("body"),
Some("[database](/2024/01/01/database-target)"),
vec![],
vec![],
None,
@@ -1214,7 +1635,16 @@ mod tests {
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
let path = dir.path().join(&published.file_path);
let content = fs::read_to_string(&path).unwrap();
fs::write(&path, content.replace("Database Title", "Filesystem Title")).unwrap();
fs::write(
&path,
content
.replace("Database Title", "Filesystem Title")
.replace(
"[database](/2024/01/01/database-target)",
"[filesystem](/2024/01/01/filesystem-target)",
),
)
.unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let item = report
.diffs
@@ -1235,6 +1665,11 @@ mod tests {
qp::get_post_by_id(db.conn(), &post.id).unwrap().title,
"Filesystem Title"
);
let links =
crate::db::queries::post_link::list_links_by_source(db.conn(), &post.id).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].target_post_id, filesystem_target.id);
assert_ne!(links[0].target_post_id, database_target.id);
}
#[test]

View File

@@ -33,6 +33,7 @@ pub mod tag;
pub mod task;
pub mod template;
pub mod template_rebuild;
pub mod ui_state;
pub mod validate_media;
pub mod validate_site;
pub mod validate_translations;

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,7 @@ use crate::db::queries::post as qp;
use crate::db::queries::post_media as qpm;
use crate::engine::EngineResult;
use crate::model::{Media, Post, PostMedia};
use crate::util::sidecar::MediaSidecar;
use crate::util::{atomic_write_str, now_unix_ms};
use crate::util::now_unix_ms;
/// Link a media item to a post and sync the media sidecar.
pub fn link_media_to_post(
@@ -31,7 +30,7 @@ pub fn link_media_to_post(
created_at: now,
};
qpm::link_media(conn, &pm)?;
sync_sidecar_linked_post_ids(conn, data_dir, media_id)?;
crate::engine::media::sync_media_sidecar(conn, data_dir, media_id)?;
Ok(pm)
}
@@ -43,7 +42,7 @@ pub fn unlink_media_from_post(
media_id: &str,
) -> EngineResult<()> {
qpm::unlink_media(conn, post_id, media_id)?;
sync_sidecar_linked_post_ids(conn, data_dir, media_id)?;
crate::engine::media::sync_media_sidecar(conn, data_dir, media_id)?;
Ok(())
}
@@ -84,22 +83,6 @@ pub fn list_posts_for_media(conn: &Connection, media_id: &str) -> EngineResult<V
Ok(posts)
}
/// Rebuild the media sidecar file so that `linkedPostIds` reflects the current
/// set of posts linked to this media item.
fn sync_sidecar_linked_post_ids(
conn: &Connection,
data_dir: &Path,
media_id: &str,
) -> EngineResult<()> {
let links = qpm::list_post_media_by_media(conn, media_id)?;
let post_ids: Vec<String> = links.iter().map(|pm| pm.post_id.clone()).collect();
let media = qm::get_media_by_id(conn, media_id)?;
let sidecar = MediaSidecar::from_media(&media, &post_ids);
let abs_path = data_dir.join(&media.sidecar_path);
atomic_write_str(&abs_path, &sidecar.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -112,6 +95,7 @@ mod tests {
use crate::db::queries::post::{insert_post, make_test_post};
use crate::db::queries::post_media::list_post_media_by_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::util::sidecar::MediaSidecar;
use crate::util::sidecar::read_sidecar;
fn setup() -> (Database, TempDir) {

View File

@@ -243,11 +243,19 @@ fn render_preview_response(
let (content_type, xml) = match kind {
PreviewFeedKind::Rss => (
"application/rss+xml; charset=utf-8",
crate::engine::generation::build_rss_xml(&metadata, &localized_posts, &language),
crate::engine::generation::build_rss_xml(
&metadata,
localized_posts.iter().map(|source| &source.post),
&language,
),
),
PreviewFeedKind::Atom => (
"application/atom+xml; charset=utf-8",
crate::engine::generation::build_atom_xml(&metadata, &localized_posts, &language),
crate::engine::generation::build_atom_xml(
&metadata,
localized_posts.iter().map(|source| &source.post),
&language,
),
),
};
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], xml).into_response());
@@ -664,6 +672,13 @@ mod tests {
GUARD.get_or_init(|| Mutex::new(()))
}
fn preview_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.no_proxy()
.build()
.unwrap()
}
fn setup_preview_fixture() -> (tempfile::TempDir, Database) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
@@ -1112,7 +1127,7 @@ mod tests {
)
.unwrap();
let client = reqwest::blocking::Client::new();
let client = preview_client();
let mut body = None;
for _ in 0..20 {
if let Ok(response) = client
@@ -1229,7 +1244,7 @@ mod tests {
)
.unwrap();
let client = reqwest::blocking::Client::new();
let client = preview_client();
let response = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"
@@ -1269,7 +1284,7 @@ mod tests {
)
.unwrap();
let client = reqwest::blocking::Client::new();
let client = preview_client();
let media = client
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/ok.txt"))
.send()
@@ -1362,7 +1377,7 @@ mod tests {
"project-1".into(),
)
.unwrap();
let client = reqwest::blocking::Client::new();
let client = preview_client();
let preview_html = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1"
@@ -1427,7 +1442,7 @@ mod tests {
)
.unwrap();
let client = reqwest::blocking::Client::new();
let client = preview_client();
let response = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=amber&mode=dark"
@@ -1487,7 +1502,7 @@ mod tests {
)
.unwrap();
let client = reqwest::blocking::Client::new();
let client = preview_client();
let response = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=amber&mode=light"
@@ -1530,6 +1545,8 @@ mod tests {
(
"hidden".to_string(),
crate::model::CategorySettings {
title: None,
titles: std::collections::BTreeMap::new(),
render_in_lists: false,
show_title: true,
post_template_slug: None,
@@ -1539,6 +1556,8 @@ mod tests {
(
"featured".to_string(),
crate::model::CategorySettings {
title: None,
titles: std::collections::BTreeMap::new(),
render_in_lists: true,
show_title: false,
post_template_slug: None,

View File

@@ -56,7 +56,8 @@ pub fn create_project(
}
// Write default meta files
write_default_meta_files(&data_dir, name)?;
write_default_meta_files(&data_dir, &project)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?;
emit_project(&project, NotificationAction::Created);
@@ -122,6 +123,8 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
project.data_path = Some(resolved_path);
project.updated_at = now_unix_ms();
q::update_project(conn, &project)?;
crate::engine::meta::startup_sync(&folder_path)?;
crate::engine::meta::initialize_metadata_snapshots(conn, &folder_path, &project.id)?;
emit_project(&project, NotificationAction::Updated);
return Ok(project);
}
@@ -141,6 +144,8 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
updated_at: now,
};
q::insert_project(conn, &project)?;
crate::engine::meta::startup_sync(&folder_path)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, &folder_path, &project.id)?;
emit_project(&project, NotificationAction::Created);
Ok(project)
}
@@ -153,7 +158,13 @@ pub fn ensure_default_project(
default_data_dir: Option<&Path>,
) -> EngineResult<Project> {
match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) {
Ok(p) => Ok(p),
Ok(p) => {
if let Some(data_dir) = default_data_dir {
crate::engine::meta::startup_sync(data_dir)?;
crate::engine::meta::initialize_metadata_snapshots(conn, data_dir, &p.id)?;
}
Ok(p)
}
Err(diesel::result::Error::NotFound) => {
let now = now_unix_ms();
let project = Project {
@@ -173,7 +184,8 @@ pub fn ensure_default_project(
None => std::path::PathBuf::from("projects").join(DEFAULT_PROJECT_ID),
};
create_directory_structure(&data_dir)?;
write_default_meta_files(&data_dir, "My Blog")?;
write_default_meta_files(&data_dir, &project)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?;
emit_project(&project, NotificationAction::Created);
Ok(project)
}
@@ -237,7 +249,22 @@ pub fn delete_project(
.map_err(|_| EngineError::NotFound(format!("project {project_id}")))?;
let is_custom_path = project.data_path.is_some();
q::delete_project(conn, project_id)?;
conn.begin_savepoint()?;
let deleted = (|| {
q::delete_project(conn, project_id)?;
crate::db::queries::setting::delete_settings_by_prefix(
conn,
&format!("project:{project_id}:"),
)?;
Ok::<_, EngineError>(())
})();
match deleted {
Ok(()) => conn.release_savepoint()?,
Err(error) => {
let _ = conn.rollback_savepoint();
return Err(error);
}
}
crate::engine::embedding::EmbeddingService::forget_project(project_id);
// Clean up internal filesystem only (not custom external paths per spec)
@@ -275,12 +302,12 @@ fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
Ok(())
}
fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult<()> {
fn write_default_meta_files(data_dir: &Path, project: &Project) -> EngineResult<()> {
let meta_dir = data_dir.join("meta");
// project.json
let project_meta = ProjectMetadata {
name: project_name.to_string(),
name: project.name.clone(),
description: None,
public_url: None,
main_language: None,
@@ -305,14 +332,19 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult
let json = serde_json::to_string_pretty(&empty_map)?;
atomic_write_str(&meta_dir.join("category-meta.json"), &json)?;
// publishing.json — empty object
atomic_write_str(&meta_dir.join("publishing.json"), "{}")?;
// publishing.json
crate::engine::meta::write_publishing_json(data_dir, &Default::default())?;
// tags.json — empty array
atomic_write_str(&meta_dir.join("tags.json"), "[]")?;
// menu.opml — default empty menu per menu.allium HomeAlwaysPresent
let default_opml = crate::engine::menu::default_menu_opml();
let timestamp = if project.updated_at > 0 {
project.updated_at
} else {
project.created_at
};
let default_opml = crate::engine::menu::default_menu_opml(&project.name, timestamp);
atomic_write_str(&meta_dir.join("menu.opml"), &default_opml)?;
copy_bundled_site_assets(data_dir)?;
@@ -492,12 +524,26 @@ mod tests {
updated_at: now,
};
crate::db::queries::project::insert_project(db.conn(), &project).unwrap();
crate::db::queries::setting::set_setting_value(
db.conn(),
&format!("project:{}:categories", project.id),
r#"{"categories":[]}"#,
now,
)
.unwrap();
// Create the internal directory structure under the temp dir
let internal_dir = dir.path().join("projects").join(&project.id);
create_directory_structure(&internal_dir).unwrap();
assert!(internal_dir.join("posts").is_dir());
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty());
assert!(
crate::db::queries::setting::get_setting_by_key(
db.conn(),
&format!("project:{}:categories", project.id)
)
.is_err()
);
assert!(
!internal_dir.exists(),
"internal directory should be cleaned up"

View File

@@ -1,7 +1,10 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
@@ -124,6 +127,26 @@ pub fn upload_site(
private_cache_dir: &Path,
preferences: &PublishingPreferences,
mut on_progress: impl FnMut(usize, usize, UploadTargetKind),
) -> EngineResult<PublishJob> {
let cancelled = AtomicBool::new(false);
upload_site_cancellable(
data_dir,
private_cache_dir,
preferences,
&cancelled,
move |current, total, kind| {
on_progress(current, total, kind);
true
},
)
}
pub fn upload_site_cancellable(
data_dir: &Path,
private_cache_dir: &Path,
preferences: &PublishingPreferences,
cancel_flag: &AtomicBool,
mut on_progress: impl FnMut(usize, usize, UploadTargetKind) -> bool,
) -> EngineResult<PublishJob> {
if std::env::var_os("SSH_AUTH_SOCK").is_none() {
return Err(EngineError::Validation(
@@ -134,7 +157,7 @@ pub fn upload_site(
data_dir,
private_cache_dir,
preferences,
&mut |program, args| run_command(program, args),
&mut |program, args| run_command_cancellable(program, args, cancel_flag),
&mut on_progress,
)
}
@@ -144,7 +167,7 @@ fn upload_site_with_runner(
private_cache_dir: &Path,
preferences: &PublishingPreferences,
runner: &mut CommandRunner<'_>,
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind),
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind) -> bool,
) -> EngineResult<PublishJob> {
let credentials = Credentials::from_preferences(preferences)?;
let targets = build_upload_targets(data_dir, &credentials);
@@ -162,7 +185,9 @@ fn upload_site_with_runner(
let cache_path = private_cache_dir.join("publishing-scp-mtimes.json");
let mut cache = read_cache(&cache_path);
for (index, target) in targets.iter().enumerate() {
on_progress(index + 1, targets.len(), target.kind);
if !on_progress(index + 1, targets.len(), target.kind) {
return Err(EngineError::Cancelled);
}
let result = match credentials.mode {
SshMode::Rsync => upload_rsync(target, &credentials, runner),
SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner),
@@ -328,21 +353,49 @@ fn write_cache(path: &Path, cache: &ScpMtimeCache) -> EngineResult<()> {
Ok(())
}
fn run_command(program: &str, args: &[String]) -> Result<(), String> {
let output = Command::new(program)
fn run_command_cancellable(
program: &str,
args: &[String],
cancel_flag: &AtomicBool,
) -> Result<(), String> {
let mut child = Command::new(program)
.args(args)
.output()
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("failed to start {program}: {error}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{program} exited with {}", output.status)
} else {
stderr
})
let stderr = child.stderr.take();
let stderr_reader = std::thread::spawn(move || {
let mut output = String::new();
if let Some(mut stderr) = stderr {
let _ = stderr.read_to_string(&mut output);
}
output
});
let status = loop {
if cancel_flag.load(Ordering::Acquire) {
let _ = child.kill();
let _ = child.wait();
let _ = stderr_reader.join();
return Err("operation cancelled".into());
}
if let Some(status) = child
.try_wait()
.map_err(|error| format!("failed to wait for {program}: {error}"))?
{
break status;
}
std::thread::sleep(Duration::from_millis(50));
};
let stderr = stderr_reader.join().unwrap_or_default().trim().to_string();
if status.success() {
return Ok(());
}
Err(if stderr.is_empty() {
format!("{program} exited with {status}")
} else {
stderr
})
}
#[cfg(test)]
@@ -376,7 +429,7 @@ mod tests {
commands.push((program.to_owned(), args.to_vec()));
Ok(())
},
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap();
@@ -397,6 +450,28 @@ mod tests {
);
}
#[test]
fn cancellation_stops_before_starting_the_next_target() {
let dir = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("html")).unwrap();
let mut commands = 0;
let result = upload_site_with_runner(
dir.path(),
cache.path(),
&preferences(SshMode::Rsync),
&mut |_, _| {
commands += 1;
Ok(())
},
&mut |_, _, _| false,
);
assert!(matches!(result, Err(EngineError::Cancelled)));
assert_eq!(commands, 0);
}
#[test]
fn scp_excludes_sidecars_and_skips_unchanged_files() {
let dir = TempDir::new().unwrap();
@@ -415,7 +490,7 @@ mod tests {
first.push((program.to_owned(), args.to_vec()));
Ok(())
},
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap();
assert_eq!(
@@ -437,7 +512,7 @@ mod tests {
second.push(program.to_owned());
Ok(())
},
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap();
assert!(second.is_empty());
@@ -454,7 +529,7 @@ mod tests {
cache.path(),
&preferences(SshMode::Rsync),
&mut |_, _| Err("network down".into()),
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap_err();
assert!(error.to_string().contains("network down"));

View File

@@ -10,6 +10,7 @@ use crate::engine::media;
use crate::engine::post;
use crate::engine::script_rebuild;
use crate::engine::template_rebuild;
use crate::i18n::{UiLocale, translate, translate_with};
/// Report from a full rebuild operation.
#[derive(Debug, Default)]
@@ -26,6 +27,8 @@ pub struct FullRebuildReport {
pub templates_updated: usize,
pub scripts_created: usize,
pub scripts_updated: usize,
pub thumbnails_generated: usize,
pub thumbnail_media_failed: usize,
pub errors: Vec<String>,
}
@@ -42,12 +45,33 @@ pub fn rebuild_incremental(
data_dir: &Path,
project_id: &str,
) -> EngineResult<IncrementalRebuildReport> {
let report = super::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?;
rebuild_incremental_with_progress(conn, data_dir, project_id, |_| true)
}
pub fn rebuild_incremental_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_progress: impl FnMut(f32) -> bool,
) -> EngineResult<IncrementalRebuildReport> {
let report = super::metadata_diff::compute_metadata_diff_with_progress(
conn,
data_dir,
project_id,
|current, total| on_progress(0.5 * current as f32 / total.max(1) as f32),
)?;
if !report.errors.is_empty() {
return Err(crate::engine::EngineError::Validation(
report.errors.join("; "),
));
}
let orphans = report
.orphans
.iter()
.filter(|orphan| orphan.reason == "file_without_db_entry")
.collect::<Vec<_>>();
let total = report.diffs.len() + orphans.len();
let mut current = 0;
for item in &report.diffs {
super::metadata_diff::repair_metadata_diff_item(
conn,
@@ -56,26 +80,116 @@ pub fn rebuild_incremental(
super::metadata_diff::RepairDirection::FileToDatabase,
item,
)?;
current += 1;
if !on_progress(0.5 + 0.5 * current as f32 / total.max(1) as f32) {
return Err(crate::engine::EngineError::Cancelled);
}
}
let mut result = IncrementalRebuildReport {
differences_applied: report.diffs.len(),
..Default::default()
};
for orphan in report
.orphans
.iter()
.filter(|orphan| orphan.reason == "file_without_db_entry")
{
for orphan in orphans {
match super::metadata_diff::import_orphan_file(conn, data_dir, project_id, orphan) {
Ok(()) => result.orphans_imported += 1,
Err(_) => result.orphans_failed += 1,
}
current += 1;
if !on_progress(0.5 + 0.5 * current as f32 / total.max(1) as f32) {
return Err(crate::engine::EngineError::Cancelled);
}
}
if total == 0 && !on_progress(1.0) {
return Err(crate::engine::EngineError::Cancelled);
}
Ok(result)
}
/// Progress callback: (percent 0.0..1.0, phase description).
pub type ProgressFn = Arc<dyn Fn(f32, &str) + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebuildProgress {
LoadingProjectMetadata,
ScanningPosts,
PostItem {
current: usize,
total: usize,
name: String,
},
ScanningMedia,
MediaItem {
current: usize,
total: usize,
name: String,
},
RebuildingTemplates,
TemplateItem {
current: usize,
total: usize,
name: String,
},
RebuildingScripts,
ScriptItem {
current: usize,
total: usize,
name: String,
},
ImportingTags,
RebuildingThumbnails,
RefreshingSemanticIndex,
Complete,
}
impl RebuildProgress {
pub fn localized(&self, locale: UiLocale) -> String {
match self {
Self::LoadingProjectMetadata => {
translate(locale, "engine.progress.loadingProjectMetadata")
}
Self::ScanningPosts => translate(locale, "engine.progress.scanningPosts"),
Self::PostItem {
current,
total,
name,
} => localize_item(locale, "engine.progress.postItem", *current, *total, name),
Self::ScanningMedia => translate(locale, "engine.progress.scanningMedia"),
Self::MediaItem {
current,
total,
name,
} => localize_item(locale, "engine.progress.mediaItem", *current, *total, name),
Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"),
Self::TemplateItem {
current,
total,
name,
} => localize_item(locale, "engine.checkingItem", *current, *total, name),
Self::RebuildingScripts => translate(locale, "engine.progress.rebuildingScripts"),
Self::ScriptItem {
current,
total,
name,
} => localize_item(locale, "engine.checkingItem", *current, *total, name),
Self::ImportingTags => translate(locale, "engine.progress.importingTags"),
Self::RebuildingThumbnails => translate(locale, "engine.progress.rebuildingThumbnails"),
Self::RefreshingSemanticIndex => {
translate(locale, "engine.progress.refreshingSemanticIndex")
}
Self::Complete => translate(locale, "engine.progress.rebuildComplete"),
}
}
}
fn localize_item(locale: UiLocale, key: &str, current: usize, total: usize, name: &str) -> String {
let current = current.to_string();
let total = total.to_string();
translate_with(
locale,
key,
&[("current", &current), ("total", &total), ("name", name)],
)
}
/// Progress callback: (percent 0.0..1.0, semantic progress event).
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) -> bool + Send + Sync>;
/// Orchestrate a full rebuild from filesystem into the database.
///
@@ -116,22 +230,26 @@ fn rebuild_from_filesystem_inner(
on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> {
let mut report = FullRebuildReport::default();
let progress = |pct: f32, msg: &str| {
if let Some(ref f) = on_progress {
f(pct, msg);
let progress = |pct: f32, event: RebuildProgress| -> EngineResult<()> {
if let Some(ref f) = on_progress
&& !f(pct, &event)
{
return Err(crate::engine::EngineError::Cancelled);
}
Ok(())
};
// Phase weights: posts 0.0..0.35, media 0.35..0.70, templates 0.70..0.85, scripts 0.85..1.0
// 1. Load portable project metadata and clear all reconstructible rows.
progress(0.0, "Loading project metadata...");
progress(0.0, RebuildProgress::LoadingProjectMetadata)?;
fts::ensure_fts_tables(conn)?;
crate::engine::meta::sync_project_from_file(conn, data_dir, project_id)?;
crate::engine::meta::startup_sync(data_dir)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?;
clear_project_rows(conn, project_id)?;
// 2. Rebuild posts (0.00 .. 0.35)
progress(0.01, "Scanning posts...");
progress(0.01, RebuildProgress::ScanningPosts)?;
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
@@ -141,8 +259,14 @@ fn rebuild_from_filesystem_inner(
1.0
};
let global_pct = 0.01 + phase_pct * 0.34;
let msg = format!("Posts: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg);
cb(
global_pct,
&RebuildProgress::PostItem {
current,
total,
name: name.to_string(),
},
)
});
f
});
@@ -159,7 +283,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(post_report.errors);
// 3. Rebuild media (0.35 .. 0.70)
progress(0.35, "Scanning media...");
progress(0.35, RebuildProgress::ScanningMedia)?;
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
@@ -169,8 +293,14 @@ fn rebuild_from_filesystem_inner(
1.0
};
let global_pct = 0.35 + phase_pct * 0.35;
let msg = format!("Media: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg);
cb(
global_pct,
&RebuildProgress::MediaItem {
current,
total,
name: name.to_string(),
},
)
});
f
});
@@ -187,26 +317,87 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(media_report.errors);
// 4. Rebuild templates (0.70 .. 0.85)
progress(0.70, "Rebuilding templates...");
let tpl_report =
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?;
progress(0.70, RebuildProgress::RebuildingTemplates)?;
let template_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.70 + current as f32 / total.max(1) as f32 * 0.15,
&RebuildProgress::TemplateItem {
current,
total,
name: name.to_string(),
},
)
}) as template_rebuild::ItemProgressFn
});
let tpl_report = template_rebuild::rebuild_templates_from_filesystem_with_progress(
conn,
data_dir,
project_id,
template_progress,
)?;
report.templates_created = tpl_report.created;
report.templates_updated = tpl_report.updated;
report.errors.extend(tpl_report.errors);
// 5. Rebuild scripts (0.85 .. 0.95)
progress(0.85, "Rebuilding scripts...");
let script_report =
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
progress(0.85, RebuildProgress::RebuildingScripts)?;
let script_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.85 + current as f32 / total.max(1) as f32 * 0.10,
&RebuildProgress::ScriptItem {
current,
total,
name: name.to_string(),
},
)
}) as script_rebuild::ItemProgressFn
});
let script_report = script_rebuild::rebuild_scripts_from_filesystem_with_progress(
conn,
data_dir,
project_id,
script_progress,
)?;
report.scripts_created = script_report.created;
report.scripts_updated = script_report.updated;
report.errors.extend(script_report.errors);
// 6. Restore relationships and tags (0.95 .. 1.0)
progress(0.95, "Importing tags...");
progress(0.95, RebuildProgress::ImportingTags)?;
super::tag::import_tags_from_file(conn, data_dir, project_id)?;
super::tag::sync_tags_from_posts(conn, project_id)?;
post::rebuild_all_links(conn, data_dir, project_id)?;
let link_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.95 + current as f32 / total.max(1) as f32 * 0.01,
&RebuildProgress::PostItem {
current,
total,
name: name.to_string(),
},
)
}) as post::ItemProgressFn
});
post::rebuild_all_links_with_progress(conn, data_dir, project_id, link_progress)?;
let media_link_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.96 + current as f32 / total.max(1) as f32 * 0.01,
&RebuildProgress::MediaItem {
current,
total,
name: name.to_string(),
},
)
}) as media::ItemProgressFn
});
media::rebuild_media_links_with_progress(conn, data_dir, project_id, media_link_progress)?;
if !report.errors.is_empty() {
return Err(crate::engine::EngineError::Validation(format!(
@@ -215,11 +406,39 @@ fn rebuild_from_filesystem_inner(
)));
}
progress(0.98, "Refreshing semantic index...");
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.index_unindexed(project_id)?;
progress(0.97, RebuildProgress::RebuildingThumbnails)?;
let thumbnail_report = media::regenerate_missing_thumbnails_with_progress(
conn,
data_dir,
project_id,
|current, total, name| {
on_progress.as_ref().is_none_or(|callback| {
callback(
0.97 + current as f32 / total.max(1) as f32 * 0.02,
&RebuildProgress::MediaItem {
current,
total,
name: name.to_string(),
},
)
})
},
)?;
report.thumbnails_generated = thumbnail_report.thumbnails_generated;
report.thumbnail_media_failed = thumbnail_report.media_failed;
progress(1.0, "Rebuild complete");
progress(0.99, RebuildProgress::RefreshingSemanticIndex)?;
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.index_unindexed_with_progress(project_id, |current, total| {
on_progress.as_ref().is_none_or(|callback| {
callback(
0.99 + current as f32 / total.max(1) as f32 * 0.01,
&RebuildProgress::RefreshingSemanticIndex,
)
})
})?;
progress(1.0, RebuildProgress::Complete)?;
Ok(report)
}
@@ -302,6 +521,27 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn progress_events_use_the_selected_ui_locale() {
assert_eq!(
RebuildProgress::LoadingProjectMetadata.localized(UiLocale::It),
"Caricamento dei metadati del progetto…"
);
assert_eq!(
RebuildProgress::PostItem {
current: 2,
total: 4,
name: "Bonjour".into(),
}
.localized(UiLocale::Fr),
"Articles : 2/4 — Bonjour"
);
assert_eq!(
RebuildProgress::Complete.localized(UiLocale::De),
"Neuaufbau abgeschlossen"
);
}
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
@@ -349,6 +589,36 @@ mod tests {
assert!(report.errors.is_empty());
}
#[test]
fn cancellation_rolls_back_the_full_rebuild_savepoint() {
let (db, dir) = setup();
let post = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"Preserved",
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let result = rebuild_from_filesystem_with_progress(
db.conn(),
dir.path(),
"p1",
Some(Arc::new(|_, event| {
!matches!(event, RebuildProgress::ScanningPosts)
})),
);
assert!(matches!(result, Err(crate::engine::EngineError::Cancelled)));
assert!(qp::get_post_by_id(db.conn(), &post.id).is_ok());
}
#[test]
fn rebuild_creates_posts_and_media() {
let (db, dir) = setup();
@@ -628,6 +898,18 @@ function render() end
let project = crate::db::queries::project::get_project_by_id(db.conn(), "p1").unwrap();
assert_eq!(project.name, "Rebuilt Project");
assert!(project.description.is_none());
assert_eq!(
crate::engine::meta::read_categories_snapshot(db.conn(), "p1")
.unwrap()
.unwrap(),
vec!["article", "aside", "page", "picture"]
);
assert_eq!(
crate::engine::meta::read_publishing_snapshot(db.conn(), "p1")
.unwrap()
.unwrap(),
crate::model::PublishingPreferences::default()
);
let links =
crate::db::queries::post_media::list_post_media_by_media(db.conn(), "test-media-1")

View File

@@ -8,6 +8,7 @@ use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::model::{DomainEntity, NotificationAction, Script, ScriptKind, ScriptStatus};
use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
use crate::util::paths::script_file_path;
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// Create a new draft script. Content stored in DB, no file written.
@@ -64,6 +65,7 @@ pub fn create_script(
)]
pub fn update_script(
conn: &Connection,
data_dir: &Path,
script_id: &str,
project_id: &str,
title: Option<&str>,
@@ -74,23 +76,37 @@ pub fn update_script(
content: Option<&str>,
) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?;
if script.project_id != project_id {
return Err(EngineError::NotFound(format!("script {script_id}")));
}
let original_slug = script.slug.clone();
let original_file_path = script.file_path.clone();
let was_published = script.status == ScriptStatus::Published;
let effective_content = script.content.clone().unwrap_or_else(|| {
if original_file_path.is_empty() {
String::new()
} else {
read_published_script_body(data_dir, &original_file_path)
}
});
let content_changed = content.is_some_and(|new_content| new_content != effective_content);
// Slug uniqueness
if let Some(new_slug) = slug
&& new_slug != script.slug
&& qs::get_script_by_slug(conn, project_id, new_slug).is_ok()
{
return Err(EngineError::Conflict(format!(
"script slug '{new_slug}' already exists"
)));
if let Some(requested_slug) = slug {
let slug = slugify(requested_slug);
let slug = if slug.is_empty() {
"script".to_string()
} else {
slug
};
script.slug = ensure_unique(&slug, |candidate| {
qs::get_script_by_slug(conn, project_id, candidate)
.is_ok_and(|existing| existing.id != script_id)
});
}
if let Some(t) = title {
script.title = t.to_string();
}
if let Some(s) = slug {
script.slug = s.to_string();
}
if let Some(k) = kind {
script.kind = k;
}
@@ -104,30 +120,61 @@ pub fn update_script(
script.content = Some(c.to_string());
}
// If published, transition back to draft on edit
if script.status == ScriptStatus::Published {
let slug_changed = script.slug != original_slug;
let published_body = if !original_file_path.is_empty()
&& (slug_changed || (was_published && !content_changed))
{
Some(effective_content)
} else {
None
};
if slug_changed && published_body.is_some() {
script.file_path = script_file_path(&script.slug);
}
if was_published && content_changed {
script.status = ScriptStatus::Draft;
}
script.version += 1;
script.updated_at = now_unix_ms();
qs::update_script(conn, &script)?;
if let Some(body) = published_body {
rewrite_renamed_script_file(data_dir, &original_file_path, &script, &body)?;
}
emit_script(&script, NotificationAction::Updated);
Ok(script)
}
/// Save script content (editor save). Updates DB, bumps version.
pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineResult<Script> {
pub fn save_script(
conn: &Connection,
data_dir: &Path,
script_id: &str,
content: &str,
) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?;
let was_published = script.status == ScriptStatus::Published;
let effective_content = script.content.clone().unwrap_or_else(|| {
if script.file_path.is_empty() {
String::new()
} else {
read_published_script_body(data_dir, &script.file_path)
}
});
let content_changed = content != effective_content;
script.content = Some(content.to_string());
script.version += 1;
script.updated_at = now_unix_ms();
if script.status == ScriptStatus::Published {
if was_published && content_changed {
script.status = ScriptStatus::Draft;
}
qs::update_script(conn, &script)?;
if was_published && !content_changed && !script.file_path.is_empty() {
rewrite_renamed_script_file(data_dir, &script.file_path, &script, &effective_content)?;
}
emit_script(&script, NotificationAction::Updated);
Ok(script)
}
@@ -276,6 +323,46 @@ fn script_kind_to_frontmatter(kind: &ScriptKind) -> String {
}
}
fn read_published_script_body(data_dir: &Path, file_path: &str) -> String {
fs::read_to_string(data_dir.join(file_path))
.ok()
.and_then(|file| crate::util::frontmatter::read_script_file(&file).ok())
.map(|(_, body)| body)
.unwrap_or_default()
}
fn rewrite_renamed_script_file(
data_dir: &Path,
original_file_path: &str,
script: &Script,
body: &str,
) -> EngineResult<()> {
let frontmatter = ScriptFrontmatter {
id: script.id.clone(),
project_id: Some(script.project_id.clone()),
slug: script.slug.clone(),
title: script.title.clone(),
kind: script_kind_to_frontmatter(&script.kind),
entrypoint: script.entrypoint.clone(),
enabled: script.enabled,
version: script.version,
created_at: script.created_at,
updated_at: script.updated_at,
};
atomic_write_str(
&data_dir.join(&script.file_path),
&write_script_file(&frontmatter, body),
)?;
if original_file_path != script.file_path {
match fs::remove_file(data_dir.join(original_file_path)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -339,10 +426,11 @@ mod tests {
#[test]
fn update_script_bumps_version() {
let (db, _dir) = setup();
let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&s.id,
"p1",
Some("New"),
@@ -358,11 +446,189 @@ mod tests {
assert_eq!(updated.version, 2);
}
#[test]
fn update_script_slug_normalizes_and_uniquifies() {
let (db, dir) = setup();
create_script(db.conn(), "p1", "Alpha", ScriptKind::Utility, "", None).unwrap();
let script = create_script(db.conn(), "p1", "Beta", ScriptKind::Utility, "", None).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
Some(" Alpha! "),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(updated.slug, "alpha-2");
assert!(updated.file_path.is_empty());
let unchanged = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
Some(" Alpha 2 "),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(unchanged.slug, "alpha-2");
}
#[test]
fn update_published_script_slug_rewrites_and_renames_file() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let old_path = dir.path().join(&published.file_path);
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
Some(" Renamed Script! "),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(updated.slug, "renamed-script");
assert_eq!(updated.file_path, "scripts/renamed-script.lua");
assert!(!old_path.exists());
let new_file = fs::read_to_string(dir.path().join(&updated.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&new_file).unwrap();
assert_eq!(frontmatter.slug, "renamed-script");
assert_eq!(body, "function main()\nend");
assert_eq!(updated.status, ScriptStatus::Published);
}
#[test]
fn published_script_metadata_update_stays_published_and_rewrites_frontmatter() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
Some("Renamed title"),
None,
Some(ScriptKind::Transform),
Some("transform"),
Some(false),
None,
)
.unwrap();
assert_eq!(updated.status, ScriptStatus::Published);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&file).unwrap();
assert_eq!(frontmatter.title, "Renamed title");
assert_eq!(frontmatter.kind, "transform");
assert_eq!(frontmatter.entrypoint, "transform");
assert!(!frontmatter.enabled);
assert_eq!(frontmatter.version, updated.version);
assert_eq!(body, "function main()\nend");
}
#[test]
fn published_script_content_change_reopens_draft_and_preserves_published_file() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\n return 'old'\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
None,
None,
None,
None,
Some("function main()\n return 'new'\nend"),
)
.unwrap();
assert_eq!(updated.status, ScriptStatus::Draft);
assert_eq!(
updated.content.as_deref(),
Some("function main()\n return 'new'\nend")
);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (_, body) = crate::util::frontmatter::read_script_file(&file).unwrap();
assert_eq!(body, "function main()\n return 'old'\nend");
}
#[test]
fn identical_published_script_save_stays_published() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let saved = save_script(db.conn(), dir.path(), &script.id, "function main()\nend").unwrap();
assert_eq!(saved.status, ScriptStatus::Published);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&file).unwrap();
assert_eq!(frontmatter.version, saved.version);
assert_eq!(body, "function main()\nend");
}
#[test]
fn save_script_updates_content() {
let (db, _dir) = setup();
let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
let saved = save_script(db.conn(), &s.id, "new body").unwrap();
let saved = save_script(db.conn(), dir.path(), &s.id, "new body").unwrap();
assert_eq!(saved.content, Some("new body".to_string()));
assert_eq!(saved.version, 2);
}

View File

@@ -17,6 +17,8 @@ pub struct ScriptRebuildReport {
pub errors: Vec<String>,
}
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Rebuild scripts from the filesystem into the database.
///
/// Walks the `scripts/` directory for `*.lua` files, parses each
@@ -26,6 +28,15 @@ pub fn rebuild_scripts_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ScriptRebuildReport> {
rebuild_scripts_from_filesystem_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_scripts_from_filesystem_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<ScriptRebuildReport> {
let mut report = ScriptRebuildReport::default();
let scripts_dir = data_dir.join("scripts");
@@ -34,17 +45,23 @@ pub fn rebuild_scripts_from_filesystem(
return Ok(report);
}
for entry in WalkDir::new(&scripts_dir)
let files = WalkDir::new(&scripts_dir)
.into_iter()
.filter_map(|e| e.ok())
{
.filter(|entry| entry.path().is_file())
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("lua"))
.collect::<Vec<_>>();
for (index, entry) in files.iter().enumerate() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("lua") {
continue;
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("?");
if let Some(ref callback) = on_item
&& !callback(index + 1, files.len(), name)
{
return Err(EngineError::Cancelled);
}
match rebuild_single_script(conn, data_dir, project_id, path) {
@@ -185,6 +202,47 @@ end
assert_eq!(script.file_path, "scripts/my-macro.lua");
}
#[test]
fn rebuild_defaults_missing_entrypoint_from_script_kind() {
let (db, dir) = setup();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
for (id, kind, expected_entrypoint) in [
("legacy-macro", "macro", "render"),
("legacy-utility", "utility", "main"),
("legacy-transform", "transform", "main"),
] {
let content = format!(
"---\nid: \"{id}\"\nslug: \"{id}\"\ntitle: \"{id}\"\nkind: \"{kind}\"\nenabled: true\nversion: 1\ncreatedAt: \"2024-01-01T00:00:00.000Z\"\nupdatedAt: \"2024-01-01T00:00:00.000Z\"\n---\nfunction {expected_entrypoint}() end\n"
);
fs::write(scripts_dir.join(format!("{id}.lua")), content).unwrap();
}
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 3);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert_eq!(
qs::get_script_by_id(db.conn(), "legacy-macro")
.unwrap()
.entrypoint,
"render"
);
assert_eq!(
qs::get_script_by_id(db.conn(), "legacy-utility")
.unwrap()
.entrypoint,
"main"
);
assert_eq!(
qs::get_script_by_id(db.conn(), "legacy-transform")
.unwrap()
.entrypoint,
"main"
);
}
#[test]
fn rebuild_updates_existing() {
let (db, dir) = setup();

View File

@@ -1,5 +1,6 @@
//! Full-text search reindexing engine functions.
use std::fs;
use std::path::Path;
use crate::db::DbConnection as Connection;
@@ -9,7 +10,8 @@ use crate::db::queries::{
media as media_q, media_translation, post as post_q, post_translation, project as project_q,
setting,
};
use crate::engine::{EngineResult, domain_events};
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::util::frontmatter::{read_post_file, read_translation_file};
use crate::util::now_unix_ms;
const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required";
@@ -65,7 +67,7 @@ pub struct ReindexReport {
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Repair a missing or previously deployed FTS schema and report whether its
/// derived content still needs to be rebuilt.
@@ -201,8 +203,9 @@ fn index_project(
current: &mut usize,
on_item: Option<&ItemProgressFn>,
) -> EngineResult<ReindexReport> {
let main_language = data_path
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
let data_dir = data_path.map(Path::new);
let main_language = data_dir
.and_then(|path| crate::engine::meta::read_project_json(path).ok())
.and_then(|metadata| metadata.main_language)
.unwrap_or_else(|| "en".to_string());
let mut report = ReindexReport {
@@ -212,25 +215,38 @@ fn index_project(
for post in post_q::list_posts_by_project(conn, project_id)? {
*current += 1;
if let Some(callback) = on_item {
callback(*current, total, &post.title);
if let Some(callback) = on_item
&& !callback(*current, total, &post.title)
{
return Err(EngineError::Cancelled);
}
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let translation_data = translations
.iter()
.map(|translation| fts::PostTranslationFts {
title: translation.title.clone(),
excerpt: translation.excerpt.clone(),
content: translation.content.clone(),
language: translation.language.clone(),
.map(|translation| {
Ok(fts::PostTranslationFts {
title: translation.title.clone(),
excerpt: translation.excerpt.clone(),
content: data_dir
.map(|dir| resolve_translation_fts_content(dir, translation))
.transpose()?
.flatten()
.or_else(|| translation.content.clone()),
language: translation.language.clone(),
})
})
.collect::<Vec<_>>();
.collect::<EngineResult<Vec<_>>>()?;
let content = data_dir
.map(|dir| resolve_post_fts_content(dir, &post))
.transpose()?
.flatten()
.or_else(|| post.content.clone());
fts::index_post(
conn,
&post.id,
&post.title,
post.excerpt.as_deref(),
post.content.as_deref(),
content.as_deref(),
&post.tags,
&post.categories,
&translation_data,
@@ -241,8 +257,10 @@ fn index_project(
for media in media_q::list_media_by_project(conn, project_id)? {
*current += 1;
if let Some(callback) = on_item {
callback(*current, total, &media.original_name);
if let Some(callback) = on_item
&& !callback(*current, total, &media.original_name)
{
return Err(EngineError::Cancelled);
}
let translations = media_translation::list_media_translations_by_media(conn, &media.id)?;
let translation_data = translations
@@ -271,11 +289,41 @@ fn index_project(
Ok(report)
}
fn resolve_post_fts_content(
data_dir: &Path,
post: &crate::model::Post,
) -> EngineResult<Option<String>> {
if post.content.is_some() {
return Ok(post.content.clone());
}
if post.file_path.is_empty() {
return Ok(None);
}
let raw = fs::read_to_string(data_dir.join(&post.file_path))?;
let (_fm, body) = read_post_file(&raw).map_err(EngineError::Parse)?;
Ok(Some(body))
}
fn resolve_translation_fts_content(
data_dir: &Path,
translation: &crate::model::PostTranslation,
) -> EngineResult<Option<String>> {
if translation.content.is_some() {
return Ok(translation.content.clone());
}
if translation.file_path.is_empty() {
return Ok(None);
}
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?;
let (_fm, body) = read_translation_file(&raw).map_err(EngineError::Parse)?;
Ok(Some(body))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables;
use crate::db::fts::{self, ensure_fts_tables};
use crate::engine;
fn setup() -> (Database, String) {
@@ -330,6 +378,80 @@ mod tests {
assert_eq!(results.len(), 1);
}
#[test]
fn cancellation_rolls_back_shared_index_rebuild() {
let (db, project_id) = setup();
let dir = tempfile::tempdir().unwrap();
engine::post::create_post(
db.conn(),
dir.path(),
&project_id,
"Cancel Me",
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let result = rebuild_search_index(db.conn(), Some(Box::new(|_, _, _| false)));
assert!(matches!(result, Err(EngineError::Cancelled)));
}
#[test]
fn rebuild_indexes_published_post_and_translation_bodies_from_files() {
let db = Database::open_in_memory().unwrap();
let _ = db.migrate();
ensure_fts_tables(db.conn()).unwrap();
let dir = tempfile::tempdir().unwrap();
let project = engine::project::create_project(
db.conn(),
"Published Project",
Some(dir.path().to_str().unwrap()),
)
.unwrap();
let post = engine::post::create_post(
db.conn(),
dir.path(),
&project.id,
"Published Rebuild",
Some("distinctive platypusnova body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
engine::post::upsert_translation(
db.conn(),
dir.path(),
&post.id,
"de",
"Veröffentlichter Neuaufbau",
None,
Some("markantes schmetterlingskomet wort"),
)
.unwrap();
engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
fts::remove_post_from_index(db.conn(), &post.id).unwrap();
let report = reindex_project(db.conn(), &project.id, None).unwrap();
assert_eq!(report.posts_indexed, 1);
assert_eq!(
fts::search_posts(db.conn(), "platypusnova", "en").unwrap(),
vec![post.id.clone()]
);
assert_eq!(
fts::search_posts(db.conn(), "schmetterlingskomet", "de").unwrap(),
vec![post.id]
);
}
#[test]
fn shared_rebuild_indexes_every_project() {
let (db, first_project_id) = setup();

View File

@@ -6,6 +6,7 @@ use crate::engine::{EngineError, EngineResult, domain_events};
use crate::util::now_unix_ms;
pub const UI_LANGUAGE_KEY: &str = "ui.language";
pub const AIRPLANE_MODE_KEY: &str = "ai.airplane_mode_enabled";
pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key";
pub const AIRPLANE_API_KEY: &str = "ai.endpoint.airplane.api_key";
const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured";
@@ -81,6 +82,7 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
);
for value in setting::list_all_settings(conn)? {
if !value.key.starts_with("app.")
&& !value.key.starts_with("project:")
&& value.key != ONLINE_API_KEY
&& value.key != ONLINE_API_KEY_CONFIGURED
&& value.key != AIRPLANE_API_KEY
@@ -112,6 +114,18 @@ pub fn ui_language(conn: &Connection) -> EngineResult<Option<String>> {
get(conn, UI_LANGUAGE_KEY)
}
pub fn airplane_mode(conn: &Connection) -> EngineResult<bool> {
Ok(get(conn, AIRPLANE_MODE_KEY)?.as_deref() != Some("false"))
}
pub fn set_airplane_mode(conn: &Connection, enabled: bool) -> EngineResult<()> {
set(
conn,
AIRPLANE_MODE_KEY,
if enabled { "true" } else { "false" },
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -127,4 +141,45 @@ mod tests {
let removed_prefix = ["style", "."].concat();
assert!(!values.keys().any(|key| key.starts_with(&removed_prefix)));
}
#[test]
fn airplane_mode_defaults_to_safe_mode_and_persists_changes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bds.db");
let db = Database::open(&path).unwrap();
db.migrate().unwrap();
assert!(airplane_mode(db.conn()).unwrap());
set_airplane_mode(db.conn(), false).unwrap();
drop(db);
let db = Database::open(&path).unwrap();
assert!(!airplane_mode(db.conn()).unwrap());
set_airplane_mode(db.conn(), true).unwrap();
drop(db);
let db = Database::open(&path).unwrap();
assert!(airplane_mode(db.conn()).unwrap());
}
#[test]
fn effective_settings_hide_internal_project_metadata_snapshots() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
setting::set_setting_value(
db.conn(),
"project:p1:categories",
r#"{"categories":["article"]}"#,
1,
)
.unwrap();
assert!(
!list_effective(db.conn())
.unwrap()
.contains_key("project:p1:categories")
);
}
}

View File

@@ -1,10 +1,7 @@
use std::fs;
use std::path::Path;
use crate::db::DbConnection as Connection;
use crate::engine::{EngineError, EngineResult};
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
use crate::engine::EngineResult;
#[derive(Debug, Clone, Copy)]
pub(crate) struct BundledSiteAsset {
@@ -238,6 +235,10 @@ pub(crate) fn bundled_site_asset(relative_path: &str) -> Option<&'static [u8]> {
.map(|asset| asset.bytes)
}
pub(crate) fn bundled_site_assets() -> &'static [BundledSiteAsset] {
BUNDLED_SITE_ASSETS
}
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS {
let target = project_dir.join(asset.relative_path);
@@ -252,33 +253,6 @@ pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
Ok(())
}
pub(crate) fn write_bundled_site_assets(
conn: &Connection,
output_dir: &Path,
project_id: &str,
report: &mut crate::engine::generation::GenerationReport,
) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS {
match write_generated_bytes(
conn,
output_dir,
project_id,
asset.relative_path,
asset.bytes,
)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
GeneratedWriteOutcome::Written => {
report.written_paths.push(asset.relative_path.to_string())
}
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(asset.relative_path.to_string())
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::bundled_site_asset;

View File

@@ -78,7 +78,9 @@ pub fn update_tag(
Ok(())
}
/// Delete a tag: remove from all posts' tag arrays, delete from DB, rewrite tags.json.
/// Delete a tag: remove its exact name from posts, delete from DB, rewrite tags.json.
/// Tag entity lookup remains case-insensitive, but portable post tag arrays
/// follow bDS2 and Allium's exact string membership semantics.
pub fn delete_tag(
conn: &Connection,
data_dir: &Path,
@@ -96,7 +98,9 @@ pub fn delete_tag(
Ok(())
}
/// Rename a tag: update all posts' tag arrays, update tag in DB, rewrite tags.json.
/// Rename a tag: replace its exact name in posts, update the DB and tags.json.
/// Tag entity lookup remains case-insensitive, but portable post tag arrays
/// follow bDS2 and Allium's exact string membership semantics.
pub fn rename_tag(
conn: &Connection,
data_dir: &Path,
@@ -113,12 +117,12 @@ pub fn rename_tag(
let now = now_unix_ms();
let mut modified = Vec::new();
for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) {
if post.tags.iter().any(|t| t == &old_name) {
post.tags = post
.tags
.into_iter()
.map(|t| {
if t.eq_ignore_ascii_case(&old_name) {
if t == old_name {
new_name.to_string()
} else {
t
@@ -142,6 +146,8 @@ pub fn rename_tag(
/// Merge multiple source tags into one target tag.
/// For each source: update posts (remove source name, add target name if not present), delete source.
/// Source and target membership in portable post tag arrays is exact, matching
/// bDS2 and Allium; tag entity lookup and uniqueness remain case-insensitive.
pub fn merge_tags(
conn: &Connection,
data_dir: &Path,
@@ -161,20 +167,12 @@ pub fn merge_tags(
let posts = post_q::list_posts_by_project(conn, project_id)?;
let now = now_unix_ms();
for mut post in posts {
let has_source = post
.tags
.iter()
.any(|t| t.eq_ignore_ascii_case(&source_tag.name));
let has_source = post.tags.iter().any(|t| t == &source_tag.name);
if has_source {
// Remove source tag name
post.tags
.retain(|t| !t.eq_ignore_ascii_case(&source_tag.name));
post.tags.retain(|t| t != &source_tag.name);
// Add target tag name if not already present
if !post
.tags
.iter()
.any(|t| t.eq_ignore_ascii_case(&target_tag.name))
{
if !post.tags.iter().any(|t| t == &target_tag.name) {
post.tags.push(target_tag.name.clone());
}
post.updated_at = now;
@@ -354,8 +352,8 @@ fn remove_tag_name_from_posts(
let now = now_unix_ms();
let mut modified = Vec::new();
for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
post.tags.retain(|t| !t.eq_ignore_ascii_case(tag_name));
if post.tags.iter().any(|t| t == tag_name) {
post.tags.retain(|t| t != tag_name);
post.updated_at = now;
post_q::update_post(conn, &post)?;
modified.push(post.id.clone());
@@ -519,6 +517,57 @@ mod tests {
assert_eq!(all[0].name, "target");
}
#[test]
fn tag_mutations_match_post_tag_names_exactly() {
let (db, dir) = setup();
let renamed = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
insert_post(
db.conn(),
&make_post("rename", "rename", vec!["Rust".into(), "rust".into()]),
)
.unwrap();
rename_tag(db.conn(), dir.path(), "p1", &renamed.id, "golang").unwrap();
assert_eq!(
post_q::get_post_by_id(db.conn(), "rename").unwrap().tags,
vec!["Rust", "golang"]
);
let source = create_tag(db.conn(), dir.path(), "p1", "source", None).unwrap();
let target = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
insert_post(
db.conn(),
&make_post(
"merge",
"merge",
vec!["SOURCE".into(), "source".into(), "Target".into()],
),
)
.unwrap();
merge_tags(db.conn(), dir.path(), "p1", &[&source.id], &target.id).unwrap();
assert_eq!(
post_q::get_post_by_id(db.conn(), "merge").unwrap().tags,
vec!["SOURCE", "Target", "target"]
);
let deleted = create_tag(db.conn(), dir.path(), "p1", "delete", None).unwrap();
insert_post(
db.conn(),
&make_post("delete", "delete", vec!["DELETE".into(), "delete".into()]),
)
.unwrap();
delete_tag(db.conn(), dir.path(), "p1", &deleted.id).unwrap();
assert_eq!(
post_q::get_post_by_id(db.conn(), "delete").unwrap().tags,
vec!["DELETE"]
);
}
#[test]
fn sync_tags_from_posts_creates_missing() {
let (db, _dir) = setup();

View File

@@ -1,7 +1,10 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
/// Unique task identifier.
pub type TaskId = u64;
@@ -25,6 +28,7 @@ pub struct TaskSnapshot {
pub status: TaskStatus,
pub progress: Option<f32>,
pub message: Option<String>,
pub cancellation_requested: bool,
pub created_at: Instant,
}
@@ -42,228 +46,350 @@ struct TaskEntry {
created_at: Instant,
finished_at: Option<Instant>,
last_progress_report: Option<Instant>,
worker_active: bool,
worker_started: bool,
worker_exited: bool,
}
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
pub struct TaskManager {
max_concurrent: usize,
next_id: Mutex<TaskId>,
tasks: Mutex<Vec<TaskEntry>>,
state: Mutex<TaskState>,
state_changed: Condvar,
async_changed: Notify,
}
struct TaskState {
next_id: TaskId,
tasks: HashMap<TaskId, TaskEntry>,
order: VecDeque<TaskId>,
pending: VecDeque<TaskId>,
worker_count: usize,
}
/// Capacity reservation held until both the worker exits and its result is recorded.
pub struct TaskWorker {
manager: Arc<TaskManager>,
task_id: TaskId,
}
impl Drop for TaskWorker {
fn drop(&mut self) {
self.manager.worker_exited(self.task_id);
}
}
impl TaskManager {
/// Create a new task manager with the given concurrency limit.
pub fn new(max_concurrent: usize) -> Self {
Self {
max_concurrent,
next_id: Mutex::new(1),
tasks: Mutex::new(Vec::new()),
max_concurrent: max_concurrent.max(1),
state: Mutex::new(TaskState {
next_id: 1,
tasks: HashMap::new(),
order: VecDeque::new(),
pending: VecDeque::new(),
worker_count: 0,
}),
state_changed: Condvar::new(),
async_changed: Notify::new(),
}
}
/// Submit a new task. Returns its unique identifier.
pub fn submit(&self, label: &str) -> TaskId {
let mut next = self.next_id.lock().unwrap();
let id = *next;
*next += 1;
self.submit_with_group(label, None, None)
}
let entry = TaskEntry {
fn submit_with_group(
&self,
label: &str,
group_id: Option<&str>,
group_name: Option<&str>,
) -> TaskId {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
let id = state.next_id;
state.next_id += 1;
state.tasks.insert(
id,
label: label.to_owned(),
group_id: None,
group_name: None,
status: TaskStatus::Pending,
cancel_flag: Arc::new(AtomicBool::new(false)),
progress: None,
message: None,
created_at: Instant::now(),
finished_at: None,
last_progress_report: None,
};
let mut tasks = self.tasks.lock().unwrap();
tasks.push(entry);
// Auto-start if under capacity
let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running < self.max_concurrent
&& let Some(t) = tasks
.iter_mut()
.find(|t| t.id == id && t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
}
TaskEntry {
id,
label: label.to_owned(),
group_id: group_id.map(str::to_owned),
group_name: group_name.map(str::to_owned),
status: TaskStatus::Pending,
cancel_flag: Arc::new(AtomicBool::new(false)),
progress: None,
message: None,
created_at: Instant::now(),
finished_at: None,
last_progress_report: None,
worker_active: false,
worker_started: false,
worker_exited: false,
},
);
state.order.push_back(id);
state.pending.push_back(id);
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
id
}
/// Submit a new task within a group. Returns its unique identifier.
pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId {
let id = self.submit(label);
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) {
entry.group_id = Some(group_id.to_owned());
entry.group_name = Some(group_name.to_owned());
}
id
self.submit_with_group(label, Some(group_id), Some(group_name))
}
/// Block a worker until its task may run. Returns false if cancelled.
/// Wait synchronously for admission. Prefer [`Self::admit`] before spawning workers.
pub fn wait_until_runnable(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap();
let mut state = self.state.lock().unwrap();
loop {
match tasks
.iter()
.find(|task| task.id == task_id)
.map(|task| &task.status)
{
Some(TaskStatus::Running) => return true,
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(),
match state.tasks.get(&task_id).map(|task| &task.status) {
Some(TaskStatus::Running) => {
if let Some(task) = state.tasks.get_mut(&task_id) {
task.worker_started = true;
task.worker_exited = false;
}
return true;
}
Some(TaskStatus::Pending) => state = self.state_changed.wait(state).unwrap(),
_ => return false,
}
}
}
/// Admit without occupying a blocking-pool thread while queued.
pub async fn admit(self: &Arc<Self>, task_id: TaskId) -> Option<TaskWorker> {
loop {
let notified = self.async_changed.notified();
{
let mut state = self.state.lock().unwrap();
match state.tasks.get_mut(&task_id) {
Some(task) if task.status == TaskStatus::Running => {
task.worker_started = true;
task.worker_exited = false;
return Some(TaskWorker {
manager: Arc::clone(self),
task_id,
});
}
Some(task) if task.status == TaskStatus::Pending => {}
_ => return None,
}
}
notified.await;
}
}
/// Admit a non-Tokio worker while retaining capacity until its guard drops.
pub fn admit_blocking(self: &Arc<Self>, task_id: TaskId) -> Option<TaskWorker> {
if self.wait_until_runnable(task_id) {
Some(TaskWorker {
manager: Arc::clone(self),
task_id,
})
} else {
None
}
}
/// Mark a task as completed.
pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running)
{
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
let mut state = self.state.lock().unwrap();
let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
if matches!(entry.status, TaskStatus::Running) {
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
entry.finished_at = Some(Instant::now());
}
let released = entry.worker_active && !entry.worker_started;
if released {
entry.worker_active = false;
}
released
} else {
false
};
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
/// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running)
{
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
let mut state = self.state.lock().unwrap();
let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
if matches!(entry.status, TaskStatus::Running) {
if entry.cancel_flag.load(Ordering::Acquire) {
entry.status = TaskStatus::Cancelled;
} else {
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
}
entry.finished_at = Some(Instant::now());
}
let released = entry.worker_active && !entry.worker_started;
if released {
entry.worker_active = false;
}
released
} else {
false
};
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
/// Cancel a task by setting its cancel flag and status.
pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
/// Cancel queued work immediately, or request a cooperative stop from a worker.
pub fn cancel(&self, task_id: TaskId) -> bool {
let mut state = self.state.lock().unwrap();
let mut cancelled = false;
let mut released = false;
if let Some(entry) = state.tasks.get_mut(&task_id)
&& matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
&& !entry.worker_exited
{
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(Instant::now());
if !entry.worker_started {
if entry.worker_active {
entry.worker_active = false;
released = true;
}
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(Instant::now());
}
cancelled = true;
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
cancelled
}
/// Cancel every active task in a group and release their workers.
pub fn cancel_group(&self, group_id: &str) {
let mut tasks = self.tasks.lock().unwrap();
let mut state = self.state.lock().unwrap();
let now = Instant::now();
for entry in tasks.iter_mut().filter(|task| {
task.group_id.as_deref() == Some(group_id)
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending)
}) {
let group_ids = state
.tasks
.values()
.filter(|task| {
task.group_id.as_deref() == Some(group_id)
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending)
})
.map(|task| task.id)
.collect::<Vec<_>>();
let mut released = 0;
for id in group_ids {
let entry = state.tasks.get_mut(&id).unwrap();
if entry.worker_exited {
continue;
}
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(now);
if !entry.worker_started {
if entry.worker_active {
entry.worker_active = false;
released += 1;
}
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(now);
}
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
state.worker_count = state.worker_count.saturating_sub(released);
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
/// Return the group containing a task, if any.
pub fn group_id(&self, task_id: TaskId) -> Option<String> {
self.tasks
.lock()
.unwrap()
.iter()
.find(|task| task.id == task_id)
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.get(&task_id)
.and_then(|task| task.group_id.clone())
}
/// Check whether a task has been cancelled.
pub fn is_cancelled(&self, task_id: TaskId) -> bool {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.get(&task_id)
.map(|t| t.cancel_flag.load(Ordering::Acquire))
.unwrap_or(false)
}
/// Shared cancellation flag for a worker owned by this task.
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> {
self.tasks
.lock()
.unwrap()
.iter()
.find(|task| task.id == task_id)
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.get(&task_id)
.map(|task| Arc::clone(&task.cancel_flag))
}
/// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.status.clone())
self.get(task_id).map(|task| task.status)
}
/// Count tasks that are still queued.
pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Pending)
.count()
}
/// Count tasks that are currently running.
pub fn running_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Running)
.count()
}
/// Remove finished tasks older than the configured retention period.
pub fn evict_expired(&self) {
let cutoff = Instant::now() - FINISHED_TASK_TTL;
let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|task| {
task.finished_at
.is_none_or(|finished_at| finished_at > cutoff)
});
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
}
/// Remove every finished task while preserving running and queued work.
pub fn clear_completed(&self) {
self.tasks
.lock()
.unwrap()
.retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running));
let mut state = self.state.lock().unwrap();
Self::remove_where(&mut state, |task| task.status == TaskStatus::Completed);
}
/// Remove all terminal task results while preserving active work.
pub fn clear_finished(&self) {
let mut state = self.state.lock().unwrap();
Self::remove_where(&mut state, |task| {
task.finished_at.is_some() && !task.worker_active
});
}
/// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.tasks.get_mut(&task_id)
&& entry.status == TaskStatus::Running
{
let now = Instant::now();
@@ -271,7 +397,7 @@ impl TaskManager {
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
None => true,
};
if should_report {
if should_report || progress.is_some_and(|value| value >= 1.0) {
entry.progress = progress;
entry.message = message;
entry.last_progress_report = Some(now);
@@ -281,63 +407,164 @@ impl TaskManager {
/// Return the current progress of a task.
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.progress)
self.get(task_id).and_then(|task| task.progress)
}
/// Return a snapshot of all tasks for UI display.
/// Return active tasks plus ten recent finished tasks for status surfaces.
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
let tasks = self.tasks.lock().unwrap();
let mut snapshots = tasks
.iter()
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
let mut active = state
.tasks
.values()
.filter(|task| task.finished_at.is_none())
.chain(
tasks
.iter()
.rev()
.filter(|task| task.finished_at.is_some())
.take(RECENT_FINISHED_LIMIT),
)
.map(|task| TaskSnapshot {
id: task.id,
label: task.label.clone(),
group_id: task.group_id.clone(),
group_name: task.group_name.clone(),
status: task.status.clone(),
progress: task.progress,
message: task.message.clone(),
created_at: task.created_at,
})
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.created_at);
snapshots
active.sort_by_key(|task| (task.status != TaskStatus::Running, task.created_at));
let active_groups = active
.iter()
.filter_map(|task| task.group_id.as_deref())
.collect::<HashSet<_>>();
let mut finished = state
.tasks
.values()
.filter(|task| task.finished_at.is_some())
.collect::<Vec<_>>();
finished.sort_by_key(|task| std::cmp::Reverse(task.finished_at));
let recent_ids = finished
.iter()
.take(RECENT_FINISHED_LIMIT)
.map(|task| task.id)
.collect::<HashSet<_>>();
active
.into_iter()
.chain(finished.into_iter().filter(|task| {
recent_ids.contains(&task.id)
|| task
.group_id
.as_deref()
.is_some_and(|group| active_groups.contains(group))
}))
.map(Self::snapshot)
.collect()
}
/// Promote the next queued task to running if capacity allows.
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
while tasks
/// Return one retained task by id.
pub fn get(&self, task_id: TaskId) -> Option<TaskSnapshot> {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state.tasks.get(&task_id).map(Self::snapshot)
}
/// Return every retained task, newest first.
pub fn all(&self) -> Vec<TaskSnapshot> {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.order
.iter()
.rev()
.filter_map(|id| state.tasks.get(id).map(Self::snapshot))
.collect()
}
/// Return running tasks in start order.
pub fn running(&self) -> Vec<TaskSnapshot> {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.order
.iter()
.filter_map(|id| state.tasks.get(id))
.filter(|task| task.status == TaskStatus::Running)
.count()
< max_concurrent
{
let Some(task) = tasks
.iter_mut()
.find(|task| task.status == TaskStatus::Pending)
else {
.map(Self::snapshot)
.collect()
}
fn worker_exited(&self, task_id: TaskId) {
let mut state = self.state.lock().unwrap();
let released = if let Some(task) = state.tasks.get_mut(&task_id) {
task.worker_started = false;
task.worker_exited = true;
let released = task.worker_active
&& !matches!(task.status, TaskStatus::Pending | TaskStatus::Running);
if released {
task.worker_active = false;
}
released
} else {
false
};
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
fn promote_next(state: &mut TaskState, max_concurrent: usize) {
while state.worker_count < max_concurrent {
let Some(id) = state.pending.pop_front() else {
break;
};
let Some(task) = state.tasks.get_mut(&id) else {
continue;
};
if task.status != TaskStatus::Pending {
continue;
}
task.status = TaskStatus::Running;
task.worker_active = true;
state.worker_count += 1;
}
}
fn snapshot(task: &TaskEntry) -> TaskSnapshot {
TaskSnapshot {
id: task.id,
label: task.label.clone(),
group_id: task.group_id.clone(),
group_name: task.group_name.clone(),
status: task.status.clone(),
progress: task.progress,
message: task.message.clone(),
cancellation_requested: task.cancel_flag.load(Ordering::Acquire),
created_at: task.created_at,
}
}
fn prune_expired(state: &mut TaskState) {
let cutoff = Instant::now() - FINISHED_TASK_TTL;
Self::remove_where(state, |task| {
task.finished_at
.is_some_and(|finished_at| finished_at <= cutoff)
&& !task.worker_active
});
}
fn remove_where(state: &mut TaskState, predicate: impl Fn(&TaskEntry) -> bool) {
let removed = state
.tasks
.values()
.filter(|task| predicate(task))
.map(|task| task.id)
.collect::<HashSet<_>>();
state.tasks.retain(|id, _| !removed.contains(id));
state.order.retain(|id| !removed.contains(id));
state.pending.retain(|id| !removed.contains(id));
}
fn notify_changed(&self) {
self.state_changed.notify_all();
self.async_changed.notify_waiters();
}
}
impl Default for TaskManager {
fn default() -> Self {
Self::new(3)
Self::new(
std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1),
)
}
}
@@ -358,6 +585,19 @@ mod tests {
assert_eq!(mgr.status(id), Some(TaskStatus::Running));
}
#[test]
fn default_uses_every_online_worker_like_bds2() {
let mgr = TaskManager::default();
let expected = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1);
for index in 0..expected {
mgr.submit(&format!("task {index}"));
}
assert_eq!(mgr.running_count(), expected);
}
#[test]
fn max_concurrent_enforced() {
let mgr = TaskManager::new(3);
@@ -382,7 +622,7 @@ mod tests {
}
#[test]
fn cancel_sets_flag() {
fn cancelling_unstarted_task_settles_immediately() {
let mgr = TaskManager::default();
let id = mgr.submit("upload");
// Task is auto-started (Running)
@@ -392,6 +632,36 @@ mod tests {
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
}
#[tokio::test]
async fn cancelling_started_task_stays_running_until_worker_stops() {
let mgr = Arc::new(TaskManager::new(1));
let id = mgr.submit("upload");
let worker = mgr.admit(id).await.unwrap();
mgr.cancel(id);
let snapshot = mgr.get(id).unwrap();
assert_eq!(snapshot.status, TaskStatus::Running);
assert!(snapshot.cancellation_requested);
drop(worker);
mgr.fail(id, "operation cancelled".into());
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
}
#[tokio::test]
async fn completed_work_wins_a_late_cancellation_request() {
let mgr = Arc::new(TaskManager::new(1));
let id = mgr.submit("atomic update");
let worker = mgr.admit(id).await.unwrap();
drop(worker);
assert!(!mgr.cancel(id));
mgr.complete(id);
assert_eq!(mgr.status(id), Some(TaskStatus::Completed));
}
#[test]
fn complete_and_fail() {
let mgr = TaskManager::default();
@@ -427,8 +697,12 @@ mod tests {
// After b fails: d promoted to running
{
let mut tasks = mgr.tasks.lock().unwrap();
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) {
let mut state = mgr.state.lock().unwrap();
for task in state
.tasks
.values_mut()
.filter(|task| task.finished_at.is_some())
{
task.finished_at =
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
}
@@ -452,6 +726,34 @@ mod tests {
assert_eq!(mgr.snapshots().len(), 10);
}
#[test]
fn snapshots_retain_every_active_task_and_complete_active_groups() {
let mgr = TaskManager::new(20);
let finished_group_member = mgr.submit_grouped("finished", "group", "Group");
mgr.complete(finished_group_member);
for index in 0..12 {
mgr.submit_grouped(&format!("active {index}"), "group", "Group");
}
for index in 0..12 {
let id = mgr.submit(&format!("history {index}"));
mgr.complete(id);
}
let snapshots = mgr.snapshots();
assert_eq!(
snapshots
.iter()
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
.count(),
12
);
assert!(
snapshots
.iter()
.any(|task| task.id == finished_group_member)
);
}
#[test]
fn clear_completed_preserves_active_tasks() {
let mgr = TaskManager::new(2);
@@ -538,4 +840,91 @@ mod tests {
assert_eq!(mgr.progress(id), Some(0.5));
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway"));
}
#[tokio::test]
async fn cancellation_holds_capacity_until_worker_exits() {
let mgr = Arc::new(TaskManager::new(1));
let running = mgr.submit("running");
let queued = mgr.submit("queued");
let worker = mgr.admit(running).await.unwrap();
mgr.cancel(running);
assert_eq!(mgr.status(running), Some(TaskStatus::Running));
assert!(mgr.get(running).unwrap().cancellation_requested);
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
drop(worker);
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
mgr.fail(running, "operation cancelled".into());
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
}
#[tokio::test]
async fn panicking_worker_waits_for_terminal_failure_before_releasing_capacity() {
let mgr = Arc::new(TaskManager::new(1));
let panicking = mgr.submit("panicking");
let queued = mgr.submit("queued");
let worker = mgr.admit(panicking).await.unwrap();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _worker = worker;
panic!("boom");
}));
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
mgr.fail(panicking, "background task panicked".into());
assert_eq!(
mgr.status(panicking),
Some(TaskStatus::Failed("background task panicked".into()))
);
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
}
#[tokio::test]
async fn terminal_result_holds_capacity_until_worker_exits() {
let mgr = Arc::new(TaskManager::new(1));
let running = mgr.submit("running");
let queued = mgr.submit("queued");
let worker = mgr.admit(running).await.unwrap();
mgr.complete(running);
assert_eq!(mgr.status(running), Some(TaskStatus::Completed));
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
drop(worker);
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
}
#[test]
fn terminal_progress_bypasses_throttle() {
let mgr = TaskManager::new(1);
let id = mgr.submit("work");
mgr.report_progress(id, Some(0.5), Some("working".into()));
mgr.report_progress(id, Some(1.0), Some("done".into()));
let task = mgr
.snapshots()
.into_iter()
.find(|task| task.id == id)
.unwrap();
assert_eq!(task.progress, Some(1.0));
assert_eq!(task.message.as_deref(), Some("done"));
}
#[test]
fn clear_completed_keeps_other_terminal_results() {
let mgr = TaskManager::new(3);
let completed = mgr.submit("completed");
let failed = mgr.submit("failed");
let cancelled = mgr.submit("cancelled");
mgr.complete(completed);
mgr.fail(failed, "failed".into());
mgr.cancel(cancelled);
mgr.clear_completed();
assert_eq!(mgr.status(completed), None);
assert!(matches!(mgr.status(failed), Some(TaskStatus::Failed(_))));
assert_eq!(mgr.status(cancelled), Some(TaskStatus::Cancelled));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,8 @@ pub struct TemplateRebuildReport {
pub errors: Vec<String>,
}
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Rebuild templates from the filesystem into the database.
///
/// Walks the `templates/` directory for `*.liquid` files, parses each via
@@ -26,6 +28,15 @@ pub fn rebuild_templates_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<TemplateRebuildReport> {
rebuild_templates_from_filesystem_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_templates_from_filesystem_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<TemplateRebuildReport> {
let mut report = TemplateRebuildReport::default();
let templates_dir = data_dir.join("templates");
@@ -34,17 +45,23 @@ pub fn rebuild_templates_from_filesystem(
return Ok(report);
}
for entry in WalkDir::new(&templates_dir)
let files = WalkDir::new(&templates_dir)
.into_iter()
.filter_map(|e| e.ok())
{
.filter(|entry| entry.path().is_file())
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("liquid"))
.collect::<Vec<_>>();
for (index, entry) in files.iter().enumerate() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("liquid") {
continue;
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("?");
if let Some(ref callback) = on_item
&& !callback(index + 1, files.len(), name)
{
return Err(EngineError::Cancelled);
}
match rebuild_single_template(conn, data_dir, project_id, path) {

View File

@@ -0,0 +1,146 @@
use serde::{Deserialize, Serialize};
use crate::db::DbConnection as Connection;
use crate::db::queries::{project, setting};
use crate::engine::{EngineError, EngineResult};
use crate::util::now_unix_ms;
const KEY_SUFFIX: &str = "ui_state";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectUiState {
pub sidebar_view: String,
pub sidebar_visible: bool,
pub sidebar_width: f32,
pub panel_visible: bool,
pub panel_tab: String,
pub tabs: Vec<PersistedTab>,
pub active_tab: Option<String>,
}
impl Default for ProjectUiState {
fn default() -> Self {
Self {
sidebar_view: "posts".to_string(),
sidebar_visible: true,
sidebar_width: 280.0,
panel_visible: false,
panel_tab: "tasks".to_string(),
tabs: Vec::new(),
active_tab: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedTab {
pub tab_type: String,
pub id: String,
pub title: String,
pub is_transient: bool,
}
pub fn load(conn: &Connection, project_id: &str) -> EngineResult<Option<ProjectUiState>> {
match setting::get_setting_by_key(conn, &key(project_id)) {
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 save(conn: &Connection, project_id: &str, state: &ProjectUiState) -> EngineResult<()> {
match project::get_project_by_id(conn, project_id) {
Ok(_) => {}
Err(diesel::result::Error::NotFound) => {
return Err(EngineError::NotFound(format!("project {project_id}")));
}
Err(error) => return Err(EngineError::Db(error)),
}
let serialized = serde_json::to_string(state)?;
setting::set_setting_value(conn, &key(project_id), &serialized, now_unix_ms())?;
Ok(())
}
fn key(project_id: &str) -> String {
format!("project:{project_id}:{KEY_SUFFIX}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "one")).unwrap();
insert_project(db.conn(), &make_test_project("p2", "two")).unwrap();
db
}
#[test]
fn round_trips_independent_project_sessions() {
let db = setup();
let one = ProjectUiState {
sidebar_view: "media".into(),
sidebar_visible: false,
sidebar_width: 412.0,
panel_visible: true,
panel_tab: "output".into(),
tabs: vec![PersistedTab {
tab_type: "post".into(),
id: "post-1".into(),
title: "Post one".into(),
is_transient: false,
}],
active_tab: Some("post-1".into()),
};
let two = ProjectUiState {
sidebar_view: "scripts".into(),
tabs: vec![PersistedTab {
tab_type: "scripts".into(),
id: "script-2".into(),
title: "Script two".into(),
is_transient: true,
}],
active_tab: Some("script-2".into()),
..ProjectUiState::default()
};
save(db.conn(), "p1", &one).unwrap();
save(db.conn(), "p2", &two).unwrap();
assert_eq!(load(db.conn(), "p1").unwrap(), Some(one));
assert_eq!(load(db.conn(), "p2").unwrap(), Some(two));
assert_eq!(load(db.conn(), "missing").unwrap(), None);
}
#[test]
fn missing_newer_fields_use_safe_defaults() {
let db = setup();
setting::set_setting_value(db.conn(), &key("p1"), r#"{"sidebar_view":"media"}"#, 1)
.unwrap();
let state = load(db.conn(), "p1").unwrap().unwrap();
assert_eq!(state.sidebar_view, "media");
assert!(state.sidebar_visible);
assert_eq!(state.sidebar_width, 280.0);
assert!(state.tabs.is_empty());
}
#[test]
fn deleting_a_project_removes_its_session() {
let db = setup();
save(db.conn(), "p1", &ProjectUiState::default()).unwrap();
crate::engine::project::delete_project(db.conn(), "p1", None).unwrap();
assert_eq!(load(db.conn(), "p1").unwrap(), None);
assert!(matches!(
save(db.conn(), "p1", &ProjectUiState::default()),
Err(EngineError::NotFound(_))
));
}
}

View File

@@ -1,14 +1,17 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::db::DbConnection as Connection;
use walkdir::WalkDir;
use crate::db::queries;
use crate::engine::generation::has_published_snapshot;
use crate::engine::{EngineError, EngineResult};
use crate::model::Post;
use crate::render::build_site_render_artifacts;
use crate::util::file_hash;
use crate::render::{build_canonical_post_path, build_site_route_manifest};
const MTIME_GRANULARITY_TOLERANCE_MS: i64 = 1_000;
#[derive(Debug, Clone, Default)]
pub struct SiteValidationReport {
@@ -22,81 +25,89 @@ pub fn validate_site(
data_dir: &Path,
project_id: &str,
) -> EngineResult<SiteValidationReport> {
validate_site_with_progress(conn, data_dir, project_id, |_, _| true)
}
pub fn validate_site_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_progress: impl FnMut(usize, usize) -> bool,
) -> EngineResult<SiteValidationReport> {
const PHASES: usize = 4;
if !on_progress(0, PHASES) {
return Err(EngineError::Cancelled);
}
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = generated_output_dir(data_dir);
let published_posts = load_published_posts(data_dir, conn, project_id)?;
let artifacts =
build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut expected = artifacts
.pages
.iter()
.map(|page| page.relative_path.clone())
.collect::<HashSet<_>>();
expected.insert("calendar.json".to_string());
for language in render_languages(&metadata) {
let prefix = if language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new()
} else {
format!("{language}/")
};
expected.insert(format!("{prefix}rss.xml"));
expected.insert(format!("{prefix}atom.xml"));
let published_posts = load_published_posts(conn, project_id)?;
let route_manifest = build_site_route_manifest(data_dir, &metadata, &published_posts)
.map_err(|error| crate::engine::EngineError::Parse(error.to_string()))?;
if !on_progress(1, PHASES) {
return Err(EngineError::Cancelled);
}
crate::engine::generation::refresh_validation_sitemap(
conn,
&output_dir,
project_id,
data_dir,
&metadata,
&published_posts,
&route_manifest,
)?;
let expected = route_manifest
.into_iter()
.map(|page| page.relative_path)
.collect::<HashSet<_>>();
if !on_progress(2, PHASES) {
return Err(EngineError::Cancelled);
}
expected.insert("sitemap.xml".to_string());
let mut actual = HashSet::new();
let mut zero_byte = HashSet::new();
if output_dir.exists() {
for entry in WalkDir::new(&output_dir).into_iter().filter_map(Result::ok) {
if !entry.file_type().is_file() {
if !entry.file_type().is_file() || entry.file_name() != "index.html" {
continue;
}
let rel = entry
.path()
.strip_prefix(&output_dir)
.unwrap_or(entry.path())
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with("meta/")
|| rel.starts_with("posts/")
|| rel.starts_with("media/")
|| rel.starts_with("assets/")
{
continue;
}
if rel.starts_with("pagefind") || rel.contains("/pagefind/") {
continue;
}
if rel.ends_with(".html") || rel.ends_with(".xml") || rel.ends_with(".json") {
actual.insert(rel);
let path = relative_path(&output_dir, entry.path());
if entry.metadata().is_ok_and(|metadata| metadata.len() > 0) {
actual.insert(path);
} else {
zero_byte.insert(path);
}
}
}
let mut missing_pages = expected.difference(&actual).cloned().collect::<Vec<_>>();
let mut extra_pages = actual.difference(&expected).cloned().collect::<Vec<_>>();
let mut stale_pages = Vec::new();
for rel in expected.intersection(&actual) {
if let Ok(stored) =
queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel)
{
let actual_hash = file_hash(&output_dir.join(rel))?;
if actual_hash != stored.content_hash {
stale_pages.push(rel.clone());
}
}
extra_pages.extend(zero_byte.difference(&expected).cloned());
let generated_at = queries::generated_file_hash::list_generated_file_hashes(conn, project_id)?
.into_iter()
.map(|file| (file.relative_path, file.updated_at))
.collect::<HashMap<_, _>>();
if !on_progress(3, PHASES) {
return Err(EngineError::Cancelled);
}
let mut stale_pages = stale_post_paths(
data_dir,
&output_dir,
&metadata,
&published_posts,
&expected,
&actual,
&generated_at,
);
missing_pages.sort();
extra_pages.sort();
extra_pages.dedup();
stale_pages.sort();
stale_pages.dedup();
if !on_progress(PHASES, PHASES) {
return Err(EngineError::Cancelled);
}
Ok(SiteValidationReport {
missing_pages,
@@ -105,6 +116,77 @@ pub fn validate_site(
})
}
fn stale_post_paths(
data_dir: &Path,
output_dir: &Path,
metadata: &crate::model::ProjectMetadata,
published_posts: &[Post],
expected: &HashSet<String>,
actual: &HashSet<String>,
generated_at: &HashMap<String, i64>,
) -> Vec<String> {
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let mut languages = vec![main_language.to_string()];
for language in &metadata.blog_languages {
if !languages
.iter()
.any(|known| known.eq_ignore_ascii_case(language))
{
languages.push(language.clone());
}
}
let mut stale = Vec::new();
for post in published_posts {
let Some(source_modified) = modified_ms(&data_dir.join(&post.file_path)) else {
continue;
};
for language in &languages {
if language != main_language && post.do_not_translate {
continue;
}
let relative_path = format!(
"{}/index.html",
build_canonical_post_path(post, language, main_language).trim_start_matches('/')
);
if !expected.contains(&relative_path) || !actual.contains(&relative_path) {
continue;
}
let Some(output_modified) = modified_ms(&output_dir.join(&relative_path)) else {
continue;
};
let effective_generated = output_modified.max(
generated_at
.get(&relative_path)
.copied()
.unwrap_or_default(),
);
if source_modified > effective_generated + MTIME_GRANULARITY_TOLERANCE_MS {
stale.push(relative_path);
}
}
}
stale
}
fn modified_ms(path: &Path) -> Option<i64> {
let modified = path.metadata().ok()?.modified().ok()?;
Some(system_time_ms(modified))
}
fn system_time_ms(time: SystemTime) -> i64 {
time.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
fn relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn generated_output_dir(data_dir: &Path) -> std::path::PathBuf {
let html_dir = data_dir.join("html");
if html_dir.exists() {
@@ -114,38 +196,9 @@ fn generated_output_dir(data_dir: &Path) -> std::path::PathBuf {
}
}
fn load_published_posts(
data_dir: &Path,
conn: &Connection,
project_id: &str,
) -> EngineResult<Vec<(Post, String)>> {
let posts = queries::post::list_posts_by_project(conn, project_id)?;
let mut published = Vec::new();
for post in posts
fn load_published_posts(conn: &Connection, project_id: &str) -> EngineResult<Vec<Post>> {
Ok(queries::post::list_posts_by_project(conn, project_id)?
.into_iter()
.filter(crate::engine::generation::has_published_snapshot)
{
if let Some(source) = crate::engine::generation::load_published_post_source(data_dir, post)?
{
published.push((source.post, source.body_markdown));
}
}
Ok(published)
}
fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> {
let main = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()];
for language in &metadata.blog_languages {
if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone());
}
}
languages
.filter(has_published_snapshot)
.collect())
}

View File

@@ -5,7 +5,7 @@ use std::path::Path;
use crate::db::DbConnection as Connection;
use crate::db::queries::{post as post_q, post_translation};
use crate::engine::EngineResult;
use crate::engine::{EngineError, EngineResult};
use crate::model::PostStatus;
/// Normalize a language code for comparison (lowercase, strip region).
@@ -53,7 +53,7 @@ pub struct TranslationValidationReport {
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Validate all translations with optional per-item progress.
pub fn validate_translations_with_progress(
@@ -75,8 +75,10 @@ pub fn validate_translations_with_progress(
let post_count = posts.len();
for (i, post) in posts.iter().enumerate() {
if let Some(ref cb) = on_item {
cb(i + 1, post_count, &post.title);
if let Some(ref cb) = on_item
&& !cb(i + 1, post_count, &post.title)
{
return Err(EngineError::Cancelled);
}
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
@@ -170,8 +172,10 @@ pub fn validate_translations_with_progress(
let path = entry.path();
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if let Some(ref cb) = on_item {
cb(i + 1, fs_total, stem);
if let Some(ref cb) = on_item
&& !cb(i + 1, fs_total, stem)
{
return Err(EngineError::Cancelled);
}
checked_fs_files += 1;

View File

@@ -25,8 +25,8 @@ use crate::model::{
TaxonomyKind,
};
use crate::util::{
atomic_write_str, content_hash, file_hash, media_sidecar_path, media_translation_sidecar_path,
now_unix_ms, post_file_path, slugify,
atomic_write_str, content_hash, media_file_hash, media_sidecar_path,
media_translation_sidecar_path, now_unix_ms, post_file_path, slugify,
};
const TRANSACTION_BATCH_SIZE: usize = 500;
@@ -598,16 +598,13 @@ pub fn analyze_wxr(
}
fn existing_post_body(post: &Post, data_dir: &Path) -> Option<String> {
post.content
.clone()
.or_else(|| post.published_content.clone())
.or_else(|| {
(!post.file_path.is_empty())
.then(|| fs::read_to_string(data_dir.join(&post.file_path)).ok())
.flatten()
.and_then(|raw| crate::util::frontmatter::read_post_file(&raw).ok())
.map(|(_, body)| body)
})
post.content.clone().or_else(|| {
(!post.file_path.is_empty())
.then(|| fs::read_to_string(data_dir.join(&post.file_path)).ok())
.flatten()
.and_then(|raw| crate::util::frontmatter::read_post_file(&raw).ok())
.map(|(_, body)| body)
})
}
fn analyze_post(
@@ -678,7 +675,7 @@ fn analyze_media(
let checksum = source_path
.as_ref()
.filter(|path| path.is_file())
.and_then(|path| file_hash(path).ok());
.and_then(|path| media_file_hash(path).ok());
let existing_by_name = media_by_name.get(&item.filename.to_lowercase()).copied();
let existing_by_checksum = checksum
.as_ref()
@@ -1098,7 +1095,9 @@ pub fn execute_import(
false
} else {
match item.kind {
TaxonomyKind::Category => meta::add_category(data_dir, &item.name)?,
TaxonomyKind::Category => {
meta::add_category(conn, data_dir, project_id, &item.name)?
}
TaxonomyKind::Tag => {
tag::create_tag(conn, data_dir, project_id, &item.name, None)?;
}
@@ -1363,13 +1362,12 @@ fn import_post_item(
qp::update_post(conn, &imported)?;
if item.source_status.as_deref() == Some("publish") {
imported = post::publish_post(conn, data_dir, &imported.id)?;
let body = imported.published_content.clone().unwrap_or_default();
let body = content.to_string();
imported.created_at = item.created_at.unwrap_or(imported.created_at);
imported.updated_at = item.updated_at.unwrap_or(imported.created_at);
imported.published_at = item.published_at.or(Some(imported.created_at));
imported.file_path = post_file_path(imported.created_at, &imported.slug);
let serialized = crate::util::frontmatter::write_post_file(&imported, &body);
imported.checksum = Some(content_hash(serialized.as_bytes()));
atomic_write_str(&data_dir.join(&imported.file_path), &serialized)?;
if !previous_file_path.is_empty() && previous_file_path != imported.file_path {
remove_file_if_present(&data_dir.join(previous_file_path))?;
@@ -1476,6 +1474,7 @@ fn execute_media_phase(
default_author,
None,
Vec::new(),
item.checksum.as_deref(),
item.created_at.unwrap_or_else(now_unix_ms),
)?
};

View File

@@ -234,6 +234,47 @@ static RENDER_MAPS: LazyLock<[HashMap<String, String>; 5]> = LazyLock::new(|| {
mod tests {
use super::*;
#[test]
fn engine_progress_keys_exist_in_every_ui_catalog() {
let keys = [
"engine-progress-scanningPublishedPosts",
"engine-progress-translatingPost",
"engine-progress-translationBatchComplete",
"engine-progress-loadingProjectMetadata",
"engine-progress-scanningPosts",
"engine-progress-postItem",
"engine-progress-scanningMedia",
"engine-progress-mediaItem",
"engine-progress-rebuildingTemplates",
"engine-progress-rebuildingScripts",
"engine-progress-importingTags",
"engine-progress-refreshingSemanticIndex",
"engine-progress-rebuildComplete",
];
let catalogs = [
("en", UI_EN),
("de", include_str!("../../../../locales/ui/de.ftl")),
("fr", include_str!("../../../../locales/ui/fr.ftl")),
("it", include_str!("../../../../locales/ui/it.ftl")),
("es", include_str!("../../../../locales/ui/es.ftl")),
];
for (language, source) in catalogs {
let present = resource(source)
.entries()
.filter_map(|entry| match entry {
fluent_syntax::ast::Entry::Message(message) => {
Some(message.id.name.to_string())
}
_ => None,
})
.collect::<std::collections::HashSet<_>>();
for key in keys {
assert!(present.contains(key), "{language} is missing {key}");
}
}
}
// LanguageNormalization invariant: base-extract and fallback
#[test]
fn normalize_strips_region() {

View File

@@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize};
)]
#[diesel(
table_name = crate::db::schema::generated_file_hashes,
check_for_backend(diesel::sqlite::Sqlite)
check_for_backend(diesel::sqlite::Sqlite),
treat_none_as_default_value = false
)]
pub struct GeneratedFileHash {
pub project_id: String,
@@ -133,17 +134,17 @@ pub enum SshMode {
}
/// Publishing preferences stored in meta/publishing.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishingPreferences {
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_remote_path: Option<String>,
#[serde(default = "default_ssh_mode")]
pub ssh_mode: SshMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_remote_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_user: Option<String>,
}
impl Default for PublishingPreferences {

View File

@@ -119,7 +119,7 @@ pub struct McpProposal {
pub project_id: String,
pub kind: ProposalKind,
pub status: ProposalStatus,
pub entity_id: Option<String>,
pub entity_id: String,
pub data: String,
pub result: Option<String>,
pub created_at: i64,

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const SUPPORTED_PICO_THEMES: [&str; 20] = [
@@ -41,33 +43,33 @@ fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMetadata {
pub name: String,
#[serde(default)]
pub blog_languages: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub main_language: Option<String>,
pub blogmark_category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_author: Option<String>,
#[serde(default = "default_max_posts")]
pub max_posts_per_page: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(
default = "default_image_import_concurrency",
deserialize_with = "deserialize_image_import_concurrency"
)]
pub image_import_concurrency: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub blogmark_category: Option<String>,
pub main_language: Option<String>,
#[serde(default = "default_max_posts")]
pub max_posts_per_page: i32,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pico_theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
#[serde(default)]
pub semantic_similarity_enabled: bool,
#[serde(default)]
pub blog_languages: Vec<String>,
}
impl ProjectMetadata {
@@ -94,28 +96,54 @@ impl ProjectMetadata {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CategorySettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_template_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_template_slug: Option<String>,
#[serde(default = "default_true")]
pub render_in_lists: bool,
#[serde(default = "default_true")]
pub show_title: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_template_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_template_slug: Option<String>,
pub title: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub titles: BTreeMap<String, String>,
}
impl CategorySettings {
pub fn title_for(&self, language: &str, main_language: &str) -> Option<&str> {
if language.eq_ignore_ascii_case(main_language) {
self.title.as_deref()
} else {
self.titles
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(language))
.map(|(_, title)| title.as_str())
}
.map(str::trim)
.filter(|title| !title.is_empty())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagEntry {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "option_is_none_or_blank")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "postTemplateSlug")]
pub name: String,
#[serde(
skip_serializing_if = "option_is_none_or_blank",
rename = "postTemplateSlug"
)]
pub post_template_slug: Option<String>,
}
fn option_is_none_or_blank(value: &Option<String>) -> bool {
value.as_deref().is_none_or(str::is_empty)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -167,17 +195,36 @@ mod tests {
#[test]
fn category_settings_camel_case() {
let settings = CategorySettings {
title: Some("Article".into()),
titles: BTreeMap::new(),
render_in_lists: false,
show_title: true,
post_template_slug: Some("article-tpl".into()),
list_template_slug: None,
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("title"));
assert!(json.contains("renderInLists"));
assert!(json.contains("showTitle"));
assert!(json.contains("postTemplateSlug"));
}
#[test]
fn category_settings_select_title_for_render_language() {
let settings = CategorySettings {
title: Some("Artikel".into()),
titles: std::collections::BTreeMap::from([("en".into(), "Articles".into())]),
render_in_lists: true,
show_title: true,
post_template_slug: None,
list_template_slug: None,
};
assert_eq!(settings.title_for("de", "de"), Some("Artikel"));
assert_eq!(settings.title_for("en", "de"), Some("Articles"));
assert_eq!(settings.title_for("fr", "de"), None);
}
#[test]
fn tag_entry_roundtrip() {
let tag = TagEntry {

View File

@@ -96,7 +96,7 @@ pub struct Post {
serialize_as = crate::db::types::DbStringList
)]
pub categories: Vec<String>,
// Published snapshot fields (used for diff detection)
// Legacy bDS2-compatible columns. Publishing and lifecycle logic leave them untouched.
#[serde(skip_serializing_if = "Option::is_none")]
pub published_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection;
use chrono::{Datelike, Local, TimeZone};
@@ -16,6 +17,117 @@ pub enum GeneratedWriteOutcome {
SkippedUnchanged,
}
pub(crate) struct GeneratedFileWriter<'a> {
conn: &'a Connection,
output_dir: &'a Path,
project_id: &'a str,
existing: Arc<HashMap<String, String>>,
pending: HashMap<String, GeneratedFileHash>,
force: bool,
verify_output: bool,
}
impl<'a> GeneratedFileWriter<'a> {
pub(crate) fn new(
conn: &'a Connection,
output_dir: &'a Path,
project_id: &'a str,
force: bool,
verify_output: bool,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let existing = Arc::new(
qhash::list_generated_file_hashes(conn, project_id)?
.into_iter()
.map(|hash| (hash.relative_path, hash.content_hash))
.collect(),
);
Self::with_existing(conn, output_dir, project_id, existing, force, verify_output)
}
pub(crate) fn with_existing(
conn: &'a Connection,
output_dir: &'a Path,
project_id: &'a str,
existing: Arc<HashMap<String, String>>,
force: bool,
verify_output: bool,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Ok(Self {
conn,
output_dir,
project_id,
existing,
pending: HashMap::new(),
force,
verify_output,
})
}
pub(crate) fn write_str(
&mut self,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
self.write(relative_path, content.as_bytes(), |path| {
atomic_write_str(path, content)
})
}
pub(crate) fn write_bytes(
&mut self,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
self.write(relative_path, content, |path| {
crate::util::atomic_write(path, content)
})
}
fn write(
&mut self,
relative_path: &str,
content: &[u8],
write: impl FnOnce(&Path) -> std::io::Result<()>,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = self.output_dir.join(relative_path);
let existing_hash = self
.pending
.get(relative_path)
.map(|pending| &pending.content_hash)
.or_else(|| self.existing.get(relative_path));
if !self.force
&& existing_hash == Some(&hash)
&& target_path.exists()
&& (!self.verify_output || file_hash(&target_path)? == hash)
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
write(&target_path)?;
self.pending.insert(
relative_path.to_string(),
GeneratedFileHash {
project_id: self.project_id.to_string(),
relative_path: relative_path.to_string(),
content_hash: hash,
updated_at: now_unix_ms(),
},
);
Ok(GeneratedWriteOutcome::Written)
}
pub(crate) fn finish(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
qhash::upsert_generated_file_hashes(
self.conn,
&self.pending.into_values().collect::<Vec<_>>(),
)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CalendarArchiveData {
pub years: BTreeMap<String, usize>,
@@ -29,13 +141,70 @@ pub fn write_generated_file(
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_file_with_mode(
conn,
output_dir,
project_id,
relative_path,
content,
false,
false,
)
}
pub fn write_generated_file_verified(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_file_with_mode(
conn,
output_dir,
project_id,
relative_path,
content,
false,
true,
)
}
pub fn write_generated_file_forced(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_file_with_mode(
conn,
output_dir,
project_id,
relative_path,
content,
true,
false,
)
}
fn write_generated_file_with_mode(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
force: bool,
verify_output: bool,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
if !force
&& let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
&& (!verify_output || file_hash(&target_path)? == hash)
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
@@ -64,13 +233,34 @@ pub fn write_generated_bytes(
project_id: &str,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_bytes_with_force(conn, output_dir, project_id, relative_path, content, false)
}
pub fn write_generated_bytes_forced(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_bytes_with_force(conn, output_dir, project_id, relative_path, content, true)
}
fn write_generated_bytes_with_force(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &[u8],
force: bool,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
if !force
&& let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}

View File

@@ -6,6 +6,17 @@ use serde_json::{Map, Value as JsonValue};
use crate::i18n::translate_render;
const GALLERY_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/macros/gallery.liquid");
const YOUTUBE_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/macros/youtube.liquid");
const VIMEO_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/macros/vimeo.liquid");
const PHOTO_ARCHIVE_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/macros/photo-archive.liquid");
const TAG_CLOUD_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/macros/tag-cloud.liquid");
#[derive(Clone)]
pub(crate) struct MacroRenderContext {
pub roots: Map<String, JsonValue>,
@@ -202,80 +213,48 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
.filter(is_image)
.collect::<Vec<_>>();
images.sort_by(media_newest_first);
if images.is_empty() {
return empty_block(
&format!("macro-gallery gallery-cols-{columns}"),
"gallery-empty",
&render_translation(context, "render.gallery.empty"),
);
}
let gallery_id = format!("gallery-{}", context.post_id.as_deref().unwrap_or_default());
let mut html = format!(
"<section class=\"macro-gallery gallery-cols-{columns}\" data-post-id=\"{}\" data-columns=\"{columns}\" data-lightbox=\"true\"><div class=\"gallery-container gallery-lightbox\">",
escape_html_attr(context.post_id.as_deref().unwrap_or_default()),
);
for image in images {
let Some(path) = image_path(&image) else {
continue;
};
let title = image_title(&image);
let alt = image_alt(&image);
html.push_str(&format!(
"<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
escape_html_attr(&path),
escape_html_attr(&gallery_id),
title
.as_deref()
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
.unwrap_or_default(),
escape_html_attr(&path),
escape_html_attr(&alt),
));
}
html.push_str("</div>");
if let Some(caption) = args
.get("caption")
.map(stringify_scalar)
.filter(|caption| !caption.is_empty())
{
html.push_str(&format!(
"<figcaption class=\"gallery-caption\">{}</figcaption>",
escape_html(&caption)
));
}
html.push_str("</section>");
html
let items = images
.into_iter()
.filter_map(|image| {
Some(serde_json::json!({
"media_path": ensure_leading_slash(&image_path(&image)?),
"title": image_title(&image).unwrap_or_default(),
"alt": image_alt(&image),
"group_name": gallery_id,
}))
})
.collect::<Vec<_>>();
render_macro_template(
macro_template(context, "gallery", GALLERY_TEMPLATE),
serde_json::json!({
"columns": columns,
"post_id": context.post_id.as_deref().unwrap_or_default(),
"items": items,
"caption": args.get("caption").map(stringify_scalar),
"no_items_label": render_translation(context, "render.gallery.empty"),
}),
)
}
fn render_youtube(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() {
return empty_block(
"macro-youtube",
"gallery-empty",
"Missing YouTube video id.",
);
}
let title = macro_title(args, context, "render.video.youtubeTitle");
format!(
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}?rel=0\" title=\"{}\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
escape_html_attr(&video_id),
escape_html_attr(&title),
render_macro_template(
macro_template(context, "youtube", YOUTUBE_TEMPLATE),
serde_json::json!({
"id": args.get("id").map(stringify_scalar).unwrap_or_default(),
"title": macro_title(args, context, "render.video.youtubeTitle"),
}),
)
}
fn render_vimeo(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() {
return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id.");
}
let title = macro_title(args, context, "render.video.vimeoTitle");
format!(
"<section class=\"macro-vimeo\"><iframe src=\"https://player.vimeo.com/video/{}\" title=\"{}\" loading=\"lazy\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></section>",
escape_html_attr(&video_id),
escape_html_attr(&title),
render_macro_template(
macro_template(context, "vimeo", VIMEO_TEMPLATE),
serde_json::json!({
"id": args.get("id").map(stringify_scalar).unwrap_or_default(),
"title": macro_title(args, context, "render.video.vimeoTitle"),
}),
)
}
@@ -305,72 +284,48 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
media.truncate(200);
}
if media.is_empty() {
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
&render_translation(context, "render.photoArchive.empty"),
);
}
let mut grouped = BTreeMap::<(i32, u32), Vec<JsonValue>>::new();
for item in media {
if let Some(bucket) = media_archive_month(&item) {
grouped.entry(bucket).or_default().push(item);
}
}
if grouped.is_empty() {
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
&render_translation(context, "render.photoArchive.empty"),
);
}
let single_month = grouped.len() == 1;
let mut html = format!(
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
if single_month {
" photo-archive-single-month"
} else {
""
}
);
let month_limit = if year.is_none() { 10 } else { usize::MAX };
for ((year, month), items) in grouped.into_iter().rev().take(month_limit) {
let label = format!(
"{} {year}",
render_translation(context, &format!("render.month.{month}"))
);
html.push_str(
"<section class=\"photo-archive-month-wrapper\"><div class=\"photo-archive-month\">",
);
html.push_str(&format!(
"<header class=\"photo-archive-month-label\"><span>{}</span></header>",
escape_html(&label),
));
html.push_str("<div class=\"photo-archive-gallery\">");
for item in items {
let Some(path) = image_path(&item) else {
continue;
};
let title = image_archive_title(&item);
let alt = image_alt(&item);
html.push_str(&format!(
"<a class=\"photo-archive-item\" href=\"{}\" data-lightbox=\"{year}-{month}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
escape_html_attr(&path),
title
.as_deref()
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
.unwrap_or_default(),
escape_html_attr(&path),
escape_html_attr(&alt),
));
}
html.push_str("</div></div></section>");
}
html.push_str("</div></section>");
html
let months = grouped
.into_iter()
.rev()
.take(month_limit)
.map(|((year, month), items)| {
let items = items
.into_iter()
.filter_map(|item| {
Some(serde_json::json!({
"media_path": ensure_leading_slash(&image_path(&item)?),
"title": image_archive_title(&item).unwrap_or_default(),
"alt": image_alt(&item),
"group_name": format!("{year}-{month}"),
}))
})
.collect::<Vec<_>>();
serde_json::json!({
"label": format!(
"{} {year}",
render_translation(context, &format!("render.month.{month}"))
),
"items": items,
})
})
.collect::<Vec<_>>();
render_macro_template(
macro_template(context, "photo-archive", PHOTO_ARCHIVE_TEMPLATE),
serde_json::json!({
"root_classes": "macro-photo-archive",
"data_attrs": [],
"months": months,
"no_items_label": render_translation(context, "render.photoArchive.empty"),
}),
)
}
fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
@@ -380,38 +335,26 @@ fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderCont
.cloned()
.or_else(|| tag_items(context));
let Some(tags) = tags else {
return empty_block(
"macro-tag-cloud",
"tag-cloud-empty",
&render_translation(context, "render.tagCloud.empty"),
);
};
if tags.is_empty() {
return empty_block(
"macro-tag-cloud",
"tag-cloud-empty",
&render_translation(context, "render.tagCloud.empty"),
);
}
let words = build_tag_cloud_words(&tags, context);
if words.is_empty() {
return empty_block(
"macro-tag-cloud",
"tag-cloud-empty",
&render_translation(context, "render.tagCloud.empty"),
);
}
let orientation = normalize_tag_cloud_orientation(args.get("orientation"));
let width = tag_cloud_dimension(args.get("width"), 320, 1600, 900);
let height = tag_cloud_dimension(args.get("height"), 180, 900, 420);
let words_json = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string());
format!(
"<section class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-color-distribution=\"quantile\" data-color-theme=\"pico\" data-color-easing=\"0.7\" data-width=\"{width}\" data-height=\"{height}\" data-orientation=\"{orientation}\" data-tag-cloud-words=\"{}\"><svg class=\"tag-cloud-canvas\" viewBox=\"0 0 {width} {height}\" preserveAspectRatio=\"xMidYMid meet\" aria-label=\"{}\"></svg></section>",
escape_html_attr(&words_json),
escape_html_attr(&render_translation(context, "render.tagCloud.ariaLabel")),
let words_json = tags
.as_deref()
.map(|tags| build_tag_cloud_words(tags, context))
.filter(|words| !words.is_empty())
.and_then(|words| serde_json::to_string(&words).ok())
.map(|words| escape_html_attr(&words));
render_macro_template(
macro_template(context, "tag-cloud", TAG_CLOUD_TEMPLATE),
serde_json::json!({
"orientation": orientation,
"words_json": words_json,
"width": width,
"height": height,
"aria_label": render_translation(context, "render.tagCloud.ariaLabel"),
"no_items_label": render_translation(context, "render.tagCloud.empty"),
}),
)
}
@@ -431,11 +374,7 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
.iter()
.filter_map(|tag| {
let name = tag.get("name").and_then(JsonValue::as_str)?;
let slug = tag
.get("slug")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| name.to_lowercase().replace(' ', "-"));
let encoded_name = encode_path_segment(name);
let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1);
let size = if max_count == min_count {
35.0
@@ -458,7 +397,10 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
let mut word = Map::from_iter([
("text".into(), JsonValue::String(name.into())),
("count".into(), JsonValue::from(count)),
("url".into(), JsonValue::String(format!("/tag/{slug}/"))),
(
"url".into(),
JsonValue::String(format!("/tag/{encoded_name}/")),
),
("size".into(), JsonValue::from(size.round() as u64)),
]);
if let Some(color) = color.filter(|color| !color.is_empty()) {
@@ -482,6 +424,43 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
words
}
fn render_macro_template(template: &str, assigns: JsonValue) -> String {
crate::render::render_liquid_template(template, &HashMap::new(), &assigns).unwrap_or_default()
}
fn macro_template<'a>(context: &'a MacroRenderContext, name: &str, bundled: &'a str) -> &'a str {
context
.roots
.get("macro_templates")
.and_then(JsonValue::as_object)
.and_then(|templates| templates.get(name))
.and_then(JsonValue::as_str)
.unwrap_or(bundled)
}
pub(super) fn bundled_macro_templates() -> HashMap<String, String> {
HashMap::from([
("gallery".into(), GALLERY_TEMPLATE.into()),
("youtube".into(), YOUTUBE_TEMPLATE.into()),
("vimeo".into(), VIMEO_TEMPLATE.into()),
("photo-archive".into(), PHOTO_ARCHIVE_TEMPLATE.into()),
("tag-cloud".into(), TAG_CLOUD_TEMPLATE.into()),
])
}
fn encode_path_segment(value: &str) -> String {
use std::fmt::Write;
value.bytes().fold(String::new(), |mut encoded, byte| {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
write!(encoded, "%{byte:02X}").expect("writing to a String cannot fail");
}
encoded
})
}
fn linked_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
lookup_path("post.linked_media", context)
.and_then(|value| value.as_array().cloned())
@@ -588,6 +567,14 @@ fn image_path(image: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
fn ensure_leading_slash(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn image_title(image: &JsonValue) -> Option<String> {
image
.get("caption")
@@ -679,31 +666,57 @@ fn month_bucket(path: &str) -> Option<(i32, u32)> {
}
}
fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> String {
format!(
"<section class=\"{}\"><p class=\"{}\">{}</p></section>",
wrapper_class,
message_class,
escape_html(message),
)
}
fn escape_html(value: &str) -> String {
fn escape_html_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn escape_html_attr(value: &str) -> String {
escape_html(value)
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
mod tests {
use super::{MacroRenderContext, expand_builtin_macros};
use super::{
GALLERY_TEMPLATE, MacroRenderContext, PHOTO_ARCHIVE_TEMPLATE, TAG_CLOUD_TEMPLATE,
VIMEO_TEMPLATE, YOUTUBE_TEMPLATE, expand_builtin_macros, render_macro_template,
};
fn compact_html(html: &str) -> String {
html.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace("> <", "><")
}
#[test]
fn bundled_macro_templates_use_supported_liquid_syntax() {
for (name, template) in [
("gallery", GALLERY_TEMPLATE),
("youtube", YOUTUBE_TEMPLATE),
("vimeo", VIMEO_TEMPLATE),
("photo-archive", PHOTO_ARCHIVE_TEMPLATE),
("tag-cloud", TAG_CLOUD_TEMPLATE),
] {
let result = crate::render::page_renderer::validate_liquid_template_syntax(template);
assert!(
result.is_ok(),
"{name} macro template must parse: {result:?}"
);
}
}
#[test]
fn macro_template_assignments_do_not_escape_the_isolated_scope() {
let assigns = serde_json::json!({"visible": "outside"});
let rendered = render_macro_template(
"{{ visible }}|{% assign visible = 'inside' %}{{ visible }}",
assigns.clone(),
);
assert_eq!(rendered, "outside|inside");
assert_eq!(assigns["visible"], "outside");
}
#[test]
fn expands_gallery_and_tag_cloud_macros() {
@@ -744,6 +757,192 @@ mod tests {
assert!(html.contains("data-tag-cloud=\"true\""));
}
#[test]
fn built_in_macros_match_the_bds2_template_markup() {
let mut roots = serde_json::Map::new();
roots.insert("language".into(), serde_json::json!("en"));
roots.insert(
"post".into(),
serde_json::json!({
"linked_media": [{
"id": "image-1",
"file_path": "/media/2026/04/one.jpg",
"mime_type": "image/jpeg",
"title": "One & only",
"alt": "Alt <one>"
}]
}),
);
roots.insert(
"project".into(),
serde_json::json!({
"media": [{
"id": "image-1",
"file_path": "/media/2026/04/one.jpg",
"mime_type": "image/jpeg",
"title": "One & only",
"alt": "Alt <one>"
}]
}),
);
roots.insert(
"post_tags".into(),
serde_json::json!([
{"name": "Rust & SQLite", "slug": "ignored", "post_count": 4, "color": "#ff6600"},
{"name": "Iced", "slug": "also-ignored", "post_count": 2}
]),
);
let context = MacroRenderContext {
roots,
post_id: Some("post-1".into()),
..MacroRenderContext::default()
};
let youtube = compact_html(&expand_builtin_macros("[[youtube id=abc123]]", &context));
assert_eq!(
youtube,
compact_html(
"<div class=\"macro-youtube\"> <iframe src=\"https://www.youtube.com/embed/abc123?rel=0\" title=\"YouTube video\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen ></iframe> </div>"
)
);
let vimeo = compact_html(&expand_builtin_macros("[[vimeo id=98765]]", &context));
assert_eq!(
vimeo,
compact_html(
"<div class=\"macro-vimeo\"> <iframe src=\"https://player.vimeo.com/video/98765\" title=\"Vimeo video\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen ></iframe> </div>"
)
);
let gallery = compact_html(&expand_builtin_macros(
"[[gallery columns=2 caption='A caption']]",
&context,
));
assert_eq!(
gallery,
compact_html(
"<div class=\"macro-gallery gallery-cols-2\" data-post-id=\"post-1\" data-columns=\"2\" data-lightbox=\"true\" > <div class=\"gallery-container gallery-lightbox\"><a class=\"gallery-item\" href=\"/media/2026/04/one.jpg\" data-lightbox=\"gallery-post-1\" data-title=\"One &amp; only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt &lt;one&gt;\" loading=\"lazy\" /> </a></div><figcaption class=\"gallery-caption\">A caption</figcaption></div>"
)
);
let archive = compact_html(&expand_builtin_macros("[[photo_archive]]", &context));
assert_eq!(
archive,
compact_html(
"<div class=\"macro-photo-archive\"> <div class=\"photo-archive-container\"><div class=\"photo-archive-month-wrapper\"> <div class=\"photo-archive-month\"> <div class=\"photo-archive-month-label\"> <span>April 2026</span> </div> <div class=\"photo-archive-gallery\"><a class=\"photo-archive-item\" href=\"/media/2026/04/one.jpg\" data-lightbox=\"2026-4\" data-title=\"One &amp; only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt &lt;one&gt;\" loading=\"lazy\" /> </a></div> </div> </div></div> </div>"
)
);
let tag_cloud = compact_html(&expand_builtin_macros(
"[[tag_cloud orientation=diagonal width=640 height=300]]",
&context,
));
assert!(tag_cloud.starts_with(
"<div class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-orientation=\"mixed-diagonal\" data-color-distribution=\"quantile\" data-color-easing=\"0.7\" data-color-theme=\"pico\" data-tag-cloud-words=\""
), "{tag_cloud}");
assert!(tag_cloud.contains("&quot;url&quot;:&quot;/tag/Rust%20%26%20SQLite/&quot;"));
assert!(tag_cloud.contains("data-width=\"640\" data-height=\"300\""));
assert!(tag_cloud.ends_with(
&compact_html("> <svg class=\"tag-cloud-canvas\" viewBox=\"0 0 640 300\" preserveAspectRatio=\"xMidYMid meet\" aria-label=\"Tag cloud\" ></svg></div>")
));
}
#[test]
fn video_macros_with_missing_ids_still_use_the_localized_templates() {
for (language, youtube_title, vimeo_title) in [
("en", "YouTube video", "Vimeo video"),
("de", "YouTube-Video", "Vimeo-Video"),
("fr", "Vidéo YouTube", "Vidéo Vimeo"),
("it", "Video YouTube", "Video Vimeo"),
("es", "Vídeo de YouTube", "Vídeo de Vimeo"),
] {
let mut roots = serde_json::Map::new();
roots.insert("language".into(), serde_json::json!(language));
let context = MacroRenderContext {
roots,
..MacroRenderContext::default()
};
let rendered = expand_builtin_macros("[[youtube]] [[vimeo]]", &context);
assert!(rendered.contains("https://www.youtube.com/embed/?rel=0"));
assert!(rendered.contains("https://player.vimeo.com/video/"));
assert!(rendered.contains(&format!("title=\"{youtube_title}\"")));
assert!(rendered.contains(&format!("title=\"{vimeo_title}\"")));
assert!(!rendered.contains("Missing"));
}
}
#[test]
fn empty_macro_states_use_render_domain_translations() {
let mut roots = serde_json::Map::new();
roots.insert("language".into(), serde_json::json!("de"));
let context = MacroRenderContext {
roots,
..MacroRenderContext::default()
};
let rendered =
expand_builtin_macros("[[gallery]] [[photo_archive]] [[tag_cloud]]", &context);
assert!(rendered.contains("Keine verknüpften Bilder gefunden."));
assert!(rendered.contains("Keine Fotos für dieses Archiv gefunden."));
assert!(rendered.contains("Keine Tags gefunden."));
}
#[test]
fn project_macro_templates_override_the_bundled_defaults() {
let mut roots = serde_json::Map::new();
roots.insert(
"macro_templates".into(),
serde_json::json!({
"gallery": "<aside data-columns=\"{{ columns }}\">custom gallery</aside>"
}),
);
let rendered = expand_builtin_macros(
"[[gallery columns=4]]",
&MacroRenderContext {
roots,
..MacroRenderContext::default()
},
);
assert_eq!(rendered, "<aside data-columns=\"4\">custom gallery</aside>");
}
#[test]
fn template_rendering_preserves_macro_clamps_aliases_and_archive_filters() {
let mut roots = serde_json::Map::new();
roots.insert(
"project".into(),
serde_json::json!({
"media": [
{"file_path": "/media/2026/04/new.jpg", "mime_type": "image/jpeg"},
{"file_path": "/media/2025/03/old.jpg", "mime_type": "image/jpeg"}
]
}),
);
roots.insert(
"post_tags".into(),
serde_json::json!([{"name": "Rust", "post_count": 1}]),
);
let context = MacroRenderContext {
roots,
..MacroRenderContext::default()
};
let rendered = expand_builtin_macros(
"[[gallery columns=99]] [[tag_cloud orientation=hv width=99999 height=1]] [[photo_archive year=2025 month=3]]",
&context,
);
assert!(rendered.contains("gallery-cols-6"));
assert!(rendered.contains("data-orientation=\"mixed-hv\""));
assert!(rendered.contains("data-width=\"900\" data-height=\"420\""));
assert!(rendered.contains("/media/2025/03/old.jpg"));
assert!(!rendered.contains("/media/2026/04/new.jpg"));
}
#[test]
fn leaves_unknown_macros_verbatim() {
let markdown = "Before [[future_macro value='x']] after";

View File

@@ -6,12 +6,14 @@ mod routes;
mod site;
mod template_lookup;
pub(crate) use generation::GeneratedFileWriter;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
write_generated_bytes, write_generated_file,
write_generated_bytes, write_generated_bytes_forced, write_generated_file,
write_generated_file_forced, write_generated_file_verified,
};
pub use markdown::render_markdown_to_html;
pub(crate) use page_renderer::render_liquid_template_with_host;
pub(crate) use page_renderer::validate_liquid_template_syntax;
pub use page_renderer::{RenderError, render_liquid_template};
pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
pub use routes::{
@@ -19,10 +21,13 @@ pub use routes::{
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
};
pub(crate) use site::count_site_render_pages_from_context;
pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
build_site_render_artifacts, build_site_section_render_artifacts,
build_targeted_site_section_render_artifacts,
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext,
build_preview_response, build_site_render_artifacts, build_site_render_artifacts_from_context,
build_site_route_manifest, build_site_section_render_artifacts,
build_targeted_site_section_render_artifacts, estimate_site_render_pages,
prepare_site_render_context,
};
pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,

View File

@@ -1,10 +1,10 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::{Arc, Mutex, OnceLock};
use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::model::ScalarCow;
use liquid_core::model::{Object, ScalarCow, ValueCow};
use liquid_core::parser::FilterArguments;
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
@@ -18,7 +18,7 @@ use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html;
use crate::scripting::{HostApi, UnavailableHost};
use crate::util::slugify;
use crate::util::{content_hash, slugify};
#[derive(Debug, Error)]
pub enum RenderError {
@@ -26,6 +26,59 @@ pub enum RenderError {
Liquid(#[from] liquid::Error),
}
#[derive(Clone)]
pub(crate) struct CompiledLiquidTemplate(Arc<liquid::Template>);
#[derive(Clone)]
pub(crate) struct LiquidRenderer {
parser: liquid::Parser,
}
impl LiquidRenderer {
pub(crate) fn new(
partials: &HashMap<String, String>,
host: Arc<dyn HostApi>,
) -> Result<Self, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
compiled_partials.add(format!("{name}.liquid"), content.clone());
}
Ok(Self {
parser: liquid_parser_builder(host, Default::default())
.partials(compiled_partials)
.build()?,
})
}
pub(crate) fn compile(&self, source: &str) -> Result<CompiledLiquidTemplate, RenderError> {
Ok(CompiledLiquidTemplate(Arc::new(self.parser.parse(source)?)))
}
pub(crate) fn render<T: Serialize>(
&self,
template: &CompiledLiquidTemplate,
context: &T,
) -> Result<String, RenderError> {
let globals = liquid::to_object(context)?;
Ok(template.0.render(&globals)?)
}
pub(crate) fn render_with_base<T: Serialize>(
&self,
template: &CompiledLiquidTemplate,
base: &Object,
context: &T,
) -> Result<String, RenderError> {
let page = liquid::to_object(context)?;
let globals = base
.iter()
.chain(page.iter())
.map(|(key, value)| (key.to_string(), ValueCow::from(value)))
.collect::<HashMap<_, _>>();
Ok(template.0.render(&globals)?)
}
}
#[derive(Debug, Clone, Default)]
struct HtmlRewriteContext {
canonical_post_path_by_slug: HashMap<String, String>,
@@ -51,20 +104,31 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
context: &T,
host: Arc<dyn HostApi>,
) -> Result<String, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
compiled_partials.add(format!("{name}.liquid"), content.clone());
}
let renderer = LiquidRenderer::new(partials, host)?;
let template = renderer.compile(template_source)?;
renderer.render(&template, context)
}
let parser = ParserBuilder::with_stdlib()
pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(), String> {
let parser = liquid_parser_builder(Arc::new(UnavailableHost), Default::default())
.build()
.map_err(|error| error.to_string())?;
parser
.parse(template_source)
.map(|_| ())
.map_err(|error| error.to_string())
}
type MarkdownCache = Arc<Mutex<HashMap<MarkdownCacheKey, Arc<OnceLock<String>>>>>;
fn liquid_parser_builder(host: Arc<dyn HostApi>, markdown_cache: MarkdownCache) -> ParserBuilder {
ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown { host })
.filter(Markdown {
host,
cache: markdown_cache,
})
.filter(Slugify)
.partials(compiled_partials)
.build()?;
let template = parser.parse(template_source)?;
let globals = liquid::to_object(context)?;
Ok(template.render(&globals)?)
}
#[derive(Clone, ParseFilter, FilterReflection)]
@@ -148,6 +212,7 @@ struct MarkdownArgs {
)]
struct Markdown {
host: Arc<dyn HostApi>,
cache: MarkdownCache,
}
impl ParseFilter for Markdown {
@@ -155,6 +220,7 @@ impl ParseFilter for Markdown {
Ok(Box::new(MarkdownFilter {
args: MarkdownArgs::from_args(args)?,
host: Arc::clone(&self.host),
cache: Arc::clone(&self.cache),
}))
}
@@ -168,6 +234,7 @@ impl ParseFilter for Markdown {
struct MarkdownFilter {
args: MarkdownArgs,
host: Arc<dyn HostApi>,
cache: MarkdownCache,
}
impl fmt::Debug for MarkdownFilter {
@@ -194,31 +261,58 @@ impl Filter for MarkdownFilter {
.map(value_to_string_map)
.unwrap_or_default(),
};
let macro_context = MacroRenderContext {
roots: collect_macro_roots(runtime),
post_id: args
.post_id
.as_ref()
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
.or_else(|| {
runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| {
value.as_scalar().map(|scalar| scalar.to_kstr().to_string())
})
}),
host: Arc::clone(&self.host),
let post_id = args
.post_id
.as_ref()
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
.or_else(|| {
runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
});
let language = args
.language
.as_ref()
.and_then(|value| value.as_scalar())
.map(|scalar| scalar.to_kstr().to_string())
.unwrap_or_default();
let key = MarkdownCacheKey {
content_hash: content_hash(markdown.as_bytes()),
language,
post_id: post_id.clone(),
};
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
let rendered = render_markdown_to_html(&expanded);
Ok(Value::scalar(rewrite_rendered_html_urls(
&rendered,
&rewrite_context,
)))
let entry = {
let mut cache = self.cache.lock().unwrap_or_else(|error| error.into_inner());
Arc::clone(cache.entry(key).or_default())
};
Ok(Value::scalar(
entry
.get_or_init(|| {
let rendered = if markdown.contains("[[") {
let macro_context = MacroRenderContext {
roots: collect_macro_roots(runtime),
post_id,
host: Arc::clone(&self.host),
};
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
render_markdown_to_html(&expanded)
} else {
render_markdown_to_html(markdown.as_str())
};
rewrite_rendered_html_urls(&rendered, &rewrite_context)
})
.clone(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct MarkdownCacheKey {
content_hash: String,
language: String,
post_id: Option<String>,
}
fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
let mut roots = JsonMap::new();
@@ -230,6 +324,7 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
"project",
"Tags",
"macro_scripts",
"macro_templates",
"language",
"language_prefix",
"main_language",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine as _;
use chrono::{SecondsFormat, TimeZone, Utc};
@@ -207,6 +208,7 @@ impl CoreHost {
match method {
"get_project_metadata" => public_metadata(&self.data_dir),
"update_project_metadata" | "set_project_metadata" => {
let db = self.database()?;
let mut metadata = engine::meta::read_project_json(&self.data_dir)?;
let updates = object_arg(args, 0)?;
assign_string(updates, "name", &mut metadata.name);
@@ -218,15 +220,33 @@ impl CoreHost {
metadata.blog_languages = languages;
}
metadata.validate().map_err(text)?;
engine::meta::write_project_json(&self.data_dir, &metadata)?;
let project = project::get_project_by_id(db.conn(), &self.project_id)?;
engine::meta::update_project_metadata(
db.conn(),
&self.data_dir,
&project,
&metadata,
)?;
public_metadata(&self.data_dir)
}
"add_category" => {
engine::meta::add_category(&self.data_dir, string_arg(args, 0)?)?;
let db = self.database()?;
engine::meta::add_category(
db.conn(),
&self.data_dir,
&self.project_id,
string_arg(args, 0)?,
)?;
public_metadata(&self.data_dir)
}
"remove_category" => {
engine::meta::remove_category(&self.data_dir, string_arg(args, 0)?)?;
let db = self.database()?;
engine::meta::remove_category(
db.conn(),
&self.data_dir,
&self.project_id,
string_arg(args, 0)?,
)?;
public_metadata(&self.data_dir)
}
"add_tag" => self.meta_tag(string_arg(args, 0)?, true),
@@ -242,18 +262,36 @@ impl CoreHost {
json_value(engine::meta::read_publishing_json(&self.data_dir))
}
"set_publishing_preferences" => {
let db = self.database()?;
let prefs = serde_json::from_value(Value::Object(object_arg(args, 0)?.clone()))
.map_err(text)?;
engine::meta::write_publishing_json(&self.data_dir, &prefs)?;
engine::meta::set_publishing_preferences(
db.conn(),
&self.data_dir,
&self.project_id,
&prefs,
)?;
json_value(engine::meta::read_publishing_json(&self.data_dir))
}
"clear_publishing_preferences" => {
let db = self.database()?;
let prefs = Default::default();
engine::meta::write_publishing_json(&self.data_dir, &prefs)?;
engine::meta::set_publishing_preferences(
db.conn(),
&self.data_dir,
&self.project_id,
&prefs,
)?;
json_value(Ok::<_, EngineError>(prefs))
}
"sync_on_startup" => {
let db = self.database()?;
engine::meta::startup_sync(&self.data_dir)?;
engine::meta::initialize_metadata_snapshots(
db.conn(),
&self.data_dir,
&self.project_id,
)?;
Ok(json!({
"metadata": public_metadata(&self.data_dir)?,
"categories": engine::meta::read_categories_json(&self.data_dir)?,
@@ -844,21 +882,16 @@ impl CoreHost {
Ok(json!({"generated": true, "media_id": item.id}))
}
"regenerate_missing_thumbnails" => {
let mut generated = 0;
for item in media::list_media_by_project(db.conn(), &self.project_id)? {
let path = self
.data_dir
.join(crate::util::thumbnail_path(&item.id, "small", "webp"));
if !path.is_file() {
crate::util::thumbnail::generate_all_thumbnails(
&self.data_dir.join(&item.file_path),
&self.data_dir.join("thumbnails"),
&item.id,
)?;
generated += 1;
}
}
Ok(json!({"generated": generated}))
let report = engine::media::regenerate_missing_thumbnails(
db.conn(),
&self.data_dir,
&self.project_id,
)?;
Ok(json!({
"processed": report.media_processed,
"generated": report.thumbnails_generated,
"failed": report.media_failed,
}))
}
"reindex_text" => {
engine::search::reindex_project(db.conn(), &self.project_id, None)?;
@@ -894,6 +927,7 @@ impl CoreHost {
let data = object_arg(args, 1)?;
public_script(engine::script::update_script(
db.conn(),
&self.data_dir,
id,
&self.project_id,
optional_string_field(data, "title"),
@@ -976,6 +1010,7 @@ impl CoreHost {
let data = object_arg(args, 1)?;
public_template(engine::template::update_template(
db.conn(),
&self.data_dir,
id,
&self.project_id,
optional_string_field(data, "title"),
@@ -1181,33 +1216,39 @@ impl CoreHost {
"get" => {
let id = task_id_arg(args, 0)?;
manager
.snapshots()
.into_iter()
.find(|task| task.id == id)
.get(id)
.map(public_task)
.transpose()
.map(|value| value.unwrap_or(Value::Null))
}
"get_all" => public_tasks(manager.snapshots()),
"get_running" => public_tasks(
manager
.snapshots()
.into_iter()
.filter(|task| task.status == TaskStatus::Running)
.collect(),
),
"get_all" => public_tasks(manager.all()),
"get_running" => public_tasks(manager.running()),
"status_snapshot" => {
let tasks = manager.snapshots();
let active = tasks
.iter()
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
.collect::<Vec<_>>();
let running_task_message = active.first().map(|task| {
if task.status == TaskStatus::Pending {
format!("Queued: {}", task.label)
} else if let Some(message) =
task.message.as_deref().filter(|value| !value.is_empty())
{
format!("{}: {message}", task.label)
} else {
task.label.clone()
}
});
Ok(json!({
"active_count": tasks.iter().filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)).count(),
"active_count": active.len(),
"running_count": manager.running_count(), "pending_count": manager.pending_count(),
"running_task_message": running_task_message,
"running_task_overflow": active.len().saturating_sub(1),
"tasks": public_tasks(tasks)?,
}))
}
"cancel" => {
manager.cancel(task_id_arg(args, 0)?);
Ok(Value::Bool(true))
}
"cancel" => Ok(Value::Bool(manager.cancel(task_id_arg(args, 0)?))),
"clear_completed" => {
manager.clear_completed();
Ok(Value::Bool(true))
@@ -1321,10 +1362,17 @@ impl CoreHost {
}
let manager = self.task_manager.clone();
let task_id = self.task_id;
engine::publishing::upload_site(
let cancel_flag = manager
.as_ref()
.zip(task_id)
.and_then(|(manager, task_id)| manager.cancellation_flag(task_id))
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
let progress_cancel_flag = Arc::clone(&cancel_flag);
engine::publishing::upload_site_cancellable(
&self.data_dir,
&self.private_cache_dir,
&preferences,
cancel_flag.as_ref(),
move |current, total, _| {
if let (Some(manager), Some(task_id)) = (&manager, task_id) {
manager.report_progress(
@@ -1333,6 +1381,7 @@ impl CoreHost {
Some("uploading site".into()),
);
}
!progress_cancel_flag.load(Ordering::Acquire)
},
)?;
match (&self.task_manager, self.task_id) {
@@ -1625,6 +1674,8 @@ fn public_tag(value: Tag) -> HostResult<Value> {
}
fn public_task(value: TaskSnapshot) -> HostResult<Value> {
let cancellable = matches!(value.status, TaskStatus::Pending | TaskStatus::Running)
&& !value.cancellation_requested;
let (status, error) = match value.status {
TaskStatus::Pending => ("pending", None),
TaskStatus::Running => ("running", None),
@@ -1634,6 +1685,9 @@ fn public_task(value: TaskSnapshot) -> HostResult<Value> {
};
Ok(
json!({"id":value.id.to_string(),"name":value.label,"status":status,
"group_id":value.group_id,"group_name":value.group_name,
"cancellable":cancellable,
"cancellation_requested":value.cancellation_requested,
"progress":value.progress,"message":value.message.or(error)}),
)
}
@@ -2278,6 +2332,7 @@ mod tests {
post = bds.posts.get(post.id).title,
script = script.kind,
template = template.kind,
template_validation = bds.templates.validate("{{ title | upcase }}"),
tag = tag.name,
media = media.title,
category = metadata.categories[1],
@@ -2304,6 +2359,11 @@ mod tests {
assert_eq!(result.value["post"], "Lua post");
assert_eq!(result.value["script"], "utility");
assert_eq!(result.value["template"], "post");
assert_eq!(result.value["template_validation"]["valid"], false);
assert_eq!(
result.value["template_validation"]["errors"][0],
"unsupported filter: upcase"
);
assert_eq!(result.value["tag"], "lua");
assert_eq!(result.value["media"], "Close");
assert!(result.value["foreign"].is_null());

View File

@@ -395,6 +395,10 @@ fn field_description(field: &str) -> &'static str {
"updated" => "Whether the Git network command completed successfully.",
"progress" => "Completion value reported by the task.",
"message" => "Latest user-facing task message.",
"cancellable" => "Whether a cancellation request can still be submitted.",
"cancellation_requested" => "Whether cooperative cancellation is in progress.",
"group_id" => "Identifier shared by tasks in one workflow.",
"group_name" => "Human-readable workflow name.",
"created_at" => "Creation timestamp.",
"updated_at" => "Last-update timestamp.",
"language" | "main_language" => "BCP 47 language code.",

View File

@@ -1,7 +1,9 @@
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use mlua::chunk::ChunkMode;
use mlua::{
Function, HookTriggers, Lua, LuaOptions, LuaSerdeExt, MultiValue, StdLib, Value, VmState,
};
@@ -15,9 +17,69 @@ pub use manifest::{ApiManifest, api_manifest};
const MACRO_TIMEOUT: Duration = Duration::from_secs(10);
const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
const BYTECODE_CACHE_MAX_ENTRIES: usize = 32;
const MAX_TOASTS_PER_SCRIPT: usize = 5;
const MAX_TOAST_LENGTH: usize = 300;
struct BytecodeCache {
entries: HashMap<String, Arc<[u8]>>,
order: VecDeque<String>,
max_entries: usize,
}
impl BytecodeCache {
fn new(max_entries: usize) -> Self {
assert!(max_entries > 0);
Self {
entries: HashMap::new(),
order: VecDeque::new(),
max_entries,
}
}
fn get_or_compile(
&mut self,
source: &str,
compile: impl FnOnce() -> Result<Vec<u8>, String>,
) -> Result<Arc<[u8]>, String> {
if let Some(bytecode) = self.entries.get(source) {
return Ok(Arc::clone(bytecode));
}
let bytecode: Arc<[u8]> = compile()?.into();
if self.entries.len() == self.max_entries
&& let Some(oldest) = self.order.pop_front()
{
self.entries.remove(&oldest);
}
let key = source.to_string();
self.order.push_back(key.clone());
self.entries.insert(key, Arc::clone(&bytecode));
Ok(bytecode)
}
}
fn bytecode_cache() -> &'static Mutex<BytecodeCache> {
static CACHE: OnceLock<Mutex<BytecodeCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(BytecodeCache::new(BYTECODE_CACHE_MAX_ENTRIES)))
}
fn lock_bytecode_cache(cache: &Mutex<BytecodeCache>) -> MutexGuard<'_, BytecodeCache> {
cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn compiled_chunk(lua: &Lua, source: &str) -> Result<Arc<[u8]>, String> {
lock_bytecode_cache(bytecode_cache()).get_or_compile(source, || {
lua.load(source)
.set_name("script")
.into_function()
.map(|function| function.dump(false))
.map_err(|error| error.to_string())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionKind {
Macro,
@@ -93,11 +155,7 @@ impl ExecutionControl {
/// Parse Lua source with the same runtime used for execution.
pub fn validate(source: &str) -> Result<(), String> {
let lua = sandboxed_lua()?;
lua.load(source)
.set_name("script")
.into_function()
.map(|_| ())
.map_err(|error| error.to_string())
compiled_chunk(&lua, source).map(|_| ())
}
/// Execute a named Lua entrypoint with one JSON-compatible argument.
@@ -192,8 +250,10 @@ pub fn execute_many_with_host(
)
.map_err(|error| error.to_string())?;
lua.load(source)
let bytecode = compiled_chunk(&lua, source)?;
lua.load(bytecode.as_ref())
.set_name("script")
.set_mode(ChunkMode::Binary)
.exec()
.map_err(|error| error.to_string())?;
let function: Function = lua
@@ -800,6 +860,156 @@ mod tests {
assert!(validate("function main( return 1 end").is_err());
}
#[test]
fn bytecode_cache_reuses_exact_source_and_misses_edits() {
let mut cache = BytecodeCache::new(2);
let mut compilations = 0;
let first = cache
.get_or_compile("return 1", || {
compilations += 1;
Ok(vec![1])
})
.unwrap();
let repeated = cache
.get_or_compile("return 1", || {
compilations += 1;
Ok(vec![2])
})
.unwrap();
let edited = cache
.get_or_compile("return 2", || {
compilations += 1;
Ok(vec![2])
})
.unwrap();
assert!(Arc::ptr_eq(&first, &repeated));
assert!(!Arc::ptr_eq(&first, &edited));
assert_eq!(compilations, 2);
}
#[test]
fn bytecode_cache_excludes_failures_and_evicts_oldest() {
let mut cache = BytecodeCache::new(2);
assert!(
cache
.get_or_compile("invalid", || Err("invalid source".to_string()))
.is_err()
);
assert!(!cache.entries.contains_key("invalid"));
cache.get_or_compile("one", || Ok(vec![1])).unwrap();
cache.get_or_compile("two", || Ok(vec![2])).unwrap();
cache.get_or_compile("three", || Ok(vec![3])).unwrap();
assert!(!cache.entries.contains_key("one"));
assert!(cache.entries.contains_key("two"));
assert!(cache.entries.contains_key("three"));
}
#[test]
fn bytecode_cache_lock_recovers_from_poisoning() {
let cache = Arc::new(Mutex::new(BytecodeCache::new(2)));
let poisoned = Arc::clone(&cache);
let _ = std::thread::spawn(move || {
let _guard = poisoned.lock().unwrap();
panic!("poison cache");
})
.join();
lock_bytecode_cache(&cache)
.get_or_compile("return 1", || Ok(vec![1]))
.unwrap();
}
#[test]
fn bytecode_cache_compiles_once_under_concurrent_access() {
let cache = Arc::new(Mutex::new(BytecodeCache::new(2)));
let compilations = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let workers = (0..8)
.map(|_| {
let cache = Arc::clone(&cache);
let compilations = Arc::clone(&compilations);
std::thread::spawn(move || {
lock_bytecode_cache(&cache)
.get_or_compile("return 1", || {
compilations.fetch_add(1, Ordering::Relaxed);
Ok(vec![1])
})
.unwrap()
})
})
.collect::<Vec<_>>();
for worker in workers {
worker.join().unwrap();
}
assert_eq!(compilations.load(Ordering::Relaxed), 1);
}
#[test]
fn cached_execution_preserves_results_errors_and_fresh_globals() {
let source = r#"
counter = (counter or 0) + 1
function main(input)
if input.fail then error("expected failure") end
return { counter = counter, value = input.value }
end
"#;
validate(source).unwrap();
for value in ["first", "second"] {
let result = execute(
source,
"main",
&json!({"value": value}),
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap();
assert_eq!(result.value, json!({"counter": 1, "value": value}));
}
let error = execute(
source,
"main",
&json!({"fail": true}),
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap_err();
assert!(error.contains("expected failure"));
assert!(error.contains("script"));
assert!(error.contains(":4:"), "{error}");
}
#[test]
fn validation_warms_the_execution_cache() {
let source = "-- validation cache sharing\nfunction main() return 7 end";
validate(source).unwrap();
let validated = lock_bytecode_cache(bytecode_cache())
.entries
.get(source)
.cloned()
.unwrap();
let result = execute(
source,
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap();
let executed = lock_bytecode_cache(bytecode_cache())
.entries
.get(source)
.cloned()
.unwrap();
assert_eq!(result.value, json!(7));
assert!(Arc::ptr_eq(&validated, &executed));
}
#[test]
fn enforces_per_script_toast_budget_and_length() {
let result = execute(

View File

@@ -25,6 +25,26 @@ pub fn file_hash(path: &Path) -> std::io::Result<String> {
Ok(encode_hex(hasher.finalize()))
}
/// Compute the bDS2-compatible lower-hex MD5 change signal for media bytes.
pub fn media_content_hash(content: &[u8]) -> String {
format!("{:x}", md5::compute(content))
}
/// Compute the bDS2-compatible lower-hex MD5 change signal for a media file.
pub fn media_file_hash(path: &Path) -> std::io::Result<String> {
let mut file = std::fs::File::open(path)?;
let mut context = md5::Context::new();
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
context.consume(&buf[..n]);
}
Ok(format!("{:x}", context.finalize()))
}
fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
bytes
.as_ref()
@@ -55,4 +75,13 @@ mod tests {
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn media_hash_matches_bds2_fixture() {
// Base.encode16(:crypto.hash(:md5, "hello"), case: :lower) in bDS2.
assert_eq!(
media_content_hash(b"hello"),
"5d41402abc4b2a76b9719d911017c592"
);
}
}

View File

@@ -1,6 +1,12 @@
use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
use regex::Regex;
use serde::{Deserialize, Deserializer};
use std::sync::LazyLock;
static SIMPLE_YAML_STRING: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[\p{L}\p{N} ._/-]+$").expect("canonical frontmatter regex is valid")
});
fn scalar_string(value: serde_yaml::Value) -> Option<String> {
match value {
@@ -35,31 +41,6 @@ where
Ok(deserialize_optional_scalar_string(deserializer)?.filter(|value| !value.is_empty()))
}
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(|value| value.as_str().map(str::to_owned)))
}
fn deserialize_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
serde_yaml::Value::deserialize(deserializer)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| serde::de::Error::custom("expected a string"))
}
fn deserialize_optional_nonempty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_string(deserializer)?.filter(|value| !value.is_empty()))
}
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
@@ -89,13 +70,6 @@ where
.and_then(|value| iso_to_unix_ms(&value).ok()))
}
fn deserialize_optional_string_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_string(deserializer)?.and_then(|value| iso_to_unix_ms(&value).ok()))
}
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
@@ -126,6 +100,10 @@ fn default_entrypoint() -> String {
"render".to_owned()
}
fn default_entrypoint_for_kind(kind: &str) -> String {
if kind == "macro" { "render" } else { "main" }.to_owned()
}
fn deserialize_version<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
@@ -163,7 +141,7 @@ pub fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
/// Format frontmatter + body into a complete file string.
pub fn format_frontmatter(yaml: &str, body: &str) -> String {
format!("---\n{yaml}\n---\n{body}")
format!("---\n{yaml}\n---\n{}\n", body.trim_end_matches('\n'))
}
// --- Post Frontmatter ---
@@ -235,51 +213,27 @@ impl PostFrontmatter {
}
}
/// Serialize to YAML string (matching TypeScript gray-matter output).
/// Serialize using the canonical bDS2 frontmatter field and scalar rules.
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: {}", self.id));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
lines.push(format!("slug: {}", self.slug));
lines.push(format!("status: {}", self.status));
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
// Tags as YAML list
if self.tags.is_empty() {
lines.push("tags: []".to_string());
} else {
lines.push("tags:".to_string());
for tag in &self.tags {
lines.push(format!(" - {}", yaml_string_value(tag)));
}
}
// Categories as YAML list
if self.categories.is_empty() {
lines.push("categories: []".to_string());
} else {
lines.push("categories:".to_string());
for cat in &self.categories {
lines.push(format!(" - {}", yaml_string_value(cat)));
}
}
// Conditional fields (only when truthy)
push_yaml_string(&mut lines, "id", &self.id);
push_yaml_string(&mut lines, "title", &self.title);
push_yaml_string(&mut lines, "slug", &self.slug);
if let Some(ref excerpt) = self.excerpt
&& !excerpt.is_empty()
{
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
push_yaml_string(&mut lines, "excerpt", excerpt);
}
push_yaml_string(&mut lines, "status", &self.status);
if let Some(ref author) = self.author
&& !author.is_empty()
{
lines.push(format!("author: {}", yaml_string_value(author)));
push_yaml_string(&mut lines, "author", author);
}
if let Some(ref language) = self.language
&& !language.is_empty()
{
lines.push(format!("language: {language}"));
push_yaml_string(&mut lines, "language", language);
}
if self.do_not_translate {
lines.push("doNotTranslate: true".to_string());
@@ -287,11 +241,15 @@ impl PostFrontmatter {
if let Some(ref template_slug) = self.template_slug
&& !template_slug.is_empty()
{
lines.push(format!("templateSlug: {template_slug}"));
push_yaml_string(&mut lines, "templateSlug", template_slug);
}
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
}
serialize_yaml_list(&mut lines, "tags", &self.tags);
serialize_yaml_list(&mut lines, "categories", &self.categories);
lines.join("\n")
}
@@ -312,7 +270,7 @@ pub fn write_post_file(post: &Post, body: &str) -> String {
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = PostFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
Ok((fm, body.trim_end_matches('\n').to_string()))
}
// --- Translation Frontmatter ---
@@ -321,23 +279,26 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TranslationFrontmatter {
#[serde(default, deserialize_with = "deserialize_optional_string")]
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
pub id: Option<String>,
#[serde(deserialize_with = "deserialize_string")]
#[serde(deserialize_with = "deserialize_scalar_string")]
pub translation_for: String,
#[serde(deserialize_with = "deserialize_string")]
#[serde(deserialize_with = "deserialize_scalar_string")]
pub language: String,
#[serde(deserialize_with = "deserialize_string")]
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(default, deserialize_with = "deserialize_optional_nonempty_string")]
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub excerpt: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
pub status: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
pub created_at: Option<i64>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
pub updated_at: Option<i64>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
pub published_at: Option<i64>,
}
@@ -359,18 +320,18 @@ impl TranslationFrontmatter {
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
if let Some(id) = &self.id {
lines.push(format!("id: {id}"));
push_yaml_string(&mut lines, "id", id);
}
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
push_yaml_string(&mut lines, "translationFor", &self.translation_for);
push_yaml_string(&mut lines, "language", &self.language);
push_yaml_string(&mut lines, "title", &self.title);
if let Some(ref excerpt) = self.excerpt
&& !excerpt.is_empty()
{
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
push_yaml_string(&mut lines, "excerpt", excerpt);
}
if let Some(status) = &self.status {
lines.push(format!("status: {status}"));
push_yaml_string(&mut lines, "status", status);
}
if let Some(created_at) = self.created_at {
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(created_at)));
@@ -399,12 +360,12 @@ pub fn write_translation_file(translation: &PostTranslation, body: &str) -> Stri
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = TranslationFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
Ok((fm, body.trim_end_matches('\n').to_string()))
}
// --- Template Frontmatter ---
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
/// Parsed template frontmatter.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateFrontmatter {
@@ -432,26 +393,20 @@ pub struct TemplateFrontmatter {
}
impl TemplateFrontmatter {
/// Serialize to YAML with double-quoted strings (matching TypeScript).
/// Serialize using the canonical bDS2 frontmatter scalar rules.
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: \"{}\"", self.id));
push_yaml_string(&mut lines, "id", &self.id);
if let Some(ref pid) = self.project_id {
lines.push(format!("projectId: \"{pid}\""));
push_yaml_string(&mut lines, "projectId", pid);
}
lines.push(format!("slug: \"{}\"", self.slug));
lines.push(format!("title: \"{}\"", self.title));
lines.push(format!("kind: \"{}\"", self.kind));
push_yaml_string(&mut lines, "slug", &self.slug);
push_yaml_string(&mut lines, "title", &self.title);
push_yaml_string(&mut lines, "kind", &self.kind);
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!(
"createdAt: \"{}\"",
unix_ms_to_iso(self.created_at)
));
lines.push(format!(
"updatedAt: \"{}\"",
unix_ms_to_iso(self.updated_at)
));
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
lines.join("\n")
}
@@ -464,7 +419,7 @@ impl TemplateFrontmatter {
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = TemplateFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
Ok((fm, body.trim_end_matches('\n').to_string()))
}
/// Write a template file (frontmatter + body).
@@ -474,7 +429,7 @@ pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
// --- Script Frontmatter ---
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
/// Parsed script frontmatter (template fields plus entrypoint).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScriptFrontmatter {
@@ -507,32 +462,37 @@ pub struct ScriptFrontmatter {
}
impl ScriptFrontmatter {
/// Serialize to YAML with double-quoted strings.
/// Serialize using the canonical bDS2 frontmatter scalar rules.
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: \"{}\"", self.id));
push_yaml_string(&mut lines, "id", &self.id);
if let Some(ref pid) = self.project_id {
lines.push(format!("projectId: \"{pid}\""));
push_yaml_string(&mut lines, "projectId", pid);
}
lines.push(format!("slug: \"{}\"", self.slug));
lines.push(format!("title: \"{}\"", self.title));
lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
push_yaml_string(&mut lines, "slug", &self.slug);
push_yaml_string(&mut lines, "title", &self.title);
push_yaml_string(&mut lines, "kind", &self.kind);
push_yaml_string(&mut lines, "entrypoint", &self.entrypoint);
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!(
"createdAt: \"{}\"",
unix_ms_to_iso(self.created_at)
));
lines.push(format!(
"updatedAt: \"{}\"",
unix_ms_to_iso(self.updated_at)
));
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
let value: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))?;
let entrypoint_key = serde_yaml::Value::String("entrypoint".to_owned());
let entrypoint_is_missing = value
.as_mapping()
.is_none_or(|mapping| !mapping.contains_key(&entrypoint_key));
let mut frontmatter: Self =
serde_yaml::from_value(value).map_err(|error| format!("YAML parse error: {error}"))?;
if entrypoint_is_missing {
frontmatter.entrypoint = default_entrypoint_for_kind(&frontmatter.kind);
}
Ok(frontmatter)
}
}
@@ -540,7 +500,7 @@ impl ScriptFrontmatter {
pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = ScriptFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
Ok((fm, body.trim_end_matches('\n').to_string()))
}
/// Write a script file (always Lua format with --- delimiters).
@@ -550,50 +510,31 @@ pub fn write_script_file(fm: &ScriptFrontmatter, body: &str) -> String {
// --- Helpers ---
/// Quote a YAML string value if it contains special characters.
/// Simple values (alphanumeric, hyphens, dots) are left unquoted.
/// Values with colons, quotes, special chars are single-quoted.
fn yaml_string_value(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
// Check if the value needs quoting
let needs_quoting = s.contains(':')
|| s.contains('#')
|| s.contains('\'')
|| s.contains('"')
|| s.contains('\n')
|| s.contains('{')
|| s.contains('}')
|| s.contains('[')
|| s.contains(']')
|| s.contains(',')
|| s.contains('&')
|| s.contains('*')
|| s.contains('!')
|| s.contains('|')
|| s.contains('>')
|| s.contains('%')
|| s.contains('@')
|| s.contains('`')
|| s.starts_with(' ')
|| s.ends_with(' ')
|| s.starts_with('-')
|| s.starts_with('?')
|| s == "true"
|| s == "false"
|| s == "null"
|| s == "yes"
|| s == "no"
|| s == "on"
|| s == "off";
if needs_quoting {
// Use single quotes, escaping internal single quotes by doubling them
let escaped = s.replace('\'', "''");
format!("'{escaped}'")
} else {
if SIMPLE_YAML_STRING.is_match(s) {
s.to_string()
} else {
format!(
"\"{}\"",
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
)
}
}
fn serialize_yaml_list(lines: &mut Vec<String>, key: &str, values: &[String]) {
lines.push(format!("{key}:"));
lines.extend(
values
.iter()
.map(|value| format!(" - {}", yaml_string_value(value))),
);
}
fn push_yaml_string(lines: &mut Vec<String>, key: &str, value: &str) {
if !value.is_empty() {
lines.push(format!("{key}: {}", yaml_string_value(value)));
}
}
@@ -638,9 +579,12 @@ mod tests {
}
#[test]
fn translation_frontmatter_remains_string_only() {
let yaml = "translationFor: 42\nlanguage: en\ntitle: Title";
assert!(TranslationFrontmatter::from_yaml(yaml).is_err());
fn translation_frontmatter_accepts_canonical_unquoted_scalars() {
let yaml = "translationFor: 42\nlanguage: en\ntitle: true";
let parsed = TranslationFrontmatter::from_yaml(yaml).unwrap();
assert_eq!(parsed.translation_for, "42");
assert_eq!(parsed.language, "en");
assert_eq!(parsed.title, "true");
}
#[test]
@@ -729,42 +673,6 @@ mod tests {
assert_eq!(body, body2);
}
#[test]
fn golden_output_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
}
#[test]
fn golden_output_ghostty() {
let path = fixture_dir().join("posts/2026/03/ghostty.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for ghostty.md");
}
#[test]
fn golden_output_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_translation_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(
actual, expected,
"golden output mismatch for esmeralda.en.md"
);
}
#[test]
fn current_translation_output_carries_full_metadata() {
let translation = PostTranslation {
@@ -791,12 +699,155 @@ mod tests {
assert!(output.contains("publishedAt:"));
}
#[test]
fn translation_output_matches_bds2_quoting_and_trailing_newline() {
let fm = TranslationFrontmatter {
id: Some("translation-1".into()),
translation_for: "post-1".into(),
language: "de".into(),
title: "true".into(),
excerpt: Some("Summary: translated".into()),
status: Some("published".into()),
created_at: Some(1_711_833_600_000),
updated_at: Some(1_711_920_000_000),
published_at: Some(1_712_006_400_000),
};
assert_eq!(
format_frontmatter(&fm.to_yaml(), "body\n\n"),
concat!(
"---\n",
"id: translation-1\n",
"translationFor: post-1\n",
"language: de\n",
"title: true\n",
"excerpt: \"Summary: translated\"\n",
"status: published\n",
"createdAt: '2024-03-30T21:20:00.000Z'\n",
"updatedAt: '2024-03-31T21:20:00.000Z'\n",
"publishedAt: '2024-04-01T21:20:00.000Z'\n",
"---\n",
"body\n",
)
);
let parsed = TranslationFrontmatter::from_yaml(&fm.to_yaml()).unwrap();
assert_eq!(parsed.title, "true");
}
#[test]
fn yaml_quoting() {
assert_eq!(yaml_string_value("simple"), "simple");
assert_eq!(yaml_string_value("has: colon"), "'has: colon'");
assert_eq!(yaml_string_value("true"), "'true'");
assert_eq!(yaml_string_value(""), "''");
assert_eq!(yaml_string_value("Über Öl/123"), "Über Öl/123");
assert_eq!(yaml_string_value("has: colon"), "\"has: colon\"");
assert_eq!(yaml_string_value("true"), "true");
assert_eq!(
yaml_string_value("say \"hi\"\nnext"),
"\"say \\\"hi\\\"\\nnext\""
);
assert_eq!(yaml_string_value(""), "\"\"");
}
#[test]
fn post_output_matches_bds2_canonical_bytes() {
let fm = PostFrontmatter {
id: "post-1".into(),
title: "Published: \"Post\"".into(),
slug: "published-post".into(),
excerpt: Some("Summary".into()),
status: "published".into(),
author: Some("Writer".into()),
language: Some("en".into()),
do_not_translate: true,
template_slug: Some("article".into()),
created_at: 1_711_833_600_000,
updated_at: 1_711_920_000_000,
published_at: Some(1_712_006_400_000),
tags: Vec::new(),
categories: vec!["notes".into()],
};
assert_eq!(
format_frontmatter(&fm.to_yaml(), "Hello from markdown\n\n"),
concat!(
"---\n",
"id: post-1\n",
"title: \"Published: \\\"Post\\\"\"\n",
"slug: published-post\n",
"excerpt: Summary\n",
"status: published\n",
"author: Writer\n",
"language: en\n",
"doNotTranslate: true\n",
"templateSlug: article\n",
"createdAt: '2024-03-30T21:20:00.000Z'\n",
"updatedAt: '2024-03-31T21:20:00.000Z'\n",
"publishedAt: '2024-04-01T21:20:00.000Z'\n",
"tags:\n",
"categories:\n",
" - notes\n",
"---\n",
"Hello from markdown\n",
)
);
}
#[test]
fn template_and_script_output_match_bds2_scalar_and_timestamp_rules() {
let template = TemplateFrontmatter {
id: "template-1".into(),
project_id: Some("project-1".into()),
slug: "article".into(),
title: "Article: Wide".into(),
kind: "post".into(),
enabled: true,
version: 2,
created_at: 1_711_833_600_000,
updated_at: 1_711_920_000_000,
};
assert_eq!(
write_template_file(&template, "body\n\n"),
concat!(
"---\n",
"id: template-1\n",
"projectId: project-1\n",
"slug: article\n",
"title: \"Article: Wide\"\n",
"kind: post\n",
"enabled: true\n",
"version: 2\n",
"createdAt: '2024-03-30T21:20:00.000Z'\n",
"updatedAt: '2024-03-31T21:20:00.000Z'\n",
"---\n",
"body\n",
)
);
let script = ScriptFrontmatter {
id: "script-1".into(),
project_id: Some("project-1".into()),
slug: "render-card".into(),
title: "Render Card".into(),
kind: "macro".into(),
entrypoint: "render".into(),
enabled: true,
version: 3,
created_at: 1_711_833_600_000,
updated_at: 1_711_920_000_000,
};
assert_eq!(
script.to_yaml(),
concat!(
"id: script-1\n",
"projectId: project-1\n",
"slug: render-card\n",
"title: Render Card\n",
"kind: macro\n",
"entrypoint: render\n",
"enabled: true\n",
"version: 3\n",
"createdAt: '2024-03-30T21:20:00.000Z'\n",
"updatedAt: '2024-03-31T21:20:00.000Z'",
)
);
}
#[test]
@@ -868,18 +919,6 @@ mod tests {
assert!(body.contains("<div>"));
}
#[test]
fn golden_output_template() {
let path = fixture_dir().join("templates/testvorlage.liquid");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_template_file(&expected).unwrap();
let actual = write_template_file(&fm, &body);
assert_eq!(
actual, expected,
"golden output mismatch for testvorlage.liquid"
);
}
#[test]
fn template_frontmatter_roundtrip() {
let fm = TemplateFrontmatter {

View File

@@ -10,7 +10,7 @@ pub mod timestamp;
pub use app_paths::{application_data_dir, application_database_path, default_project_data_dir};
pub use atomic_write::{atomic_write, atomic_write_str};
pub use checksum::{content_hash, file_hash};
pub use checksum::{content_hash, file_hash, media_content_hash, media_file_hash};
pub use paths::*;
pub use slug::{ensure_unique, slugify};
pub use timestamp::{

View File

@@ -54,7 +54,7 @@ impl MediaSidecar {
}
}
/// Serialize to the hand-built YAML-like format matching TypeScript output.
/// Serialize to the canonical bDS2 hand-built YAML-like format.
fn serialize(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
@@ -99,27 +99,14 @@ impl MediaSidecar {
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
// Tags: inline JSON array
if self.tags.is_empty() {
lines.push("tags: []".into());
} else {
let quoted: Vec<String> = self
.tags
.iter()
.map(|t| format!("\"{}\"", escape_double_quotes(t)))
.collect();
lines.push(format!("tags: [{}]", quoted.join(", ")));
}
// linkedPostIds: only if non-empty
if !self.linked_post_ids.is_empty() {
let quoted: Vec<String> = self
.linked_post_ids
.iter()
.map(|id| format!("\"{id}\""))
.collect();
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
}
lines.push(format!(
"linkedPostIds: {}",
serialize_inline_string_array(&self.linked_post_ids)
));
lines.push(format!(
"tags: {}",
serialize_inline_string_array(&self.tags)
));
lines.push("---".into());
lines.join("\n")
@@ -281,19 +268,32 @@ pub fn read_translation_sidecar(content: &str) -> Result<MediaTranslationSidecar
// --- Helpers ---
fn escape_double_quotes(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn unquote_double(s: &str) -> String {
let s = s.trim();
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
let inner = &s[1..s.len() - 1];
inner.replace("\\\"", "\"").replace("\\\\", "\\")
inner
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
} else {
s.to_string()
}
}
fn serialize_inline_string_array(values: &[String]) -> String {
let quoted = values
.iter()
.map(|value| format!("\"{}\"", escape_double_quotes(value)))
.collect::<Vec<_>>();
format!("[{}]", quoted.join(", "))
}
/// Parse inline JSON-like array: `["a", "b"]` or `[]`.
fn parse_inline_json_array(s: &str) -> Vec<String> {
let s = s.trim();
@@ -346,21 +346,6 @@ mod tests {
);
}
#[test]
fn golden_output_sidecar() {
let path =
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap();
let actual = sc.to_string();
// Compare trimmed (fixture may or may not have trailing newline)
assert_eq!(
actual.trim(),
expected.trim(),
"golden output mismatch for media sidecar"
);
}
#[test]
fn roundtrip_sidecar() {
let sc = MediaSidecar {
@@ -420,5 +405,61 @@ mod tests {
assert_eq!(unquote_double("\"hello\""), "hello");
assert_eq!(unquote_double("plain"), "plain");
assert_eq!(escape_double_quotes("say \"hi\""), "say \\\"hi\\\"");
assert_eq!(
escape_double_quotes("line one\nline two"),
"line one\\nline two"
);
}
#[test]
fn output_matches_bds2_order_empty_links_and_escaping() {
let sidecar = MediaSidecar {
id: "media-1".into(),
original_name: "photo.jpg".into(),
mime_type: "image/jpeg".into(),
size: 123,
width: None,
height: None,
title: Some("Photo".into()),
alt: None,
caption: Some("First line\nSecond line".into()),
author: None,
language: Some("en".into()),
created_at: 1_711_833_600_000,
updated_at: 1_711_920_000_000,
tags: vec!["alpha".into()],
linked_post_ids: Vec::new(),
};
assert_eq!(
sidecar.to_string(),
concat!(
"---\n",
"id: media-1\n",
"originalName: \"photo.jpg\"\n",
"mimeType: image/jpeg\n",
"size: 123\n",
"title: \"Photo\"\n",
"caption: \"First line\\nSecond line\"\n",
"language: en\n",
"createdAt: 2024-03-30T21:20:00.000Z\n",
"updatedAt: 2024-03-31T21:20:00.000Z\n",
"linkedPostIds: []\n",
"tags: [\"alpha\"]\n",
"---",
)
);
let translation = MediaTranslationSidecar {
translation_for: "media-1".into(),
language: "de".into(),
title: None,
alt: None,
caption: Some("Erste Zeile\nZweite Zeile".into()),
};
assert!(
translation
.to_string()
.contains("caption: \"Erste Zeile\\nZweite Zeile\"")
);
}
}

View File

@@ -1,5 +1,5 @@
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageFormat, ImageReader};
use image::{DynamicImage, GenericImageView, ImageReader, RgbImage, RgbaImage};
use std::fs;
use std::io::Cursor;
use std::path::Path;
@@ -70,7 +70,15 @@ pub fn generate_thumbnail(
quality: u8,
) -> Result<(), String> {
let img = load_and_orient(source)?;
generate_thumbnail_from_image(&img, dest, size, quality)
}
fn generate_thumbnail_from_image(
img: &DynamicImage,
dest: &Path,
size: &ThumbnailSize,
quality: u8,
) -> Result<(), String> {
let resized = match size.fit {
ThumbnailFit::Inside => {
let (orig_w, orig_h) = img.dimensions();
@@ -101,13 +109,11 @@ pub fn generate_thumbnail(
match size.format {
ThumbnailFormat::Webp => {
// Encode as WebP via image crate
let mut buf = Cursor::new(Vec::new());
resized
.write_to(&mut buf, ImageFormat::WebP)
.map_err(|e| format!("WebP encode: {e}"))?;
let _ = quality; // image crate WebP encoder doesn't expose quality directly
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
let rgba = resized.to_rgba8();
let encoded = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height())
.encode_simple(false, f32::from(quality))
.map_err(|e| format!("WebP encode: {e:?}"))?;
fs::write(dest, &*encoded).map_err(|e| format!("write: {e}"))?;
}
ThumbnailFormat::Jpeg => {
let rgb = resized.to_rgb8();
@@ -131,6 +137,7 @@ pub fn generate_all_thumbnails(
) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
let prefix = &media_id[..2.min(media_id.len())];
let image = load_and_orient(source)?;
for size in THUMBNAIL_SIZES {
let ext = match size.format {
@@ -145,7 +152,7 @@ pub fn generate_all_thumbnails(
} else {
80
};
generate_thumbnail(source, &dest, size, quality)?;
generate_thumbnail_from_image(&image, &dest, size, quality)?;
paths.push(dest.to_string_lossy().to_string());
}
@@ -154,6 +161,10 @@ pub fn generate_all_thumbnails(
/// Load an image and apply EXIF orientation.
fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
if is_heic_path(path) {
return load_heic(path);
}
let reader = ImageReader::open(path)
.map_err(|e| format!("open image: {e}"))?
.with_guessed_format()
@@ -171,6 +182,135 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
Ok(img)
}
fn is_heic_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "heic" | "heif"))
}
fn load_heic(path: &Path) -> Result<DynamicImage, String> {
let bytes = fs::read(path).map_err(|error| format!("read HEIC image: {error}"))?;
let decoded = hpvcd::Decoder::default()
.with_decode_gain_map(false)
.decode(&bytes)
.map_err(|error| format!("decode HEIC image: {error}"))?;
let shift = decoded.bit_depth.minus8();
let rgb = match decoded.pixels {
hpvcd::ImageBuffer::Rgb8(pixels) => pixels,
hpvcd::ImageBuffer::Rgb16(samples) => samples
.into_iter()
.map(|sample| (sample >> shift) as u8)
.collect(),
hpvcd::ImageBuffer::Luma8(samples) => {
samples.into_iter().flat_map(|sample| [sample; 3]).collect()
}
hpvcd::ImageBuffer::Luma16(samples) => samples
.into_iter()
.flat_map(|sample| [(sample >> shift) as u8; 3])
.collect(),
};
let mut image = if let Some(alpha) = decoded.alpha {
let alpha: Vec<u8> = match alpha {
hpvcd::SampleBuf::U8(samples) => samples,
hpvcd::SampleBuf::U16(samples) => samples
.into_iter()
.map(|sample| (sample >> shift) as u8)
.collect(),
};
if alpha.len().checked_mul(3) != Some(rgb.len()) {
return Err("decode HEIC image: decoder returned an invalid alpha buffer".to_string());
}
let rgba = rgb
.chunks_exact(3)
.zip(alpha)
.flat_map(|(rgb, alpha)| [rgb[0], rgb[1], rgb[2], alpha])
.collect();
DynamicImage::ImageRgba8(
RgbaImage::from_raw(decoded.width, decoded.height, rgba).ok_or_else(|| {
"decode HEIC image: decoder returned an invalid pixel buffer".to_string()
})?,
)
} else {
DynamicImage::ImageRgb8(
RgbImage::from_raw(decoded.width, decoded.height, rgb).ok_or_else(|| {
"decode HEIC image: decoder returned an invalid pixel buffer".to_string()
})?,
)
};
if let Some(aperture) = decoded.clean_aperture
&& let Some((x, y, width, height)) =
clean_aperture_crop(decoded.width, decoded.height, decoded.orientation, aperture)
{
image = image.crop_imm(x, y, width, height);
}
Ok(image)
}
fn clean_aperture_crop(
oriented_width: u32,
oriented_height: u32,
orientation: hpvcd::Orientation,
aperture: hpvcd::CleanAperture,
) -> Option<(u32, u32, u32, u32)> {
let width = aperture.width_pixels()?;
let height = aperture.height_pixels()?;
if width == 0 || height == 0 {
return None;
}
let swaps_axes = matches!(
orientation,
hpvcd::Orientation::Rotate90
| hpvcd::Orientation::Rotate270
| hpvcd::Orientation::Transpose
| hpvcd::Orientation::Transverse
);
let (source_width, source_height) = if swaps_axes {
(oriented_height, oriented_width)
} else {
(oriented_width, oriented_height)
};
if width > source_width || height > source_height {
return None;
}
let horizontal_offset =
f64::from(aperture.horiz_off_n) / f64::from(aperture.horiz_off_d.max(1));
let vertical_offset = f64::from(aperture.vert_off_n) / f64::from(aperture.vert_off_d.max(1));
let source_x = ((f64::from(source_width - width) / 2.0) + horizontal_offset)
.round()
.clamp(0.0, f64::from(source_width - width)) as u32;
let source_y = ((f64::from(source_height - height) / 2.0) + vertical_offset)
.round()
.clamp(0.0, f64::from(source_height - height)) as u32;
let crop = match orientation {
hpvcd::Orientation::Normal => (source_x, source_y, width, height),
hpvcd::Orientation::FlipH => (source_width - source_x - width, source_y, width, height),
hpvcd::Orientation::FlipV => (source_x, source_height - source_y - height, width, height),
hpvcd::Orientation::Rotate180 => (
source_width - source_x - width,
source_height - source_y - height,
width,
height,
),
hpvcd::Orientation::Rotate90 => {
(source_height - source_y - height, source_x, height, width)
}
hpvcd::Orientation::Rotate270 => (source_y, source_width - source_x - width, height, width),
hpvcd::Orientation::Transpose => (
source_height - source_y - height,
source_width - source_x - width,
height,
width,
),
hpvcd::Orientation::Transverse => (source_y, source_x, height, width),
};
(crop.0 + crop.2 <= oriented_width && crop.1 + crop.3 <= oriented_height).then_some(crop)
}
/// Read EXIF orientation tag from raw file bytes.
/// Returns orientation value 1-8, or None if not found.
fn read_exif_orientation(data: &[u8]) -> Option<u16> {
@@ -270,6 +410,31 @@ fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage {
/// Extract image dimensions from a file (header-only, no full decode).
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
if is_heic_path(path) {
let bytes = fs::read(path).map_err(|error| format!("read HEIC image: {error}"))?;
let info =
hpvcd::read_heic_info(&bytes).map_err(|error| format!("HEIC dimensions: {error}"))?;
let swaps_axes = matches!(
info.orientation,
hpvcd::Orientation::Rotate90
| hpvcd::Orientation::Rotate270
| hpvcd::Orientation::Transpose
| hpvcd::Orientation::Transverse
);
if let Some(aperture) = info.clean_aperture
&& let (Some(width), Some(height)) = (aperture.width_pixels(), aperture.height_pixels())
&& width > 0
&& height > 0
{
return Ok(if swaps_axes {
(height, width)
} else {
(width, height)
});
}
return Ok((info.width, info.height));
}
let reader = ImageReader::open(path)
.map_err(|e| format!("open: {e}"))?
.with_guessed_format()
@@ -313,6 +478,12 @@ mod tests {
path
}
fn heic_fixture() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("testdata")
.join("close.heic")
}
#[test]
fn generate_small_thumbnail() {
let dir = TempDir::new().unwrap();
@@ -325,6 +496,8 @@ mod tests {
// 100x80 is smaller than 150 wide, no enlargement
assert_eq!(w, 100);
assert_eq!(h, 80);
let encoded = fs::read(&dest).unwrap();
assert_eq!(&encoded[12..16], b"VP8 ", "thumbnail must use lossy WebP");
}
#[test]
@@ -357,6 +530,8 @@ mod tests {
assert_eq!(mime_from_extension("jpg"), "image/jpeg");
assert_eq!(mime_from_extension("PNG"), "image/png");
assert_eq!(mime_from_extension("webp"), "image/webp");
assert_eq!(mime_from_extension("HEIC"), "image/heic");
assert_eq!(mime_from_extension("heif"), "image/heif");
assert_eq!(mime_from_extension("xyz"), "application/octet-stream");
}
@@ -368,4 +543,19 @@ mod tests {
assert_eq!(w, 100);
assert_eq!(h, 80);
}
#[test]
fn heic_dimensions_and_thumbnails() {
let dir = TempDir::new().unwrap();
let source = heic_fixture();
assert_eq!(image_dimensions(&source).unwrap(), (27, 27));
let paths =
generate_all_thumbnails(&source, &dir.path().join("thumbnails"), "heic-test").unwrap();
assert_eq!(paths.len(), THUMBNAIL_SIZES.len());
assert!(paths.iter().all(|path| Path::new(path).is_file()));
assert_eq!(image_dimensions(Path::new(&paths[0])).unwrap(), (27, 27));
assert_eq!(image_dimensions(Path::new(&paths[3])).unwrap(), (448, 448));
}
}

BIN
crates/bds-core/testdata/close.heic vendored Normal file

Binary file not shown.

View File

@@ -335,6 +335,20 @@ fn representative_shared_mutations_emit_exactly_one_typed_event_each() {
&post.id,
NotificationAction::Updated,
);
bds_core::engine::post::archive_post(db.conn(), dir.path(), &post.id).unwrap();
assert_one_entity_event(
&subscription,
DomainEntity::Post,
&post.id,
NotificationAction::Updated,
);
bds_core::engine::post::unarchive_post(db.conn(), dir.path(), &post.id).unwrap();
assert_one_entity_event(
&subscription,
DomainEntity::Post,
&post.id,
NotificationAction::Updated,
);
bds_core::engine::media::update_media(
db.conn(),

View File

@@ -134,7 +134,10 @@ fn remote_state_history_fetch_and_fast_forward_pull_use_real_refs() {
git(&local, &["add", "-A"]);
git(&local, &["commit", "-m", "local one"]);
let engine = GitEngine::new(&local);
engine.set_remote(remote.to_str().unwrap()).unwrap();
git(
&local,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
git(&local, &["push", "-u", "origin", "master"]);
git(

View File

@@ -296,11 +296,11 @@ fn test_tag_sync_roundtrip() {
}
// ════════════════════════════════════════════════════════════════════
// Step 20: Golden-file Comparisons
// Step 20: Legacy Fixture Compatibility and Canonical Reserialization
// ════════════════════════════════════════════════════════════════════
#[test]
fn test_golden_post_esmeralda() {
fn test_legacy_post_fixture_reserializes_as_bds2_canonical_output() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
@@ -323,12 +323,13 @@ fn test_golden_post_esmeralda() {
let yaml = fm.to_yaml();
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
// Compare byte-for-byte with fixture
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
assert!(actual.find("language: es").unwrap() < actual.find("createdAt:").unwrap());
assert!(actual.find("publishedAt:").unwrap() < actual.find("tags:").unwrap());
assert!(actual.ends_with('\n'));
}
#[test]
fn test_golden_translation_esmeralda_en() {
fn test_legacy_translation_fixture_matches_bds2_canonical_output() {
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_translation_file(&expected).unwrap();
@@ -348,7 +349,7 @@ fn test_golden_translation_esmeralda_en() {
}
#[test]
fn test_golden_sidecar() {
fn test_legacy_sidecar_fixture_reserializes_as_bds2_canonical_output() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap();
@@ -362,13 +363,10 @@ fn test_golden_sidecar() {
assert_eq!(sc.height, Some(1200));
assert_eq!(sc.title.as_deref(), Some("Esmeralda"));
// Write back via to_string() and compare
// Write back via to_string() in canonical bDS2 order.
let actual = sc.to_string();
assert_eq!(
actual.trim(),
expected.trim(),
"golden output mismatch for media sidecar"
);
assert!(actual.find("linkedPostIds:").unwrap() < actual.find("tags:").unwrap());
assert!(actual.ends_with("---"));
}
#[test]
@@ -426,7 +424,7 @@ fn test_golden_meta_files() {
}
#[test]
fn test_golden_template() {
fn test_legacy_template_fixture_reserializes_as_bds2_canonical_output() {
let path = fixture_dir().join("templates/testvorlage.liquid");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_template_file(&expected).unwrap();
@@ -443,12 +441,11 @@ fn test_golden_template() {
assert!(fm.enabled);
assert_eq!(fm.version, 3);
// Write back and compare byte-for-byte
// Write back with bDS2 scalar and timestamp quoting.
let actual = write_template_file(&fm, &body);
assert_eq!(
actual, expected,
"golden output mismatch for testvorlage.liquid"
);
assert!(actual.contains("id: 38704737-b7e7-4dd4-b010-9208bcf80ef6\n"));
assert!(actual.contains("createdAt: '2026-02-27T20:33:00.000Z'"));
assert!(actual.ends_with('\n'));
}
// ════════════════════════════════════════════════════════════════════
@@ -841,7 +838,7 @@ fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String
#[test]
fn test_diff_detects_template_title() {
let fields = template_diff_for_field(|content| {
content.replace("title: \"Diff Template\"", "title: \"Changed Template\"")
content.replace("title: Diff Template", "title: Changed Template")
});
assert!(
fields.contains(&"title".to_string()),
@@ -851,8 +848,7 @@ fn test_diff_detects_template_title() {
#[test]
fn test_diff_detects_template_kind() {
let fields =
template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
let fields = template_diff_for_field(|content| content.replace("kind: post", "kind: list"));
assert!(
fields.contains(&"kind".to_string()),
"expected 'kind' in template diff fields, got: {fields:?}"
@@ -938,7 +934,7 @@ fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String>
#[test]
fn test_diff_detects_script_title() {
let fields = script_diff_for_field(|content| {
content.replace("title: \"Diff Script\"", "title: \"Changed Script\"")
content.replace("title: Diff Script", "title: Changed Script")
});
assert!(
fields.contains(&"title".to_string()),
@@ -948,9 +944,8 @@ fn test_diff_detects_script_title() {
#[test]
fn test_diff_detects_script_kind() {
let fields = script_diff_for_field(|content| {
content.replace("kind: \"utility\"", "kind: \"transform\"")
});
let fields =
script_diff_for_field(|content| content.replace("kind: utility", "kind: transform"));
assert!(
fields.contains(&"kind".to_string()),
"expected 'kind' in script diff fields, got: {fields:?}"
@@ -959,9 +954,8 @@ fn test_diff_detects_script_kind() {
#[test]
fn test_diff_detects_script_entrypoint() {
let fields = script_diff_for_field(|content| {
content.replace("entrypoint: \"main\"", "entrypoint: \"run\"")
});
let fields =
script_diff_for_field(|content| content.replace("entrypoint: main", "entrypoint: run"));
assert!(
fields.contains(&"entrypoint".to_string()),
"expected 'entrypoint' in script diff fields, got: {fields:?}"

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use bds_core::db::Database;
use bds_core::db::queries::project::insert_project;
@@ -6,7 +6,8 @@ use bds_core::db::queries::template::insert_template;
use bds_core::engine::generation::{
GenerationSection, PublishedPostSource, apply_validation_section_with_progress,
apply_validation_sections, build_site_search_index, generate_starter_site,
load_published_post_source, render_site_section_with_progress, sections_from_validation_report,
generate_starter_site_forced, load_published_post_source, render_site_section_with_progress,
sections_from_validation_report,
};
use bds_core::engine::meta::write_category_meta_json;
use bds_core::engine::validate_site::validate_site;
@@ -14,6 +15,7 @@ use bds_core::model::{
CategorySettings, Post, PostStatus, PostTranslation, Project, ProjectMetadata, Template,
TemplateKind, TemplateStatus,
};
use bds_core::render::estimate_site_render_pages;
use tempfile::TempDir;
fn make_project() -> Project {
@@ -126,6 +128,19 @@ fn reopened_draft_generation_uses_last_published_file() {
assert_eq!(source.body_markdown, "Published body");
}
#[test]
fn archived_post_with_published_file_is_not_a_generation_source() {
let (_db, dir) = setup();
let mut post = make_post("archived", 1_710_000_000_000);
write_published_snapshot(&dir, &mut post, "Published body");
post.status = PostStatus::Archived;
post.content = None;
let source = load_published_post_source(dir.path(), post).unwrap();
assert!(source.is_none());
}
#[test]
fn validation_keeps_reopened_draft_published_snapshots() {
let (db, dir) = setup();
@@ -239,6 +254,21 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() {
metadata.main_language = Some("de".into());
metadata.blog_languages = vec!["de".into(), "en".into()];
bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap();
write_category_meta_json(
dir.path(),
&HashMap::from([(
"article".into(),
CategorySettings {
title: Some("Artikel".into()),
titles: BTreeMap::from([("en".into(), "Articles".into())]),
render_in_lists: true,
show_title: true,
post_template_slug: None,
list_template_slug: None,
},
)]),
)
.unwrap();
insert_template(
db.conn(),
&Template {
@@ -298,6 +328,7 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() {
let mut german_source = make_post("german-source", 1_710_086_400_000);
german_source.language = Some("de".into());
german_source.title = "Deutsche Quelle".into();
german_source.categories = vec!["aside".into()];
write_published_snapshot(&dir, &mut german_source, "Deutscher Quelltext");
bds_core::db::queries::post::insert_post(db.conn(), &german_source).unwrap();
let english_translation = PostTranslation {
@@ -372,6 +403,15 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() {
assert!(canonical_german_source.contains("Deutscher Quelltext"));
assert!(localized_german_source.contains("English translation body"));
assert!(localized_fallback.contains("Unübersetzter Quelltext"));
let english_index = std::fs::read_to_string(output.join("en/index.html")).unwrap();
assert!(english_index.contains("English translation body"));
assert!(!english_index.contains(">English translation</a></h2>"));
let german_category =
std::fs::read_to_string(output.join("category/article/index.html")).unwrap();
let english_category =
std::fs::read_to_string(output.join("en/category/article/index.html")).unwrap();
assert!(german_category.contains("<h1 class=\"archive-heading\">Artikel</h1>"));
assert!(english_category.contains("<h1 class=\"archive-heading\">Articles</h1>"));
assert!(
output
.join("2024/03/12/german-private/index.html")
@@ -417,6 +457,7 @@ fn section_generation_reports_its_urls_and_defers_pagefind() {
&posts,
GenerationSection::Single,
|current, total, url| urls.push((current, total, url.to_string())),
|_| {},
|| false,
)
.unwrap();
@@ -451,6 +492,7 @@ fn section_generation_reports_its_urls_and_defers_pagefind() {
&changed_posts,
GenerationSection::Single,
|_current, _total, _url| {},
|_| {},
|| false,
)
.unwrap();
@@ -459,6 +501,113 @@ fn section_generation_reports_its_urls_and_defers_pagefind() {
assert!(!dir.path().join(old_fragment).exists());
}
#[test]
fn list_pages_receive_only_their_page_post_data_like_bds2() {
let (db, dir) = setup();
insert_template(
db.conn(),
&make_list_template("list", "POST_DATA={{ post_data_json_by_id | size }}"),
)
.unwrap();
let mut metadata = make_metadata();
metadata.max_posts_per_page = 1;
let posts = vec![
PublishedPostSource {
post: make_post("hello", 1_710_000_000_000),
body_markdown: "Hello".into(),
},
PublishedPostSource {
post: make_post("next", 1_710_086_400_000),
body_markdown: "Next".into(),
},
];
render_site_section_with_progress(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
GenerationSection::Core,
|_, _, _| {},
|_| {},
|| false,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("index.html")).unwrap(),
"POST_DATA=1"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("page/2/index.html")).unwrap(),
"POST_DATA=1"
);
}
#[test]
fn full_generation_uses_the_project_description_for_every_home_page_title() {
let (db, dir) = setup();
let mut metadata = make_metadata();
metadata.description = Some("My Wonderful Blog".into());
metadata.blog_languages = vec!["en".into(), "de".into()];
let posts = vec![PublishedPostSource {
post: make_post("hello", 1_710_000_000_000),
body_markdown: "Hello".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
for path in ["index.html", "de/index.html"] {
let html = std::fs::read_to_string(dir.path().join(path)).unwrap();
assert!(
html.contains("<title>My Wonderful Blog</title>"),
"unexpected document title in {path}: {html}"
);
assert!(
html.contains("<h1 class=\"archive-heading\">My Wonderful Blog</h1>"),
"missing homepage title in {path}: {html}"
);
}
}
#[test]
fn section_generation_reports_while_html_is_rendered_before_files_are_written() {
let (db, dir) = setup();
let metadata = make_metadata();
let posts = vec![PublishedPostSource {
post: make_post("hello", 1_710_000_000_000),
body_markdown: "Hello **world**".into(),
}];
let output = dir.path().join("2024/03/09/hello/index.html");
let rendered_before_write = std::sync::atomic::AtomicBool::new(false);
let rendered = std::sync::atomic::AtomicUsize::new(0);
let estimated = estimate_site_render_pages(dir.path(), &metadata, &[posts[0].post.clone()])
.unwrap()[&GenerationSection::Single];
render_site_section_with_progress(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
GenerationSection::Single,
|_current, _total, _url| {},
|_| {
rendered.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
rendered_before_write.store(!output.exists(), std::sync::atomic::Ordering::Relaxed);
},
|| false,
)
.unwrap();
assert!(rendered_before_write.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(
rendered.load(std::sync::atomic::Ordering::Relaxed),
estimated
);
}
#[test]
fn validation_apply_rewrites_only_the_reported_urls() {
let (db, dir) = setup();
@@ -490,6 +639,7 @@ fn validation_apply_rewrites_only_the_reported_urls() {
&validation,
GenerationSection::Single,
|current, total, url| urls.push((current, total, url.to_string())),
|_| {},
|| false,
)
.unwrap();
@@ -581,6 +731,7 @@ fn page_aliases_belong_to_core_while_dated_posts_belong_to_single() {
&posts,
GenerationSection::Single,
|_current, _total, _url| {},
|_| {},
|| false,
)
.unwrap();
@@ -595,6 +746,7 @@ fn page_aliases_belong_to_core_while_dated_posts_belong_to_single() {
&posts,
GenerationSection::Core,
|_current, _total, _url| {},
|_| {},
|| false,
)
.unwrap();
@@ -609,7 +761,7 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
db.conn(),
&make_list_template(
"featured-list",
"FEATURED:{% for day_block in day_blocks %}{% for post in day_block.posts %}[{{ post.title }}|{{ post.show_title }}]{% endfor %}{% endfor %}",
"FEATURED:{% if archive_context %}{% if archive_context.kind == 'category' %}{{ archive_context.name }}{% endif %}{% endif %}:{% for day_block in day_blocks %}{% for post in day_block.posts %}[{{ post.title }}|{{ post.show_title }}]{% endfor %}{% endfor %}",
),
)
.unwrap();
@@ -619,6 +771,8 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
(
"hidden".to_string(),
CategorySettings {
title: None,
titles: BTreeMap::new(),
render_in_lists: false,
show_title: true,
post_template_slug: None,
@@ -628,6 +782,8 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
(
"featured".to_string(),
CategorySettings {
title: Some("Featured Archive".to_string()),
titles: BTreeMap::from([("de".into(), "Ausgewählt".into())]),
render_in_lists: true,
show_title: false,
post_template_slug: None,
@@ -690,7 +846,7 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
let featured_html =
std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
assert!(featured_html.contains("FEATURED:Featured Archive:[Featured Post|false]"));
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
assert!(rss.contains("hidden-post"));
@@ -728,7 +884,62 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
}
#[test]
fn site_validation_detects_stale_and_missing_outputs() {
fn forced_generation_rewrites_unchanged_outputs_without_erasing_unrelated_cache_records() {
let (db, dir) = setup();
let metadata = make_metadata();
let posts = vec![PublishedPostSource {
post: make_post("hello", 1_710_000_000_000),
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let unchanged =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(unchanged.skipped_paths.contains(&"index.html".to_string()));
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
let normal_after_drift =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(
normal_after_drift
.skipped_paths
.contains(&"index.html".to_string())
);
assert_eq!(
std::fs::read_to_string(dir.path().join("index.html")).unwrap(),
"tampered"
);
bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(
db.conn(),
&bds_core::model::GeneratedFileHash {
project_id: "p1".into(),
relative_path: "legacy-output.txt".into(),
content_hash: "legacy".into(),
updated_at: 42,
},
)
.unwrap();
let forced =
generate_starter_site_forced(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(forced.written_paths.contains(&"index.html".to_string()));
assert!(forced.skipped_paths.is_empty());
assert_ne!(
std::fs::read_to_string(dir.path().join("index.html")).unwrap(),
"tampered"
);
assert!(
bds_core::db::queries::generated_file_hash::get_generated_file_hash(
db.conn(),
"p1",
"legacy-output.txt",
)
.is_ok()
);
}
#[test]
fn site_validation_matches_bds2_index_route_comparison() {
let (db, dir) = setup();
let metadata = make_metadata();
let mut post = make_post("hello", 1_710_000_000_000);
@@ -740,16 +951,97 @@ fn site_validation_detects_stale_and_missing_outputs() {
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered but non-empty").unwrap();
std::fs::remove_file(dir.path().join("atom.xml")).unwrap();
std::fs::write(dir.path().join("ghost.xml"), "not a route").unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.stale_pages.contains(&"index.html".to_string()));
assert!(report.missing_pages.contains(&"atom.xml".to_string()));
assert!(report.stale_pages.is_empty());
assert!(report.missing_pages.is_empty());
assert!(report.extra_pages.is_empty());
}
#[test]
fn site_validation_detects_missing_zero_byte_and_extra_index_routes() {
let (db, dir) = setup();
let metadata = make_metadata();
let mut post = make_post("hello", 1_710_000_000_000);
write_published_snapshot(&dir, &mut post, "Hello **world**");
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "").unwrap();
let extra = dir.path().join("ghost/index.html");
std::fs::create_dir_all(extra.parent().unwrap()).unwrap();
std::fs::write(&extra, "ghost").unwrap();
let zero_byte_extra = dir.path().join("empty/index.html");
std::fs::create_dir_all(zero_byte_extra.parent().unwrap()).unwrap();
std::fs::write(&zero_byte_extra, "").unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.missing_pages.contains(&"index.html".to_string()));
assert!(report.extra_pages.contains(&"ghost/index.html".to_string()));
assert!(report.extra_pages.contains(&"empty/index.html".to_string()));
}
#[test]
fn site_validation_detects_post_sources_newer_than_generated_routes() {
use std::fs::{File, FileTimes};
use std::time::{Duration, SystemTime};
let (db, dir) = setup();
let metadata = make_metadata();
let mut post = make_post("hello", 1_710_000_000_000);
write_published_snapshot(&dir, &mut post, "Hello **world**");
let source_path = dir.path().join(&post.file_path);
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
File::options()
.write(true)
.open(source_path)
.unwrap()
.set_times(FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(5)))
.unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(
report
.stale_pages
.contains(&"2024/03/09/hello/index.html".to_string())
);
}
#[test]
fn site_validation_refreshes_sitemap_without_rendering_pages() {
let (db, dir) = setup();
let mut post = make_post("hello", 1_710_000_000_000);
write_published_snapshot(&dir, &mut post, "Hello **world**");
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
assert!(
report
.missing_pages
.contains(&"2024/03/09/hello/index.html".to_string())
);
assert!(sitemap.contains("https://example.com/2024/03/09/hello/"));
assert!(!dir.path().join("2024/03/09/hello/index.html").exists());
}
#[test]
fn apply_validation_repairs_core_section_outputs() {
let (db, dir) = setup();
@@ -763,14 +1055,20 @@ fn apply_validation_repairs_core_section_outputs() {
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
std::fs::remove_file(dir.path().join("atom.xml")).unwrap();
std::fs::remove_file(dir.path().join("index.html")).unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&report);
let apply_report =
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections)
.unwrap();
let sections = sections_from_validation_report(&report, &metadata);
let apply_report = apply_validation_sections(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
&report,
&sections,
)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
@@ -779,6 +1077,147 @@ fn apply_validation_repairs_core_section_outputs() {
assert!(repaired.stale_pages.is_empty());
}
#[test]
fn validation_apply_clears_unchanged_touched_post_routes() {
use std::fs::{File, FileTimes};
use std::time::{Duration, UNIX_EPOCH};
let (db, dir) = setup();
let metadata = make_metadata();
let mut post = make_post("hello", 1_710_000_000_000);
write_published_snapshot(&dir, &mut post, "Hello **world**");
let source_path = dir.path().join(&post.file_path);
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let relative_path = "2024/03/09/hello/index.html";
let output_path = dir.path().join(relative_path);
File::options()
.write(true)
.open(&output_path)
.unwrap()
.set_times(FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(10)))
.unwrap();
File::options()
.write(true)
.open(source_path)
.unwrap()
.set_times(FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(20)))
.unwrap();
let mut hash = bds_core::db::queries::generated_file_hash::get_generated_file_hash(
db.conn(),
"p1",
relative_path,
)
.unwrap();
hash.updated_at = 10_000;
bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(db.conn(), &hash)
.unwrap();
let validation = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(validation.stale_pages, vec![relative_path.to_string()]);
let sections = sections_from_validation_report(&validation, &metadata);
let applied = apply_validation_sections(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
&validation,
&sections,
)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(applied.skipped_paths.contains(&relative_path.to_string()));
assert!(repaired.stale_pages.is_empty());
}
#[test]
fn validation_apply_expands_a_missing_post_to_its_aggregates() {
let (db, dir) = setup();
let mut metadata = make_metadata();
metadata.max_posts_per_page = 1;
let first = make_post("first", 1_710_000_000_000);
let second = make_post("second", 1_710_000_001_000);
generate_starter_site(
db.conn(),
dir.path(),
"p1",
&metadata,
&[PublishedPostSource {
post: first.clone(),
body_markdown: "First body".into(),
}],
"en",
)
.unwrap();
let posts = vec![
PublishedPostSource {
post: first,
body_markdown: "First body".into(),
},
PublishedPostSource {
post: second,
body_markdown: "Second body".into(),
},
];
let validation = bds_core::engine::validate_site::SiteValidationReport {
missing_pages: vec!["2024/03/09/second/index.html".into()],
extra_pages: Vec::new(),
stale_pages: Vec::new(),
};
let sections = sections_from_validation_report(&validation, &metadata);
apply_validation_sections(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
&validation,
&sections,
)
.unwrap();
for path in [
"index.html",
"category/article/index.html",
"tag/rust/index.html",
"2024/index.html",
"2024/03/index.html",
"2024/03/09/index.html",
] {
assert!(
std::fs::read_to_string(dir.path().join(path))
.unwrap()
.contains("second"),
"aggregate {path} was not refreshed"
);
}
for path in [
"page/2/index.html",
"category/article/page/2/index.html",
"tag/rust/page/2/index.html",
"2024/page/2/index.html",
"2024/03/page/2/index.html",
"2024/03/09/page/2/index.html",
] {
assert!(
std::fs::read_to_string(dir.path().join(path))
.unwrap()
.contains("first"),
"aggregate pagination {path} was not refreshed"
);
}
}
#[test]
fn apply_validation_removes_extra_section_outputs() {
let (db, dir) = setup();
@@ -802,10 +1241,17 @@ fn apply_validation_removes_extra_section_outputs() {
.extra_pages
.contains(&"tag/ghost/index.html".to_string())
);
let sections = sections_from_validation_report(&report);
let apply_report =
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections)
.unwrap();
let sections = sections_from_validation_report(&report, &metadata);
let apply_report = apply_validation_sections(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
&report,
&sections,
)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(
@@ -816,6 +1262,135 @@ fn apply_validation_removes_extra_section_outputs() {
assert!(repaired.extra_pages.is_empty());
}
#[test]
fn configured_language_prefixes_select_their_actual_validation_section() {
let mut metadata = make_metadata();
metadata.blog_languages = vec!["en".into(), "pt".into(), "ja".into()];
let report = bds_core::engine::validate_site::SiteValidationReport {
missing_pages: vec![
"pt/category/ghost/index.html".into(),
"ja/category/ghost/index.html".into(),
],
extra_pages: Vec::new(),
stale_pages: Vec::new(),
};
assert_eq!(
sections_from_validation_report(&report, &metadata),
vec![GenerationSection::Category]
);
}
#[test]
fn localized_page_paths_select_the_core_validation_section() {
let mut metadata = make_metadata();
metadata.blog_languages.push("de".into());
let report = bds_core::engine::validate_site::SiteValidationReport {
missing_pages: vec!["de/about/index.html".into()],
extra_pages: Vec::new(),
stale_pages: Vec::new(),
};
assert_eq!(
sections_from_validation_report(&report, &metadata),
vec![GenerationSection::Core]
);
}
#[test]
fn validation_apply_repairs_and_cleans_localized_page_outputs() {
let (db, dir) = setup();
let mut metadata = make_metadata();
metadata.blog_languages = vec!["en".into(), "de".into()];
bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap();
let mut page = make_post("about", 1_710_000_000_000);
page.categories = vec!["page".into()];
write_published_snapshot(&dir, &mut page, "About");
bds_core::db::queries::post::insert_post(db.conn(), &page).unwrap();
let posts = vec![PublishedPostSource {
post: page,
body_markdown: "About".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let localized_page = dir.path().join("de/about/index.html");
let localized_extra = dir.path().join("de/ghost/index.html");
std::fs::write(&localized_page, "").unwrap();
std::fs::create_dir_all(localized_extra.parent().unwrap()).unwrap();
std::fs::write(&localized_extra, "ghost").unwrap();
let validation = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&validation, &metadata);
let applied = apply_validation_sections(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
&validation,
&sections,
)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(sections, vec![GenerationSection::Core]);
assert!(
applied
.written_paths
.contains(&"de/about/index.html".into())
);
assert!(
applied
.deleted_paths
.contains(&"de/ghost/index.html".into())
);
assert!(!localized_extra.exists());
assert!(repaired.missing_pages.is_empty());
assert!(repaired.extra_pages.is_empty());
assert!(repaired.stale_pages.is_empty());
}
#[test]
fn validation_apply_removes_extra_output_under_configured_language() {
let (db, dir) = setup();
let mut metadata = make_metadata();
metadata.blog_languages = vec!["en".into(), "pt".into(), "ja".into()];
bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap();
let mut post = make_post("hello", 1_710_000_000_000);
write_published_snapshot(&dir, &mut post, "Hello **world**");
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let extra = dir.path().join("pt/category/ghost/index.html");
std::fs::create_dir_all(extra.parent().unwrap()).unwrap();
std::fs::write(&extra, "ghost").unwrap();
let validation = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&validation, &metadata);
let applied = apply_validation_sections(
db.conn(),
dir.path(),
"p1",
&metadata,
&posts,
&validation,
&sections,
)
.unwrap();
assert_eq!(sections, vec![GenerationSection::Category]);
assert!(
applied
.deleted_paths
.contains(&"pt/category/ghost/index.html".to_string())
);
assert!(!extra.exists());
}
#[test]
fn site_validation_uses_html_output_directory_when_present() {
let (db, dir) = setup();

View File

@@ -6,6 +6,7 @@ use bds_core::db::queries::project::insert_project;
use bds_core::model::{Post, PostStatus, Project};
use bds_core::render::{
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
write_generated_file_forced,
};
use tempfile::TempDir;
@@ -95,7 +96,7 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
}
#[test]
fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
fn generated_write_trusts_the_cached_hash_like_bds2() {
let (db, dir) = setup();
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
@@ -103,7 +104,23 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::SkippedUnchanged);
assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"tampered"
);
}
#[test]
fn forced_generated_write_ignores_the_cached_hash() {
let (db, dir) = setup();
write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
fs::write(dir.path().join("index.html"), "tampered").unwrap();
let forced =
write_generated_file_forced(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(forced, GeneratedWriteOutcome::Written);
assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello"

View File

@@ -91,6 +91,26 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
assert!(rendered.contains("data-tag-cloud=\"true\""));
}
#[test]
fn markdown_filter_uses_project_macro_template_overrides() {
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
let context = serde_json::json!({
"body": "[[youtube id=custom-id]]",
"macro_templates": {
"youtube": "<figure data-video=\"{{ id | escape }}\">custom</figure>"
},
"canonical_post_path_by_slug": {},
"canonical_media_path_by_source_path": {},
"language": "en",
"language_prefix": ""
});
let rendered = render_liquid_template(template, &HashMap::new(), &context).unwrap();
assert!(rendered.contains("<figure data-video=\"custom-id\">custom</figure>"));
assert!(!rendered.contains("macro-youtube"));
}
#[test]
fn markdown_filter_leaves_unknown_macros_verbatim() {
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";

View File

@@ -2,7 +2,7 @@ use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Barrier};
use bds_core::db::Database;
use bds_core::engine::{mcp, project};
use bds_core::engine::{EngineError, mcp, project};
use bds_core::model::{DomainEntity, DomainEvent, McpProposal, ProposalKind, ProposalStatus};
use serde_json::json;
@@ -489,6 +489,31 @@ fn every_write_tool_is_inert_then_approves_or_rejects_through_shared_engines() {
);
}
#[test]
fn propose_template_rejects_unsupported_liquid_without_creating_a_proposal() {
let fixture = Fixture::new();
let context = fixture.context();
let error = context
.call_tool(
"propose_template",
json!({
"title": "Unsafe template",
"kind": "partial",
"content": "{{ title | upcase }}"
}),
)
.unwrap_err();
assert!(error.to_string().contains("unsupported filter: upcase"));
let db = fixture.database();
assert!(
mcp::list_proposals(db.conn(), &fixture.project_id)
.unwrap()
.is_empty()
);
}
#[test]
fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() {
let fixture = Fixture::new();
@@ -498,7 +523,7 @@ fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe()
project_id: fixture.project_id.clone(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
entity_id: "expired-entity".into(),
data: json!({"title":"Expired","content":"Body"}).to_string(),
result: None,
created_at: 1,
@@ -562,6 +587,130 @@ fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe()
);
}
#[test]
fn duplicate_pending_entity_proposals_are_rejected_cleanly() {
let fixture = Fixture::new();
let post = fixture.post("One target", "Body");
let context = fixture.context();
let arguments = json!({"postId": post.id, "title": "First change"});
context
.call_tool("propose_post_metadata", arguments.clone())
.unwrap();
let error = context
.call_tool("propose_post_metadata", arguments)
.unwrap_err();
assert!(matches!(error, EngineError::Conflict(_)));
assert_eq!(
error.to_string(),
format!(
"conflict: proposal already pending for propose_post_metadata entity {}",
post.id
)
);
let db = fixture.database();
assert_eq!(
mcp::list_pending_proposals(db.conn(), &fixture.project_id)
.unwrap()
.len(),
1
);
}
#[test]
fn untargeted_proposals_receive_distinct_entity_ids() {
let fixture = Fixture::new();
let context = fixture.context();
context
.call_tool("draft_post", json!({"title": "First", "content": "Body"}))
.unwrap();
context
.call_tool("draft_post", json!({"title": "Second", "content": "Body"}))
.unwrap();
let db = fixture.database();
let proposals = mcp::list_pending_proposals(db.conn(), &fixture.project_id).unwrap();
assert_eq!(proposals.len(), 2);
assert_ne!(proposals[0].entity_id, proposals[1].entity_id);
}
#[test]
fn newer_terminal_proposals_replace_older_status_history() {
let fixture = Fixture::new();
let post = fixture.post("Original", "Body");
let context = fixture.context();
let first = context
.call_tool(
"propose_post_metadata",
json!({"postId": post.id, "title": "First change"}),
)
.unwrap();
let db = fixture.database();
mcp::accept_proposal(
db.conn(),
&fixture.data_dir,
first["proposalId"].as_str().unwrap(),
)
.unwrap();
let second = context
.call_tool(
"propose_post_metadata",
json!({"postId": post.id, "title": "Second change"}),
)
.unwrap();
mcp::accept_proposal(
db.conn(),
&fixture.data_dir,
second["proposalId"].as_str().unwrap(),
)
.unwrap();
let accepted = mcp::list_proposals(db.conn(), &fixture.project_id)
.unwrap()
.into_iter()
.filter(|proposal| proposal.status == ProposalStatus::Accepted)
.collect::<Vec<_>>();
assert_eq!(accepted.len(), 1);
assert_eq!(accepted[0].id, second["proposalId"]);
}
#[test]
fn newer_expired_proposals_replace_older_expired_history() {
let fixture = Fixture::new();
let db = fixture.database();
for (id, status, expires_at) in [
("old-expired", ProposalStatus::Expired, 1),
("new-expiring", ProposalStatus::Pending, 2),
] {
bds_core::db::queries::mcp_proposal::insert_proposal(
db.conn(),
&McpProposal {
id: id.into(),
project_id: fixture.project_id.clone(),
kind: ProposalKind::ProposePostMetadata,
status,
entity_id: "same-post".into(),
data: "{}".into(),
result: None,
created_at: expires_at,
expires_at,
resolved_at: (status == ProposalStatus::Expired).then_some(1),
},
)
.unwrap();
}
assert_eq!(mcp::expire_proposals(db.conn()).unwrap(), 1);
let proposals = mcp::list_proposals(db.conn(), &fixture.project_id).unwrap();
assert_eq!(proposals.len(), 1);
assert_eq!(proposals[0].id, "new-expiring");
assert_eq!(proposals[0].status, ProposalStatus::Expired);
}
#[test]
fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
let fixture = Fixture::new();
@@ -671,7 +820,10 @@ fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
);
let server = mcp::McpHttpServer::start(fixture.database_path.clone(), 0).unwrap();
let client = reqwest::blocking::Client::new();
let client = reqwest::blocking::Client::builder()
.no_proxy()
.build()
.unwrap();
let response = client
.post(server.endpoint())
.header("accept", "application/json, text/event-stream")

View File

@@ -50,7 +50,7 @@ fn parser_rejects_malformed_or_channel_less_xml_and_ignores_unknown_elements() {
#[test]
fn analysis_converts_html_and_shortcodes_and_classifies_every_status() {
let (db, dir, project) = setup();
meta::add_category(dir.path(), "GENERAL").unwrap();
meta::add_category(db.conn(), dir.path(), &project.id, "GENERAL").unwrap();
tag::create_tag(db.conn(), dir.path(), &project.id, "nEWs", None).unwrap();
let update = post::create_post(
@@ -163,7 +163,7 @@ fn analysis_distinguishes_new_updates_conflicts_duplicates_and_missing_media() {
.unwrap();
let old_different = dir.path().join("old-different.png");
DynamicImage::new_rgb8(2, 2).save(&old_different).unwrap();
bds_core::engine::media::import_media(
let same = bds_core::engine::media::import_media(
db.conn(),
dir.path(),
&project.id,
@@ -177,7 +177,14 @@ fn analysis_distinguishes_new_updates_conflicts_duplicates_and_missing_media() {
Vec::new(),
)
.unwrap();
bds_core::engine::media::import_media(
bds_core::engine::media::replace_media_file(
db.conn(),
dir.path(),
&same.id,
&uploads.join("same.png"),
)
.unwrap();
let different = bds_core::engine::media::import_media(
db.conn(),
dir.path(),
&project.id,
@@ -191,6 +198,13 @@ fn analysis_distinguishes_new_updates_conflicts_duplicates_and_missing_media() {
Vec::new(),
)
.unwrap();
bds_core::engine::media::replace_media_file(
db.conn(),
dir.path(),
&different.id,
&old_different,
)
.unwrap();
let same_body = "Hello **world**.\n\n[[gallery ids=\"1,2\"]]";
let update = post::create_post(
@@ -621,8 +635,12 @@ fn overwrite_resolution_preserves_existing_post_and_media_identity() {
bds_core::db::queries::post::get_post_by_id(db.conn(), &existing_post.id).unwrap();
assert_eq!(overwritten_post.id, existing_post.id);
assert_eq!(overwritten_post.status, PostStatus::Published);
assert!(overwritten_post.published_content.is_none());
let (_, overwritten_body) =
read_post_file(&fs::read_to_string(dir.path().join(&overwritten_post.file_path)).unwrap())
.unwrap();
assert_eq!(
overwritten_post.published_content.as_deref(),
Some(overwritten_body.as_str()),
report.posts[0].content.as_deref()
);
let overwritten_media =

View File

@@ -7,7 +7,6 @@ use iced::advanced::renderer;
use iced::advanced::text;
use iced::advanced::widget::{self, Widget};
use iced::advanced::{Clipboard, Shell};
use iced::event::Status;
use iced::keyboard;
use iced::mouse;
use iced::{Color, Element, Event, Length, Pixels, Point, Rectangle, Shadow, Size, Theme, Vector};
@@ -54,8 +53,9 @@ pub fn mono_metrics() -> &'static MonoMetrics {
buffer.set_text(
&mut font_system,
"M",
Attrs::new().family(Family::Monospace),
&Attrs::new().family(Family::Monospace),
Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut font_system, false);
@@ -313,7 +313,7 @@ where
}
fn layout(
&self,
&mut self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
limits: &layout::Limits,
@@ -350,6 +350,7 @@ where
offset: Vector::new(0.0, 3.0),
blur_radius: 14.0,
},
snap: true,
},
theme.palette().background,
);
@@ -365,6 +366,7 @@ where
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
snap: true,
},
Color::from_rgb8(0x2B, 0x2B, 0x2B),
);
@@ -465,6 +467,7 @@ where
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
snap: true,
},
SELECTION_BG,
);
@@ -489,8 +492,8 @@ where
metrics.line_height,
)),
font,
horizontal_alignment: iced::alignment::Horizontal::Right,
vertical_alignment: iced::alignment::Vertical::Top,
align_x: iced::alignment::Horizontal::Right.into(),
align_y: iced::alignment::Vertical::Top,
shaping: iced::widget::text::Shaping::Basic,
wrapping: iced::widget::text::Wrapping::None,
},
@@ -546,8 +549,8 @@ where
metrics.line_height,
)),
font,
horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top,
align_x: iced::alignment::Horizontal::Left.into(),
align_y: iced::alignment::Vertical::Top,
shaping: iced::widget::text::Shaping::Advanced,
wrapping: iced::widget::text::Wrapping::None,
},
@@ -577,8 +580,8 @@ where
metrics.line_height,
)),
font,
horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top,
align_x: iced::alignment::Horizontal::Left.into(),
align_y: iced::alignment::Vertical::Top,
shaping: iced::widget::text::Shaping::Advanced,
wrapping: iced::widget::text::Wrapping::None,
},
@@ -603,6 +606,7 @@ where
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
snap: true,
},
CURSOR_COLOR,
);
@@ -622,6 +626,7 @@ where
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
snap: true,
},
CURSOR_COLOR,
);
@@ -656,6 +661,7 @@ where
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
snap: true,
},
SCROLLBAR_TRACK,
);
@@ -674,6 +680,7 @@ where
..iced::Border::default()
},
shadow: iced::Shadow::default(),
snap: true,
},
if is_hover {
SCROLLBAR_THUMB_HOVER
@@ -684,17 +691,17 @@ where
}
}
fn on_event(
fn update(
&mut self,
tree: &mut widget::Tree,
event: Event,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) -> Status {
) {
let state = tree.state.downcast_mut::<EditorState>();
let bounds = layout.bounds();
let metrics = mono_metrics();
@@ -734,11 +741,13 @@ where
let new_scroll = (ratio * max_scroll as f32).round() as usize;
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
}
return Status::Captured;
shell.capture_event();
return;
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.scrollbar_drag = None;
return Status::Captured;
shell.capture_event();
return;
}
_ => {}
}
@@ -771,7 +780,8 @@ where
state.scrollbar_drag = Some(thumb_height / 2.0);
}
state.is_focused = true;
return Status::Captured;
shell.capture_event();
return;
}
}
@@ -811,7 +821,7 @@ where
state.last_click_col = col;
}
}
return Status::Captured;
shell.capture_event();
} else {
state.is_focused = false;
}
@@ -859,7 +869,7 @@ where
buf.set_selection(anchor_line, anchor_col, clamped_line, clamped_col);
buf.set_cursor(clamped_line, clamped_col);
}
return Status::Captured;
shell.capture_event();
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
@@ -872,7 +882,7 @@ where
let total = total_visual_lines(&buf, cpl);
let max_scroll = total.saturating_sub(vis);
buf.scroll_by_clamped(lines, max_scroll);
return Status::Captured;
shell.capture_event();
}
Event::Keyboard(keyboard::Event::KeyPressed {
key,
@@ -900,7 +910,7 @@ where
match key {
// Cmd+Z = undo, Cmd+Shift+Z = redo
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "z" => {
{
let mut buf = self.buffer.borrow_mut();
if is_shift {
@@ -913,7 +923,7 @@ where
self.emit_change(shell);
}
// Cmd+Y = redo (Windows convention)
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "y" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "y" => {
{
let mut buf = self.buffer.borrow_mut();
buf.redo();
@@ -922,18 +932,18 @@ where
self.emit_change(shell);
}
// Cmd+A = select all
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "a" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "a" => {
self.buffer.borrow_mut().select_all();
}
// Cmd+C = copy
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "c" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "c" => {
let text = self.buffer.borrow().selected_text();
if !text.is_empty() {
clipboard.write(iced::advanced::clipboard::Kind::Standard, text);
}
}
// Cmd+X = cut
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "x" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "x" => {
let text = self.buffer.borrow().selected_text();
if !text.is_empty() {
clipboard.write(iced::advanced::clipboard::Kind::Standard, text);
@@ -942,7 +952,7 @@ where
}
}
// Cmd+V = paste
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "v" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "v" => {
if let Some(text) =
clipboard.read(iced::advanced::clipboard::Kind::Standard)
{
@@ -954,7 +964,7 @@ where
}
}
// Cmd+S = save
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "s" => {
keyboard::Key::Character(c) if is_cmd && c.as_str() == "s" => {
if let Some(ref on_change) = self.on_change {
shell.publish((on_change)(EditorMessage::SaveRequested));
}
@@ -1083,13 +1093,12 @@ where
}
self.emit_change(shell);
}
_ => return Status::Ignored,
_ => return,
}
return Status::Captured;
shell.capture_event();
}
_ => {}
}
Status::Ignored
}
}

View File

@@ -73,6 +73,8 @@ fn main() -> Result<(), Box<dyn Error>> {
}
fn sign_app_for_dmg(workspace: &Path) -> Result<(), Box<dyn Error>> {
#[cfg(not(target_os = "macos"))]
let _ = workspace;
#[cfg(target_os = "macos")]
if env::var_os("CARGO_PACKAGER_FORMAT").as_deref() == Some(OsStr::new("dmg")) {
let target = env::var_os("CARGO_TARGET_DIR")

View File

@@ -0,0 +1,25 @@
[package]
name = "bds-release"
edition.workspace = true
version.workspace = true
license.workspace = true
authors.workspace = true
publish = false
[dependencies]
apple-bundles.workspace = true
apple-codesign.workspace = true
apple-dmg.workspace = true
flate2.workspace = true
icns.workspace = true
plist.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
simple-file-manifest.workspace = true
tar.workspace = true
zip.workspace = true
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,441 @@
use reqwest::{
StatusCode,
blocking::{Body, Client},
};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeSet,
env,
error::Error,
fs::{File, OpenOptions},
io::Write,
path::Path,
process::Command,
thread,
time::Duration,
};
#[derive(Debug, Deserialize)]
struct Issue {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
labels: Vec<Label>,
}
#[derive(Debug, Deserialize)]
struct Label {
name: String,
}
#[derive(Debug, Deserialize)]
struct Release {
id: u64,
draft: bool,
#[serde(default)]
assets: Option<Vec<Asset>>,
}
#[derive(Debug, Deserialize)]
struct Asset {
id: u64,
}
#[derive(Serialize)]
struct ReleasePayload<'a> {
tag_name: &'a str,
target_commitish: &'a str,
name: &'a str,
body: String,
draft: bool,
prerelease: bool,
}
struct Gitea {
client: Client,
api_url: String,
repository: String,
token: String,
}
impl Gitea {
fn from_environment() -> Result<Self, Box<dyn Error>> {
Ok(Self {
client: Client::builder().build()?,
api_url: required_env("GITEA_API_URL")?
.trim_end_matches('/')
.to_owned(),
repository: required_env("GITEA_REPOSITORY")?,
token: required_env("GITEA_TOKEN")?,
})
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.api_url)
}
fn issue(&self, number: u64) -> Result<Issue, Box<dyn Error>> {
Ok(self
.client
.get(self.url(&format!("/repos/{}/issues/{number}", self.repository)))
.header("Authorization", format!("token {}", self.token))
.send()?
.error_for_status()?
.json()?)
}
fn release_for_tag(&self, tag: &str) -> Result<Option<Release>, Box<dyn Error>> {
let response = self
.client
.get(self.url(&format!(
"/repos/{}/releases/tags/{}",
self.repository,
percent_encode(tag)
)))
.header("Authorization", format!("token {}", self.token))
.send()?;
if response.status() == StatusCode::NOT_FOUND {
Ok(None)
} else {
Ok(Some(response.error_for_status()?.json()?))
}
}
fn create_release(&self, payload: &ReleasePayload<'_>) -> Result<Release, Box<dyn Error>> {
Ok(self
.client
.post(self.url(&format!("/repos/{}/releases", self.repository)))
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()?
.error_for_status()?
.json()?)
}
fn update_release(
&self,
release_id: u64,
payload: &impl Serialize,
) -> Result<Release, Box<dyn Error>> {
Ok(self
.client
.patch(self.url(&format!("/repos/{}/releases/{release_id}", self.repository)))
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()?
.error_for_status()?
.json()?)
}
fn delete_asset(&self, release_id: u64, asset_id: u64) -> Result<(), Box<dyn Error>> {
self.client
.delete(self.url(&format!(
"/repos/{}/releases/{release_id}/assets/{asset_id}",
self.repository
)))
.header("Authorization", format!("token {}", self.token))
.send()?
.error_for_status()?;
Ok(())
}
fn upload_asset(&self, release_id: u64, path: &Path) -> Result<(), Box<dyn Error>> {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("invalid release asset name: {}", path.display()))?;
for attempt in 1..=3 {
let file = File::open(path)?;
let length = file.metadata()?.len();
let body = Body::sized(file, length);
let response = self
.client
.post(self.url(&format!(
"/repos/{}/releases/{release_id}/assets",
self.repository
)))
.query(&[("name", name)])
.header("Authorization", format!("token {}", self.token))
.header("Content-Type", "application/octet-stream")
.body(body)
.send();
match response {
Ok(response) if response.status().is_success() => {
println!("uploaded {}", path.display());
return Ok(());
}
Ok(response)
if attempt < 3
&& (response.status().is_server_error()
|| response.status() == StatusCode::TOO_MANY_REQUESTS) => {}
Ok(response) => {
return Err(response.error_for_status().unwrap_err().into());
}
Err(error) if attempt < 3 => {
eprintln!("asset upload attempt {attempt} failed: {error}");
}
Err(error) => return Err(error.into()),
}
thread::sleep(Duration::from_secs(attempt));
}
unreachable!("upload retry loop always returns")
}
}
pub(crate) fn prepare() -> Result<(), Box<dyn Error>> {
let gitea = Gitea::from_environment()?;
let tag = required_env("GITEA_REF_NAME")?;
let sha = required_env("GITEA_SHA")?;
let server = required_env("GITEA_SERVER_URL")?;
let messages = commit_messages(&sha)?;
let issues = issue_numbers(&messages)
.into_iter()
.map(|number| gitea.issue(number))
.collect::<Result<Vec<_>, _>>()?;
let closed = issues
.iter()
.filter(|issue| issue.state == "closed")
.collect::<Vec<_>>();
let payload = ReleasePayload {
tag_name: &tag,
target_commitish: &sha,
name: &tag,
body: release_body(closed.iter().copied(), &server, &gitea.repository, &sha),
draft: true,
prerelease: false,
};
let release = if let Some(existing) = gitea.release_for_tag(&tag)? {
if !existing.draft
&& !replace_published_release(env::var("REBUILD_PUBLISHED_RELEASE").ok().as_deref())
{
return Err(format!("release {tag} is already published").into());
}
if !existing.draft {
gitea.update_release(existing.id, &serde_json::json!({ "draft": true }))?;
}
for asset in existing.assets.unwrap_or_default() {
gitea.delete_asset(existing.id, asset.id)?;
}
gitea.update_release(existing.id, &payload)?
} else {
gitea.create_release(&payload)?
};
let output_path = required_env("GITHUB_OUTPUT")?;
writeln!(
OpenOptions::new()
.create(true)
.append(true)
.open(output_path)?,
"release_id={}",
release.id
)?;
Ok(())
}
pub(crate) fn upload<'a>(paths: impl Iterator<Item = &'a Path>) -> Result<(), Box<dyn Error>> {
let gitea = Gitea::from_environment()?;
let release_id = required_env("RELEASE_ID")?.parse()?;
for path in paths {
gitea.upload_asset(release_id, path)?;
}
Ok(())
}
pub(crate) fn publish() -> Result<(), Box<dyn Error>> {
let gitea = Gitea::from_environment()?;
let release_id = required_env("RELEASE_ID")?.parse()?;
gitea.update_release(release_id, &serde_json::json!({ "draft": false }))?;
Ok(())
}
fn replace_published_release(value: Option<&str>) -> bool {
value == Some("true")
}
fn required_env(name: &str) -> Result<String, Box<dyn Error>> {
env::var(name).map_err(|_| format!("required environment variable {name} is missing").into())
}
fn commit_messages(sha: &str) -> Result<String, Box<dyn Error>> {
let previous = Command::new("git")
.args(["describe", "--tags", "--abbrev=0", &format!("{sha}^")])
.output()?;
let previous = if previous.status.success() {
String::from_utf8(previous.stdout)?.trim().to_owned()
} else {
String::new()
};
let revision = if previous.is_empty() {
sha.to_owned()
} else {
format!("{previous}..{sha}")
};
let output = Command::new("git")
.args(["log", "--format=%s%n%b", &revision])
.output()?;
if !output.status.success() {
return Err(format!("git log failed for {revision}").into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn issue_numbers(message: &str) -> Vec<u64> {
let bytes = message.as_bytes();
let mut numbers = BTreeSet::new();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'#' {
index += 1;
continue;
}
let start = index + 1;
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
if end > start
&& let Ok(number) = message[start..end].parse()
{
numbers.insert(number);
}
index = end.max(index + 1);
}
numbers.into_iter().collect()
}
fn release_body<'a>(
issues: impl Iterator<Item = &'a Issue>,
server: &str,
repository: &str,
sha: &str,
) -> String {
let mut fixes = Vec::new();
let mut improvements = Vec::new();
let mut other = Vec::new();
for issue in issues {
let title = issue.title.split_whitespace().collect::<Vec<_>>().join(" ");
let entry = format!(
"- [#{} {}]({})",
issue.number,
markdown(&title),
issue.html_url
);
let labels = issue
.labels
.iter()
.map(|label| label.name.as_str())
.collect::<BTreeSet<_>>();
if labels.contains("bug") {
fixes.push(entry);
} else if labels.contains("enhancement") {
improvements.push(entry);
} else {
other.push(entry);
}
}
let mut sections = Vec::new();
for (title, entries) in [
("Fixes", fixes),
("Improvements", improvements),
("Other changes", other),
] {
if !entries.is_empty() {
sections.push(format!("## {title}\n{}", entries.join("\n")));
}
}
if sections.is_empty() {
sections.push("No closed issues were linked from commits in this release.".to_owned());
}
let short_sha = &sha[..sha.len().min(12)];
sections.push(format!(
"Built from [{short_sha}]({}/{repository}/commit/{sha}).",
server.trim_end_matches('/')
));
sections.join("\n\n")
}
fn markdown(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_references_are_unique_and_sorted() {
assert_eq!(
issue_numbers("Fix #12, refs #3 and #12; ignore #x"),
[3, 12]
);
}
#[test]
fn release_notes_are_grouped_linked_and_escaped() {
let issues = [
Issue {
number: 12,
title: "Fix [unlock]".to_owned(),
html_url: "https://example.test/issues/12".to_owned(),
state: "closed".to_owned(),
labels: vec![Label {
name: "bug".to_owned(),
}],
},
Issue {
number: 3,
title: "Add search".to_owned(),
html_url: "https://example.test/issues/3".to_owned(),
state: "closed".to_owned(),
labels: vec![Label {
name: "enhancement".to_owned(),
}],
},
];
let body = release_body(
issues.iter(),
"https://example.test",
"owner/repo",
"1234567890abcdef",
);
assert!(body.contains("## Fixes\n- [#12 Fix \\[unlock\\]]"));
assert!(body.contains("## Improvements\n- [#3 Add search]"));
assert!(
body.contains(
"[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)"
)
);
}
#[test]
fn release_tag_is_encoded_as_one_api_path_segment() {
assert_eq!(percent_encode("release/1+beta"), "release%2F1%2Bbeta");
}
#[test]
fn published_release_replacement_requires_explicit_opt_in() {
assert!(!replace_published_release(None));
assert!(!replace_published_release(Some("false")));
assert!(!replace_published_release(Some("TRUE")));
assert!(replace_published_release(Some("true")));
}
}

Some files were not shown because too many files have changed in this diff Show More