feat: implementation of M1

This commit is contained in:
2026-04-04 14:18:09 +02:00
parent fd744573dd
commit 2d07ac7866
44 changed files with 12197 additions and 30 deletions

509
Cargo.lock generated
View File

@@ -57,6 +57,24 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -112,6 +130,23 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -130,6 +165,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "ash" name = "ash"
version = "0.37.3+1.3.251" version = "0.37.3+1.3.251"
@@ -349,6 +393,49 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror 2.0.18",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"log",
"nom",
"num-rational",
"v_frame",
]
[[package]]
name = "avif-serialize"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -370,8 +457,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deunicode", "deunicode",
"image 0.25.10",
"refinery", "refinery",
"rusqlite", "rusqlite",
"rust-stemmers",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -453,6 +542,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "bitstream-io"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
dependencies = [
"core2",
]
[[package]] [[package]]
name = "block" name = "block"
version = "0.1.6" version = "0.1.6"
@@ -499,6 +597,12 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "built"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -537,6 +641,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -818,6 +928,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "cosmic-text" name = "cosmic-text"
version = "0.12.1" version = "0.12.1"
@@ -1134,6 +1253,26 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1235,6 +1374,26 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
dependencies = [
"fax_derive",
]
[[package]]
name = "fax_derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -1575,6 +1734,16 @@ dependencies = [
"weezl", "weezl",
] ]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.18.4" version = "0.18.4"
@@ -1951,7 +2120,7 @@ dependencies = [
"iced_renderer", "iced_renderer",
"iced_widget", "iced_widget",
"iced_winit", "iced_winit",
"image", "image 0.24.9",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@@ -2014,7 +2183,7 @@ dependencies = [
"half", "half",
"iced_core", "iced_core",
"iced_futures", "iced_futures",
"image", "image 0.24.9",
"kamadak-exif", "kamadak-exif",
"log", "log",
"once_cell", "once_cell",
@@ -2240,14 +2409,54 @@ dependencies = [
"byteorder", "byteorder",
"color_quant", "color_quant",
"exr", "exr",
"gif", "gif 0.13.3",
"jpeg-decoder", "jpeg-decoder",
"num-traits", "num-traits",
"png", "png 0.17.16",
"qoi", "qoi",
"tiff", "tiff 0.9.1",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif 0.14.1",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff 0.11.3",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -2269,6 +2478,26 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "interpolate_name"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2429,6 +2658,16 @@ version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
dependencies = [
"arbitrary",
"cc",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.7.4" version = "0.7.4"
@@ -2542,6 +2781,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.12.5" version = "0.12.5"
@@ -2557,6 +2805,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -2617,6 +2875,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.15.3" version = "0.15.3"
@@ -2632,7 +2900,7 @@ dependencies = [
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"once_cell", "once_cell",
"png", "png 0.17.16",
"thiserror 1.0.69", "thiserror 1.0.69",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -2702,6 +2970,12 @@ dependencies = [
"jni-sys 0.3.1", "jni-sys 0.3.1",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -2715,12 +2989,68 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -3297,6 +3627,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -3426,6 +3762,19 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3553,6 +3902,25 @@ name = "profiling"
version = "1.0.17" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]] [[package]]
name = "qoi" name = "qoi"
@@ -3563,6 +3931,12 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -3673,6 +4047,56 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.2",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.18",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e",
"rayon",
"rgb",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3857,6 +4281,12 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]] [[package]]
name = "ropey" name = "ropey"
version = "1.6.1" version = "1.6.1"
@@ -3897,6 +4327,16 @@ dependencies = [
"ordered-multimap", "ordered-multimap",
] ]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
dependencies = [
"serde",
"serde_derive",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -4149,6 +4589,15 @@ dependencies = [
"simdutf8", "simdutf8",
] ]
[[package]]
name = "simd_helpers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
[[package]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
@@ -4511,6 +4960,20 @@ dependencies = [
"weezl", "weezl",
] ]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -4553,7 +5016,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"log", "log",
"png", "png 0.17.16",
"tiny-skia-path", "tiny-skia-path",
] ]
@@ -4879,6 +5342,17 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "v_frame"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -5788,6 +6262,12 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yaml-rust" name = "yaml-rust"
version = "0.4.5" version = "0.4.5"
@@ -6035,6 +6515,12 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]] [[package]]
name = "zune-inflate" name = "zune-inflate"
version = "0.2.54" version = "0.2.54"
@@ -6044,6 +6530,15 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "4.2.0" version = "4.2.0"

View File

@@ -27,6 +27,8 @@ thiserror = "2"
anyhow = "1" anyhow = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
walkdir = "2" walkdir = "2"
image = "0.25"
rust-stemmers = "1"
# UI framework # UI framework
iced = { version = "0.13", features = ["wgpu", "advanced", "image"] } iced = { version = "0.13", features = ["wgpu", "advanced", "image"] }

View File

@@ -64,37 +64,37 @@ Rules:
### `bds-core/db` ### `bds-core/db`
- verify schema compatibility against existing projects - ~~verify schema compatibility against existing projects~~ **DONE**
- add FTS5 virtual tables (`posts_fts`, `media_fts`) for in-app full-text search - ~~add FTS5 virtual tables (`posts_fts`, `media_fts`) for in-app full-text search~~ **DONE**
- verify `mediaTranslations` table read/write - ~~verify `mediaTranslations` table read/write~~ **DONE**
- verify `postLinks` table read/write - ~~verify `postLinks` table read/write~~ **DONE**
### `bds-core/engine` ### `bds-core/engine`
- implement `ProjectEngine` - ~~implement `ProjectEngine`~~ **DONE**
- implement `PostEngine` - ~~implement `PostEngine`~~ **DONE**
- implement `MediaEngine` - ~~implement `MediaEngine`~~ **DONE**
- implement `PostMediaEngine` - ~~implement `PostMediaEngine`~~ **DONE**
- implement `TagEngine` - ~~implement `TagEngine`~~ **DONE**
- implement `MetaEngine` - ~~implement `MetaEngine`~~ **DONE**
- implement metadata diff flow (posts, media, scripts, templates) - ~~implement metadata diff flow (posts, media, scripts, templates)~~ **DONE**
- implement rebuild-from-filesystem flow - ~~implement rebuild-from-filesystem flow~~ **DONE**
### `bds-core/util` ### `bds-core/util`
- frontmatter parser/writer via `serde_yaml` - ~~frontmatter parser/writer via `serde_yaml`~~ **DONE**
- translation file parser/writer - ~~translation file parser/writer~~ **DONE**
- sidecar parser/writer - ~~sidecar parser/writer~~ **DONE**
- content hashing support via `sha2` - ~~content hashing support via `sha2`~~ **DONE**
- thumbnail generation via `image` crate (resize, WEBP encoding) - ~~thumbnail generation via `image` crate (resize, WEBP encoding)~~ **DONE**
- recursive directory traversal via `walkdir` (for rebuild-from-filesystem) - ~~recursive directory traversal via `walkdir` (for rebuild-from-filesystem)~~ **DONE**
### Validation ### Validation
- round-trip persistence tests - ~~round-trip persistence tests~~ **DONE** (38 m1_validation integration tests)
- rebuild tests - ~~rebuild tests~~ **DONE**
- metadata diff tests - ~~metadata diff tests~~ **DONE** (full coverage matrix: 13 post fields, 6 media, 4 template, 5 script)
- golden-file comparisons for written files - ~~golden-file comparisons for written files~~ **DONE**
## Milestone M2: Native Workspace ## Milestone M2: Native Workspace

View File

@@ -16,6 +16,8 @@ sha2 = { workspace = true }
deunicode = { workspace = true } deunicode = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
walkdir = { workspace = true } walkdir = { workspace = true }
image = { workspace = true }
rust-stemmers = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -0,0 +1,619 @@
use rusqlite::Row;
use crate::model::{
DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction,
NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script,
ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus,
};
// ── helpers ──────────────────────────────────────────────────────────
fn conversion_err(msg: String) -> rusqlite::Error {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)),
)
}
fn parse_post_status(s: &str) -> rusqlite::Result<PostStatus> {
match s {
"draft" => Ok(PostStatus::Draft),
"published" => Ok(PostStatus::Published),
"archived" => Ok(PostStatus::Archived),
_ => Err(conversion_err(format!("invalid PostStatus: {s}"))),
}
}
fn parse_template_kind(s: &str) -> rusqlite::Result<TemplateKind> {
match s {
"post" => Ok(TemplateKind::Post),
"list" => Ok(TemplateKind::List),
"not_found" => Ok(TemplateKind::NotFound),
"partial" => Ok(TemplateKind::Partial),
_ => Err(conversion_err(format!("invalid TemplateKind: {s}"))),
}
}
fn parse_template_status(s: &str) -> rusqlite::Result<TemplateStatus> {
match s {
"draft" => Ok(TemplateStatus::Draft),
"published" => Ok(TemplateStatus::Published),
_ => Err(conversion_err(format!("invalid TemplateStatus: {s}"))),
}
}
fn parse_script_kind(s: &str) -> rusqlite::Result<ScriptKind> {
match s {
"macro" => Ok(ScriptKind::Macro),
"utility" => Ok(ScriptKind::Utility),
"transform" => Ok(ScriptKind::Transform),
_ => Err(conversion_err(format!("invalid ScriptKind: {s}"))),
}
}
fn parse_script_status(s: &str) -> rusqlite::Result<ScriptStatus> {
match s {
"draft" => Ok(ScriptStatus::Draft),
"published" => Ok(ScriptStatus::Published),
_ => Err(conversion_err(format!("invalid ScriptStatus: {s}"))),
}
}
fn parse_notification_entity(s: &str) -> rusqlite::Result<NotificationEntity> {
match s {
"post" => Ok(NotificationEntity::Post),
"media" => Ok(NotificationEntity::Media),
"script" => Ok(NotificationEntity::Script),
"template" => Ok(NotificationEntity::Template),
_ => Err(conversion_err(format!("invalid NotificationEntity: {s}"))),
}
}
fn parse_notification_action(s: &str) -> rusqlite::Result<NotificationAction> {
match s {
"created" => Ok(NotificationAction::Created),
"updated" => Ok(NotificationAction::Updated),
"deleted" => Ok(NotificationAction::Deleted),
_ => Err(conversion_err(format!("invalid NotificationAction: {s}"))),
}
}
fn json_to_vec_string(s: &str) -> rusqlite::Result<Vec<String>> {
serde_json::from_str(s).map_err(|e| conversion_err(format!("JSON parse error: {e}")))
}
fn bool_from_i64(v: i64) -> bool {
v != 0
}
// ── enum → DB string (for INSERT / UPDATE) ───────────────────────────
pub fn post_status_to_str(s: &PostStatus) -> &'static str {
match s {
PostStatus::Draft => "draft",
PostStatus::Published => "published",
PostStatus::Archived => "archived",
}
}
pub fn template_kind_to_str(k: &TemplateKind) -> &'static str {
match k {
TemplateKind::Post => "post",
TemplateKind::List => "list",
TemplateKind::NotFound => "not_found",
TemplateKind::Partial => "partial",
}
}
pub fn template_status_to_str(s: &TemplateStatus) -> &'static str {
match s {
TemplateStatus::Draft => "draft",
TemplateStatus::Published => "published",
}
}
pub fn script_kind_to_str(k: &ScriptKind) -> &'static str {
match k {
ScriptKind::Macro => "macro",
ScriptKind::Utility => "utility",
ScriptKind::Transform => "transform",
}
}
pub fn script_status_to_str(s: &ScriptStatus) -> &'static str {
match s {
ScriptStatus::Draft => "draft",
ScriptStatus::Published => "published",
}
}
pub fn notification_entity_to_str(e: &NotificationEntity) -> &'static str {
match e {
NotificationEntity::Post => "post",
NotificationEntity::Media => "media",
NotificationEntity::Script => "script",
NotificationEntity::Template => "template",
}
}
pub fn notification_action_to_str(a: &NotificationAction) -> &'static str {
match a {
NotificationAction::Created => "created",
NotificationAction::Updated => "updated",
NotificationAction::Deleted => "deleted",
}
}
// ── column lists (keep in sync with from_row functions) ──────────────
pub const PROJECT_COLUMNS: &str =
"id, name, slug, description, data_path, is_active, created_at, updated_at";
pub const POST_COLUMNS: &str = "\
id, project_id, title, slug, excerpt, content, status, author, \
language, do_not_translate, template_slug, file_path, checksum, \
tags, categories, \
published_title, published_content, published_tags, \
published_categories, published_excerpt, \
created_at, updated_at, published_at";
pub const POST_TRANSLATION_COLUMNS: &str = "\
id, project_id, translation_for, language, title, excerpt, content, \
status, file_path, checksum, created_at, updated_at, published_at";
pub const POST_LINK_COLUMNS: &str =
"id, source_post_id, target_post_id, link_text, created_at";
pub const POST_MEDIA_COLUMNS: &str =
"id, project_id, post_id, media_id, sort_order, created_at";
pub const MEDIA_COLUMNS: &str = "\
id, project_id, filename, original_name, mime_type, size, \
width, height, title, alt, caption, author, language, \
file_path, sidecar_path, checksum, tags, created_at, updated_at";
pub const MEDIA_TRANSLATION_COLUMNS: &str =
"id, project_id, translation_for, language, title, alt, caption, created_at, updated_at";
pub const TAG_COLUMNS: &str =
"id, project_id, name, color, post_template_slug, created_at, updated_at";
pub const TEMPLATE_COLUMNS: &str = "\
id, project_id, slug, title, kind, enabled, version, \
file_path, status, content, created_at, updated_at";
pub const SCRIPT_COLUMNS: &str = "\
id, project_id, slug, title, kind, entrypoint, enabled, version, \
file_path, status, content, created_at, updated_at";
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
pub const GENERATED_FILE_HASH_COLUMNS: &str =
"project_id, relative_path, content_hash, updated_at";
pub const DB_NOTIFICATION_COLUMNS: &str =
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
// ── from_row functions ───────────────────────────────────────────────
pub fn project_from_row(row: &Row) -> rusqlite::Result<Project> {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
slug: row.get(2)?,
description: row.get(3)?,
data_path: row.get(4)?,
is_active: bool_from_i64(row.get(5)?),
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
pub fn post_from_row(row: &Row) -> rusqlite::Result<Post> {
let status_str: String = row.get(6)?;
let tags_json: String = row.get(13)?;
let categories_json: String = row.get(14)?;
Ok(Post {
id: row.get(0)?,
project_id: row.get(1)?,
title: row.get(2)?,
slug: row.get(3)?,
excerpt: row.get(4)?,
content: row.get(5)?,
status: parse_post_status(&status_str)?,
author: row.get(7)?,
language: row.get(8)?,
do_not_translate: bool_from_i64(row.get(9)?),
template_slug: row.get(10)?,
file_path: row.get(11)?,
checksum: row.get(12)?,
tags: json_to_vec_string(&tags_json)?,
categories: json_to_vec_string(&categories_json)?,
published_title: row.get(15)?,
published_content: row.get(16)?,
published_tags: row.get(17)?,
published_categories: row.get(18)?,
published_excerpt: row.get(19)?,
created_at: row.get(20)?,
updated_at: row.get(21)?,
published_at: row.get(22)?,
})
}
pub fn post_translation_from_row(row: &Row) -> rusqlite::Result<PostTranslation> {
let status_str: String = row.get(7)?;
Ok(PostTranslation {
id: row.get(0)?,
project_id: row.get(1)?,
translation_for: row.get(2)?,
language: row.get(3)?,
title: row.get(4)?,
excerpt: row.get(5)?,
content: row.get(6)?,
status: parse_post_status(&status_str)?,
file_path: row.get(8)?,
checksum: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
published_at: row.get(12)?,
})
}
pub fn post_link_from_row(row: &Row) -> rusqlite::Result<PostLink> {
Ok(PostLink {
id: row.get(0)?,
source_post_id: row.get(1)?,
target_post_id: row.get(2)?,
link_text: row.get(3)?,
created_at: row.get(4)?,
})
}
pub fn post_media_from_row(row: &Row) -> rusqlite::Result<PostMedia> {
Ok(PostMedia {
id: row.get(0)?,
project_id: row.get(1)?,
post_id: row.get(2)?,
media_id: row.get(3)?,
sort_order: row.get(4)?,
created_at: row.get(5)?,
})
}
pub fn media_from_row(row: &Row) -> rusqlite::Result<Media> {
let tags_json: String = row.get(16)?;
Ok(Media {
id: row.get(0)?,
project_id: row.get(1)?,
filename: row.get(2)?,
original_name: row.get(3)?,
mime_type: row.get(4)?,
size: row.get(5)?,
width: row.get(6)?,
height: row.get(7)?,
title: row.get(8)?,
alt: row.get(9)?,
caption: row.get(10)?,
author: row.get(11)?,
language: row.get(12)?,
file_path: row.get(13)?,
sidecar_path: row.get(14)?,
checksum: row.get(15)?,
tags: json_to_vec_string(&tags_json)?,
created_at: row.get(17)?,
updated_at: row.get(18)?,
})
}
pub fn media_translation_from_row(row: &Row) -> rusqlite::Result<MediaTranslation> {
Ok(MediaTranslation {
id: row.get(0)?,
project_id: row.get(1)?,
translation_for: row.get(2)?,
language: row.get(3)?,
title: row.get(4)?,
alt: row.get(5)?,
caption: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
}
pub fn tag_from_row(row: &Row) -> rusqlite::Result<Tag> {
Ok(Tag {
id: row.get(0)?,
project_id: row.get(1)?,
name: row.get(2)?,
color: row.get(3)?,
post_template_slug: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
}
pub fn template_from_row(row: &Row) -> rusqlite::Result<Template> {
let kind_str: String = row.get(4)?;
let status_str: String = row.get(8)?;
Ok(Template {
id: row.get(0)?,
project_id: row.get(1)?,
slug: row.get(2)?,
title: row.get(3)?,
kind: parse_template_kind(&kind_str)?,
enabled: bool_from_i64(row.get(5)?),
version: row.get(6)?,
file_path: row.get(7)?,
status: parse_template_status(&status_str)?,
content: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
}
pub fn script_from_row(row: &Row) -> rusqlite::Result<Script> {
let kind_str: String = row.get(4)?;
let status_str: String = row.get(9)?;
Ok(Script {
id: row.get(0)?,
project_id: row.get(1)?,
slug: row.get(2)?,
title: row.get(3)?,
kind: parse_script_kind(&kind_str)?,
entrypoint: row.get(5)?,
enabled: bool_from_i64(row.get(6)?),
version: row.get(7)?,
file_path: row.get(8)?,
status: parse_script_status(&status_str)?,
content: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
}
pub fn setting_from_row(row: &Row) -> rusqlite::Result<Setting> {
Ok(Setting {
key: row.get(0)?,
value: row.get(1)?,
updated_at: row.get(2)?,
})
}
pub fn generated_file_hash_from_row(row: &Row) -> rusqlite::Result<GeneratedFileHash> {
Ok(GeneratedFileHash {
project_id: row.get(0)?,
relative_path: row.get(1)?,
content_hash: row.get(2)?,
updated_at: row.get(3)?,
})
}
pub fn db_notification_from_row(row: &Row) -> rusqlite::Result<DbNotification> {
let entity_str: String = row.get(1)?;
let action_str: String = row.get(3)?;
Ok(DbNotification {
id: row.get(0)?,
entity_type: parse_notification_entity(&entity_str)?,
entity_id: row.get(2)?,
action: parse_notification_action(&action_str)?,
from_cli: bool_from_i64(row.get(4)?),
seen_at: row.get(5)?,
created_at: row.get(6)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
db
}
// ── enum parsing ─────────────────────────────────────────────────
#[test]
fn parse_post_status_valid() {
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
}
#[test]
fn parse_post_status_invalid() {
assert!(parse_post_status("nope").is_err());
}
#[test]
fn parse_template_kind_valid() {
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
}
#[test]
fn parse_template_kind_invalid() {
assert!(parse_template_kind("unknown").is_err());
}
#[test]
fn parse_template_status_valid() {
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
}
#[test]
fn parse_script_kind_valid() {
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
}
#[test]
fn parse_script_status_valid() {
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
}
#[test]
fn parse_notification_entity_valid() {
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
}
#[test]
fn parse_notification_action_valid() {
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
}
// ── JSON helpers ─────────────────────────────────────────────────
#[test]
fn json_vec_string_roundtrip() {
let v = json_to_vec_string(r#"["a","b","c"]"#).unwrap();
assert_eq!(v, vec!["a", "b", "c"]);
}
#[test]
fn json_vec_string_empty() {
let v = json_to_vec_string("[]").unwrap();
assert!(v.is_empty());
}
#[test]
fn json_vec_string_invalid() {
assert!(json_to_vec_string("not json").is_err());
}
// ── from_row round-trips via real DB ─────────────────────────────
#[test]
fn project_from_row_roundtrip() {
let db = setup();
let c = db.conn();
c.execute(
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
[],
).unwrap();
let p = c.query_row(
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
[],
project_from_row,
).unwrap();
assert_eq!(p.id, "p1");
assert_eq!(p.name, "Blog");
assert_eq!(p.slug, "blog");
assert_eq!(p.description.as_deref(), Some("My blog"));
assert_eq!(p.data_path.as_deref(), Some("/data"));
assert!(p.is_active);
assert_eq!(p.created_at, 1000);
assert_eq!(p.updated_at, 2000);
}
#[test]
fn post_from_row_roundtrip() {
let db = setup();
let c = db.conn();
c.execute(
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
[],
).unwrap();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
language, do_not_translate, template_slug, file_path, checksum,
tags, categories,
published_title, published_content, published_tags, published_categories, published_excerpt,
created_at, updated_at, published_at)
VALUES ('x', 'p1', 'Hello', 'hello', 'sum', 'body', 'draft', 'Alice',
'en', 1, 'tpl', 'posts/hello.md', 'abc',
'[\"rust\"]', '[\"tech\"]',
NULL, NULL, NULL, NULL, NULL,
1000, 2000, NULL)",
[],
).unwrap();
let p = c.query_row(
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
[],
post_from_row,
).unwrap();
assert_eq!(p.id, "x");
assert_eq!(p.project_id, "p1");
assert_eq!(p.title, "Hello");
assert_eq!(p.slug, "hello");
assert_eq!(p.excerpt.as_deref(), Some("sum"));
assert_eq!(p.content.as_deref(), Some("body"));
assert_eq!(p.status, PostStatus::Draft);
assert_eq!(p.author.as_deref(), Some("Alice"));
assert_eq!(p.language.as_deref(), Some("en"));
assert!(p.do_not_translate);
assert_eq!(p.template_slug.as_deref(), Some("tpl"));
assert_eq!(p.file_path, "posts/hello.md");
assert_eq!(p.checksum.as_deref(), Some("abc"));
assert_eq!(p.tags, vec!["rust"]);
assert_eq!(p.categories, vec!["tech"]);
assert!(p.published_title.is_none());
assert_eq!(p.created_at, 1000);
assert_eq!(p.updated_at, 2000);
assert!(p.published_at.is_none());
}
#[test]
fn template_from_row_roundtrip() {
let db = setup();
let c = db.conn();
c.execute(
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
[],
).unwrap();
c.execute(
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
file_path, status, content, created_at, updated_at)
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
[],
).unwrap();
let t = c.query_row(
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
[],
template_from_row,
).unwrap();
assert_eq!(t.kind, TemplateKind::NotFound);
assert!(!t.enabled);
assert_eq!(t.version, 3);
assert_eq!(t.status, TemplateStatus::Draft);
assert_eq!(t.content.as_deref(), Some("html"));
}
#[test]
fn db_notification_from_row_roundtrip() {
let db = setup();
let c = db.conn();
c.execute(
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, seen_at, created_at)
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
[],
).unwrap();
let n = c.query_row(
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
[],
db_notification_from_row,
).unwrap();
assert_eq!(n.entity_type, NotificationEntity::Media);
assert_eq!(n.entity_id, "m1");
assert_eq!(n.action, NotificationAction::Deleted);
assert!(n.from_cli);
assert_eq!(n.seen_at, Some(5000));
assert_eq!(n.created_at, 1000);
}
}

View File

@@ -0,0 +1,297 @@
use rust_stemmers::{Algorithm, Stemmer};
use rusqlite::Connection;
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
post_id UNINDEXED,
content
);
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
media_id UNINDEXED,
content
);"
)?;
Ok(())
}
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
fn stemmer_for_language(lang: &str) -> Algorithm {
match lang {
"ar" => Algorithm::Arabic,
"da" => Algorithm::Danish,
"de" => Algorithm::German,
"el" => Algorithm::Greek,
"en" => Algorithm::English,
"es" => Algorithm::Spanish,
"fi" => Algorithm::Finnish,
"fr" => Algorithm::French,
"hu" => Algorithm::Hungarian,
"hy" | "id" => Algorithm::English, // no dedicated stemmer, fallback
"it" => Algorithm::Italian,
"nb" | "nn" | "no" => Algorithm::Norwegian,
"nl" => Algorithm::Dutch,
"pt" => Algorithm::Portuguese,
"ro" => Algorithm::Romanian,
"ru" => Algorithm::Russian,
"sv" => Algorithm::Swedish,
"ta" => Algorithm::Tamil,
"tr" => Algorithm::Turkish,
_ => Algorithm::English,
}
}
/// Stem a text string using the Snowball stemmer for the given language.
pub fn stem_text(text: &str, language: &str) -> String {
let algo = stemmer_for_language(language);
let stemmer = Stemmer::create(algo);
text.split_whitespace()
.map(|word| stemmer.stem(word).into_owned())
.collect::<Vec<_>>()
.join(" ")
}
/// Index a post in the FTS table. Concatenates title, excerpt, content, tags,
/// categories, and all translation text. Pre-stems before inserting.
pub fn index_post(
conn: &Connection,
post_id: &str,
title: &str,
excerpt: Option<&str>,
content: Option<&str>,
tags: &[String],
categories: &[String],
translation_texts: &[String],
language: &str,
) -> rusqlite::Result<()> {
// Remove existing entry
remove_post_from_index(conn, post_id)?;
let mut parts: Vec<&str> = vec![title];
if let Some(exc) = excerpt {
parts.push(exc);
}
if let Some(cnt) = content {
parts.push(cnt);
}
let tags_str = tags.join(" ");
parts.push(&tags_str);
let cats_str = categories.join(" ");
parts.push(&cats_str);
let combined = parts.join(" ");
let mut full_text = stem_text(&combined, language);
// Add translation texts (stemmed with their own implied language or same)
for t in translation_texts {
full_text.push(' ');
full_text.push_str(&stem_text(t, language));
}
conn.execute(
"INSERT INTO posts_fts (post_id, content) VALUES (?1, ?2)",
rusqlite::params![post_id, full_text],
)?;
Ok(())
}
/// Index a media item in the FTS table.
pub fn index_media(
conn: &Connection,
media_id: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
original_name: &str,
tags: &[String],
translation_texts: &[String],
language: &str,
) -> rusqlite::Result<()> {
remove_media_from_index(conn, media_id)?;
let mut parts: Vec<&str> = Vec::new();
if let Some(t) = title {
parts.push(t);
}
if let Some(a) = alt {
parts.push(a);
}
if let Some(c) = caption {
parts.push(c);
}
parts.push(original_name);
let tags_str = tags.join(" ");
parts.push(&tags_str);
let combined = parts.join(" ");
let mut full_text = stem_text(&combined, language);
for t in translation_texts {
full_text.push(' ');
full_text.push_str(&stem_text(t, language));
}
conn.execute(
"INSERT INTO media_fts (media_id, content) VALUES (?1, ?2)",
rusqlite::params![media_id, full_text],
)?;
Ok(())
}
/// Remove a post from the FTS index.
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM posts_fts WHERE post_id = ?1",
rusqlite::params![post_id],
)?;
Ok(())
}
/// Remove a media item from the FTS index.
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM media_fts WHERE media_id = ?1",
rusqlite::params![media_id],
)?;
Ok(())
}
/// Search posts by full-text query. Returns matching post IDs.
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language);
let mut stmt = conn.prepare(
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
)?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
rows.collect()
}
/// Search media by full-text query. Returns matching media IDs.
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language);
let mut stmt = conn.prepare(
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
)?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
rows.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate();
ensure_fts_tables(db.conn()).unwrap();
db
}
#[test]
fn fts_tables_created() {
let db = setup();
let count: i64 = db.conn()
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('posts_fts', 'media_fts')",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 2);
}
#[test]
fn fts_tables_idempotent() {
let db = setup();
ensure_fts_tables(db.conn()).unwrap(); // second call should not error
}
#[test]
fn stem_german() {
let stemmed = stem_text("Programmierung Entwicklung", "de");
// German stemmer should reduce these words
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "Programmierung Entwicklung");
}
#[test]
fn stem_english() {
let stemmed = stem_text("running jumps", "en");
assert!(stemmed.contains("run"));
assert!(stemmed.contains("jump"));
}
#[test]
fn index_and_search_post() {
let db = setup();
index_post(
db.conn(),
"post-1",
"Esmeralda Spider",
Some("A macro photo"),
Some("Beautiful spider web"),
&["fotografie".into(), "natur".into()],
&["picture".into()],
&["Esmeralda English".into()],
"en",
).unwrap();
let results = search_posts(db.conn(), "spider", "en").unwrap();
assert_eq!(results, vec!["post-1"]);
}
#[test]
fn index_and_search_media() {
let db = setup();
index_media(
db.conn(),
"media-1",
Some("Sunset Photo"),
Some("Beautiful sunset over mountains"),
None,
"sunset.jpg",
&["nature".into()],
&[],
"en",
).unwrap();
let results = search_media(db.conn(), "sunset", "en").unwrap();
assert_eq!(results, vec!["media-1"]);
}
#[test]
fn search_no_results() {
let db = setup();
let results = search_posts(db.conn(), "nonexistent", "en").unwrap();
assert!(results.is_empty());
}
#[test]
fn remove_from_index() {
let db = setup();
index_post(
db.conn(), "p1", "Test", None, None,
&[], &[], &[], "en",
).unwrap();
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
remove_post_from_index(db.conn(), "p1").unwrap();
assert!(search_posts(db.conn(), "test", "en").unwrap().is_empty());
}
#[test]
fn reindex_replaces_old() {
let db = setup();
index_post(db.conn(), "p1", "Alpha", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "p1", "Beta", None, None, &[], &[], &[], "en").unwrap();
assert!(search_posts(db.conn(), "alpha", "en").unwrap().is_empty());
assert_eq!(search_posts(db.conn(), "beta", "en").unwrap().len(), 1);
}
}

