diff --git a/Cargo.lock b/Cargo.lock index d8c8141..3128141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + [[package]] name = "approx" version = "0.5.1" @@ -458,7 +464,12 @@ dependencies = [ "chrono", "deunicode", "image 0.25.10", + "keyring", + "liquid", + "liquid-core", + "pulldown-cmark", "refinery", + "reqwest", "rusqlite", "rust-stemmers", "serde", @@ -835,7 +846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -904,6 +915,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -917,7 +938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -930,7 +951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -1079,6 +1100,27 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "dconf_rs" version = "0.3.0" @@ -1741,6 +1783,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1748,8 +1799,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1759,9 +1812,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2138,6 +2193,105 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2551,6 +2705,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2694,6 +2864,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2711,6 +2896,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "serde", + "static_assertions", +] + [[package]] name = "kurbo" version = "0.10.4" @@ -2750,6 +2945,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.12" @@ -2852,6 +3056,60 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "liquid" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a494c3f9dad3cb7ed16f1c51812cbe4b29493d6c2e5cd1e2b87477263d9534d" +dependencies = [ + "liquid-core", + "liquid-derive", + "liquid-lib", + "serde", +] + +[[package]] +name = "liquid-core" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc623edee8a618b4543e8e8505584f4847a4e51b805db1af6d9af0a3395d0d57" +dependencies = [ + "anymap2", + "itertools", + "kstring", + "liquid-derive", + "pest", + "pest_derive", + "regex", + "serde", + "time", +] + +[[package]] +name = "liquid-derive" +version = "0.26.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de66c928222984aea59fcaed8ba627f388aaac3c1f57dcb05cc25495ef8faefe" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "liquid-lib" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9befeedd61f5995bc128c571db65300aeb50d62e4f0542c88282dbcb5f72372a" +dependencies = [ + "itertools", + "liquid-core", + "percent-encoding", + "regex", + "time", + "unicode-segmentation", +] + [[package]] name = "litemap" version = "0.8.2" @@ -2888,6 +3146,12 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3754,6 +4018,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.11.3" @@ -4037,6 +4344,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.28" @@ -4076,6 +4402,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -4378,6 +4759,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "resvg" version = "0.42.0" @@ -4427,6 +4848,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ropey" version = "1.6.1" @@ -4524,6 +4959,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4587,6 +5057,42 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -4662,6 +5168,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -4940,6 +5458,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4989,6 +5513,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5259,6 +5792,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.2" @@ -5334,6 +5877,51 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -5366,6 +5954,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.20.0" @@ -5390,6 +5984,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5401,6 +6001,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5461,6 +6067,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5473,6 +6085,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5576,6 +6194,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5859,6 +6486,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6128,6 +6764,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -6161,13 +6806,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6180,6 +6842,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6192,6 +6860,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6204,12 +6878,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6222,6 +6908,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6234,6 +6926,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6246,6 +6944,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6258,6 +6962,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.30.13" @@ -6273,7 +6983,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases 0.2.1", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6743,6 +7453,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index ccc2ea3..553aa54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,11 @@ rust-stemmers = "1" sys-locale = "0.3" dirs = "5" open = "5" +pulldown-cmark = "0.13" +liquid = "0.26" +liquid-core = { version = "0.26", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } # UI framework iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] } diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 120329f..c6038bb 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -19,6 +19,11 @@ walkdir = { workspace = true } image = { workspace = true } rust-stemmers = { workspace = true } sys-locale = { workspace = true } +pulldown-cmark = { workspace = true } +liquid = { workspace = true } +liquid-core = { workspace = true } +reqwest = { workspace = true } +keyring = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/src/db/queries/generated_file_hash.rs b/crates/bds-core/src/db/queries/generated_file_hash.rs new file mode 100644 index 0000000..0203812 --- /dev/null +++ b/crates/bds-core/src/db/queries/generated_file_hash.rs @@ -0,0 +1,94 @@ +use rusqlite::{params, Connection}; + +use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS}; +use crate::model::GeneratedFileHash; + +pub fn get_generated_file_hash( + conn: &Connection, + project_id: &str, + relative_path: &str, +) -> rusqlite::Result { + conn.query_row( + &format!( + "SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2" + ), + params![project_id, relative_path], + generated_file_hash_from_row, + ) +} + +pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(project_id, relative_path) + DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at", + params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at], + )?; + Ok(()) +} + +pub fn delete_generated_file_hash( + conn: &Connection, + project_id: &str, + relative_path: &str, +) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2", + params![project_id, relative_path], + )?; + Ok(()) +} + +pub fn list_generated_file_hashes_by_project( + conn: &Connection, + project_id: &str, +) -> rusqlite::Result> { + let mut stmt = conn.prepare(&format!( + "SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 ORDER BY relative_path" + ))?; + let rows = stmt.query_map(params![project_id], generated_file_hash_from_row)?; + rows.collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::queries::project::{insert_project, make_test_project}; + use crate::db::Database; + + fn setup() -> Database { + let mut db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + db + } + + #[test] + fn upsert_and_get_generated_hash() { + let db = setup(); + let hash = GeneratedFileHash { + project_id: "p1".into(), + relative_path: "index.html".into(), + content_hash: "abc".into(), + updated_at: 42, + }; + + upsert_generated_file_hash(db.conn(), &hash).unwrap(); + let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap(); + assert_eq!(stored.content_hash, "abc"); + + upsert_generated_file_hash( + db.conn(), + &GeneratedFileHash { + content_hash: "def".into(), + updated_at: 99, + ..hash + }, + ) + .unwrap(); + let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap(); + assert_eq!(stored.content_hash, "def"); + assert_eq!(stored.updated_at, 99); + } +} \ No newline at end of file diff --git a/crates/bds-core/src/db/queries/mod.rs b/crates/bds-core/src/db/queries/mod.rs index 893fc4b..60f4d47 100644 --- a/crates/bds-core/src/db/queries/mod.rs +++ b/crates/bds-core/src/db/queries/mod.rs @@ -9,3 +9,4 @@ pub mod post_media; pub mod template; pub mod script; pub mod setting; +pub mod generated_file_hash; diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs new file mode 100644 index 0000000..ccd16c8 --- /dev/null +++ b/crates/bds-core/src/engine/ai.rs @@ -0,0 +1,756 @@ +use std::time::Duration; + +use keyring::Entry; +use reqwest::blocking::Client; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::db::queries::setting; +use crate::engine::{EngineError, EngineResult}; +use crate::util::now_unix_ms; + +const KEYRING_SERVICE: &str = "RuDS"; +const KEYRING_SETTING_PREFIX: &str = "ai.endpoint"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AiEndpointKind { + Online, + Airplane, +} + +impl AiEndpointKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Online => "online", + Self::Airplane => "airplane", + } + } + + fn settings_prefix(self) -> String { + format!("ai.endpoint.{}", self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiEndpointConfig { + pub kind: AiEndpointKind, + pub url: String, + pub model: String, + pub api_key: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredAiEndpointConfig { + pub kind: AiEndpointKind, + pub url: String, + pub model: String, + pub api_key_configured: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AiSettings { + pub offline_mode: bool, + pub default_model: Option, + pub title_model: Option, + pub image_model: Option, + pub system_prompt: String, + pub online_endpoint: StoredAiEndpointConfig, + pub airplane_endpoint: StoredAiEndpointConfig, +} + +impl Default for StoredAiEndpointConfig { + fn default() -> Self { + Self { + kind: AiEndpointKind::Online, + url: String::new(), + model: String::new(), + api_key_configured: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiModelInfo { + pub id: String, + pub name: String, + pub context_window: Option, + pub max_output_tokens: Option, + pub supports_vision: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OneShotOperation { + AnalyzeTaxonomy, + AnalyzePost, + DetectLanguage, + TranslatePost { target_language: String }, + AnalyzeImage, + TranslateMedia { target_language: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OneShotRequest { + pub operation: OneShotOperation, + pub content: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaxonomySuggestion { + pub tags: Vec, + pub categories: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostAnalysisResult { + pub title: String, + pub excerpt: String, + pub slug: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LanguageDetectionResult { + pub language_code: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranslationResult { + pub title: String, + pub excerpt: String, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageAnalysisResult { + pub title: String, + pub alt: String, + pub caption: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MediaTranslationResult { + pub title: String, + pub alt: String, + pub caption: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OneShotResponse { + Taxonomy(TaxonomySuggestion), + PostAnalysis(PostAnalysisResult), + LanguageDetection(LanguageDetectionResult), + Translation(TranslationResult), + ImageAnalysis(ImageAnalysisResult), + MediaTranslation(MediaTranslationResult), +} + +pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult { + let online_endpoint = load_endpoint(conn, AiEndpointKind::Online)?; + let airplane_endpoint = load_endpoint(conn, AiEndpointKind::Airplane)?; + let default_model = get_optional_setting(conn, "ai.default_model")?; + let title_model = get_optional_setting(conn, "ai.title_model")?; + let image_model = get_optional_setting(conn, "ai.image_model")?; + let system_prompt = get_optional_setting(conn, "ai.system_prompt")?.unwrap_or_default(); + + Ok(AiSettings { + offline_mode, + default_model, + title_model, + image_model, + system_prompt, + online_endpoint, + airplane_endpoint, + }) +} + +pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> { + validate_endpoint_config(endpoint)?; + let checked_at = now_unix_ms(); + set_setting(conn, &endpoint_setting_key(endpoint.kind, "url"), endpoint.url.trim(), checked_at)?; + set_setting(conn, &endpoint_setting_key(endpoint.kind, "model"), endpoint.model.trim(), checked_at)?; + if endpoint.kind == AiEndpointKind::Online { + let entry = endpoint_keyring_entry(endpoint.kind)?; + if let Some(api_key) = &endpoint.api_key { + if api_key.trim().is_empty() { + entry.delete_credential().ok(); + set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "false", checked_at)?; + } else { + entry.set_password(api_key.trim()).map_err(keyring_error)?; + set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "true", checked_at)?; + } + } + } + Ok(()) +} + +pub fn save_model_preferences( + conn: &Connection, + default_model: Option<&str>, + title_model: Option<&str>, + image_model: Option<&str>, + system_prompt: &str, +) -> EngineResult<()> { + let updated_at = now_unix_ms(); + set_optional_setting(conn, "ai.default_model", default_model, updated_at)?; + set_optional_setting(conn, "ai.title_model", title_model, updated_at)?; + set_optional_setting(conn, "ai.image_model", image_model, updated_at)?; + set_setting(conn, "ai.system_prompt", system_prompt, updated_at)?; + Ok(()) +} + +pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult { + let kind = if offline_mode { AiEndpointKind::Airplane } else { AiEndpointKind::Online }; + let stored = load_endpoint(conn, kind)?; + if stored.url.trim().is_empty() { + return Err(EngineError::Validation(format!( + "AI unavailable - configure {} endpoint in Settings", + kind.as_str() + ))); + } + let api_key = if kind == AiEndpointKind::Online { + let entry = endpoint_keyring_entry(kind)?; + let password = entry.get_password().map_err(keyring_error)?; + if password.trim().is_empty() { + return Err(EngineError::Validation( + "AI unavailable - configure online endpoint in Settings".to_string(), + )); + } + Some(password) + } else { + None + }; + + Ok(AiEndpointConfig { + kind, + url: stored.url, + model: stored.model, + api_key, + }) +} + +pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult> { + let entry = endpoint_keyring_entry(kind)?; + match entry.get_password() { + Ok(password) if password.trim().is_empty() => Ok(None), + Ok(password) => Ok(Some(password)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(keyring_error(error)), + } +} + +pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult> { + validate_endpoint_config(endpoint)?; + let client = build_http_client()?; + let request = client.get(models_url(&endpoint.url)); + let response = with_auth(request, endpoint) + .send()? + .error_for_status()?; + let body: Value = response.json()?; + let models = body + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| EngineError::Parse("model catalog response missing data array".to_string()))?; + + let mut result = Vec::new(); + for model in models { + let id = model + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::Parse("model entry missing id".to_string()))? + .to_string(); + let name = model + .get("name") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let context_window = model + .get("context_window") + .or_else(|| model.get("contextWindow")) + .and_then(Value::as_u64); + let max_output_tokens = model + .get("max_output_tokens") + .or_else(|| model.get("maxOutputTokens")) + .and_then(Value::as_u64); + let supports_vision = model + .get("modalities") + .and_then(Value::as_array) + .map(|modalities| modalities.iter().any(|value| value.as_str() == Some("vision"))) + .unwrap_or(false); + result.push(AiModelInfo { + id, + name, + context_window, + max_output_tokens, + supports_vision, + }); + } + + Ok(result) +} + +pub fn test_endpoint(endpoint: &AiEndpointConfig) -> EngineResult<()> { + let _ = refresh_model_catalog(endpoint)?; + Ok(()) +} + +pub fn run_one_shot( + conn: &Connection, + offline_mode: bool, + request: &OneShotRequest, +) -> EngineResult { + let settings = load_ai_settings(conn, offline_mode)?; + let endpoint = active_endpoint(conn, offline_mode)?; + let model = select_model(&settings, &endpoint, &request.operation)?; + let prompt = build_one_shot_prompt(request)?; + let schema = response_schema(&request.operation); + let payload = json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": build_system_prompt(&settings.system_prompt, &request.operation), + }, + { + "role": "user", + "content": prompt, + } + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": schema.0, + "schema": schema.1, + "strict": true + } + } + }); + let client = build_http_client()?; + let response = with_auth(client.post(chat_completions_url(&endpoint.url)).json(&payload), &endpoint) + .send()? + .error_for_status()?; + let body: Value = response.json()?; + let content = body + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .ok_or_else(|| EngineError::Parse("chat completions response missing message content".to_string()))?; + parse_one_shot_response(request, content) +} + +fn build_http_client() -> EngineResult { + Ok(Client::builder().timeout(Duration::from_secs(5)).build()?) +} + +fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult { + let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default(); + let model = get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default(); + let api_key_configured = get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))? + .map(|value| value == "true") + .unwrap_or(false); + Ok(StoredAiEndpointConfig { + kind, + url, + model, + api_key_configured, + }) +} + +fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> { + if endpoint.url.trim().is_empty() { + return Err(EngineError::Validation("endpoint url is required".to_string())); + } + if endpoint.model.trim().is_empty() { + return Err(EngineError::Validation("endpoint model is required".to_string())); + } + if endpoint.kind == AiEndpointKind::Online + && endpoint.api_key.as_ref().map(|value| value.trim().is_empty()).unwrap_or(true) + { + return Err(EngineError::Validation("online endpoint api key is required".to_string())); + } + Ok(()) +} + +fn select_model(settings: &AiSettings, endpoint: &AiEndpointConfig, operation: &OneShotOperation) -> EngineResult { + let selected = match operation { + OneShotOperation::AnalyzeImage => settings.image_model.as_ref(), + OneShotOperation::AnalyzeTaxonomy + | OneShotOperation::AnalyzePost + | OneShotOperation::DetectLanguage + | OneShotOperation::TranslatePost { .. } + | OneShotOperation::TranslateMedia { .. } => settings.title_model.as_ref().or(settings.default_model.as_ref()), + } + .filter(|model| !model.trim().is_empty()) + .cloned() + .unwrap_or_else(|| endpoint.model.clone()); + if selected.trim().is_empty() { + return Err(EngineError::Validation("AI unavailable - configure model in Settings".to_string())); + } + Ok(selected) +} + +fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String { + let operation_prompt = match operation { + OneShotOperation::AnalyzeTaxonomy => "Return only JSON with tags and categories for the post.", + OneShotOperation::AnalyzePost => "Return only JSON with title, excerpt, and slug suggestions for the post.", + OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.", + OneShotOperation::TranslatePost { target_language } => { + return format!("{} Translate the post into {} and return only JSON with title, excerpt, and content.", base_prompt.trim(), target_language); + } + OneShotOperation::AnalyzeImage => "Return only JSON with title, alt, and caption suggestions for the image.", + OneShotOperation::TranslateMedia { target_language } => { + return format!("{} Translate the media metadata into {} and return only JSON with title, alt, and caption.", base_prompt.trim(), target_language); + } + }; + if base_prompt.trim().is_empty() { + operation_prompt.to_string() + } else { + format!("{} {}", base_prompt.trim(), operation_prompt) + } +} + +fn build_one_shot_prompt(request: &OneShotRequest) -> EngineResult { + match &request.operation { + OneShotOperation::AnalyzeTaxonomy => Ok(format!( + "Suggest tags and categories for this post: {}", + serde_json::to_string(&request.content)? + )), + OneShotOperation::AnalyzePost => Ok(format!( + "Analyze this post and suggest title, excerpt, and slug: {}", + serde_json::to_string(&request.content)? + )), + OneShotOperation::DetectLanguage => Ok(format!( + "Detect the language of this text: {}", + serde_json::to_string(&request.content)? + )), + OneShotOperation::TranslatePost { target_language } => Ok(format!( + "Translate this post to {}: {}", + target_language, + serde_json::to_string(&request.content)? + )), + OneShotOperation::AnalyzeImage => Ok(format!( + "Analyze this image metadata and return title, alt, and caption suggestions: {}", + serde_json::to_string(&request.content)? + )), + OneShotOperation::TranslateMedia { target_language } => Ok(format!( + "Translate this media metadata to {}: {}", + target_language, + serde_json::to_string(&request.content)? + )), + } +} + +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::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 { + Ok(match request.operation { + OneShotOperation::AnalyzeTaxonomy => OneShotResponse::Taxonomy(serde_json::from_str(content)?), + OneShotOperation::AnalyzePost => OneShotResponse::PostAnalysis(serde_json::from_str(content)?), + OneShotOperation::DetectLanguage => OneShotResponse::LanguageDetection(serde_json::from_str(content)?), + OneShotOperation::TranslatePost { .. } => OneShotResponse::Translation(serde_json::from_str(content)?), + OneShotOperation::AnalyzeImage => OneShotResponse::ImageAnalysis(serde_json::from_str(content)?), + OneShotOperation::TranslateMedia { .. } => OneShotResponse::MediaTranslation(serde_json::from_str(content)?), + }) +} + +fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String { + format!("{}.{}", kind.settings_prefix(), suffix) +} + +fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult { + Entry::new(KEYRING_SERVICE, &format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str())).map_err(keyring_error) +} + +fn keyring_error(error: keyring::Error) -> EngineError { + EngineError::Validation(error.to_string()) +} + +fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> { + setting::set_setting_value(conn, key, value, updated_at)?; + Ok(()) +} + +fn set_optional_setting(conn: &Connection, key: &str, value: Option<&str>, updated_at: i64) -> EngineResult<()> { + set_setting(conn, key, value.unwrap_or(""), updated_at) +} + +fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult> { + match setting::get_setting_by_key(conn, key) { + Ok(setting) if setting.value.trim().is_empty() => Ok(None), + Ok(setting) => Ok(Some(setting.value)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(EngineError::Db(error)), + } +} + +fn models_url(base_url: &str) -> String { + join_openai_path(base_url, "models") +} + +fn chat_completions_url(base_url: &str) -> String { + join_openai_path(base_url, "chat/completions") +} + +fn join_openai_path(base_url: &str, suffix: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/v1") { + format!("{trimmed}/{suffix}") + } else { + format!("{trimmed}/v1/{suffix}") + } +} + +fn with_auth( + request: reqwest::blocking::RequestBuilder, + endpoint: &AiEndpointConfig, +) -> reqwest::blocking::RequestBuilder { + if let Some(api_key) = &endpoint.api_key { + request.bearer_auth(api_key) + } else { + request + } +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + use super::*; + use crate::db::Database; + + fn setup() -> Database { + let mut db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + db + } + + fn clear_keyring(kind: AiEndpointKind) { + let entry = endpoint_keyring_entry(kind).unwrap(); + entry.delete_credential().ok(); + } + + #[test] + fn loads_empty_defaults() { + clear_keyring(AiEndpointKind::Online); + let db = setup(); + let settings = load_ai_settings(db.conn(), false).unwrap(); + assert!(!settings.offline_mode); + assert!(settings.online_endpoint.url.is_empty()); + assert!(settings.airplane_endpoint.url.is_empty()); + assert!(settings.default_model.is_none()); + } + + #[test] + fn saves_online_endpoint_with_keychain_secret() { + clear_keyring(AiEndpointKind::Online); + let db = setup(); + save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Online, + url: "https://example.test/v1".to_string(), + model: "gpt-4.1-mini".to_string(), + api_key: Some("secret-token".to_string()), + }, + ) + .unwrap(); + + let active = active_endpoint(db.conn(), false).unwrap(); + assert_eq!(active.url, "https://example.test/v1"); + assert_eq!(active.model, "gpt-4.1-mini"); + assert_eq!(active.api_key.as_deref(), Some("secret-token")); + } + + #[test] + fn airplane_endpoint_does_not_require_api_key() { + let db = setup(); + save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url: "http://localhost:11434/v1".to_string(), + model: "llama3.2".to_string(), + api_key: None, + }, + ) + .unwrap(); + + let active = active_endpoint(db.conn(), true).unwrap(); + assert_eq!(active.kind, AiEndpointKind::Airplane); + assert!(active.api_key.is_none()); + } + + #[test] + fn refresh_model_catalog_parses_openai_models_shape() { + let server = spawn_test_server(|request| { + assert!(request.starts_with("GET /v1/models HTTP/1.1")); + http_ok( + r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#, + ) + }); + let models = refresh_model_catalog(&AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url: server, + model: "gpt-4.1-mini".to_string(), + api_key: None, + }) + .unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].name, "GPT 4.1 mini"); + assert!(models[1].supports_vision); + } + + #[test] + fn run_one_shot_uses_active_endpoint_and_parses_response() { + clear_keyring(AiEndpointKind::Online); + let server = spawn_test_server(|request| { + if request.starts_with("GET /v1/models HTTP/1.1") { + return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#); + } + assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1")); + assert!(request.contains("authorization: Bearer secret-token") || request.contains("Authorization: Bearer secret-token")); + http_ok( + r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#, + ) + }); + + let db = setup(); + save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Online, + url: server, + model: "gpt-4.1-mini".to_string(), + api_key: Some("secret-token".to_string()), + }, + ) + .unwrap(); + save_model_preferences(db.conn(), None, Some("gpt-4.1-mini"), None, "").unwrap(); + + let response = run_one_shot( + db.conn(), + false, + &OneShotRequest { + operation: OneShotOperation::AnalyzePost, + content: json!({"title":"Draft title","excerpt":"","content":"Body"}), + }, + ) + .unwrap(); + + assert_eq!( + response, + OneShotResponse::PostAnalysis(PostAnalysisResult { + title: "Better title".to_string(), + excerpt: "Short summary".to_string(), + slug: "better-title".to_string(), + }) + ); + } + + fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + thread::spawn(move || { + for stream in listener.incoming().take(2) { + let mut stream = stream.unwrap(); + let mut buffer = [0_u8; 8192]; + let size = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..size]).to_string(); + let response = handler(request); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + format!("http://{}", addr) + } + + fn http_ok(body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ) + } +} \ No newline at end of file diff --git a/crates/bds-core/src/engine/error.rs b/crates/bds-core/src/engine/error.rs index 011f6b7..e9eb713 100644 --- a/crates/bds-core/src/engine/error.rs +++ b/crates/bds-core/src/engine/error.rs @@ -46,6 +46,12 @@ impl From for EngineError { } } +impl From for EngineError { + fn from(e: reqwest::Error) -> Self { + Self::Parse(e.to_string()) + } +} + impl From for EngineError { fn from(e: serde_json::Error) -> Self { Self::Parse(e.to_string()) diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs new file mode 100644 index 0000000..2a5cd74 --- /dev/null +++ b/crates/bds-core/src/engine/generation.rs @@ -0,0 +1,227 @@ +use std::path::Path; + +use chrono::{DateTime, TimeZone, Utc}; +use rusqlite::Connection; + +use crate::engine::{EngineError, EngineResult}; +use crate::model::{Post, ProjectMetadata}; +use crate::render::{ + GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path, + render_markdown_to_html, render_starter_list_page, render_starter_single_post_page, + write_generated_file, +}; + +#[derive(Debug, Clone)] +pub struct PublishedPostSource { + pub post: Post, + pub body_markdown: String, +} + +#[derive(Debug, Default, Clone)] +pub struct GenerationReport { + pub written_paths: Vec, + pub skipped_paths: Vec, +} + +pub fn generate_starter_site( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + language: &str, +) -> EngineResult { + let mut report = GenerationReport::default(); + + let list_input = posts + .iter() + .map(|source| (source.post.clone(), source.body_markdown.clone())) + .collect::>(); + let index_page = render_starter_list_page(&list_input, metadata, language) + .map_err(|error| EngineError::Parse(error.to_string()))?; + write_out(conn, output_dir, project_id, &index_page.relative_path, &index_page.html, &mut report)?; + + for source in posts { + let rendered = render_starter_single_post_page(&source.post, &source.body_markdown, metadata, language) + .map_err(|error| EngineError::Parse(error.to_string()))?; + write_out(conn, output_dir, project_id, &rendered.relative_path, &rendered.html, &mut report)?; + } + + write_out( + conn, + output_dir, + project_id, + "calendar.json", + &build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::>())?, + &mut report, + )?; + + let rss = build_rss_xml(metadata, posts, language); + write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?; + write_out(conn, output_dir, project_id, "feed.xml", &rss, &mut report)?; + write_out(conn, output_dir, project_id, "atom.xml", &build_atom_xml(metadata, posts, language), &mut report)?; + write_out(conn, output_dir, project_id, "sitemap.xml", &build_sitemap_xml(metadata, posts, language), &mut report)?; + + Ok(report) +} + +fn write_out( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &str, + report: &mut GenerationReport, +) -> EngineResult<()> { + match write_generated_file(conn, output_dir, project_id, relative_path, content) + .map_err(|error| EngineError::Parse(error.to_string()))? + { + GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()), + GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative_path.to_string()), + } + Ok(()) +} + +fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { + let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + let last_build = posts + .iter() + .filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at))) + .max() + .unwrap_or_else(Utc::now); + + let mut xml = vec![ + "".to_string(), + "".to_string(), + " ".to_string(), + format!(" {}", escape_xml(&metadata.name)), + format!(" {base_url}/"), + format!(" {}", escape_xml(metadata.description.as_deref().unwrap_or(""))), + format!(" {}", last_build.format("%a, %d %b %Y %H:%M:%S GMT")), + " bDS".to_string(), + ]; + + for source in posts { + let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en"))); + let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now); + xml.push(" ".to_string()); + xml.push(format!(" {}", escape_xml(&source.post.title))); + xml.push(format!(" {url}")); + xml.push(format!(" {url}")); + xml.push(format!(" {}", published.format("%a, %d %b %Y %H:%M:%S GMT"))); + if let Some(author) = &source.post.author { + xml.push(format!(" {}", escape_xml(author))); + } + xml.push(format!(" {}", escape_xml(language))); + let html = render_markdown_to_html(&source.body_markdown); + xml.push(format!(" ")); + xml.push(format!(" ")); + for category in &source.post.categories { + xml.push(format!(" {}", escape_xml(category))); + } + for tag in &source.post.tags { + xml.push(format!(" {}", escape_xml(tag))); + } + xml.push(" ".to_string()); + } + + xml.push(" ".to_string()); + xml.push("".to_string()); + xml.join("\n") +} + +fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { + let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + let updated = posts + .iter() + .filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at))) + .max() + .unwrap_or_else(Utc::now); + let mut xml = vec![ + "".to_string(), + "".to_string(), + format!(" {}", escape_xml(&metadata.name)), + format!(" {}", escape_xml(metadata.description.as_deref().unwrap_or(""))), + format!(" {base_url}/"), + format!(" "), + format!(" "), + format!(" {}", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), + ]; + + for source in posts { + let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en"))); + let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now); + let html = render_markdown_to_html(&source.body_markdown); + xml.push(format!(" ", escape_xml(language))); + xml.push(format!(" {}", escape_xml(&source.post.title))); + xml.push(format!(" {url}")); + xml.push(format!(" ")); + xml.push(format!(" {}", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))); + xml.push(format!(" {}", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))); + if let Some(author) = &source.post.author { + xml.push(format!(" {}", escape_xml(author))); + } + xml.push(format!("
{html}
")); + xml.push(format!("
{html}
")); + for category in &source.post.categories { + xml.push(format!(" ", escape_xml(category))); + } + for tag in &source.post.tags { + xml.push(format!(" ", escape_xml(tag))); + } + xml.push("
".to_string()); + } + + xml.push("
".to_string()); + xml.join("\n") +} + +fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { + let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + let index_lastmod = posts + .iter() + .filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at))) + .max() + .unwrap_or_else(Utc::now) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + + let mut xml = vec![ + "".to_string(), + "".to_string(), + " ".to_string(), + format!(" {base_url}/"), + format!(" {index_lastmod}"), + " daily".to_string(), + " 1.0".to_string(), + " ".to_string(), + ]; + + for source in posts { + let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en"))); + let lastmod = timestamp(source.post.published_at.unwrap_or(source.post.created_at)) + .unwrap_or_else(Utc::now) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + xml.push(" ".to_string()); + xml.push(format!(" {url}")); + xml.push(format!(" {lastmod}")); + xml.push(" weekly".to_string()); + xml.push(" 0.8".to_string()); + xml.push(" ".to_string()); + } + + xml.push("".to_string()); + xml.join("\n") +} + +fn timestamp(timestamp_ms: i64) -> Option> { + chrono::Utc.timestamp_millis_opt(timestamp_ms).single() +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} \ No newline at end of file diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index adb5f09..6c0019d 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod ai; pub mod context; pub mod project; pub mod meta; @@ -15,6 +16,8 @@ pub mod metadata_diff; pub mod rebuild; pub mod search; pub mod calendar; +pub mod generation; +pub mod preview; pub mod validate_translations; pub mod validate_media; pub mod validate_content; diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs new file mode 100644 index 0000000..a87eb64 --- /dev/null +++ b/crates/bds-core/src/engine/preview.rs @@ -0,0 +1,126 @@ +use crate::engine::{EngineError, EngineResult}; +use crate::engine::generation::PublishedPostSource; +use crate::model::ProjectMetadata; +use crate::render::{build_canonical_post_path, render_starter_list_page, render_starter_single_post_page}; + +pub fn render_preview_path( + path: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], +) -> EngineResult> { + let normalized = if path.is_empty() { "/" } else { path }; + let main_language = metadata.main_language.as_deref().unwrap_or("en"); + + if normalized == "/" { + let list_posts = posts + .iter() + .map(|source| (source.post.clone(), source.body_markdown.clone())) + .collect::>(); + return render_starter_list_page(&list_posts, metadata, main_language) + .map(|page| Some(page.html)) + .map_err(|error| EngineError::Parse(error.to_string())); + } + + let (language, route_path) = split_language_prefix(normalized, metadata); + if let Some(source) = posts.iter().find(|source| { + build_canonical_post_path(&source.post, &language, main_language) == route_path + }) { + return render_starter_single_post_page(&source.post, &source.body_markdown, metadata, &language) + .map(|page| Some(page.html)) + .map_err(|error| EngineError::Parse(error.to_string())); + } + + Ok(None) +} + +fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, String) { + let trimmed = path.trim_start_matches('/'); + let mut segments = trimmed.split('/'); + let first = segments.next().unwrap_or_default(); + if metadata.blog_languages.iter().any(|language| language == first) { + let remainder = segments.collect::>().join("/"); + return (first.to_string(), format!("/{first}/{}", remainder.trim_start_matches('/'))); + } + + ( + metadata.main_language.as_deref().unwrap_or("en").to_string(), + path.to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Post, PostStatus}; + + fn make_metadata() -> ProjectMetadata { + ProjectMetadata { + name: "Blog".into(), + description: None, + public_url: Some("https://example.com".into()), + main_language: Some("en".into()), + default_author: None, + max_posts_per_page: 50, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: vec!["en".into(), "de".into()], + } + } + + fn make_post() -> PublishedPostSource { + PublishedPostSource { + post: Post { + id: "post-1".into(), + project_id: "project-1".into(), + title: "Hello".into(), + slug: "hello".into(), + excerpt: None, + content: Some("Body".into()), + status: PostStatus::Published, + author: None, + language: Some("en".into()), + do_not_translate: false, + template_slug: None, + file_path: String::new(), + checksum: None, + tags: vec![], + categories: vec![], + published_title: None, + published_content: None, + published_tags: None, + published_categories: None, + published_excerpt: None, + created_at: 1_710_000_000_000, + updated_at: 1_710_000_000_000, + published_at: Some(1_710_000_000_000), + }, + body_markdown: "Hello **world**".into(), + } + } + + #[test] + fn root_preview_renders_index_page() { + let html = render_preview_path("/", &make_metadata(), &[make_post()]) + .unwrap() + .unwrap(); + assert!(html.contains("post-list")); + } + + #[test] + fn preview_renders_single_post_for_canonical_path() { + let html = render_preview_path("/2024/03/09/hello", &make_metadata(), &[make_post()]) + .unwrap() + .unwrap(); + assert!(html.contains("

Hello

")); + assert!(html.contains("world")); + } + + #[test] + fn preview_renders_language_prefixed_single_post() { + let html = render_preview_path("/de/2024/03/09/hello", &make_metadata(), &[make_post()]) + .unwrap() + .unwrap(); + assert!(html.contains("lang=\"de\"")); + } +} \ No newline at end of file diff --git a/crates/bds-core/src/render/generation.rs b/crates/bds-core/src/render/generation.rs new file mode 100644 index 0000000..2d45ac3 --- /dev/null +++ b/crates/bds-core/src/render/generation.rs @@ -0,0 +1,104 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use chrono::{Datelike, TimeZone, Utc}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::db::queries::generated_file_hash as qhash; +use crate::model::{GeneratedFileHash, Post}; +use crate::util::{atomic_write_str, content_hash, now_unix_ms}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeneratedWriteOutcome { + Written, + SkippedUnchanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CalendarArchiveData { + pub years: BTreeMap, + pub months: BTreeMap, + pub days: BTreeMap, +} + +pub fn write_generated_file( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &str, +) -> Result> { + let hash = content_hash(content.as_bytes()); + if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) { + if existing.content_hash == hash { + return Ok(GeneratedWriteOutcome::SkippedUnchanged); + } + } + + let target_path = output_dir.join(relative_path); + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + atomic_write_str(&target_path, content)?; + + qhash::upsert_generated_file_hash( + conn, + &GeneratedFileHash { + project_id: project_id.to_string(), + relative_path: relative_path.to_string(), + content_hash: hash, + updated_at: now_unix_ms(), + }, + )?; + + Ok(GeneratedWriteOutcome::Written) +} + +pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String]) -> Vec { + let mut paths = vec![ + "index.html".to_string(), + "sitemap.xml".to_string(), + "feed.xml".to_string(), + "atom.xml".to_string(), + "calendar.json".to_string(), + ]; + + for language in blog_languages { + if language != main_language { + paths.push(format!("{language}/index.html")); + paths.push(format!("{language}/feed.xml")); + paths.push(format!("{language}/atom.xml")); + } + } + + paths +} + +pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData { + let mut years = BTreeMap::new(); + let mut months = BTreeMap::new(); + let mut days = BTreeMap::new(); + + for post in posts { + let timestamp_ms = post.published_at.unwrap_or(post.created_at); + let Some(created_at) = Utc.timestamp_millis_opt(timestamp_ms).single() else { + continue; + }; + + let year = created_at.year().to_string(); + let month = format!("{year}-{:02}", created_at.month()); + let day = format!("{month}-{:02}", created_at.day()); + + *years.entry(year).or_insert(0) += 1; + *months.entry(month).or_insert(0) += 1; + *days.entry(day).or_insert(0) += 1; + } + + CalendarArchiveData { years, months, days } +} + +pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result { + serde_json::to_string_pretty(&build_calendar_archive_data(posts)) +} \ No newline at end of file diff --git a/crates/bds-core/src/render/markdown.rs b/crates/bds-core/src/render/markdown.rs new file mode 100644 index 0000000..7253e3b --- /dev/null +++ b/crates/bds-core/src/render/markdown.rs @@ -0,0 +1,25 @@ +use pulldown_cmark::{Options, Parser, html}; + +pub fn render_markdown_to_html(markdown: &str) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_TASKLISTS); + + let parser = Parser::new_ext(markdown, options); + let mut output = String::new(); + html::push_html(&mut output, parser); + output +} + +#[cfg(test)] +mod tests { + use super::render_markdown_to_html; + + #[test] + fn renders_commonmark_to_html() { + let rendered = render_markdown_to_html("# Title\n\nSome *text*."); + assert!(rendered.contains("

