Compare commits
123 Commits
486a9f8019
...
0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58596c8de0 | ||
|
|
d3bfc13e67 | ||
|
|
0c32f9fdee | ||
|
|
6a4cf3fc2b | ||
|
|
2bc42c7483 | ||
|
|
612f4f71df | ||
|
|
f59fd03ad2 | ||
|
|
f30c295e1f | ||
|
|
21c6daea9b | ||
|
|
35eec1d000 | ||
|
|
cfe3aa5c2c | ||
|
|
f21aab5910 | ||
|
|
4c3e260961 | ||
|
|
5a2a98b89f | ||
|
|
11c39e3f38 | ||
|
|
7f12cd053b | ||
|
|
f00dd177de | ||
|
|
83fe50657d | ||
|
|
b0e10f52e7 | ||
|
|
dfba7efdac | ||
|
|
0e697750e7 | ||
|
|
f924830176 | ||
|
|
e59e92afa5 | ||
|
|
3506538752 | ||
|
|
87d2cc2a1f | ||
|
|
7fa37ddf6e | ||
|
|
0d1f63e77f | ||
|
|
f3a30bf19e | ||
|
|
aee456cd93 | ||
|
|
8402981d94 | ||
|
|
1424d59845 | ||
|
|
27470465a7 | ||
|
|
917947a421 | ||
| 20f9d15b46 | |||
| 520e1d86ea | |||
| c757314c91 | |||
| 5709c80209 | |||
| 87a161058f | |||
| 41d5f1c014 | |||
| 0417aaf749 | |||
| 358648378e | |||
| 3b1a65359c | |||
| 36cbaa0cc5 | |||
|
|
c96fd9c041 | ||
| 3b1d239863 | |||
| 454089ade1 | |||
| a74165fae5 | |||
| 932b73cd97 | |||
| bda3c400ef | |||
| a961cea207 | |||
| 9a937675b4 | |||
| c4e5dbb8e6 | |||
| b25691ddac | |||
| 2fed5371ca | |||
| 81382b45ce | |||
| 45f68422ed | |||
| 085a3e8175 | |||
| da13bac755 | |||
| c187108f89 | |||
| 7a3c5683e7 | |||
| d9085cf681 | |||
| 473c48937d | |||
| 28119c15b7 | |||
| 87318c8b22 | |||
| ea90167a5a | |||
| 2844ed5a47 | |||
| d87e80f96b | |||
| 3e3b2d9698 | |||
| 632822fe1b | |||
| 08a5bd192c | |||
| a01c86e383 | |||
| 4de0b6b0d1 | |||
| 0d44c21b0e | |||
| 7b8e539340 | |||
| fdf47da40a | |||
| 0753b290ad | |||
| fc039d813a | |||
| 9087096c5d | |||
| 3051331b87 | |||
| a1c04ff898 | |||
| e9f2cdb25d | |||
| bdfe57b66f | |||
| 70503c8bc2 | |||
| 2dcaf7f7f7 | |||
| 556d9c1ff4 | |||
| ba6a297d9b | |||
| 64e3847ef2 | |||
|
|
a3f2c451e3 | ||
|
|
cc520d517a | ||
|
|
5968cf47ba | ||
|
|
2bf95bfcd0 | ||
|
|
733aa9a6ff | ||
|
|
1dd8194fa8 | ||
| 2fc683d513 | |||
| 1410b97aeb | |||
| 8a6274ef6a | |||
| e28d42d345 | |||
| b8014e0a10 | |||
| 535303e8b5 | |||
| 1cdc86b9cc | |||
| 53ada2c705 | |||
| 3920d6fc70 | |||
| b0e9223158 | |||
| 89a07c885a | |||
| 1c04087471 | |||
| 1e32c7e945 | |||
| 80f291b8ea | |||
| b3bf134980 | |||
| 8e78a5fc2b | |||
| 8170076e9b | |||
| 3ac7a8bfc4 | |||
| 557497d4c2 | |||
| 2f7d752e73 | |||
| 812de13f62 | |||
|
|
71d998f68e | ||
|
|
489d3646f8 | ||
|
|
9d3a092359 | ||
|
|
1ecd7a32d2 | ||
|
|
0b4c303c89 | ||
|
|
18d96a10b8 | ||
|
|
184d6e4845 | ||
|
|
d50e538fc3 | ||
|
|
1e6c2a4e2c |
@@ -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")']
|
||||
|
||||
115
.gitea/scripts/install-linux-build-dependencies.sh
Normal file
115
.gitea/scripts/install-linux-build-dependencies.sh
Normal 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
|
||||
2
.gitea/scripts/x86_64-linux-gnu-g++-pic
Executable file
2
.gitea/scripts/x86_64-linux-gnu-g++-pic
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec x86_64-linux-gnu-g++ "$@" -fPIC
|
||||
2
.gitea/scripts/x86_64-linux-gnu-gcc-pic
Executable file
2
.gitea/scripts/x86_64-linux-gnu-gcc-pic
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec x86_64-linux-gnu-gcc "$@" -fPIC
|
||||
21
.gitea/workflows/dependency-audit.yml
Normal file
21
.gitea/workflows/dependency-audit.yml
Normal 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 .
|
||||
261
.gitea/workflows/release.yml
Normal file
261
.gitea/workflows/release.yml
Normal 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
|
||||
8
BUILD.md
8
BUILD.md
@@ -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
6
CLI.md
@@ -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
3566
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
43
Cargo.toml
43
Cargo.toml
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
36
README.md
36
README.md
@@ -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
|
||||
|
||||
30
assets/starter-templates/macros/gallery.liquid
Normal file
30
assets/starter-templates/macros/gallery.liquid
Normal file
@@ -0,0 +1,30 @@
|
||||
<div
|
||||
class="macro-gallery gallery-cols-{{ columns }}"
|
||||
data-post-id="{{ post_id | escape }}"
|
||||
data-columns="{{ columns }}"
|
||||
data-lightbox="true"
|
||||
>
|
||||
<div class="gallery-container gallery-lightbox">
|
||||
{%- if items.size > 0 -%}
|
||||
{%- for item in items -%}
|
||||
<a
|
||||
class="gallery-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="gallery-empty">{{ no_items_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- if caption -%}
|
||||
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
33
assets/starter-templates/macros/photo-archive.liquid
Normal file
33
assets/starter-templates/macros/photo-archive.liquid
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
|
||||
<div class="photo-archive-container">
|
||||
{%- if months.size > 0 -%}
|
||||
{%- for month in months -%}
|
||||
<div class="photo-archive-month-wrapper">
|
||||
<div class="photo-archive-month">
|
||||
<div class="photo-archive-month-label">
|
||||
<span>{{ month.label | escape }}</span>
|
||||
</div>
|
||||
<div class="photo-archive-gallery">
|
||||
{%- for item in month.items -%}
|
||||
<a
|
||||
class="photo-archive-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="photo-archive-empty">{{ no_items_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
19
assets/starter-templates/macros/tag-cloud.liquid
Normal file
19
assets/starter-templates/macros/tag-cloud.liquid
Normal file
@@ -0,0 +1,19 @@
|
||||
<div
|
||||
class="macro-tag-cloud"
|
||||
data-tag-cloud="true"
|
||||
data-orientation="{{ orientation }}"
|
||||
data-color-distribution="quantile"
|
||||
data-color-easing="0.7"
|
||||
data-color-theme="pico"{%- if words_json %} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
|
||||
>
|
||||
{%- if words_json -%}
|
||||
<svg
|
||||
class="tag-cloud-canvas"
|
||||
viewBox="0 0 {{ width }} {{ height }}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label="{{ aria_label | escape }}"
|
||||
></svg>
|
||||
{%- else -%}
|
||||
<div class="tag-cloud-empty">{{ no_items_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
9
assets/starter-templates/macros/vimeo.liquid
Normal file
9
assets/starter-templates/macros/vimeo.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-vimeo">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/{{ id | escape }}"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
9
assets/starter-templates/macros/youtube.liquid
Normal file
9
assets/starter-templates/macros/youtube.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-youtube">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -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,
|
||||
§ions,
|
||||
|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", ¤t.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
DROP INDEX mcp_proposals_entity_idx;
|
||||
|
||||
CREATE TABLE mcp_proposals_old (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'draft_post',
|
||||
'propose_script',
|
||||
'propose_template',
|
||||
'propose_media_translation',
|
||||
'propose_media_metadata',
|
||||
'propose_post_metadata'
|
||||
)),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||
)),
|
||||
entity_id TEXT,
|
||||
data TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
resolved_at BIGINT
|
||||
);
|
||||
|
||||
INSERT INTO mcp_proposals_old (
|
||||
id, project_id, kind, status, entity_id, data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
)
|
||||
SELECT
|
||||
id, project_id, kind, status, entity_id, data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
FROM mcp_proposals;
|
||||
|
||||
DROP TABLE mcp_proposals;
|
||||
ALTER TABLE mcp_proposals_old RENAME TO mcp_proposals;
|
||||
|
||||
CREATE INDEX mcp_proposals_project_status_idx
|
||||
ON mcp_proposals(project_id, status, created_at);
|
||||
@@ -0,0 +1,55 @@
|
||||
CREATE TABLE mcp_proposals_new (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'draft_post',
|
||||
'propose_script',
|
||||
'propose_template',
|
||||
'propose_media_translation',
|
||||
'propose_media_metadata',
|
||||
'propose_post_metadata'
|
||||
)),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||
)),
|
||||
entity_id TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
resolved_at BIGINT
|
||||
);
|
||||
|
||||
INSERT INTO mcp_proposals_new (
|
||||
id, project_id, kind, status, entity_id, data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
)
|
||||
SELECT
|
||||
id, project_id, kind, status, COALESCE(entity_id, id), data, result,
|
||||
created_at, expires_at, resolved_at
|
||||
FROM mcp_proposals;
|
||||
|
||||
DROP TABLE mcp_proposals;
|
||||
ALTER TABLE mcp_proposals_new RENAME TO mcp_proposals;
|
||||
|
||||
-- Keep the newest record if a pre-migration database already contains an
|
||||
-- invalid duplicate. NULL-backed proposals were assigned their own IDs above
|
||||
-- and therefore remain distinct.
|
||||
DELETE FROM mcp_proposals AS duplicate
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mcp_proposals AS keeper
|
||||
WHERE keeper.kind = duplicate.kind
|
||||
AND keeper.entity_id = duplicate.entity_id
|
||||
AND keeper.status = duplicate.status
|
||||
AND (
|
||||
keeper.created_at > duplicate.created_at
|
||||
OR (keeper.created_at = duplicate.created_at AND keeper.id > duplicate.id)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX mcp_proposals_project_status_idx
|
||||
ON mcp_proposals(project_id, status, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX mcp_proposals_entity_idx
|
||||
ON mcp_proposals(kind, entity_id, status);
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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![
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", ×tamp)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
writer
|
||||
.write_event(Event::End(BytesEnd::new("title")))
|
||||
write_text_element(&mut writer, "dateModified", ×tamp)
|
||||
.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(
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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", ¤t), ("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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
146
crates/bds-core/src/engine/ui_state.rs
Normal file
146
crates/bds-core/src/engine/ui_state.rs
Normal 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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
)?
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
escape_html(value)
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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 & only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt <one>\" 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 & only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt <one>\" 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(""url":"/tag/Rust%20%26%20SQLite/""));
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(), ¯o_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(), ¯o_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
@@ -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());
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
BIN
crates/bds-core/testdata/close.heic
vendored
Normal file
Binary file not shown.
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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, §ions)
|
||||
.unwrap();
|
||||
let sections = sections_from_validation_report(&report, &metadata);
|
||||
let apply_report = apply_validation_sections(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&metadata,
|
||||
&posts,
|
||||
&report,
|
||||
§ions,
|
||||
)
|
||||
.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,
|
||||
§ions,
|
||||
)
|
||||
.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,
|
||||
§ions,
|
||||
)
|
||||
.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, §ions)
|
||||
.unwrap();
|
||||
let sections = sections_from_validation_report(&report, &metadata);
|
||||
let apply_report = apply_validation_sections(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&metadata,
|
||||
&posts,
|
||||
&report,
|
||||
§ions,
|
||||
)
|
||||
.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,
|
||||
§ions,
|
||||
)
|
||||
.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,
|
||||
§ions,
|
||||
)
|
||||
.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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
25
crates/bds-release/Cargo.toml
Normal file
25
crates/bds-release/Cargo.toml
Normal 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"
|
||||
441
crates/bds-release/src/gitea.rs
Normal file
441
crates/bds-release/src/gitea.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
52
crates/bds-release/src/main.rs
Normal file
52
crates/bds-release/src/main.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
mod gitea;
|
||||
mod packaging;
|
||||
|
||||
use std::{env, error::Error, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||
match args.as_slice() {
|
||||
[command] if command == "prepare" => gitea::prepare(),
|
||||
[command] if command == "publish" => gitea::publish(),
|
||||
[command, directory] if command == "macos-linker-alias" => {
|
||||
packaging::prepare_macos_linker_alias(Path::new(directory))
|
||||
}
|
||||
[command, directory] if command == "macos-x64-runtime" => {
|
||||
packaging::prepare_macos_x64_runtime(Path::new(directory))
|
||||
}
|
||||
[command, directory, sdk_libraries] if command == "windows-runtime" => {
|
||||
packaging::prepare_windows_runtime(Path::new(directory), Path::new(sdk_libraries))
|
||||
}
|
||||
[command, paths @ ..] if command == "upload" && !paths.is_empty() => {
|
||||
gitea::upload(paths.iter().map(Path::new))
|
||||
}
|
||||
[command, tag, release_dir, rust_lib_dir, platform, dist_dir]
|
||||
if command == "package" =>
|
||||
{
|
||||
packaging::package(
|
||||
tag,
|
||||
Path::new(release_dir),
|
||||
Path::new(rust_lib_dir),
|
||||
platform,
|
||||
Path::new(dist_dir),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
[command, tag, release_dir, rust_lib_dir, platform, dist_dir, upload]
|
||||
if command == "package" && upload == "--upload" =>
|
||||
{
|
||||
let assets = packaging::package(
|
||||
tag,
|
||||
Path::new(release_dir),
|
||||
Path::new(rust_lib_dir),
|
||||
platform,
|
||||
Path::new(dist_dir),
|
||||
)?;
|
||||
gitea::upload(assets.iter().map(|path| path.as_path()))
|
||||
}
|
||||
_ => Err(
|
||||
"usage: bds-release prepare|publish|macos-linker-alias <directory>|macos-x64-runtime <directory>|windows-runtime <directory> <sdk-libraries>|upload <asset>...|package <tag> <release-dir> <rust-lib-dir> <platform> <dist-dir> [--upload]"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user