View File

@@ -1,5 +1,8 @@
mod connection; mod connection;
pub mod from_row;
pub mod fts;
mod migrations; mod migrations;
pub mod queries;
pub use connection::Database; pub use connection::Database;
pub use migrations::run_migrations; pub use migrations::run_migrations;

View File

@@ -0,0 +1,210 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS};
use crate::model::Media;
fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
}
pub fn insert_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO media (
id, project_id, filename, original_name, mime_type, size,
width, height, title, alt, caption, author, language,
file_path, sidecar_path, checksum, tags, created_at, updated_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6,
?7, ?8, ?9, ?10, ?11, ?12, ?13,
?14, ?15, ?16, ?17, ?18, ?19
)",
params![
m.id,
m.project_id,
m.filename,
m.original_name,
m.mime_type,
m.size,
m.width,
m.height,
m.title,
m.alt,
m.caption,
m.author,
m.language,
m.file_path,
m.sidecar_path,
m.checksum,
tags_to_json(&m.tags),
m.created_at,
m.updated_at,
],
)?;
Ok(())
}
pub fn get_media_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Media> {
conn.query_row(
&format!("SELECT {MEDIA_COLUMNS} FROM media WHERE id = ?1"),
params![id],
media_from_row,
)
}
pub fn list_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Media>> {
let mut stmt = conn.prepare(&format!(
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC"
))?;
let rows = stmt.query_map(params![project_id], media_from_row)?;
rows.collect()
}
pub fn update_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
conn.execute(
"UPDATE media SET
filename = ?1, original_name = ?2, mime_type = ?3, size = ?4,
width = ?5, height = ?6, title = ?7, alt = ?8, caption = ?9,
author = ?10, language = ?11, file_path = ?12, sidecar_path = ?13,
checksum = ?14, tags = ?15, updated_at = ?16
WHERE id = ?17",
params![
m.filename,
m.original_name,
m.mime_type,
m.size,
m.width,
m.height,
m.title,
m.alt,
m.caption,
m.author,
m.language,
m.file_path,
m.sidecar_path,
m.checksum,
tags_to_json(&m.tags),
m.updated_at,
m.id,
],
)?;
Ok(())
}
pub fn delete_media(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM media WHERE id = ?1", params![id])?;
Ok(())
}
/// Test helper: create a minimal Media value (available to sibling test modules).
#[cfg(test)]
pub fn make_test_media(id: &str, project_id: &str) -> Media {
Media {
id: id.to_string(),
project_id: project_id.to_string(),
filename: format!("{id}.jpg"),
original_name: "photo.jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 50000,
width: Some(1920),
height: Some(1080),
title: None,
alt: None,
caption: None,
author: None,
language: None,
file_path: format!("/media/{id}.jpg"),
sidecar_path: format!("/media/{id}.jpg.meta"),
checksum: None,
tags: vec![],
created_at: 1000,
updated_at: 2000,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db
}
fn make_media_item(id: &str) -> Media {
Media {
id: id.to_string(),
project_id: "p1".to_string(),
filename: "abc.jpg".to_string(),
original_name: "photo.jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 50000,
width: Some(1920),
height: Some(1080),
title: Some("Sunset".into()),
alt: Some("A sunset".into()),
caption: None,
author: Some("Bob".into()),
language: Some("en".into()),
file_path: "/media/abc.jpg".to_string(),
sidecar_path: "/media/abc.jpg.meta".to_string(),
checksum: Some("hash1".into()),
tags: vec!["nature".into()],
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
let m = make_media_item("m1");
insert_media(db.conn(), &m).unwrap();
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
assert_eq!(fetched.original_name, "photo.jpg");
assert_eq!(fetched.width, Some(1920));
assert_eq!(fetched.height, Some(1080));
assert_eq!(fetched.tags, vec!["nature"]);
assert_eq!(fetched.size, 50000);
}
#[test]
fn list_by_project() {
let db = setup();
let mut m1 = make_media_item("m1");
m1.created_at = 1000;
let mut m2 = make_media_item("m2");
m2.filename = "def.jpg".into();
m2.created_at = 2000;
insert_media(db.conn(), &m1).unwrap();
insert_media(db.conn(), &m2).unwrap();
let list = list_media_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, "m2");
}
#[test]
fn update_media_fields() {
let db = setup();
let mut m = make_media_item("m1");
insert_media(db.conn(), &m).unwrap();
m.title = Some("New Title".into());
m.tags = vec!["updated".into()];
m.updated_at = 9999;
update_media(db.conn(), &m).unwrap();
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
assert_eq!(fetched.title.as_deref(), Some("New Title"));
assert_eq!(fetched.tags, vec!["updated"]);
}
#[test]
fn delete_removes_media() {
let db = setup();
insert_media(db.conn(), &make_media_item("m1")).unwrap();
delete_media(db.conn(), "m1").unwrap();
assert!(get_media_by_id(db.conn(), "m1").is_err());
}
}

View File

@@ -0,0 +1,171 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS};
use crate::model::MediaTranslation;
pub fn insert_media_translation(
conn: &Connection,
t: &MediaTranslation,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO media_translations (
id, project_id, translation_for, language, title, alt, caption,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
t.id,
t.project_id,
t.translation_for,
t.language,
t.title,
t.alt,
t.caption,
t.created_at,
t.updated_at,
],
)?;
Ok(())
}
pub fn get_media_translation_by_media_and_language(
conn: &Connection,
translation_for: &str,
language: &str,
) -> rusqlite::Result<MediaTranslation> {
conn.query_row(
&format!(
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
WHERE translation_for = ?1 AND language = ?2"
),
params![translation_for, language],
media_translation_from_row,
)
}
pub fn list_media_translations_by_media(
conn: &Connection,
translation_for: &str,
) -> rusqlite::Result<Vec<MediaTranslation>> {
let mut stmt = conn.prepare(&format!(
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
WHERE translation_for = ?1 ORDER BY language"
))?;
let rows = stmt.query_map(params![translation_for], media_translation_from_row)?;
rows.collect()
}
pub fn upsert_media_translation(
conn: &Connection,
t: &MediaTranslation,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO media_translations (
id, project_id, translation_for, language, title, alt, caption,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(translation_for, language) DO UPDATE SET
title = excluded.title,
alt = excluded.alt,
caption = excluded.caption,
updated_at = excluded.updated_at",
params![
t.id,
t.project_id,
t.translation_for,
t.language,
t.title,
t.alt,
t.caption,
t.created_at,
t.updated_at,
],
)?;
Ok(())
}
pub fn delete_media_translation(
conn: &Connection,
translation_for: &str,
language: &str,
) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM media_translations WHERE translation_for = ?1 AND language = ?2",
params![translation_for, language],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
db
}
fn make_mt(id: &str, lang: &str) -> MediaTranslation {
MediaTranslation {
id: id.to_string(),
project_id: "p1".to_string(),
translation_for: "m1".to_string(),
language: lang.to_string(),
title: Some(format!("Title {lang}")),
alt: Some(format!("Alt {lang}")),
caption: None,
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get() {
let db = setup();
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
let t = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
assert_eq!(t.title.as_deref(), Some("Title de"));
}
#[test]
fn list_by_media() {
let db = setup();
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
insert_media_translation(db.conn(), &make_mt("mt2", "fr")).unwrap();
let list = list_media_translations_by_media(db.conn(), "m1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].language, "de");
assert_eq!(list[1].language, "fr");
}
#[test]
fn upsert_inserts_then_updates() {
let db = setup();
let mut t = make_mt("mt1", "de");
upsert_media_translation(db.conn(), &t).unwrap();
let fetched = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
assert_eq!(fetched.title.as_deref(), Some("Title de"));
t.title = Some("Neuer Titel".into());
t.updated_at = 9999;
upsert_media_translation(db.conn(), &t).unwrap();
let fetched = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
assert_eq!(fetched.title.as_deref(), Some("Neuer Titel"));
assert_eq!(fetched.updated_at, 9999);
}
#[test]
fn delete_translation() {
let db = setup();
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
delete_media_translation(db.conn(), "m1", "de").unwrap();
assert!(
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
);
}
}

View File

@@ -0,0 +1,11 @@
pub mod project;
pub mod post;
pub mod post_translation;
pub mod media;
pub mod media_translation;
pub mod tag;
pub mod post_link;
pub mod post_media;
pub mod template;
pub mod script;
pub mod setting;

View File

@@ -0,0 +1,339 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS};
use crate::model::{Post, PostStatus};
fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
}
pub fn insert_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO posts (
id, project_id, title, slug, excerpt, content, status, author,
language, do_not_translate, template_slug, file_path, checksum,
tags, categories,
published_title, published_content, published_tags,
published_categories, published_excerpt,
created_at, updated_at, published_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
?9, ?10, ?11, ?12, ?13,
?14, ?15,
?16, ?17, ?18, ?19, ?20,
?21, ?22, ?23
)",
params![
post.id,
post.project_id,
post.title,
post.slug,
post.excerpt,
post.content,
post_status_to_str(&post.status),
post.author,
post.language,
post.do_not_translate as i64,
post.template_slug,
post.file_path,
post.checksum,
tags_to_json(&post.tags),
tags_to_json(&post.categories),
post.published_title,
post.published_content,
post.published_tags,
post.published_categories,
post.published_excerpt,
post.created_at,
post.updated_at,
post.published_at,
],
)?;
Ok(())
}
pub fn get_post_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Post> {
conn.query_row(
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = ?1"),
params![id],
post_from_row,
)
}
pub fn get_post_by_project_and_slug(
conn: &Connection,
project_id: &str,
slug: &str,
) -> rusqlite::Result<Post> {
conn.query_row(
&format!("SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 AND slug = ?2"),
params![project_id, slug],
post_from_row,
)
}
pub fn list_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Post>> {
let mut stmt = conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC"
))?;
let rows = stmt.query_map(params![project_id], post_from_row)?;
rows.collect()
}
pub fn update_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
conn.execute(
"UPDATE posts SET
title = ?1, slug = ?2, excerpt = ?3, content = ?4, status = ?5,
author = ?6, language = ?7, do_not_translate = ?8, template_slug = ?9,
file_path = ?10, checksum = ?11, tags = ?12, categories = ?13,
published_title = ?14, published_content = ?15, published_tags = ?16,
published_categories = ?17, published_excerpt = ?18,
updated_at = ?19, published_at = ?20
WHERE id = ?21",
params![
post.title,
post.slug,
post.excerpt,
post.content,
post_status_to_str(&post.status),
post.author,
post.language,
post.do_not_translate as i64,
post.template_slug,
post.file_path,
post.checksum,
tags_to_json(&post.tags),
tags_to_json(&post.categories),
post.published_title,
post.published_content,
post.published_tags,
post.published_categories,
post.published_excerpt,
post.updated_at,
post.published_at,
post.id,
],
)?;
Ok(())
}
pub fn update_post_status(
conn: &Connection,
id: &str,
status: &PostStatus,
updated_at: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE posts SET status = ?1, updated_at = ?2 WHERE id = ?3",
params![post_status_to_str(status), updated_at, id],
)?;
Ok(())
}
pub fn clear_post_content(conn: &Connection, id: &str, updated_at: i64) -> rusqlite::Result<()> {
conn.execute(
"UPDATE posts SET content = NULL, updated_at = ?1 WHERE id = ?2",
params![updated_at, id],
)?;
Ok(())
}
pub fn set_post_file_path(
conn: &Connection,
id: &str,
file_path: &str,
updated_at: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE posts SET file_path = ?1, updated_at = ?2 WHERE id = ?3",
params![file_path, updated_at, id],
)?;
Ok(())
}
pub fn set_published_snapshot(
conn: &Connection,
id: &str,
title: &str,
content: &str,
tags: &str,
categories: &str,
excerpt: Option<&str>,
published_at: i64,
updated_at: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE posts SET
published_title = ?1, published_content = ?2, published_tags = ?3,
published_categories = ?4, published_excerpt = ?5,
published_at = ?6, updated_at = ?7
WHERE id = ?8",
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
)?;
Ok(())
}
pub fn delete_post(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM posts WHERE id = ?1", params![id])?;
Ok(())
}
pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM posts WHERE project_id = ?1",
params![project_id],
|row| row.get(0),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db
}
fn make_post(id: &str, slug: &str) -> Post {
Post {
id: id.to_string(),
project_id: "p1".to_string(),
title: format!("Post {id}"),
slug: slug.to_string(),
excerpt: Some("excerpt".into()),
content: Some("body".into()),
status: PostStatus::Draft,
author: Some("Alice".into()),
language: Some("en".into()),
do_not_translate: false,
template_slug: None,
file_path: format!("posts/{slug}.md"),
checksum: None,
tags: vec!["rust".into()],
categories: vec!["tech".into()],
published_title: None,
published_content: None,
published_tags: None,
published_categories: None,
published_excerpt: None,
created_at: 1000,
updated_at: 2000,
published_at: None,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
let post = make_post("x1", "hello");
insert_post(db.conn(), &post).unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert_eq!(fetched.title, "Post x1");
assert_eq!(fetched.tags, vec!["rust"]);
assert_eq!(fetched.categories, vec!["tech"]);
assert_eq!(fetched.status, PostStatus::Draft);
assert!(!fetched.do_not_translate);
}
#[test]
fn get_by_project_and_slug() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
let fetched = get_post_by_project_and_slug(db.conn(), "p1", "hello").unwrap();
assert_eq!(fetched.id, "x1");
}
#[test]
fn list_posts_ordered_by_created_at_desc() {
let db = setup();
let mut p1 = make_post("x1", "first");
p1.created_at = 1000;
let mut p2 = make_post("x2", "second");
p2.created_at = 2000;
insert_post(db.conn(), &p1).unwrap();
insert_post(db.conn(), &p2).unwrap();
let list = list_posts_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, "x2");
assert_eq!(list[1].id, "x1");
}
#[test]
fn update_post_fields() {
let db = setup();
let mut post = make_post("x1", "hello");
insert_post(db.conn(), &post).unwrap();
post.title = "Updated".into();
post.tags = vec!["new-tag".into()];
post.updated_at = 9999;
update_post(db.conn(), &post).unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert_eq!(fetched.title, "Updated");
assert_eq!(fetched.tags, vec!["new-tag"]);
assert_eq!(fetched.updated_at, 9999);
}
#[test]
fn update_status() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
update_post_status(db.conn(), "x1", &PostStatus::Published, 5000).unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert_eq!(fetched.status, PostStatus::Published);
assert_eq!(fetched.updated_at, 5000);
}
#[test]
fn clear_content_sets_null() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
clear_post_content(db.conn(), "x1", 5000).unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert!(fetched.content.is_none());
}
#[test]
fn set_file_path_updates() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
set_post_file_path(db.conn(), "x1", "posts/2024/01/hello.md", 5000).unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert_eq!(fetched.file_path, "posts/2024/01/hello.md");
}
#[test]
fn published_snapshot() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
set_published_snapshot(
db.conn(), "x1", "Pub Title", "Pub Body",
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
).unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
assert_eq!(fetched.published_at, Some(3000));
}
#[test]
fn delete_removes_post() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
delete_post(db.conn(), "x1").unwrap();
assert!(get_post_by_id(db.conn(), "x1").is_err());
}
#[test]
fn count_posts() {
let db = setup();
assert_eq!(count_posts_by_project(db.conn(), "p1").unwrap(), 0);
insert_post(db.conn(), &make_post("x1", "a")).unwrap();
insert_post(db.conn(), &make_post("x2", "b")).unwrap();
assert_eq!(count_posts_by_project(db.conn(), "p1").unwrap(), 2);
}
}

View File

@@ -0,0 +1,117 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS};
use crate::model::PostLink;
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
link.id,
link.source_post_id,
link.target_post_id,
link.link_text,
link.created_at,
],
)?;
Ok(())
}
pub fn delete_links_by_source(conn: &Connection, source_post_id: &str) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM post_links WHERE source_post_id = ?1",
params![source_post_id],
)?;
Ok(())
}
pub fn list_links_by_source(
conn: &Connection,
source_post_id: &str,
) -> rusqlite::Result<Vec<PostLink>> {
let mut stmt = conn.prepare(&format!(
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE source_post_id = ?1 ORDER BY created_at"
))?;
let rows = stmt.query_map(params![source_post_id], post_link_from_row)?;
rows.collect()
}
pub fn list_links_by_target(
conn: &Connection,
target_post_id: &str,
) -> rusqlite::Result<Vec<PostLink>> {
let mut stmt = conn.prepare(&format!(
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE target_post_id = ?1 ORDER BY created_at"
))?;
let rows = stmt.query_map(params![target_post_id], post_link_from_row)?;
rows.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let c = db.conn();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
[],
).unwrap();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
[],
).unwrap();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
[],
).unwrap();
db
}
fn make_link(id: &str, src: &str, tgt: &str) -> PostLink {
PostLink {
id: id.to_string(),
source_post_id: src.to_string(),
target_post_id: tgt.to_string(),
link_text: Some("see also".into()),
created_at: 1000,
}
}
#[test]
fn insert_and_list_by_source() {
let db = setup();
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
insert_post_link(db.conn(), &make_link("l2", "a", "c")).unwrap();
let links = list_links_by_source(db.conn(), "a").unwrap();
assert_eq!(links.len(), 2);
}
#[test]
fn list_by_target() {
let db = setup();
insert_post_link(db.conn(), &make_link("l1", "a", "c")).unwrap();
insert_post_link(db.conn(), &make_link("l2", "b", "c")).unwrap();
let links = list_links_by_target(db.conn(), "c").unwrap();
assert_eq!(links.len(), 2);
}
#[test]
fn delete_by_source() {
let db = setup();
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
insert_post_link(db.conn(), &make_link("l2", "a", "c")).unwrap();
delete_links_by_source(db.conn(), "a").unwrap();
let links = list_links_by_source(db.conn(), "a").unwrap();
assert!(links.is_empty());
}
}

View File