Title

")); + assert!(rendered.contains("

Some text.

")); + } +} \ No newline at end of file diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index 67c9d95..bc26d3d 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -1 +1,20 @@ -// Rendering pipeline — stubs for M0, implemented in M4. +mod markdown; +mod generation; +mod page_renderer; +mod routes; +mod template_lookup; + +pub use generation::{ + CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, + build_core_generation_paths, write_generated_file, +}; +pub use markdown::render_markdown_to_html; +pub use page_renderer::{RenderError, render_liquid_template}; +pub use routes::{ + RenderedPage, build_canonical_post_path, render_starter_list_page, + render_starter_single_post_page, +}; +pub use template_lookup::{ + RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, + resolve_post_template, +}; diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs new file mode 100644 index 0000000..c6362bd --- /dev/null +++ b/crates/bds-core/src/render/page_renderer.rs @@ -0,0 +1,335 @@ +use std::collections::HashMap; + +use liquid::ParserBuilder; +use liquid::partials::{EagerCompiler, InMemorySource}; +use liquid_core::{ + Display_filter, Expression, Filter, FilterParameters, FilterReflection, + FromFilterParameters, ParseFilter, Runtime, Value, ValueView, +}; +use serde::Serialize; +use thiserror::Error; + +use crate::i18n::translate_render; +use crate::render::render_markdown_to_html; + +#[derive(Debug, Error)] +pub enum RenderError { + #[error("liquid error: {0}")] + Liquid(#[from] liquid::Error), +} + +#[derive(Debug, Clone, Default)] +struct HtmlRewriteContext { + canonical_post_path_by_slug: HashMap, + canonical_media_path_by_source_path: HashMap, +} + +pub fn render_liquid_template( + template_source: &str, + partials: &HashMap, + context: &T, +) -> Result { + let mut compiled_partials: EagerCompiler = EagerCompiler::empty(); + for (name, content) in partials { + compiled_partials.add(format!("{name}.liquid"), content.clone()); + } + + let parser = ParserBuilder::with_stdlib() + .filter(I18n) + .filter(Markdown) + .partials(compiled_partials) + .build()?; + let template = parser.parse(template_source)?; + let globals = liquid::to_object(context)?; + Ok(template.render(&globals)?) +} + +#[derive(Debug, FilterParameters)] +struct I18nArgs { + #[parameter(description = "Render language", arg_type = "str")] + language: Expression, +} + +#[derive(Clone, ParseFilter, FilterReflection)] +#[filter( + name = "i18n", + description = "Translate a render key for a content language.", + parameters(I18nArgs), + parsed(I18nFilter) +)] +struct I18n; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "i18n"] +struct I18nFilter { + #[parameters] + args: I18nArgs, +} + +impl Filter for I18nFilter { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result { + let args = self.args.evaluate(runtime)?; + let key = input.to_kstr(); + let language = args.language.to_kstr(); + Ok(Value::scalar(translate_render(language.as_str(), key.as_str()))) + } +} + +#[derive(Debug, FilterParameters)] +struct MarkdownArgs { + #[parameter(description = "Post id", arg_type = "str")] + post_id: Option, + #[parameter(description = "Post data by id", arg_type = "any")] + post_data_json_by_id: Option, + #[parameter(description = "Canonical post path map", arg_type = "any")] + canonical_post_path_by_slug: Option, + #[parameter(description = "Canonical media path map", arg_type = "any")] + canonical_media_path_by_source_path: Option, + #[parameter(description = "Render language", arg_type = "str")] + language: Option, + #[parameter(description = "Language prefix", arg_type = "str")] + language_prefix: Option, +} + +#[derive(Clone, ParseFilter, FilterReflection)] +#[filter( + name = "markdown", + description = "Render markdown to HTML and rewrite preview URLs.", + parameters(MarkdownArgs), + parsed(MarkdownFilter) +)] +struct Markdown; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "markdown"] +struct MarkdownFilter { + #[parameters] + args: MarkdownArgs, +} + +impl Filter for MarkdownFilter { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result { + let args = self.args.evaluate(runtime)?; + let markdown = input.to_kstr(); + let rewrite_context = HtmlRewriteContext { + canonical_post_path_by_slug: args + .canonical_post_path_by_slug + .as_ref() + .map(value_to_string_map) + .unwrap_or_default(), + canonical_media_path_by_source_path: args + .canonical_media_path_by_source_path + .as_ref() + .map(value_to_string_map) + .unwrap_or_default(), + }; + + let rendered = render_markdown_to_html(markdown.as_str()); + Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context))) + } +} + +fn value_to_string_map(value: &impl ValueView) -> HashMap { + value + .as_object() + .map(|object| { + object + .iter() + .map(|(key, value)| (key.to_string(), value.to_kstr().into_owned().to_string())) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String { + let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context)); + rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context)) +} + +pub(crate) trait RewriteContextView { + fn canonical_post_path_by_slug(&self) -> &HashMap; + fn canonical_media_path_by_source_path(&self) -> &HashMap; +} + +impl RewriteContextView for HtmlRewriteContext { + fn canonical_post_path_by_slug(&self) -> &HashMap { + &self.canonical_post_path_by_slug + } + + fn canonical_media_path_by_source_path(&self) -> &HashMap { + &self.canonical_media_path_by_source_path + } +} + +fn rewrite_attribute_urls( + html: &str, + attribute: &str, + normalize: impl Fn(&str) -> String, +) -> String { + let mut result = String::with_capacity(html.len()); + let mut cursor = 0; + + while let Some(offset) = html[cursor..].find(attribute) { + let attr_start = cursor + offset; + result.push_str(&html[cursor..attr_start]); + result.push_str(attribute); + + let after_attr = attr_start + attribute.len(); + if !html[after_attr..].starts_with('=') { + cursor = after_attr; + continue; + } + result.push('='); + + let quote_index = after_attr + 1; + let Some(quote) = html[quote_index..].chars().next() else { + cursor = after_attr + 1; + continue; + }; + if quote != '\'' && quote != '"' { + cursor = after_attr + 1; + continue; + } + result.push(quote); + + let value_start = quote_index + quote.len_utf8(); + let Some(value_end_rel) = html[value_start..].find(quote) else { + result.push_str(&html[value_start..]); + return result; + }; + let value_end = value_start + value_end_rel; + result.push_str(&normalize(&html[value_start..value_end])); + result.push(quote); + cursor = value_end + quote.len_utf8(); + } + + result.push_str(&html[cursor..]); + result +} + +fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextView) -> String { + if raw_href.is_empty() || is_external_or_special_url(raw_href) { + return raw_href.to_string(); + } + + let (path_part, suffix) = split_path_suffix(raw_href.trim()); + if let Some(normalized) = normalize_day_route(path_part) { + return format!("{normalized}{suffix}"); + } + + if let Some(slug) = extract_post_slug(path_part) { + let canonical = rewrite_context + .canonical_post_path_by_slug() + .get(&slug) + .cloned() + .unwrap_or_else(|| format!("/posts/{slug}")); + return format!("{canonical}{suffix}"); + } + + if let Some(media_source_key) = extract_media_source_key(path_part) { + let canonical = rewrite_context + .canonical_media_path_by_source_path() + .get(&media_source_key) + .cloned() + .unwrap_or_else(|| format!("/{media_source_key}")); + return format!("{canonical}{suffix}"); + } + + raw_href.to_string() +} + +fn normalize_preview_src(raw_src: &str, rewrite_context: &impl RewriteContextView) -> String { + if raw_src.is_empty() || is_external_or_special_url(raw_src) { + return raw_src.to_string(); + } + + let (path_part, suffix) = split_path_suffix(raw_src.trim()); + if let Some(media_source_key) = extract_media_source_key(path_part) { + let canonical = rewrite_context + .canonical_media_path_by_source_path() + .get(&media_source_key) + .cloned() + .unwrap_or_else(|| format!("/{media_source_key}")); + return format!("{canonical}{suffix}"); + } + + raw_src.to_string() +} + +fn is_external_or_special_url(value: &str) -> bool { + let normalized = value.trim(); + if normalized.is_empty() { + return false; + } + if normalized.starts_with('#') || normalized.starts_with("//") { + return true; + } + + let mut seen_alpha = false; + for ch in normalized.chars() { + if ch == ':' { + return seen_alpha; + } + if ch.is_ascii_alphanumeric() || matches!(ch, '+' | '.' | '-') { + seen_alpha = true; + continue; + } + break; + } + + false +} + +fn split_path_suffix(value: &str) -> (&str, &str) { + let split_index = value.find(['?', '#']).unwrap_or(value.len()); + (&value[..split_index], &value[split_index..]) +} + +fn normalize_day_route(path: &str) -> Option { + let segments: Vec<_> = path.trim_start_matches('/').split('/').collect(); + if segments.len() != 4 { + return None; + } + let [year, month, day, slug] = segments.as_slice() else { + return None; + }; + if year.len() != 4 || !year.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + let month = month.parse::().ok()?; + let day = day.parse::().ok()?; + if slug.is_empty() { + return None; + } + Some(format!("/{year}/{month:02}/{day:02}/{slug}")) +} + +fn extract_post_slug(path: &str) -> Option { + let trimmed = path.trim_start_matches('/'); + let segments: Vec<_> = trimmed.split('/').collect(); + match segments.as_slice() { + ["post" | "posts", slug] => Some(trim_html_suffix(slug)), + ["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => { + Some(trim_html_suffix(slug)) + } + _ => None, + } +} + +fn extract_media_source_key(path: &str) -> Option { + let trimmed = path.trim_start_matches('/'); + let segments: Vec<_> = trimmed.split('/').collect(); + match segments.as_slice() { + ["media", year, month, filename] if year.len() == 4 && month.len() == 2 => { + Some(format!("media/{year}/{month}/{}", filename.to_lowercase())) + } + _ => None, + } +} + +fn trim_html_suffix(value: &str) -> String { + value + .trim_end_matches(".html") + .trim_end_matches(".htm") + .to_string() +} \ No newline at end of file diff --git a/crates/bds-core/src/render/routes.rs b/crates/bds-core/src/render/routes.rs new file mode 100644 index 0000000..ce4fdb4 --- /dev/null +++ b/crates/bds-core/src/render/routes.rs @@ -0,0 +1,474 @@ +use std::collections::HashMap; + +use chrono::{Datelike, TimeZone, Utc}; +use serde::Serialize; + +use crate::i18n::normalize_language; +use crate::model::{Post, ProjectMetadata}; +use crate::render::{RenderError, render_liquid_template}; + +const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid"); +const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid"); +const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid"); +const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid"); +const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedPage { + pub relative_path: String, + pub html: String, +} + +#[derive(Debug, Clone, Serialize)] +struct AlternateLinkContext { + href: String, + hreflang: String, +} + +#[derive(Debug, Clone, Serialize)] +struct BlogLanguageContext { + is_current: bool, + code: String, + flag: String, + href: String, + href_prefix: String, +} + +#[derive(Debug, Clone, Serialize)] +struct DayBlockContext { + show_date_marker: bool, + date_label: String, + posts: Vec, + show_separator: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct ListTemplateContext { + language: String, + language_prefix: String, + page_title: String, + pico_stylesheet_href: Option, + html_theme_attribute: Option, + alternate_links: Vec, + blog_languages: Vec, + menu_items: Vec, + calendar_initial_year: i32, + calendar_initial_month: u32, + archive_context: Option, + show_archive_range_heading: bool, + min_date: Option, + max_date: Option, + day_blocks: Vec, + is_list_page: bool, + is_first_page: bool, + is_last_page: bool, + has_prev_page: bool, + has_next_page: bool, + prev_page_href: Option, + next_page_href: Option, + canonical_post_path_by_slug: HashMap, + canonical_media_path_by_source_path: HashMap, + post_data_json_by_id: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +struct PostTemplateContext<'a> { + language: &'a str, + language_prefix: String, + page_title: &'a str, + pico_stylesheet_href: Option, + html_theme_attribute: Option, + alternate_links: Vec, + blog_languages: Vec, + menu_items: Vec, + calendar_initial_year: i32, + calendar_initial_month: u32, + post: serde_json::Value, + post_categories: Vec, + post_tags: Vec, + tag_color_by_name: HashMap, + backlinks: Vec, + canonical_post_path_by_slug: HashMap, + canonical_media_path_by_source_path: HashMap, + post_data_json_by_id: HashMap, +} + +pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &str) -> String { + let timestamp_ms = post.published_at.unwrap_or(post.created_at); + let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else { + return fallback_language_path(post, language, main_language); + }; + + let base = format!( + "/{:04}/{:02}/{:02}/{}", + timestamp.year(), + timestamp.month(), + timestamp.day(), + post.slug + ); + + if language.eq_ignore_ascii_case(main_language) { + base + } else { + format!("/{language}{base}") + } +} + +pub fn render_starter_single_post_page( + post: &Post, + body_markdown: &str, + metadata: &ProjectMetadata, + language: &str, +) -> Result { + let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')); + let canonical_path = build_canonical_post_path(post, language, main_language(metadata)); + let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post); + let context = PostTemplateContext { + language, + language_prefix: language_prefix(language, main_language(metadata)), + page_title: &post.title, + pico_stylesheet_href: metadata + .pico_theme + .as_ref() + .map(|_| "/assets/pico.min.css".to_string()), + html_theme_attribute: None, + alternate_links: build_alternate_links(post, metadata, language), + blog_languages: build_blog_languages(post, metadata, language), + menu_items: vec![], + calendar_initial_year, + calendar_initial_month, + post: serde_json::json!({ + "id": post.id, + "title": post.title, + "content": body_markdown, + }), + post_categories: post.categories.clone(), + post_tags: post.tags.clone(), + tag_color_by_name: post + .tags + .iter() + .map(|tag| (tag.clone(), String::new())) + .collect(), + backlinks: vec![], + canonical_post_path_by_slug: HashMap::from([(post.slug.clone(), canonical_path)]), + canonical_media_path_by_source_path: HashMap::new(), + post_data_json_by_id: HashMap::new(), + }; + + let html = render_liquid_template( + STARTER_SINGLE_POST_TEMPLATE, + &starter_partials(), + &context, + )?; + + Ok(RenderedPage { relative_path, html }) +} + +pub fn render_starter_list_page( + posts: &[(Post, String)], + metadata: &ProjectMetadata, + language: &str, +) -> Result { + let relative_path = if language.eq_ignore_ascii_case(main_language(metadata)) { + "index.html".to_string() + } else { + format!("{language}/index.html") + }; + + let canonical_paths = posts + .iter() + .map(|(post, _)| { + ( + post.slug.clone(), + build_canonical_post_path(post, language, main_language(metadata)), + ) + }) + .collect::>(); + + let (calendar_initial_year, calendar_initial_month) = posts + .first() + .map(|(post, _)| calendar_initial_parts(post)) + .unwrap_or((1970, 1)); + + let context = ListTemplateContext { + language: language.to_string(), + language_prefix: language_prefix(language, main_language(metadata)), + page_title: metadata.name.clone(), + pico_stylesheet_href: metadata + .pico_theme + .as_ref() + .map(|_| "/assets/pico.min.css".to_string()), + html_theme_attribute: None, + alternate_links: vec![], + blog_languages: build_blog_languages_for_index(metadata, language), + menu_items: vec![], + calendar_initial_year, + calendar_initial_month, + archive_context: None, + show_archive_range_heading: false, + min_date: None, + max_date: None, + day_blocks: build_day_blocks(posts), + is_list_page: false, + is_first_page: true, + is_last_page: true, + has_prev_page: false, + has_next_page: false, + prev_page_href: None, + next_page_href: None, + canonical_post_path_by_slug: canonical_paths, + canonical_media_path_by_source_path: HashMap::new(), + post_data_json_by_id: HashMap::new(), + }; + + let html = render_liquid_template( + &starter_post_list_template(), + &starter_partials(), + &context, + )?; + + Ok(RenderedPage { relative_path, html }) +} + +fn starter_partials() -> HashMap { + HashMap::from([ + ("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()), + ("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()), + ( + "partials/language-switcher".to_string(), + STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(), + ), + ( + "partials/menu-items".to_string(), + "{% for item in items %}{{ item.title }}{% endfor %}".to_string(), + ), + ]) +} + +fn starter_post_list_template() -> String { + STARTER_POST_LIST_TEMPLATE.replace( + "{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}", + "{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix, alternate_links: alternate_links %}", + ) +} + +fn main_language(metadata: &ProjectMetadata) -> &str { + metadata.main_language.as_deref().unwrap_or("en") +} + +fn language_prefix(language: &str, main_language: &str) -> String { + if language.eq_ignore_ascii_case(main_language) { + String::new() + } else { + format!("/{language}") + } +} + +fn fallback_language_path(post: &Post, language: &str, main_language: &str) -> String { + if language.eq_ignore_ascii_case(main_language) { + format!("/posts/{}", post.slug) + } else { + format!("/{language}/posts/{}", post.slug) + } +} + +fn calendar_initial_parts(post: &Post) -> (i32, u32) { + let timestamp_ms = post.published_at.unwrap_or(post.created_at); + Utc.timestamp_millis_opt(timestamp_ms) + .single() + .map(|timestamp| (timestamp.year(), timestamp.month())) + .unwrap_or((1970, 1)) +} + +fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec { + metadata + .blog_languages + .iter() + .map(|language| AlternateLinkContext { + href: build_absolute_post_url(post, metadata, language), + hreflang: language.clone(), + }) + .chain(std::iter::once(AlternateLinkContext { + href: build_absolute_post_url(post, metadata, current_language), + hreflang: "x-default".to_string(), + })) + .collect() +} + +fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec { + metadata + .blog_languages + .iter() + .map(|language| BlogLanguageContext { + is_current: language.eq_ignore_ascii_case(current_language), + code: language.clone(), + flag: render_flag(language), + href: build_absolute_post_url(post, metadata, language), + href_prefix: language_prefix(language, main_language(metadata)), + }) + .collect() +} + +fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec { + metadata + .blog_languages + .iter() + .map(|language| BlogLanguageContext { + is_current: language.eq_ignore_ascii_case(current_language), + code: language.clone(), + flag: render_flag(language), + href: build_absolute_index_url(metadata, language), + href_prefix: language_prefix(language, main_language(metadata)), + }) + .collect() +} + +fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String { + let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata))) +} + +fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String { + let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) { + "/".to_string() + } else { + format!("/{language}/") + }; + format!("{base_url}{suffix}") +} + +fn render_flag(language: &str) -> String { + normalize_language(language).flag_emoji().to_string() +} + +fn build_day_blocks(posts: &[(Post, String)]) -> Vec { + let mut blocks: Vec = Vec::new(); + let mut current_key: Option = None; + + for (post, body) in posts { + let timestamp_ms = post.published_at.unwrap_or(post.created_at); + let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else { + continue; + }; + + let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day()); + if current_key.as_deref() != Some(key.as_str()) { + if let Some(last) = blocks.last_mut() { + last.show_separator = true; + } + current_key = Some(key); + blocks.push(DayBlockContext { + show_date_marker: true, + date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()), + posts: Vec::new(), + show_separator: false, + }); + } + + if let Some(block) = blocks.last_mut() { + block.posts.push(serde_json::json!({ + "id": post.id, + "slug": post.slug, + "title": post.title, + "content": post.excerpt.clone().unwrap_or_else(|| body.clone()), + "show_title": true, + })); + } + } + + blocks +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PostStatus; + + fn make_post() -> Post { + Post { + id: "post-1".into(), + project_id: "project-1".into(), + title: "Hello".into(), + slug: "hello".into(), + excerpt: None, + content: Some("Body".into()), + status: PostStatus::Published, + author: None, + language: Some("en".into()), + do_not_translate: false, + template_slug: None, + file_path: String::new(), + checksum: None, + tags: vec![], + categories: vec![], + published_title: None, + published_content: None, + published_tags: None, + published_categories: None, + published_excerpt: None, + created_at: 1_710_000_000_000, + updated_at: 1_710_000_000_000, + published_at: Some(1_710_000_000_000), + } + } + + fn make_metadata() -> ProjectMetadata { + ProjectMetadata { + name: "Blog".into(), + description: None, + public_url: Some("https://example.com".into()), + main_language: Some("en".into()), + default_author: None, + max_posts_per_page: 50, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: vec!["en".into(), "de".into()], + } + } + + #[test] + fn canonical_post_paths_follow_language_prefix_rule() { + let post = make_post(); + assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello"); + assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello"); + } + + #[test] + fn starter_single_post_renderer_uses_canonical_route_and_language_links() { + let post = make_post(); + let metadata = make_metadata(); + let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap(); + + assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html"); + assert!(rendered.html.contains("https://example.com/2024/03/09/hello")); + assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello")); + assert!(rendered.html.contains("href=\"/2024/03/09/hello\"")); + } + + #[test] + fn starter_list_renderer_groups_posts_and_uses_language_specific_index_path() { + let metadata = make_metadata(); + let first = make_post(); + let mut second = make_post(); + second.id = "post-2".into(); + second.slug = "next".into(); + second.title = "Next".into(); + second.published_at = Some(1_710_086_400_000); + second.created_at = 1_710_086_400_000; + + let rendered = render_starter_list_page( + &[(first, "First body".into()), (second, "Second body".into())], + &metadata, + "de", + ) + .unwrap(); + + assert_eq!(rendered.relative_path, "de/index.html"); + assert!(rendered.html.contains("archive-day-group")); + assert!(rendered.html.contains("09.03.2024")); + assert!(rendered.html.contains("10.03.2024")); + assert!(rendered.html.contains("href=\"/de/2024/03/10/next\"")); + } +} \ No newline at end of file diff --git a/crates/bds-core/src/render/template_lookup.rs b/crates/bds-core/src/render/template_lookup.rs new file mode 100644 index 0000000..6f4a446 --- /dev/null +++ b/crates/bds-core/src/render/template_lookup.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; + +use crate::model::{Post, Tag, Template, TemplateKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderCategorySettings { + pub post_template_slug: Option, +} + +#[derive(Debug, Clone)] +pub struct RenderTemplateLookup<'a> { + pub post: &'a Post, + pub templates: &'a [Template], + pub tags: &'a [Tag], + pub category_settings: &'a HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateLookupError { + MissingExplicitTemplate(String), + MissingDefaultTemplate, +} + +pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a Template, TemplateLookupError> { + if let Some(explicit_slug) = lookup.post.template_slug.as_deref() { + return lookup + .templates + .iter() + .find(|template| is_enabled_post_template(template, explicit_slug)) + .ok_or_else(|| TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string())); + } + + for post_tag in &lookup.post.tags { + if let Some(template_slug) = lookup + .tags + .iter() + .find(|tag| tag.name.eq_ignore_ascii_case(post_tag)) + .and_then(|tag| tag.post_template_slug.as_deref()) + { + if let Some(template) = lookup + .templates + .iter() + .find(|template| is_enabled_post_template(template, template_slug)) + { + return Ok(template); + } + } + } + + for category_name in &lookup.post.categories { + if let Some(template_slug) = lookup + .category_settings + .get(category_name) + .and_then(|settings| settings.post_template_slug.as_deref()) + { + if let Some(template) = lookup + .templates + .iter() + .find(|template| is_enabled_post_template(template, template_slug)) + { + return Ok(template); + } + } + } + + lookup + .templates + .iter() + .find(|template| is_enabled_post_template(template, "post")) + .ok_or(TemplateLookupError::MissingDefaultTemplate) +} + +fn is_enabled_post_template(template: &Template, slug: &str) -> bool { + template.enabled && template.kind == TemplateKind::Post && template.slug == slug +} \ No newline at end of file diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs new file mode 100644 index 0000000..65765ed --- /dev/null +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -0,0 +1,128 @@ +use bds_core::db::queries::project::insert_project; +use bds_core::db::Database; +use bds_core::engine::generation::{PublishedPostSource, generate_starter_site}; +use bds_core::model::{Post, PostStatus, Project, ProjectMetadata}; +use tempfile::TempDir; + +fn make_project() -> Project { + Project { + id: "p1".into(), + name: "Blog".into(), + slug: "blog".into(), + description: None, + data_path: None, + is_active: false, + created_at: 1, + updated_at: 1, + } +} + +fn make_metadata() -> ProjectMetadata { + ProjectMetadata { + name: "Blog".into(), + description: Some("desc".into()), + public_url: Some("https://example.com".into()), + main_language: Some("en".into()), + default_author: None, + max_posts_per_page: 50, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: vec!["en".into()], + } +} + +fn make_post(slug: &str, published_at: i64) -> Post { + Post { + id: format!("post-{slug}"), + project_id: "p1".into(), + title: slug.into(), + slug: slug.into(), + excerpt: None, + content: Some("Body".into()), + status: PostStatus::Published, + author: Some("alice".into()), + language: Some("en".into()), + do_not_translate: false, + template_slug: None, + file_path: String::new(), + checksum: None, + tags: vec!["rust".into()], + categories: vec!["article".into()], + published_title: None, + published_content: None, + published_tags: None, + published_categories: None, + published_excerpt: None, + created_at: published_at, + updated_at: published_at, + published_at: Some(published_at), + } +} + +fn setup() -> (Database, TempDir) { + let mut db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + insert_project(db.conn(), &make_project()).unwrap(); + (db, TempDir::new().unwrap()) +} + +#[test] +fn generation_engine_writes_core_and_single_post_artifacts() { + 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(), + }, + PublishedPostSource { + post: make_post("next", 1_710_086_400_000), + body_markdown: "Next post".into(), + }, + ]; + + let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + + assert!(report.written_paths.contains(&"index.html".to_string())); + assert!(report.written_paths.contains(&"calendar.json".to_string())); + assert!(report.written_paths.contains(&"rss.xml".to_string())); + assert!(report.written_paths.contains(&"feed.xml".to_string())); + assert!(report.written_paths.contains(&"atom.xml".to_string())); + assert!(report.written_paths.contains(&"sitemap.xml".to_string())); + assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string())); + assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string())); + + assert!(dir.path().join("index.html").exists()); + assert!(dir.path().join("rss.xml").exists()); + assert!(dir.path().join("feed.xml").exists()); + assert!(dir.path().join("atom.xml").exists()); + assert!(dir.path().join("sitemap.xml").exists()); + assert!(dir.path().join("2024/03/09/hello/index.html").exists()); + + let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap(); + assert!(rss.contains(" Project { + Project { + id: "p1".into(), + name: "Blog".into(), + slug: "blog".into(), + description: None, + data_path: None, + is_active: false, + created_at: 1, + updated_at: 1, + } +} + +fn make_post(slug: &str, published_at: i64) -> Post { + Post { + id: format!("post-{slug}"), + project_id: "p1".into(), + title: slug.into(), + slug: slug.into(), + excerpt: None, + content: Some("Body".into()), + status: PostStatus::Published, + author: None, + language: Some("en".into()), + do_not_translate: false, + template_slug: None, + file_path: String::new(), + checksum: None, + tags: vec![], + categories: vec![], + published_title: None, + published_content: None, + published_tags: None, + published_categories: None, + published_excerpt: None, + created_at: published_at, + updated_at: published_at, + published_at: Some(published_at), + } +} + +fn setup() -> (Database, TempDir) { + let mut db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + insert_project(db.conn(), &make_project()).unwrap(); + (db, TempDir::new().unwrap()) +} + +#[test] +fn generated_write_skips_unchanged_content() { + let (db, dir) = setup(); + + let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + let third = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "changed").unwrap(); + + assert_eq!(first, GeneratedWriteOutcome::Written); + assert_eq!(second, GeneratedWriteOutcome::SkippedUnchanged); + assert_eq!(third, GeneratedWriteOutcome::Written); + + let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap(); + assert_eq!(stored.relative_path, "index.html"); + assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed")); +} + +#[test] +fn core_generation_paths_include_language_prefixed_variants() { + let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]); + assert!(paths.contains(&"index.html".to_string())); + assert!(paths.contains(&"sitemap.xml".to_string())); + assert!(paths.contains(&"feed.xml".to_string())); + assert!(paths.contains(&"atom.xml".to_string())); + assert!(paths.contains(&"calendar.json".to_string())); + assert!(paths.contains(&"de/index.html".to_string())); + assert!(paths.contains(&"de/feed.xml".to_string())); + assert!(paths.contains(&"de/atom.xml".to_string())); + assert!(paths.contains(&"fr/index.html".to_string())); +} + +#[test] +fn calendar_json_groups_posts_by_year_month_day() { + let posts = vec![ + make_post("a", 1_710_000_000_000), + make_post("b", 1_710_000_000_000), + make_post("c", 1_712_678_400_000), + ]; + + let json = build_calendar_json(&posts).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["years"]["2024"], 3); + assert_eq!(parsed["months"]["2024-03"], 2); + assert_eq!(parsed["months"]["2024-04"], 1); + assert_eq!(parsed["days"]["2024-03-09"], 2); + assert_eq!(parsed["days"]["2024-04-09"], 1); +} \ No newline at end of file diff --git a/crates/bds-core/tests/m4_page_renderer.rs b/crates/bds-core/tests/m4_page_renderer.rs new file mode 100644 index 0000000..8b65000 --- /dev/null +++ b/crates/bds-core/tests/m4_page_renderer.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; + +use serde::Serialize; + +use bds_core::render::render_liquid_template; + +#[derive(Debug, Serialize)] +struct SinglePostContext { + language: String, + language_prefix: String, + page_title: String, + pico_stylesheet_href: Option, + html_theme_attribute: Option, + alternate_links: Vec, + blog_languages: Vec, + menu_items: Vec, + calendar_initial_year: i32, + calendar_initial_month: i32, + post: serde_json::Value, + post_categories: Vec, + post_tags: Vec, + tag_color_by_name: HashMap, + backlinks: Vec, + canonical_post_path_by_slug: HashMap, + canonical_media_path_by_source_path: HashMap, + post_data_json_by_id: HashMap, +} + +#[test] +fn i18n_filter_renders_partial_content_language() { + let template = "{% render 'partials/label', label: 'render.archive', language: language %}"; + let partials = HashMap::from([( + "partials/label".to_string(), + "{{ label | i18n: language }}".to_string(), + )]); + let context = serde_json::json!({ "language": "de" }); + + let rendered = render_liquid_template(template, &partials, &context).unwrap(); + assert_eq!(rendered, "Archiv"); +} + +#[test] +fn markdown_filter_rewrites_post_and_media_urls() { + let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}"; + let partials = HashMap::new(); + let context = serde_json::json!({ + "body": "[Post](/posts/hello) ![Img](/media/2024/03/pic.png)", + "canonical_post_path_by_slug": {"hello": "/2024/03/09/hello"}, + "canonical_media_path_by_source_path": {"media/2024/03/pic.png": "/assets/pic.png"}, + "language": "en", + "language_prefix": "" + }); + + let rendered = render_liquid_template(template, &partials, &context).unwrap(); + assert!(rendered.contains("href=\"/2024/03/09/hello\"")); + assert!(rendered.contains("src=\"/assets/pic.png\"")); +} + +#[test] +fn starter_single_post_template_renders_with_partials() { + let template = include_str!("../../../assets/starter-templates/single-post.liquid"); + let partials = HashMap::from([ + ( + "partials/head".to_string(), + include_str!("../../../assets/starter-templates/partials/head.liquid").to_string(), + ), + ( + "partials/menu".to_string(), + include_str!("../../../assets/starter-templates/partials/menu.liquid").to_string(), + ), + ( + "partials/language-switcher".to_string(), + include_str!("../../../assets/starter-templates/partials/language-switcher.liquid").to_string(), + ), + ( + "partials/menu-items".to_string(), + "{% for item in items %}{{ item.title }}{% endfor %}".to_string(), + ), + ]); + + let context = SinglePostContext { + language: "en".into(), + language_prefix: String::new(), + page_title: "Hello".into(), + pico_stylesheet_href: None, + html_theme_attribute: None, + alternate_links: vec![], + blog_languages: vec![serde_json::json!({ + "is_current": true, + "code": "en", + "flag": "GB", + "href": "/", + "href_prefix": "" + })], + menu_items: vec![], + calendar_initial_year: 2024, + calendar_initial_month: 3, + post: serde_json::json!({ + "id": "post-1", + "title": "Hello", + "content": "A **world** post with [link](/posts/hello).", + }), + post_categories: vec![], + post_tags: vec![], + tag_color_by_name: HashMap::new(), + backlinks: vec![], + canonical_post_path_by_slug: HashMap::from([("hello".into(), "/2024/03/09/hello".into())]), + canonical_media_path_by_source_path: HashMap::new(), + post_data_json_by_id: HashMap::new(), + }; + + let rendered = render_liquid_template(template, &partials, &context).unwrap(); + assert!(rendered.contains("