@@ -0,0 +1,144 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS};
use crate::model::PostMedia;
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
pm.id,
pm.project_id,
pm.post_id,
pm.media_id,
pm.sort_order,
pm.created_at,
],
)?;
Ok(())
}
pub fn unlink_media(conn: &Connection, post_id: &str, media_id: &str) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM post_media WHERE post_id = ?1 AND media_id = ?2",
params![post_id, media_id],
)?;
Ok(())
}
pub fn list_post_media_by_post(
conn: &Connection,
post_id: &str,
) -> rusqlite::Result<Vec<PostMedia>> {
let mut stmt = conn.prepare(&format!(
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE post_id = ?1 ORDER BY sort_order"
))?;
let rows = stmt.query_map(params![post_id], post_media_from_row)?;
rows.collect()
}
pub fn list_post_media_by_media(
conn: &Connection,
media_id: &str,
) -> rusqlite::Result<Vec<PostMedia>> {
let mut stmt = conn.prepare(&format!(
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE media_id = ?1 ORDER BY created_at"
))?;
let rows = stmt.query_map(params![media_id], post_media_from_row)?;
rows.collect()
}
pub fn update_sort_order(
conn: &Connection,
post_id: &str,
media_id: &str,
sort_order: i32,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE post_media SET sort_order = ?1 WHERE post_id = ?2 AND media_id = ?3",
params![sort_order, post_id, media_id],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
[],
)
.unwrap();
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
db
}
fn make_pm(id: &str, media_id: &str, order: i32) -> PostMedia {
PostMedia {
id: id.to_string(),
project_id: "p1".to_string(),
post_id: "post1".to_string(),
media_id: media_id.to_string(),
sort_order: order,
created_at: 1000,
}
}
#[test]
fn link_and_list_by_post() {
let db = setup();
link_media(db.conn(), &make_pm("pm1", "m1", 1)).unwrap();
link_media(db.conn(), &make_pm("pm2", "m2", 0)).unwrap();
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].media_id, "m2"); // sort_order 0 first
assert_eq!(list[1].media_id, "m1");
}
#[test]
fn list_by_media() {
let db = setup();
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
let list = list_post_media_by_media(db.conn(), "m1").unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].post_id, "post1");
}
#[test]
fn unlink_removes_association() {
let db = setup();
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
unlink_media(db.conn(), "post1", "m1").unwrap();
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
assert!(list.is_empty());
}
#[test]
fn update_sort_order_changes_value() {
let db = setup();
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
update_sort_order(db.conn(), "post1", "m1", 10).unwrap();
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
assert_eq!(list[0].sort_order, 10);
}
#[test]
fn duplicate_post_media_rejected() {
let db = setup();
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
let result = link_media(db.conn(), &make_pm("pm2", "m1", 1));
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,221 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS};
use crate::model::PostTranslation;
pub fn insert_post_translation(
conn: &Connection,
t: &PostTranslation,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO post_translations (
id, project_id, translation_for, language, title, excerpt, content,
status, file_path, checksum, created_at, updated_at, published_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
t.id,
t.project_id,
t.translation_for,
t.language,
t.title,
t.excerpt,
t.content,
post_status_to_str(&t.status),
t.file_path,
t.checksum,
t.created_at,
t.updated_at,
t.published_at,
],
)?;
Ok(())
}
pub fn get_post_translation_by_id(
conn: &Connection,
id: &str,
) -> rusqlite::Result<PostTranslation> {
conn.query_row(
&format!("SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations WHERE id = ?1"),
params![id],
post_translation_from_row,
)
}
pub fn get_post_translation_by_post_and_language(
conn: &Connection,
translation_for: &str,
language: &str,
) -> rusqlite::Result<PostTranslation> {
conn.query_row(
&format!(
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
WHERE translation_for = ?1 AND language = ?2"
),
params![translation_for, language],
post_translation_from_row,
)
}
pub fn list_post_translations_by_post(
conn: &Connection,
translation_for: &str,
) -> rusqlite::Result<Vec<PostTranslation>> {
let mut stmt = conn.prepare(&format!(
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
WHERE translation_for = ?1 ORDER BY language"
))?;
let rows = stmt.query_map(params![translation_for], post_translation_from_row)?;
rows.collect()
}
pub fn update_post_translation(
conn: &Connection,
t: &PostTranslation,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE post_translations SET
title = ?1, excerpt = ?2, content = ?3, status = ?4,
file_path = ?5, checksum = ?6, updated_at = ?7, published_at = ?8
WHERE id = ?9",
params![
t.title,
t.excerpt,
t.content,
post_status_to_str(&t.status),
t.file_path,
t.checksum,
t.updated_at,
t.published_at,
t.id,
],
)?;
Ok(())
}
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM post_translations WHERE id = ?1",
params![id],
)?;
Ok(())
}
pub fn delete_all_translations_for_post(
conn: &Connection,
translation_for: &str,
) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM post_translations WHERE translation_for = ?1",
params![translation_for],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::model::PostStatus;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
// insert a parent post
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
[],
)
.unwrap();
db
}
fn make_translation(id: &str, lang: &str) -> PostTranslation {
PostTranslation {
id: id.to_string(),
project_id: "p1".to_string(),
translation_for: "post1".to_string(),
language: lang.to_string(),
title: format!("Title {lang}"),
excerpt: Some("excerpt".into()),
content: Some("body".into()),
status: PostStatus::Draft,
file_path: format!("posts/hello.{lang}.md"),
checksum: None,
created_at: 1000,
updated_at: 2000,
published_at: None,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
let t = get_post_translation_by_id(db.conn(), "t1").unwrap();
assert_eq!(t.language, "de");
assert_eq!(t.title, "Title de");
}
#[test]
fn get_by_post_and_language() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
let t = get_post_translation_by_post_and_language(db.conn(), "post1", "de").unwrap();
assert_eq!(t.id, "t1");
}
#[test]
fn list_by_post() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
let list = list_post_translations_by_post(db.conn(), "post1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].language, "de");
assert_eq!(list[1].language, "fr");
}
#[test]
fn update_translation() {
let db = setup();
let mut t = make_translation("t1", "de");
insert_post_translation(db.conn(), &t).unwrap();
t.title = "Neuer Titel".into();
t.updated_at = 9999;
update_post_translation(db.conn(), &t).unwrap();
let fetched = get_post_translation_by_id(db.conn(), "t1").unwrap();
assert_eq!(fetched.title, "Neuer Titel");
assert_eq!(fetched.updated_at, 9999);
}
#[test]
fn delete_single() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
delete_post_translation(db.conn(), "t1").unwrap();
assert!(get_post_translation_by_id(db.conn(), "t1").is_err());
}
#[test]
fn delete_all_for_post() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
delete_all_translations_for_post(db.conn(), "post1").unwrap();
let list = list_post_translations_by_post(db.conn(), "post1").unwrap();
assert!(list.is_empty());
}
#[test]
fn duplicate_language_rejected() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
let result = insert_post_translation(db.conn(), &make_translation("t2", "de"));
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,215 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS};
use crate::model::Project;
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
project.id,
project.name,
project.slug,
project.description,
project.data_path,
project.is_active as i64,
project.created_at,
project.updated_at,
],
)?;
Ok(())
}
pub fn get_project_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Project> {
conn.query_row(
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"),
params![id],
project_from_row,
)
}
pub fn get_project_by_slug(conn: &Connection, slug: &str) -> rusqlite::Result<Project> {
conn.query_row(
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE slug = ?1"),
params![slug],
project_from_row,
)
}
pub fn get_active_project(conn: &Connection) -> rusqlite::Result<Project> {
conn.query_row(
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE is_active = 1 LIMIT 1"),
[],
project_from_row,
)
}
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
conn.execute(
"UPDATE projects SET is_active = 1 WHERE id = ?1",
params![id],
)?;
Ok(())
}
pub fn list_projects(conn: &Connection) -> rusqlite::Result<Vec<Project>> {
let mut stmt = conn.prepare(&format!(
"SELECT {PROJECT_COLUMNS} FROM projects ORDER BY name"
))?;
let rows = stmt.query_map([], project_from_row)?;
rows.collect()
}
pub fn update_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
conn.execute(
"UPDATE projects SET name = ?1, slug = ?2, description = ?3, data_path = ?4,
is_active = ?5, updated_at = ?6
WHERE id = ?7",
params![
project.name,
project.slug,
project.description,
project.data_path,
project.is_active as i64,
project.updated_at,
project.id,
],
)?;
Ok(())
}
pub fn delete_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
Ok(())
}
/// Test helper: create a minimal Project value (available to sibling test modules).
#[cfg(test)]
pub fn make_test_project(id: &str, slug: &str) -> Project {
Project {
id: id.to_string(),
name: format!("Project {id}"),
slug: slug.to_string(),
description: Some("A test project".into()),
data_path: Some("/data".into()),
is_active: false,
created_at: 1000,
updated_at: 2000,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
db
}
fn make_project(id: &str, slug: &str) -> Project {
Project {
id: id.to_string(),
name: format!("Project {id}"),
slug: slug.to_string(),
description: Some("A test project".into()),
data_path: Some("/data".into()),
is_active: false,
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
let p = make_project("p1", "blog");
insert_project(db.conn(), &p).unwrap();
let fetched = get_project_by_id(db.conn(), "p1").unwrap();
assert_eq!(fetched.id, "p1");
assert_eq!(fetched.name, "Project p1");
assert_eq!(fetched.slug, "blog");
assert_eq!(fetched.description.as_deref(), Some("A test project"));
assert_eq!(fetched.data_path.as_deref(), Some("/data"));
assert!(!fetched.is_active);
assert_eq!(fetched.created_at, 1000);
assert_eq!(fetched.updated_at, 2000);
}
#[test]
fn get_by_slug() {
let db = setup();
insert_project(db.conn(), &make_project("p1", "my-blog")).unwrap();
let fetched = get_project_by_slug(db.conn(), "my-blog").unwrap();
assert_eq!(fetched.id, "p1");
}
#[test]
fn active_project_flow() {
let db = setup();
insert_project(db.conn(), &make_project("p1", "blog1")).unwrap();
insert_project(db.conn(), &make_project("p2", "blog2")).unwrap();
set_active_project(db.conn(), "p1").unwrap();
let active = get_active_project(db.conn()).unwrap();
assert_eq!(active.id, "p1");
set_active_project(db.conn(), "p2").unwrap();
let active = get_active_project(db.conn()).unwrap();
assert_eq!(active.id, "p2");
let p1 = get_project_by_id(db.conn(), "p1").unwrap();
assert!(!p1.is_active);
}
#[test]
fn list_projects_ordered() {
let db = setup();
let mut p2 = make_project("p2", "zebra");
p2.name = "Zebra".into();
let mut p1 = make_project("p1", "alpha");
p1.name = "Alpha".into();
insert_project(db.conn(), &p2).unwrap();
insert_project(db.conn(), &p1).unwrap();
let list = list_projects(db.conn()).unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "Alpha");
assert_eq!(list[1].name, "Zebra");
}
#[test]
fn update_project_fields() {
let db = setup();
let mut p = make_project("p1", "blog");
insert_project(db.conn(), &p).unwrap();
p.name = "Updated".into();
p.description = None;
p.updated_at = 9999;
update_project(db.conn(), &p).unwrap();
let fetched = get_project_by_id(db.conn(), "p1").unwrap();
assert_eq!(fetched.name, "Updated");
assert!(fetched.description.is_none());
assert_eq!(fetched.updated_at, 9999);
}
#[test]
fn delete_project_removes_row() {
let db = setup();
insert_project(db.conn(), &make_project("p1", "blog")).unwrap();
delete_project(db.conn(), "p1").unwrap();
let result = get_project_by_id(db.conn(), "p1");
assert!(result.is_err());
}
#[test]
fn duplicate_slug_rejected() {
let db = setup();
insert_project(db.conn(), &make_project("p1", "blog")).unwrap();
let result = insert_project(db.conn(), &make_project("p2", "blog"));
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,194 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS,
};
use crate::model::Script;
pub fn insert_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO scripts (
id, project_id, slug, title, kind, entrypoint, enabled, version,
file_path, status, content, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
s.id,
s.project_id,
s.slug,
s.title,
script_kind_to_str(&s.kind),
s.entrypoint,
s.enabled as i64,
s.version,
s.file_path,
script_status_to_str(&s.status),
s.content,
s.created_at,
s.updated_at,
],
)?;
Ok(())
}
pub fn get_script_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Script> {
conn.query_row(
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE id = ?1"),
params![id],
script_from_row,
)
}
pub fn get_script_by_slug(
conn: &Connection,
project_id: &str,
slug: &str,
) -> rusqlite::Result<Script> {
conn.query_row(
&format!(
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
),
params![project_id, slug],
script_from_row,
)
}
pub fn list_scripts_by_project(
conn: &Connection,
project_id: &str,
) -> rusqlite::Result<Vec<Script>> {
let mut stmt = conn.prepare(&format!(
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 ORDER BY title"
))?;
let rows = stmt.query_map(params![project_id], script_from_row)?;
rows.collect()
}
pub fn update_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
conn.execute(
"UPDATE scripts SET
slug = ?1, title = ?2, kind = ?3, entrypoint = ?4, enabled = ?5,
version = ?6, file_path = ?7, status = ?8, content = ?9, updated_at = ?10
WHERE id = ?11",
params![
s.slug,
s.title,
script_kind_to_str(&s.kind),
s.entrypoint,
s.enabled as i64,
s.version,
s.file_path,
script_status_to_str(&s.status),
s.content,
s.updated_at,
s.id,
],
)?;
Ok(())
}
pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM scripts WHERE id = ?1", params![id])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::model::{ScriptKind, ScriptStatus};
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db
}
fn make_script(id: &str, slug: &str) -> Script {
Script {
id: id.to_string(),
project_id: "p1".to_string(),
slug: slug.to_string(),
title: format!("Script {slug}"),
kind: ScriptKind::Macro,
entrypoint: "render".to_string(),
enabled: true,
version: 1,
file_path: format!("scripts/{slug}.lua"),
status: ScriptStatus::Published,
content: Some("return html".into()),
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
let s = get_script_by_id(db.conn(), "s1").unwrap();
assert_eq!(s.slug, "gallery");
assert_eq!(s.kind, ScriptKind::Macro);
assert_eq!(s.entrypoint, "render");
assert!(s.enabled);
}
#[test]
fn get_by_slug() {
let db = setup();
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
let s = get_script_by_slug(db.conn(), "p1", "gallery").unwrap();
assert_eq!(s.id, "s1");
}
#[test]
fn list_by_project() {
let db = setup();
let mut s1 = make_script("s1", "zebra");
s1.title = "Zebra".into();
let mut s2 = make_script("s2", "alpha");
s2.title = "Alpha".into();
insert_script(db.conn(), &s1).unwrap();
insert_script(db.conn(), &s2).unwrap();
let list = list_scripts_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].title, "Alpha");
}
#[test]
fn update_script_fields() {
let db = setup();
let mut s = make_script("s1", "gallery");
insert_script(db.conn(), &s).unwrap();
s.kind = ScriptKind::Utility;
s.enabled = false;
s.version = 3;
s.status = ScriptStatus::Draft;
s.content = None;
s.updated_at = 9999;
update_script(db.conn(), &s).unwrap();
let fetched = get_script_by_id(db.conn(), "s1").unwrap();
assert_eq!(fetched.kind, ScriptKind::Utility);
assert!(!fetched.enabled);
assert_eq!(fetched.version, 3);
assert_eq!(fetched.status, ScriptStatus::Draft);
assert!(fetched.content.is_none());
}
#[test]
fn delete_removes_script() {
let db = setup();
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
delete_script(db.conn(), "s1").unwrap();
assert!(get_script_by_id(db.conn(), "s1").is_err());
}
#[test]
fn duplicate_slug_rejected() {
let db = setup();
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
let result = insert_script(db.conn(), &make_script("s2", "gallery"));
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,83 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS};
use crate::model::Setting;
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
conn.query_row(
&format!("SELECT {SETTING_COLUMNS} FROM settings WHERE key = ?1"),
params![key],
setting_from_row,
)
}
pub fn set_setting_value(
conn: &Connection,
key: &str,
value: &str,
updated_at: i64,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
params![key, value, updated_at],
)?;
Ok(())
}
pub fn list_all_settings(conn: &Connection) -> rusqlite::Result<Vec<Setting>> {
let mut stmt = conn.prepare(&format!(
"SELECT {SETTING_COLUMNS} FROM settings ORDER BY key"
))?;
let rows = stmt.query_map([], setting_from_row)?;
rows.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
db
}
#[test]
fn set_and_get() {
let db = setup();
set_setting_value(db.conn(), "theme", "dark", 1000).unwrap();
let s = get_setting_by_key(db.conn(), "theme").unwrap();
assert_eq!(s.key, "theme");
assert_eq!(s.value, "dark");
assert_eq!(s.updated_at, 1000);
}
#[test]
fn upsert_updates_existing() {
let db = setup();
set_setting_value(db.conn(), "theme", "dark", 1000).unwrap();
set_setting_value(db.conn(), "theme", "light", 2000).unwrap();
let s = get_setting_by_key(db.conn(), "theme").unwrap();
assert_eq!(s.value, "light");
assert_eq!(s.updated_at, 2000);
}
#[test]
fn list_all() {
let db = setup();
set_setting_value(db.conn(), "b_key", "val_b", 1000).unwrap();
set_setting_value(db.conn(), "a_key", "val_a", 1000).unwrap();
let list = list_all_settings(db.conn()).unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].key, "a_key");
assert_eq!(list[1].key, "b_key");
}
#[test]
fn get_nonexistent_returns_error() {
let db = setup();
assert!(get_setting_by_key(db.conn(), "nope").is_err());
}
}

View File

@@ -0,0 +1,157 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{tag_from_row, TAG_COLUMNS};
use crate::model::Tag;
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO tags (id, project_id, name, color, post_template_slug, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
tag.id,
tag.project_id,
tag.name,
tag.color,
tag.post_template_slug,
tag.created_at,
tag.updated_at,
],
)?;
Ok(())
}
pub fn get_tag_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Tag> {
conn.query_row(
&format!("SELECT {TAG_COLUMNS} FROM tags WHERE id = ?1"),
params![id],
tag_from_row,
)
}
pub fn get_tag_by_project_and_name(
conn: &Connection,
project_id: &str,
name: &str,
) -> rusqlite::Result<Tag> {
conn.query_row(
&format!(
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 AND LOWER(name) = LOWER(?2)"
),
params![project_id, name],
tag_from_row,
)
}
pub fn list_tags_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Tag>> {
let mut stmt = conn.prepare(&format!(
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 ORDER BY name"
))?;
let rows = stmt.query_map(params![project_id], tag_from_row)?;
rows.collect()
}
pub fn update_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
conn.execute(
"UPDATE tags SET name = ?1, color = ?2, post_template_slug = ?3, updated_at = ?4
WHERE id = ?5",
params![
tag.name,
tag.color,
tag.post_template_slug,
tag.updated_at,
tag.id,
],
)?;
Ok(())
}
pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db
}
fn make_tag(id: &str, name: &str) -> Tag {
Tag {
id: id.to_string(),
project_id: "p1".to_string(),
name: name.to_string(),
color: Some("#ff0000".into()),
post_template_slug: None,
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
let tag = get_tag_by_id(db.conn(), "t1").unwrap();
assert_eq!(tag.name, "rust");
assert_eq!(tag.color.as_deref(), Some("#ff0000"));
}
#[test]
fn get_by_project_and_name_case_insensitive() {
let db = setup();
insert_tag(db.conn(), &make_tag("t1", "Rust")).unwrap();
let tag = get_tag_by_project_and_name(db.conn(), "p1", "rust").unwrap();
assert_eq!(tag.id, "t1");
let tag = get_tag_by_project_and_name(db.conn(), "p1", "RUST").unwrap();
assert_eq!(tag.id, "t1");
}
#[test]
fn list_ordered_by_name() {
let db = setup();
insert_tag(db.conn(), &make_tag("t1", "zebra")).unwrap();
insert_tag(db.conn(), &make_tag("t2", "alpha")).unwrap();
let list = list_tags_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "alpha");
assert_eq!(list[1].name, "zebra");
}
#[test]
fn update_tag_fields() {
let db = setup();
let mut tag = make_tag("t1", "rust");
insert_tag(db.conn(), &tag).unwrap();
tag.name = "go".into();
tag.color = None;
tag.updated_at = 9999;
update_tag(db.conn(), &tag).unwrap();
let fetched = get_tag_by_id(db.conn(), "t1").unwrap();
assert_eq!(fetched.name, "go");
assert!(fetched.color.is_none());
}
#[test]
fn delete_tag_removes_row() {
let db = setup();
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
delete_tag(db.conn(), "t1").unwrap();
assert!(get_tag_by_id(db.conn(), "t1").is_err());
}
#[test]
fn duplicate_name_rejected() {
let db = setup();
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
let result = insert_tag(db.conn(), &make_tag("t2", "rust"));
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,190 @@
use rusqlite::{params, Connection};
use crate::db::from_row::{
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS,
};
use crate::model::Template;
pub fn insert_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO templates (
id, project_id, slug, title, kind, enabled, version,
file_path, status, content, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
t.id,
t.project_id,
t.slug,
t.title,
template_kind_to_str(&t.kind),
t.enabled as i64,
t.version,
t.file_path,
template_status_to_str(&t.status),
t.content,
t.created_at,
t.updated_at,
],
)?;
Ok(())
}
pub fn get_template_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Template> {
conn.query_row(
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = ?1"),
params![id],
template_from_row,
)
}
pub fn get_template_by_slug(
conn: &Connection,
project_id: &str,
slug: &str,
) -> rusqlite::Result<Template> {
conn.query_row(
&format!(
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
),
params![project_id, slug],
template_from_row,
)
}
pub fn list_templates_by_project(
conn: &Connection,
project_id: &str,
) -> rusqlite::Result<Vec<Template>> {
let mut stmt = conn.prepare(&format!(
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 ORDER BY title"
))?;
let rows = stmt.query_map(params![project_id], template_from_row)?;
rows.collect()
}
pub fn update_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
conn.execute(
"UPDATE templates SET
slug = ?1, title = ?2, kind = ?3, enabled = ?4, version = ?5,
file_path = ?6, status = ?7, content = ?8, updated_at = ?9
WHERE id = ?10",
params![
t.slug,
t.title,
template_kind_to_str(&t.kind),
t.enabled as i64,
t.version,
t.file_path,
template_status_to_str(&t.status),
t.content,
t.updated_at,
t.id,
],
)?;
Ok(())
}
pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::model::{TemplateKind, TemplateStatus};
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db
}
fn make_tpl(id: &str, slug: &str) -> Template {
Template {
id: id.to_string(),
project_id: "p1".to_string(),
slug: slug.to_string(),
title: format!("Template {slug}"),
kind: TemplateKind::Post,
enabled: true,
version: 1,
file_path: format!("templates/{slug}.liquid"),
status: TemplateStatus::Published,
content: Some("html".into()),
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
let t = get_template_by_id(db.conn(), "t1").unwrap();
assert_eq!(t.slug, "default");
assert_eq!(t.kind, TemplateKind::Post);
assert!(t.enabled);
}
#[test]
fn get_by_slug() {
let db = setup();
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
let t = get_template_by_slug(db.conn(), "p1", "default").unwrap();
assert_eq!(t.id, "t1");
}
#[test]
fn list_by_project() {
let db = setup();
let mut t1 = make_tpl("t1", "zebra");
t1.title = "Zebra".into();
let mut t2 = make_tpl("t2", "alpha");
t2.title = "Alpha".into();
insert_template(db.conn(), &t1).unwrap();
insert_template(db.conn(), &t2).unwrap();
let list = list_templates_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].title, "Alpha");
}
#[test]
fn update_template_fields() {
let db = setup();
let mut t = make_tpl("t1", "default");
insert_template(db.conn(), &t).unwrap();
t.kind = TemplateKind::List;
t.enabled = false;
t.version = 5;
t.status = TemplateStatus::Draft;
t.content = None;
t.updated_at = 9999;
update_template(db.conn(), &t).unwrap();
let fetched = get_template_by_id(db.conn(), "t1").unwrap();
assert_eq!(fetched.kind, TemplateKind::List);
assert!(!fetched.enabled);
assert_eq!(fetched.version, 5);
assert_eq!(fetched.status, TemplateStatus::Draft);
assert!(fetched.content.is_none());
}
#[test]
fn delete_removes_template() {
let db = setup();
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
delete_template(db.conn(), "t1").unwrap();
assert!(get_template_by_id(db.conn(), "t1").is_err());
}
#[test]
fn duplicate_slug_rejected() {
let db = setup();
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
let result = insert_template(db.conn(), &make_tpl("t2", "default"));
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,28 @@
use std::path::PathBuf;
/// Shared context passed to engine operations.
pub struct EngineContext<'a> {
pub conn: &'a rusqlite::Connection,
pub project_id: String,
pub data_dir: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use tempfile::TempDir;
#[test]
fn context_holds_references() {
let db = Database::open_in_memory().unwrap();
let dir = TempDir::new().unwrap();
let ctx = EngineContext {
conn: db.conn(),
project_id: "p1".into(),
data_dir: dir.path().to_path_buf(),
};
assert_eq!(ctx.project_id, "p1");
assert!(ctx.data_dir.exists());
}
}

View File

@@ -0,0 +1,81 @@
use std::fmt;
/// Errors produced by engine operations.
#[derive(Debug)]
pub enum EngineError {
Db(rusqlite::Error),
Io(std::io::Error),
Parse(String),
NotFound(String),
Conflict(String),
Validation(String),
}
impl fmt::Display for EngineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Db(e) => write!(f, "database error: {e}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Parse(msg) => write!(f, "parse error: {msg}"),
Self::NotFound(msg) => write!(f, "not found: {msg}"),
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
Self::Validation(msg) => write!(f, "validation error: {msg}"),
}
}
}
impl std::error::Error for EngineError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Db(e) => Some(e),
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<rusqlite::Error> for EngineError {
fn from(e: rusqlite::Error) -> Self {
Self::Db(e)
}
}
impl From<std::io::Error> for EngineError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for EngineError {
fn from(e: serde_json::Error) -> Self {
Self::Parse(e.to_string())
}
}
impl From<serde_yaml::Error> for EngineError {
fn from(e: serde_yaml::Error) -> Self {
Self::Parse(e.to_string())
}
}
pub type EngineResult<T> = Result<T, EngineError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_variants() {
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error"));
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found"));
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict"));
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation"));
}
#[test]
fn from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let engine_err = EngineError::from(io_err);
assert!(matches!(engine_err, EngineError::Io(_)));
}
}

View File