Hello

")); + assert!(rendered.contains("world")); + assert!(rendered.contains("href=\"/2024/03/09/hello\"")); + assert!(rendered.contains("data-pagefind-body")); +} \ No newline at end of file diff --git a/crates/bds-core/tests/m4_render.rs b/crates/bds-core/tests/m4_render.rs new file mode 100644 index 0000000..70a9191 --- /dev/null +++ b/crates/bds-core/tests/m4_render.rs @@ -0,0 +1,174 @@ +use std::collections::HashMap; + +use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus}; +use bds_core::render::{ + RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, + render_markdown_to_html, resolve_post_template, +}; + +fn make_post() -> Post { + Post { + id: "post-1".into(), + project_id: "project-1".into(), + title: "Post".into(), + slug: "post".into(), + excerpt: None, + content: Some("# Hello".into()), + status: PostStatus::Published, + author: None, + language: Some("en".into()), + do_not_translate: false, + template_slug: None, + file_path: "posts/2026/04/10/post.md".into(), + checksum: None, + tags: vec![], + categories: vec![], + published_title: None, + published_content: None, + published_tags: None, + published_categories: None, + published_excerpt: None, + created_at: 1, + updated_at: 1, + published_at: Some(1), + } +} + +fn make_template(slug: &str) -> Template { + Template { + id: format!("template-{slug}"), + project_id: "project-1".into(), + slug: slug.into(), + title: slug.into(), + kind: TemplateKind::Post, + enabled: true, + version: 1, + file_path: format!("templates/{slug}.liquid"), + status: TemplateStatus::Published, + content: Some(format!("template:{slug}")), + created_at: 1, + updated_at: 1, + } +} + +fn make_tag(name: &str, post_template_slug: Option<&str>) -> Tag { + Tag { + id: format!("tag-{name}"), + project_id: "project-1".into(), + name: name.into(), + color: None, + post_template_slug: post_template_slug.map(ToOwned::to_owned), + created_at: 1, + updated_at: 1, + } +} + +#[test] +fn template_lookup_prefers_post_specific_template() { + let mut post = make_post(); + post.template_slug = Some("custom".into()); + post.tags = vec!["rust".into()]; + post.categories = vec!["article".into()]; + + let templates = vec![make_template("post"), make_template("tag-template"), make_template("custom")]; + let tags = vec![make_tag("rust", Some("tag-template"))]; + let mut categories = HashMap::new(); + categories.insert( + "article".into(), + RenderCategorySettings { + post_template_slug: Some("category-template".into()), + }, + ); + + let resolved = resolve_post_template(RenderTemplateLookup { + post: &post, + templates: &templates, + tags: &tags, + category_settings: &categories, + }) + .unwrap(); + + assert_eq!(resolved.slug, "custom"); +} + +#[test] +fn template_lookup_falls_back_to_tag_then_category_then_default() { + let mut post = make_post(); + post.tags = vec!["rust".into()]; + post.categories = vec!["article".into()]; + + let templates = vec![ + make_template("post"), + make_template("tag-template"), + make_template("category-template"), + ]; + let tags = vec![make_tag("rust", Some("tag-template"))]; + let mut categories = HashMap::new(); + categories.insert( + "article".into(), + RenderCategorySettings { + post_template_slug: Some("category-template".into()), + }, + ); + + let resolved = resolve_post_template(RenderTemplateLookup { + post: &post, + templates: &templates, + tags: &tags, + category_settings: &categories, + }) + .unwrap(); + assert_eq!(resolved.slug, "tag-template"); + + let category_post = Post { + tags: vec![], + ..post.clone() + }; + let resolved = resolve_post_template(RenderTemplateLookup { + post: &category_post, + templates: &templates, + tags: &tags, + category_settings: &categories, + }) + .unwrap(); + assert_eq!(resolved.slug, "category-template"); + + let default_post = Post { + tags: vec![], + categories: vec![], + ..post + }; + let empty_categories = HashMap::new(); + let resolved = resolve_post_template(RenderTemplateLookup { + post: &default_post, + templates: &templates, + tags: &tags, + category_settings: &empty_categories, + }) + .unwrap(); + assert_eq!(resolved.slug, "post"); +} + +#[test] +fn template_lookup_errors_when_explicit_template_missing() { + let mut post = make_post(); + post.template_slug = Some("missing".into()); + let templates = vec![make_template("post")]; + + let err = resolve_post_template(RenderTemplateLookup { + post: &post, + templates: &templates, + tags: &[], + category_settings: &HashMap::new(), + }) + .unwrap_err(); + + assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into())); +} + +#[test] +fn markdown_render_produces_html() { + let html = render_markdown_to_html("# Hello\n\nA paragraph with **bold** text."); + assert!(html.contains("