@@ -0,0 +1,987 @@
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use uuid::Uuid;
use walkdir::WalkDir;
use crate::db::fts;
use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt;
use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation};
use crate::util::sidecar::{MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar};
use crate::util::thumbnail::{
generate_all_thumbnails, image_dimensions, mime_from_extension,
ThumbnailFormat, THUMBNAIL_SIZES,
};
use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
media_translation_sidecar_path, now_unix_ms,
};
/// Report returned by `rebuild_media_from_filesystem`.
#[derive(Debug, Default)]
pub struct MediaRebuildReport {
pub media_created: usize,
pub media_updated: usize,
pub translations_created: usize,
pub translations_updated: usize,
pub errors: Vec<String>,
}
/// Import a media file (image, etc.) into the project.
pub fn import_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
source_path: &Path,
original_name: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
) -> EngineResult<Media> {
let id = Uuid::new_v4().to_string();
let now = now_unix_ms();
// Derive extension from original_name
let ext = Path::new(original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let filename = format!("{id}.{ext}");
// Compute target directory and copy file
let dir_path = media_dir_path(now);
let rel_file_path = format!("{dir_path}{filename}");
let abs_file_path = data_dir.join(&rel_file_path);
if let Some(parent) = abs_file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source_path, &abs_file_path)?;
// Try to get image dimensions (silently ignore errors for non-image files)
let (width, height) = image_dimensions(&abs_file_path)
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
.unwrap_or((None, None));
// Detect MIME type from extension
let mime_type = mime_from_extension(ext).to_string();
// Get file size
let file_size = fs::metadata(&abs_file_path)?.len() as i64;
// Compute sidecar path
let sidecar_rel = media_sidecar_path(&rel_file_path);
// Compute checksum of the copied file
let file_bytes = fs::read(&abs_file_path)?;
let checksum = content_hash(&file_bytes);
// Generate thumbnails (silently ignore errors for non-image files)
let thumbnails_dir = data_dir.join("thumbnails");
let _ = generate_all_thumbnails(&abs_file_path, &thumbnails_dir, &id);
let media = Media {
id: id.clone(),
project_id: project_id.to_string(),
filename,
original_name: original_name.to_string(),
mime_type,
size: file_size,
width,
height,
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
author: author.map(|s| s.to_string()),
language: language.map(|s| s.to_string()),
file_path: rel_file_path,
sidecar_path: sidecar_rel.clone(),
checksum: Some(checksum),
tags,
created_at: now,
updated_at: now,
};
// Write sidecar
let sidecar = MediaSidecar::from_media(&media, &[]);
let abs_sidecar = data_dir.join(&sidecar_rel);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
// Insert into DB
qm::insert_media(conn, &media)?;
// Index in FTS
fts_index_media(conn, &media)?;
Ok(media)
}
/// Update a media item's metadata fields.
pub fn update_media(
conn: &Connection,
data_dir: &Path,
media_id: &str,
title: Option<Option<&str>>,
alt: Option<Option<&str>>,
caption: Option<Option<&str>>,
author: Option<Option<&str>>,
language: Option<Option<&str>>,
tags: Option<Vec<String>>,
) -> EngineResult<Media> {
let mut media = qm::get_media_by_id(conn, media_id)?;
if let Some(t) = title {
media.title = t.map(|s| s.to_string());
}
if let Some(a) = alt {
media.alt = a.map(|s| s.to_string());
}
if let Some(c) = caption {
media.caption = c.map(|s| s.to_string());
}
if let Some(a) = author {
media.author = a.map(|s| s.to_string());
}
if let Some(l) = language {
media.language = l.map(|s| s.to_string());
}
if let Some(t) = tags {
media.tags = t;
}
media.updated_at = now_unix_ms();
qm::update_media(conn, &media)?;
// Rewrite sidecar (need linked_post_ids from post_media table)
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
let linked_post_ids: Vec<String> = linked.iter().map(|pm| pm.post_id.clone()).collect();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
let abs_sidecar = data_dir.join(&media.sidecar_path);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
// Re-index FTS
fts_index_media(conn, &media)?;
Ok(media)
}
/// Delete a media item and all related artifacts.
pub fn delete_media(
conn: &Connection,
data_dir: &Path,
media_id: &str,
) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete binary file
let abs_file = data_dir.join(&media.file_path);
if abs_file.exists() {
fs::remove_file(&abs_file)?;
}
// Delete sidecar file
let abs_sidecar = data_dir.join(&media.sidecar_path);
if abs_sidecar.exists() {
fs::remove_file(&abs_sidecar)?;
}
// Delete all translation sidecar files
let translations = qmt::list_media_translations_by_media(conn, media_id).unwrap_or_default();
for t in &translations {
let trans_sidecar = media_translation_sidecar_path(&media.file_path, &t.language);
let abs_trans = data_dir.join(&trans_sidecar);
if abs_trans.exists() {
fs::remove_file(&abs_trans)?;
}
}
// Delete all thumbnails
let ext_map = |fmt: &ThumbnailFormat| -> &str {
match fmt {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
}
};
let prefix = &media_id[..2.min(media_id.len())];
for size in THUMBNAIL_SIZES {
let ext = ext_map(&size.format);
let thumb_rel = format!("thumbnails/{prefix}/{media_id}-{}.{ext}", size.name);
let abs_thumb = data_dir.join(&thumb_rel);
if abs_thumb.exists() {
let _ = fs::remove_file(&abs_thumb);
}
}
// Delete media translations from DB
for t in &translations {
qmt::delete_media_translation(conn, media_id, &t.language)?;
}
// Delete post_media links from DB
let links = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
for link in &links {
qpm::unlink_media(conn, &link.post_id, media_id)?;
}
// Remove from FTS index
fts::remove_media_from_index(conn, media_id)?;
// Delete from media table
qm::delete_media(conn, media_id)?;
Ok(())
}
/// Create or update a translation for a media item.
pub fn upsert_media_translation(
conn: &Connection,
data_dir: &Path,
media_id: &str,
language: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
) -> EngineResult<MediaTranslation> {
let media = qm::get_media_by_id(conn, media_id)?;
let now = now_unix_ms();
// Check if translation already exists
let existing = qmt::get_media_translation_by_media_and_language(conn, media_id, language);
let translation = match existing {
Ok(mut t) => {
t.title = title.map(|s| s.to_string());
t.alt = alt.map(|s| s.to_string());
t.caption = caption.map(|s| s.to_string());
t.updated_at = now;
qmt::upsert_media_translation(conn, &t)?;
t
}
Err(_) => {
let t = MediaTranslation {
id: Uuid::new_v4().to_string(),
project_id: media.project_id.clone(),
translation_for: media_id.to_string(),
language: language.to_string(),
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
created_at: now,
updated_at: now,
};
qmt::upsert_media_translation(conn, &t)?;
t
}
};
// Write translation sidecar file
let trans_sidecar = MediaTranslationSidecar {
translation_for: media_id.to_string(),
language: language.to_string(),
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
};
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
let abs_sidecar = data_dir.join(&sidecar_rel);
atomic_write_str(&abs_sidecar, &trans_sidecar.to_string())?;
// Re-index FTS for parent media
fts_index_media(conn, &media)?;
Ok(translation)
}
/// Delete a media translation and its sidecar file.
pub fn delete_media_translation(
conn: &Connection,
data_dir: &Path,
media_id: &str,
language: &str,
) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete translation sidecar file
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
let abs_sidecar = data_dir.join(&sidecar_rel);
if abs_sidecar.exists() {
fs::remove_file(&abs_sidecar)?;
}
// Delete from DB
qmt::delete_media_translation(conn, media_id, language)?;
// Re-index FTS for parent media
fts_index_media(conn, &media)?;
Ok(())
}
/// Rebuild media entries from filesystem. Walk `media/` directory, parse sidecars, upsert into DB.
pub fn rebuild_media_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaRebuildReport> {
let mut report = MediaRebuildReport::default();
let media_dir = data_dir.join("media");
if !media_dir.exists() {
return Ok(report);
}
let mut canonical_sidecars = Vec::new();
let mut translation_sidecars = Vec::new();
for entry in WalkDir::new(&media_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if !file_name.ends_with(".meta") {
continue;
}
// Determine if this is a translation sidecar: {name}.{lang}.meta
// where lang is exactly 2 lowercase letters.
if is_translation_sidecar(file_name) {
translation_sidecars.push(path.to_path_buf());
} else {
canonical_sidecars.push(path.to_path_buf());
}
}
// Process canonical sidecars first
for path in &canonical_sidecars {
match rebuild_canonical_media(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.media_created += 1;
} else {
report.media_updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
// Process translation sidecars
for path in &translation_sidecars {
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.translations_created += 1;
} else {
report.translations_updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
// Re-index FTS for all media in this project
let all_media = qm::list_media_by_project(conn, project_id)?;
for m in &all_media {
fts_index_media(conn, m)?;
}
Ok(report)
}
// --- Internal helpers ---
/// Index a media item in FTS, gathering translation texts.
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
let translation_texts: Vec<String> = translations
.iter()
.map(|t| {
let mut parts = Vec::new();
if let Some(ref title) = t.title {
parts.push(title.clone());
}
if let Some(ref alt) = t.alt {
parts.push(alt.clone());
}
if let Some(ref caption) = t.caption {
parts.push(caption.clone());
}
parts.join(" ")
})
.collect();
let lang = media.language.as_deref().unwrap_or("en");
fts::index_media(
conn,
&media.id,
media.title.as_deref(),
media.alt.as_deref(),
media.caption.as_deref(),
&media.original_name,
&media.tags,
&translation_texts,
lang,
)?;
Ok(())
}
/// Check if a .meta filename is a translation sidecar: `{name}.{lang}.meta`
/// where lang is exactly 2 lowercase ASCII letters.
fn is_translation_sidecar(file_name: &str) -> bool {
// file_name ends with .meta
// Strip .meta suffix, then check if remaining ends with .{2-letter-lang}
let without_meta = match file_name.strip_suffix(".meta") {
Some(s) => s,
None => return false,
};
// Find the last dot in what remains
if let Some(dot_pos) = without_meta.rfind('.') {
let suffix = &without_meta[dot_pos + 1..];
// Must also have another dot before this (the extension of the media file)
// e.g. "foo.jpg.de" -> dot_pos points to the dot before "de"
// "foo.jpg" would be the canonical sidecar (no extra dot+lang)
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) && dot_pos > 0
} else {
false
}
}
/// Rebuild a canonical media from its sidecar file. Returns true if created, false if updated.
fn rebuild_canonical_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
sidecar_path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(sidecar_path)?;
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
// Derive file_path: the sidecar path minus ".meta" suffix, relative to data_dir
let sidecar_rel = sidecar_path
.strip_prefix(data_dir)
.unwrap_or(sidecar_path)
.to_string_lossy()
.to_string();
let file_path = sidecar_rel
.strip_suffix(".meta")
.unwrap_or(&sidecar_rel)
.to_string();
let abs_file = data_dir.join(&file_path);
// Get file size (if file exists)
let file_size = if abs_file.exists() {
fs::metadata(&abs_file)?.len() as i64
} else {
sc.size
};
// Get checksum (if file exists)
let checksum = if abs_file.exists() {
let bytes = fs::read(&abs_file)?;
Some(content_hash(&bytes))
} else {
None
};
// Get dimensions (if file exists and is an image)
let (width, height) = if abs_file.exists() {
image_dimensions(&abs_file)
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
.unwrap_or((sc.width, sc.height))
} else {
(sc.width, sc.height)
};
// Derive filename from file_path
let filename = Path::new(&file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let now = now_unix_ms();
let existing = qm::get_media_by_id(conn, &sc.id);
match existing {
Ok(mut media) => {
// Update existing media
media.original_name = sc.original_name;
media.mime_type = sc.mime_type;
media.size = file_size;
media.width = width;
media.height = height;
media.title = sc.title;
media.alt = sc.alt;
media.caption = sc.caption;
media.author = sc.author;
media.language = sc.language;
media.file_path = file_path;
media.sidecar_path = sidecar_rel;
media.checksum = checksum;
media.tags = sc.tags;
media.updated_at = now;
qm::update_media(conn, &media)?;
Ok(false)
}
Err(_) => {
let media = Media {
id: sc.id,
project_id: project_id.to_string(),
filename,
original_name: sc.original_name,
mime_type: sc.mime_type,
size: file_size,
width,
height,
title: sc.title,
alt: sc.alt,
caption: sc.caption,
author: sc.author,
language: sc.language,
file_path,
sidecar_path: sidecar_rel,
checksum,
tags: sc.tags,
created_at: sc.created_at,
updated_at: now,
};
qm::insert_media(conn, &media)?;
Ok(true)
}
}
}
/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.
fn rebuild_translation_sidecar(
conn: &Connection,
_data_dir: &Path,
project_id: &str,
sidecar_path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(sidecar_path)?;
let sc = read_translation_sidecar(&content).map_err(EngineError::Parse)?;
// Check parent media exists
let _media = qm::get_media_by_id(conn, &sc.translation_for).map_err(|_| {
EngineError::NotFound(format!(
"parent media '{}' not found for translation",
sc.translation_for
))
})?;
let now = now_unix_ms();
let existing =
qmt::get_media_translation_by_media_and_language(conn, &sc.translation_for, &sc.language);
match existing {
Ok(mut t) => {
t.title = sc.title;
t.alt = sc.alt;
t.caption = sc.caption;
t.updated_at = now;
qmt::upsert_media_translation(conn, &t)?;
Ok(false)
}
Err(_) => {
let t = MediaTranslation {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
translation_for: sc.translation_for,
language: sc.language,
title: sc.title,
alt: sc.alt,
caption: sc.caption,
created_at: now,
updated_at: now,
};
qmt::upsert_media_translation(conn, &t)?;
Ok(true)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use image::DynamicImage;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
/// Create a simple 100x80 PNG in the given directory.
fn create_test_png(dir: &Path) -> std::path::PathBuf {
let path = dir.join("test-source.png");
let img = DynamicImage::new_rgb8(100, 80);
img.save(&path).unwrap();
path
}
#[test]
fn import_media_creates_artifacts() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("My Photo"),
Some("A photo"),
None,
Some("Alice"),
Some("en"),
vec!["nature".into()],
)
.unwrap();
// Verify DB entry
let from_db = qm::get_media_by_id(db.conn(), &media.id).unwrap();
assert_eq!(from_db.original_name, "photo.png");
assert_eq!(from_db.title.as_deref(), Some("My Photo"));
assert_eq!(from_db.alt.as_deref(), Some("A photo"));
assert_eq!(from_db.author.as_deref(), Some("Alice"));
assert_eq!(from_db.language.as_deref(), Some("en"));
assert_eq!(from_db.tags, vec!["nature"]);
assert_eq!(from_db.mime_type, "image/png");
assert_eq!(from_db.width, Some(100));
assert_eq!(from_db.height, Some(80));
assert!(from_db.checksum.is_some());
assert!(from_db.size > 0);
// Verify binary file exists
let abs_file = dir.path().join(&from_db.file_path);
assert!(abs_file.exists(), "binary file should exist");
// Verify sidecar exists
let abs_sidecar = dir.path().join(&from_db.sidecar_path);
assert!(abs_sidecar.exists(), "sidecar should exist");
// Verify sidecar content is parseable
let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
let sc = read_sidecar(&sidecar_content).unwrap();
assert_eq!(sc.id, media.id);
assert_eq!(sc.original_name, "photo.png");
// Verify thumbnails exist
let prefix = &media.id[..2];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{ext}", media.id, size.name));
assert!(thumb.exists(), "thumbnail {} should exist", size.name);
}
}
#[test]
fn update_media_rewrites_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("Original Title"),
None,
None,
None,
None,
vec![],
)
.unwrap();
// Read original sidecar
let abs_sidecar = dir.path().join(&media.sidecar_path);
let original_content = fs::read_to_string(&abs_sidecar).unwrap();
// Update
let updated = update_media(
db.conn(),
dir.path(),
&media.id,
Some(Some("New Title")),
Some(Some("New alt")),
None,
None,
None,
Some(vec!["updated-tag".into()]),
)
.unwrap();
assert_eq!(updated.title.as_deref(), Some("New Title"));
assert_eq!(updated.alt.as_deref(), Some("New alt"));
assert_eq!(updated.tags, vec!["updated-tag"]);
// Verify sidecar was rewritten
let new_content = fs::read_to_string(&abs_sidecar).unwrap();
assert_ne!(original_content, new_content);
let sc = read_sidecar(&new_content).unwrap();
assert_eq!(sc.title.as_deref(), Some("New Title"));
assert_eq!(sc.tags, vec!["updated-tag"]);
}
#[test]
fn delete_media_removes_everything() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let abs_file = dir.path().join(&media.file_path);
let abs_sidecar = dir.path().join(&media.sidecar_path);
assert!(abs_file.exists());
assert!(abs_sidecar.exists());
// Delete
delete_media(db.conn(), dir.path(), &media.id).unwrap();
// Verify file gone
assert!(!abs_file.exists(), "binary file should be removed");
// Verify sidecar gone
assert!(!abs_sidecar.exists(), "sidecar should be removed");
// Verify DB entry gone
assert!(qm::get_media_by_id(db.conn(), &media.id).is_err());
// Verify thumbnails gone
let prefix = &media.id[..2];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{ext}", media.id, size.name));
assert!(!thumb.exists(), "thumbnail {} should be removed", size.name);
}
}
#[test]
fn upsert_media_translation_writes_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
// Create translation
let t = upsert_media_translation(
db.conn(),
dir.path(),
&media.id,
"de",
Some("Deutsches Foto"),
Some("Ein Foto"),
None,
)
.unwrap();
assert_eq!(t.language, "de");
assert_eq!(t.title.as_deref(), Some("Deutsches Foto"));
// Verify translation sidecar file
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
let abs_sidecar = dir.path().join(&sidecar_rel);
assert!(abs_sidecar.exists(), "translation sidecar should exist");
let content = fs::read_to_string(&abs_sidecar).unwrap();
let sc = read_translation_sidecar(&content).unwrap();
assert_eq!(sc.language, "de");
assert_eq!(sc.title.as_deref(), Some("Deutsches Foto"));
}
#[test]
fn delete_media_translation_removes_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
// Create translation
upsert_media_translation(
db.conn(),
dir.path(),
&media.id,
"de",
Some("Titel"),
None,
None,
)
.unwrap();
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
let abs_sidecar = dir.path().join(&sidecar_rel);
assert!(abs_sidecar.exists());
// Delete translation
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
// Sidecar should be gone
assert!(!abs_sidecar.exists(), "translation sidecar should be removed");
// DB entry should be gone
assert!(
qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_err()
);
}
#[test]
fn rebuild_media_from_filesystem_test() {
let (db, dir) = setup();
// Create a fake media file and its sidecar manually
let media_subdir = dir.path().join("media").join("2024").join("01");
fs::create_dir_all(&media_subdir).unwrap();
// Write a dummy binary file
let media_file = media_subdir.join("abcdef12-test-uuid.png");
fs::write(&media_file, b"fake-png-data").unwrap();
// Write a canonical sidecar
let sidecar_content = "\
---
id: abcdef12-test-uuid
originalName: \"photo.png\"
mimeType: image/png
size: 13
title: \"Rebuild Test\"
alt: \"An image\"
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: [\"test\"]
---";
fs::write(media_subdir.join("abcdef12-test-uuid.png.meta"), sidecar_content).unwrap();
// Write a translation sidecar
let trans_sidecar_content = "\
---
translationFor: abcdef12-test-uuid
language: de
title: \"Wiederherstellungstest\"
alt: \"Ein Bild\"
---";
fs::write(
media_subdir.join("abcdef12-test-uuid.png.de.meta"),
trans_sidecar_content,
)
.unwrap();
// Run rebuild
let report = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.media_created, 1);
assert_eq!(report.translations_created, 1);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Verify media in DB
let media = qm::get_media_by_id(db.conn(), "abcdef12-test-uuid").unwrap();
assert_eq!(media.title.as_deref(), Some("Rebuild Test"));
assert_eq!(media.tags, vec!["test"]);
assert_eq!(media.original_name, "photo.png");
// Verify translation in DB
let trans =
qmt::get_media_translation_by_media_and_language(db.conn(), "abcdef12-test-uuid", "de")
.unwrap();
assert_eq!(trans.title.as_deref(), Some("Wiederherstellungstest"));
// Run rebuild again - should update, not create
let report2 = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report2.media_created, 0);
assert_eq!(report2.media_updated, 1);
assert_eq!(report2.translations_created, 0);
assert_eq!(report2.translations_updated, 1);
}
#[test]
fn is_translation_sidecar_detection() {
assert!(is_translation_sidecar("photo.jpg.de.meta"));
assert!(is_translation_sidecar("photo.jpg.fr.meta"));
assert!(is_translation_sidecar("uuid.png.en.meta"));
assert!(!is_translation_sidecar("photo.jpg.meta"));
assert!(!is_translation_sidecar("photo.meta"));
assert!(!is_translation_sidecar("photo.jpg.123.meta"));
assert!(!is_translation_sidecar("photo.jpg.DEU.meta"));
}
}

View File

@@ -0,0 +1,318 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::engine::EngineResult;
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
use crate::model::PublishingPreferences;
use crate::util::atomic_write_str;
// ── project.json ────────────────────────────────────────────────────
/// Read and parse meta/project.json.
pub fn read_project_json(data_dir: &Path) -> EngineResult<ProjectMetadata> {
let path = data_dir.join("meta").join("project.json");
let content = fs::read_to_string(&path)?;
let meta: ProjectMetadata = serde_json::from_str(&content)?;
Ok(meta)
}
/// Serialize with pretty JSON, atomic write to meta/project.json.
pub fn write_project_json(data_dir: &Path, meta: &ProjectMetadata) -> EngineResult<()> {
let path = data_dir.join("meta").join("project.json");
let json = serde_json::to_string_pretty(meta)?;
atomic_write_str(&path, &json)?;
Ok(())
}
// ── categories.json ─────────────────────────────────────────────────
/// Read meta/categories.json as a sorted array of strings.
pub fn read_categories_json(data_dir: &Path) -> EngineResult<Vec<String>> {
let path = data_dir.join("meta").join("categories.json");
let content = fs::read_to_string(&path)?;
let cats: Vec<String> = serde_json::from_str(&content)?;
Ok(cats)
}
/// Sort categories, then atomic write to meta/categories.json.
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
let mut sorted = categories.to_vec();
sorted.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
let path = data_dir.join("meta").join("categories.json");
let json = serde_json::to_string_pretty(&sorted)?;
atomic_write_str(&path, &json)?;
Ok(())
}
// ── category-meta.json ──────────────────────────────────────────────
/// Read meta/category-meta.json.
pub fn read_category_meta_json(
data_dir: &Path,
) -> EngineResult<HashMap<String, CategorySettings>> {
let path = data_dir.join("meta").join("category-meta.json");
let content = fs::read_to_string(&path)?;
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
Ok(meta)
}
/// Atomic write to meta/category-meta.json.
pub fn write_category_meta_json(
data_dir: &Path,
meta: &HashMap<String, CategorySettings>,
) -> EngineResult<()> {
let path = data_dir.join("meta").join("category-meta.json");
let json = serde_json::to_string_pretty(meta)?;
atomic_write_str(&path, &json)?;
Ok(())
}
// ── publishing.json ─────────────────────────────────────────────────
/// Read meta/publishing.json.
pub fn read_publishing_json(data_dir: &Path) -> EngineResult<PublishingPreferences> {
let path = data_dir.join("meta").join("publishing.json");
let content = fs::read_to_string(&path)?;
let prefs: PublishingPreferences = serde_json::from_str(&content)?;
Ok(prefs)
}
/// Atomic write to meta/publishing.json.
pub fn write_publishing_json(
data_dir: &Path,
prefs: &PublishingPreferences,
) -> EngineResult<()> {
let path = data_dir.join("meta").join("publishing.json");
let json = serde_json::to_string_pretty(prefs)?;
atomic_write_str(&path, &json)?;
Ok(())
}
// ── tags.json ───────────────────────────────────────────────────────
/// Read meta/tags.json.
pub fn read_tags_json(data_dir: &Path) -> EngineResult<Vec<TagEntry>> {
let path = data_dir.join("meta").join("tags.json");
let content = fs::read_to_string(&path)?;
let tags: Vec<TagEntry> = serde_json::from_str(&content)?;
Ok(tags)
}
/// Sort by name case-insensitive, then atomic write to meta/tags.json.
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
let mut sorted = tags.to_vec();
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let path = data_dir.join("meta").join("tags.json");
let json = serde_json::to_string_pretty(&sorted)?;
atomic_write_str(&path, &json)?;
Ok(())
}
// ── category helpers ────────────────────────────────────────────────
/// Add a category to categories.json and initialize it in category-meta.json.
pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> {
let mut cats = read_categories_json(data_dir)?;
if !cats.iter().any(|c| c.eq_ignore_ascii_case(category)) {
cats.push(category.to_string());
write_categories_json(data_dir, &cats)?;
}
let mut meta = read_category_meta_json(data_dir)?;
if !meta.contains_key(category) {
meta.insert(
category.to_string(),
CategorySettings {
render_in_lists: true,
show_title: true,
title: None,
post_template_slug: None,
list_template_slug: None,
},
);
write_category_meta_json(data_dir, &meta)?;
}
Ok(())
}
/// Remove a category from both categories.json and category-meta.json.
pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
let mut cats = read_categories_json(data_dir)?;
cats.retain(|c| !c.eq_ignore_ascii_case(category));
write_categories_json(data_dir, &cats)?;
let mut meta = read_category_meta_json(data_dir)?;
meta.remove(category);
write_category_meta_json(data_dir, &meta)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::SshMode;
use tempfile::TempDir;
fn setup() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
dir
}
// ── project.json ────────────────────────────────────────────────
#[test]
fn project_json_roundtrip() {
let dir = setup();
let meta = ProjectMetadata {
name: "Test".into(),
description: Some("A blog".into()),
public_url: None,
main_language: Some("en".into()),
default_author: None,
max_posts_per_page: 25,
blogmark_category: None,
pico_theme: None,
python_runtime_mode: None,
semantic_similarity_enabled: false,
blog_languages: vec!["en".into()],
};
write_project_json(dir.path(), &meta).unwrap();
let read = read_project_json(dir.path()).unwrap();
assert_eq!(read.name, "Test");
assert_eq!(read.max_posts_per_page, 25);
assert_eq!(read.description.as_deref(), Some("A blog"));
}
// ── categories.json ─────────────────────────────────────────────
#[test]
fn categories_json_sorted() {
let dir = setup();
let cats = vec!["picture".into(), "article".into(), "aside".into()];
write_categories_json(dir.path(), &cats).unwrap();
let read = read_categories_json(dir.path()).unwrap();
assert_eq!(read, vec!["article", "aside", "picture"]);
}
// ── category-meta.json ──────────────────────────────────────────
#[test]
fn category_meta_json_roundtrip() {
let dir = setup();
let mut meta = HashMap::new();
meta.insert(
"article".to_string(),
CategorySettings {
render_in_lists: true,
show_title: true,
title: None,
post_template_slug: None,
list_template_slug: None,
},
);
write_category_meta_json(dir.path(), &meta).unwrap();
let read = read_category_meta_json(dir.path()).unwrap();
assert!(read.contains_key("article"));
assert!(read["article"].render_in_lists);
}
// ── publishing.json ─────────────────────────────────────────────
#[test]
fn publishing_json_roundtrip() {
let dir = setup();
let prefs = PublishingPreferences {
ssh_host: Some("example.com".into()),
ssh_user: Some("deploy".into()),
ssh_remote_path: Some("/var/www".into()),
ssh_mode: SshMode::Rsync,
};
write_publishing_json(dir.path(), &prefs).unwrap();
let read = read_publishing_json(dir.path()).unwrap();
assert_eq!(read.ssh_host.as_deref(), Some("example.com"));
assert_eq!(read.ssh_mode, SshMode::Rsync);
}
// ── tags.json ───────────────────────────────────────────────────
#[test]
fn tags_json_sorted_case_insensitive() {
let dir = setup();
let tags = vec![
TagEntry {
name: "Zebra".into(),
color: None,
post_template_slug: None,
},
TagEntry {
name: "alpha".into(),
color: Some("#00ff00".into()),
post_template_slug: None,
},
];
write_tags_json(dir.path(), &tags).unwrap();
let read = read_tags_json(dir.path()).unwrap();
assert_eq!(read[0].name, "alpha");
assert_eq!(read[1].name, "Zebra");
}
// ── add / remove category ───────────────────────────────────────
#[test]
fn add_category_creates_entries() {
let dir = setup();
// Seed files
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
add_category(dir.path(), "page").unwrap();
let cats = read_categories_json(dir.path()).unwrap();
assert!(cats.contains(&"page".to_string()));
let meta = read_category_meta_json(dir.path()).unwrap();
assert!(meta.contains_key("page"));
}
#[test]
fn add_category_idempotent() {
let dir = setup();
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
add_category(dir.path(), "article").unwrap();
let cats = read_categories_json(dir.path()).unwrap();
assert_eq!(cats.iter().filter(|c| *c == "article").count(), 1);
}
#[test]
fn remove_category_deletes_entries() {
let dir = setup();
write_categories_json(dir.path(), &vec!["article".into(), "page".into()]).unwrap();
let mut meta = HashMap::new();
meta.insert(
"article".to_string(),
CategorySettings {
render_in_lists: true,
show_title: true,
title: None,
post_template_slug: None,
list_template_slug: None,
},
);
write_category_meta_json(dir.path(), &meta).unwrap();
remove_category(dir.path(), "article").unwrap();
let cats = read_categories_json(dir.path()).unwrap();
assert!(!cats.contains(&"article".to_string()));
assert!(cats.contains(&"page".to_string()));
let meta = read_category_meta_json(dir.path()).unwrap();
assert!(!meta.contains_key("article"));
}
}

View File

@@ -0,0 +1,980 @@
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use walkdir::WalkDir;
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
use crate::db::queries::media as qm;
use crate::db::queries::post as qp;
use crate::db::queries::post_translation as qt;
use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template};
use crate::util::frontmatter::{read_post_file, read_script_file, read_template_file, read_translation_file};
use crate::util::sidecar::read_sidecar;
/// A single field difference.
#[derive(Debug, Clone)]
pub struct DiffField {
pub field_name: String,
pub db_value: String,
pub file_value: String,
}
/// Diff for a single entity.
#[derive(Debug, Clone)]
pub struct EntityDiff {
pub entity_type: String,
pub entity_id: String,
pub file_path: String,
pub fields: Vec<DiffField>,
}
/// An orphan file (exists on disk but not in DB, or vice versa).
#[derive(Debug, Clone)]
pub struct OrphanFile {
pub file_path: String,
pub reason: String,
}
/// Complete diff report.
#[derive(Debug, Default)]
pub struct DiffReport {
pub diffs: Vec<EntityDiff>,
pub orphans: Vec<OrphanFile>,
pub errors: Vec<String>,
}
/// Compare DB state vs filesystem files and report all differences.
///
/// This function does NOT modify anything -- it only reports differences.
pub fn compute_metadata_diff(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<DiffReport> {
let mut report = DiffReport::default();
// 1. Diff posts
let posts = qp::list_posts_by_project(conn, project_id)?;
for post in &posts {
if post.file_path.is_empty() {
continue;
}
match diff_post(data_dir, post) {
Ok(Some(d)) => report.diffs.push(d),
Ok(None) => {}
Err(e) => report.errors.push(format!("post {}: {e}", post.id)),
}
}
// 2. Diff translations
for post in &posts {
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
for t in &translations {
if t.file_path.is_empty() {
continue;
}
match diff_translation(data_dir, t) {
Ok(Some(d)) => report.diffs.push(d),
Ok(None) => {}
Err(e) => report.errors.push(format!("translation {}: {e}", t.id)),
}
}
}
// 3. Diff media
let media_items = qm::list_media_by_project(conn, project_id)?;
for m in &media_items {
if m.sidecar_path.is_empty() {
continue;
}
match diff_media(data_dir, m) {
Ok(Some(d)) => report.diffs.push(d),
Ok(None) => {}
Err(e) => report.errors.push(format!("media {}: {e}", m.id)),
}
}
// 4. Diff templates
let templates = qtpl::list_templates_by_project(conn, project_id)?;
for t in &templates {
if t.file_path.is_empty() {
continue;
}
match diff_template(data_dir, t) {
Ok(Some(d)) => report.diffs.push(d),
Ok(None) => {}
Err(e) => report.errors.push(format!("template {}: {e}", t.id)),
}
}
// 5. Diff scripts
let scripts = qs::list_scripts_by_project(conn, project_id)?;
for s in &scripts {
if s.file_path.is_empty() {
continue;
}
match diff_script(data_dir, s) {
Ok(Some(d)) => report.diffs.push(d),
Ok(None) => {}
Err(e) => report.errors.push(format!("script {}: {e}", s.id)),
}
}
// 6. Detect orphans
let orphans = detect_orphan_files(conn, data_dir, project_id)?;
report.orphans = orphans;
Ok(report)
}
// --- Internal helpers ---
fn opt_to_str(opt: &Option<String>) -> String {
opt.clone().unwrap_or_default()
}
fn bool_to_str(b: bool) -> String {
if b { "true".to_string() } else { "false".to_string() }
}
fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
}
fn status_to_str(s: &PostStatus) -> &'static str {
match s {
PostStatus::Draft => "draft",
PostStatus::Published => "published",
PostStatus::Archived => "archived",
}
}
fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
if db_val != file_val {
fields.push(DiffField {
field_name: name.to_string(),
db_value: db_val.to_string(),
file_value: file_val.to_string(),
});
}
}
fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&post.file_path);
if !abs_path.exists() {
// Will be caught by orphan detection
return Ok(None);
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
let mut fields = Vec::new();
compare_field(&mut fields, "title", &post.title, &fm.title);
compare_field(&mut fields, "slug", &post.slug, &fm.slug);
compare_field(
&mut fields,
"status",
status_to_str(&post.status),
&fm.status,
);
compare_field(
&mut fields,
"tags",
&tags_to_json(&post.tags),
&tags_to_json(&fm.tags),
);
compare_field(
&mut fields,
"categories",
&tags_to_json(&post.categories),
&tags_to_json(&fm.categories),
);
compare_field(
&mut fields,
"excerpt",
&opt_to_str(&post.excerpt),
&opt_to_str(&fm.excerpt),
);
compare_field(
&mut fields,
"author",
&opt_to_str(&post.author),
&opt_to_str(&fm.author),
);
compare_field(
&mut fields,
"language",
&opt_to_str(&post.language),
&opt_to_str(&fm.language),
);
compare_field(
&mut fields,
"doNotTranslate",
&bool_to_str(post.do_not_translate),
&bool_to_str(fm.do_not_translate),
);
compare_field(
&mut fields,
"templateSlug",
&opt_to_str(&post.template_slug),
&opt_to_str(&fm.template_slug),
);
compare_field(
&mut fields,
"createdAt",
&post.created_at.to_string(),
&fm.created_at.to_string(),
);
compare_field(
&mut fields,
"updatedAt",
&post.updated_at.to_string(),
&fm.updated_at.to_string(),
);
compare_field(
&mut fields,
"publishedAt",
&post.published_at.map(|v| v.to_string()).unwrap_or_default(),
&fm.published_at.map(|v| v.to_string()).unwrap_or_default(),
);
if fields.is_empty() {
Ok(None)
} else {
Ok(Some(EntityDiff {
entity_type: "post".to_string(),
entity_id: post.id.clone(),
file_path: post.file_path.clone(),
fields,
}))
}
}
fn diff_translation(
data_dir: &Path,
t: &PostTranslation,
) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&t.file_path);
if !abs_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
let mut fields = Vec::new();
compare_field(&mut fields, "translationFor", &t.translation_for, &fm.translation_for);
compare_field(&mut fields, "language", &t.language, &fm.language);
compare_field(&mut fields, "title", &t.title, &fm.title);
compare_field(
&mut fields,
"excerpt",
&opt_to_str(&t.excerpt),
&opt_to_str(&fm.excerpt),
);
if fields.is_empty() {
Ok(None)
} else {
Ok(Some(EntityDiff {
entity_type: "translation".to_string(),
entity_id: t.id.clone(),
file_path: t.file_path.clone(),
fields,
}))
}
}
fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&media.sidecar_path);
if !abs_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&abs_path)?;
let sc = read_sidecar(&content).map_err(|e| EngineError::Parse(e))?;
let mut fields = Vec::new();
compare_field(
&mut fields,
"title",
&opt_to_str(&media.title),
&opt_to_str(&sc.title),
);
compare_field(
&mut fields,
"alt",
&opt_to_str(&media.alt),
&opt_to_str(&sc.alt),
);
compare_field(
&mut fields,
"caption",
&opt_to_str(&media.caption),
&opt_to_str(&sc.caption),
);
compare_field(
&mut fields,
"author",
&opt_to_str(&media.author),
&opt_to_str(&sc.author),
);
compare_field(
&mut fields,
"tags",
&tags_to_json(&media.tags),
&tags_to_json(&sc.tags),
);
compare_field(
&mut fields,
"language",
&opt_to_str(&media.language),
&opt_to_str(&sc.language),
);
if fields.is_empty() {
Ok(None)
} else {
Ok(Some(EntityDiff {
entity_type: "media".to_string(),
entity_id: media.id.clone(),
file_path: media.sidecar_path.clone(),
fields,
}))
}
}
fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&tpl.file_path);
if !abs_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_template_file(&content).map_err(|e| EngineError::Parse(e))?;
let mut fields = Vec::new();
compare_field(&mut fields, "title", &tpl.title, &fm.title);
compare_field(
&mut fields,
"kind",
template_kind_to_str(&tpl.kind),
&fm.kind,
);
compare_field(
&mut fields,
"enabled",
&bool_to_str(tpl.enabled),
&bool_to_str(fm.enabled),
);
compare_field(
&mut fields,
"version",
&tpl.version.to_string(),
&fm.version.to_string(),
);
if fields.is_empty() {
Ok(None)
} else {
Ok(Some(EntityDiff {
entity_type: "template".to_string(),
entity_id: tpl.id.clone(),
file_path: tpl.file_path.clone(),
fields,
}))
}
}
fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&script.file_path);
if !abs_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_script_file(&content).map_err(|e| EngineError::Parse(e))?;
let mut fields = Vec::new();
compare_field(&mut fields, "title", &script.title, &fm.title);
compare_field(
&mut fields,
"kind",
script_kind_to_str(&script.kind),
&fm.kind,
);
compare_field(&mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint);
compare_field(
&mut fields,
"enabled",
&bool_to_str(script.enabled),
&bool_to_str(fm.enabled),
);
compare_field(
&mut fields,
"version",
&script.version.to_string(),
&fm.version.to_string(),
);
if fields.is_empty() {
Ok(None)
} else {
Ok(Some(EntityDiff {
entity_type: "script".to_string(),
entity_id: script.id.clone(),
file_path: script.file_path.clone(),
fields,
}))
}
}
fn detect_orphan_files(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<Vec<OrphanFile>> {
let mut orphans = Vec::new();
// Collect all known file paths from DB
let mut db_file_paths: HashSet<String> = HashSet::new();
let posts = qp::list_posts_by_project(conn, project_id)?;
for post in &posts {
if !post.file_path.is_empty() {
db_file_paths.insert(post.file_path.clone());
}
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
for t in &translations {
if !t.file_path.is_empty() {
db_file_paths.insert(t.file_path.clone());
}
}
}
let media_items = qm::list_media_by_project(conn, project_id)?;
for m in &media_items {
if !m.file_path.is_empty() {
db_file_paths.insert(m.file_path.clone());
}
if !m.sidecar_path.is_empty() {
db_file_paths.insert(m.sidecar_path.clone());
}
}
let templates = qtpl::list_templates_by_project(conn, project_id)?;
for t in &templates {
if !t.file_path.is_empty() {
db_file_paths.insert(t.file_path.clone());
}
}
let scripts = qs::list_scripts_by_project(conn, project_id)?;
for s in &scripts {
if !s.file_path.is_empty() {
db_file_paths.insert(s.file_path.clone());
}
}
// Walk filesystem directories and find orphans
let dirs_to_walk = ["posts", "media", "templates", "scripts"];
for dir_name in &dirs_to_walk {
let dir_path = data_dir.join(dir_name);
if !dir_path.exists() {
continue;
}
for entry in WalkDir::new(&dir_path)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
// Compute relative path from data_dir
let rel_path = path
.strip_prefix(data_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
// Skip non-content files (thumbnails, etc.)
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_content_file = match *dir_name {
"posts" => ext == "md",
"media" => ext == "meta",
"templates" => ext == "liquid",
"scripts" => ext == "lua" || ext == "py",
_ => false,
};
if !is_content_file {
continue;
}
if !db_file_paths.contains(&rel_path) {
orphans.push(OrphanFile {
file_path: rel_path,
reason: "file_without_db_entry".to_string(),
});
}
}
}
// Check DB entries whose file_path doesn't exist on disk
for post in &posts {
if !post.file_path.is_empty() {
let abs = data_dir.join(&post.file_path);
if !abs.exists() {
orphans.push(OrphanFile {
file_path: post.file_path.clone(),
reason: "db_entry_without_file".to_string(),
});
}
}
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
for t in &translations {
if !t.file_path.is_empty() {
let abs = data_dir.join(&t.file_path);
if !abs.exists() {
orphans.push(OrphanFile {
file_path: t.file_path.clone(),
reason: "db_entry_without_file".to_string(),
});
}
}
}
}
for m in &media_items {
if !m.sidecar_path.is_empty() {
let abs = data_dir.join(&m.sidecar_path);
if !abs.exists() {
orphans.push(OrphanFile {
file_path: m.sidecar_path.clone(),
reason: "db_entry_without_file".to_string(),
});
}
}
}
for t in &templates {
if !t.file_path.is_empty() {
let abs = data_dir.join(&t.file_path);
if !abs.exists() {
orphans.push(OrphanFile {
file_path: t.file_path.clone(),
reason: "db_entry_without_file".to_string(),
});
}
}
}
for s in &scripts {
if !s.file_path.is_empty() {
let abs = data_dir.join(&s.file_path);
if !abs.exists() {
orphans.push(OrphanFile {
file_path: s.file_path.clone(),
reason: "db_entry_without_file".to_string(),
});
}
}
}
Ok(orphans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::media::insert_media;
use crate::db::queries::post::insert_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script::insert_script;
use crate::db::queries::template::insert_template;
use crate::db::Database;
use crate::engine::post::{create_post, publish_post};
use crate::model::{
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
TemplateStatus,
};
use crate::util::frontmatter::{
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
};
use crate::util::sidecar::MediaSidecar;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn no_diffs_for_clean_state() {
let (db, dir) = setup();
// Create and publish a post via the engine
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Clean Post",
Some("body text"),
vec!["rust".into()],
vec!["tech".into()],
Some("Alice"),
Some("en"),
None,
)
.unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
// Published post: file was just written by publish, DB and file should match.
// The only expected diff is updatedAt because publish sets it to now() in the DB
// but the file was written with that same now(). They should match.
// However, publish_post updates updatedAt multiple times, so the DB value may
// differ from the file. Filter to only non-updatedAt diffs.
let non_time_diffs: Vec<_> = report
.diffs
.iter()
.filter(|d| {
d.fields
.iter()
.any(|f| f.field_name != "updatedAt")
})
.collect();
assert!(
non_time_diffs.is_empty(),
"expected no non-timestamp diffs, got: {non_time_diffs:?}"
);
assert!(report.orphans.is_empty(), "orphans: {:?}", report.orphans);
}
#[test]
fn detects_title_drift_in_post() {
let (db, dir) = setup();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Original Title",
Some("body"),
vec!["rust".into()],
vec!["tech".into()],
None,
None,
None,
)
.unwrap();
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
// Manually edit the .md file to change the title
let abs_path = dir.path().join(&published.file_path);
let content = fs::read_to_string(&abs_path).unwrap();
let modified = content.replace("Original Title", "Tampered Title");
fs::write(&abs_path, modified).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
// Should detect title drift
let title_diffs: Vec<_> = report
.diffs
.iter()
.filter(|d| d.entity_type == "post")
.flat_map(|d| d.fields.iter())
.filter(|f| f.field_name == "title")
.collect();
assert_eq!(title_diffs.len(), 1);
assert_eq!(title_diffs[0].db_value, "Original Title");
assert_eq!(title_diffs[0].file_value, "Tampered Title");
}
#[test]
fn detects_media_sidecar_drift() {
let (db, dir) = setup();
// Create media in DB with sidecar
let media = Media {
id: "m1".to_string(),
project_id: "p1".to_string(),
filename: "m1.jpg".to_string(),
original_name: "photo.jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 50000,
width: Some(800),
height: Some(600),
title: Some("My Photo".to_string()),
alt: Some("A nice photo".to_string()),
caption: None,
author: None,
language: None,
file_path: "media/2024/01/m1.jpg".to_string(),
sidecar_path: "media/2024/01/m1.jpg.meta".to_string(),
checksum: None,
tags: vec![],
created_at: 1000,
updated_at: 2000,
};
insert_media(db.conn(), &media).unwrap();
// Write sidecar with matching data initially, then change alt
let sidecar = MediaSidecar {
id: "m1".to_string(),
original_name: "photo.jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 50000,
width: Some(800),
height: Some(600),
title: Some("My Photo".to_string()),
alt: Some("TAMPERED ALT".to_string()),
caption: None,
author: None,
language: None,
created_at: 1000,
updated_at: 2000,
tags: vec![],
linked_post_ids: vec![],
};
let sidecar_dir = dir.path().join("media/2024/01");
fs::create_dir_all(&sidecar_dir).unwrap();
fs::write(sidecar_dir.join("m1.jpg.meta"), sidecar.to_string()).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let alt_diffs: Vec<_> = report
.diffs
.iter()
.filter(|d| d.entity_type == "media")
.flat_map(|d| d.fields.iter())
.filter(|f| f.field_name == "alt")
.collect();
assert_eq!(alt_diffs.len(), 1);
assert_eq!(alt_diffs[0].db_value, "A nice photo");
assert_eq!(alt_diffs[0].file_value, "TAMPERED ALT");
}
#[test]
fn detects_orphan_file() {
let (db, dir) = setup();
// Create a .md file in posts/ that is not in the DB
let posts_dir = dir.path().join("posts/2024/01");
fs::create_dir_all(&posts_dir).unwrap();
fs::write(posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n").unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let orphan_files: Vec<_> = report
.orphans
.iter()
.filter(|o| o.reason == "file_without_db_entry")
.collect();
assert!(
!orphan_files.is_empty(),
"expected at least one orphan file"
);
assert!(
orphan_files.iter().any(|o| o.file_path.contains("orphan.md")),
"expected orphan.md in orphans, got: {orphan_files:?}"
);
}
#[test]
fn detects_db_without_file() {
let (db, dir) = setup();
// Insert post in DB with file_path pointing to a non-existent file
let post = Post {
id: "ghost-post".to_string(),
project_id: "p1".to_string(),
title: "Ghost".to_string(),
slug: "ghost".to_string(),
excerpt: None,
content: None,
status: PostStatus::Published,
author: None,
language: None,
do_not_translate: false,
template_slug: None,
file_path: "posts/2024/01/ghost.md".to_string(),
checksum: None,
tags: vec![],
categories: vec![],
published_title: None,
published_content: None,
published_tags: None,
published_categories: None,
published_excerpt: None,
created_at: 1000,
updated_at: 2000,
published_at: Some(3000),
};
insert_post(db.conn(), &post).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let db_orphans: Vec<_> = report
.orphans
.iter()
.filter(|o| o.reason == "db_entry_without_file")
.collect();
assert!(
!db_orphans.is_empty(),
"expected at least one db_entry_without_file orphan"
);
assert!(
db_orphans.iter().any(|o| o.file_path.contains("ghost.md")),
"expected ghost.md in orphans, got: {db_orphans:?}"
);
}
#[test]
fn detects_template_drift() {
let (db, dir) = setup();
// Insert template in DB
let tpl = Template {
id: "tpl1".to_string(),
project_id: "p1".to_string(),
slug: "my-template".to_string(),
title: "My Template".to_string(),
kind: TemplateKind::Post,
enabled: true,
version: 1,
file_path: "templates/my-template.liquid".to_string(),
status: TemplateStatus::Published,
content: None,
created_at: 1000,
updated_at: 2000,
};
insert_template(db.conn(), &tpl).unwrap();
// Write template file with different title
let fm = TemplateFrontmatter {
id: "tpl1".to_string(),
project_id: Some("p1".to_string()),
slug: "my-template".to_string(),
title: "CHANGED Template Title".to_string(),
kind: "post".to_string(),
enabled: true,
version: 1,
created_at: 1000,
updated_at: 2000,
};
let file_content = write_template_file(&fm, "<div>body</div>");
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
fs::write(tpl_dir.join("my-template.liquid"), file_content).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let title_diffs: Vec<_> = report
.diffs
.iter()
.filter(|d| d.entity_type == "template")
.flat_map(|d| d.fields.iter())
.filter(|f| f.field_name == "title")
.collect();
assert_eq!(title_diffs.len(), 1);
assert_eq!(title_diffs[0].db_value, "My Template");
assert_eq!(title_diffs[0].file_value, "CHANGED Template Title");
}
#[test]
fn detects_script_drift() {
let (db, dir) = setup();
// Insert script in DB
let script = Script {
id: "s1".to_string(),
project_id: "p1".to_string(),
slug: "my-script".to_string(),
title: "My Script".to_string(),
kind: ScriptKind::Utility,
entrypoint: "main".to_string(),
enabled: true,
version: 1,
file_path: "scripts/my-script.lua".to_string(),
status: ScriptStatus::Published,
content: None,
created_at: 1000,
updated_at: 2000,
};
insert_script(db.conn(), &script).unwrap();
// Write script file with different title and version
let fm = ScriptFrontmatter {
id: "s1".to_string(),
project_id: Some("p1".to_string()),
slug: "my-script".to_string(),
title: "CHANGED Script Title".to_string(),
kind: "utility".to_string(),
entrypoint: "main".to_string(),
enabled: true,
version: 5,
created_at: 1000,
updated_at: 2000,
};
let file_content = write_script_file(&fm, "-- lua code\nreturn 1");
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(scripts_dir.join("my-script.lua"), file_content).unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
let script_diffs: Vec<_> = report
.diffs
.iter()
.filter(|d| d.entity_type == "script")
.collect();
assert!(!script_diffs.is_empty(), "expected script diffs");
let title_diffs: Vec<_> = script_diffs
.iter()
.flat_map(|d| d.fields.iter())
.filter(|f| f.field_name == "title")
.collect();
assert_eq!(title_diffs.len(), 1);
assert_eq!(title_diffs[0].db_value, "My Script");
assert_eq!(title_diffs[0].file_value, "CHANGED Script Title");
let version_diffs: Vec<_> = script_diffs
.iter()
.flat_map(|d| d.fields.iter())
.filter(|f| f.field_name == "version")
.collect();
assert_eq!(version_diffs.len(), 1);
assert_eq!(version_diffs[0].db_value, "1");
assert_eq!(version_diffs[0].file_value, "5");
}
}

View File

@@ -1 +1,16 @@
// Engine modules — stubs for M0, implemented in M1. pub mod error;
pub mod context;
pub mod project;
pub mod meta;
pub mod tag;
pub mod post;
pub mod media;
pub mod post_media;
pub mod template_rebuild;
pub mod script_rebuild;
pub mod task;
pub mod metadata_diff;
pub mod rebuild;
pub use error::{EngineError, EngineResult};
pub use context::EngineContext;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
use std::path::Path;
use rusqlite::Connection;
use uuid::Uuid;
use crate::db::queries::media as qm;
use crate::db::queries::post_media as qpm;
use crate::engine::EngineResult;
use crate::model::PostMedia;
use crate::util::sidecar::MediaSidecar;
use crate::util::{atomic_write_str, now_unix_ms};
/// Link a media item to a post and sync the media sidecar.
pub fn link_media_to_post(
conn: &Connection,
data_dir: &Path,
project_id: &str,
post_id: &str,
media_id: &str,
sort_order: i32,
) -> EngineResult<PostMedia> {
let id = Uuid::new_v4().to_string();
let now = now_unix_ms();
let pm = PostMedia {
id,
project_id: project_id.to_string(),
post_id: post_id.to_string(),
media_id: media_id.to_string(),
sort_order,
created_at: now,
};
qpm::link_media(conn, &pm)?;
sync_sidecar_linked_post_ids(conn, data_dir, media_id)?;
Ok(pm)
}
/// Unlink a media item from a post and sync the media sidecar.
pub fn unlink_media_from_post(
conn: &Connection,
data_dir: &Path,
post_id: &str,
media_id: &str,
) -> EngineResult<()> {
qpm::unlink_media(conn, post_id, media_id)?;
sync_sidecar_linked_post_ids(conn, data_dir, media_id)?;
Ok(())
}
/// Reorder media items for a post. `media_ids` contains the new ordering;
/// each entry gets sort_order = its index.
pub fn reorder_post_media(
conn: &Connection,
post_id: &str,
media_ids: &[String],
) -> EngineResult<()> {
for (i, media_id) in media_ids.iter().enumerate() {
qpm::update_sort_order(conn, post_id, media_id, i as i32)?;
}
Ok(())
}
/// Rebuild the media sidecar file so that `linkedPostIds` reflects the current
/// set of posts linked to this media item.
fn sync_sidecar_linked_post_ids(
conn: &Connection,
data_dir: &Path,
media_id: &str,
) -> EngineResult<()> {
let links = qpm::list_post_media_by_media(conn, media_id)?;
let post_ids: Vec<String> = links.iter().map(|pm| pm.post_id.clone()).collect();
let media = qm::get_media_by_id(conn, media_id)?;
let sidecar = MediaSidecar::from_media(&media, &post_ids);
let abs_path = data_dir.join(&media.sidecar_path);
atomic_write_str(&abs_path, &sidecar.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::post_media::list_post_media_by_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::util::sidecar::read_sidecar;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
// Create a post
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
[],
)
.unwrap();
(db, dir)
}
/// Insert a media item whose sidecar_path is inside the temp dir.
fn insert_test_media(db: &Database, dir: &Path, id: &str) {
let sidecar_rel = format!("media/{id}.jpg.meta");
let mut media = make_test_media(id, "p1");
media.file_path = format!("media/{id}.jpg");
media.sidecar_path = sidecar_rel.clone();
insert_media(db.conn(), &media).unwrap();
// Write an initial sidecar so the directory exists
let initial = MediaSidecar::from_media(&media, &[]);
let abs_sidecar = dir.join(&sidecar_rel);
fs::create_dir_all(abs_sidecar.parent().unwrap()).unwrap();
fs::write(&abs_sidecar, initial.to_string()).unwrap();
}
fn read_linked_ids(dir: &Path, sidecar_rel: &str) -> Vec<String> {
let content = fs::read_to_string(dir.join(sidecar_rel)).unwrap();
let sc = read_sidecar(&content).unwrap();
sc.linked_post_ids
}
#[test]
fn link_and_verify_sidecar() {
let (db, dir) = setup();
insert_test_media(&db, dir.path(), "m1");
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
let ids = read_linked_ids(dir.path(), "media/m1.jpg.meta");
assert_eq!(ids, vec!["post1"]);
}
#[test]
fn unlink_removes_from_sidecar() {
let (db, dir) = setup();
insert_test_media(&db, dir.path(), "m1");
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
unlink_media_from_post(db.conn(), dir.path(), "post1", "m1").unwrap();
let ids = read_linked_ids(dir.path(), "media/m1.jpg.meta");
assert!(ids.is_empty());
}
#[test]
fn reorder_updates_sort_order() {
let (db, dir) = setup();
insert_test_media(&db, dir.path(), "m1");
insert_test_media(&db, dir.path(), "m2");
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
// Reverse the order: m2 first, m1 second
reorder_post_media(
db.conn(),
"post1",
&["m2".to_string(), "m1".to_string()],
)
.unwrap();
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].media_id, "m2");
assert_eq!(list[0].sort_order, 0);
assert_eq!(list[1].media_id, "m1");
assert_eq!(list[1].sort_order, 1);
}
#[test]
fn multiple_posts_linked() {
let (db, dir) = setup();
insert_test_media(&db, dir.path(), "m1");
// Create a second post
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post2', 'p1', 'Test2', 'test2', 'draft', '', 2000, 2000)",
[],
)
.unwrap();
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
link_media_to_post(db.conn(), dir.path(), "p1", "post2", "m1", 0).unwrap();
let ids = read_linked_ids(dir.path(), "media/m1.jpg.meta");
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"post1".to_string()));
assert!(ids.contains(&"post2".to_string()));
}
}

View File

@@ -0,0 +1,235 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use uuid::Uuid;
use crate::db::queries::project as q;
use crate::engine::{EngineError, EngineResult};
use crate::model::Project;
use crate::model::metadata::ProjectMetadata;
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique};
/// Create a new project: insert into DB, create directory structure, write default meta files.
pub fn create_project(
conn: &Connection,
name: &str,
data_path: Option<&str>,
) -> EngineResult<Project> {
let id = Uuid::new_v4().to_string();
let base_slug = slugify(name);
let slug = ensure_unique(&base_slug, |candidate| {
q::get_project_by_slug(conn, candidate).is_ok()
});
let now = now_unix_ms();
let project = Project {
id: id.clone(),
name: name.to_string(),
slug: slug.clone(),
description: None,
data_path: data_path.map(|s| s.to_string()),
is_active: false,
created_at: now,
updated_at: now,
};
q::insert_project(conn, &project)?;
// Determine data directory
let data_dir = match data_path {
Some(p) => std::path::PathBuf::from(p),
None => std::path::PathBuf::from(&slug),
};
// Create directory structure
create_directory_structure(&data_dir)?;
// Write default meta files
write_default_meta_files(&data_dir, name)?;
Ok(project)
}
/// Get the currently active project, if any.
pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
match q::get_active_project(conn) {
Ok(p) => Ok(Some(p)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(EngineError::Db(e)),
}
}
/// Deactivate all projects, then activate the given one.
pub fn set_active_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
q::set_active_project(conn, project_id)?;
Ok(())
}
/// List all projects ordered by name.
pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
Ok(q::list_projects(conn)?)
}
/// Delete a project row (cascading handled by queries).
pub fn delete_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
q::delete_project(conn, project_id)?;
Ok(())
}
// ── helpers ──────────────────────────────────────────────────────────
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts"];
for sub in &subdirs {
fs::create_dir_all(data_dir.join(sub))?;
}
Ok(())
}
fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult<()> {
let meta_dir = data_dir.join("meta");
// project.json
let project_meta = ProjectMetadata {
name: project_name.to_string(),
description: None,
public_url: None,
main_language: None,
default_author: None,
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None,
python_runtime_mode: None,
semantic_similarity_enabled: false,
blog_languages: Vec::new(),
};
let json = serde_json::to_string_pretty(&project_meta)?;
atomic_write_str(&meta_dir.join("project.json"), &json)?;
// categories.json — default categories
let categories = vec!["article", "aside", "page", "picture"];
let json = serde_json::to_string_pretty(&categories)?;
atomic_write_str(&meta_dir.join("categories.json"), &json)?;
// category-meta.json — empty object
let empty_map: HashMap<String, serde_json::Value> = HashMap::new();
let json = serde_json::to_string_pretty(&empty_map)?;
atomic_write_str(&meta_dir.join("category-meta.json"), &json)?;
// publishing.json — empty object
atomic_write_str(&meta_dir.join("publishing.json"), "{}")?;
// tags.json — empty array
atomic_write_str(&meta_dir.join("tags.json"), "[]")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn create_project_inserts_and_creates_dirs() {
let (db, dir) = setup();
let data_path = dir.path().join("my-blog");
let project = create_project(
db.conn(),
"My Blog",
Some(data_path.to_str().unwrap()),
)
.unwrap();
assert_eq!(project.name, "My Blog");
assert_eq!(project.slug, "my-blog");
assert!(!project.is_active);
// Verify directories
assert!(data_path.join("posts").is_dir());
assert!(data_path.join("media").is_dir());
assert!(data_path.join("meta").is_dir());
assert!(data_path.join("thumbnails").is_dir());
assert!(data_path.join("templates").is_dir());
assert!(data_path.join("scripts").is_dir());
// Verify meta files
let project_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(data_path.join("meta/project.json")).unwrap())
.unwrap();
assert_eq!(project_json["name"], "My Blog");
assert_eq!(project_json["maxPostsPerPage"], 50);
let cats: Vec<String> =
serde_json::from_str(&fs::read_to_string(data_path.join("meta/categories.json")).unwrap())
.unwrap();
assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
let cat_meta: serde_json::Value =
serde_json::from_str(&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap())
.unwrap();
assert!(cat_meta.as_object().unwrap().is_empty());
let tags: Vec<String> =
serde_json::from_str(&fs::read_to_string(data_path.join("meta/tags.json")).unwrap())
.unwrap();
assert!(tags.is_empty());
}
#[test]
fn create_project_unique_slug() {
let (db, dir) = setup();
let p1_path = dir.path().join("blog1");
create_project(db.conn(), "Blog", Some(p1_path.to_str().unwrap())).unwrap();
let p2_path = dir.path().join("blog2");
let p2 = create_project(db.conn(), "Blog", Some(p2_path.to_str().unwrap())).unwrap();
assert_eq!(p2.slug, "blog-2");
}
#[test]
fn get_active_project_none() {
let (db, _dir) = setup();
assert!(get_active_project(db.conn()).unwrap().is_none());
}
#[test]
fn set_and_get_active_project() {
let (db, dir) = setup();
let p1_path = dir.path().join("p1");
let p1 = create_project(db.conn(), "P1", Some(p1_path.to_str().unwrap())).unwrap();
set_active_project(db.conn(), &p1.id).unwrap();
let active = get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p1.id);
}
#[test]
fn list_projects_returns_all() {
let (db, dir) = setup();
let p1_path = dir.path().join("alpha");
let p2_path = dir.path().join("beta");
create_project(db.conn(), "Beta", Some(p2_path.to_str().unwrap())).unwrap();
create_project(db.conn(), "Alpha", Some(p1_path.to_str().unwrap())).unwrap();
let list = list_projects(db.conn()).unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "Alpha");
assert_eq!(list[1].name, "Beta");
}
#[test]
fn delete_project_removes_row() {
let (db, dir) = setup();
let p_path = dir.path().join("p");
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
delete_project(db.conn(), &p.id).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty());
}
}