Hello

")); + assert!(html.contains("bold")); +} \ No newline at end of file diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 557f3c3..d5060b7 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -8,6 +8,7 @@ use iced::{Element, Subscription, Task}; use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine; +use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind}; use bds_core::i18n::{detect_os_locale, UiLocale}; use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template}; @@ -28,7 +29,7 @@ use crate::views::{ template_editor::{TemplateEditorState, TemplateEditorMsg}, script_editor::{ScriptEditorState, ScriptEditorMsg}, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, - settings_view::{default_category_rows, SettingsCategoryRow, SettingsViewState, SettingsMsg}, + settings_view::{default_category_rows, AiModelOption, SettingsCategoryRow, SettingsViewState, SettingsMsg}, dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth}, }; @@ -257,6 +258,15 @@ fn persist_media_editor_state_impl( } } +fn load_generation_post_body(data_dir: &Path, post: &Post) -> Result { + if let Some(content) = &post.content { + return Ok(content.clone()); + } + let raw = std::fs::read_to_string(data_dir.join(&post.file_path)).map_err(|e| e.to_string())?; + let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw)?; + Ok(body) +} + fn save_template_editor_state_impl( db: &Database, project_id: &str, @@ -1766,11 +1776,47 @@ impl BdsApp { "engine.generateSiteStarted", |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.20), Some("Generating calendar...".into())); - engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) + let metadata = engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?; + if metadata.public_url.as_deref().unwrap_or("").trim().is_empty() { + return Err("public URL is required before generating the site".to_string()); + } + let main_language = metadata.main_language.clone().unwrap_or_else(|| "en".to_string()); + let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id) .map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.90), Some("Calendar written".into())); - Ok("done".to_string()) + let published_posts = all_posts + .into_iter() + .filter(|post| post.status == PostStatus::Published) + .collect::>(); + let total = published_posts.len().max(1) as f32; + let mut sources = Vec::new(); + for (index, post) in published_posts.into_iter().enumerate() { + tm.report_progress( + tid, + Some(((index as f32) / total) * 0.7), + Some(format!("Rendering {}", post.slug)), + ); + let body_markdown = load_generation_post_body(&data_dir, &post)?; + sources.push(engine::generation::PublishedPostSource { post, body_markdown }); + } + let output_dir = data_dir.join("html"); + std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; + tm.report_progress(tid, Some(0.85), Some("Writing generated files".into())); + let report = engine::generation::generate_starter_site( + db.conn(), + &output_dir, + &project_id, + &metadata, + &sources, + &main_language, + ) + .map_err(|e| e.to_string())?; + tm.report_progress(tid, Some(1.0), Some("Site generation complete".into())); + Ok(format!( + "written={}, skipped={}, output={}", + report.written_paths.len(), + report.skipped_paths.len(), + output_dir.display(), + )) }, ) } @@ -4526,6 +4572,30 @@ impl BdsApp { if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt") { state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value); } + if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) { + state.online_endpoint_url = ai_settings.online_endpoint.url; + state.online_endpoint_model = ai_settings.online_endpoint.model; + state.online_api_key_configured = ai_settings.online_endpoint.api_key_configured; + if !state.online_endpoint_model.is_empty() { + state.online_model_options = vec![AiModelOption { + id: state.online_endpoint_model.clone(), + label: state.online_endpoint_model.clone(), + supports_vision: false, + }]; + } + state.airplane_endpoint_url = ai_settings.airplane_endpoint.url; + state.airplane_endpoint_model = ai_settings.airplane_endpoint.model; + if !state.airplane_endpoint_model.is_empty() { + state.airplane_model_options = vec![AiModelOption { + id: state.airplane_endpoint_model.clone(), + label: state.airplane_endpoint_model.clone(), + supports_vision: false, + }]; + } + state.default_model = ai_settings.default_model.unwrap_or_default(); + state.title_model = ai_settings.title_model.unwrap_or_default(); + state.image_model = ai_settings.image_model.unwrap_or_default(); + } } state.offline_mode = self.offline_mode; state @@ -4972,17 +5042,44 @@ impl BdsApp { state.offline_mode = b; return Task::done(Message::SetOfflineMode(b)); } + SettingsMsg::OnlineEndpointUrlChanged(value) => { state.online_endpoint_url = value; } + SettingsMsg::OnlineEndpointModelChanged(value) => { state.online_endpoint_model = value; } + SettingsMsg::OnlineApiKeyChanged(value) => { state.online_api_key_input = value; } + SettingsMsg::AirplaneEndpointUrlChanged(value) => { state.airplane_endpoint_url = value; } + SettingsMsg::AirplaneEndpointModelChanged(value) => { state.airplane_endpoint_model = value; } + SettingsMsg::DefaultModelChanged(value) => { state.default_model = value; } + SettingsMsg::TitleModelChanged(value) => { state.title_model = value; } + SettingsMsg::ImageModelChanged(value) => { state.image_model = value; } + SettingsMsg::RefreshOnlineModels => { + if let Some(db) = &self.db { + match Self::refresh_ai_models( + db, + state, + AiEndpointKind::Online, + ) { + Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(error) => self.notify(ToastLevel::Error, &format!("Save failed: {error}")), + } + } + } + SettingsMsg::RefreshAirplaneModels => { + if let Some(db) = &self.db { + match Self::refresh_ai_models( + db, + state, + AiEndpointKind::Airplane, + ) { + Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(error) => self.notify(ToastLevel::Error, &format!("Save failed: {error}")), + } + } + } SettingsMsg::SystemPromptAction(action) => { state.system_prompt.perform(action); } - SettingsMsg::SaveSystemPrompt => { + SettingsMsg::SaveAi => { if let Some(db) = &self.db { - match bds_core::db::queries::setting::set_setting_value( - db.conn(), - "ai.system_prompt", - &state.system_prompt.text(), - bds_core::util::now_unix_ms(), - ) { + match Self::save_ai_settings_state(db, state) { Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), } @@ -5396,4 +5493,86 @@ impl BdsApp { _ => {} } } + + fn refresh_ai_models( + db: &Database, + state: &mut SettingsViewState, + kind: AiEndpointKind, + ) -> Result<(), String> { + let endpoint = Self::compose_ai_endpoint(state, kind)?; + let models = ai::refresh_model_catalog(&endpoint).map_err(|error| error.to_string())?; + let options = models + .into_iter() + .map(|model| AiModelOption { + id: model.id, + label: model.name, + supports_vision: model.supports_vision, + }) + .collect::>(); + match kind { + AiEndpointKind::Online => state.online_model_options = options, + AiEndpointKind::Airplane => state.airplane_model_options = options, + } + let _ = db; + Ok(()) + } + + fn save_ai_settings_state( + db: &Database, + state: &mut SettingsViewState, + ) -> Result<(), String> { + let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?; + let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?; + ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?; + ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?; + ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?; + ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?; + ai::save_model_preferences( + db.conn(), + (!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()), + (!state.title_model.trim().is_empty()).then_some(state.title_model.as_str()), + (!state.image_model.trim().is_empty()).then_some(state.image_model.as_str()), + &state.system_prompt.text(), + ) + .map_err(|error| error.to_string())?; + state.online_api_key_input.clear(); + state.online_api_key_configured = true; + Ok(()) + } + + fn compose_ai_endpoint( + state: &SettingsViewState, + kind: AiEndpointKind, + ) -> Result { + let (url, model, configured) = match kind { + AiEndpointKind::Online => ( + state.online_endpoint_url.trim().to_string(), + state.online_endpoint_model.trim().to_string(), + state.online_api_key_configured, + ), + AiEndpointKind::Airplane => ( + state.airplane_endpoint_url.trim().to_string(), + state.airplane_endpoint_model.trim().to_string(), + false, + ), + }; + let api_key = if kind == AiEndpointKind::Online { + let input = state.online_api_key_input.trim(); + if !input.is_empty() { + Some(input.to_string()) + } else if configured { + ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())? + } else { + None + } + } else { + None + }; + Ok(AiEndpointConfig { + kind, + url, + model, + api_key, + }) + } } diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 276edd9..2396306 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -8,6 +8,19 @@ use crate::app::Message; use crate::components::inputs; use crate::i18n::{t, tw}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AiModelOption { + pub id: String, + pub label: String, + pub supports_vision: bool, +} + +impl std::fmt::Display for AiModelOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) + } +} + /// Collapsible section identifiers per editor_settings.allium. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum SettingsSection { @@ -115,6 +128,17 @@ pub struct SettingsViewState { pub ssh_remote_path: String, // AI pub offline_mode: bool, + pub online_endpoint_url: String, + pub online_endpoint_model: String, + pub online_api_key_input: String, + pub online_api_key_configured: bool, + pub online_model_options: Vec, + pub airplane_endpoint_url: String, + pub airplane_endpoint_model: String, + pub airplane_model_options: Vec, + pub default_model: String, + pub title_model: String, + pub image_model: String, pub system_prompt: text_editor::Content, // Technology pub semantic_similarity_enabled: bool, @@ -156,6 +180,17 @@ impl Clone for SettingsViewState { ssh_username: self.ssh_username.clone(), ssh_remote_path: self.ssh_remote_path.clone(), offline_mode: self.offline_mode, + online_endpoint_url: self.online_endpoint_url.clone(), + online_endpoint_model: self.online_endpoint_model.clone(), + online_api_key_input: self.online_api_key_input.clone(), + online_api_key_configured: self.online_api_key_configured, + online_model_options: self.online_model_options.clone(), + airplane_endpoint_url: self.airplane_endpoint_url.clone(), + airplane_endpoint_model: self.airplane_endpoint_model.clone(), + airplane_model_options: self.airplane_model_options.clone(), + default_model: self.default_model.clone(), + title_model: self.title_model.clone(), + image_model: self.image_model.clone(), system_prompt: text_editor::Content::with_text(&self.system_prompt.text()), semantic_similarity_enabled: self.semantic_similarity_enabled, } @@ -190,6 +225,17 @@ impl Default for SettingsViewState { ssh_username: String::new(), ssh_remote_path: String::new(), offline_mode: false, + online_endpoint_url: String::new(), + online_endpoint_model: String::new(), + online_api_key_input: String::new(), + online_api_key_configured: false, + online_model_options: Vec::new(), + airplane_endpoint_url: String::new(), + airplane_endpoint_model: String::new(), + airplane_model_options: Vec::new(), + default_model: String::new(), + title_model: String::new(), + image_model: String::new(), system_prompt: text_editor::Content::new(), semantic_similarity_enabled: false, } @@ -263,8 +309,18 @@ pub enum SettingsMsg { ClearPublishing, // AI OfflineModeChanged(bool), + OnlineEndpointUrlChanged(String), + OnlineEndpointModelChanged(String), + OnlineApiKeyChanged(String), + RefreshOnlineModels, + AirplaneEndpointUrlChanged(String), + AirplaneEndpointModelChanged(String), + RefreshAirplaneModels, + DefaultModelChanged(String), + TitleModelChanged(String), + ImageModelChanged(String), SystemPromptAction(text_editor::Action), - SaveSystemPrompt, + SaveAi, ResetSystemPrompt, // Technology SemanticSimilarityChanged(bool), @@ -659,11 +715,92 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen } fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { + let active_model_options = if state.offline_mode { + &state.airplane_model_options + } else { + &state.online_model_options + }; + let model_options = std::iter::once(AiModelOption { + id: String::new(), + label: t(locale, "tags.noTemplate"), + supports_vision: false, + }) + .chain(active_model_options.iter().cloned()) + .collect::>(); + let image_model_options = std::iter::once(AiModelOption { + id: String::new(), + label: t(locale, "tags.noTemplate"), + supports_vision: false, + }) + .chain(active_model_options.iter().filter(|option| option.supports_vision).cloned()) + .collect::>(); + let offline = inputs::labeled_checkbox( &t(locale, "settings.offlineMode"), state.offline_mode, |b| Message::Settings(SettingsMsg::OfflineModeChanged(b)), ); + let online_url = inputs::labeled_input( + &t(locale, "settings.onlineEndpointUrl"), + "https://api.example.com/v1", + &state.online_endpoint_url, + |value| Message::Settings(SettingsMsg::OnlineEndpointUrlChanged(value)), + ); + let online_model = inputs::labeled_input( + &t(locale, "settings.onlineEndpointModel"), + "gpt-4.1-mini", + &state.online_endpoint_model, + |value| Message::Settings(SettingsMsg::OnlineEndpointModelChanged(value)), + ); + let keychain_placeholder = t(locale, "settings.keychainConfigured"); + let online_api_key = inputs::labeled_input( + &t(locale, "settings.onlineApiKey"), + if state.online_api_key_configured { + &keychain_placeholder + } else { + "sk-..." + }, + &state.online_api_key_input, + |value| Message::Settings(SettingsMsg::OnlineApiKeyChanged(value)), + ); + let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) + .on_press(Message::Settings(SettingsMsg::RefreshOnlineModels)) + .padding([6, 16]); + + let airplane_url = inputs::labeled_input( + &t(locale, "settings.airplaneEndpointUrl"), + "http://localhost:11434/v1", + &state.airplane_endpoint_url, + |value| Message::Settings(SettingsMsg::AirplaneEndpointUrlChanged(value)), + ); + let airplane_model = inputs::labeled_input( + &t(locale, "settings.airplaneEndpointModel"), + "llama3.2", + &state.airplane_endpoint_model, + |value| Message::Settings(SettingsMsg::AirplaneEndpointModelChanged(value)), + ); + let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) + .on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels)) + .padding([6, 16]); + + let default_model = inputs::labeled_select( + &t(locale, "settings.defaultModel"), + &model_options, + model_options.iter().find(|option| option.id == state.default_model), + |option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)), + ); + let title_model = inputs::labeled_select( + &t(locale, "settings.titleModel"), + &model_options, + model_options.iter().find(|option| option.id == state.title_model), + |option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)), + ); + let image_model = inputs::labeled_select( + &t(locale, "settings.imageAnalysisModel"), + &image_model_options, + image_model_options.iter().find(|option| option.id == state.image_model), + |option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)), + ); let prompt = column![ text(t(locale, "settings.systemPrompt")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced), text_editor(&state.system_prompt) @@ -674,7 +811,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, .spacing(4); let btns = row![ button(text(t(locale, "common.save")).size(13)) - .on_press(Message::Settings(SettingsMsg::SaveSystemPrompt)) + .on_press(Message::Settings(SettingsMsg::SaveAi)) .style(inputs::primary_button) .padding([6, 16]), button(text(t(locale, "settings.resetToDefault")).size(13)) @@ -683,7 +820,21 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, ] .spacing(8); - column![offline, prompt, btns] + column![ + offline, + text(t(locale, "settings.onlineEndpointSection")).size(12).color(inputs::LABEL_COLOR), + online_url, + row![online_model, online_api_key].spacing(12), + online_refresh, + text(t(locale, "settings.airplaneEndpointSection")).size(12).color(inputs::LABEL_COLOR), + airplane_url, + row![airplane_model, airplane_refresh].spacing(12), + default_model, + title_model, + image_model, + prompt, + btns, + ] .spacing(8) .padding([0, 16]) .into() diff --git a/locales/ui/de.json b/locales/ui/de.json index 32c9642..9da5374 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -311,6 +311,18 @@ "settings.wrapLongLines": "Lange Zeilen umbrechen", "settings.hideUnchangedRegions": "Unveränderte Bereiche ausblenden", "settings.offlineMode": "Flugmodus", + "settings.onlineEndpointSection": "Online-Endpunkt", + "settings.onlineEndpointUrl": "URL des Online-Endpunkts", + "settings.onlineEndpointModel": "Modell des Online-Endpunkts", + "settings.onlineApiKey": "Online-API-Schlüssel", + "settings.airplaneEndpointSection": "Flugmodus-Endpunkt", + "settings.airplaneEndpointUrl": "URL des Flugmodus-Endpunkts", + "settings.airplaneEndpointModel": "Modell des Flugmodus-Endpunkts", + "settings.refreshModels": "Modelle aktualisieren", + "settings.defaultModel": "Standardmodell", + "settings.titleModel": "Titelmodell", + "settings.imageAnalysisModel": "Bildanalysemodell", + "settings.keychainConfigured": "Im Schlüsselbund gespeichert", "settings.systemPrompt": "System-Prompt", "settings.resetToDefault": "Auf Standard zurücksetzen", "settings.luaRuntimeOnly": "Lua ist die einzige Skriptlaufzeit in der Rust-App.", diff --git a/locales/ui/en.json b/locales/ui/en.json index 07d96c7..4b54368 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -311,6 +311,18 @@ "settings.wrapLongLines": "Wrap Long Lines", "settings.hideUnchangedRegions": "Hide Unchanged Regions", "settings.offlineMode": "Airplane Mode", + "settings.onlineEndpointSection": "Online Endpoint", + "settings.onlineEndpointUrl": "Online Endpoint URL", + "settings.onlineEndpointModel": "Online Endpoint Model", + "settings.onlineApiKey": "Online API Key", + "settings.airplaneEndpointSection": "Airplane Endpoint", + "settings.airplaneEndpointUrl": "Airplane Endpoint URL", + "settings.airplaneEndpointModel": "Airplane Endpoint Model", + "settings.refreshModels": "Refresh Models", + "settings.defaultModel": "Default Model", + "settings.titleModel": "Title Model", + "settings.imageAnalysisModel": "Image Analysis Model", + "settings.keychainConfigured": "Stored in keychain", "settings.systemPrompt": "System Prompt", "settings.resetToDefault": "Reset to Default", "settings.luaRuntimeOnly": "Lua is the only scripting runtime in the Rust app.", diff --git a/locales/ui/es.json b/locales/ui/es.json index 1eee586..94f8fd2 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -311,6 +311,18 @@ "settings.wrapLongLines": "Ajuste de línea automático", "settings.hideUnchangedRegions": "Ocultar regiones sin cambios", "settings.offlineMode": "Modo avión", + "settings.onlineEndpointSection": "Extremo en línea", + "settings.onlineEndpointUrl": "URL del extremo en línea", + "settings.onlineEndpointModel": "Modelo del extremo en línea", + "settings.onlineApiKey": "Clave API en línea", + "settings.airplaneEndpointSection": "Extremo de modo avión", + "settings.airplaneEndpointUrl": "URL del extremo de modo avión", + "settings.airplaneEndpointModel": "Modelo del extremo de modo avión", + "settings.refreshModels": "Actualizar modelos", + "settings.defaultModel": "Modelo predeterminado", + "settings.titleModel": "Modelo de títulos", + "settings.imageAnalysisModel": "Modelo de análisis de imágenes", + "settings.keychainConfigured": "Guardado en el llavero", "settings.systemPrompt": "Prompt del sistema", "settings.resetToDefault": "Restaurar valores predeterminados", "settings.luaRuntimeOnly": "Lua es el único tiempo de ejecución de scripts en la app Rust.", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 1ad3457..63a5408 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -311,6 +311,18 @@ "settings.wrapLongLines": "Retour à la ligne automatique", "settings.hideUnchangedRegions": "Masquer les régions inchangées", "settings.offlineMode": "Mode avion", + "settings.onlineEndpointSection": "Point de terminaison en ligne", + "settings.onlineEndpointUrl": "URL du point de terminaison en ligne", + "settings.onlineEndpointModel": "Modèle du point de terminaison en ligne", + "settings.onlineApiKey": "Clé API en ligne", + "settings.airplaneEndpointSection": "Point de terminaison mode avion", + "settings.airplaneEndpointUrl": "URL du point de terminaison mode avion", + "settings.airplaneEndpointModel": "Modèle du point de terminaison mode avion", + "settings.refreshModels": "Rafraîchir les modèles", + "settings.defaultModel": "Modèle par défaut", + "settings.titleModel": "Modèle de titres", + "settings.imageAnalysisModel": "Modèle d'analyse d'image", + "settings.keychainConfigured": "Stocké dans le trousseau", "settings.systemPrompt": "Prompt système", "settings.resetToDefault": "Rétablir les valeurs par défaut", "settings.luaRuntimeOnly": "Lua est l'unique moteur de script dans l'application Rust.", diff --git a/locales/ui/it.json b/locales/ui/it.json index 2d36b03..28612f8 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -311,6 +311,18 @@ "settings.wrapLongLines": "A capo automatico", "settings.hideUnchangedRegions": "Nascondi regioni invariate", "settings.offlineMode": "Modalità aereo", + "settings.onlineEndpointSection": "Endpoint online", + "settings.onlineEndpointUrl": "URL endpoint online", + "settings.onlineEndpointModel": "Modello endpoint online", + "settings.onlineApiKey": "Chiave API online", + "settings.airplaneEndpointSection": "Endpoint modalità aereo", + "settings.airplaneEndpointUrl": "URL endpoint modalità aereo", + "settings.airplaneEndpointModel": "Modello endpoint modalità aereo", + "settings.refreshModels": "Aggiorna modelli", + "settings.defaultModel": "Modello predefinito", + "settings.titleModel": "Modello titoli", + "settings.imageAnalysisModel": "Modello analisi immagini", + "settings.keychainConfigured": "Salvato nel portachiavi", "settings.systemPrompt": "Prompt di sistema", "settings.resetToDefault": "Ripristina predefiniti", "settings.luaRuntimeOnly": "Lua è l'unico runtime di scripting nell'app Rust.",