View File

@@ -0,0 +1,343 @@
use std::path::Path;
use rusqlite::Connection;
use crate::db::fts;
use crate::engine::media;
use crate::engine::post;
use crate::engine::script_rebuild;
use crate::engine::template_rebuild;
use crate::engine::EngineResult;
/// Report from a full rebuild operation.
#[derive(Debug, Default)]
pub struct FullRebuildReport {
pub posts_created: usize,
pub posts_updated: usize,
pub translations_created: usize,
pub translations_updated: usize,
pub media_created: usize,
pub media_updated: usize,
pub media_translations_created: usize,
pub media_translations_updated: usize,
pub templates_created: usize,
pub templates_updated: usize,
pub scripts_created: usize,
pub scripts_updated: usize,
pub errors: Vec<String>,
}
/// Orchestrate a full rebuild from filesystem into the database.
///
/// Ensures FTS tables exist, then rebuilds posts, media, templates, and scripts
/// from their respective filesystem directories. Returns an aggregated report.
pub fn rebuild_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<FullRebuildReport> {
let mut report = FullRebuildReport::default();
// 1. Ensure FTS tables exist
fts::ensure_fts_tables(conn)?;
// 2. Rebuild posts
let post_report = post::rebuild_posts_from_filesystem(conn, data_dir, project_id)?;
report.posts_created = post_report.posts_created;
report.posts_updated = post_report.posts_updated;
report.translations_created = post_report.translations_created;
report.translations_updated = post_report.translations_updated;
report.errors.extend(post_report.errors);
// 3. Rebuild media
let media_report = media::rebuild_media_from_filesystem(conn, data_dir, project_id)?;
report.media_created = media_report.media_created;
report.media_updated = media_report.media_updated;
report.media_translations_created = media_report.translations_created;
report.media_translations_updated = media_report.translations_updated;
report.errors.extend(media_report.errors);
// 4. Rebuild templates
let tpl_report =
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?;
report.templates_created = tpl_report.created;
report.templates_updated = tpl_report.updated;
report.errors.extend(tpl_report.errors);
// 5. Rebuild scripts
let script_report =
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
report.scripts_created = script_report.created;
report.scripts_updated = script_report.updated;
report.errors.extend(script_report.errors);
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::media as qm;
use crate::db::queries::post as qp;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl;
use crate::db::Database;
use std::fs;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
fts::ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn rebuild_empty_dir_returns_zeros() {
let (db, dir) = setup();
let report = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.posts_created, 0);
assert_eq!(report.posts_updated, 0);
assert_eq!(report.translations_created, 0);
assert_eq!(report.translations_updated, 0);
assert_eq!(report.media_created, 0);
assert_eq!(report.media_updated, 0);
assert_eq!(report.media_translations_created, 0);
assert_eq!(report.media_translations_updated, 0);
assert_eq!(report.templates_created, 0);
assert_eq!(report.templates_updated, 0);
assert_eq!(report.scripts_created, 0);
assert_eq!(report.scripts_updated, 0);
assert!(report.errors.is_empty());
}
#[test]
fn rebuild_creates_posts_and_media() {
let (db, dir) = setup();
// Write a post fixture
let posts_dir = dir.path().join("posts").join("2024").join("01");
fs::create_dir_all(&posts_dir).unwrap();
let post_content = "\
---
id: test-post-1
title: Test Post
slug: test-post
status: published
createdAt: '2024-01-15T12:00:00.000Z'
updatedAt: '2024-01-15T12:00:00.000Z'
tags:
- test
categories: []
publishedAt: '2024-01-15T12:00:00.000Z'
---
Hello from rebuild test!
";
fs::write(posts_dir.join("test-post.md"), post_content).unwrap();
// Write a media fixture: sidecar + dummy binary
let media_dir = dir.path().join("media").join("2024").join("01");
fs::create_dir_all(&media_dir).unwrap();
let media_file = media_dir.join("test-media-1.jpg");
fs::write(&media_file, b"fake-jpeg-data").unwrap();
let sidecar_content = "\
---
id: test-media-1
originalName: \"photo.jpg\"
mimeType: image/jpeg
size: 12345
width: 800
height: 600
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: []
---
";
fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();
let report = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.posts_created, 1);
assert_eq!(report.media_created, 1);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Verify post in DB
let post = qp::get_post_by_id(db.conn(), "test-post-1").unwrap();
assert_eq!(post.title, "Test Post");
assert_eq!(post.slug, "test-post");
// Verify media in DB
let m = qm::get_media_by_id(db.conn(), "test-media-1").unwrap();
assert_eq!(m.original_name, "photo.jpg");
}
#[test]
fn rebuild_all_entity_types() {
let (db, dir) = setup();
// Post
let posts_dir = dir.path().join("posts").join("2024").join("01");
fs::create_dir_all(&posts_dir).unwrap();
let post_content = "\
---
id: test-post-1
title: Test Post
slug: test-post
status: published
createdAt: '2024-01-15T12:00:00.000Z'
updatedAt: '2024-01-15T12:00:00.000Z'
tags:
- test
categories: []
publishedAt: '2024-01-15T12:00:00.000Z'
---
Hello from rebuild test!
";
fs::write(posts_dir.join("test-post.md"), post_content).unwrap();
// Media
let media_dir = dir.path().join("media").join("2024").join("01");
fs::create_dir_all(&media_dir).unwrap();
fs::write(media_dir.join("test-media-1.jpg"), b"fake-jpeg").unwrap();
let sidecar_content = "\
---
id: test-media-1
originalName: \"photo.jpg\"
mimeType: image/jpeg
size: 12345
width: 800
height: 600
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: []
---
";
fs::write(
media_dir.join("test-media-1.jpg.meta"),
sidecar_content,
)
.unwrap();
// Template
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
let tpl_content = "\
---
id: \"test-tpl-1\"
slug: \"test-template\"
title: \"Test Template\"
kind: \"post\"
enabled: true
version: 1
createdAt: \"2024-01-15T12:00:00.000Z\"
updatedAt: \"2024-01-15T12:00:00.000Z\"
---
<html>{{ content }}</html>
";
fs::write(tpl_dir.join("test-template.liquid"), tpl_content).unwrap();
// Script
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let script_content = "\
---
id: \"test-script-1\"
slug: \"test-script\"
title: \"Test Script\"
kind: \"macro\"
entrypoint: \"render\"
enabled: true
version: 1
createdAt: \"2024-01-15T12:00:00.000Z\"
updatedAt: \"2024-01-15T12:00:00.000Z\"
---
function render() end
";
fs::write(scripts_dir.join("test-script.lua"), script_content).unwrap();
let report = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.posts_created, 1);
assert_eq!(report.media_created, 1);
assert_eq!(report.templates_created, 1);
assert_eq!(report.scripts_created, 1);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Verify all entities in DB
let post = qp::get_post_by_id(db.conn(), "test-post-1").unwrap();
assert_eq!(post.title, "Test Post");
let m = qm::get_media_by_id(db.conn(), "test-media-1").unwrap();
assert_eq!(m.original_name, "photo.jpg");
let tpl = qtpl::get_template_by_id(db.conn(), "test-tpl-1").unwrap();
assert_eq!(tpl.title, "Test Template");
let script = qs::get_script_by_id(db.conn(), "test-script-1").unwrap();
assert_eq!(script.title, "Test Script");
}
#[test]
fn rebuild_idempotent() {
let (db, dir) = setup();
// Write one of each entity type
let posts_dir = dir.path().join("posts").join("2024").join("01");
fs::create_dir_all(&posts_dir).unwrap();
let post_content = "\
---
id: idem-post
title: Idempotent Post
slug: idem-post
status: draft
createdAt: '2024-01-15T12:00:00.000Z'
updatedAt: '2024-01-15T12:00:00.000Z'
tags: []
categories: []
---
Body text.
";
fs::write(posts_dir.join("idem-post.md"), post_content).unwrap();
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
let tpl_content = "\
---
id: \"idem-tpl\"
slug: \"idem-tpl\"
title: \"Idempotent Template\"
kind: \"post\"
enabled: true
version: 1
createdAt: \"2024-01-15T12:00:00.000Z\"
updatedAt: \"2024-01-15T12:00:00.000Z\"
---
<html>{{ content }}</html>
";
fs::write(tpl_dir.join("idem-tpl.liquid"), tpl_content).unwrap();
// First rebuild
let r1 = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.posts_created, 1);
assert_eq!(r1.posts_updated, 0);
assert_eq!(r1.templates_created, 1);
assert_eq!(r1.templates_updated, 0);
assert!(r1.errors.is_empty(), "errors: {:?}", r1.errors);
// Second rebuild - should update, not create
let r2 = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.posts_created, 0);
assert_eq!(r2.posts_updated, 1);
assert_eq!(r2.templates_created, 0);
assert_eq!(r2.templates_updated, 1);
assert!(r2.errors.is_empty(), "errors: {:?}", r2.errors);
}
}

View File

@@ -0,0 +1,339 @@
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use walkdir::WalkDir;
use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Script, ScriptKind, ScriptStatus};
use crate::util::frontmatter::read_script_file;
use crate::util::now_unix_ms;
/// Report returned by `rebuild_scripts_from_filesystem`.
#[derive(Debug, Default)]
pub struct ScriptRebuildReport {
pub created: usize,
pub updated: usize,
pub errors: Vec<String>,
}
/// Parse a script kind string from frontmatter into a `ScriptKind`.
fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
match s {
"macro" => Ok(ScriptKind::Macro),
"utility" => Ok(ScriptKind::Utility),
"transform" => Ok(ScriptKind::Transform),
other => Err(format!("unknown script kind: '{other}'")),
}
}
/// Rebuild scripts from the filesystem into the database.
///
/// Walks the `scripts/` directory for `*.lua` and `*.py` files, parses each
/// via frontmatter, and either creates or updates the corresponding DB row.
/// Published scripts (those present on disk) have `content = None` in the DB.
pub fn rebuild_scripts_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ScriptRebuildReport> {
let mut report = ScriptRebuildReport::default();
let scripts_dir = data_dir.join("scripts");
if !scripts_dir.exists() {
return Ok(report);
}
for entry in WalkDir::new(&scripts_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("lua") && ext != Some("py") {
continue;
}
match rebuild_single_script(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.created += 1;
} else {
report.updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
Ok(report)
}
/// Rebuild a single script from a `.lua` or `.py` file.
/// Returns `true` if created, `false` if updated.
fn rebuild_single_script(
conn: &Connection,
data_dir: &Path,
project_id: &str,
path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(path)?;
let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?;
let rel_path = path
.strip_prefix(data_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let kind = parse_script_kind(&fm.kind).map_err(EngineError::Parse)?;
let now = now_unix_ms();
// File exists on disk -> Published; content is None in DB
let status = ScriptStatus::Published;
let existing = qs::get_script_by_id(conn, &fm.id);
match existing {
Ok(mut script) => {
script.slug = fm.slug;
script.title = fm.title;
script.kind = kind;
script.entrypoint = fm.entrypoint;
script.enabled = fm.enabled;
script.version = fm.version;
script.file_path = rel_path;
script.status = status;
script.content = None;
script.created_at = fm.created_at;
script.updated_at = now;
qs::update_script(conn, &script)?;
Ok(false)
}
Err(_) => {
let script = Script {
id: fm.id,
project_id: project_id.to_string(),
slug: fm.slug,
title: fm.title,
kind,
entrypoint: fm.entrypoint,
enabled: fm.enabled,
version: fm.version,
file_path: rel_path,
status,
content: None,
created_at: fm.created_at,
updated_at: now,
};
qs::insert_script(conn, &script)?;
Ok(true)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn rebuild_creates_script_from_lua() {
let (db, dir) = setup();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let content = "\
---
id: \"lua-script-1\"
slug: \"my-macro\"
title: \"My Macro\"
kind: \"macro\"
entrypoint: \"render\"
enabled: true
version: 1
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
function render()
return \"<p>hello</p>\"
end
";
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
let report =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1);
assert_eq!(report.updated, 0);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let script = qs::get_script_by_id(db.conn(), "lua-script-1").unwrap();
assert_eq!(script.slug, "my-macro");
assert_eq!(script.title, "My Macro");
assert_eq!(script.kind, ScriptKind::Macro);
assert_eq!(script.entrypoint, "render");
assert!(script.enabled);
assert_eq!(script.version, 1);
assert_eq!(script.status, ScriptStatus::Published);
assert!(script.content.is_none(), "published script should have content=None in DB");
assert_eq!(script.file_path, "scripts/my-macro.lua");
}
#[test]
fn rebuild_creates_script_from_py() {
let (db, dir) = setup();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let content = "\"\"\"\n\
---
id: \"py-script-1\"
slug: \"my-utility\"
title: \"My Utility\"
kind: \"utility\"
entrypoint: \"main\"
enabled: true
version: 1
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
\"\"\"
def main():
print(\"hello\")
";
fs::write(scripts_dir.join("my-utility.py"), content).unwrap();
let report =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1);
assert_eq!(report.updated, 0);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let script = qs::get_script_by_id(db.conn(), "py-script-1").unwrap();
assert_eq!(script.slug, "my-utility");
assert_eq!(script.title, "My Utility");
assert_eq!(script.kind, ScriptKind::Utility);
assert_eq!(script.entrypoint, "main");
assert_eq!(script.status, ScriptStatus::Published);
assert!(script.content.is_none());
assert_eq!(script.file_path, "scripts/my-utility.py");
}
#[test]
fn rebuild_updates_existing() {
let (db, dir) = setup();
// Insert a script in DB first
let existing = Script {
id: "existing-script".to_string(),
project_id: "p1".to_string(),
slug: "old-script".to_string(),
title: "Old Script".to_string(),
kind: ScriptKind::Macro,
entrypoint: "render".to_string(),
enabled: false,
version: 1,
file_path: "scripts/old-script.lua".to_string(),
status: ScriptStatus::Draft,
content: Some("old content".to_string()),
created_at: 1000,
updated_at: 2000,
};
qs::insert_script(db.conn(), &existing).unwrap();
// Write updated file
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let content = "\
---
id: \"existing-script\"
slug: \"updated-script\"
title: \"Updated Script\"
kind: \"transform\"
entrypoint: \"process\"
enabled: true
version: 5
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
function process()
return \"updated\"
end
";
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
let report =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0);
assert_eq!(report.updated, 1);
assert!(report.errors.is_empty());
let script = qs::get_script_by_id(db.conn(), "existing-script").unwrap();
assert_eq!(script.slug, "updated-script");
assert_eq!(script.title, "Updated Script");
assert_eq!(script.kind, ScriptKind::Transform);
assert_eq!(script.entrypoint, "process");
assert!(script.enabled);
assert_eq!(script.version, 5);
assert_eq!(script.status, ScriptStatus::Published);
assert!(script.content.is_none());
}
#[test]
fn rebuild_idempotent() {
let (db, dir) = setup();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let content = "\
---
id: \"idem-script\"
slug: \"idem\"
title: \"Idempotent\"
kind: \"utility\"
entrypoint: \"run\"
enabled: true
version: 1
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
function run() end
";
fs::write(scripts_dir.join("idem.lua"), content).unwrap();
// First run
let r1 =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.created, 1);
assert_eq!(r1.updated, 0);
// Second run - should update, not create
let r2 =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.created, 0);
assert_eq!(r2.updated, 1);
// Still only one script in DB
let list = qs::list_scripts_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 1);
}
}

View File

@@ -0,0 +1,466 @@
use std::path::Path;
use rusqlite::Connection;
use uuid::Uuid;
use crate::db::queries::post as post_q;
use crate::db::queries::tag as tag_q;
use crate::engine::meta;
use crate::engine::{EngineError, EngineResult};
use crate::model::metadata::TagEntry;
use crate::model::Tag;
use crate::util::now_unix_ms;
/// Create a new tag. Case-insensitive duplicate check.
pub fn create_tag(
conn: &Connection,
data_dir: &Path,
project_id: &str,
name: &str,
color: Option<&str>,
) -> EngineResult<Tag> {
// Check for case-insensitive duplicate
if tag_q::get_tag_by_project_and_name(conn, project_id, name).is_ok() {
return Err(EngineError::Conflict(format!(
"tag '{name}' already exists"
)));
}
let now = now_unix_ms();
let tag = Tag {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
name: name.to_string(),
color: color.map(|s| s.to_string()),
post_template_slug: None,
created_at: now,
updated_at: now,
};
tag_q::insert_tag(conn, &tag)?;
rewrite_tags_json(conn, data_dir, project_id)?;
Ok(tag)
}
/// Update a tag's name, color, and/or post_template_slug.
pub fn update_tag(
conn: &Connection,
data_dir: &Path,
tag_id: &str,
name: Option<&str>,
color: Option<&str>,
post_template_slug: Option<&str>,
) -> EngineResult<()> {
let mut tag = tag_q::get_tag_by_id(conn, tag_id)
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
if let Some(n) = name {
tag.name = n.to_string();
}
if let Some(c) = color {
tag.color = if c.is_empty() {
None
} else {
Some(c.to_string())
};
}
if let Some(pts) = post_template_slug {
tag.post_template_slug = if pts.is_empty() {
None
} else {
Some(pts.to_string())
};
}
tag.updated_at = now_unix_ms();
tag_q::update_tag(conn, &tag)?;
rewrite_tags_json(conn, data_dir, &tag.project_id)?;
Ok(())
}
/// Delete a tag: remove from all posts' tag arrays, delete from DB, rewrite tags.json.
pub fn delete_tag(
conn: &Connection,
data_dir: &Path,
project_id: &str,
tag_id: &str,
) -> EngineResult<()> {
let tag = tag_q::get_tag_by_id(conn, tag_id)
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
// Remove tag name from all posts
remove_tag_name_from_posts(conn, project_id, &tag.name)?;
tag_q::delete_tag(conn, tag_id)?;
rewrite_tags_json(conn, data_dir, project_id)?;
Ok(())
}
/// Rename a tag: update all posts' tag arrays, update tag in DB, rewrite tags.json.
pub fn rename_tag(
conn: &Connection,
data_dir: &Path,
project_id: &str,
tag_id: &str,
new_name: &str,
) -> EngineResult<()> {
let mut tag = tag_q::get_tag_by_id(conn, tag_id)
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
let old_name = tag.name.clone();
// Update all posts: replace old name with new name in tag arrays
let posts = post_q::list_posts_by_project(conn, project_id)?;
let now = now_unix_ms();
for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) {
post.tags = post
.tags
.into_iter()
.map(|t| {
if t.eq_ignore_ascii_case(&old_name) {
new_name.to_string()
} else {
t
}
})
.collect();
post.updated_at = now;
post_q::update_post(conn, &post)?;
}
}
tag.name = new_name.to_string();
tag.updated_at = now;
tag_q::update_tag(conn, &tag)?;
rewrite_tags_json(conn, data_dir, project_id)?;
Ok(())
}
/// Merge multiple source tags into one target tag.
/// For each source: update posts (remove source name, add target name if not present), delete source.
pub fn merge_tags(
conn: &Connection,
data_dir: &Path,
project_id: &str,
source_ids: &[&str],
target_id: &str,
) -> EngineResult<()> {
let target_tag = tag_q::get_tag_by_id(conn, target_id)
.map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?;
for &source_id in source_ids {
let source_tag = tag_q::get_tag_by_id(conn, source_id)
.map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?;
let posts = post_q::list_posts_by_project(conn, project_id)?;
let now = now_unix_ms();
for mut post in posts {
let has_source = post
.tags
.iter()
.any(|t| t.eq_ignore_ascii_case(&source_tag.name));
if has_source {
// Remove source tag name
post.tags
.retain(|t| !t.eq_ignore_ascii_case(&source_tag.name));
// Add target tag name if not already present
if !post
.tags
.iter()
.any(|t| t.eq_ignore_ascii_case(&target_tag.name))
{
post.tags.push(target_tag.name.clone());
}
post.updated_at = now;
post_q::update_post(conn, &post)?;
}
}
tag_q::delete_tag(conn, source_id)?;
}
rewrite_tags_json(conn, data_dir, project_id)?;
Ok(())
}
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
pub fn sync_tags_from_posts(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<Vec<Tag>> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
// Collect all unique tag names from posts
let mut tag_names = std::collections::HashSet::new();
for post in &posts {
for tag_name in &post.tags {
tag_names.insert(tag_name.to_lowercase());
}
}
// Create any tags that don't exist yet
let now = now_unix_ms();
for name in &tag_names {
if tag_q::get_tag_by_project_and_name(conn, project_id, name).is_err() {
let tag = Tag {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
name: name.clone(),
color: None,
post_template_slug: None,
created_at: now,
updated_at: now,
};
tag_q::insert_tag(conn, &tag)?;
}
}
rewrite_tags_json(conn, data_dir, project_id)?;
let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
Ok(all_tags)
}
/// Rewrite meta/tags.json from DB state.
pub fn rewrite_tags_json(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
let tags = tag_q::list_tags_by_project(conn, project_id)?;
let entries: Vec<TagEntry> = tags
.into_iter()
.map(|t| TagEntry {
name: t.name,
color: t.color,
post_template_slug: t.post_template_slug,
})
.collect();
meta::write_tags_json(data_dir, &entries)?;
Ok(())
}
// ── helpers ─────────────────────────────────────────────────────────
fn remove_tag_name_from_posts(
conn: &Connection,
project_id: &str,
tag_name: &str,
) -> EngineResult<()> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
let now = now_unix_ms();
for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
post.tags
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
post.updated_at = now;
post_q::update_post(conn, &post)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::post::insert_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::model::{Post, PostStatus};
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
// Seed tags.json
std::fs::write(dir.path().join("meta/tags.json"), "[]").unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
(db, dir)
}
fn make_post(id: &str, slug: &str, tags: Vec<String>) -> Post {
Post {
id: id.to_string(),
project_id: "p1".to_string(),
title: format!("Post {id}"),
slug: slug.to_string(),
excerpt: None,
content: Some("body".into()),
status: PostStatus::Draft,
author: None,
language: None,
do_not_translate: false,
template_slug: None,
file_path: format!("posts/{slug}.md"),
checksum: None,
tags,
categories: vec![],
published_title: None,
published_content: None,
published_tags: None,
published_categories: None,
published_excerpt: None,
created_at: 1000,
updated_at: 2000,
published_at: None,
}
}
#[test]
fn create_tag_and_rewrite_json() {
let (db, dir) = setup();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", Some("#ff0000")).unwrap();
assert_eq!(tag.name, "rust");
assert_eq!(tag.color.as_deref(), Some("#ff0000"));
// Verify tags.json was written
let entries = meta::read_tags_json(dir.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "rust");
}
#[test]
fn create_tag_duplicate_rejected() {
let (db, dir) = setup();
create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
let result = create_tag(db.conn(), dir.path(), "p1", "Rust", None);
assert!(result.is_err());
}
#[test]
fn update_tag_fields() {
let (db, dir) = setup();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
update_tag(
db.conn(),
dir.path(),
&tag.id,
Some("go"),
Some("#00ff00"),
None,
)
.unwrap();
let entries = meta::read_tags_json(dir.path()).unwrap();
assert_eq!(entries[0].name, "go");
assert_eq!(entries[0].color.as_deref(), Some("#00ff00"));
}
#[test]
fn delete_tag_removes_from_posts() {
let (db, dir) = setup();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
insert_post(
db.conn(),
&make_post("x1", "hello", vec!["rust".into(), "web".into()]),
)
.unwrap();
delete_tag(db.conn(), dir.path(), "p1", &tag.id).unwrap();
let post = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
assert!(!post.tags.contains(&"rust".to_string()));
assert!(post.tags.contains(&"web".to_string()));
let entries = meta::read_tags_json(dir.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn rename_tag_updates_posts() {
let (db, dir) = setup();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
insert_post(
db.conn(),
&make_post("x1", "hello", vec!["rust".into()]),
)
.unwrap();
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
let post = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
assert!(post.tags.contains(&"golang".to_string()));
assert!(!post.tags.contains(&"rust".to_string()));
let entries = meta::read_tags_json(dir.path()).unwrap();
assert_eq!(entries[0].name, "golang");
}
#[test]
fn merge_tags_combines_into_target() {
let (db, dir) = setup();
let t1 = create_tag(db.conn(), dir.path(), "p1", "rs", None).unwrap();
let t2 = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
let t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
insert_post(
db.conn(),
&make_post("x1", "a", vec!["rs".into()]),
)
.unwrap();
insert_post(
db.conn(),
&make_post("x2", "b", vec!["rust".into(), "target".into()]),
)
.unwrap();
merge_tags(
db.conn(),
dir.path(),
"p1",
&[&t1.id, &t2.id],
&t3.id,
)
.unwrap();
// Post x1 should now have "target"
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
assert!(p1.tags.contains(&"target".to_string()));
assert!(!p1.tags.contains(&"rs".to_string()));
// Post x2 should still have "target" only once
let p2 = crate::db::queries::post::get_post_by_id(db.conn(), "x2").unwrap();
assert_eq!(p2.tags.iter().filter(|t| *t == "target").count(), 1);
// Source tags deleted from DB
let all = crate::db::queries::tag::list_tags_by_project(db.conn(), "p1").unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].name, "target");
}
#[test]
fn sync_tags_from_posts_creates_missing() {
let (db, dir) = setup();
insert_post(
db.conn(),
&make_post("x1", "a", vec!["rust".into(), "web".into()]),
)
.unwrap();
insert_post(
db.conn(),
&make_post("x2", "b", vec!["web".into(), "go".into()]),
)
.unwrap();
let tags = sync_tags_from_posts(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(tags.len(), 3);
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"go"));
assert!(names.contains(&"rust"));
assert!(names.contains(&"web"));
let entries = meta::read_tags_json(dir.path()).unwrap();
assert_eq!(entries.len(), 3);
}
#[test]
fn rewrite_tags_json_matches_db() {
let (db, dir) = setup();
create_tag(db.conn(), dir.path(), "p1", "zebra", None).unwrap();
create_tag(db.conn(), dir.path(), "p1", "alpha", None).unwrap();
let entries = meta::read_tags_json(dir.path()).unwrap();
assert_eq!(entries[0].name, "alpha");
assert_eq!(entries[1].name, "zebra");
}
}

View File

@@ -0,0 +1,303 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
/// Unique task identifier.
pub type TaskId = u64;
/// Task status.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskStatus {
Queued,
Running,
Completed,
Failed(String),
Cancelled,
}
/// Progress update for a task.
#[derive(Debug, Clone)]
pub struct TaskProgress {
pub task_id: TaskId,
pub message: String,
pub percent: Option<f32>,
}
/// Entry tracking a task.
#[derive(Debug)]
struct TaskEntry {
id: TaskId,
label: String,
status: TaskStatus,
cancel_flag: Arc<AtomicBool>,
}
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
pub struct TaskManager {
max_concurrent: usize,
next_id: Mutex<TaskId>,
tasks: Mutex<Vec<TaskEntry>>,
}
impl TaskManager {
/// Create a new task manager with the given concurrency limit.
pub fn new(max_concurrent: usize) -> Self {
Self {
max_concurrent,
next_id: Mutex::new(1),
tasks: Mutex::new(Vec::new()),
}
}
/// Submit a new task. Returns its unique identifier.
pub fn submit(&self, label: &str) -> TaskId {
let mut next = self.next_id.lock().unwrap();
let id = *next;
*next += 1;
let entry = TaskEntry {
id,
label: label.to_owned(),
status: TaskStatus::Queued,
cancel_flag: Arc::new(AtomicBool::new(false)),
};
self.tasks.lock().unwrap().push(entry);
id
}
/// Try to start a queued task. Returns true if the task was moved to
/// Running, false if concurrency is at capacity or the task is not Queued.
pub fn try_start(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap();
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running >= self.max_concurrent {
return false;
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if entry.status == TaskStatus::Queued {
entry.status = TaskStatus::Running;
return true;
}
}
false
}
/// Mark a task as completed.
pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
entry.status = TaskStatus::Completed;
}
}
/// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
entry.status = TaskStatus::Failed(error);
}
}
/// Cancel a task by setting its cancel flag and status.
pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
}
}
/// Check whether a task has been cancelled.
pub fn is_cancelled(&self, task_id: TaskId) -> bool {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.cancel_flag.load(Ordering::Acquire))
.unwrap_or(false)
}
/// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).map(|t| t.status.clone())
}
/// Count tasks that are still queued.
pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Queued).count()
}
/// Count tasks that are currently running.
pub fn running_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Running).count()
}
/// Remove all completed, failed, and cancelled tasks.
pub fn drain_completed(&self) {
let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|t| matches!(t.status, TaskStatus::Queued | TaskStatus::Running));
}
/// Return the label of a task.
pub fn label(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).map(|t| t.label.clone())
}
/// Return the id of the first queued task (FIFO order).
pub fn next_queued(&self) -> Option<TaskId> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id)
}
}
impl Default for TaskManager {
fn default() -> Self {
Self::new(3)
}
}
/// Default progress throttle interval (250ms per spec).
pub const PROGRESS_THROTTLE_MS: u64 = 250;
/// Throttles progress reporting to avoid flooding.
pub struct ProgressThrottle {
interval_ms: u64,
last_report: Mutex<Option<Instant>>,
}
impl ProgressThrottle {
/// Create a throttle with the given interval in milliseconds.
pub fn new(interval_ms: u64) -> Self {
Self {
interval_ms,
last_report: Mutex::new(None),
}
}
/// Returns true if enough time has elapsed since the last report.
pub fn should_report(&self) -> bool {
let mut last = self.last_report.lock().unwrap();
let now = Instant::now();
match *last {
Some(prev) if now.duration_since(prev).as_millis() < self.interval_ms as u128 => false,
_ => {
*last = Some(now);
true
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn submit_and_start() {
let mgr = TaskManager::default();
let id = mgr.submit("build site");
assert_eq!(mgr.status(id), Some(TaskStatus::Queued));
assert!(mgr.try_start(id));
assert_eq!(mgr.status(id), Some(TaskStatus::Running));
}
#[test]
fn max_concurrent_enforced() {
let mgr = TaskManager::new(3);
let ids: Vec<TaskId> = (0..4).map(|i| mgr.submit(&format!("task {i}"))).collect();
assert!(mgr.try_start(ids[0]));
assert!(mgr.try_start(ids[1]));
assert!(mgr.try_start(ids[2]));
assert!(!mgr.try_start(ids[3]));
assert_eq!(mgr.running_count(), 3);
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued));
}
#[test]
fn fifo_order() {
let mgr = TaskManager::default();
let a = mgr.submit("first");
let b = mgr.submit("second");
let c = mgr.submit("third");
assert_eq!(mgr.next_queued(), Some(a));
mgr.try_start(a);
assert_eq!(mgr.next_queued(), Some(b));
mgr.try_start(b);
assert_eq!(mgr.next_queued(), Some(c));
}
#[test]
fn cancel_sets_flag() {
let mgr = TaskManager::default();
let id = mgr.submit("upload");
mgr.try_start(id);
assert!(!mgr.is_cancelled(id));
mgr.cancel(id);
assert!(mgr.is_cancelled(id));
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
}
#[test]
fn complete_and_fail() {
let mgr = TaskManager::default();
let ok = mgr.submit("good task");
let bad = mgr.submit("bad task");
mgr.try_start(ok);
mgr.try_start(bad);
mgr.complete(ok);
mgr.fail(bad, "disk full".into());
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
}
#[test]
fn drain_removes_finished() {
let mgr = TaskManager::default();
let a = mgr.submit("done");
let b = mgr.submit("broken");
let c = mgr.submit("stopped");
let d = mgr.submit("waiting");
let e = mgr.submit("busy");
mgr.try_start(a);
mgr.try_start(b);
mgr.try_start(e);
mgr.complete(a);
mgr.fail(b, "oops".into());
mgr.cancel(c);
mgr.drain_completed();
assert_eq!(mgr.status(a), None);
assert_eq!(mgr.status(b), None);
assert_eq!(mgr.status(c), None);
assert_eq!(mgr.status(d), Some(TaskStatus::Queued));
assert_eq!(mgr.status(e), Some(TaskStatus::Running));
}
#[test]
fn progress_throttle_initial_reports() {
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
assert!(throttle.should_report());
}
#[test]
fn progress_throttle_suppresses_rapid() {
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
assert!(throttle.should_report());
assert!(!throttle.should_report());
}
}

View File

@@ -0,0 +1,304 @@
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use walkdir::WalkDir;
use crate::db::queries::template as qt;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Template, TemplateKind, TemplateStatus};
use crate::util::frontmatter::read_template_file;
use crate::util::now_unix_ms;
/// Report returned by `rebuild_templates_from_filesystem`.
#[derive(Debug, Default)]
pub struct TemplateRebuildReport {
pub created: usize,
pub updated: usize,
pub errors: Vec<String>,
}
/// Parse a template kind string from frontmatter into a `TemplateKind`.
fn parse_template_kind(s: &str) -> Result<TemplateKind, String> {
match s {
"post" => Ok(TemplateKind::Post),
"list" => Ok(TemplateKind::List),
"not_found" | "notFound" | "not-found" => Ok(TemplateKind::NotFound),
"partial" => Ok(TemplateKind::Partial),
other => Err(format!("unknown template kind: '{other}'")),
}
}
/// Rebuild templates from the filesystem into the database.
///
/// Walks the `templates/` directory for `*.liquid` files, parses each via
/// frontmatter, and either creates or updates the corresponding DB row.
/// Published templates (those present on disk) have `content = None` in the DB.
pub fn rebuild_templates_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<TemplateRebuildReport> {
let mut report = TemplateRebuildReport::default();
let templates_dir = data_dir.join("templates");
if !templates_dir.exists() {
return Ok(report);
}
for entry in WalkDir::new(&templates_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("liquid") {
continue;
}
match rebuild_single_template(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.created += 1;
} else {
report.updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
Ok(report)
}
/// Rebuild a single template from a `.liquid` file.
/// Returns `true` if created, `false` if updated.
fn rebuild_single_template(
conn: &Connection,
data_dir: &Path,
project_id: &str,
path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(path)?;
let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?;
let rel_path = path
.strip_prefix(data_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let kind = parse_template_kind(&fm.kind).map_err(EngineError::Parse)?;
let now = now_unix_ms();
// File exists on disk -> Published; content is None in DB
let status = TemplateStatus::Published;
let existing = qt::get_template_by_id(conn, &fm.id);
match existing {
Ok(mut tpl) => {
tpl.slug = fm.slug;
tpl.title = fm.title;
tpl.kind = kind;
tpl.enabled = fm.enabled;
tpl.version = fm.version;
tpl.file_path = rel_path;
tpl.status = status;
tpl.content = None;
tpl.created_at = fm.created_at;
tpl.updated_at = now;
qt::update_template(conn, &tpl)?;
Ok(false)
}
Err(_) => {
let tpl = Template {
id: fm.id,
project_id: project_id.to_string(),
slug: fm.slug,
title: fm.title,
kind,
enabled: fm.enabled,
version: fm.version,
file_path: rel_path,
status,
content: None,
created_at: fm.created_at,
updated_at: now,
};
qt::insert_template(conn, &tpl)?;
Ok(true)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn rebuild_creates_template() {
let (db, dir) = setup();
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
let content = "\
---
id: \"aaa-bbb-ccc\"
slug: \"my-template\"
title: \"My Template\"
kind: \"post\"
enabled: true
version: 1
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
<div>hello</div>
";
fs::write(tpl_dir.join("my-template.liquid"), content).unwrap();
let report =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1);
assert_eq!(report.updated, 0);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let tpl = qt::get_template_by_id(db.conn(), "aaa-bbb-ccc").unwrap();
assert_eq!(tpl.slug, "my-template");
assert_eq!(tpl.title, "My Template");
assert_eq!(tpl.kind, TemplateKind::Post);
assert!(tpl.enabled);
assert_eq!(tpl.version, 1);
assert_eq!(tpl.status, TemplateStatus::Published);
assert!(tpl.content.is_none(), "published template should have content=None in DB");
assert_eq!(tpl.file_path, "templates/my-template.liquid");
}
#[test]
fn rebuild_updates_existing() {
let (db, dir) = setup();
// Insert a template in DB first
let existing = Template {
id: "existing-id".to_string(),
project_id: "p1".to_string(),
slug: "old-slug".to_string(),
title: "Old Title".to_string(),
kind: TemplateKind::Post,
enabled: false,
version: 1,
file_path: "templates/old-slug.liquid".to_string(),
status: TemplateStatus::Draft,
content: Some("<p>old</p>".to_string()),
created_at: 1000,
updated_at: 2000,
};
qt::insert_template(db.conn(), &existing).unwrap();
// Write updated file
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
let content = "\
---
id: \"existing-id\"
slug: \"updated-slug\"
title: \"Updated Title\"
kind: \"list\"
enabled: true
version: 3
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
<div>updated body</div>
";
fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap();
let report =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0);
assert_eq!(report.updated, 1);
assert!(report.errors.is_empty());
let tpl = qt::get_template_by_id(db.conn(), "existing-id").unwrap();
assert_eq!(tpl.slug, "updated-slug");
assert_eq!(tpl.title, "Updated Title");
assert_eq!(tpl.kind, TemplateKind::List);
assert!(tpl.enabled);
assert_eq!(tpl.version, 3);
assert_eq!(tpl.status, TemplateStatus::Published);
assert!(tpl.content.is_none());
}
#[test]
fn rebuild_ignores_non_liquid_files() {
let (db, dir) = setup();
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap();
fs::write(tpl_dir.join("styles.css"), "body {}").unwrap();
let report =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0);
assert_eq!(report.updated, 0);
assert!(report.errors.is_empty());
}
#[test]
fn rebuild_idempotent() {
let (db, dir) = setup();
let tpl_dir = dir.path().join("templates");
fs::create_dir_all(&tpl_dir).unwrap();
let content = "\
---
id: \"idem-id\"
slug: \"idem\"
title: \"Idempotent\"
kind: \"partial\"
enabled: true
version: 1
createdAt: \"2024-01-01T00:00:00.000Z\"
updatedAt: \"2024-01-01T00:00:00.000Z\"
---
<span>partial</span>
";
fs::write(tpl_dir.join("idem.liquid"), content).unwrap();
// First run
let r1 =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.created, 1);
assert_eq!(r1.updated, 0);
// Second run - should update, not create
let r2 =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.created, 0);
assert_eq!(r2.updated, 1);
// Still only one template in DB
let list = qt::list_templates_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 1);
}
}

View File

@@ -50,6 +50,7 @@ pub enum SshMode {
/// Publishing preferences stored in meta/publishing.json. /// Publishing preferences stored in meta/publishing.json.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishingPreferences { pub struct PublishingPreferences {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub ssh_host: Option<String>, pub ssh_host: Option<String>,

View File

@@ -0,0 +1,146 @@
use serde::{Deserialize, Serialize};
fn default_max_posts() -> i32 {
50
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMetadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub main_language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_author: Option<String>,
#[serde(default = "default_max_posts")]
pub max_posts_per_page: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub blogmark_category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pico_theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub python_runtime_mode: Option<String>,
#[serde(default)]
pub semantic_similarity_enabled: bool,
#[serde(default)]
pub blog_languages: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CategorySettings {
#[serde(default = "default_true")]
pub render_in_lists: bool,
#[serde(default = "default_true")]
pub show_title: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_template_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_template_slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagEntry {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "postTemplateSlug")]
pub post_template_slug: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_metadata_roundtrip() {
let meta = ProjectMetadata {
name: "Test 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,
python_runtime_mode: None,
semantic_similarity_enabled: false,
blog_languages: vec!["en".into(), "de".into()],
};
let json = serde_json::to_string_pretty(&meta).unwrap();
let parsed: ProjectMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "Test Blog");
assert_eq!(parsed.max_posts_per_page, 50);
assert_eq!(parsed.blog_languages, vec!["en", "de"]);
// Verify camelCase
assert!(json.contains("publicUrl"));
assert!(json.contains("maxPostsPerPage"));
assert!(json.contains("blogLanguages"));
}
#[test]
fn project_metadata_defaults() {
let json = r#"{"name": "Minimal"}"#;
let meta: ProjectMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.max_posts_per_page, 50);
assert!(!meta.semantic_similarity_enabled);
assert!(meta.blog_languages.is_empty());
}
#[test]
fn category_settings_defaults() {
let json = "{}";
let settings: CategorySettings = serde_json::from_str(json).unwrap();
assert!(settings.render_in_lists);
assert!(settings.show_title);
assert!(settings.title.is_none());
}
#[test]
fn category_settings_camel_case() {
let settings = CategorySettings {
render_in_lists: false,
show_title: true,
title: Some("Articles".into()),
post_template_slug: Some("article-tpl".into()),
list_template_slug: None,
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("renderInLists"));
assert!(json.contains("showTitle"));
assert!(json.contains("postTemplateSlug"));
}
#[test]
fn tag_entry_roundtrip() {
let tag = TagEntry {
name: "rust".into(),
color: Some("#ff0000".into()),
post_template_slug: Some("code-tpl".into()),
};
let json = serde_json::to_string(&tag).unwrap();
assert!(json.contains("postTemplateSlug"));
let parsed: TagEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "rust");
assert_eq!(parsed.color.as_deref(), Some("#ff0000"));
}
#[test]
fn tag_entry_minimal() {
let json = r#"{"name": "go"}"#;
let tag: TagEntry = serde_json::from_str(json).unwrap();
assert_eq!(tag.name, "go");
assert!(tag.color.is_none());
assert!(tag.post_template_slug.is_none());
}
}

View File

@@ -5,6 +5,7 @@ mod project;
mod template; mod template;
mod script; mod script;
mod generation; mod generation;
pub mod metadata;
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation}; pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
pub use media::{Media, MediaTranslation}; pub use media::{Media, MediaTranslation};
@@ -16,3 +17,4 @@ pub use generation::{
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity, DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
PublishingPreferences, SshMode, PublishingPreferences, SshMode,
}; };
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};

View File

@@ -0,0 +1,53 @@
use std::fs;
use std::io::{self, Write};
use std::path::Path;
/// Write `content` to `path` atomically: write to a temp file in the same
/// directory, then rename. Creates parent directories if missing.
pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
fs::rename(&tmp_path, path)?;
Ok(())
}
/// Convenience wrapper for UTF-8 string content.
pub fn atomic_write_str(path: &Path, content: &str) -> io::Result<()> {
atomic_write(path, content.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_and_read_back() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write_str(&path, "hello world").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
}
#[test]
fn creates_parent_directories() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a").join("b").join("c.txt");
atomic_write_str(&path, "nested").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "nested");
}
#[test]
fn overwrites_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write_str(&path, "v1").unwrap();
atomic_write_str(&path, "v2").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
}

View File

@@ -0,0 +1,813 @@
use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
/// Split content at `---` delimiters into (yaml, body).
/// Returns `None` if the content does not start with `---`.
pub fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim_start_matches('\u{feff}'); // strip BOM
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
let end = after_first.find("\n---")?;
let yaml = &after_first[..end];
let rest = &after_first[end + 4..]; // skip "\n---"
let body = rest.strip_prefix('\n').unwrap_or(rest);
Some((yaml, body))
}
/// Split content at Python docstring `"""` delimiters (for legacy .py scripts).
/// The YAML frontmatter is inside: `"""\n---\n{yaml}\n---\n"""`
pub fn split_docstring_frontmatter(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim_start_matches('\u{feff}');
if !trimmed.starts_with("\"\"\"") {
return None;
}
let after_open = &trimmed[3..];
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
// Find closing """
let close_pos = after_open.find("\"\"\"")?;
let inside = &after_open[..close_pos].trim();
// Inside should be ---\n{yaml}\n---
let yaml_content = split_frontmatter(inside)?;
let body_start = close_pos + 3;
let rest = &after_open[body_start..];
let body = rest.strip_prefix('\n').unwrap_or(rest);
Some((yaml_content.0, body))
}
/// Format frontmatter + body into a complete file string.
pub fn format_frontmatter(yaml: &str, body: &str) -> String {
format!("---\n{yaml}\n---\n{body}")
}
// --- Post Frontmatter ---
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
#[derive(Debug, Clone)]
pub struct PostFrontmatter {
pub id: String,
pub title: String,
pub slug: String,
pub status: String,
pub created_at: i64,
pub updated_at: i64,
pub tags: Vec<String>,
pub categories: Vec<String>,
pub excerpt: Option<String>,
pub author: Option<String>,
pub language: Option<String>,
pub template_slug: Option<String>,
pub do_not_translate: bool,
pub published_at: Option<i64>,
}
impl PostFrontmatter {
/// Build from a Post model.
pub fn from_post(post: &Post) -> Self {
Self {
id: post.id.clone(),
title: post.title.clone(),
slug: post.slug.clone(),
status: serde_json::to_string(&post.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
created_at: post.created_at,
updated_at: post.updated_at,
tags: post.tags.clone(),
categories: post.categories.clone(),
excerpt: post.excerpt.clone(),
author: post.author.clone(),
language: post.language.clone(),
template_slug: post.template_slug.clone(),
do_not_translate: post.do_not_translate,
published_at: post.published_at,
}
}
/// Serialize to YAML string (matching TypeScript gray-matter output).
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: {}", self.id));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
lines.push(format!("slug: {}", self.slug));
lines.push(format!("status: {}", self.status));
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
// Tags as YAML list
if self.tags.is_empty() {
lines.push("tags: []".to_string());
} else {
lines.push("tags:".to_string());
for tag in &self.tags {
lines.push(format!(" - {}", yaml_string_value(tag)));
}
}
// Categories as YAML list
if self.categories.is_empty() {
lines.push("categories: []".to_string());
} else {
lines.push("categories:".to_string());
for cat in &self.categories {
lines.push(format!(" - {}", yaml_string_value(cat)));
}
}
// Conditional fields (only when truthy)
if let Some(ref excerpt) = self.excerpt {
if !excerpt.is_empty() {
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
}
if let Some(ref author) = self.author {
if !author.is_empty() {
lines.push(format!("author: {}", yaml_string_value(author)));
}
}
if let Some(ref language) = self.language {
if !language.is_empty() {
lines.push(format!("language: {language}"));
}
}
if self.do_not_translate {
lines.push("doNotTranslate: true".to_string());
}
if let Some(ref template_slug) = self.template_slug {
if !template_slug.is_empty() {
lines.push(format!("templateSlug: {template_slug}"));
}
}
if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
}
lines.join("\n")
}
/// Parse from a YAML string.
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
let get_string_list = |key: &str| -> Vec<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
};
let get_timestamp = |key: &str| -> Result<i64, String> {
let s = get_str(key).ok_or(format!("missing required field '{key}'"))?;
iso_to_unix_ms(&s)
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
title: get_str("title").ok_or("missing 'title'")?,
slug: get_str("slug").ok_or("missing 'slug'")?,
status: get_str("status").ok_or("missing 'status'")?,
created_at: get_timestamp("createdAt")?,
updated_at: get_timestamp("updatedAt")?,
tags: get_string_list("tags"),
categories: get_string_list("categories"),
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
author: get_str("author").filter(|s| !s.is_empty()),
language: get_str("language").filter(|s| !s.is_empty()),
template_slug: get_str("templateSlug").filter(|s| !s.is_empty()),
do_not_translate: get_str("doNotTranslate")
.map(|s| s == "true")
.unwrap_or(false),
published_at: get_str("publishedAt")
.and_then(|s| iso_to_unix_ms(&s).ok()),
})
}
}
/// Write a complete post file (frontmatter + body).
pub fn write_post_file(post: &Post, body: &str) -> String {
let fm = PostFrontmatter::from_post(post);
format_frontmatter(&fm.to_yaml(), body)
}
/// Read a complete post file, returning frontmatter + body.
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = PostFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
// --- Translation Frontmatter ---
/// Parsed translation frontmatter.
#[derive(Debug, Clone)]
pub struct TranslationFrontmatter {
pub translation_for: String,
pub language: String,
pub title: String,
pub excerpt: Option<String>,
}
impl TranslationFrontmatter {
pub fn from_translation(t: &PostTranslation) -> Self {
Self {
translation_for: t.translation_for.clone(),
language: t.language.clone(),
title: t.title.clone(),
excerpt: t.excerpt.clone(),
}
}
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
if let Some(ref excerpt) = self.excerpt {
if !excerpt.is_empty() {
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
}
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
Ok(Self {
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
language: get_str("language").ok_or("missing 'language'")?,
title: get_str("title").ok_or("missing 'title'")?,
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
})
}
}
/// Write a complete translation file (frontmatter + body).
pub fn write_translation_file(translation: &PostTranslation, body: &str) -> String {
let fm = TranslationFrontmatter::from_translation(translation);
format_frontmatter(&fm.to_yaml(), body)
}
/// Read a complete translation file.
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = TranslationFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
// --- Template Frontmatter ---
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
#[derive(Debug, Clone)]
pub struct TemplateFrontmatter {
pub id: String,
pub project_id: Option<String>,
pub slug: String,
pub title: String,
pub kind: String,
pub enabled: bool,
pub version: i32,
pub created_at: i64,
pub updated_at: i64,
}
impl TemplateFrontmatter {
/// Serialize to YAML with double-quoted strings (matching TypeScript).
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: \"{}\"", self.id));
if let Some(ref pid) = self.project_id {
lines.push(format!("projectId: \"{pid}\""));
}
lines.push(format!("slug: \"{}\"", self.slug));
lines.push(format!("title: \"{}\"", self.title));
lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
}
}
/// Read a template file (frontmatter + body).
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = TemplateFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
/// Write a template file (frontmatter + body).
pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
format_frontmatter(&fm.to_yaml(), body)
}
// --- Script Frontmatter ---
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
#[derive(Debug, Clone)]
pub struct ScriptFrontmatter {
pub id: String,
pub project_id: Option<String>,
pub slug: String,
pub title: String,
pub kind: String,
pub entrypoint: String,
pub enabled: bool,
pub version: i32,
pub created_at: i64,
pub updated_at: i64,
}
impl ScriptFrontmatter {
/// Serialize to YAML with double-quoted strings.
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: \"{}\"", self.id));
if let Some(ref pid) = self.project_id {
lines.push(format!("projectId: \"{pid}\""));
}
lines.push(format!("slug: \"{}\"", self.slug));
lines.push(format!("title: \"{}\"", self.title));
lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
entrypoint: get_str("entrypoint").unwrap_or_else(|| "render".to_string()),
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
}
}
/// Read a script file. Supports both `---` (Lua) and `"""` (Python) delimiters.
pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), String> {
// Try docstring format first (Python scripts)
if let Some((yaml, body)) = split_docstring_frontmatter(content) {
let fm = ScriptFrontmatter::from_yaml(yaml)?;
return Ok((fm, body.to_string()));
}
// Fall back to standard --- format (Lua scripts)
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = ScriptFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
/// Write a script file (always Lua format with --- delimiters).
pub fn write_script_file(fm: &ScriptFrontmatter, body: &str) -> String {
format_frontmatter(&fm.to_yaml(), body)
}
// --- Helpers ---
/// Quote a YAML string value if it contains special characters.
/// Simple values (alphanumeric, hyphens, dots) are left unquoted.
/// Values with colons, quotes, special chars are single-quoted.
fn yaml_string_value(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
// Check if the value needs quoting
let needs_quoting = s.contains(':')
|| s.contains('#')
|| s.contains('\'')
|| s.contains('"')
|| s.contains('\n')
|| s.contains('{')
|| s.contains('}')
|| s.contains('[')
|| s.contains(']')
|| s.contains(',')
|| s.contains('&')
|| s.contains('*')
|| s.contains('!')
|| s.contains('|')
|| s.contains('>')
|| s.contains('%')
|| s.contains('@')
|| s.contains('`')
|| s.starts_with(' ')
|| s.ends_with(' ')
|| s.starts_with('-')
|| s.starts_with('?')
|| s == "true"
|| s == "false"
|| s == "null"
|| s == "yes"
|| s == "no"
|| s == "on"
|| s == "off";
if needs_quoting {
// Use single quotes, escaping internal single quotes by doubling them
let escaped = s.replace('\'', "''");
format!("'{escaped}'")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample")
}
#[test]
fn split_basic() {
let input = "---\nfoo: bar\n---\nbody text\n";
let (yaml, body) = split_frontmatter(input).unwrap();
assert_eq!(yaml, "foo: bar");
assert_eq!(body, "body text\n");
}
#[test]
fn split_no_frontmatter() {
assert!(split_frontmatter("no frontmatter here").is_none());
}
#[test]
fn split_docstring() {
let input = "\"\"\"\n---\nfoo: bar\n---\n\"\"\"\nbody\n";
let (yaml, body) = split_docstring_frontmatter(input).unwrap();
assert_eq!(yaml, "foo: bar");
assert_eq!(body, "body\n");
}
#[test]
fn parse_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_post_file(&content).unwrap();
assert_eq!(fm.id, "40a83ab1-423d-4310-aac4-642d84675007");
assert_eq!(fm.title, "Esmeralda");
assert_eq!(fm.slug, "esmeralda");
assert_eq!(fm.status, "published");
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]);
assert_eq!(fm.categories, vec!["picture"]);
assert_eq!(fm.language.as_deref(), Some("es"));
assert_eq!(fm.published_at, Some(1131883200000));
assert_eq!(fm.created_at, 1131883200000);
assert!(body.contains("Esmeralda"));
}
#[test]
fn parse_ghostty() {
let path = fixture_dir().join("posts/2026/03/ghostty.md");
let content = fs::read_to_string(path).unwrap();
let (fm, _body) = read_post_file(&content).unwrap();
assert_eq!(fm.id, "6745981d-da41-4cfd-80ec-95ad339acf6f");
assert_eq!(fm.title, "Ghostty");
assert_eq!(fm.slug, "ghostty");
assert_eq!(fm.tags, vec!["programmierung", "sysadmin", "mac-os-x"]);
assert_eq!(fm.categories, vec!["aside"]);
assert!(fm.language.is_none());
}
#[test]
fn parse_cmux() {
let path = fixture_dir().join("posts/2026/03/cmux-das-terminal-fur-multitasking.md");
let content = fs::read_to_string(path).unwrap();
let (fm, _body) = read_post_file(&content).unwrap();
assert_eq!(fm.slug, "cmux-das-terminal-fur-multitasking");
assert!(fm.title.contains("cmux"));
}
#[test]
fn parse_esmeralda_en_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_translation_file(&content).unwrap();
assert_eq!(fm.translation_for, "40a83ab1-423d-4310-aac4-642d84675007");
assert_eq!(fm.language, "en");
assert_eq!(fm.title, "Esmeralda");
assert!(body.contains("Esmeralda"));
}
#[test]
fn parse_esmeralda_de_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.de.md");
let content = fs::read_to_string(path).unwrap();
let (fm, _body) = read_translation_file(&content).unwrap();
assert_eq!(fm.language, "de");
}
#[test]
fn roundtrip_post_frontmatter() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_post_file(&content).unwrap();
// Write back out
let yaml = fm.to_yaml();
let output = format_frontmatter(&yaml, &body);
// Parse again
let (fm2, body2) = read_post_file(&output).unwrap();
assert_eq!(fm.id, fm2.id);
assert_eq!(fm.title, fm2.title);
assert_eq!(fm.slug, fm2.slug);
assert_eq!(fm.status, fm2.status);
assert_eq!(fm.created_at, fm2.created_at);
assert_eq!(fm.updated_at, fm2.updated_at);
assert_eq!(fm.tags, fm2.tags);
assert_eq!(fm.categories, fm2.categories);
assert_eq!(fm.language, fm2.language);
assert_eq!(fm.published_at, fm2.published_at);
assert_eq!(body, body2);
}
#[test]
fn golden_output_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
}
#[test]
fn golden_output_ghostty() {
let path = fixture_dir().join("posts/2026/03/ghostty.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for ghostty.md");
}
#[test]
fn golden_output_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_translation_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md");
}
#[test]
fn yaml_quoting() {
assert_eq!(yaml_string_value("simple"), "simple");
assert_eq!(yaml_string_value("has: colon"), "'has: colon'");
assert_eq!(yaml_string_value("true"), "'true'");
assert_eq!(yaml_string_value(""), "''");
}
#[test]
fn conditional_fields_omitted_when_empty() {
let fm = PostFrontmatter {
id: "test-id".into(),
title: "Test".into(),
slug: "test".into(),
status: "draft".into(),
created_at: 1131883200000,
updated_at: 1131883200000,
tags: vec![],
categories: vec![],
excerpt: None,
author: None,
language: None,
template_slug: None,
do_not_translate: false,
published_at: None,
};
let yaml = fm.to_yaml();
assert!(!yaml.contains("excerpt"));
assert!(!yaml.contains("author"));
assert!(!yaml.contains("language"));
assert!(!yaml.contains("doNotTranslate"));
assert!(!yaml.contains("templateSlug"));
assert!(!yaml.contains("publishedAt"));
}
#[test]
fn do_not_translate_written_when_true() {
let fm = PostFrontmatter {
id: "test-id".into(),
title: "Test".into(),
slug: "test".into(),
status: "draft".into(),
created_at: 1131883200000,
updated_at: 1131883200000,
tags: vec![],
categories: vec![],
excerpt: None,
author: None,
language: None,
template_slug: None,
do_not_translate: true,
published_at: None,
};
let yaml = fm.to_yaml();
assert!(yaml.contains("doNotTranslate: true"));
}
// --- Template frontmatter tests ---
#[test]
fn parse_fixture_template() {
let path = fixture_dir().join("templates/testvorlage.liquid");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_template_file(&content).unwrap();
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
assert_eq!(fm.project_id.as_deref(), Some("1979237c-034d-41f6-99a0-f35eb57b3f6c"));
assert_eq!(fm.slug, "testvorlage");
assert_eq!(fm.title, "Testvorlage");
assert_eq!(fm.kind, "post");
assert!(fm.enabled);
assert_eq!(fm.version, 3);
assert!(body.contains("<div>"));
}
#[test]
fn golden_output_template() {
let path = fixture_dir().join("templates/testvorlage.liquid");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_template_file(&expected).unwrap();
let actual = write_template_file(&fm, &body);
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid");
}
// --- Script frontmatter tests ---
#[test]
fn parse_fixture_script_bgg() {
let path = fixture_dir().join("scripts/bgg_link.py");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_script_file(&content).unwrap();
assert_eq!(fm.id, "2b393cae-84b9-426f-b4cf-4902aea79d7d");
assert_eq!(fm.slug, "bgg_link");
assert_eq!(fm.title, "bgg link");
assert_eq!(fm.kind, "transform");
assert_eq!(fm.entrypoint, "normalize_blogmark");
assert!(fm.enabled);
assert_eq!(fm.version, 12);
assert!(body.contains("def normalize_blogmark"));
}
#[test]
fn parse_fixture_script_test() {
let path = fixture_dir().join("scripts/test_script.py");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_script_file(&content).unwrap();
assert_eq!(fm.slug, "test_script");
assert_eq!(fm.kind, "utility");
assert_eq!(fm.entrypoint, "main");
assert!(body.contains("print"));
}
#[test]
fn template_frontmatter_roundtrip() {
let fm = TemplateFrontmatter {
id: "test-uuid".into(),
project_id: Some("proj-uuid".into()),
slug: "my-template".into(),
title: "My Template".into(),
kind: "post".into(),
enabled: true,
version: 1,
created_at: 1131883200000,
updated_at: 1131883200000,
};
let yaml = fm.to_yaml();
let parsed = TemplateFrontmatter::from_yaml(&yaml).unwrap();
assert_eq!(parsed.id, fm.id);
assert_eq!(parsed.slug, fm.slug);
assert_eq!(parsed.kind, fm.kind);
assert_eq!(parsed.version, fm.version);
}
#[test]
fn script_frontmatter_roundtrip() {
let fm = ScriptFrontmatter {
id: "test-uuid".into(),
project_id: None,
slug: "my-script".into(),
title: "My Script".into(),
kind: "utility".into(),
entrypoint: "main".into(),
enabled: true,
version: 2,
created_at: 1131883200000,
updated_at: 1131883200000,
};
let yaml = fm.to_yaml();
let parsed = ScriptFrontmatter::from_yaml(&yaml).unwrap();
assert_eq!(parsed.id, fm.id);
assert_eq!(parsed.entrypoint, "main");
assert_eq!(parsed.version, 2);
}
}

View File

@@ -1,5 +1,14 @@
mod slug; mod slug;
mod checksum; mod checksum;
pub mod timestamp;
pub mod atomic_write;
pub mod paths;
pub mod frontmatter;
pub mod sidecar;
pub mod thumbnail;
pub use slug::{slugify, ensure_unique}; pub use slug::{slugify, ensure_unique};
pub use checksum::content_hash; pub use checksum::content_hash;
pub use timestamp::{unix_ms_to_iso, iso_to_unix_ms, year_month_from_unix_ms, year_month_day_from_unix_ms, now_unix_ms};
pub use atomic_write::{atomic_write, atomic_write_str};
pub use paths::*;

View File

@@ -0,0 +1,107 @@
use super::timestamp::year_month_from_unix_ms;
/// Post file path: `posts/YYYY/MM/{slug}.md` from `created_at` unix ms.
pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
let (y, m) = year_month_from_unix_ms(created_at_ms);
format!("posts/{y}/{m}/{slug}.md")
}
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
/// post's `created_at`.
pub fn translation_file_path(canonical_created_at_ms: i64, canonical_slug: &str, language: &str) -> String {
let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
}
/// Media directory path: `media/YYYY/MM/` from `created_at`.
pub fn media_dir_path(created_at_ms: i64) -> String {
let (y, m) = year_month_from_unix_ms(created_at_ms);
format!("media/{y}/{m}/")
}
/// Media sidecar path: `{binary_file_path}.meta`.
pub fn media_sidecar_path(file_path: &str) -> String {
format!("{file_path}.meta")
}
/// Media translation sidecar path: `{binary_file_path}.{lang}.meta`.
pub fn media_translation_sidecar_path(file_path: &str, language: &str) -> String {
format!("{file_path}.{language}.meta")
}
/// Thumbnail path: `thumbnails/{id[0..2]}/{id}-{size}.{ext}`.
pub fn thumbnail_path(id: &str, size: &str, ext: &str) -> String {
let prefix = &id[..2.min(id.len())];
format!("thumbnails/{prefix}/{id}-{size}.{ext}")
}
/// Template file path: `templates/{slug}.liquid`.
pub fn template_file_path(slug: &str) -> String {
format!("templates/{slug}.liquid")
}
/// Script file path: `scripts/{slug}.lua`.
pub fn script_file_path(slug: &str) -> String {
format!("scripts/{slug}.lua")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn post_path_from_fixture() {
// esmeralda created_at: 2005-11-13T12:00:00.000Z = 1131883200000
assert_eq!(
post_file_path(1131883200000, "esmeralda"),
"posts/2005/11/esmeralda.md"
);
}
#[test]
fn post_path_march_2026() {
// ghostty created_at: 2026-03-13
let ms = 1773583200000_i64; // approx 2026-03-13
let path = post_file_path(ms, "ghostty");
assert!(path.starts_with("posts/2026/03/"));
assert!(path.ends_with("ghostty.md"));
}
#[test]
fn translation_path() {
assert_eq!(
translation_file_path(1131883200000, "esmeralda", "en"),
"posts/2005/11/esmeralda.en.md"
);
}
#[test]
fn media_paths() {
assert_eq!(
media_sidecar_path("media/2005/11/eb0cf9d7.jpg"),
"media/2005/11/eb0cf9d7.jpg.meta"
);
assert_eq!(
media_translation_sidecar_path("media/2005/11/eb0cf9d7.jpg", "fr"),
"media/2005/11/eb0cf9d7.jpg.fr.meta"
);
}
#[test]
fn thumbnail_paths() {
assert_eq!(
thumbnail_path("eb0cf9d7-6fbd-4b74-9be3-759d6e16f240", "small", "webp"),
"thumbnails/eb/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240-small.webp"
);
assert_eq!(
thumbnail_path("eb0cf9d7-6fbd-4b74-9be3-759d6e16f240", "ai", "jpg"),
"thumbnails/eb/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240-ai.jpg"
);
}
#[test]
fn template_and_script_paths() {
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid");
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
}
}

View File

@@ -0,0 +1,393 @@
use crate::model::Media;
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
/// Parsed media sidecar fields.
#[derive(Debug, Clone)]
pub struct MediaSidecar {
pub id: String,
pub original_name: String,
pub mime_type: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
pub author: Option<String>,
pub language: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub tags: Vec<String>,
pub linked_post_ids: Vec<String>,
}
/// Parsed media translation sidecar fields.
#[derive(Debug, Clone)]
pub struct MediaTranslationSidecar {
pub translation_for: String,
pub language: String,
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
}
impl MediaSidecar {
pub fn from_media(media: &Media, linked_post_ids: &[String]) -> Self {
Self {
id: media.id.clone(),
original_name: media.original_name.clone(),
mime_type: media.mime_type.clone(),
size: media.size,
width: media.width,
height: media.height,
title: media.title.clone(),
alt: media.alt.clone(),
caption: media.caption.clone(),
author: media.author.clone(),
language: media.language.clone(),
created_at: media.created_at,
updated_at: media.updated_at,
tags: media.tags.clone(),
linked_post_ids: linked_post_ids.to_vec(),
}
}
/// Serialize to the hand-built YAML-like format matching TypeScript output.
pub fn to_string(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
lines.push(format!("id: {}", self.id));
lines.push(format!("originalName: \"{}\"", escape_double_quotes(&self.original_name)));
lines.push(format!("mimeType: {}", self.mime_type));
lines.push(format!("size: {}", self.size));
if let Some(w) = self.width {
lines.push(format!("width: {w}"));
}
if let Some(h) = self.height {
lines.push(format!("height: {h}"));
}
if let Some(ref title) = self.title {
if !title.is_empty() {
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
}
if let Some(ref alt) = self.alt {
if !alt.is_empty() {
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
}
if let Some(ref caption) = self.caption {
if !caption.is_empty() {
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
}
if let Some(ref author) = self.author {
if !author.is_empty() {
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
}
}
if let Some(ref language) = self.language {
if !language.is_empty() {
lines.push(format!("language: {language}"));
}
}
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
// Tags: inline JSON array
if self.tags.is_empty() {
lines.push("tags: []".into());
} else {
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect();
lines.push(format!("tags: [{}]", quoted.join(", ")));
}
// linkedPostIds: only if non-empty
if !self.linked_post_ids.is_empty() {
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect();
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
}
lines.push("---".into());
lines.join("\n")
}
}
impl MediaTranslationSidecar {
/// Serialize to the hand-built format.
pub fn to_string(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
if let Some(ref title) = self.title {
if !title.is_empty() {
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
}
if let Some(ref alt) = self.alt {
if !alt.is_empty() {
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
}
if let Some(ref caption) = self.caption {
if !caption.is_empty() {
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
}
lines.push("---".into());
lines.join("\n")
}
}
/// Parse a canonical media sidecar.
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
// Strip --- delimiters
let inner = content.trim();
let inner = inner.strip_prefix("---").ok_or("missing opening ---")?;
let inner = inner.trim_start_matches('\n');
let inner = inner.strip_suffix("---").ok_or("missing closing ---")?;
let inner = inner.trim_end_matches('\n');
let mut id = None;
let mut original_name = None;
let mut mime_type = None;
let mut size = None;
let mut width = None;
let mut height = None;
let mut title = None;
let mut alt = None;
let mut caption = None;
let mut author = None;
let mut language = None;
let mut created_at = None;
let mut updated_at = None;
let mut tags = Vec::new();
let mut linked_post_ids = Vec::new();
for line in inner.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(": ") {
let value = value.trim();
match key.trim() {
"id" => id = Some(value.to_string()),
"originalName" => original_name = Some(unquote_double(value)),
"mimeType" => mime_type = Some(value.to_string()),
"size" => size = value.parse::<i64>().ok(),
"width" => width = value.parse::<i32>().ok(),
"height" => height = value.parse::<i32>().ok(),
"title" => title = Some(unquote_double(value)),
"alt" => alt = Some(unquote_double(value)),
"caption" => caption = Some(unquote_double(value)),
"author" => author = Some(unquote_double(value)),
"language" => language = Some(value.to_string()),
"createdAt" => created_at = iso_to_unix_ms(value).ok(),
"updatedAt" => updated_at = iso_to_unix_ms(value).ok(),
"tags" => tags = parse_inline_json_array(value),
"linkedPostIds" => linked_post_ids = parse_inline_json_array(value),
_ => {} // ignore unknown fields
}
}
}
Ok(MediaSidecar {
id: id.ok_or("missing 'id'")?,
original_name: original_name.ok_or("missing 'originalName'")?,
mime_type: mime_type.ok_or("missing 'mimeType'")?,
size: size.ok_or("missing 'size'")?,
width,
height,
title,
alt,
caption,
author,
language,
created_at: created_at.ok_or("missing 'createdAt'")?,
updated_at: updated_at.ok_or("missing 'updatedAt'")?,
tags,
linked_post_ids,
})
}
/// Parse a media translation sidecar.
pub fn read_translation_sidecar(content: &str) -> Result<MediaTranslationSidecar, String> {
let inner = content.trim();
let inner = inner.strip_prefix("---").ok_or("missing opening ---")?;
let inner = inner.trim_start_matches('\n');
let inner = inner.strip_suffix("---").ok_or("missing closing ---")?;
let inner = inner.trim_end_matches('\n');
let mut translation_for = None;
let mut language = None;
let mut title = None;
let mut alt = None;
let mut caption = None;
for line in inner.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(": ") {
let value = value.trim();
match key.trim() {
"translationFor" => translation_for = Some(value.to_string()),
"language" => language = Some(value.to_string()),
"title" => title = Some(unquote_double(value)),
"alt" => alt = Some(unquote_double(value)),
"caption" => caption = Some(unquote_double(value)),
_ => {}
}
}
}
Ok(MediaTranslationSidecar {
translation_for: translation_for.ok_or("missing 'translationFor'")?,
language: language.ok_or("missing 'language'")?,
title,
alt,
caption,
})
}
// --- Helpers ---
fn escape_double_quotes(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn unquote_double(s: &str) -> String {
let s = s.trim();
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
let inner = &s[1..s.len() - 1];
inner.replace("\\\"", "\"").replace("\\\\", "\\")
} else {
s.to_string()
}
}
/// Parse inline JSON-like array: `["a", "b"]` or `[]`.
fn parse_inline_json_array(s: &str) -> Vec<String> {
let s = s.trim();
if s == "[]" {
return Vec::new();
}
// Try serde_json first
if let Ok(arr) = serde_json::from_str::<Vec<String>>(s) {
return arr;
}
// Fallback: strip brackets and split by comma
let inner = s.trim_start_matches('[').trim_end_matches(']');
inner
.split(',')
.map(|item| unquote_double(item.trim()))
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample")
}
#[test]
fn parse_fixture_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let content = fs::read_to_string(path).unwrap();
let sc = read_sidecar(&content).unwrap();
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
assert_eq!(sc.original_name, "CRW_1121.jpg");
assert_eq!(sc.mime_type, "image/jpeg");
assert_eq!(sc.size, 706358);
assert_eq!(sc.width, Some(1800));
assert_eq!(sc.height, Some(1200));
assert_eq!(sc.title.as_deref(), Some("Esmeralda"));
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
assert!(sc.tags.is_empty());
assert_eq!(sc.linked_post_ids, vec!["40a83ab1-423d-4310-aac4-642d84675007"]);
}
#[test]
fn golden_output_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap();
let actual = sc.to_string();
// Compare trimmed (fixture may or may not have trailing newline)
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar");
}
#[test]
fn roundtrip_sidecar() {
let sc = MediaSidecar {
id: "test-uuid".into(),
original_name: "photo.jpg".into(),
mime_type: "image/jpeg".into(),
size: 12345,
width: Some(800),
height: Some(600),
title: Some("My Photo".into()),
alt: Some("A nice photo".into()),
caption: None,
author: Some("Hugo".into()),
language: None,
created_at: 1131883200000,
updated_at: 1131883200000,
tags: vec!["nature".into(), "photo".into()],
linked_post_ids: vec![],
};
let output = sc.to_string();
let parsed = read_sidecar(&output).unwrap();
assert_eq!(parsed.id, sc.id);
assert_eq!(parsed.original_name, sc.original_name);
assert_eq!(parsed.size, sc.size);
assert_eq!(parsed.title, sc.title);
assert_eq!(parsed.alt, sc.alt);
assert!(parsed.caption.is_none());
assert_eq!(parsed.tags, sc.tags);
}
#[test]
fn translation_sidecar_roundtrip() {
let ts = MediaTranslationSidecar {
translation_for: "uuid-123".into(),
language: "fr".into(),
title: Some("Mon titre".into()),
alt: Some("Description".into()),
caption: None,
};
let output = ts.to_string();
let parsed = read_translation_sidecar(&output).unwrap();
assert_eq!(parsed.translation_for, "uuid-123");
assert_eq!(parsed.language, "fr");
assert_eq!(parsed.title.as_deref(), Some("Mon titre"));
assert_eq!(parsed.alt.as_deref(), Some("Description"));
assert!(parsed.caption.is_none());
}
#[test]
fn inline_json_array_parsing() {
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
assert_eq!(
parse_inline_json_array("[\"a\", \"b\"]"),
vec!["a", "b"]
);
}
#[test]
fn escape_and_unquote() {
assert_eq!(unquote_double("\"hello\""), "hello");
assert_eq!(unquote_double("plain"), "plain");
assert_eq!(escape_double_quotes("say \"hi\""), "say \\\"hi\\\"");
}
}

View File

@@ -0,0 +1,241 @@
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageFormat, ImageReader};
use std::fs;
use std::io::Cursor;
use std::path::Path;
/// Thumbnail size configuration.
pub struct ThumbnailSize {
pub name: &'static str,
pub width: u32,
pub height: u32,
pub format: ThumbnailFormat,
pub fit: ThumbnailFit,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ThumbnailFormat {
Webp,
Jpeg,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ThumbnailFit {
/// Scale to fit inside, no enlargement.
Inside,
/// Scale to fill, crop center (cover).
Cover,
/// Scale to fit, letterbox with black background.
Contain,
}
/// Standard thumbnail sizes matching TypeScript implementation.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
];
/// Generate a single thumbnail from a source image file.
pub fn generate_thumbnail(
source: &Path,
dest: &Path,
size: &ThumbnailSize,
quality: u8,
) -> Result<(), String> {
let img = load_and_orient(source)?;
let resized = match size.fit {
ThumbnailFit::Inside => {
let (orig_w, orig_h) = img.dimensions();
// Don't enlarge
if orig_w <= size.width && orig_h <= size.height {
img.clone()
} else {
img.resize(size.width, size.height, FilterType::Lanczos3)
}
}
ThumbnailFit::Cover => {
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
}
ThumbnailFit::Contain => {
// Resize to fit, then place on black background
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
let (rw, rh) = resized.dimensions();
let mut canvas = DynamicImage::new_rgb8(size.width, size.height);
let offset_x = (size.width - rw) / 2;
let offset_y = (size.height - rh) / 2;
image::imageops::overlay(&mut canvas, &resized, offset_x as i64, offset_y as i64);
canvas
}
};
// Ensure parent directory exists
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
}
match size.format {
ThumbnailFormat::Webp => {
// Encode as WebP via image crate
let mut buf = Cursor::new(Vec::new());
resized
.write_to(&mut buf, ImageFormat::WebP)
.map_err(|e| format!("WebP encode: {e}"))?;
let _ = quality; // image crate WebP encoder doesn't expose quality directly
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
}
ThumbnailFormat::Jpeg => {
let rgb = resized.to_rgb8();
let mut buf = Cursor::new(Vec::new());
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
encoder
.encode_image(&rgb)
.map_err(|e| format!("JPEG encode: {e}"))?;
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
}
}
Ok(())
}
/// Generate all standard thumbnails for a media item.
pub fn generate_all_thumbnails(
source: &Path,
thumbnails_dir: &Path,
media_id: &str,
) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
let prefix = &media_id[..2.min(media_id.len())];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let dest = thumbnails_dir
.join(prefix)
.join(format!("{media_id}-{}.{ext}", size.name));
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
generate_thumbnail(source, &dest, size, quality)?;
paths.push(dest.to_string_lossy().to_string());
}
Ok(paths)
}
/// Load an image and apply EXIF orientation.
fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
let reader = ImageReader::open(path)
.map_err(|e| format!("open image: {e}"))?
.with_guessed_format()
.map_err(|e| format!("guess format: {e}"))?;
let img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
// EXIF orientation is handled by the image crate for JPEG automatically
// when reading. For other formats, no EXIF orientation exists.
Ok(img)
}
/// Extract image dimensions from a file.
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
let img = ImageReader::open(path)
.map_err(|e| format!("open: {e}"))?
.with_guessed_format()
.map_err(|e| format!("format: {e}"))?
.decode()
.map_err(|e| format!("decode: {e}"))?;
Ok(img.dimensions())
}
/// Detect MIME type from file extension.
pub fn mime_from_extension(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
"bmp" => "image/bmp",
"heic" => "image/heic",
"heif" => "image/heif",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_png(dir: &Path) -> std::path::PathBuf {
let path = dir.join("test.png");
// Create a small 100x80 red PNG
let img = DynamicImage::new_rgb8(100, 80);
img.save(&path).unwrap();
path
}
#[test]
fn generate_small_thumbnail() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb.webp");
let size = &THUMBNAIL_SIZES[0]; // small: 150x150
generate_thumbnail(&source, &dest, size, 80).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
// 100x80 is already smaller than 150x150, so no resize (Inside fit)
assert_eq!(w, 100);
assert_eq!(h, 80);
}
#[test]
fn generate_ai_thumbnail_contain() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb-ai.jpg");
let size = &THUMBNAIL_SIZES[3]; // ai: 448x448 contain
generate_thumbnail(&source, &dest, size, 85).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
assert_eq!(w, 448);
assert_eq!(h, 448);
}
#[test]
fn generate_all() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let thumb_dir = dir.path().join("thumbnails");
let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap();
assert_eq!(paths.len(), 4);
for p in &paths {
assert!(Path::new(p).exists(), "thumbnail missing: {p}");
}
}
#[test]
fn mime_detection() {
assert_eq!(mime_from_extension("jpg"), "image/jpeg");
assert_eq!(mime_from_extension("PNG"), "image/png");
assert_eq!(mime_from_extension("webp"), "image/webp");
assert_eq!(mime_from_extension("xyz"), "application/octet-stream");
}
#[test]
fn dimensions_extraction() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let (w, h) = image_dimensions(&source).unwrap();
assert_eq!(w, 100);
assert_eq!(h, 80);
}
}

View File

@@ -0,0 +1,86 @@
use chrono::{DateTime, Utc, TimeZone};
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
pub fn unix_ms_to_iso(ms: i64) -> String {
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
/// Parse an ISO 8601 string back to Unix milliseconds.
pub fn iso_to_unix_ms(iso: &str) -> Result<i64, String> {
let dt = iso
.parse::<DateTime<Utc>>()
.map_err(|e| format!("invalid ISO 8601 timestamp '{iso}': {e}"))?;
Ok(dt.timestamp_millis())
}
/// Extract zero-padded (YYYY, MM) from Unix milliseconds.
pub fn year_month_from_unix_ms(ms: i64) -> (String, String) {
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
(dt.format("%Y").to_string(), dt.format("%m").to_string())
}
/// Extract zero-padded (YYYY, MM, DD) from Unix milliseconds.
pub fn year_month_day_from_unix_ms(ms: i64) -> (String, String, String) {
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
(
dt.format("%Y").to_string(),
dt.format("%m").to_string(),
dt.format("%d").to_string(),
)
}
/// Current time as Unix milliseconds.
pub fn now_unix_ms() -> i64 {
Utc::now().timestamp_millis()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_conversion() {
let ms: i64 = 1131883200000; // 2005-11-13T12:00:00.000Z
let iso = unix_ms_to_iso(ms);
assert_eq!(iso, "2005-11-13T12:00:00.000Z");
assert_eq!(iso_to_unix_ms(&iso).unwrap(), ms);
}
#[test]
fn roundtrip_with_real_millis() {
let ms: i64 = 1741376097243; // some real timestamp with millis
let iso = unix_ms_to_iso(ms);
assert!(iso.ends_with("Z"));
assert_eq!(iso_to_unix_ms(&iso).unwrap(), ms);
}
#[test]
fn year_month_extraction() {
let ms: i64 = 1131883200000;
let (y, m) = year_month_from_unix_ms(ms);
assert_eq!(y, "2005");
assert_eq!(m, "11");
}
#[test]
fn year_month_day_extraction() {
let ms: i64 = 1131883200000;
let (y, m, d) = year_month_day_from_unix_ms(ms);
assert_eq!(y, "2005");
assert_eq!(m, "11");
assert_eq!(d, "13");
}
#[test]
fn now_is_recent() {
let ms = now_unix_ms();
// Should be after 2024-01-01
assert!(ms > 1704067200000);
}
#[test]
fn invalid_iso_returns_error() {
assert!(iso_to_unix_ms("not a date").is_err());
}
}

File diff suppressed because it is too large Load Diff