chore: source formatting and spec allignment

This commit is contained in:
hugo committed 2026-07-18 14:20:23 +02:00
1 parent a594b99e90
commit 16a210c0ad
119 files changed
+8868 -5250

No files matched your search

Generated
+10
View File
@@ -805,6 +805,7 @@ dependencies = [
"liquid-core", "liquid-core",
"pagefind", "pagefind",
"pulldown-cmark", "pulldown-cmark",
"quick-xml 0.41.0",
"rayon", "rayon",
"refinery", "refinery",
"reqwest", "reqwest",
@@ -5638,6 +5639,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
+1
View File
@@ -37,6 +37,7 @@ open = "5"
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
liquid = "0.26" liquid = "0.26"
liquid-core = { version = "0.26", features = ["derive"] } liquid-core = { version = "0.26", features = ["derive"] }
quick-xml = "0.41"
rayon = "1.10" rayon = "1.10"
pagefind = "1.5.2" pagefind = "1.5.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
+2 -2
View File
@@ -227,7 +227,7 @@ Rules:
### `bds-ui/views` ### `bds-ui/views`
- preview controls - internal Markdown/Preview switch backed by Wry, plus external Open in Browser command
- generation progress display - generation progress display
- render errors and diagnostics - render errors and diagnostics
- AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate) - AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate)
@@ -467,4 +467,4 @@ Code-vs-spec gaps from the 2026-07-18 spec sync. Each task names its authoritati
## Exit Rule ## Exit Rule
Do not start extension buckets until Milestones M5 and M6 are complete and the Rust app is already a credible replacement for the baseline authoring and publishing workflow. Do not start extension buckets until Milestones M5 and M6 are complete and the Rust app is already a credible replacement for the baseline authoring and publishing workflow.
+8 -6
View File
@@ -25,7 +25,7 @@ This plan is split into multiple documents:
## Non-Negotiable Constraints ## Non-Negotiable Constraints
1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app. 1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app.
2. **No JavaScript anywhere.** No npm, no webview, no JS runtime, no Electron. This is a supply-chain security constraint. The entire app is pure Rust plus native platform APIs. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary. 2. **No JavaScript application runtime.** No npm, Node.js, Electron, or remotely loaded application assets. The required in-editor and style preview panels use Wry to display the localhost-only Rust preview server in the operating system webview; the separate Open in Browser command opens the same preview in the system browser. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary.
3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua. 3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua.
4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish. 4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish.
5. **Template editing is core scope.** Template rendering without template management UI is not sufficient. 5. **Template editing is core scope.** Template rendering without template management UI is not sufficient.
@@ -42,12 +42,14 @@ The application uses the following UI and platform integration stack:
| Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts | | Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts |
| Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) | | Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) |
| File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) | | File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) |
| Internal preview | **wry** | Embedded post and style preview panels backed only by the localhost Rust preview server |
| Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks | | Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks |
### Why this stack ### Why this stack
- **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view). - **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view).
- **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one. - **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one.
- **wry** preserves the baseline app's internal Markdown/Preview workflow using the operating system webview. It is a presentation surface for the loopback preview server, not the application runtime; external-browser preview remains available separately.
- **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE. - **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE.
- The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module. - The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module.
@@ -72,15 +74,15 @@ bds-rust/
- **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies. - **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies.
- **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate. - **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate.
- **bds-ui**: application shell, Iced views and components, message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor. - **bds-ui**: application shell, Iced views and components, embedded localhost preview (wry), message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor.
- **bds-cli**: headless automation surface. Depends on bds-core only. - **bds-cli**: headless automation surface. Depends on bds-core only.
## Distribution Characteristics ## Distribution Characteristics
- **Single static binary.** No external runtime dependencies (no GTK, no Electron, no Node.js). Lua and SQLite are compiled into the binary. - **Single application binary.** No Electron or Node.js runtime. Lua and SQLite are compiled into the binary; internal preview uses the operating system webview supplied by the target platform.
- **Binary size:** ~15–25 MB. - **Binary size:** ~15–25 MB.
- **Memory usage / startup:** no BEAM VM, no browser engine — a single native process. - **Memory usage / startup:** no BEAM VM or bundled browser engine — one native application process plus the operating system webview used while preview is visible.
- **Zero install dependencies:** users download one binary. No Homebrew, no system packages. - **Platform prerequisites:** macOS uses the system WebKit runtime. Windows/Linux packaging must account for Wry's platform webview prerequisites without adding an application-managed JavaScript runtime.
## Split Rationale ## Split Rationale
@@ -98,7 +100,7 @@ The Rust rewrite is not considered successful until the core release can do all
1. Open a real project created by the baseline app. 1. Open a real project created by the baseline app.
2. Create and edit posts, translations, media, tags, templates, and settings. 2. Create and edit posts, translations, media, tags, templates, and settings.
3. Preview drafts and published content locally. 3. Preview drafts and published content both in the post editor's internal preview panel and in the external system browser.
4. Generate a complete site whose output matches the current app modulo approved normalization differences. 4. Generate a complete site whose output matches the current app modulo approved normalization differences.
5. Publish the generated output to a remote target. 5. Publish the generated output to a remote target.
6. Rebuild the database from files and run metadata diff without losing information. 6. Rebuild the database from files and run metadata diff without losing information.
+5 -2
View File
@@ -757,6 +757,8 @@ By Wave 3, bds-editor must support the full feature set documented in the editor
- preview server binds to `127.0.0.1:4123` (localhost only, fixed port) - preview server binds to `127.0.0.1:4123` (localhost only, fixed port)
- preview and generated HTML use local assets only - preview and generated HTML use local assets only
- preview must support drafts and language-prefixed routes - preview must support drafts and language-prefixed routes
- each post editor can switch between Markdown and an internal Wry preview panel backed by the draft preview route
- Open in Browser starts or reuses the same preview server and opens its URL in the external system browser; it does not replace the internal panel
- rendered language is controlled by project settings, not by UI locale - rendered language is controlled by project settings, not by UI locale
### One-shot AI operations ### One-shot AI operations
@@ -792,12 +794,13 @@ Wave 4 introduces the one-shot AI client in `bds-core`. This is a minimal `reqwe
- fixture-based generation comparisons against current app output - fixture-based generation comparisons against current app output
- preview route tests for posts, drafts, assets, media, and language routes - preview route tests for posts, drafts, assets, media, and language routes
- UI tests for switching a post between Markdown and internal preview, plus the external Open in Browser command
- template compatibility tests using current `.liquid` templates - template compatibility tests using current `.liquid` templates
### Done when ### Done when
- the same project can be generated by both apps with matching output - the same project can be generated by both apps with matching output
- preview is accurate enough to be trusted as an authoring tool - internal and external preview are accurate enough to be trusted as authoring tools
- milestone M4 acceptance review passes - milestone M4 acceptance review passes
## Wave 5: Publishing And Operational Integrity ## Wave 5: Publishing And Operational Integrity
@@ -920,4 +923,4 @@ Cross-platform packaging is not core scope, but the architecture does not accumu
## Supporting Docs ## Supporting Docs
- [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md) - [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md)
- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md) - [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md)
+1
View File
@@ -22,6 +22,7 @@ sys-locale = { workspace = true }
pulldown-cmark = { workspace = true } pulldown-cmark = { workspace = true }
liquid = { workspace = true } liquid = { workspace = true }
liquid-core = { workspace = true } liquid-core = { workspace = true }
quick-xml = { workspace = true }
rayon = { workspace = true } rayon = { workspace = true }
pagefind = { workspace = true } pagefind = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
@@ -0,0 +1,5 @@
ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER;
ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER;
ALTER TABLE ai_providers RENAME COLUMN npm TO package_ref;
ALTER TABLE ai_models RENAME COLUMN provider_npm TO provider_package_ref;
+4 -2
View File
@@ -1,6 +1,6 @@
use crate::db::migrations;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::Path; use std::path::Path;
use crate::db::migrations;
/// Database wrapper managing a SQLite connection. /// Database wrapper managing a SQLite connection.
pub struct Database { pub struct Database {
@@ -11,7 +11,9 @@ impl Database {
/// Open an existing bDS project database. /// Open an existing bDS project database.
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> { pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?; conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
)?;
Ok(Self { conn }) Ok(Self { conn })
} }
+95 -43
View File
@@ -162,11 +162,9 @@ pub const POST_TRANSLATION_COLUMNS: &str = "\
id, project_id, translation_for, language, title, excerpt, content, \ id, project_id, translation_for, language, title, excerpt, content, \
status, file_path, checksum, created_at, updated_at, published_at"; status, file_path, checksum, created_at, updated_at, published_at";
pub const POST_LINK_COLUMNS: &str = pub const POST_LINK_COLUMNS: &str = "id, source_post_id, target_post_id, link_text, created_at";
"id, source_post_id, target_post_id, link_text, created_at";
pub const POST_MEDIA_COLUMNS: &str = pub const POST_MEDIA_COLUMNS: &str = "id, project_id, post_id, media_id, sort_order, created_at";
"id, project_id, post_id, media_id, sort_order, created_at";
pub const MEDIA_COLUMNS: &str = "\ pub const MEDIA_COLUMNS: &str = "\
id, project_id, filename, original_name, mime_type, size, \ id, project_id, filename, original_name, mime_type, size, \
@@ -189,8 +187,7 @@ pub const SCRIPT_COLUMNS: &str = "\
pub const SETTING_COLUMNS: &str = "key, value, updated_at"; pub const SETTING_COLUMNS: &str = "key, value, updated_at";
pub const GENERATED_FILE_HASH_COLUMNS: &str = pub const GENERATED_FILE_HASH_COLUMNS: &str = "project_id, relative_path, content_hash, updated_at";
"project_id, relative_path, content_hash, updated_at";
pub const DB_NOTIFICATION_COLUMNS: &str = pub const DB_NOTIFICATION_COLUMNS: &str =
"id, entity_type, entity_id, action, from_cli, seen_at, created_at"; "id, entity_type, entity_id, action, from_cli, seen_at, created_at";
@@ -418,7 +415,10 @@ mod tests {
#[test] #[test]
fn parse_post_status_valid() { fn parse_post_status_valid() {
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft); assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published); assert_eq!(
parse_post_status("published").unwrap(),
PostStatus::Published
);
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived); assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
} }
@@ -431,8 +431,14 @@ mod tests {
fn parse_template_kind_valid() { fn parse_template_kind_valid() {
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post); assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List); assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound); assert_eq!(
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial); parse_template_kind("not_found").unwrap(),
TemplateKind::NotFound
);
assert_eq!(
parse_template_kind("partial").unwrap(),
TemplateKind::Partial
);
} }
#[test] #[test]
@@ -442,36 +448,69 @@ mod tests {
#[test] #[test]
fn parse_template_status_valid() { fn parse_template_status_valid() {
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft); assert_eq!(
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published); parse_template_status("draft").unwrap(),
TemplateStatus::Draft
);
assert_eq!(
parse_template_status("published").unwrap(),
TemplateStatus::Published
);
} }
#[test] #[test]
fn parse_script_kind_valid() { fn parse_script_kind_valid() {
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro); assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility); assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform); assert_eq!(
parse_script_kind("transform").unwrap(),
ScriptKind::Transform
);
} }
#[test] #[test]
fn parse_script_status_valid() { fn parse_script_status_valid() {
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft); assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published); assert_eq!(
parse_script_status("published").unwrap(),
ScriptStatus::Published
);
} }
#[test] #[test]
fn parse_notification_entity_valid() { fn parse_notification_entity_valid() {
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post); assert_eq!(
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media); parse_notification_entity("post").unwrap(),
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script); NotificationEntity::Post
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template); );
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] #[test]
fn parse_notification_action_valid() { fn parse_notification_action_valid() {
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created); assert_eq!(
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated); parse_notification_action("created").unwrap(),
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted); NotificationAction::Created
);
assert_eq!(
parse_notification_action("updated").unwrap(),
NotificationAction::Updated
);
assert_eq!(
parse_notification_action("deleted").unwrap(),
NotificationAction::Deleted
);
} }
// ── JSON helpers ───────────────────────────────────────────────── // ── JSON helpers ─────────────────────────────────────────────────
@@ -504,11 +543,13 @@ mod tests {
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)", VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
[], [],
).unwrap(); ).unwrap();
let p = c.query_row( let p = c
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"), .query_row(
[], &format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
project_from_row, [],
).unwrap(); project_from_row,
)
.unwrap();
assert_eq!(p.id, "p1"); assert_eq!(p.id, "p1");
assert_eq!(p.name, "Blog"); assert_eq!(p.name, "Blog");
assert_eq!(p.slug, "blog"); assert_eq!(p.slug, "blog");
@@ -527,7 +568,8 @@ mod tests {
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at) "INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
VALUES ('p1', 'B', 'b', 0, 1000, 1000)", VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
c.execute( c.execute(
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author, "INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
language, do_not_translate, template_slug, file_path, checksum, language, do_not_translate, template_slug, file_path, checksum,
@@ -541,11 +583,13 @@ mod tests {
1000, 2000, NULL)", 1000, 2000, NULL)",
[], [],
).unwrap(); ).unwrap();
let p = c.query_row( let p = c
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"), .query_row(
[], &format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
post_from_row, [],
).unwrap(); post_from_row,
)
.unwrap();
assert_eq!(p.id, "x"); assert_eq!(p.id, "x");
assert_eq!(p.project_id, "p1"); assert_eq!(p.project_id, "p1");
assert_eq!(p.title, "Hello"); assert_eq!(p.title, "Hello");
@@ -575,19 +619,23 @@ mod tests {
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at) "INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
VALUES ('p1', 'B', 'b', 0, 1000, 1000)", VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
c.execute( c.execute(
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version, "INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
file_path, status, content, created_at, updated_at) file_path, status, content, created_at, updated_at)
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3, VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
'templates/default.liquid', 'draft', 'html', 1000, 2000)", 'templates/default.liquid', 'draft', 'html', 1000, 2000)",
[], [],
).unwrap(); )
let t = c.query_row( .unwrap();
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"), let t = c
[], .query_row(
template_from_row, &format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
).unwrap(); [],
template_from_row,
)
.unwrap();
assert_eq!(t.kind, TemplateKind::NotFound); assert_eq!(t.kind, TemplateKind::NotFound);
assert!(!t.enabled); assert!(!t.enabled);
assert_eq!(t.version, 3); assert_eq!(t.version, 3);
@@ -604,11 +652,15 @@ mod tests {
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)", VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
[], [],
).unwrap(); ).unwrap();
let n = c.query_row( let n = c
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"), .query_row(
[], &format!(
db_notification_from_row, "SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"
).unwrap(); ),
[],
db_notification_from_row,
)
.unwrap();
assert_eq!(n.entity_type, NotificationEntity::Media); assert_eq!(n.entity_type, NotificationEntity::Media);
assert_eq!(n.entity_id, "m1"); assert_eq!(n.entity_id, "m1");
assert_eq!(n.action, NotificationAction::Deleted); assert_eq!(n.action, NotificationAction::Deleted);
+183 -66
View File
@@ -1,5 +1,7 @@
use rust_stemmers::{Algorithm, Stemmer};
use rusqlite::Connection; use rusqlite::Connection;
use rust_stemmers::{Algorithm, Stemmer};
use crate::util::calendar_range_unix_ms;
/// Create FTS5 virtual tables at runtime (not in migrations per spec). /// Create FTS5 virtual tables at runtime (not in migrations per spec).
/// ///
@@ -22,7 +24,7 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
caption, caption,
original_name, original_name,
tags tags
);" );",
)?; )?;
Ok(()) Ok(())
} }
@@ -82,6 +84,10 @@ pub struct MediaTranslationFts {
/// Index a post in the FTS table with separate columns per spec. /// Index a post in the FTS table with separate columns per spec.
/// ///
/// Translation titles go to the title column, excerpts to excerpt, content to content. /// Translation titles go to the title column, excerpts to excerpt, content to content.
#[expect(
clippy::too_many_arguments,
reason = "FTS columns mirror the persisted post fields"
)]
pub fn index_post( pub fn index_post(
conn: &Connection, conn: &Connection,
post_id: &str, post_id: &str,
@@ -137,6 +143,10 @@ pub fn index_post(
/// Index a media item in the FTS table with separate columns per spec. /// Index a media item in the FTS table with separate columns per spec.
/// ///
/// Translation titles go to the title column, alts to alt, captions to caption. /// Translation titles go to the title column, alts to alt, captions to caption.
#[expect(
clippy::too_many_arguments,
reason = "FTS columns mirror the persisted media fields"
)]
pub fn index_media( pub fn index_media(
conn: &Connection, conn: &Connection,
media_id: &str, media_id: &str,
@@ -209,14 +219,15 @@ pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::R
} }
/// Search posts by full-text query. Returns matching post IDs. /// Search posts by full-text query. Returns matching post IDs.
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> { pub fn search_posts(
conn: &Connection,
query: &str,
language: &str,
) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language); let stemmed = stem_text(query, language);
let mut stmt = conn.prepare( let mut stmt =
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank" 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))?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
rows.collect() rows.collect()
} }
@@ -255,16 +266,28 @@ pub fn search_posts_filtered(
// Get FTS matches first // Get FTS matches first
let fts_ids = search_posts(conn, query, language)?; let fts_ids = search_posts(conn, query, language)?;
if fts_ids.is_empty() { if fts_ids.is_empty() {
return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) }); return Ok(SearchResults {
post_ids: vec![],
total: 0,
offset: filters.offset.unwrap_or(0),
limit: filters.limit.unwrap_or(0),
});
} }
// Apply filters by querying posts table // Apply filters by querying posts table
let placeholders: Vec<String> = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); let placeholders: Vec<String> = fts_ids
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect();
let mut sql = format!( let mut sql = format!(
"SELECT id FROM posts WHERE id IN ({}) ", "SELECT id FROM posts WHERE id IN ({}) ",
placeholders.join(",") placeholders.join(",")
); );
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>).collect(); let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids
.iter()
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>)
.collect();
let mut param_idx = fts_ids.len() + 1; let mut param_idx = fts_ids.len() + 1;
if let Some(status) = filters.status { if let Some(status) = filters.status {
@@ -296,29 +319,21 @@ pub fn search_posts_filtered(
} }
if let Some(year) = filters.year { if let Some(year) = filters.year {
// Filter by year from created_at (unix ms) let (start, end) =
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); sql.push_str(&format!(
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1)); "AND created_at >= ?{param_idx} AND created_at < ?{} ",
param_idx + 1
));
params.push(Box::new(start)); params.push(Box::new(start));
params.push(Box::new(end)); params.push(Box::new(end));
param_idx += 2; param_idx += 2;
} }
if let Some(month) = filters.month {
if let Some(year) = filters.year {
let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) };
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
}
if let Some(lang) = filters.language { if let Some(lang) = filters.language {
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) ")); sql.push_str(&format!(
"AND (language = ?{param_idx} OR language IS NULL) "
));
params.push(Box::new(lang.to_string())); params.push(Box::new(lang.to_string()));
param_idx += 1; param_idx += 1;
} }
@@ -349,7 +364,8 @@ pub fn search_posts_filtered(
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts"); let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
let total: usize = { let total: usize = {
let mut stmt = conn.prepare(&count_sql)?; let mut stmt = conn.prepare(&count_sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))? stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
}; };
@@ -365,22 +381,26 @@ pub fn search_posts_filtered(
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| { let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
row.get::<_, String>(0)
})?;
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?; let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(SearchResults { post_ids, total, offset, limit }) Ok(SearchResults {
post_ids,
total,
offset,
limit,
})
} }
/// Search media by full-text query. Returns matching media IDs. /// Search media by full-text query. Returns matching media IDs.
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> { pub fn search_media(
conn: &Connection,
query: &str,
language: &str,
) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language); let stemmed = stem_text(query, language);
let mut stmt = conn.prepare( let mut stmt =
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank" 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))?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
rows.collect() rows.collect()
} }
@@ -526,7 +546,8 @@ mod tests {
language: "en".into(), language: "en".into(),
}], }],
"en", "en",
).unwrap(); )
.unwrap();
let results = search_posts(db.conn(), "spider", "en").unwrap(); let results = search_posts(db.conn(), "spider", "en").unwrap();
assert_eq!(results, vec!["post-1"]); assert_eq!(results, vec!["post-1"]);
@@ -545,7 +566,8 @@ mod tests {
&["nature".into()], &["nature".into()],
&[], &[],
"en", "en",
).unwrap(); )
.unwrap();
let results = search_media(db.conn(), "sunset", "en").unwrap(); let results = search_media(db.conn(), "sunset", "en").unwrap();
assert_eq!(results, vec!["media-1"]); assert_eq!(results, vec!["media-1"]);
@@ -561,10 +583,7 @@ mod tests {
#[test] #[test]
fn remove_from_index() { fn remove_from_index() {
let db = setup(); let db = setup();
index_post( index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap();
db.conn(), "p1", "Test", None, None,
&[], &[], &[], "en",
).unwrap();
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1); assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
remove_post_from_index(db.conn(), "p1").unwrap(); remove_post_from_index(db.conn(), "p1").unwrap();
@@ -586,8 +605,13 @@ mod tests {
let db = setup(); let db = setup();
// German post with English translation // German post with English translation
index_post( index_post(
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"), db.conn(),
&[], &[], "p1",
"Programmierung",
None,
Some("Deutsche Entwicklung"),
&[],
&[],
&[PostTranslationFts { &[PostTranslationFts {
title: "English development programming".into(), title: "English development programming".into(),
excerpt: None, excerpt: None,
@@ -595,7 +619,8 @@ mod tests {
language: "en".into(), language: "en".into(),
}], }],
"de", "de",
).unwrap(); )
.unwrap();
// Search with English stemming should find via English translation // Search with English stemming should find via English translation
let results = search_posts(db.conn(), "develop", "en").unwrap(); let results = search_posts(db.conn(), "develop", "en").unwrap();
@@ -605,7 +630,18 @@ mod tests {
#[test] #[test]
fn search_by_title_field() { fn search_by_title_field() {
let db = setup(); let db = setup();
index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap(); index_post(
db.conn(),
"p1",
"Unique Title Here",
None,
Some("body text"),
&[],
&[],
&[],
"en",
)
.unwrap();
// Search for title content // Search for title content
let results = search_posts(db.conn(), "unique", "en").unwrap(); let results = search_posts(db.conn(), "unique", "en").unwrap();
@@ -615,7 +651,18 @@ mod tests {
#[test] #[test]
fn search_by_tags() { fn search_by_tags() {
let db = setup(); let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap(); index_post(
db.conn(),
"p1",
"Post",
None,
None,
&["photography".into()],
&[],
&[],
"en",
)
.unwrap();
let results = search_posts(db.conn(), "photography", "en").unwrap(); let results = search_posts(db.conn(), "photography", "en").unwrap();
assert_eq!(results, vec!["p1"]); assert_eq!(results, vec!["p1"]);
@@ -624,7 +671,18 @@ mod tests {
#[test] #[test]
fn search_by_categories() { fn search_by_categories() {
let db = setup(); let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap(); index_post(
db.conn(),
"p1",
"Post",
None,
None,
&[],
&["article".into()],
&[],
"en",
)
.unwrap();
let results = search_posts(db.conn(), "article", "en").unwrap(); let results = search_posts(db.conn(), "article", "en").unwrap();
assert_eq!(results, vec!["p1"]); assert_eq!(results, vec!["p1"]);
@@ -647,8 +705,30 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap(); index_post(
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap(); db.conn(),
"post1",
"Test Post",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
index_post(
db.conn(),
"post2",
"Draft Post",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
let filters = PostSearchFilters { let filters = PostSearchFilters {
status: Some("published"), status: Some("published"),
@@ -670,19 +750,45 @@ mod tests {
// 2023-06-15 in unix ms // 2023-06-15 in unix ms
let ts_2023: i64 = 1686873600000; let ts_2023: i64 = 1686873600000;
db.conn().execute( db.conn()
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) .execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)", VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
rusqlite::params![ts_2024], rusqlite::params![ts_2024],
).unwrap(); )
db.conn().execute( .unwrap();
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)", VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
rusqlite::params![ts_2023], rusqlite::params![ts_2023],
).unwrap(); )
.unwrap();
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap(); index_post(
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap(); db.conn(),
"p2024",
"Year 2024",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
index_post(
db.conn(),
"p2023",
"Year 2023",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
let filters = PostSearchFilters { let filters = PostSearchFilters {
year: Some(2024), year: Some(2024),
@@ -707,7 +813,18 @@ mod tests {
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)", VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000], rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
).unwrap(); ).unwrap();
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap(); index_post(
db.conn(),
&id,
"Searchable",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
} }
let filters = PostSearchFilters { let filters = PostSearchFilters {
+277 -121
View File
@@ -2,14 +2,16 @@ use rusqlite::Connection;
mod embedded { mod embedded {
use refinery::embed_migrations; use refinery::embed_migrations;
embed_migrations!("migrations"); embed_migrations!("./migrations");
} }
/// Run all embedded migrations against the given connection using refinery. /// Run all embedded migrations against the given connection using refinery.
/// ///
/// Creates the full bDS schema as specified in specs/schema.allium. /// Creates the full bDS schema as specified in specs/schema.allium.
/// Uses refinery for proper versioned migration tracking. /// Uses refinery for proper versioned migration tracking.
pub fn run_migrations(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { pub fn run_migrations(
conn: &mut Connection,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
embedded::migrations::runner().run(conn)?; embedded::migrations::runner().run(conn)?;
Ok(()) Ok(())
} }
@@ -63,19 +65,34 @@ mod tests {
fn all_tables_exist() { fn all_tables_exist() {
let conn = setup(); let conn = setup();
let expected = [ let expected = [
"projects", "posts", "post_translations", "media", "media_translations", "projects",
"tags", "templates", "scripts", "post_links", "post_media", "settings", "posts",
"generated_file_hashes", "chat_conversations", "chat_messages", "ai_providers", "post_translations",
"ai_models", "ai_model_modalities", "ai_catalog_meta", "embedding_keys", "media",
"dismissed_duplicate_pairs", "import_definitions", "db_notifications", "media_translations",
"tags",
"templates",
"scripts",
"post_links",
"post_media",
"settings",
"generated_file_hashes",
"chat_conversations",
"chat_messages",
"ai_providers",
"ai_models",
"ai_model_modalities",
"ai_catalog_meta",
"embedding_keys",
"dismissed_duplicate_pairs",
"import_definitions",
"db_notifications",
]; ];
for table in &expected { for table in &expected {
let count: i64 = conn let count: i64 = conn
.query_row( .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
&format!("SELECT COUNT(*) FROM {table}"), row.get(0)
[], })
|row| row.get(0),
)
.unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}")); .unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}"));
assert_eq!(count, 0, "table '{table}' should start empty"); assert_eq!(count, 0, "table '{table}' should start empty");
} }
@@ -89,11 +106,9 @@ mod tests {
fn refinery_schema_history_exists() { fn refinery_schema_history_exists() {
let conn = setup(); let conn = setup();
let count: i64 = conn let count: i64 = conn
.query_row( .query_row("SELECT COUNT(*) FROM refinery_schema_history", [], |row| {
"SELECT COUNT(*) FROM refinery_schema_history", row.get(0)
[], })
|row| row.get(0),
)
.unwrap(); .unwrap();
assert!(count >= 1, "refinery should track at least one migration"); assert!(count >= 1, "refinery should track at least one migration");
} }
@@ -124,7 +139,10 @@ mod tests {
VALUES ('post2', 'p1', 'Other', 'hello', 'draft', 1000, 1000)", VALUES ('post2', 'p1', 'Other', 'hello', 'draft', 1000, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate post slug within same project must be rejected"); assert!(
err.is_err(),
"duplicate post slug within same project must be rejected"
);
} }
#[test] #[test]
@@ -156,7 +174,10 @@ mod tests {
VALUES ('t2', 'p1', 'post1', 'de', 'Hallo2', 'draft', 1000, 1000)", VALUES ('t2', 'p1', 'post1', 'de', 'Hallo2', 'draft', 1000, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate (translation_for, language) must be rejected"); assert!(
err.is_err(),
"duplicate (translation_for, language) must be rejected"
);
} }
#[test] #[test]
@@ -174,7 +195,10 @@ mod tests {
VALUES ('mt2', 'p1', 'm1', 'de', 1000, 1000)", VALUES ('mt2', 'p1', 'm1', 'de', 1000, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate (media translation_for, language) must be rejected"); assert!(
err.is_err(),
"duplicate (media translation_for, language) must be rejected"
);
} }
#[test] #[test]
@@ -185,13 +209,17 @@ mod tests {
"INSERT INTO tags (id, project_id, name, created_at, updated_at) "INSERT INTO tags (id, project_id, name, created_at, updated_at)
VALUES ('t1', 'p1', 'rust', 1000, 1000)", VALUES ('t1', 'p1', 'rust', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let err = conn.execute( let err = conn.execute(
"INSERT INTO tags (id, project_id, name, created_at, updated_at) "INSERT INTO tags (id, project_id, name, created_at, updated_at)
VALUES ('t2', 'p1', 'rust', 1000, 1000)", VALUES ('t2', 'p1', 'rust', 1000, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate tag name within same project must be rejected"); assert!(
err.is_err(),
"duplicate tag name within same project must be rejected"
);
} }
#[test] #[test]
@@ -208,7 +236,10 @@ mod tests {
VALUES ('tpl2', 'p1', 'default', 'Default2', 'list', 'templates/default.liquid', 1000, 1000)", VALUES ('tpl2', 'p1', 'default', 'Default2', 'list', 'templates/default.liquid', 1000, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate template slug within same project must be rejected"); assert!(
err.is_err(),
"duplicate template slug within same project must be rejected"
);
} }
#[test] #[test]
@@ -225,7 +256,10 @@ mod tests {
VALUES ('s2', 'p1', 'gallery', 'Gallery2', 'utility', 'scripts/gallery.lua', 1000, 1000)", VALUES ('s2', 'p1', 'gallery', 'Gallery2', 'utility', 'scripts/gallery.lua', 1000, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate script slug within same project must be rejected"); assert!(
err.is_err(),
"duplicate script slug within same project must be rejected"
);
} }
#[test] #[test]
@@ -238,13 +272,17 @@ mod tests {
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)", VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)",
[], [],
).unwrap(); )
.unwrap();
let err = conn.execute( let err = conn.execute(
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES ('pm2', 'p1', 'post1', 'm1', 1, 1000)", VALUES ('pm2', 'p1', 'post1', 'm1', 1, 1000)",
[], [],
); );
assert!(err.is_err(), "duplicate (post_id, media_id) must be rejected"); assert!(
err.is_err(),
"duplicate (post_id, media_id) must be rejected"
);
} }
#[test] #[test]
@@ -261,7 +299,10 @@ mod tests {
VALUES ('p1', 'index.html', 'def456', 2000)", VALUES ('p1', 'index.html', 'def456', 2000)",
[], [],
); );
assert!(err.is_err(), "duplicate (project_id, relative_path) must be rejected"); assert!(
err.is_err(),
"duplicate (project_id, relative_path) must be rejected"
);
} }
#[test] #[test]
@@ -278,7 +319,10 @@ mod tests {
VALUES ('d2', 'p1', 'a', 'b', 2000)", VALUES ('d2', 'p1', 'a', 'b', 2000)",
[], [],
); );
assert!(err.is_err(), "duplicate (project_id, post_id_a, post_id_b) must be rejected"); assert!(
err.is_err(),
"duplicate (project_id, post_id_a, post_id_b) must be rejected"
);
} }
// ================================================================ // ================================================================
@@ -323,19 +367,25 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let title: String = conn.query_row( let title: String = conn
"SELECT title FROM posts WHERE id = 'post1'", [], |r| r.get(0) .query_row("SELECT title FROM posts WHERE id = 'post1'", [], |r| {
).unwrap(); r.get(0)
})
.unwrap();
assert_eq!(title, "Hello"); assert_eq!(title, "Hello");
let tags: String = conn.query_row( let tags: String = conn
"SELECT tags FROM posts WHERE id = 'post1'", [], |r| r.get(0) .query_row("SELECT tags FROM posts WHERE id = 'post1'", [], |r| {
).unwrap(); r.get(0)
})
.unwrap();
assert_eq!(tags, "[\"rust\",\"blog\"]"); assert_eq!(tags, "[\"rust\",\"blog\"]");
let content: Option<String> = conn.query_row( let content: Option<String> = conn
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0) .query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
).unwrap(); r.get(0)
})
.unwrap();
assert_eq!(content.as_deref(), Some("Body text")); assert_eq!(content.as_deref(), Some("Body text"));
} }
@@ -349,10 +399,15 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let content: Option<String> = conn.query_row( let content: Option<String> = conn
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0) .query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
).unwrap(); r.get(0)
assert!(content.is_none(), "published post content must be null in DB"); })
.unwrap();
assert!(
content.is_none(),
"published post content must be null in DB"
);
} }
#[test] #[test]
@@ -366,9 +421,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let (lang, title): (String, String) = conn.query_row( let (lang, title): (String, String) = conn
"SELECT language, title FROM post_translations WHERE id = 't1'", [], |r| Ok((r.get(0)?, r.get(1)?)) .query_row(
).unwrap(); "SELECT language, title FROM post_translations WHERE id = 't1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(lang, "de"); assert_eq!(lang, "de");
assert_eq!(title, "Hallo"); assert_eq!(title, "Hallo");
} }
@@ -386,10 +445,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn.query_row( let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) "SELECT original_name, width, height, tags FROM media WHERE id = 'm1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(orig, "photo.jpg"); assert_eq!(orig, "photo.jpg");
assert_eq!(w, Some(1920)); assert_eq!(w, Some(1920));
assert_eq!(h, Some(1080)); assert_eq!(h, Some(1080));
@@ -407,10 +469,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let (title, alt): (Option<String>, Option<String>) = conn.query_row( let (title, alt): (Option<String>, Option<String>) = conn
"SELECT title, alt FROM media_translations WHERE id = 'mt1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?)) "SELECT title, alt FROM media_translations WHERE id = 'mt1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(title.as_deref(), Some("Sonnenuntergang")); assert_eq!(title.as_deref(), Some("Sonnenuntergang"));
assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang")); assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang"));
} }
@@ -425,10 +490,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let (name, color, tpl): (String, Option<String>, Option<String>) = conn.query_row( let (name, color, tpl): (String, Option<String>, Option<String>) = conn
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) "SELECT name, color, post_template_slug FROM tags WHERE id = 't1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(name, "rust"); assert_eq!(name, "rust");
assert_eq!(color.as_deref(), Some("#ff5733")); assert_eq!(color.as_deref(), Some("#ff5733"));
assert_eq!(tpl.as_deref(), Some("tag-tpl")); assert_eq!(tpl.as_deref(), Some("tag-tpl"));
@@ -444,14 +512,20 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn.query_row( let (kind, ver, status, content): (String, i32, String, Option<String>) = conn
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) "SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(kind, "post"); assert_eq!(kind, "post");
assert_eq!(ver, 3); assert_eq!(ver, 3);
assert_eq!(status, "published"); assert_eq!(status, "published");
assert!(content.is_none(), "published template content should be null"); assert!(
content.is_none(),
"published template content should be null"
);
} }
#[test] #[test]
@@ -464,10 +538,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let (kind, ep, content): (String, String, Option<String>) = conn.query_row( let (kind, ep, content): (String, String, Option<String>) = conn
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) "SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(kind, "macro"); assert_eq!(kind, "macro");
assert_eq!(ep, "render"); assert_eq!(ep, "render");
assert_eq!(content.as_deref(), Some("return html")); assert_eq!(content.as_deref(), Some("return html"));
@@ -483,12 +560,16 @@ mod tests {
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at) "INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
VALUES ('pl1', 'post1', 'post2', 'see also', 1000)", VALUES ('pl1', 'post1', 'post2', 'see also', 1000)",
[], [],
).unwrap(); )
.unwrap();
let (src, tgt, txt): (String, String, Option<String>) = conn.query_row( let (src, tgt, txt): (String, String, Option<String>) = conn
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) "SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(src, "post1"); assert_eq!(src, "post1");
assert_eq!(tgt, "post2"); assert_eq!(tgt, "post2");
assert_eq!(txt.as_deref(), Some("see also")); assert_eq!(txt.as_deref(), Some("see also"));
@@ -504,11 +585,16 @@ mod tests {
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)", VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)",
[], [],
).unwrap(); )
.unwrap();
let order: i32 = conn.query_row( let order: i32 = conn
"SELECT sort_order FROM post_media WHERE id = 'pm1'", [], |r| r.get(0) .query_row(
).unwrap(); "SELECT sort_order FROM post_media WHERE id = 'pm1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(order, 5); assert_eq!(order, 5);
} }
@@ -518,11 +604,14 @@ mod tests {
conn.execute( conn.execute(
"INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)", "INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)",
[], [],
).unwrap(); )
.unwrap();
let val: String = conn.query_row( let val: String = conn
"SELECT value FROM settings WHERE key = 'theme'", [], |r| r.get(0) .query_row("SELECT value FROM settings WHERE key = 'theme'", [], |r| {
).unwrap(); r.get(0)
})
.unwrap();
assert_eq!(val, "dark"); assert_eq!(val, "dark");
} }
@@ -550,11 +639,16 @@ mod tests {
"INSERT INTO chat_conversations (id, title, model, created_at, updated_at) "INSERT INTO chat_conversations (id, title, model, created_at, updated_at)
VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)", VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let title: String = conn.query_row( let title: String = conn
"SELECT title FROM chat_conversations WHERE id = 'c1'", [], |r| r.get(0) .query_row(
).unwrap(); "SELECT title FROM chat_conversations WHERE id = 'c1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(title, "Test Chat"); assert_eq!(title, "Test Chat");
} }
@@ -565,28 +659,59 @@ mod tests {
"INSERT INTO chat_conversations (id, title, created_at, updated_at) "INSERT INTO chat_conversations (id, title, created_at, updated_at)
VALUES ('c1', 'Chat', 1000, 1000)", VALUES ('c1', 'Chat', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO chat_messages (conversation_id, role, content, created_at) "INSERT INTO chat_messages (conversation_id, role, content, created_at)
VALUES ('c1', 'user', 'Hello', 1000)", VALUES ('c1', 'user', 'Hello', 1000)",
[], [],
).unwrap(); )
.unwrap();
let (role, content): (String, Option<String>) = conn.query_row( let (role, content): (String, Option<String>) = conn
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'", [], .query_row(
|r| Ok((r.get(0)?, r.get(1)?)) "SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(role, "user"); assert_eq!(role, "user");
assert_eq!(content.as_deref(), Some("Hello")); assert_eq!(content.as_deref(), Some("Hello"));
} }
#[test]
fn ai_schema_uses_current_usage_and_package_columns() {
let conn = setup();
let columns = |table: &str| {
let mut statement = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap();
statement
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let message_columns = columns("chat_messages");
assert!(message_columns.contains(&"cache_read_tokens".to_string()));
assert!(message_columns.contains(&"cache_write_tokens".to_string()));
let provider_columns = columns("ai_providers");
assert!(provider_columns.contains(&"package_ref".to_string()));
assert!(!provider_columns.contains(&"npm".to_string()));
let model_columns = columns("ai_models");
assert!(model_columns.contains(&"provider_package_ref".to_string()));
assert!(!model_columns.contains(&"provider_npm".to_string()));
}
#[test] #[test]
fn roundtrip_ai_provider_and_model() { fn roundtrip_ai_provider_and_model() {
let conn = setup(); let conn = setup();
conn.execute( conn.execute(
"INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)", "INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO ai_models (provider, model_id, name, context_window, max_input_tokens, max_output_tokens, updated_at) "INSERT INTO ai_models (provider, model_id, name, context_window, max_input_tokens, max_output_tokens, updated_at)
VALUES ('openai', 'gpt-4', 'GPT-4', 128000, 128000, 4096, 1000)", VALUES ('openai', 'gpt-4', 'GPT-4', 128000, 128000, 4096, 1000)",
@@ -596,12 +721,16 @@ mod tests {
"INSERT INTO ai_model_modalities (provider, model_id, direction, modality) "INSERT INTO ai_model_modalities (provider, model_id, direction, modality)
VALUES ('openai', 'gpt-4', 'input', 'text')", VALUES ('openai', 'gpt-4', 'input', 'text')",
[], [],
).unwrap(); )
.unwrap();
let name: String = conn.query_row( let name: String = conn
"SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'", .query_row(
[], |r| r.get(0) "SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'",
).unwrap(); [],
|r| r.get(0),
)
.unwrap();
assert_eq!(name, "GPT-4"); assert_eq!(name, "GPT-4");
let modality: String = conn.query_row( let modality: String = conn.query_row(
@@ -617,11 +746,16 @@ mod tests {
conn.execute( conn.execute(
"INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')", "INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')",
[], [],
).unwrap(); )
.unwrap();
let val: String = conn.query_row( let val: String = conn
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'", [], |r| r.get(0) .query_row(
).unwrap(); "SELECT value FROM ai_catalog_meta WHERE key = 'etag'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(val, "abc"); assert_eq!(val, "abc");
} }
@@ -632,11 +766,16 @@ mod tests {
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) "INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector)
VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')", VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')",
[], [],
).unwrap(); )
.unwrap();
let vec: String = conn.query_row( let vec: String = conn
"SELECT vector FROM embedding_keys WHERE label = 1", [], |r| r.get(0) .query_row(
).unwrap(); "SELECT vector FROM embedding_keys WHERE label = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(vec, "base64vector"); assert_eq!(vec, "base64vector");
} }
@@ -650,10 +789,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let count: i64 = conn.query_row( let count: i64 = conn
"SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'", .query_row(
[], |r| r.get(0) "SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'",
).unwrap(); [],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
} }
@@ -667,9 +809,13 @@ mod tests {
[], [],
).unwrap(); ).unwrap();
let name: String = conn.query_row( let name: String = conn
"SELECT name FROM import_definitions WHERE id = 'i1'", [], |r| r.get(0) .query_row(
).unwrap(); "SELECT name FROM import_definitions WHERE id = 'i1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(name, "WP Import"); assert_eq!(name, "WP Import");
} }
@@ -680,7 +826,8 @@ mod tests {
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at) "INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at)
VALUES ('post', 'post1', 'created', 1, 1000)", VALUES ('post', 'post1', 'created', 1, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (etype, action, cli): (String, String, i64) = conn.query_row( let (etype, action, cli): (String, String, i64) = conn.query_row(
"SELECT entity_type, action, from_cli FROM db_notifications WHERE entity_id = 'post1'", "SELECT entity_type, action, from_cli FROM db_notifications WHERE entity_id = 'post1'",
@@ -715,7 +862,8 @@ mod tests {
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) "INSERT INTO posts (id, project_id, title, slug, created_at, updated_at)
VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)", VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (status, file_path, tags, cats, dnt): (String, String, String, String, i64) = conn.query_row( let (status, file_path, tags, cats, dnt): (String, String, String, String, i64) = conn.query_row(
"SELECT status, file_path, tags, categories, do_not_translate FROM posts WHERE id = 'post1'", "SELECT status, file_path, tags, categories, do_not_translate FROM posts WHERE id = 'post1'",
@@ -736,12 +884,16 @@ mod tests {
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) "INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at)
VALUES ('tpl1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)", VALUES ('tpl1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (kind, enabled, version, status): (String, i64, i64, String) = conn.query_row( let (kind, enabled, version, status): (String, i64, i64, String) = conn
"SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'", .query_row(
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) "SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(kind, "post", "default kind must be 'post'"); assert_eq!(kind, "post", "default kind must be 'post'");
assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(enabled, 1, "default enabled must be 1");
assert_eq!(version, 1, "default version must be 1"); assert_eq!(version, 1, "default version must be 1");
@@ -756,12 +908,16 @@ mod tests {
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) "INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at)
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)", VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn.query_row( let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn
"SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'", .query_row(
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) "SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'",
).unwrap(); [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.unwrap();
assert_eq!(kind, "utility", "default kind must be 'utility'"); assert_eq!(kind, "utility", "default kind must be 'utility'");
assert_eq!(ep, "render", "default entrypoint must be 'render'"); assert_eq!(ep, "render", "default entrypoint must be 'render'");
assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(enabled, 1, "default enabled must be 1");
@@ -1,6 +1,6 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS}; use crate::db::from_row::{GENERATED_FILE_HASH_COLUMNS, generated_file_hash_from_row};
use crate::model::GeneratedFileHash; use crate::model::GeneratedFileHash;
pub fn get_generated_file_hash( pub fn get_generated_file_hash(
@@ -17,13 +17,21 @@ pub fn get_generated_file_hash(
) )
} }
pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> { pub fn upsert_generated_file_hash(
conn: &Connection,
hash: &GeneratedFileHash,
) -> rusqlite::Result<()> {
conn.execute( conn.execute(
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at) "INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(project_id, relative_path) ON CONFLICT(project_id, relative_path)
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at", DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at], params![
hash.project_id,
hash.relative_path,
hash.content_hash,
hash.updated_at
],
)?; )?;
Ok(()) Ok(())
} }
@@ -54,8 +62,8 @@ pub fn list_generated_file_hashes_by_project(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
@@ -91,4 +99,4 @@ mod tests {
assert_eq!(stored.content_hash, "def"); assert_eq!(stored.content_hash, "def");
assert_eq!(stored.updated_at, 99); assert_eq!(stored.updated_at, 99);
} }
} }
+21 -64
View File
@@ -1,7 +1,8 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS}; use crate::db::from_row::{MEDIA_COLUMNS, media_from_row};
use crate::model::Media; use crate::model::Media;
use crate::util::calendar_range_unix_ms;
fn tags_to_json(tags: &[String]) -> String { fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into()) serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
@@ -133,9 +134,7 @@ pub struct MediaFilterParams {
impl MediaFilterParams { impl MediaFilterParams {
pub fn has_active_filters(&self) -> bool { pub fn has_active_filters(&self) -> bool {
!self.search_query.is_empty() !self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty()
|| self.year.is_some()
|| !self.tags.is_empty()
} }
} }
@@ -161,49 +160,13 @@ pub fn list_media_filtered(
} }
if let Some(year) = filters.year { if let Some(year) = filters.year {
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1) let (start, end) =
.unwrap() calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
.and_hms_opt(0, 0, 0) let idx1 = param_values.len() + 1;
.unwrap() let idx2 = param_values.len() + 2;
.and_utc() conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
.timestamp() * 1000; param_values.push(Box::new(start));
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) param_values.push(Box::new(end));
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
if let Some(month) = filters.month {
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let next_month = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
param_values.push(Box::new(m_start));
param_values.push(Box::new(next_month));
} else {
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
param_values.push(Box::new(start));
param_values.push(Box::new(end));
}
} }
for tag in &filters.tags { for tag in &filters.tags {
@@ -244,7 +207,7 @@ pub fn media_calendar_counts(
FROM media FROM media
WHERE project_id = ?1 WHERE project_id = ?1
GROUP BY y, m GROUP BY y, m
ORDER BY y DESC, m DESC" ORDER BY y DESC, m DESC",
)?; )?;
let rows = stmt.query_map(params![project_id], |row| { let rows = stmt.query_map(params![project_id], |row| {
Ok(( Ok((
@@ -257,22 +220,16 @@ pub fn media_calendar_counts(
} }
/// Collect all distinct tag values across media for a project. /// Collect all distinct tag values across media for a project.
pub fn distinct_media_tags( pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
conn: &Connection, let mut stmt =
project_id: &str, conn.prepare("SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'")?;
) -> rusqlite::Result<Vec<String>> { let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
let mut stmt = conn.prepare(
"SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'"
)?;
let rows = stmt.query_map(params![project_id], |row| {
row.get::<_, String>(0)
})?;
let mut all_tags = std::collections::BTreeSet::new(); let mut all_tags = std::collections::BTreeSet::new();
for json_str in rows { for json_str in rows {
if let Ok(json_str) = json_str { if let Ok(json_str) = json_str
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) { && let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
all_tags.extend(tags); {
} all_tags.extend(tags);
} }
} }
Ok(all_tags.into_iter().collect()) Ok(all_tags.into_iter().collect())
@@ -307,8 +264,8 @@ pub fn make_test_media(id: &str, project_id: &str) -> Media {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
@@ -1,12 +1,9 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS}; use crate::db::from_row::{MEDIA_TRANSLATION_COLUMNS, media_translation_from_row};
use crate::model::MediaTranslation; use crate::model::MediaTranslation;
pub fn insert_media_translation( pub fn insert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
conn: &Connection,
t: &MediaTranslation,
) -> rusqlite::Result<()> {
conn.execute( conn.execute(
"INSERT INTO media_translations ( "INSERT INTO media_translations (
id, project_id, translation_for, language, title, alt, caption, id, project_id, translation_for, language, title, alt, caption,
@@ -54,10 +51,7 @@ pub fn list_media_translations_by_media(
rows.collect() rows.collect()
} }
pub fn upsert_media_translation( pub fn upsert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
conn: &Connection,
t: &MediaTranslation,
) -> rusqlite::Result<()> {
conn.execute( conn.execute(
"INSERT INTO media_translations ( "INSERT INTO media_translations (
id, project_id, translation_for, language, title, alt, caption, id, project_id, translation_for, language, title, alt, caption,
@@ -98,9 +92,9 @@ pub fn delete_media_translation(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media}; use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
@@ -164,8 +158,6 @@ mod tests {
let db = setup(); let db = setup();
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap(); insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
delete_media_translation(db.conn(), "m1", "de").unwrap(); delete_media_translation(db.conn(), "m1", "de").unwrap();
assert!( assert!(get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err());
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
);
} }
} }
+6 -6
View File
@@ -1,12 +1,12 @@
pub mod project; pub mod generated_file_hash;
pub mod post;
pub mod post_translation;
pub mod media; pub mod media;
pub mod media_translation; pub mod media_translation;
pub mod tag; pub mod post;
pub mod post_link; pub mod post_link;
pub mod post_media; pub mod post_media;
pub mod template; pub mod post_translation;
pub mod project;
pub mod script; pub mod script;
pub mod setting; pub mod setting;
pub mod generated_file_hash; pub mod tag;
pub mod template;
+52 -73
View File
@@ -1,7 +1,8 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS}; use crate::db::from_row::{POST_COLUMNS, post_from_row, post_status_to_str};
use crate::model::{Post, PostStatus}; use crate::model::{Post, PostStatus};
use crate::util::calendar_range_unix_ms;
fn tags_to_json(tags: &[String]) -> String { fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into()) serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
@@ -165,6 +166,10 @@ pub fn set_post_file_path(
Ok(()) Ok(())
} }
#[expect(
clippy::too_many_arguments,
reason = "arguments mirror the published snapshot columns"
)]
pub fn set_published_snapshot( pub fn set_published_snapshot(
conn: &Connection, conn: &Connection,
id: &str, id: &str,
@@ -182,7 +187,16 @@ pub fn set_published_snapshot(
published_categories = ?4, published_excerpt = ?5, published_categories = ?4, published_excerpt = ?5,
published_at = ?6, updated_at = ?7 published_at = ?6, updated_at = ?7
WHERE id = ?8", WHERE id = ?8",
params![title, content, tags, categories, excerpt, published_at, updated_at, id], params![
title,
content,
tags,
categories,
excerpt,
published_at,
updated_at,
id
],
)?; )?;
Ok(()) Ok(())
} }
@@ -295,50 +309,15 @@ pub fn list_posts_filtered(
} }
if let Some(year) = filters.year { if let Some(year) = filters.year {
// created_at is unix ms; compute year range let (start, end) =
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1) calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
.unwrap() let idx1 = param_values.len() + 1;
.and_hms_opt(0, 0, 0) let idx2 = param_values.len() + 2;
.unwrap() filter_conditions.push(format!(
.and_utc() "(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"
.timestamp() * 1000; ));
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) param_values.push(Box::new(start));
.unwrap() param_values.push(Box::new(end));
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
if let Some(month) = filters.month {
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let next_month = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
param_values.push(Box::new(m_start));
param_values.push(Box::new(next_month));
} else {
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
param_values.push(Box::new(start));
param_values.push(Box::new(end));
}
} }
for tag in &filters.tags { for tag in &filters.tags {
@@ -435,22 +414,16 @@ pub fn post_calendar_counts(
} }
/// Collect all distinct tag values across posts for a project. /// Collect all distinct tag values across posts for a project.
pub fn distinct_post_tags( pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
conn: &Connection, let mut stmt =
project_id: &str, conn.prepare("SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'")?;
) -> rusqlite::Result<Vec<String>> { let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
let mut stmt = conn.prepare(
"SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'"
)?;
let rows = stmt.query_map(params![project_id], |row| {
row.get::<_, String>(0)
})?;
let mut all_tags = std::collections::BTreeSet::new(); let mut all_tags = std::collections::BTreeSet::new();
for json_str in rows { for json_str in rows {
if let Ok(json_str) = json_str { if let Ok(json_str) = json_str
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) { && let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
all_tags.extend(tags); {
} all_tags.extend(tags);
} }
} }
Ok(all_tags.into_iter().collect()) Ok(all_tags.into_iter().collect())
@@ -462,17 +435,15 @@ pub fn distinct_post_categories(
project_id: &str, project_id: &str,
) -> rusqlite::Result<Vec<String>> { ) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'" "SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'",
)?; )?;
let rows = stmt.query_map(params![project_id], |row| { let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
row.get::<_, String>(0)
})?;
let mut all_cats = std::collections::BTreeSet::new(); let mut all_cats = std::collections::BTreeSet::new();
for json_str in rows { for json_str in rows {
if let Ok(json_str) = json_str { if let Ok(json_str) = json_str
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) { && let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str)
all_cats.extend(cats); {
} all_cats.extend(cats);
} }
} }
Ok(all_cats.into_iter().collect()) Ok(all_cats.into_iter().collect())
@@ -481,8 +452,8 @@ pub fn distinct_post_categories(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
@@ -603,9 +574,17 @@ mod tests {
let db = setup(); let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap(); insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
set_published_snapshot( set_published_snapshot(
db.conn(), "x1", "Pub Title", "Pub Body", db.conn(),
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000, "x1",
).unwrap(); "Pub Title",
"Pub Body",
"[\"rust\"]",
"[\"tech\"]",
Some("Pub Excerpt"),
3000,
3000,
)
.unwrap();
let fetched = get_post_by_id(db.conn(), "x1").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_title.as_deref(), Some("Pub Title"));
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body")); assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
+9 -6
View File
@@ -1,6 +1,6 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS}; use crate::db::from_row::{POST_LINK_COLUMNS, post_link_from_row};
use crate::model::PostLink; use crate::model::PostLink;
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> { pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
@@ -51,8 +51,8 @@ pub fn list_links_by_target(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
@@ -63,17 +63,20 @@ mod tests {
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)", VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
c.execute( c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)", VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
c.execute( c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)", VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
db db
} }
+3 -3
View File
@@ -1,6 +1,6 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS}; use crate::db::from_row::{POST_MEDIA_COLUMNS, post_media_from_row};
use crate::model::PostMedia; use crate::model::PostMedia;
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> { pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
@@ -65,9 +65,9 @@ pub fn update_sort_order(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media}; use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
@@ -1,12 +1,11 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS}; use crate::db::from_row::{
POST_TRANSLATION_COLUMNS, post_status_to_str, post_translation_from_row,
};
use crate::model::PostTranslation; use crate::model::PostTranslation;
pub fn insert_post_translation( pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
conn: &Connection,
t: &PostTranslation,
) -> rusqlite::Result<()> {
if !t.status.is_valid_for_translation() { if !t.status.is_valid_for_translation() {
return Err(rusqlite::Error::InvalidParameterName( return Err(rusqlite::Error::InvalidParameterName(
"translation status must be draft or published".to_string(), "translation status must be draft or published".to_string(),
@@ -74,10 +73,7 @@ pub fn list_post_translations_by_post(
rows.collect() rows.collect()
} }
pub fn update_post_translation( pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
conn: &Connection,
t: &PostTranslation,
) -> rusqlite::Result<()> {
if !t.status.is_valid_for_translation() { if !t.status.is_valid_for_translation() {
return Err(rusqlite::Error::InvalidParameterName( return Err(rusqlite::Error::InvalidParameterName(
"translation status must be draft or published".to_string(), "translation status must be draft or published".to_string(),
@@ -104,10 +100,7 @@ pub fn update_post_translation(
} }
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> { pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute( conn.execute("DELETE FROM post_translations WHERE id = ?1", params![id])?;
"DELETE FROM post_translations WHERE id = ?1",
params![id],
)?;
Ok(()) Ok(())
} }
@@ -125,8 +118,8 @@ pub fn delete_all_translations_for_post(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::PostStatus; use crate::model::PostStatus;
fn setup() -> Database { fn setup() -> Database {
+2 -2
View File
@@ -1,6 +1,6 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS}; use crate::db::from_row::{PROJECT_COLUMNS, project_from_row};
use crate::model::Project; use crate::model::Project;
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> { pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
+4 -6
View File
@@ -1,7 +1,7 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{ use crate::db::from_row::{
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS, SCRIPT_COLUMNS, script_from_row, script_kind_to_str, script_status_to_str,
}; };
use crate::model::Script; use crate::model::Script;
@@ -44,9 +44,7 @@ pub fn get_script_by_slug(
slug: &str, slug: &str,
) -> rusqlite::Result<Script> { ) -> rusqlite::Result<Script> {
conn.query_row( conn.query_row(
&format!( &format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"),
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
),
params![project_id, slug], params![project_id, slug],
script_from_row, script_from_row,
) )
@@ -94,8 +92,8 @@ pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::{ScriptKind, ScriptStatus}; use crate::model::{ScriptKind, ScriptStatus};
fn setup() -> Database { fn setup() -> Database {
+2 -2
View File
@@ -1,6 +1,6 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS}; use crate::db::from_row::{SETTING_COLUMNS, setting_from_row};
use crate::model::Setting; use crate::model::Setting;
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> { pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
+3 -3
View File
@@ -1,6 +1,6 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{tag_from_row, TAG_COLUMNS}; use crate::db::from_row::{TAG_COLUMNS, tag_from_row};
use crate::model::Tag; use crate::model::Tag;
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> { pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
@@ -73,8 +73,8 @@ pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database { fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap(); let mut db = Database::open_in_memory().unwrap();
+4 -6
View File
@@ -1,7 +1,7 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use crate::db::from_row::{ use crate::db::from_row::{
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS, TEMPLATE_COLUMNS, template_from_row, template_kind_to_str, template_status_to_str,
}; };
use crate::model::Template; use crate::model::Template;
@@ -43,9 +43,7 @@ pub fn get_template_by_slug(
slug: &str, slug: &str,
) -> rusqlite::Result<Template> { ) -> rusqlite::Result<Template> {
conn.query_row( conn.query_row(
&format!( &format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"),
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
),
params![project_id, slug], params![project_id, slug],
template_from_row, template_from_row,
) )
@@ -92,8 +90,8 @@ pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::{TemplateKind, TemplateStatus}; use crate::model::{TemplateKind, TemplateStatus};
fn setup() -> Database { fn setup() -> Database {
+184 -68
View File
@@ -4,7 +4,7 @@ use keyring::Entry;
use reqwest::blocking::Client; use reqwest::blocking::Client;
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use crate::db::queries::setting; use crate::db::queries::setting;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
@@ -167,17 +167,37 @@ pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<A
pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> { pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> {
validate_endpoint_config(endpoint)?; validate_endpoint_config(endpoint)?;
let checked_at = now_unix_ms(); let checked_at = now_unix_ms();
set_setting(conn, &endpoint_setting_key(endpoint.kind, "url"), endpoint.url.trim(), checked_at)?; set_setting(
set_setting(conn, &endpoint_setting_key(endpoint.kind, "model"), endpoint.model.trim(), checked_at)?; conn,
&endpoint_setting_key(endpoint.kind, "url"),
endpoint.url.trim(),
checked_at,
)?;
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "model"),
endpoint.model.trim(),
checked_at,
)?;
if endpoint.kind == AiEndpointKind::Online { if endpoint.kind == AiEndpointKind::Online {
let entry = endpoint_keyring_entry(endpoint.kind)?; let entry = endpoint_keyring_entry(endpoint.kind)?;
if let Some(api_key) = &endpoint.api_key { if let Some(api_key) = &endpoint.api_key {
if api_key.trim().is_empty() { if api_key.trim().is_empty() {
entry.delete_credential().ok(); entry.delete_credential().ok();
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "false", checked_at)?; set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
"false",
checked_at,
)?;
} else { } else {
entry.set_password(api_key.trim()).map_err(keyring_error)?; entry.set_password(api_key.trim()).map_err(keyring_error)?;
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "true", checked_at)?; set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
"true",
checked_at,
)?;
} }
} }
} }
@@ -200,7 +220,11 @@ pub fn save_model_preferences(
} }
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> { pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
let kind = if offline_mode { AiEndpointKind::Airplane } else { AiEndpointKind::Online }; let kind = if offline_mode {
AiEndpointKind::Airplane
} else {
AiEndpointKind::Online
};
let stored = load_endpoint(conn, kind)?; let stored = load_endpoint(conn, kind)?;
if stored.url.trim().is_empty() { if stored.url.trim().is_empty() {
return Err(EngineError::Validation(format!( return Err(EngineError::Validation(format!(
@@ -243,14 +267,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
validate_endpoint_config(endpoint)?; validate_endpoint_config(endpoint)?;
let client = build_http_client()?; let client = build_http_client()?;
let request = client.get(models_url(&endpoint.url)); let request = client.get(models_url(&endpoint.url));
let response = with_auth(request, endpoint) let response = with_auth(request, endpoint).send()?.error_for_status()?;
.send()?
.error_for_status()?;
let body: Value = response.json()?; let body: Value = response.json()?;
let models = body let models = body.get("data").and_then(Value::as_array).ok_or_else(|| {
.get("data") EngineError::Parse("model catalog response missing data array".to_string())
.and_then(Value::as_array) })?;
.ok_or_else(|| EngineError::Parse("model catalog response missing data array".to_string()))?;
let mut result = Vec::new(); let mut result = Vec::new();
for model in models { for model in models {
@@ -275,7 +296,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
let supports_vision = model let supports_vision = model
.get("modalities") .get("modalities")
.and_then(Value::as_array) .and_then(Value::as_array)
.map(|modalities| modalities.iter().any(|value| value.as_str() == Some("vision"))) .map(|modalities| {
modalities
.iter()
.any(|value| value.as_str() == Some("vision"))
})
.unwrap_or(false); .unwrap_or(false);
result.push(AiModelInfo { result.push(AiModelInfo {
id, id,
@@ -326,9 +351,14 @@ pub fn run_one_shot(
} }
}); });
let client = build_http_client()?; let client = build_http_client()?;
let response = with_auth(client.post(chat_completions_url(&endpoint.url)).json(&payload), &endpoint) let response = with_auth(
.send()? client
.error_for_status()?; .post(chat_completions_url(&endpoint.url))
.json(&payload),
&endpoint,
)
.send()?
.error_for_status()?;
let body: Value = response.json()?; let body: Value = response.json()?;
let content = body let content = body
.get("choices") .get("choices")
@@ -337,7 +367,9 @@ pub fn run_one_shot(
.and_then(|choice| choice.get("message")) .and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content")) .and_then(|message| message.get("content"))
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| EngineError::Parse("chat completions response missing message content".to_string()))?; .ok_or_else(|| {
EngineError::Parse("chat completions response missing message content".to_string())
})?;
parse_one_shot_response(request, content) parse_one_shot_response(request, content)
} }
@@ -347,10 +379,12 @@ fn build_http_client() -> EngineResult<Client> {
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> { fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default(); let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default();
let model = get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default(); let model =
let api_key_configured = get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))? get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
.map(|value| value == "true") let api_key_configured =
.unwrap_or(false); get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
.map(|value| value == "true")
.unwrap_or(false);
Ok(StoredAiEndpointConfig { Ok(StoredAiEndpointConfig {
kind, kind,
url, url,
@@ -361,48 +395,81 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> { fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
if endpoint.url.trim().is_empty() { if endpoint.url.trim().is_empty() {
return Err(EngineError::Validation("endpoint url is required".to_string())); return Err(EngineError::Validation(
"endpoint url is required".to_string(),
));
} }
if endpoint.model.trim().is_empty() { if endpoint.model.trim().is_empty() {
return Err(EngineError::Validation("endpoint model is required".to_string())); return Err(EngineError::Validation(
"endpoint model is required".to_string(),
));
} }
if endpoint.kind == AiEndpointKind::Online if endpoint.kind == AiEndpointKind::Online
&& endpoint.api_key.as_ref().map(|value| value.trim().is_empty()).unwrap_or(true) && endpoint
.api_key
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{ {
return Err(EngineError::Validation("online endpoint api key is required".to_string())); return Err(EngineError::Validation(
"online endpoint api key is required".to_string(),
));
} }
Ok(()) Ok(())
} }
fn select_model(settings: &AiSettings, endpoint: &AiEndpointConfig, operation: &OneShotOperation) -> EngineResult<String> { fn select_model(
settings: &AiSettings,
endpoint: &AiEndpointConfig,
operation: &OneShotOperation,
) -> EngineResult<String> {
let selected = match operation { let selected = match operation {
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(), OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
OneShotOperation::AnalyzeTaxonomy OneShotOperation::AnalyzeTaxonomy
| OneShotOperation::AnalyzePost | OneShotOperation::AnalyzePost
| OneShotOperation::DetectLanguage | OneShotOperation::DetectLanguage
| OneShotOperation::TranslatePost { .. } | OneShotOperation::TranslatePost { .. }
| OneShotOperation::TranslateMedia { .. } => settings.title_model.as_ref().or(settings.default_model.as_ref()), | OneShotOperation::TranslateMedia { .. } => settings
.title_model
.as_ref()
.or(settings.default_model.as_ref()),
} }
.filter(|model| !model.trim().is_empty()) .filter(|model| !model.trim().is_empty())
.cloned() .cloned()
.unwrap_or_else(|| endpoint.model.clone()); .unwrap_or_else(|| endpoint.model.clone());
if selected.trim().is_empty() { if selected.trim().is_empty() {
return Err(EngineError::Validation("AI unavailable - configure model in Settings".to_string())); return Err(EngineError::Validation(
"AI unavailable - configure model in Settings".to_string(),
));
} }
Ok(selected) Ok(selected)
} }
fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String { fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String {
let operation_prompt = match operation { let operation_prompt = match operation {
OneShotOperation::AnalyzeTaxonomy => "Return only JSON with tags and categories for the post.", OneShotOperation::AnalyzeTaxonomy => {
OneShotOperation::AnalyzePost => "Return only JSON with title, excerpt, and slug suggestions for the post.", "Return only JSON with tags and categories for the post."
}
OneShotOperation::AnalyzePost => {
"Return only JSON with title, excerpt, and slug suggestions for the post."
}
OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.", OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.",
OneShotOperation::TranslatePost { target_language } => { OneShotOperation::TranslatePost { target_language } => {
return format!("{} Translate the post into {} and return only JSON with title, excerpt, and content.", base_prompt.trim(), target_language); return format!(
"{} Translate the post into {} and return only JSON with title, excerpt, and content.",
base_prompt.trim(),
target_language
);
}
OneShotOperation::AnalyzeImage => {
"Return only JSON with title, alt, and caption suggestions for the image."
} }
OneShotOperation::AnalyzeImage => "Return only JSON with title, alt, and caption suggestions for the image.",
OneShotOperation::TranslateMedia { target_language } => { OneShotOperation::TranslateMedia { target_language } => {
return format!("{} Translate the media metadata into {} and return only JSON with title, alt, and caption.", base_prompt.trim(), target_language); return format!(
"{} Translate the media metadata into {} and return only JSON with title, alt, and caption.",
base_prompt.trim(),
target_language
);
} }
}; };
if base_prompt.trim().is_empty() { if base_prompt.trim().is_empty() {
@@ -417,26 +484,31 @@ fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value>
OneShotOperation::AnalyzeTaxonomy => Ok(format!( OneShotOperation::AnalyzeTaxonomy => Ok(format!(
"Suggest tags and categories for this post: {}", "Suggest tags and categories for this post: {}",
serde_json::to_string(&request.content)? serde_json::to_string(&request.content)?
).into()), )
.into()),
OneShotOperation::AnalyzePost => Ok(format!( OneShotOperation::AnalyzePost => Ok(format!(
"Analyze this post and suggest title, excerpt, and slug: {}", "Analyze this post and suggest title, excerpt, and slug: {}",
serde_json::to_string(&request.content)? serde_json::to_string(&request.content)?
).into()), )
.into()),
OneShotOperation::DetectLanguage => Ok(format!( OneShotOperation::DetectLanguage => Ok(format!(
"Detect the language of this text: {}", "Detect the language of this text: {}",
serde_json::to_string(&request.content)? serde_json::to_string(&request.content)?
).into()), )
.into()),
OneShotOperation::TranslatePost { target_language } => Ok(format!( OneShotOperation::TranslatePost { target_language } => Ok(format!(
"Translate this post to {}: {}", "Translate this post to {}: {}",
target_language, target_language,
serde_json::to_string(&request.content)? serde_json::to_string(&request.content)?
).into()), )
.into()),
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content), OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
OneShotOperation::TranslateMedia { target_language } => Ok(format!( OneShotOperation::TranslateMedia { target_language } => Ok(format!(
"Translate this media metadata to {}: {}", "Translate this media metadata to {}: {}",
target_language, target_language,
serde_json::to_string(&request.content)? serde_json::to_string(&request.content)?
).into()), )
.into()),
} }
} }
@@ -549,14 +621,29 @@ fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
} }
} }
fn parse_one_shot_response(request: &OneShotRequest, content: &str) -> EngineResult<OneShotResponse> { fn parse_one_shot_response(
request: &OneShotRequest,
content: &str,
) -> EngineResult<OneShotResponse> {
Ok(match request.operation { Ok(match request.operation {
OneShotOperation::AnalyzeTaxonomy => OneShotResponse::Taxonomy(serde_json::from_str(content)?), OneShotOperation::AnalyzeTaxonomy => {
OneShotOperation::AnalyzePost => OneShotResponse::PostAnalysis(serde_json::from_str(content)?), OneShotResponse::Taxonomy(serde_json::from_str(content)?)
OneShotOperation::DetectLanguage => OneShotResponse::LanguageDetection(serde_json::from_str(content)?), }
OneShotOperation::TranslatePost { .. } => OneShotResponse::Translation(serde_json::from_str(content)?), OneShotOperation::AnalyzePost => {
OneShotOperation::AnalyzeImage => OneShotResponse::ImageAnalysis(serde_json::from_str(content)?), OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
OneShotOperation::TranslateMedia { .. } => OneShotResponse::MediaTranslation(serde_json::from_str(content)?), }
OneShotOperation::DetectLanguage => {
OneShotResponse::LanguageDetection(serde_json::from_str(content)?)
}
OneShotOperation::TranslatePost { .. } => {
OneShotResponse::Translation(serde_json::from_str(content)?)
}
OneShotOperation::AnalyzeImage => {
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?)
}
OneShotOperation::TranslateMedia { .. } => {
OneShotResponse::MediaTranslation(serde_json::from_str(content)?)
}
}) })
} }
@@ -565,7 +652,11 @@ fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
} }
fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> { fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
Entry::new(KEYRING_SERVICE, &format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str())).map_err(keyring_error) Entry::new(
KEYRING_SERVICE,
&format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str()),
)
.map_err(keyring_error)
} }
fn keyring_error(error: keyring::Error) -> EngineError { fn keyring_error(error: keyring::Error) -> EngineError {
@@ -577,7 +668,12 @@ fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> En
Ok(()) Ok(())
} }
fn set_optional_setting(conn: &Connection, key: &str, value: Option<&str>, updated_at: i64) -> EngineResult<()> { fn set_optional_setting(
conn: &Connection,
key: &str,
value: Option<&str>,
updated_at: i64,
) -> EngineResult<()> {
set_setting(conn, key, value.unwrap_or(""), updated_at) set_setting(conn, key, value.unwrap_or(""), updated_at)
} }
@@ -691,12 +787,15 @@ mod tests {
#[test] #[test]
fn refresh_model_catalog_parses_openai_models_shape() { fn refresh_model_catalog_parses_openai_models_shape() {
let server = spawn_test_server(|request| { let server = spawn_test_server(
assert!(request.starts_with("GET /v1/models HTTP/1.1")); |request| {
http_ok( assert!(request.starts_with("GET /v1/models HTTP/1.1"));
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#, http_ok(
) r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
}, 1); )
},
1,
);
let models = refresh_model_catalog(&AiEndpointConfig { let models = refresh_model_catalog(&AiEndpointConfig {
kind: AiEndpointKind::Airplane, kind: AiEndpointKind::Airplane,
url: server, url: server,
@@ -713,16 +812,22 @@ mod tests {
#[ignore = "touches system keychain; run explicitly when validating keychain integration"] #[ignore = "touches system keychain; run explicitly when validating keychain integration"]
fn run_one_shot_uses_active_endpoint_and_parses_response() { fn run_one_shot_uses_active_endpoint_and_parses_response() {
clear_keyring(AiEndpointKind::Online); clear_keyring(AiEndpointKind::Online);
let server = spawn_test_server(|request| { let server = spawn_test_server(
if request.starts_with("GET /v1/models HTTP/1.1") { |request| {
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#); if request.starts_with("GET /v1/models HTTP/1.1") {
} return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1")); }
assert!(request.contains("authorization: Bearer secret-token") || request.contains("Authorization: Bearer secret-token")); assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
http_ok( assert!(
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#, request.contains("authorization: Bearer secret-token")
) || request.contains("Authorization: Bearer secret-token")
}, 1); );
http_ok(
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
)
},
1,
);
let db = setup(); let db = setup();
save_endpoint( save_endpoint(
@@ -769,9 +874,17 @@ mod tests {
let parts = content.as_array().unwrap(); let parts = content.as_array().unwrap();
assert_eq!(parts.len(), 2); assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["type"], "text"); assert_eq!(parts[0]["type"], "text");
assert!(parts[0]["text"].as_str().unwrap().contains("Existing title")); assert!(
parts[0]["text"]
.as_str()
.unwrap()
.contains("Existing title")
);
assert_eq!(parts[1]["type"], "image_url"); assert_eq!(parts[1]["type"], "image_url");
assert_eq!(parts[1]["image_url"]["url"], "data:image/jpeg;base64,abc123"); assert_eq!(
parts[1]["image_url"]["url"],
"data:image/jpeg;base64,abc123"
);
} }
#[test] #[test]
@@ -940,7 +1053,10 @@ mod tests {
run_one_shot(db.conn(), true, &request).unwrap() run_one_shot(db.conn(), true, &request).unwrap()
} }
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static, request_count: usize) -> String { fn spawn_test_server(
handler: impl Fn(String) -> String + Send + 'static,
request_count: usize,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
thread::spawn(move || { thread::spawn(move || {
@@ -963,4 +1079,4 @@ mod tests {
body body
) )
} }
} }
+5 -6
View File
@@ -69,18 +69,17 @@ mod tests {
let _ = db.migrate(); let _ = db.migrate();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let p = engine::project::create_project( let p =
db.conn(), "Test", Some(tmp.path().to_str().unwrap()), engine::project::create_project(db.conn(), "Test", Some(tmp.path().to_str().unwrap()))
).unwrap(); .unwrap();
regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap(); regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap();
let cal_path = tmp.path().join("html").join("calendar.json"); let cal_path = tmp.path().join("html").join("calendar.json");
assert!(cal_path.exists()); assert!(cal_path.exists());
let data: serde_json::Value = serde_json::from_str( let data: serde_json::Value =
&std::fs::read_to_string(&cal_path).unwrap() serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
).unwrap();
assert!(data["years"].as_object().unwrap().is_empty()); assert!(data["years"].as_object().unwrap().is_empty());
} }
} }
+20 -4
View File
@@ -72,10 +72,26 @@ mod tests {
#[test] #[test]
fn display_variants() { fn display_variants() {
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error")); assert!(
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found")); EngineError::Parse("bad yaml".into())
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict")); .to_string()
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation")); .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] #[test]
+292 -97
View File
@@ -24,6 +24,31 @@ pub struct PublishedPostSource {
pub body_markdown: String, pub body_markdown: String,
} }
/// Whether a post has a published snapshot eligible for site generation.
pub fn has_published_snapshot(post: &Post) -> bool {
matches!(
post.status,
crate::model::PostStatus::Published | crate::model::PostStatus::Draft
) && !post.file_path.trim().is_empty()
}
/// Load the last-published body from disk, never from draft database content.
pub fn load_published_post_source(
data_dir: &Path,
post: Post,
) -> EngineResult<Option<PublishedPostSource>> {
if !has_published_snapshot(&post) {
return Ok(None);
}
let raw = std::fs::read_to_string(data_dir.join(&post.file_path))?;
let (_, body_markdown) =
crate::util::frontmatter::read_post_file(&raw).map_err(EngineError::Parse)?;
Ok(Some(PublishedPostSource {
post,
body_markdown,
}))
}
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct GenerationReport { pub struct GenerationReport {
pub written_paths: Vec<String>, pub written_paths: Vec<String>,
@@ -56,11 +81,19 @@ pub fn generate_starter_site(
.iter() .iter()
.map(|source| (source.post.clone(), source.body_markdown.clone())) .map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts) let artifacts =
.map_err(|error| EngineError::Parse(error.to_string()))?; build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
for page in &artifacts.pages { for page in &artifacts.pages {
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?; write_out(
conn,
output_dir,
project_id,
&page.relative_path,
&page.html,
&mut report,
)?;
} }
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?; write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
@@ -70,13 +103,24 @@ pub fn generate_starter_site(
output_dir, output_dir,
project_id, project_id,
"calendar.json", "calendar.json",
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?, &build_calendar_json(
&list_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>(),
)?,
&mut report, &mut report,
)?; )?;
for render_language in render_languages(metadata) { for render_language in render_languages(metadata) {
let localized_posts = localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?; let localized_posts =
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) { localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
let prefix = if render_language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new() String::new()
} else { } else {
format!("{}/", render_language) format!("{}/", render_language)
@@ -85,19 +129,44 @@ pub fn generate_starter_site(
if prefix.is_empty() { if prefix.is_empty() {
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?; write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
} }
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?; write_out(
write_out(conn, output_dir, project_id, &format!("{prefix}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &mut report)?; conn,
output_dir,
project_id,
&format!("{prefix}feed.xml"),
&rss,
&mut report,
)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}atom.xml"),
&build_atom_xml(metadata, &localized_posts, &render_language),
&mut report,
)?;
write_out( write_out(
conn, conn,
output_dir, output_dir,
project_id, project_id,
&format!("{prefix}sitemap.xml"), &format!("{prefix}sitemap.xml"),
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language), &build_sitemap_xml(
metadata,
&artifacts.pages,
&localized_posts,
&render_language,
),
&mut report, &mut report,
)?; )?;
} }
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?; write_pagefind_indexes(
conn,
output_dir,
project_id,
&artifacts.pagefind_documents,
&mut report,
)?;
Ok(report) Ok(report)
} }
@@ -151,20 +220,22 @@ pub fn apply_validation_sections(
.iter() .iter()
.map(|source| (source.post.clone(), source.body_markdown.clone())) .map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let artifacts = build_site_render_artifacts( let artifacts =
conn, build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
&data_dir, .map_err(|error| EngineError::Parse(error.to_string()))?;
project_id,
metadata,
&input_posts,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut report = GenerationReport::default(); let mut report = GenerationReport::default();
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, &section_set); let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, &section_set);
for page in &artifacts.pages { for page in &artifacts.pages {
if path_matches_sections(&page.relative_path, &section_set) { if path_matches_sections(&page.relative_path, &section_set) {
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?; write_out(
conn,
output_dir,
project_id,
&page.relative_path,
&page.html,
&mut report,
)?;
} }
} }
@@ -175,19 +246,24 @@ pub fn apply_validation_sections(
output_dir, output_dir,
project_id, project_id,
"calendar.json", "calendar.json",
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?, &build_calendar_json(
&list_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>(),
)?,
&mut report, &mut report,
)?; )?;
for render_language in render_languages(metadata) { for render_language in render_languages(metadata) {
let localized_posts = localized_sources( let localized_posts =
conn, localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
&data_dir, let prefix = if render_language
&list_posts, == metadata
&render_language, .main_language
metadata, .clone()
)?; .unwrap_or_else(|| "en".to_string())
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) { {
String::new() String::new()
} else { } else {
format!("{}/", render_language) format!("{}/", render_language)
@@ -196,7 +272,14 @@ pub fn apply_validation_sections(
if prefix.is_empty() { if prefix.is_empty() {
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?; write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
} }
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?; write_out(
conn,
output_dir,
project_id,
&format!("{prefix}feed.xml"),
&rss,
&mut report,
)?;
write_out( write_out(
conn, conn,
output_dir, output_dir,
@@ -210,40 +293,29 @@ pub fn apply_validation_sections(
output_dir, output_dir,
project_id, project_id,
&format!("{prefix}sitemap.xml"), &format!("{prefix}sitemap.xml"),
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language), &build_sitemap_xml(
metadata,
&artifacts.pages,
&localized_posts,
&render_language,
),
&mut report, &mut report,
)?; )?;
} }
} }
remove_extra_section_paths(output_dir, &expected_paths, &section_set, &mut report)?; remove_extra_section_paths(output_dir, &expected_paths, &section_set, &mut report)?;
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?; write_pagefind_indexes(
conn,
output_dir,
project_id,
&artifacts.pagefind_documents,
&mut report,
)?;
Ok(report) Ok(report)
} }
fn build_media_rewrite_map(
conn: &Connection,
project_id: &str,
) -> EngineResult<HashMap<String, String>> {
let media_items = queries::media::list_media_by_project(conn, project_id)?;
let mut map = HashMap::new();
for media in media_items {
let canonical_path = if media.file_path.starts_with('/') {
media.file_path.clone()
} else {
format!("/{}", media.file_path.trim_start_matches('/'))
};
map.insert(format!("bds-media://{}", media.id), canonical_path.clone());
let relative_key = media.file_path.trim_start_matches('/').to_lowercase();
map.insert(relative_key, canonical_path);
}
Ok(map)
}
fn write_out( fn write_out(
conn: &Connection, conn: &Connection,
output_dir: &Path, output_dir: &Path,
@@ -256,7 +328,9 @@ fn write_out(
.map_err(|error| EngineError::Parse(error.to_string()))? .map_err(|error| EngineError::Parse(error.to_string()))?
{ {
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()), GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative_path.to_string()), GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(relative_path.to_string())
}
} }
Ok(()) Ok(())
} }
@@ -273,10 +347,13 @@ fn write_pagefind_indexes(
.build() .build()
.map_err(EngineError::Io)?; .map_err(EngineError::Io)?;
let grouped = documents.iter().fold(HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(), |mut acc, doc| { let grouped = documents.iter().fold(
acc.entry(doc.language.clone()).or_default().push(doc); HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(),
acc |mut acc, doc| {
}); acc.entry(doc.language.clone()).or_default().push(doc);
acc
},
);
for (language, docs) in grouped { for (language, docs) in grouped {
let config = PagefindServiceConfig::builder() let config = PagefindServiceConfig::builder()
@@ -292,9 +369,16 @@ fn write_pagefind_indexes(
.await .await
.map_err(|error| EngineError::Parse(error.to_string()))?; .map_err(|error| EngineError::Parse(error.to_string()))?;
} }
let files = index.get_files().await.map_err(|error| EngineError::Parse(error.to_string()))?; let files = index
.get_files()
.await
.map_err(|error| EngineError::Parse(error.to_string()))?;
for file in files { for file in files {
let relative = file.filename.to_string_lossy().trim_start_matches('/').to_string(); let relative = file
.filename
.to_string_lossy()
.trim_start_matches('/')
.to_string();
match write_generated_bytes(conn, output_dir, project_id, &relative, &file.contents) match write_generated_bytes(conn, output_dir, project_id, &relative, &file.contents)
.map_err(|error| EngineError::Parse(error.to_string()))? .map_err(|error| EngineError::Parse(error.to_string()))?
{ {
@@ -332,7 +416,12 @@ fn expected_paths_for_sections(
expected.insert("calendar.json".to_string()); expected.insert("calendar.json".to_string());
expected.insert("rss.xml".to_string()); expected.insert("rss.xml".to_string());
for language in render_languages(metadata) { for language in render_languages(metadata) {
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) { let prefix = if language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new() String::new()
} else { } else {
format!("{language}/") format!("{language}/")
@@ -376,7 +465,10 @@ fn remove_extra_section_paths(
{ {
continue; continue;
} }
if !matches_generated_extension(&rel) || !path_matches_sections(&rel, sections) || expected.contains(&rel) { if !matches_generated_extension(&rel)
|| !path_matches_sections(&rel, sections)
|| expected.contains(&rel)
{
continue; continue;
} }
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?; std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
@@ -412,9 +504,14 @@ fn classify_generated_path(path: &str) -> Option<GenerationSection> {
["category", ..] => Some(GenerationSection::Category), ["category", ..] => Some(GenerationSection::Category),
["tag", ..] => Some(GenerationSection::Tag), ["tag", ..] => Some(GenerationSection::Tag),
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date), [year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => Some(GenerationSection::Date), [year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => {
Some(GenerationSection::Date)
}
[year, month, day, _slug, "index.html"] [year, month, day, _slug, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) => Some(GenerationSection::Single), if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
{
Some(GenerationSection::Single)
}
_ => None, _ => None,
} }
} }
@@ -425,7 +522,10 @@ fn has_language_prefix(parts: &[&str]) -> bool {
!is_year_segment(first) !is_year_segment(first)
&& *first != "category" && *first != "category"
&& *first != "tag" && *first != "tag"
&& (*second == "index.html" || is_year_segment(second) || *second == "category" || *second == "tag") && (*second == "index.html"
|| is_year_segment(second)
|| *second == "category"
|| *second == "tag")
} }
_ => false, _ => false,
} }
@@ -468,14 +568,22 @@ fn section_sort_key(section: &GenerationSection) -> u8 {
} }
fn report_is_empty(report: &SiteValidationReport) -> bool { fn report_is_empty(report: &SiteValidationReport) -> bool {
report.missing_pages.is_empty() && report.extra_pages.is_empty() && report.stale_pages.is_empty() report.missing_pages.is_empty()
&& report.extra_pages.is_empty()
&& report.stale_pages.is_empty()
} }
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> { fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string()); let main = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()]; let mut languages = vec![main.clone()];
for language in &metadata.blog_languages { for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) { if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone()); languages.push(language.clone());
} }
} }
@@ -496,9 +604,17 @@ fn localized_sources(
localized.push(source.clone()); localized.push(source.clone());
continue; continue;
} }
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &source.post.id, language) { if let Ok(translation) =
let raw = std::fs::read_to_string(data_dir.join(translation.file_path.trim_start_matches('/'))) queries::post_translation::get_post_translation_by_post_and_language(
.map_err(EngineError::Io)?; conn,
&source.post.id,
language,
)
{
let raw = std::fs::read_to_string(
data_dir.join(translation.file_path.trim_start_matches('/')),
)
.map_err(EngineError::Io)?;
let (_, body) = crate::util::frontmatter::read_translation_file(&raw) let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
.map_err(EngineError::Parse)?; .map_err(EngineError::Parse)?;
let mut translated_post = source.post.clone(); let mut translated_post = source.post.clone();
@@ -524,7 +640,8 @@ fn filter_posts_for_lists(
posts: &[PublishedPostSource], posts: &[PublishedPostSource],
category_settings: &HashMap<String, CategorySettings>, category_settings: &HashMap<String, CategorySettings>,
) -> Vec<PublishedPostSource> { ) -> Vec<PublishedPostSource> {
posts.iter() posts
.iter()
.filter(|source| { .filter(|source| {
!source.post.categories.iter().any(|category| { !source.post.categories.iter().any(|category| {
category_settings category_settings
@@ -537,8 +654,16 @@ fn filter_posts_for_lists(
.collect() .collect()
} }
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { fn build_rss_xml(
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
language: &str,
) -> String {
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let last_build = posts let last_build = posts
.iter() .iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at))) .filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
@@ -557,22 +682,46 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
]; ];
for source in posts { for source in posts {
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en"))); let url = format!(
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now); "{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
xml.push(" <item>".to_string()); xml.push(" <item>".to_string());
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title))); xml.push(format!(
" <title>{}</title>",
escape_xml(&source.post.title)
));
xml.push(format!(" <link>{url}</link>")); xml.push(format!(" <link>{url}</link>"));
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>")); xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
xml.push(format!(" <pubDate>{}</pubDate>", published.format("%a, %d %b %Y %H:%M:%S GMT"))); xml.push(format!(
" <pubDate>{}</pubDate>",
published.format("%a, %d %b %Y %H:%M:%S GMT")
));
if let Some(author) = &source.post.author { if let Some(author) = &source.post.author {
xml.push(format!(" <author>{}</author>", escape_xml(author))); xml.push(format!(" <author>{}</author>", escape_xml(author)));
} }
xml.push(format!(" <dc:language>{}</dc:language>", escape_xml(language))); xml.push(format!(
" <dc:language>{}</dc:language>",
escape_xml(language)
));
let html = render_markdown_to_html(&source.body_markdown); let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(" <description><![CDATA[{html}]]></description>")); xml.push(format!(
xml.push(format!(" <content:encoded><![CDATA[{html}]]></content:encoded>")); " <description><![CDATA[{html}]]></description>"
));
xml.push(format!(
" <content:encoded><![CDATA[{html}]]></content:encoded>"
));
for category in &source.post.categories { for category in &source.post.categories {
xml.push(format!(" <category>{}</category>", escape_xml(category))); xml.push(format!(
" <category>{}</category>",
escape_xml(category)
));
} }
for tag in &source.post.tags { for tag in &source.post.tags {
xml.push(format!(" <category>{}</category>", escape_xml(tag))); xml.push(format!(" <category>{}</category>", escape_xml(tag)));
@@ -585,8 +734,16 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
xml.join("\n") xml.join("\n")
} }
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { fn build_atom_xml(
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
language: &str,
) -> String {
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en"); let main_language = metadata.main_language.as_deref().unwrap_or("en");
let feed_prefix = language_prefix(language, main_language); let feed_prefix = language_prefix(language, main_language);
let updated = posts let updated = posts
@@ -598,30 +755,59 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(), "<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(), "<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
format!(" <title>{}</title>", escape_xml(&metadata.name)), format!(" <title>{}</title>", escape_xml(&metadata.name)),
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))), format!(
" <subtitle>{}</subtitle>",
escape_xml(metadata.description.as_deref().unwrap_or(""))
),
format!(" <id>{base_url}/</id>"), format!(" <id>{base_url}/</id>"),
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"), format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"), format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), format!(
" <updated>{}</updated>",
updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
),
]; ];
for source in posts { for source in posts {
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en"))); let url = format!(
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now); "{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
let html = render_markdown_to_html(&source.body_markdown); let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language))); xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title))); xml.push(format!(
" <title>{}</title>",
escape_xml(&source.post.title)
));
xml.push(format!(" <id>{url}</id>")); xml.push(format!(" <id>{url}</id>"));
xml.push(format!(" <link href=\"{url}\" />")); xml.push(format!(" <link href=\"{url}\" />"));
xml.push(format!(" <updated>{}</updated>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))); xml.push(format!(
xml.push(format!(" <published>{}</published>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))); " <updated>{}</updated>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
xml.push(format!(
" <published>{}</published>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
if let Some(author) = &source.post.author { if let Some(author) = &source.post.author {
xml.push(format!(" <author><name>{}</name></author>", escape_xml(author))); xml.push(format!(
" <author><name>{}</name></author>",
escape_xml(author)
));
} }
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>")); xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>")); xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
for category in &source.post.categories { for category in &source.post.categories {
xml.push(format!(" <category term=\"{}\" />", escape_xml(category))); xml.push(format!(
" <category term=\"{}\" />",
escape_xml(category)
));
} }
for tag in &source.post.tags { for tag in &source.post.tags {
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag))); xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
@@ -639,7 +825,11 @@ fn build_sitemap_xml(
posts: &[PublishedPostSource], posts: &[PublishedPostSource],
language: &str, language: &str,
) -> String { ) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en"); let main_language = metadata.main_language.as_deref().unwrap_or("en");
let languages = render_languages(metadata); let languages = render_languages(metadata);
let index_lastmod = posts let index_lastmod = posts
@@ -694,7 +884,10 @@ fn build_sitemap_xml(
alternate.url_path, alternate.url_path,
)); ));
} }
if let Some(default_page) = alternates.iter().find(|alternate| alternate.language == main_language) { if let Some(default_page) = alternates
.iter()
.find(|alternate| alternate.language == main_language)
{
xml.push(format!( xml.push(format!(
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />", " <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
default_page.url_path, default_page.url_path,
@@ -746,7 +939,9 @@ fn logical_page_key(relative_path: &str, languages: &[String], main_language: &s
if first.eq_ignore_ascii_case(main_language) { if first.eq_ignore_ascii_case(main_language) {
return parts.collect::<Vec<_>>().join("/"); return parts.collect::<Vec<_>>().join("/");
} }
if languages.iter().any(|language| language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)) { if languages.iter().any(|language| {
language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)
}) {
return parts.collect::<Vec<_>>().join("/"); return parts.collect::<Vec<_>>().join("/");
} }
relative_path.to_string() relative_path.to_string()
@@ -763,4 +958,4 @@ fn escape_xml(value: &str) -> String {
.replace('>', "&gt;") .replace('>', "&gt;")
.replace('"', "&quot;") .replace('"', "&quot;")
.replace('\'', "&apos;") .replace('\'', "&apos;")
} }
+12 -4
View File
@@ -12,11 +12,11 @@ use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation}; use crate::model::{Media, MediaTranslation};
use crate::util::sidecar::{ use crate::util::sidecar::{
read_sidecar, read_translation_sidecar, MediaSidecar, MediaTranslationSidecar, MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
}; };
use crate::util::thumbnail::{ use crate::util::thumbnail::{
generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat, THUMBNAIL_SIZES, ThumbnailFormat, generate_all_thumbnails, image_dimensions,
THUMBNAIL_SIZES, mime_from_extension,
}; };
use crate::util::{ use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path, atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
@@ -46,6 +46,10 @@ const SUPPORTED_IMAGE_TYPES: &[&str] = &[
]; ];
/// Import a media file (image, etc.) into the project. /// Import a media file (image, etc.) into the project.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied media metadata fields"
)]
pub fn import_media( pub fn import_media(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
@@ -142,6 +146,10 @@ pub fn import_media(
} }
/// Update a media item's metadata fields. /// Update a media item's metadata fields.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent media field changes"
)]
pub fn update_media( pub fn update_media(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
@@ -655,9 +663,9 @@ fn rebuild_translation_sidecar(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables; use crate::db::fts::ensure_fts_tables;
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use image::DynamicImage; use image::DynamicImage;
use tempfile::TempDir; use tempfile::TempDir;
+155 -215
View File
@@ -1,6 +1,10 @@
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::{Reader, Writer, XmlVersion, escape::escape};
use crate::engine::EngineError;
use crate::engine::EngineResult; use crate::engine::EngineResult;
use crate::util::atomic_write_str; use crate::util::atomic_write_str;
@@ -53,14 +57,14 @@ pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
}]); }]);
} }
let content = fs::read_to_string(&path)?; let content = fs::read_to_string(&path)?;
Ok(parse_opml(&content)) parse_opml(&content)
} }
/// Write the navigation menu to meta/menu.opml. /// Write the navigation menu to meta/menu.opml.
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended. /// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> { pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
let normalized = normalize_menu(items); let normalized = normalize_menu(items);
let opml = serialize_opml(&normalized); let opml = serialize_opml(&normalized)?;
let path = data_dir.join("meta").join("menu.opml"); let path = data_dir.join("meta").join("menu.opml");
atomic_write_str(&path, &opml)?; atomic_write_str(&path, &opml)?;
Ok(()) Ok(())
@@ -74,7 +78,7 @@ pub fn default_menu_opml() -> String {
slug: None, slug: None,
children: Vec::new(), children: Vec::new(),
}]; }];
serialize_opml(&items) serialize_opml(&items).expect("writing menu XML to memory cannot fail")
} }
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first. /// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
@@ -95,211 +99,136 @@ fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
} }
/// Parse OPML 2.0 XML into menu items. /// Parse OPML 2.0 XML into menu items.
fn parse_opml(content: &str) -> Vec<MenuItem> { fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
// Simple XML parsing using quick-xml-style manual parsing. let mut reader = Reader::from_str(content);
// OPML structure: <opml><body><outline .../></body></opml> reader.config_mut().trim_text(true);
let mut items = Vec::new(); let mut items = Vec::new();
parse_outlines(content, &mut items); let mut parents = Vec::new();
let mut in_body = false;
// Ensure HomeAlwaysPresent loop {
if items.is_empty() || items[0].kind != MenuItemKind::Home { match reader.read_event() {
let without_home: Vec<MenuItem> = items Ok(Event::Start(event)) if event.name().as_ref() == b"body" => in_body = true,
.into_iter() Ok(Event::End(event)) if event.name().as_ref() == b"body" => in_body = false,
.filter(|i| i.kind != MenuItemKind::Home) Ok(Event::Start(event)) if in_body && event.name().as_ref() == b"outline" => {
.collect(); parents.push(parse_outline(&event)?);
let mut normalized = vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
slug: None,
children: Vec::new(),
}];
normalized.extend(without_home);
return normalized;
}
items
}
/// Simple OPML outline parser.
/// Parses <outline text="..." type="..." htmlUrl="..."> elements.
fn parse_outlines(xml: &str, items: &mut Vec<MenuItem>) {
// Find all outline elements at the body level
let body_start = xml.find("<body>");
let body_end = xml.find("</body>");
if body_start.is_none() || body_end.is_none() {
return;
}
let body = &xml[body_start.unwrap() + 6..body_end.unwrap()];
parse_outline_children(body, items);
}
fn parse_outline_children(content: &str, items: &mut Vec<MenuItem>) {
let mut pos = 0;
while pos < content.len() {
// Find next <outline
let Some(start) = content[pos..].find("<outline") else {
break;
};
let abs_start = pos + start;
let tag_start = abs_start;
// Find the end of the opening tag
let rest = &content[tag_start..];
let is_self_closing;
let tag_end;
if let Some(sc) = rest.find("/>") {
if let Some(gt) = rest.find('>') {
if sc < gt {
is_self_closing = true;
tag_end = tag_start + sc + 2;
} else {
is_self_closing = false;
tag_end = tag_start + gt + 1;
}
} else {
is_self_closing = true;
tag_end = tag_start + sc + 2;
} }
} else if let Some(gt) = rest.find('>') { Ok(Event::Empty(event)) if in_body && event.name().as_ref() == b"outline" => {
is_self_closing = false; attach_outline(parse_outline(&event)?, &mut parents, &mut items);
tag_end = tag_start + gt + 1;
} else {
break;
}
let tag_content = &content[tag_start..tag_end];
// Extract attributes
let label = extract_attr(tag_content, "text")
.unwrap_or_else(|| "Untitled".to_string());
let kind_str = extract_attr(tag_content, "type")
.unwrap_or_else(|| "page".to_string());
let slug = extract_attr(tag_content, "htmlUrl");
let kind = MenuItemKind::from_str(&kind_str);
let mut children = Vec::new();
let after_tag;
if is_self_closing {
after_tag = tag_end;
} else {
// Find matching </outline>
let inner = &content[tag_end..];
if let Some(close_idx) = find_closing_outline(inner) {
let inner_content = &inner[..close_idx];
parse_outline_children(inner_content, &mut children);
after_tag = tag_end + close_idx + "</outline>".len();
} else {
after_tag = tag_end;
} }
} Ok(Event::End(event)) if event.name().as_ref() == b"outline" => {
let item = parents
items.push(MenuItem { .pop()
kind, .ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
label, attach_outline(item, &mut parents, &mut items);
slug,
children,
});
pos = after_tag;
}
}
fn find_closing_outline(content: &str) -> Option<usize> {
let mut depth = 0i32;
let mut pos = 0;
while pos < content.len() {
if content[pos..].starts_with("<outline") {
if let Some(sc) = content[pos..].find("/>") {
if let Some(gt) = content[pos..].find('>') {
if sc < gt {
pos += sc + 2;
continue;
}
}
} }
depth += 1; Ok(Event::Eof) => break,
pos += 8; // skip past "<outline" Ok(_) => {}
} else if content[pos..].starts_with("</outline>") { Err(error) => return Err(EngineError::Parse(error.to_string())),
if depth == 0 {
return Some(pos);
}
depth -= 1;
pos += 10;
} else {
pos += 1;
} }
} }
None
if !parents.is_empty() {
return Err(EngineError::Parse("unclosed <outline>".to_string()));
}
Ok(normalize_menu(&items))
} }
fn extract_attr(tag: &str, name: &str) -> Option<String> { fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
let pattern = format!("{name}=\""); let mut label = "Untitled".to_string();
let start = tag.find(&pattern)?; let mut kind = MenuItemKind::Page;
let value_start = start + pattern.len(); let mut slug = None;
let rest = &tag[value_start..];
let end = rest.find('"')?; for attribute in event.attributes() {
let value = &rest[..end]; let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?;
// Unescape XML entities let value = attribute
Some( .decoded_and_normalized_value(XmlVersion::Implicit1_0, event.decoder())
value .map_err(|error| EngineError::Parse(error.to_string()))?
.replace("&amp;", "&") .into_owned();
.replace("&lt;", "<") match attribute.key.as_ref() {
.replace("&gt;", ">") b"text" => label = value,
.replace("&quot;", "\"") b"type" => kind = MenuItemKind::from_str(&value),
.replace("&apos;", "'"), b"htmlUrl" => slug = Some(value),
) _ => {}
}
}
Ok(MenuItem {
kind,
label,
slug,
children: Vec::new(),
})
}
fn attach_outline(item: MenuItem, parents: &mut [MenuItem], items: &mut Vec<MenuItem>) {
if let Some(parent) = parents.last_mut() {
parent.children.push(item);
} else {
items.push(item);
}
} }
/// Serialize menu items to OPML 2.0 format. /// Serialize menu items to OPML 2.0 format.
fn serialize_opml(items: &[MenuItem]) -> String { fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
let mut out = String::new(); let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); writer
out.push_str("<opml version=\"2.0\">\n"); .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
out.push_str(" <head><title>Blog Menu</title></head>\n"); .map_err(|error| EngineError::Parse(error.to_string()))?;
out.push_str(" <body>\n"); let mut opml = BytesStart::new("opml");
opml.push_attribute(("version", "2.0"));
writer
.write_event(Event::Start(opml))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("head")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("title")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Text(BytesText::new("Blog Menu")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("title")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("head")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("body")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
for item in items { for item in items {
serialize_outline(&mut out, item, 2); write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?;
} }
out.push_str(" </body>\n"); writer
out.push_str("</opml>\n"); .write_event(Event::End(BytesEnd::new("body")))
out .map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("opml")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
String::from_utf8(writer.into_inner()).map_err(|error| EngineError::Parse(error.to_string()))
} }
fn serialize_outline(out: &mut String, item: &MenuItem, indent: usize) { fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
let pad = " ".repeat(indent); let label = escape(&item.label);
out.push_str(&pad); let slug = item.slug.as_deref().map(escape);
out.push_str("<outline"); let mut outline = BytesStart::new("outline");
out.push_str(&format!( outline.push_attribute(("text", label.as_ref()));
" text=\"{}\"", outline.push_attribute(("type", item.kind.as_str()));
xml_escape(&item.label) if let Some(slug) = &slug {
)); outline.push_attribute(("htmlUrl", slug.as_ref()));
out.push_str(&format!(
" type=\"{}\"",
item.kind.as_str()
));
if let Some(ref slug) = item.slug {
out.push_str(&format!(
" htmlUrl=\"{}\"",
xml_escape(slug)
));
} }
if item.children.is_empty() { if item.children.is_empty() {
out.push_str("/>\n"); writer.write_event(Event::Empty(outline))?;
} else { } else {
out.push_str(">\n"); writer.write_event(Event::Start(outline))?;
for child in &item.children { for child in &item.children {
serialize_outline(out, child, indent + 1); write_outline(writer, child)?;
} }
out.push_str(&pad); writer.write_event(Event::End(BytesEnd::new("outline")))?;
out.push_str("</outline>\n");
} }
} Ok(())
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
} }
#[cfg(test)] #[cfg(test)]
@@ -332,18 +261,16 @@ mod tests {
kind: MenuItemKind::Submenu, kind: MenuItemKind::Submenu,
label: "Categories".into(), label: "Categories".into(),
slug: None, slug: None,
children: vec![ children: vec![MenuItem {
MenuItem { kind: MenuItemKind::CategoryArchive,
kind: MenuItemKind::CategoryArchive, label: "Tech".into(),
label: "Tech".into(), slug: Some("/category/tech/".into()),
slug: Some("/category/tech/".into()), children: Vec::new(),
children: Vec::new(), }],
},
],
}, },
]; ];
let opml = serialize_opml(&items); let opml = serialize_opml(&items).unwrap();
let parsed = parse_opml(&opml); let parsed = parse_opml(&opml).unwrap();
assert_eq!(parsed.len(), 3); assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0].kind, MenuItemKind::Home); assert_eq!(parsed[0].kind, MenuItemKind::Home);
assert_eq!(parsed[1].label, "About"); assert_eq!(parsed[1].label, "About");
@@ -354,14 +281,12 @@ mod tests {
#[test] #[test]
fn normalize_prepends_home() { fn normalize_prepends_home() {
let items = vec![ let items = vec![MenuItem {
MenuItem { kind: MenuItemKind::Page,
kind: MenuItemKind::Page, label: "About".into(),
label: "About".into(), slug: Some("/about".into()),
slug: Some("/about".into()), children: Vec::new(),
children: Vec::new(), }];
},
];
let normalized = normalize_menu(&items); let normalized = normalize_menu(&items);
assert_eq!(normalized.len(), 2); assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].kind, MenuItemKind::Home); assert_eq!(normalized[0].kind, MenuItemKind::Home);
@@ -381,14 +306,12 @@ mod tests {
fn write_and_read_menu() { fn write_and_read_menu() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap();
let items = vec![ let items = vec![MenuItem {
MenuItem { kind: MenuItemKind::Page,
kind: MenuItemKind::Page, label: "Blog".into(),
label: "Blog".into(), slug: Some("/blog".into()),
slug: Some("/blog".into()), children: Vec::new(),
children: Vec::new(), }];
},
];
write_menu(dir.path(), &items).unwrap(); write_menu(dir.path(), &items).unwrap();
let read = read_menu(dir.path()).unwrap(); let read = read_menu(dir.path()).unwrap();
// Home is always prepended // Home is always prepended
@@ -398,8 +321,25 @@ mod tests {
} }
#[test] #[test]
fn xml_escape_special_chars() { fn reads_single_quoted_xml_attributes() {
let escaped = xml_escape("Tom & Jerry <3 \"quotes\""); let parsed = parse_opml(
assert_eq!(escaped, "Tom &amp; Jerry &lt;3 &quot;quotes&quot;"); "<opml version='2.0'><body><outline text='About' type='page' htmlUrl='/about'/></body></opml>",
)
.unwrap();
assert_eq!(parsed[1].label, "About");
assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
}
#[test]
fn read_menu_rejects_malformed_xml() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
std::fs::write(
dir.path().join("meta/menu.opml"),
"<opml><body><outline></body></opml>",
)
.unwrap();
assert!(read_menu(dir.path()).is_err());
} }
} }
+15 -19
View File
@@ -3,8 +3,8 @@ use std::fs;
use std::path::Path; use std::path::Path;
use crate::engine::EngineResult; use crate::engine::EngineResult;
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
use crate::model::PublishingPreferences; use crate::model::PublishingPreferences;
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
use crate::util::atomic_write_str; use crate::util::atomic_write_str;
// ── project.json ──────────────────────────────────────────────────── // ── project.json ────────────────────────────────────────────────────
@@ -38,7 +38,7 @@ pub fn read_categories_json(data_dir: &Path) -> EngineResult<Vec<String>> {
/// Sort categories, then atomic write to meta/categories.json. /// Sort categories, then atomic write to meta/categories.json.
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> { pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
let mut sorted = categories.to_vec(); let mut sorted = categories.to_vec();
sorted.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); sorted.sort_by_key(|a| a.to_lowercase());
let path = data_dir.join("meta").join("categories.json"); let path = data_dir.join("meta").join("categories.json");
let json = serde_json::to_string_pretty(&sorted)?; let json = serde_json::to_string_pretty(&sorted)?;
atomic_write_str(&path, &json)?; atomic_write_str(&path, &json)?;
@@ -48,9 +48,7 @@ pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineRe
// ── category-meta.json ────────────────────────────────────────────── // ── category-meta.json ──────────────────────────────────────────────
/// Read meta/category-meta.json. /// Read meta/category-meta.json.
pub fn read_category_meta_json( pub fn read_category_meta_json(data_dir: &Path) -> EngineResult<HashMap<String, CategorySettings>> {
data_dir: &Path,
) -> EngineResult<HashMap<String, CategorySettings>> {
let path = data_dir.join("meta").join("category-meta.json"); let path = data_dir.join("meta").join("category-meta.json");
let content = fs::read_to_string(&path)?; let content = fs::read_to_string(&path)?;
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?; let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
@@ -79,10 +77,7 @@ pub fn read_publishing_json(data_dir: &Path) -> EngineResult<PublishingPreferenc
} }
/// Atomic write to meta/publishing.json. /// Atomic write to meta/publishing.json.
pub fn write_publishing_json( pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> EngineResult<()> {
data_dir: &Path,
prefs: &PublishingPreferences,
) -> EngineResult<()> {
let path = data_dir.join("meta").join("publishing.json"); let path = data_dir.join("meta").join("publishing.json");
let json = serde_json::to_string_pretty(prefs)?; let json = serde_json::to_string_pretty(prefs)?;
atomic_write_str(&path, &json)?; atomic_write_str(&path, &json)?;
@@ -102,7 +97,7 @@ pub fn read_tags_json(data_dir: &Path) -> EngineResult<Vec<TagEntry>> {
/// Sort by name case-insensitive, then atomic write to meta/tags.json. /// Sort by name case-insensitive, then atomic write to meta/tags.json.
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> { pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
let mut sorted = tags.to_vec(); let mut sorted = tags.to_vec();
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); sorted.sort_by_key(|a| a.name.to_lowercase());
let path = data_dir.join("meta").join("tags.json"); let path = data_dir.join("meta").join("tags.json");
let json = serde_json::to_string_pretty(&sorted)?; let json = serde_json::to_string_pretty(&sorted)?;
atomic_write_str(&path, &json)?; atomic_write_str(&path, &json)?;
@@ -162,10 +157,7 @@ pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineR
/// Update arbitrary fields of project metadata. /// Update arbitrary fields of project metadata.
/// Per metadata.allium UpdateProjectMetadata rule. /// Per metadata.allium UpdateProjectMetadata rule.
pub fn update_project_metadata( pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> EngineResult<()> {
data_dir: &Path,
changes: &serde_json::Value,
) -> EngineResult<()> {
let mut meta = read_project_json(data_dir)?; let mut meta = read_project_json(data_dir)?;
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) { if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
meta.name = name.to_string(); meta.name = name.to_string();
@@ -191,7 +183,10 @@ pub fn update_project_metadata(
if let Some(theme) = changes.get("picoTheme") { if let Some(theme) = changes.get("picoTheme") {
meta.pico_theme = theme.as_str().map(|s| s.to_string()); meta.pico_theme = theme.as_str().map(|s| s.to_string());
} }
if let Some(enabled) = changes.get("semanticSimilarityEnabled").and_then(|v| v.as_bool()) { if let Some(enabled) = changes
.get("semanticSimilarityEnabled")
.and_then(|v| v.as_bool())
{
meta.semantic_similarity_enabled = enabled; meta.semantic_similarity_enabled = enabled;
} }
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) { if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
@@ -200,7 +195,8 @@ pub fn update_project_metadata(
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(); .collect();
} }
meta.validate().map_err(|e| crate::engine::EngineError::Validation(e))?; meta.validate()
.map_err(crate::engine::EngineError::Validation)?;
write_project_json(data_dir, &meta)?; write_project_json(data_dir, &meta)?;
Ok(()) Ok(())
} }
@@ -380,7 +376,7 @@ mod tests {
fn add_category_creates_entries() { fn add_category_creates_entries() {
let dir = setup(); let dir = setup();
// Seed files // Seed files
write_categories_json(dir.path(), &vec!["article".into()]).unwrap(); write_categories_json(dir.path(), &["article".into()]).unwrap();
write_category_meta_json(dir.path(), &HashMap::new()).unwrap(); write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
add_category(dir.path(), "page").unwrap(); add_category(dir.path(), "page").unwrap();
@@ -395,7 +391,7 @@ mod tests {
#[test] #[test]
fn add_category_idempotent() { fn add_category_idempotent() {
let dir = setup(); let dir = setup();
write_categories_json(dir.path(), &vec!["article".into()]).unwrap(); write_categories_json(dir.path(), &["article".into()]).unwrap();
write_category_meta_json(dir.path(), &HashMap::new()).unwrap(); write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
add_category(dir.path(), "article").unwrap(); add_category(dir.path(), "article").unwrap();
@@ -406,7 +402,7 @@ mod tests {
#[test] #[test]
fn remove_category_deletes_entries() { fn remove_category_deletes_entries() {
let dir = setup(); let dir = setup();
write_categories_json(dir.path(), &vec!["article".into(), "page".into()]).unwrap(); write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap();
let mut meta = HashMap::new(); let mut meta = HashMap::new();
meta.insert( meta.insert(
"article".to_string(), "article".to_string(),
+39 -27
View File
@@ -13,7 +13,9 @@ use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl; use crate::db::queries::template as qtpl;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template}; 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::frontmatter::{
read_post_file, read_script_file, read_template_file, read_translation_file,
};
use crate::util::sidecar::read_sidecar; use crate::util::sidecar::read_sidecar;
/// A single field difference. /// A single field difference.
@@ -139,7 +141,11 @@ fn opt_to_str(opt: &Option<String>) -> String {
} }
fn bool_to_str(b: bool) -> String { fn bool_to_str(b: bool) -> String {
if b { "true".to_string() } else { "false".to_string() } if b {
"true".to_string()
} else {
"false".to_string()
}
} }
fn tags_to_json(tags: &[String]) -> String { fn tags_to_json(tags: &[String]) -> String {
@@ -172,7 +178,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
} }
let content = fs::read_to_string(&abs_path)?; let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?; let (fm, _body) = read_post_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new(); let mut fields = Vec::new();
@@ -257,21 +263,23 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
} }
} }
fn diff_translation( fn diff_translation(data_dir: &Path, t: &PostTranslation) -> EngineResult<Option<EntityDiff>> {
data_dir: &Path,
t: &PostTranslation,
) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&t.file_path); let abs_path = data_dir.join(&t.file_path);
if !abs_path.exists() { if !abs_path.exists() {
return Ok(None); return Ok(None);
} }
let content = fs::read_to_string(&abs_path)?; let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?; let (fm, _body) = read_translation_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new(); let mut fields = Vec::new();
compare_field(&mut fields, "translationFor", &t.translation_for, &fm.translation_for); compare_field(
&mut fields,
"translationFor",
&t.translation_for,
&fm.translation_for,
);
compare_field(&mut fields, "language", &t.language, &fm.language); compare_field(&mut fields, "language", &t.language, &fm.language);
compare_field(&mut fields, "title", &t.title, &fm.title); compare_field(&mut fields, "title", &t.title, &fm.title);
compare_field( compare_field(
@@ -285,7 +293,7 @@ fn diff_translation(
Ok(None) Ok(None)
} else { } else {
Ok(Some(EntityDiff { Ok(Some(EntityDiff {
entity_type: "translation".to_string(), entity_type: "post_translation".to_string(),
entity_id: t.id.clone(), entity_id: t.id.clone(),
file_path: t.file_path.clone(), file_path: t.file_path.clone(),
fields, fields,
@@ -300,7 +308,7 @@ fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>
} }
let content = fs::read_to_string(&abs_path)?; let content = fs::read_to_string(&abs_path)?;
let sc = read_sidecar(&content).map_err(|e| EngineError::Parse(e))?; let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new(); let mut fields = Vec::new();
@@ -360,7 +368,7 @@ fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityD
} }
let content = fs::read_to_string(&abs_path)?; let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_template_file(&content).map_err(|e| EngineError::Parse(e))?; let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new(); let mut fields = Vec::new();
@@ -403,7 +411,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
} }
let content = fs::read_to_string(&abs_path)?; let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_script_file(&content).map_err(|e| EngineError::Parse(e))?; let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new(); let mut fields = Vec::new();
@@ -414,7 +422,12 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
script_kind_to_str(&script.kind), script_kind_to_str(&script.kind),
&fm.kind, &fm.kind,
); );
compare_field(&mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint); compare_field(
&mut fields,
"entrypoint",
&script.entrypoint,
&fm.entrypoint,
);
compare_field( compare_field(
&mut fields, &mut fields,
"enabled", "enabled",
@@ -494,10 +507,7 @@ fn detect_orphan_files(
if !dir_path.exists() { if !dir_path.exists() {
continue; continue;
} }
for entry in WalkDir::new(&dir_path) for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) {
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path(); let path = entry.path();
if !path.is_file() { if !path.is_file() {
continue; continue;
@@ -600,20 +610,20 @@ fn detect_orphan_files(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables; use crate::db::fts::ensure_fts_tables;
use crate::db::queries::media::insert_media; use crate::db::queries::media::insert_media;
use crate::db::queries::post::insert_post; use crate::db::queries::post::insert_post;
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script::insert_script; use crate::db::queries::script::insert_script;
use crate::db::queries::template::insert_template; use crate::db::queries::template::insert_template;
use crate::db::Database;
use crate::engine::post::{create_post, publish_post}; use crate::engine::post::{create_post, publish_post};
use crate::model::{ use crate::model::{
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind, Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
TemplateStatus, TemplateStatus,
}; };
use crate::util::frontmatter::{ use crate::util::frontmatter::{
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter, ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
}; };
use crate::util::sidecar::MediaSidecar; use crate::util::sidecar::MediaSidecar;
use tempfile::TempDir; use tempfile::TempDir;
@@ -658,11 +668,7 @@ mod tests {
let non_time_diffs: Vec<_> = report let non_time_diffs: Vec<_> = report
.diffs .diffs
.iter() .iter()
.filter(|d| { .filter(|d| d.fields.iter().any(|f| f.field_name != "updatedAt"))
d.fields
.iter()
.any(|f| f.field_name != "updatedAt")
})
.collect(); .collect();
assert!( assert!(
non_time_diffs.is_empty(), non_time_diffs.is_empty(),
@@ -786,7 +792,11 @@ mod tests {
// Create a .md file in posts/ that is not in the DB // Create a .md file in posts/ that is not in the DB
let posts_dir = dir.path().join("posts/2024/01"); let posts_dir = dir.path().join("posts/2024/01");
fs::create_dir_all(&posts_dir).unwrap(); fs::create_dir_all(&posts_dir).unwrap();
fs::write(posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n").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 report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
@@ -801,7 +811,9 @@ mod tests {
"expected at least one orphan file" "expected at least one orphan file"
); );
assert!( assert!(
orphan_files.iter().any(|o| o.file_path.contains("orphan.md")), orphan_files
.iter()
.any(|o| o.file_path.contains("orphan.md")),
"expected orphan.md in orphans, got: {orphan_files:?}" "expected orphan.md in orphans, got: {orphan_files:?}"
); );
} }
+18 -18
View File
@@ -1,29 +1,29 @@
pub mod error;
pub mod ai; pub mod ai;
pub mod calendar;
pub mod context; pub mod context;
pub mod project; pub mod error;
pub mod meta; pub mod generation;
pub mod tag;
pub mod post;
pub mod media; pub mod media;
pub mod menu;
pub mod meta;
pub mod metadata_diff;
pub mod post;
pub mod post_media; pub mod post_media;
pub mod template; pub mod preview;
pub mod template_rebuild; pub mod project;
pub mod rebuild;
pub mod script; pub mod script;
pub mod script_rebuild; pub mod script_rebuild;
pub mod task;
pub mod metadata_diff;
pub mod site_assets;
pub mod rebuild;
pub mod search; pub mod search;
pub mod calendar; pub mod site_assets;
pub mod generation; pub mod tag;
pub mod preview; pub mod task;
pub mod template;
pub mod template_rebuild;
pub mod validate_content;
pub mod validate_media;
pub mod validate_site; pub mod validate_site;
pub mod validate_translations; pub mod validate_translations;
pub mod validate_media;
pub mod validate_content;
pub mod menu;
pub use error::{EngineError, EngineResult};
pub use context::EngineContext; pub use context::EngineContext;
pub use error::{EngineError, EngineResult};
+170 -104
View File
@@ -1,7 +1,7 @@
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use uuid::Uuid; use uuid::Uuid;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -30,6 +30,10 @@ pub struct RebuildReport {
} }
/// Create a new draft post. /// Create a new draft post.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied post fields"
)]
pub fn create_post( pub fn create_post(
conn: &Connection, conn: &Connection,
_data_dir: &Path, _data_dir: &Path,
@@ -90,6 +94,10 @@ pub fn create_post(
} }
/// Update a post's fields. /// Update a post's fields.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent post field changes"
)]
pub fn update_post( pub fn update_post(
conn: &Connection, conn: &Connection,
_data_dir: &Path, _data_dir: &Path,
@@ -115,14 +123,13 @@ pub fn update_post(
} }
// Slug uniqueness check // Slug uniqueness check
if let Some(new_slug) = slug { if let Some(new_slug) = slug
if new_slug != post.slug { && new_slug != post.slug
if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() { && qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok()
return Err(EngineError::Conflict(format!( {
"slug '{new_slug}' already exists in this project" return Err(EngineError::Conflict(format!(
))); "slug '{new_slug}' already exists in this project"
} )));
}
} }
if let Some(t) = title { if let Some(t) = title {
@@ -161,12 +168,11 @@ pub fn update_post(
// Reload content from filesystem if content field is NULL (published state) // Reload content from filesystem if content field is NULL (published state)
if post.content.is_none() && !post.file_path.is_empty() { if post.content.is_none() && !post.file_path.is_empty() {
let abs_path = _data_dir.join(&post.file_path); let abs_path = _data_dir.join(&post.file_path);
if abs_path.exists() { if abs_path.exists()
if let Ok(file_content) = fs::read_to_string(&abs_path) { && let Ok(file_content) = fs::read_to_string(&abs_path)
if let Ok((_fm, body)) = read_post_file(&file_content) { && let Ok((_fm, body)) = read_post_file(&file_content)
post.content = Some(body); {
} post.content = Some(body);
}
} }
} }
post.status = PostStatus::Draft; post.status = PostStatus::Draft;
@@ -207,7 +213,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
c.clone() c.clone()
} else if abs_path.exists() { } else if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?; let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) = read_post_file(&file_content).map_err(|e| EngineError::Parse(e))?; let (_fm, body) = read_post_file(&file_content).map_err(EngineError::Parse)?;
body body
} else { } else {
String::new() String::new()
@@ -310,13 +316,12 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty()
{ {
let abs_path = data_dir.join(&post.file_path); let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() { if abs_path.exists()
if let Ok(file_content) = fs::read_to_string(&abs_path) { && let Ok(file_content) = fs::read_to_string(&abs_path)
if let Ok((_fm, body)) = read_post_file(&file_content) { && let Ok((_fm, body)) = read_post_file(&file_content)
post.content = Some(body); {
qp::update_post(conn, &post)?; post.content = Some(body);
} qp::update_post(conn, &post)?;
}
} }
} }
let now = now_unix_ms(); let now = now_unix_ms();
@@ -325,11 +330,7 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
} }
/// Discard unpublished draft changes and restore the published version. /// Discard unpublished draft changes and restore the published version.
pub fn discard_post_draft( pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<Post> {
let mut post = qp::get_post_by_id(conn, post_id)?; let mut post = qp::get_post_by_id(conn, post_id)?;
if post.published_at.is_none() { if post.published_at.is_none() {
return Err(EngineError::Conflict( return Err(EngineError::Conflict(
@@ -337,49 +338,43 @@ pub fn discard_post_draft(
)); ));
} }
let (title, slug, excerpt, author, language, template_slug, do_not_translate, tags, categories, checksum) = let (
if !post.file_path.is_empty() { title,
let abs_path = data_dir.join(&post.file_path); slug,
if abs_path.exists() { excerpt,
let raw = fs::read_to_string(&abs_path)?; author,
let (fm, _body) = read_post_file(&raw).map_err(EngineError::Parse)?; language,
( template_slug,
fm.title, do_not_translate,
fm.slug, tags,
fm.excerpt, categories,
fm.author, checksum,
fm.language, ) = if !post.file_path.is_empty() {
fm.template_slug, let abs_path = data_dir.join(&post.file_path);
fm.do_not_translate, if abs_path.exists() {
fm.tags, let raw = fs::read_to_string(&abs_path)?;
fm.categories, let (fm, _body) = read_post_file(&raw).map_err(EngineError::Parse)?;
Some(content_hash(raw.as_bytes())), (
) fm.title,
} else { fm.slug,
( fm.excerpt,
post.published_title.clone().unwrap_or_else(|| post.title.clone()), fm.author,
post.slug.clone(), fm.language,
post.published_excerpt.clone().or_else(|| post.excerpt.clone()), fm.template_slug,
post.author.clone(), fm.do_not_translate,
post.language.clone(), fm.tags,
post.template_slug.clone(), fm.categories,
post.do_not_translate, Some(content_hash(raw.as_bytes())),
post.published_tags )
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.tags.clone()),
post.published_categories
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
}
} else { } else {
( (
post.published_title.clone().unwrap_or_else(|| post.title.clone()), post.published_title
.clone()
.unwrap_or_else(|| post.title.clone()),
post.slug.clone(), post.slug.clone(),
post.published_excerpt.clone().or_else(|| post.excerpt.clone()), post.published_excerpt
.clone()
.or_else(|| post.excerpt.clone()),
post.author.clone(), post.author.clone(),
post.language.clone(), post.language.clone(),
post.template_slug.clone(), post.template_slug.clone(),
@@ -394,7 +389,31 @@ pub fn discard_post_draft(
.unwrap_or_else(|| post.categories.clone()), .unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(), post.checksum.clone(),
) )
}; }
} else {
(
post.published_title
.clone()
.unwrap_or_else(|| post.title.clone()),
post.slug.clone(),
post.published_excerpt
.clone()
.or_else(|| post.excerpt.clone()),
post.author.clone(),
post.language.clone(),
post.template_slug.clone(),
post.do_not_translate,
post.published_tags
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.tags.clone()),
post.published_categories
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
};
post.title = title; post.title = title;
post.slug = slug; post.slug = slug;
@@ -415,11 +434,7 @@ pub fn discard_post_draft(
} }
/// Create a new draft post by duplicating an existing post. /// Create a new draft post by duplicating an existing post.
pub fn duplicate_post( pub fn duplicate_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<Post> {
let source = qp::get_post_by_id(conn, post_id)?; let source = qp::get_post_by_id(conn, post_id)?;
let body = if let Some(ref content) = source.content { let body = if let Some(ref content) = source.content {
content.clone() content.clone()
@@ -529,7 +544,7 @@ pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
/// Upsert a translation for a post. /// Upsert a translation for a post.
pub fn upsert_translation( pub fn upsert_translation(
conn: &Connection, conn: &Connection,
_data_dir: &Path, data_dir: &Path,
post_id: &str, post_id: &str,
language: &str, language: &str,
title: &str, title: &str,
@@ -548,7 +563,20 @@ pub fn upsert_translation(
let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language); let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language);
match existing { match existing {
Ok(mut t) => { Ok(mut t) => {
// Update existing let published_body = if t.status == PostStatus::Published && !t.file_path.is_empty() {
let raw = fs::read_to_string(data_dir.join(&t.file_path))?;
let (_, body) = read_translation_file(&raw).map_err(EngineError::Parse)?;
Some(body)
} else {
t.content.clone()
};
let affects_published_content = t.title != title
|| t.excerpt.as_deref() != excerpt
|| content.is_some_and(|value| published_body.as_deref() != Some(value));
if t.status == PostStatus::Published && affects_published_content {
t.status = PostStatus::Draft;
t.content = published_body;
}
t.title = title.to_string(); t.title = title.to_string();
t.excerpt = excerpt.map(|s| s.to_string()); t.excerpt = excerpt.map(|s| s.to_string());
if let Some(c) = content { if let Some(c) = content {
@@ -834,8 +862,7 @@ fn publish_translation(
c.clone() c.clone()
} else if abs_path.exists() { } else if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?; let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) = let (_fm, body) = read_translation_file(&file_content).map_err(EngineError::Parse)?;
read_translation_file(&file_content).map_err(|e| EngineError::Parse(e))?;
body body
} else { } else {
String::new() String::new()
@@ -912,7 +939,7 @@ fn rebuild_canonical_post(
path: &Path, path: &Path,
) -> EngineResult<bool> { ) -> EngineResult<bool> {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let (fm, body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?; let (fm, body) = read_post_file(&content).map_err(EngineError::Parse)?;
let rel_path = path let rel_path = path
.strip_prefix(data_dir) .strip_prefix(data_dir)
@@ -1002,7 +1029,7 @@ fn rebuild_translation(
path: &Path, path: &Path,
) -> EngineResult<bool> { ) -> EngineResult<bool> {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?; let (fm, body) = read_translation_file(&content).map_err(EngineError::Parse)?;
let rel_path = path let rel_path = path
.strip_prefix(data_dir) .strip_prefix(data_dir)
@@ -1011,7 +1038,6 @@ fn rebuild_translation(
.to_string(); .to_string();
let hash = content_hash(content.as_bytes()); let hash = content_hash(content.as_bytes());
let now = now_unix_ms();
// Check if parent post exists // Check if parent post exists
let parent = qp::get_post_by_id(conn, &fm.translation_for); let parent = qp::get_post_by_id(conn, &fm.translation_for);
@@ -1023,12 +1049,17 @@ fn rebuild_translation(
} }
let parent = parent.unwrap(); let parent = parent.unwrap();
// Determine status from parent // Current files carry independent translation metadata. Legacy files fall back to the
let status = if parent.status == PostStatus::Published { // canonical post values for compatibility.
PostStatus::Published let status = match fm.status.as_deref() {
} else { Some("published") => PostStatus::Published,
PostStatus::Draft Some("draft") => PostStatus::Draft,
_ if parent.status == PostStatus::Published => PostStatus::Published,
_ => PostStatus::Draft,
}; };
let created_at = fm.created_at.unwrap_or(parent.created_at);
let updated_at = fm.updated_at.unwrap_or(parent.updated_at);
let published_at = fm.published_at.or(parent.published_at);
// Check if translation exists // Check if translation exists
let existing = let existing =
@@ -1045,13 +1076,15 @@ fn rebuild_translation(
t.status = status; t.status = status;
t.file_path = rel_path; t.file_path = rel_path;
t.checksum = Some(hash); t.checksum = Some(hash);
t.updated_at = now; t.created_at = created_at;
t.updated_at = updated_at;
t.published_at = published_at;
qt::update_post_translation(conn, &t)?; qt::update_post_translation(conn, &t)?;
Ok(false) Ok(false)
} }
Err(_) => { Err(_) => {
let t = PostTranslation { let t = PostTranslation {
id: Uuid::new_v4().to_string(), id: fm.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
project_id: project_id.to_string(), project_id: project_id.to_string(),
translation_for: fm.translation_for, translation_for: fm.translation_for,
language: fm.language, language: fm.language,
@@ -1065,13 +1098,9 @@ fn rebuild_translation(
status, status,
file_path: rel_path, file_path: rel_path,
checksum: Some(hash), checksum: Some(hash),
created_at: now, created_at,
updated_at: now, updated_at,
published_at: if parent.published_at.is_some() { published_at,
Some(now)
} else {
None
},
}; };
qt::insert_post_translation(conn, &t)?; qt::insert_post_translation(conn, &t)?;
Ok(true) Ok(true)
@@ -1093,15 +1122,9 @@ pub fn post_insert_link(slug: &str) -> String {
/// Returns the Markdown syntax: ![alt](bds-media://id) or [name](bds-media://id) /// Returns the Markdown syntax: ![alt](bds-media://id) or [name](bds-media://id)
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String { pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
if is_image { if is_image {
format!( format!("![](bds-media://{media_id})")
"![]({bds_media_url})",
bds_media_url = format!("bds-media://{media_id}")
)
} else { } else {
format!( format!("[{original_name}](bds-media://{media_id})")
"[{original_name}]({bds_media_url})",
bds_media_url = format!("bds-media://{media_id}")
)
} }
} }
@@ -1120,9 +1143,9 @@ pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables; use crate::db::fts::ensure_fts_tables;
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use tempfile::TempDir; use tempfile::TempDir;
fn setup() -> (Database, TempDir) { fn setup() -> (Database, TempDir) {
@@ -1584,6 +1607,49 @@ mod tests {
assert_eq!(t2.title, "Neuer Titel"); assert_eq!(t2.title, "Neuer Titel");
} }
#[test]
fn editing_published_translation_reopens_draft() {
let (db, dir) = setup();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Parent",
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
upsert_translation(
db.conn(),
dir.path(),
&post.id,
"de",
"Titel",
None,
Some("Inhalt"),
)
.unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
let edited = upsert_translation(
db.conn(),
dir.path(),
&post.id,
"de",
"Neuer Titel",
None,
Some("Neuer Inhalt"),
)
.unwrap();
assert_eq!(edited.status, PostStatus::Draft);
assert_eq!(edited.content.as_deref(), Some("Neuer Inhalt"));
}
#[test] #[test]
fn delete_translation_removes_file_and_db() { fn delete_translation_removes_file_and_db() {
let (db, dir) = setup(); let (db, dir) = setup();
+2 -7
View File
@@ -107,10 +107,10 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media}; 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::post_media::list_post_media_by_post;
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::util::sidecar::read_sidecar; use crate::util::sidecar::read_sidecar;
fn setup() -> (Database, TempDir) { fn setup() -> (Database, TempDir) {
@@ -182,12 +182,7 @@ mod tests {
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap(); link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
// Reverse the order: m2 first, m1 second // Reverse the order: m2 first, m1 second
reorder_post_media( reorder_post_media(db.conn(), "post1", &["m2".to_string(), "m1".to_string()]).unwrap();
db.conn(),
"post1",
&["m2".to_string(), "m1".to_string()],
)
.unwrap();
let list = list_post_media_by_post(db.conn(), "post1").unwrap(); let list = list_post_media_by_post(db.conn(), "post1").unwrap();
assert_eq!(list.len(), 2); assert_eq!(list.len(), 2);
+136 -66
View File
@@ -2,11 +2,11 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::thread; use std::thread;
use axum::Router;
use axum::extract::{Path as AxumPath, Query, State}; use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{StatusCode, Uri, header}; use axum::http::{StatusCode, Uri, header};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use axum::routing::get; use axum::routing::get;
use axum::Router;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -81,7 +81,9 @@ pub fn start_preview_server(
}; };
let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| { let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| {
if error.kind() == std::io::ErrorKind::AddrInUse { if error.kind() == std::io::ErrorKind::AddrInUse {
EngineError::Conflict(format!("preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}")) EngineError::Conflict(format!(
"preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"
))
} else { } else {
EngineError::Io(error) EngineError::Io(error)
} }
@@ -135,23 +137,23 @@ async fn handle_draft_preview(
theme: query.theme.clone(), theme: query.theme.clone(),
mode: query.mode.clone(), mode: query.mode.clone(),
}; };
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) { match render_preview_response(
&state,
&format!("/__draft/{post_id}"),
query.language.as_deref(),
Some(&style),
) {
Ok(response) => response, Ok(response) => response,
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
} }
} }
async fn handle_style_preview( async fn handle_style_preview(Query(query): Query<StylePreviewQuery>) -> Response {
Query(query): Query<StylePreviewQuery>,
) -> Response {
let theme = query.theme.unwrap_or_else(|| "default".to_string()); let theme = query.theme.unwrap_or_else(|| "default".to_string());
let mode = query.mode.unwrap_or_else(|| "auto".to_string()); let mode = query.mode.unwrap_or_else(|| "auto".to_string());
let html = format!( let html = format!(
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>", "<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
theme, theme, mode, theme, mode,
mode,
theme,
mode,
); );
Html(html).into_response() Html(html).into_response()
} }
@@ -163,7 +165,10 @@ fn render_preview_response(
style: Option<&StylePreviewQuery>, style: Option<&StylePreviewQuery>,
) -> EngineResult<Response> { ) -> EngineResult<Response> {
if let Some(post_id) = path.strip_prefix("/__draft/") { if let Some(post_id) = path.strip_prefix("/__draft/") {
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style); let html = apply_preview_style(
render_draft_preview(state, post_id, requested_language)?,
style,
);
return Ok(Html(html).into_response()); return Ok(Html(html).into_response());
} }
@@ -178,8 +183,15 @@ fn render_preview_response(
.iter() .iter()
.map(|source| (source.post.clone(), source.body_markdown.clone())) .map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path) let response = build_preview_response(
.map_err(|error| EngineError::Parse(error.to_string()))?; db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
&input_posts,
path,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK); let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
Ok((status, Html(apply_preview_style(response.html, style))).into_response()) Ok((status, Html(apply_preview_style(response.html, style))).into_response())
} }
@@ -188,7 +200,9 @@ fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> Strin
let Some(style) = style else { let Some(style) = style else {
return html; return html;
}; };
if style.theme.as_deref().unwrap_or("").is_empty() && style.mode.as_deref().unwrap_or("").is_empty() { if style.theme.as_deref().unwrap_or("").is_empty()
&& style.mode.as_deref().unwrap_or("").is_empty()
{
return html; return html;
} }
@@ -238,34 +252,42 @@ fn render_draft_preview(
let db = Database::open(&state.db_path)?; let db = Database::open(&state.db_path)?;
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
let post = queries::post::get_post_by_id(db.conn(), post_id)?; let post = queries::post::get_post_by_id(db.conn(), post_id)?;
let canonical_language = post.language.as_deref().unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en")); let canonical_language = post
.language
.as_deref()
.unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
let target_language = requested_language.unwrap_or(canonical_language); let target_language = requested_language.unwrap_or(canonical_language);
if target_language != canonical_language { if target_language != canonical_language
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language( && let Ok(translation) =
db.conn(), queries::post_translation::get_post_translation_by_post_and_language(
post_id,
target_language,
) {
let mut translated_post = post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(post.published_at);
let body = load_translation_body(&state.data_dir, &translation)?;
let response = build_preview_response(
db.conn(), db.conn(),
&state.data_dir, post_id,
&state.project_id, target_language,
&metadata,
&[(translated_post, body)],
&crate::render::build_canonical_post_path(&post, target_language, metadata.main_language.as_deref().unwrap_or("en")),
) )
.map_err(|error| EngineError::Parse(error.to_string()))?; {
return Ok(response.html); let mut translated_post = post.clone();
} translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(post.published_at);
let body = load_translation_body(&state.data_dir, &translation)?;
let response = build_preview_response(
db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
&[(translated_post, body)],
&crate::render::build_canonical_post_path(
&post,
target_language,
metadata.main_language.as_deref().unwrap_or("en"),
),
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
return Ok(response.html);
} }
let body = load_post_body(&state.data_dir, &post)?; let body = load_post_body(&state.data_dir, &post)?;
@@ -275,7 +297,11 @@ fn render_draft_preview(
&state.project_id, &state.project_id,
&metadata, &metadata,
&[(post.clone(), body)], &[(post.clone(), body)],
&crate::render::build_canonical_post_path(&post, canonical_language, metadata.main_language.as_deref().unwrap_or("en")), &crate::render::build_canonical_post_path(
&post,
canonical_language,
metadata.main_language.as_deref().unwrap_or("en"),
),
) )
.map_err(|error| EngineError::Parse(error.to_string()))?; .map_err(|error| EngineError::Parse(error.to_string()))?;
Ok(response.html) Ok(response.html)
@@ -288,7 +314,10 @@ fn collect_published_posts(
let db = Database::open(&state.db_path)?; let db = Database::open(&state.db_path)?;
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?; let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
let mut published = Vec::new(); let mut published = Vec::new();
for post in posts.into_iter().filter(|post| matches!(post.status, PostStatus::Published)) { for post in posts
.into_iter()
.filter(|post| matches!(post.status, PostStatus::Published))
{
published.push(PublishedPostSource { published.push(PublishedPostSource {
body_markdown: load_post_body(&state.data_dir, &post)?, body_markdown: load_post_body(&state.data_dir, &post)?,
post, post,
@@ -318,7 +347,11 @@ fn load_translation_body(
load_markdown_body(data_dir, &translation.file_path, true) load_markdown_body(data_dir, &translation.file_path, true)
} }
fn load_markdown_body(data_dir: &Path, relative_path: &str, translation: bool) -> EngineResult<String> { fn load_markdown_body(
data_dir: &Path,
relative_path: &str,
translation: bool,
) -> EngineResult<String> {
let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?; let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?;
let body = if translation { let body = if translation {
read_translation_file(&raw).map(|(_, body)| body) read_translation_file(&raw).map(|(_, body)| body)
@@ -351,25 +384,32 @@ fn serve_scoped_file(
let scope_root = data_dir.join(scope_dir); let scope_root = data_dir.join(scope_dir);
let candidate = scope_root.join(relative); let candidate = scope_root.join(relative);
if !candidate.exists() || !candidate.is_file() { if !candidate.exists() || !candidate.is_file() {
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found"))); return Ok(Some(error_response(
StatusCode::NOT_FOUND,
"preview asset not found",
)));
} }
let canonical_candidate = candidate.canonicalize()?; let canonical_candidate = candidate.canonicalize()?;
let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root); let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root);
if !canonical_candidate.starts_with(&canonical_scope_root) { if !canonical_candidate.starts_with(&canonical_scope_root) {
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found"))); return Ok(Some(error_response(
StatusCode::NOT_FOUND,
"preview asset not found",
)));
} }
let bytes = fs::read(&canonical_candidate)?; let bytes = fs::read(&canonical_candidate)?;
let mime = guess_content_type(&canonical_candidate); let mime = guess_content_type(&canonical_candidate);
Ok(Some(( Ok(Some(
StatusCode::OK, (StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
[(header::CONTENT_TYPE, mime)], ))
bytes,
)
.into_response()))
} }
fn guess_content_type(path: &Path) -> &'static str { fn guess_content_type(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()).unwrap_or_default() { match path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
{
"css" => "text/css; charset=utf-8", "css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8", "js" => "application/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8", "json" => "application/json; charset=utf-8",
@@ -383,7 +423,12 @@ fn guess_content_type(path: &Path) -> &'static str {
} }
fn error_response(status: StatusCode, message: &str) -> Response { fn error_response(status: StatusCode, message: &str) -> Response {
(status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response() (
status,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
message.to_string(),
)
.into_response()
} }
#[cfg(test)] #[cfg(test)]
@@ -391,7 +436,7 @@ mod tests {
use super::*; use super::*;
use crate::db::queries; use crate::db::queries;
use crate::engine::meta; use crate::engine::meta;
use crate::model::{Post, Project, ProjectMetadata, PostStatus}; use crate::model::{Post, PostStatus, Project, ProjectMetadata};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn preview_port_guard() -> &'static Mutex<()> { fn preview_port_guard() -> &'static Mutex<()> {
@@ -504,9 +549,16 @@ mod tests {
#[test] #[test]
fn root_preview_renders_index_page() { fn root_preview_renders_index_page() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let html = build_preview_response(db.conn(), Path::new("."), "project-1", &make_metadata(), &[(make_post().post, make_post().body_markdown)], "/") let html = build_preview_response(
.unwrap() db.conn(),
.html; Path::new("."),
"project-1",
&make_metadata(),
&[(make_post().post, make_post().body_markdown)],
"/",
)
.unwrap()
.html;
assert!(html.contains("post-list")); assert!(html.contains("post-list"));
} }
@@ -568,11 +620,15 @@ mod tests {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let mut body = None; let mut body = None;
for _ in 0..20 { for _ in 0..20 {
if let Ok(response) = client.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1")).send() { if let Ok(response) = client
if response.status().is_success() { .get(format!(
body = Some(response.text().unwrap()); "http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1"
break; ))
} .send()
&& response.status().is_success()
{
body = Some(response.text().unwrap());
break;
} }
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));
} }
@@ -599,7 +655,9 @@ mod tests {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let response = client let response = client
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt")) .get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"
))
.send() .send()
.unwrap(); .unwrap();
server.stop().unwrap(); server.stop().unwrap();
@@ -628,7 +686,9 @@ mod tests {
.send() .send()
.unwrap(); .unwrap();
let asset = client let asset = client
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css")) .get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"
))
.send() .send()
.unwrap(); .unwrap();
let media_body = media.text().unwrap(); let media_body = media.text().unwrap();
@@ -739,7 +799,10 @@ mod tests {
dir.path(), dir.path(),
"project-1", "project-1",
&make_metadata(), &make_metadata(),
&[(hidden.post.clone(), hidden.body_markdown.clone()), (featured.post.clone(), featured.body_markdown.clone())], &[
(hidden.post.clone(), hidden.body_markdown.clone()),
(featured.post.clone(), featured.body_markdown.clone()),
],
"/category/hidden", "/category/hidden",
) )
.unwrap(); .unwrap();
@@ -750,7 +813,10 @@ mod tests {
dir.path(), dir.path(),
"project-1", "project-1",
&make_metadata(), &make_metadata(),
&[(hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown)], &[
(hidden.post, hidden.body_markdown),
(featured.post, featured.body_markdown),
],
"/category/featured", "/category/featured",
) )
.unwrap(); .unwrap();
@@ -893,6 +959,10 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(response.status_code, 404); assert_eq!(response.status_code, 404);
assert!(response.html.contains("No rendered page exists for /missing/route")); assert!(
response
.html
.contains("No rendered page exists for /missing/route")
);
} }
} }
+68 -35
View File
@@ -10,7 +10,7 @@ use crate::engine::site_assets::copy_bundled_site_assets;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::Project; use crate::model::Project;
use crate::model::metadata::ProjectMetadata; use crate::model::metadata::ProjectMetadata;
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// The well-known ID of the default project (spec: DefaultProjectExists). /// The well-known ID of the default project (spec: DefaultProjectExists).
pub const DEFAULT_PROJECT_ID: &str = "default"; pub const DEFAULT_PROJECT_ID: &str = "default";
@@ -113,7 +113,11 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
/// Delete a project row (cascading handled by queries). /// Delete a project row (cascading handled by queries).
/// Rejects deletion of the default project and the currently active project. /// Rejects deletion of the default project and the currently active project.
/// Only cleans up the internal project data directory (not external custom paths). /// Only cleans up the internal project data directory (not external custom paths).
pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Option<&Path>) -> EngineResult<()> { pub fn delete_project(
conn: &Connection,
project_id: &str,
internal_data_dir: Option<&Path>,
) -> EngineResult<()> {
// Cannot delete the default project // Cannot delete the default project
if project_id == DEFAULT_PROJECT_ID { if project_id == DEFAULT_PROJECT_ID {
return Err(EngineError::Validation( return Err(EngineError::Validation(
@@ -122,12 +126,12 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
} }
// Check if this is the active project (don't delete active) // Check if this is the active project (don't delete active)
if let Ok(active) = q::get_active_project(conn) { if let Ok(active) = q::get_active_project(conn)
if active.id == project_id { && active.id == project_id
return Err(EngineError::Validation( {
"cannot delete the active project".to_string(), return Err(EngineError::Validation(
)); "cannot delete the active project".to_string(),
} ));
} }
// Only delete internal data directory, never external custom data_path. // Only delete internal data directory, never external custom data_path.
@@ -139,12 +143,11 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
q::delete_project(conn, project_id)?; q::delete_project(conn, project_id)?;
// Clean up internal filesystem only (not custom external paths per spec) // Clean up internal filesystem only (not custom external paths per spec)
if !is_custom_path { if !is_custom_path
if let Some(dir) = internal_data_dir { && let Some(dir) = internal_data_dir
if dir.exists() { && dir.exists()
let _ = fs::remove_dir_all(dir); {
} let _ = fs::remove_dir_all(dir);
}
} }
Ok(()) Ok(())
@@ -153,7 +156,15 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
// ── helpers ────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> { fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts", "assets"]; let subdirs = [
"posts",
"media",
"meta",
"thumbnails",
"templates",
"scripts",
"assets",
];
for sub in &subdirs { for sub in &subdirs {
fs::create_dir_all(data_dir.join(sub))?; fs::create_dir_all(data_dir.join(sub))?;
} }
@@ -215,15 +226,36 @@ fn copy_starter_templates(data_dir: &Path) -> EngineResult<()> {
// Starter templates embedded at compile time from assets/starter-templates/ // Starter templates embedded at compile time from assets/starter-templates/
let templates: &[(&str, &str)] = &[ let templates: &[(&str, &str)] = &[
("single-post.liquid", include_str!("../../../../assets/starter-templates/single-post.liquid")), (
("post-list.liquid", include_str!("../../../../assets/starter-templates/post-list.liquid")), "single-post.liquid",
("not-found.liquid", include_str!("../../../../assets/starter-templates/not-found.liquid")), include_str!("../../../../assets/starter-templates/single-post.liquid"),
),
(
"post-list.liquid",
include_str!("../../../../assets/starter-templates/post-list.liquid"),
),
(
"not-found.liquid",
include_str!("../../../../assets/starter-templates/not-found.liquid"),
),
]; ];
let partials: &[(&str, &str)] = &[ let partials: &[(&str, &str)] = &[
("head.liquid", include_str!("../../../../assets/starter-templates/partials/head.liquid")), (
("menu.liquid", include_str!("../../../../assets/starter-templates/partials/menu.liquid")), "head.liquid",
("menu-items.liquid", include_str!("../../../../assets/starter-templates/partials/menu-items.liquid")), include_str!("../../../../assets/starter-templates/partials/head.liquid"),
("language-switcher.liquid", include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid")), ),
(
"menu.liquid",
include_str!("../../../../assets/starter-templates/partials/menu.liquid"),
),
(
"menu-items.liquid",
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid"),
),
(
"language-switcher.liquid",
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid"),
),
]; ];
for (name, content) in templates { for (name, content) in templates {
@@ -258,12 +290,8 @@ mod tests {
fn create_project_inserts_and_creates_dirs() { fn create_project_inserts_and_creates_dirs() {
let (db, dir) = setup(); let (db, dir) = setup();
let data_path = dir.path().join("my-blog"); let data_path = dir.path().join("my-blog");
let project = create_project( let project =
db.conn(), create_project(db.conn(), "My Blog", Some(data_path.to_str().unwrap())).unwrap();
"My Blog",
Some(data_path.to_str().unwrap()),
)
.unwrap();
assert_eq!(project.name, "My Blog"); assert_eq!(project.name, "My Blog");
assert_eq!(project.slug, "my-blog"); assert_eq!(project.slug, "my-blog");
@@ -287,14 +315,16 @@ mod tests {
assert_eq!(project_json["name"], "My Blog"); assert_eq!(project_json["name"], "My Blog");
assert_eq!(project_json["maxPostsPerPage"], 50); assert_eq!(project_json["maxPostsPerPage"], 50);
let cats: Vec<String> = let cats: Vec<String> = serde_json::from_str(
serde_json::from_str(&fs::read_to_string(data_path.join("meta/categories.json")).unwrap()) &fs::read_to_string(data_path.join("meta/categories.json")).unwrap(),
.unwrap(); )
.unwrap();
assert_eq!(cats, vec!["article", "aside", "page", "picture"]); assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
let cat_meta: serde_json::Value = let cat_meta: serde_json::Value = serde_json::from_str(
serde_json::from_str(&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap()) &fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap(),
.unwrap(); )
.unwrap();
assert!(cat_meta.as_object().unwrap().is_empty()); assert!(cat_meta.as_object().unwrap().is_empty());
let tags: Vec<String> = let tags: Vec<String> =
@@ -364,7 +394,10 @@ mod tests {
assert!(internal_dir.join("posts").is_dir()); assert!(internal_dir.join("posts").is_dir());
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap(); delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty()); assert!(list_projects(db.conn()).unwrap().is_empty());
assert!(!internal_dir.exists(), "internal directory should be cleaned up"); assert!(
!internal_dir.exists(),
"internal directory should be cleaned up"
);
} }
#[test] #[test]
+21 -11
View File
@@ -4,11 +4,11 @@ use std::sync::Arc;
use rusqlite::Connection; use rusqlite::Connection;
use crate::db::fts; use crate::db::fts;
use crate::engine::EngineResult;
use crate::engine::media; use crate::engine::media;
use crate::engine::post; use crate::engine::post;
use crate::engine::script_rebuild; use crate::engine::script_rebuild;
use crate::engine::template_rebuild; use crate::engine::template_rebuild;
use crate::engine::EngineResult;
/// Report from a full rebuild operation. /// Report from a full rebuild operation.
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -68,7 +68,11 @@ pub fn rebuild_from_filesystem_with_progress(
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| { let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb); let cb = Arc::clone(cb);
let f: post::ItemProgressFn = Box::new(move |current, total, name| { let f: post::ItemProgressFn = Box::new(move |current, total, name| {
let phase_pct = if total > 0 { current as f32 / total as f32 } else { 1.0 }; let phase_pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let global_pct = 0.01 + phase_pct * 0.34; let global_pct = 0.01 + phase_pct * 0.34;
let msg = format!("Posts: {current}/{total} \u{2014} {name}"); let msg = format!("Posts: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg); cb(global_pct, &msg);
@@ -76,7 +80,10 @@ pub fn rebuild_from_filesystem_with_progress(
f f
}); });
let post_report = post::rebuild_posts_from_filesystem_with_progress( let post_report = post::rebuild_posts_from_filesystem_with_progress(
conn, data_dir, project_id, post_item_cb, conn,
data_dir,
project_id,
post_item_cb,
)?; )?;
report.posts_created = post_report.posts_created; report.posts_created = post_report.posts_created;
report.posts_updated = post_report.posts_updated; report.posts_updated = post_report.posts_updated;
@@ -89,7 +96,11 @@ pub fn rebuild_from_filesystem_with_progress(
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| { let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb); let cb = Arc::clone(cb);
let f: media::ItemProgressFn = Box::new(move |current, total, name| { let f: media::ItemProgressFn = Box::new(move |current, total, name| {
let phase_pct = if total > 0 { current as f32 / total as f32 } else { 1.0 }; let phase_pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let global_pct = 0.35 + phase_pct * 0.35; let global_pct = 0.35 + phase_pct * 0.35;
let msg = format!("Media: {current}/{total} \u{2014} {name}"); let msg = format!("Media: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg); cb(global_pct, &msg);
@@ -97,7 +108,10 @@ pub fn rebuild_from_filesystem_with_progress(
f f
}); });
let media_report = media::rebuild_media_from_filesystem_with_progress( let media_report = media::rebuild_media_from_filesystem_with_progress(
conn, data_dir, project_id, media_item_cb, conn,
data_dir,
project_id,
media_item_cb,
)?; )?;
report.media_created = media_report.media_created; report.media_created = media_report.media_created;
report.media_updated = media_report.media_updated; report.media_updated = media_report.media_updated;
@@ -133,12 +147,12 @@ pub fn rebuild_from_filesystem_with_progress(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::queries::media as qm; use crate::db::queries::media as qm;
use crate::db::queries::post as qp; use crate::db::queries::post as qp;
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script as qs; use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl; use crate::db::queries::template as qtpl;
use crate::db::Database;
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
@@ -276,11 +290,7 @@ updatedAt: 2024-01-15T12:00:00.000Z
tags: [] tags: []
--- ---
"; ";
fs::write( fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();
media_dir.join("test-media-1.jpg.meta"),
sidecar_content,
)
.unwrap();
// Template // Template
let tpl_dir = dir.path().join("templates"); let tpl_dir = dir.path().join("templates");
+65 -33
View File
@@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::db::queries::script as qs; use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Script, ScriptKind, ScriptStatus}; use crate::model::{Script, ScriptKind, ScriptStatus};
use crate::util::frontmatter::{write_script_file, ScriptFrontmatter}; use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// Create a new draft script. Content stored in DB, no file written. /// Create a new draft script. Content stored in DB, no file written.
@@ -53,6 +53,10 @@ pub fn create_script(
} }
/// Update a script's metadata and/or content. Bumps version. /// Update a script's metadata and/or content. Bumps version.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent script field changes"
)]
pub fn update_script( pub fn update_script(
conn: &Connection, conn: &Connection,
script_id: &str, script_id: &str,
@@ -67,14 +71,13 @@ pub fn update_script(
let mut script = qs::get_script_by_id(conn, script_id)?; let mut script = qs::get_script_by_id(conn, script_id)?;
// Slug uniqueness // Slug uniqueness
if let Some(new_slug) = slug { if let Some(new_slug) = slug
if new_slug != script.slug { && new_slug != script.slug
if qs::get_script_by_slug(conn, project_id, new_slug).is_ok() { && qs::get_script_by_slug(conn, project_id, new_slug).is_ok()
return Err(EngineError::Conflict(format!( {
"script slug '{new_slug}' already exists" return Err(EngineError::Conflict(format!(
))); "script slug '{new_slug}' already exists"
} )));
}
} }
if let Some(t) = title { if let Some(t) = title {
@@ -108,11 +111,7 @@ pub fn update_script(
} }
/// Save script content (editor save). Updates DB, bumps version. /// Save script content (editor save). Updates DB, bumps version.
pub fn save_script( pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineResult<Script> {
conn: &Connection,
script_id: &str,
content: &str,
) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?; let mut script = qs::get_script_by_id(conn, script_id)?;
script.content = Some(content.to_string()); script.content = Some(content.to_string());
script.version += 1; script.version += 1;
@@ -161,11 +160,7 @@ pub fn discover_entrypoints(content: &str) -> Vec<String> {
} }
/// Publish a script: write frontmatter+body to file, clear content in DB. /// Publish a script: write frontmatter+body to file, clear content in DB.
pub fn publish_script( pub fn publish_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<Script> {
conn: &Connection,
data_dir: &Path,
script_id: &str,
) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?; let mut script = qs::get_script_by_id(conn, script_id)?;
if script.status == ScriptStatus::Published { if script.status == ScriptStatus::Published {
@@ -221,9 +216,7 @@ pub fn unpublish_script(
let mut script = qs::get_script_by_id(conn, script_id)?; let mut script = qs::get_script_by_id(conn, script_id)?;
if script.status != ScriptStatus::Published { if script.status != ScriptStatus::Published {
return Err(EngineError::Conflict( return Err(EngineError::Conflict("script is not published".to_string()));
"script is not published".to_string(),
));
} }
if !script.file_path.is_empty() { if !script.file_path.is_empty() {
@@ -244,11 +237,7 @@ pub fn unpublish_script(
} }
/// Delete a script and its file. /// Delete a script and its file.
pub fn delete_script( pub fn delete_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<()> {
conn: &Connection,
data_dir: &Path,
script_id: &str,
) -> EngineResult<()> {
let script = qs::get_script_by_id(conn, script_id)?; let script = qs::get_script_by_id(conn, script_id)?;
// Delete file if exists // Delete file if exists
@@ -311,8 +300,8 @@ fn validate_lua_blocks(content: &str) -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir; use tempfile::TempDir;
fn setup() -> (Database, TempDir) { fn setup() -> (Database, TempDir) {
@@ -326,7 +315,15 @@ mod tests {
#[test] #[test]
fn create_draft_script() { fn create_draft_script() {
let (db, _dir) = setup(); let (db, _dir) = setup();
let s = create_script(db.conn(), "p1", "My Script", ScriptKind::Macro, "return 'hi'", None).unwrap(); let s = create_script(
db.conn(),
"p1",
"My Script",
ScriptKind::Macro,
"return 'hi'",
None,
)
.unwrap();
assert_eq!(s.title, "My Script"); assert_eq!(s.title, "My Script");
assert_eq!(s.slug, "my-script"); assert_eq!(s.slug, "my-script");
assert_eq!(s.kind, ScriptKind::Macro); assert_eq!(s.kind, ScriptKind::Macro);
@@ -340,7 +337,15 @@ mod tests {
#[test] #[test]
fn create_with_custom_entrypoint() { fn create_with_custom_entrypoint() {
let (db, _dir) = setup(); let (db, _dir) = setup();
let s = create_script(db.conn(), "p1", "Util", ScriptKind::Utility, "", Some("main")).unwrap(); let s = create_script(
db.conn(),
"p1",
"Util",
ScriptKind::Utility,
"",
Some("main"),
)
.unwrap();
assert_eq!(s.entrypoint, "main"); assert_eq!(s.entrypoint, "main");
} }
@@ -357,7 +362,18 @@ mod tests {
fn update_script_bumps_version() { fn update_script_bumps_version() {
let (db, _dir) = setup(); let (db, _dir) = setup();
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap(); let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
let updated = update_script(db.conn(), &s.id, "p1", Some("New"), None, None, None, None, Some("new")).unwrap(); let updated = update_script(
db.conn(),
&s.id,
"p1",
Some("New"),
None,
None,
None,
None,
Some("new"),
)
.unwrap();
assert_eq!(updated.title, "New"); assert_eq!(updated.title, "New");
assert_eq!(updated.content, Some("new".to_string())); assert_eq!(updated.content, Some("new".to_string()));
assert_eq!(updated.version, 2); assert_eq!(updated.version, 2);
@@ -375,7 +391,15 @@ mod tests {
#[test] #[test]
fn publish_and_unpublish_script() { fn publish_and_unpublish_script() {
let (db, dir) = setup(); let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "Pub", ScriptKind::Macro, "function render()\n return 'hi'\nend", None).unwrap(); let s = create_script(
db.conn(),
"p1",
"Pub",
ScriptKind::Macro,
"function render()\n return 'hi'\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &s.id).unwrap(); let published = publish_script(db.conn(), dir.path(), &s.id).unwrap();
assert_eq!(published.status, ScriptStatus::Published); assert_eq!(published.status, ScriptStatus::Published);
@@ -391,7 +415,15 @@ mod tests {
#[test] #[test]
fn delete_script_removes_file() { fn delete_script_removes_file() {
let (db, dir) = setup(); let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "Del", ScriptKind::Macro, "function render()\nend", None).unwrap(); let s = create_script(
db.conn(),
"p1",
"Del",
ScriptKind::Macro,
"function render()\nend",
None,
)
.unwrap();
publish_script(db.conn(), dir.path(), &s.id).unwrap(); publish_script(db.conn(), dir.path(), &s.id).unwrap();
assert!(dir.path().join("scripts/del.lua").exists()); assert!(dir.path().join("scripts/del.lua").exists());
+10 -12
View File
@@ -140,8 +140,8 @@ fn rebuild_single_script(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir; use tempfile::TempDir;
fn setup() -> (Database, TempDir) { fn setup() -> (Database, TempDir) {
@@ -176,8 +176,7 @@ end
"; ";
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap(); fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
let report = let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1); assert_eq!(report.created, 1);
assert_eq!(report.updated, 0); assert_eq!(report.updated, 0);
@@ -191,7 +190,10 @@ end
assert!(script.enabled); assert!(script.enabled);
assert_eq!(script.version, 1); assert_eq!(script.version, 1);
assert_eq!(script.status, ScriptStatus::Published); assert_eq!(script.status, ScriptStatus::Published);
assert!(script.content.is_none(), "published script should have content=None in DB"); assert!(
script.content.is_none(),
"published script should have content=None in DB"
);
assert_eq!(script.file_path, "scripts/my-macro.lua"); assert_eq!(script.file_path, "scripts/my-macro.lua");
} }
@@ -219,8 +221,7 @@ def main():
"; ";
fs::write(scripts_dir.join("my-utility.py"), content).unwrap(); fs::write(scripts_dir.join("my-utility.py"), content).unwrap();
let report = let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1); assert_eq!(report.created, 1);
assert_eq!(report.updated, 0); assert_eq!(report.updated, 0);
@@ -280,8 +281,7 @@ end
"; ";
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap(); fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
let report = let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0); assert_eq!(report.created, 0);
assert_eq!(report.updated, 1); assert_eq!(report.updated, 1);
@@ -321,14 +321,12 @@ function run() end
fs::write(scripts_dir.join("idem.lua"), content).unwrap(); fs::write(scripts_dir.join("idem.lua"), content).unwrap();
// First run // First run
let r1 = let r1 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.created, 1); assert_eq!(r1.created, 1);
assert_eq!(r1.updated, 0); assert_eq!(r1.updated, 0);
// Second run - should update, not create // Second run - should update, not create
let r2 = let r2 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.created, 0); assert_eq!(r2.created, 0);
assert_eq!(r2.updated, 1); assert_eq!(r2.updated, 1);
+231 -41
View File
@@ -13,42 +13,222 @@ pub(crate) struct BundledSiteAsset {
} }
const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[ const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
BundledSiteAsset { relative_path: "assets/bds.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css") }, BundledSiteAsset {
BundledSiteAsset { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") }, relative_path: "assets/bds.css",
BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") }, bytes: include_bytes!(
BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") }, "../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css"
BundledSiteAsset { relative_path: "assets/highlight.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css") }, ),
BundledSiteAsset { relative_path: "assets/highlight.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js") }, },
BundledSiteAsset { relative_path: "assets/lightbox.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css") }, BundledSiteAsset {
BundledSiteAsset { relative_path: "assets/lightbox.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js") }, relative_path: "assets/calendar-runtime.js",
BundledSiteAsset { relative_path: "assets/pico.amber.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css") }, bytes: include_bytes!(
BundledSiteAsset { relative_path: "assets/pico.blue.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css") }, "../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js"
BundledSiteAsset { relative_path: "assets/pico.cyan.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css") }, ),
BundledSiteAsset { relative_path: "assets/pico.fuchsia.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css") }, },
BundledSiteAsset { relative_path: "assets/pico.green.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css") }, BundledSiteAsset {
BundledSiteAsset { relative_path: "assets/pico.grey.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css") }, relative_path: "assets/code-enhancements.js",
BundledSiteAsset { relative_path: "assets/pico.indigo.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css") }, bytes: include_bytes!(
BundledSiteAsset { relative_path: "assets/pico.jade.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css") }, "../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js"
BundledSiteAsset { relative_path: "assets/pico.lime.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css") }, ),
BundledSiteAsset { relative_path: "assets/pico.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css") }, },
BundledSiteAsset { relative_path: "assets/pico.orange.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css") }, BundledSiteAsset {
BundledSiteAsset { relative_path: "assets/pico.pink.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css") }, relative_path: "assets/d3.layout.cloud.js",
BundledSiteAsset { relative_path: "assets/pico.pumpkin.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css") }, bytes: include_bytes!(
BundledSiteAsset { relative_path: "assets/pico.purple.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css") }, "../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js"
BundledSiteAsset { relative_path: "assets/pico.red.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css") }, ),
BundledSiteAsset { relative_path: "assets/pico.sand.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css") }, },
BundledSiteAsset { relative_path: "assets/pico.slate.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css") }, BundledSiteAsset {
BundledSiteAsset { relative_path: "assets/pico.violet.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css") }, relative_path: "assets/highlight.min.css",
BundledSiteAsset { relative_path: "assets/pico.yellow.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css") }, bytes: include_bytes!(
BundledSiteAsset { relative_path: "assets/pico.zinc.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css") }, "../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css"
BundledSiteAsset { relative_path: "assets/search-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js") }, ),
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") }, },
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") }, BundledSiteAsset {
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") }, relative_path: "assets/highlight.min.js",
BundledSiteAsset { relative_path: "images/close.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png") }, bytes: include_bytes!(
BundledSiteAsset { relative_path: "images/loading.gif", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif") }, "../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js"
BundledSiteAsset { relative_path: "images/next.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png") }, ),
BundledSiteAsset { relative_path: "images/prev.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png") }, },
BundledSiteAsset {
relative_path: "assets/lightbox.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/lightbox.min.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js"
),
},
BundledSiteAsset {
relative_path: "assets/pico.amber.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.blue.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.cyan.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.fuchsia.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.green.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.grey.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.indigo.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.jade.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.lime.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.orange.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.pink.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.pumpkin.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.purple.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.red.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.sand.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.slate.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.violet.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.yellow.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.zinc.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/search-runtime.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js"
),
},
BundledSiteAsset {
relative_path: "assets/tag-cloud.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js"
),
},
BundledSiteAsset {
relative_path: "assets/vanilla-calendar.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/vanilla-calendar.min.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js"
),
},
BundledSiteAsset {
relative_path: "images/close.png",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png"
),
},
BundledSiteAsset {
relative_path: "images/loading.gif",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif"
),
},
BundledSiteAsset {
relative_path: "images/next.png",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png"
),
},
BundledSiteAsset {
relative_path: "images/prev.png",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png"
),
},
]; ];
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> { pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
@@ -72,12 +252,22 @@ pub(crate) fn write_bundled_site_assets(
report: &mut crate::engine::generation::GenerationReport, report: &mut crate::engine::generation::GenerationReport,
) -> EngineResult<()> { ) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS { for asset in BUNDLED_SITE_ASSETS {
match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes) match write_generated_bytes(
.map_err(|error| EngineError::Parse(error.to_string()))? conn,
output_dir,
project_id,
asset.relative_path,
asset.bytes,
)
.map_err(|error| EngineError::Parse(error.to_string()))?
{ {
GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()), GeneratedWriteOutcome::Written => {
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()), report.written_paths.push(asset.relative_path.to_string())
}
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(asset.relative_path.to_string())
}
} }
} }
Ok(()) Ok(())
} }
+44 -41
View File
@@ -7,8 +7,8 @@ use crate::db::queries::post as post_q;
use crate::db::queries::tag as tag_q; use crate::db::queries::tag as tag_q;
use crate::engine::meta; use crate::engine::meta;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::metadata::TagEntry;
use crate::model::Tag; use crate::model::Tag;
use crate::model::metadata::TagEntry;
use crate::util::now_unix_ms; use crate::util::now_unix_ms;
/// Create a new tag. Case-insensitive duplicate check. /// Create a new tag. Case-insensitive duplicate check.
@@ -240,10 +240,7 @@ pub fn import_tags_from_file(
/// Sync tags from all posts: collect unique tag names, create missing tags in DB. /// Sync tags from all posts: collect unique tag names, create missing tags in DB.
/// This is additive only — it does NOT rewrite tags.json. /// This is additive only — it does NOT rewrite tags.json.
pub fn sync_tags_from_posts( pub fn sync_tags_from_posts(conn: &Connection, project_id: &str) -> EngineResult<Vec<Tag>> {
conn: &Connection,
project_id: &str,
) -> EngineResult<Vec<Tag>> {
let posts = post_q::list_posts_by_project(conn, project_id)?; let posts = post_q::list_posts_by_project(conn, project_id)?;
// Collect all unique tag names from posts (preserve original casing per spec). // Collect all unique tag names from posts (preserve original casing per spec).
@@ -290,11 +287,7 @@ pub fn discover_tags(
} }
/// Rewrite meta/tags.json from DB state. /// Rewrite meta/tags.json from DB state.
pub fn rewrite_tags_json( pub fn rewrite_tags_json(conn: &Connection, data_dir: &Path, project_id: &str) -> EngineResult<()> {
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
let tags = tag_q::list_tags_by_project(conn, project_id)?; let tags = tag_q::list_tags_by_project(conn, project_id)?;
let entries: Vec<TagEntry> = tags let entries: Vec<TagEntry> = tags
.into_iter() .into_iter()
@@ -317,8 +310,8 @@ fn flush_post_frontmatter(
data_dir: &Path, data_dir: &Path,
post_ids: &[String], post_ids: &[String],
) -> EngineResult<()> { ) -> EngineResult<()> {
use crate::util::frontmatter::write_post_file;
use crate::util::atomic_write_str; use crate::util::atomic_write_str;
use crate::util::frontmatter::write_post_file;
for post_id in post_ids { for post_id in post_ids {
let post = post_q::get_post_by_id(conn, post_id)?; let post = post_q::get_post_by_id(conn, post_id)?;
@@ -328,7 +321,7 @@ fn flush_post_frontmatter(
// Read existing body from file // Read existing body from file
let content = std::fs::read_to_string(&abs_path)?; let content = std::fs::read_to_string(&abs_path)?;
let (_fm, body) = crate::util::frontmatter::read_post_file(&content) let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
.map_err(|e| EngineError::Parse(e))?; .map_err(EngineError::Parse)?;
// Rewrite with updated frontmatter // Rewrite with updated frontmatter
let file_content = write_post_file(&post, &body); let file_content = write_post_file(&post, &body);
atomic_write_str(&abs_path, &file_content)?; atomic_write_str(&abs_path, &file_content)?;
@@ -348,8 +341,7 @@ fn remove_tag_name_from_posts(
let mut modified = Vec::new(); let mut modified = Vec::new();
for mut post in posts { for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) { if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
post.tags post.tags.retain(|t| !t.eq_ignore_ascii_case(tag_name));
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
post.updated_at = now; post.updated_at = now;
post_q::update_post(conn, &post)?; post_q::update_post(conn, &post)?;
modified.push(post.id.clone()); modified.push(post.id.clone());
@@ -361,9 +353,9 @@ fn remove_tag_name_from_posts(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Database;
use crate::db::queries::post::insert_post; use crate::db::queries::post::insert_post;
use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::model::{Post, PostStatus}; use crate::model::{Post, PostStatus};
use tempfile::TempDir; use tempfile::TempDir;
@@ -470,11 +462,7 @@ mod tests {
fn rename_tag_updates_posts() { fn rename_tag_updates_posts() {
let (db, dir) = setup(); let (db, dir) = setup();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap(); let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
insert_post( insert_post(db.conn(), &make_post("x1", "hello", vec!["rust".into()])).unwrap();
db.conn(),
&make_post("x1", "hello", vec!["rust".into()]),
)
.unwrap();
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap(); rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
@@ -493,25 +481,14 @@ mod tests {
let t2 = create_tag(db.conn(), dir.path(), "p1", "rust", 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(); let t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
insert_post( insert_post(db.conn(), &make_post("x1", "a", vec!["rs".into()])).unwrap();
db.conn(),
&make_post("x1", "a", vec!["rs".into()]),
)
.unwrap();
insert_post( insert_post(
db.conn(), db.conn(),
&make_post("x2", "b", vec!["rust".into(), "target".into()]), &make_post("x2", "b", vec!["rust".into(), "target".into()]),
) )
.unwrap(); .unwrap();
merge_tags( merge_tags(db.conn(), dir.path(), "p1", &[&t1.id, &t2.id], &t3.id).unwrap();
db.conn(),
dir.path(),
"p1",
&[&t1.id, &t2.id],
&t3.id,
)
.unwrap();
// Post x1 should now have "target" // Post x1 should now have "target"
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap(); let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
@@ -563,7 +540,10 @@ mod tests {
assert_eq!(tags.len(), 2); assert_eq!(tags.len(), 2);
let entries = meta::read_tags_json(dir.path()).unwrap(); let entries = meta::read_tags_json(dir.path()).unwrap();
let names = entries.into_iter().map(|entry| entry.name).collect::<Vec<_>>(); let names = entries
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
assert_eq!(names, vec!["rust".to_string(), "web".to_string()]); assert_eq!(names, vec!["rust".to_string(), "web".to_string()]);
} }
@@ -572,8 +552,16 @@ mod tests {
let (db, dir) = setup(); let (db, dir) = setup();
// Write tags.json with colors // Write tags.json with colors
let entries = vec![ let entries = vec![
TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None }, TagEntry {
TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) }, name: "rust".into(),
color: Some("#ff0000".into()),
post_template_slug: None,
},
TagEntry {
name: "web".into(),
color: None,
post_template_slug: Some("blog".into()),
},
]; ];
meta::write_tags_json(dir.path(), &entries).unwrap(); meta::write_tags_json(dir.path(), &entries).unwrap();
@@ -605,9 +593,18 @@ mod tests {
use crate::db::fts::ensure_fts_tables; use crate::db::fts::ensure_fts_tables;
ensure_fts_tables(db.conn()).unwrap(); ensure_fts_tables(db.conn()).unwrap();
let post = crate::engine::post::create_post( let post = crate::engine::post::create_post(
db.conn(), dir.path(), "p1", "Tagged Post", Some("body content"), db.conn(),
vec!["rust".into()], vec![], None, None, None, dir.path(),
).unwrap(); "p1",
"Tagged Post",
Some("body content"),
vec!["rust".into()],
vec![],
None,
None,
None,
)
.unwrap();
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap(); crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap(); let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
@@ -616,7 +613,13 @@ mod tests {
// Read the file from disk and verify tag was updated // Read the file from disk and verify tag was updated
let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap(); let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap(); let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap();
assert!(file_content.contains("golang"), "frontmatter should contain renamed tag"); assert!(
assert!(!file_content.contains("rust"), "frontmatter should not contain old tag name"); file_content.contains("golang"),
"frontmatter should contain renamed tag"
);
assert!(
!file_content.contains("rust"),
"frontmatter should not contain old tag name"
);
} }
} }
+165 -69
View File
@@ -1,6 +1,6 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::{Duration, Instant};
/// Unique task identifier. /// Unique task identifier.
pub type TaskId = u64; pub type TaskId = u64;
@@ -23,6 +23,19 @@ pub struct TaskProgress {
pub percent: Option<f32>, pub percent: Option<f32>,
} }
/// Immutable task state exposed to UI consumers.
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
pub id: TaskId,
pub label: String,
pub group_id: Option<String>,
pub group_name: Option<String>,
pub status: TaskStatus,
pub progress: Option<f32>,
pub message: Option<String>,
pub created_at: Instant,
}
/// Entry tracking a task. /// Entry tracking a task.
#[derive(Debug)] #[derive(Debug)]
struct TaskEntry { struct TaskEntry {
@@ -35,6 +48,7 @@ struct TaskEntry {
progress: Option<f32>, progress: Option<f32>,
message: Option<String>, message: Option<String>,
created_at: Instant, created_at: Instant,
finished_at: Option<Instant>,
last_progress_report: Option<Instant>, last_progress_report: Option<Instant>,
} }
@@ -71,17 +85,23 @@ impl TaskManager {
progress: None, progress: None,
message: None, message: None,
created_at: Instant::now(), created_at: Instant::now(),
finished_at: None,
last_progress_report: None, last_progress_report: None,
}; };
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
tasks.push(entry); tasks.push(entry);
// Auto-start if under capacity // Auto-start if under capacity
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); let running = tasks
if running < self.max_concurrent { .iter()
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) { .filter(|t| t.status == TaskStatus::Running)
t.status = TaskStatus::Running; .count();
} if running < self.max_concurrent
&& let Some(t) = tasks
.iter_mut()
.find(|t| t.id == id && t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
} }
id id
} }
@@ -101,15 +121,18 @@ impl TaskManager {
/// Running, false if concurrency is at capacity or the task is not Queued. /// Running, false if concurrency is at capacity or the task is not Queued.
pub fn try_start(&self, task_id: TaskId) -> bool { pub fn try_start(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running >= self.max_concurrent { if running >= self.max_concurrent {
return false; return false;
} }
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
if entry.status == TaskStatus::Pending { && entry.status == TaskStatus::Pending
entry.status = TaskStatus::Running; {
return true; entry.status = TaskStatus::Running;
} return true;
} }
false false
} }
@@ -117,11 +140,12 @@ impl TaskManager {
/// Mark a task as completed. /// Mark a task as completed.
pub fn complete(&self, task_id: TaskId) { pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
if matches!(entry.status, TaskStatus::Running) { && matches!(entry.status, TaskStatus::Running)
entry.status = TaskStatus::Completed; {
entry.progress = Some(1.0); entry.status = TaskStatus::Completed;
} entry.progress = Some(1.0);
entry.finished_at = Some(Instant::now());
} }
Self::promote_next(&mut tasks, self.max_concurrent); Self::promote_next(&mut tasks, self.max_concurrent);
} }
@@ -129,11 +153,12 @@ impl TaskManager {
/// Mark a task as failed with an error message. /// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) { pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
if matches!(entry.status, TaskStatus::Running) { && matches!(entry.status, TaskStatus::Running)
entry.message = Some(error.clone()); {
entry.status = TaskStatus::Failed(error); entry.message = Some(error.clone());
} entry.status = TaskStatus::Failed(error);
entry.finished_at = Some(Instant::now());
} }
Self::promote_next(&mut tasks, self.max_concurrent); Self::promote_next(&mut tasks, self.max_concurrent);
} }
@@ -141,11 +166,12 @@ impl TaskManager {
/// Cancel a task by setting its cancel flag and status. /// Cancel a task by setting its cancel flag and status.
pub fn cancel(&self, task_id: TaskId) { pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) { && matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
entry.cancel_flag.store(true, Ordering::Release); {
entry.status = TaskStatus::Cancelled; entry.cancel_flag.store(true, Ordering::Release);
} entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(Instant::now());
} }
Self::promote_next(&mut tasks, self.max_concurrent); Self::promote_next(&mut tasks, self.max_concurrent);
} }
@@ -163,54 +189,73 @@ impl TaskManager {
/// Return the current status of a task. /// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> { pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).map(|t| t.status.clone()) tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.status.clone())
} }
/// Count tasks that are still queued. /// Count tasks that are still queued.
pub fn pending_count(&self) -> usize { pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Pending).count() tasks
.iter()
.filter(|t| t.status == TaskStatus::Pending)
.count()
} }
/// Count tasks that are currently running. /// Count tasks that are currently running.
pub fn running_count(&self) -> usize { pub fn running_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Running).count() tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count()
} }
/// Remove all completed, failed, and cancelled tasks. /// Remove finished tasks older than the configured retention period.
pub fn drain_completed(&self) { pub fn evict_expired(&self) {
let cutoff = Instant::now() - FINISHED_TASK_TTL;
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|t| matches!(t.status, TaskStatus::Pending | TaskStatus::Running)); tasks.retain(|task| {
task.finished_at
.is_none_or(|finished_at| finished_at > cutoff)
});
} }
/// Return the label of a task. /// Return the label of a task.
pub fn label(&self, task_id: TaskId) -> Option<String> { pub fn label(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).map(|t| t.label.clone()) tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.label.clone())
} }
/// Return the id of the first queued task (FIFO order). /// Return the id of the first queued task (FIFO order).
pub fn next_queued(&self) -> Option<TaskId> { pub fn next_queued(&self) -> Option<TaskId> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.status == TaskStatus::Pending).map(|t| t.id) tasks
.iter()
.find(|t| t.status == TaskStatus::Pending)
.map(|t| t.id)
} }
/// Update progress for a running task. Throttled to at most once per 250ms. /// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) { pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
if entry.status == TaskStatus::Running { && entry.status == TaskStatus::Running
let now = Instant::now(); {
let should_report = match entry.last_progress_report { let now = Instant::now();
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128, let should_report = match entry.last_progress_report {
None => true, Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
}; None => true,
if should_report { };
entry.progress = progress; if should_report {
entry.message = message; entry.progress = progress;
entry.last_progress_report = Some(now); entry.message = message;
} entry.last_progress_report = Some(now);
} }
} }
} }
@@ -218,31 +263,59 @@ impl TaskManager {
/// Return the current progress of a task. /// Return the current progress of a task.
pub fn progress(&self, task_id: TaskId) -> Option<f32> { pub fn progress(&self, task_id: TaskId) -> Option<f32> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.progress) tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.progress)
} }
/// Return the current message of a task. /// Return the current message of a task.
pub fn message(&self, task_id: TaskId) -> Option<String> { pub fn message(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone()) tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.message.clone())
} }
/// Return a snapshot of all tasks for UI display. /// Return a snapshot of all tasks for UI display.
pub fn snapshots(&self) -> Vec<(TaskId, String, TaskStatus, Option<f32>, Option<String>)> { pub fn snapshots(&self) -> Vec<TaskSnapshot> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks let mut snapshots = tasks
.iter() .iter()
.map(|t| (t.id, t.label.clone(), t.status.clone(), t.progress, t.message.clone())) .filter(|task| task.finished_at.is_none())
.collect() .chain(
tasks
.iter()
.rev()
.filter(|task| task.finished_at.is_some())
.take(RECENT_FINISHED_LIMIT),
)
.map(|task| TaskSnapshot {
id: task.id,
label: task.label.clone(),
group_id: task.group_id.clone(),
group_name: task.group_name.clone(),
status: task.status.clone(),
progress: task.progress,
message: task.message.clone(),
created_at: task.created_at,
})
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.created_at);
snapshots
} }
/// Promote the next queued task to running if capacity allows. /// Promote the next queued task to running if capacity allows.
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) { fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); let running = tasks
if running < max_concurrent { .iter()
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) { .filter(|t| t.status == TaskStatus::Running)
t.status = TaskStatus::Running; .count();
} if running < max_concurrent
&& let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
} }
} }
} }
@@ -255,6 +328,8 @@ impl Default for TaskManager {
/// Default progress throttle interval (250ms per spec). /// Default progress throttle interval (250ms per spec).
pub const PROGRESS_THROTTLE_MS: u64 = 250; pub const PROGRESS_THROTTLE_MS: u64 = 250;
pub const RECENT_FINISHED_LIMIT: usize = 10;
pub const FINISHED_TASK_TTL: Duration = Duration::from_secs(60 * 60);
/// Throttles progress reporting to avoid flooding. /// Throttles progress reporting to avoid flooding.
pub struct ProgressThrottle { pub struct ProgressThrottle {
@@ -310,14 +385,14 @@ mod tests {
#[test] #[test]
fn fifo_order() { fn fifo_order() {
let mgr = TaskManager::new(1); // limit to 1 to test FIFO let mgr = TaskManager::new(1); // limit to 1 to test FIFO
let a = mgr.submit("first"); // auto-starts let a = mgr.submit("first"); // auto-starts
let b = mgr.submit("second"); // queued let b = mgr.submit("second"); // queued
let c = mgr.submit("third"); // queued let c = mgr.submit("third"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running)); assert_eq!(mgr.status(a), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(b)); assert_eq!(mgr.next_queued(), Some(b));
mgr.complete(a); // should auto-promote b mgr.complete(a); // should auto-promote b
assert_eq!(mgr.status(b), Some(TaskStatus::Running)); assert_eq!(mgr.status(b), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(c)); assert_eq!(mgr.next_queued(), Some(c));
} }
@@ -344,17 +419,20 @@ mod tests {
mgr.fail(bad, "disk full".into()); mgr.fail(bad, "disk full".into());
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed)); assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into()))); assert_eq!(
mgr.status(bad),
Some(TaskStatus::Failed("disk full".into()))
);
// Progress should be 1.0 on completed // Progress should be 1.0 on completed
assert_eq!(mgr.progress(ok), Some(1.0)); assert_eq!(mgr.progress(ok), Some(1.0));
} }
#[test] #[test]
fn drain_removes_finished() { fn eviction_removes_only_expired_finished_tasks() {
let mgr = TaskManager::new(3); let mgr = TaskManager::new(3);
let a = mgr.submit("done"); // auto-starts let a = mgr.submit("done"); // auto-starts
let b = mgr.submit("broken"); // auto-starts let b = mgr.submit("broken"); // auto-starts
let e = mgr.submit("busy"); // auto-starts let e = mgr.submit("busy"); // auto-starts
let _c = mgr.submit("stopped"); // queued (at capacity) let _c = mgr.submit("stopped"); // queued (at capacity)
let _d = mgr.submit("waiting"); // queued let _d = mgr.submit("waiting"); // queued
@@ -364,7 +442,14 @@ mod tests {
// After a completes: c promoted to running // After a completes: c promoted to running
// After b fails: d promoted to running // After b fails: d promoted to running
mgr.drain_completed(); {
let mut tasks = mgr.tasks.lock().unwrap();
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) {
task.finished_at =
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
}
}
mgr.evict_expired();
assert_eq!(mgr.status(a), None); assert_eq!(mgr.status(a), None);
assert_eq!(mgr.status(b), None); assert_eq!(mgr.status(b), None);
@@ -372,10 +457,21 @@ mod tests {
assert_eq!(mgr.status(e), Some(TaskStatus::Running)); assert_eq!(mgr.status(e), Some(TaskStatus::Running));
} }
#[test]
fn snapshots_retain_only_ten_finished_tasks() {
let mgr = TaskManager::new(20);
for index in 0..12 {
let id = mgr.submit(&format!("task {index}"));
mgr.complete(id);
}
assert_eq!(mgr.snapshots().len(), 10);
}
#[test] #[test]
fn completing_task_starts_next_queued() { fn completing_task_starts_next_queued() {
let mgr = TaskManager::new(1); let mgr = TaskManager::new(1);
let a = mgr.submit("first"); // auto-starts let a = mgr.submit("first"); // auto-starts
let b = mgr.submit("second"); // queued let b = mgr.submit("second"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running)); assert_eq!(mgr.status(a), Some(TaskStatus::Running));
+63 -30
View File
@@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::db::queries::template as qt; use crate::db::queries::template as qt;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Template, TemplateKind, TemplateStatus}; use crate::model::{Template, TemplateKind, TemplateStatus};
use crate::util::frontmatter::{write_template_file, TemplateFrontmatter}; use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// Create a new draft template. Content stored in DB, no file written. /// Create a new draft template. Content stored in DB, no file written.
@@ -51,6 +51,10 @@ pub fn create_template(
} }
/// Update a template's metadata and/or content. Bumps version. /// Update a template's metadata and/or content. Bumps version.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent template field changes"
)]
pub fn update_template( pub fn update_template(
conn: &Connection, conn: &Connection,
template_id: &str, template_id: &str,
@@ -64,14 +68,13 @@ pub fn update_template(
let mut tpl = qt::get_template_by_id(conn, template_id)?; let mut tpl = qt::get_template_by_id(conn, template_id)?;
// Slug uniqueness check // Slug uniqueness check
if let Some(new_slug) = slug { if let Some(new_slug) = slug
if new_slug != tpl.slug { && new_slug != tpl.slug
if qt::get_template_by_slug(conn, project_id, new_slug).is_ok() { && qt::get_template_by_slug(conn, project_id, new_slug).is_ok()
return Err(EngineError::Conflict(format!( {
"template slug '{new_slug}' already exists" return Err(EngineError::Conflict(format!(
))); "template slug '{new_slug}' already exists"
} )));
}
} }
if let Some(t) = title { if let Some(t) = title {
@@ -149,10 +152,7 @@ pub fn publish_template(
)); ));
} }
let body = tpl let body = tpl.content.clone().unwrap_or_default();
.content
.clone()
.unwrap_or_default();
// Validate before publishing // Validate before publishing
validate_template(&body).map_err(EngineError::Validation)?; validate_template(&body).map_err(EngineError::Validation)?;
@@ -210,9 +210,8 @@ pub fn unpublish_template(
let abs_path = data_dir.join(&tpl.file_path); let abs_path = data_dir.join(&tpl.file_path);
if abs_path.exists() { if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?; let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) = let (_fm, body) = crate::util::frontmatter::read_template_file(&file_content)
crate::util::frontmatter::read_template_file(&file_content) .map_err(EngineError::Parse)?;
.map_err(EngineError::Parse)?;
tpl.content = Some(body); tpl.content = Some(body);
} }
} }
@@ -340,25 +339,29 @@ fn validate_liquid_blocks(content: &str) -> Result<(), String> {
let start = i + 2; let start = i + 2;
if let Some(end_offset) = content[start..].find("%}") { if let Some(end_offset) = content[start..].find("%}") {
let tag_content = content[start..start + end_offset].trim(); let tag_content = content[start..start + end_offset].trim();
let tag_content = tag_content.trim_start_matches('-').trim_end_matches('-').trim(); let tag_content = tag_content
let first_word = tag_content .trim_start_matches('-')
.split_whitespace() .trim_end_matches('-')
.next() .trim();
.unwrap_or(""); let first_word = tag_content.split_whitespace().next().unwrap_or("");
match first_word { match first_word {
"if" => if_depth += 1, "if" => if_depth += 1,
"endif" => { "endif" => {
if_depth -= 1; if_depth -= 1;
if if_depth < 0 { if if_depth < 0 {
return Err("unexpected {% endif %} without matching {% if %}".to_string()); return Err(
"unexpected {% endif %} without matching {% if %}".to_string()
);
} }
} }
"for" => for_depth += 1, "for" => for_depth += 1,
"endfor" => { "endfor" => {
for_depth -= 1; for_depth -= 1;
if for_depth < 0 { if for_depth < 0 {
return Err("unexpected {% endfor %} without matching {% for %}".to_string()); return Err(
"unexpected {% endfor %} without matching {% for %}".to_string()
);
} }
} }
_ => {} _ => {}
@@ -416,8 +419,8 @@ fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir; use tempfile::TempDir;
fn setup() -> (Database, TempDir) { fn setup() -> (Database, TempDir) {
@@ -431,7 +434,14 @@ mod tests {
#[test] #[test]
fn create_draft_template() { fn create_draft_template() {
let (db, _dir) = setup(); let (db, _dir) = setup();
let tpl = create_template(db.conn(), "p1", "My Template", TemplateKind::Post, "<p>hello</p>").unwrap(); let tpl = create_template(
db.conn(),
"p1",
"My Template",
TemplateKind::Post,
"<p>hello</p>",
)
.unwrap();
assert_eq!(tpl.title, "My Template"); assert_eq!(tpl.title, "My Template");
assert_eq!(tpl.slug, "my-template"); assert_eq!(tpl.slug, "my-template");
assert_eq!(tpl.kind, TemplateKind::Post); assert_eq!(tpl.kind, TemplateKind::Post);
@@ -456,9 +466,16 @@ mod tests {
let (db, _dir) = setup(); let (db, _dir) = setup();
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap(); let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
let updated = update_template( let updated = update_template(
db.conn(), &tpl.id, "p1", db.conn(),
Some("New Title"), None, None, None, Some("new content"), &tpl.id,
).unwrap(); "p1",
Some("New Title"),
None,
None,
None,
Some("new content"),
)
.unwrap();
assert_eq!(updated.title, "New Title"); assert_eq!(updated.title, "New Title");
assert_eq!(updated.content, Some("new content".to_string())); assert_eq!(updated.content, Some("new content".to_string()));
assert_eq!(updated.version, 2); assert_eq!(updated.version, 2);
@@ -469,7 +486,16 @@ mod tests {
let (db, _dir) = setup(); let (db, _dir) = setup();
create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap(); create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap();
let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap(); let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap();
let result = update_template(db.conn(), &t2.id, "p1", None, Some("alpha"), None, None, None); let result = update_template(
db.conn(),
&t2.id,
"p1",
None,
Some("alpha"),
None,
None,
None,
);
assert!(result.is_err()); assert!(result.is_err());
} }
@@ -485,7 +511,14 @@ mod tests {
#[test] #[test]
fn publish_and_unpublish_template() { fn publish_and_unpublish_template() {
let (db, dir) = setup(); let (db, dir) = setup();
let tpl = create_template(db.conn(), "p1", "Pub", TemplateKind::Post, "<div>body</div>").unwrap(); let tpl = create_template(
db.conn(),
"p1",
"Pub",
TemplateKind::Post,
"<div>body</div>",
)
.unwrap();
// Publish // Publish
let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap(); let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
+10 -12
View File
@@ -139,8 +139,8 @@ fn rebuild_single_template(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database; use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir; use tempfile::TempDir;
fn setup() -> (Database, TempDir) { fn setup() -> (Database, TempDir) {
@@ -172,8 +172,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
"; ";
fs::write(tpl_dir.join("my-template.liquid"), content).unwrap(); fs::write(tpl_dir.join("my-template.liquid"), content).unwrap();
let report = let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1); assert_eq!(report.created, 1);
assert_eq!(report.updated, 0); assert_eq!(report.updated, 0);
@@ -186,7 +185,10 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
assert!(tpl.enabled); assert!(tpl.enabled);
assert_eq!(tpl.version, 1); assert_eq!(tpl.version, 1);
assert_eq!(tpl.status, TemplateStatus::Published); assert_eq!(tpl.status, TemplateStatus::Published);
assert!(tpl.content.is_none(), "published template should have content=None in DB"); assert!(
tpl.content.is_none(),
"published template should have content=None in DB"
);
assert_eq!(tpl.file_path, "templates/my-template.liquid"); assert_eq!(tpl.file_path, "templates/my-template.liquid");
} }
@@ -230,8 +232,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
"; ";
fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap(); fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap();
let report = let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0); assert_eq!(report.created, 0);
assert_eq!(report.updated, 1); assert_eq!(report.updated, 1);
@@ -256,8 +257,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap(); fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap();
fs::write(tpl_dir.join("styles.css"), "body {}").unwrap(); fs::write(tpl_dir.join("styles.css"), "body {}").unwrap();
let report = let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0); assert_eq!(report.created, 0);
assert_eq!(report.updated, 0); assert_eq!(report.updated, 0);
@@ -286,14 +286,12 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
fs::write(tpl_dir.join("idem.liquid"), content).unwrap(); fs::write(tpl_dir.join("idem.liquid"), content).unwrap();
// First run // First run
let r1 = let r1 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.created, 1); assert_eq!(r1.created, 1);
assert_eq!(r1.updated, 0); assert_eq!(r1.updated, 0);
// Second run - should update, not create // Second run - should update, not create
let r2 = let r2 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.created, 0); assert_eq!(r2.created, 0);
assert_eq!(r2.updated, 1); assert_eq!(r2.updated, 1);
+10 -8
View File
@@ -1,9 +1,9 @@
/// Validation functions for template (Liquid) and script (Lua/Python) content. //! Validation functions for template (Liquid) and script (Lua/Python) content.
/// Per template.allium and script.allium, these are pre-publish gates. //! Per template.allium and script.allium, these are pre-publish gates.
/// //!
/// Current implementation: basic structural checks. //! Current implementation: basic structural checks.
/// When a full Liquid parser crate is added, upgrade `validate_liquid` to //! When a full Liquid parser crate is added, upgrade `validate_liquid` to
/// attempt a real parse. Similarly for `validate_script` with a Lua parser. //! attempt a real parse. Similarly for `validate_script` with a Lua parser.
/// Result of a validation check. /// Result of a validation check.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -35,8 +35,10 @@ pub fn validate_liquid(content: &str) -> ValidationResult {
let mut errors = Vec::new(); let mut errors = Vec::new();
// Check for unmatched Liquid block tags // Check for unmatched Liquid block tags
let block_tags = ["if", "unless", "for", "case", "capture", "comment", let block_tags = [
"raw", "paginate", "tablerow", "block", "schema"]; "if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow",
"block", "schema",
];
for tag in &block_tags { for tag in &block_tags {
let open_pattern = format!("{{% {tag}"); let open_pattern = format!("{{% {tag}");
+27 -9
View File
@@ -25,8 +25,9 @@ pub fn validate_site(
let metadata = crate::engine::meta::read_project_json(data_dir)?; let metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = generated_output_dir(data_dir); let output_dir = generated_output_dir(data_dir);
let published_posts = load_published_posts(data_dir, conn, project_id)?; let published_posts = load_published_posts(data_dir, conn, project_id)?;
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts) let artifacts =
.map_err(|error| EngineError::Parse(error.to_string()))?; build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut expected = artifacts let mut expected = artifacts
.pages .pages
@@ -36,7 +37,12 @@ pub fn validate_site(
expected.insert("calendar.json".to_string()); expected.insert("calendar.json".to_string());
expected.insert("rss.xml".to_string()); expected.insert("rss.xml".to_string());
for language in render_languages(&metadata) { for language in render_languages(&metadata) {
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) { let prefix = if language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new() String::new()
} else { } else {
format!("{language}/") format!("{language}/")
@@ -79,7 +85,9 @@ pub fn validate_site(
let mut stale_pages = Vec::new(); let mut stale_pages = Vec::new();
for rel in expected.intersection(&actual) { for rel in expected.intersection(&actual) {
if let Ok(stored) = queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel) { if let Ok(stored) =
queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel)
{
let actual_hash = file_hash(&output_dir.join(rel))?; let actual_hash = file_hash(&output_dir.join(rel))?;
if actual_hash != stored.content_hash { if actual_hash != stored.content_hash {
stale_pages.push(rel.clone()); stale_pages.push(rel.clone());
@@ -114,13 +122,17 @@ fn load_published_posts(
) -> EngineResult<Vec<(Post, String)>> { ) -> EngineResult<Vec<(Post, String)>> {
let posts = queries::post::list_posts_by_project(conn, project_id)?; let posts = queries::post::list_posts_by_project(conn, project_id)?;
let mut published = Vec::new(); let mut published = Vec::new();
for post in posts.into_iter().filter(|post| post.status == PostStatus::Published) { for post in posts
.into_iter()
.filter(|post| post.status == PostStatus::Published)
{
let body = if let Some(content) = &post.content { let body = if let Some(content) = &post.content {
content.clone() content.clone()
} else if let Some(content) = &post.published_content { } else if let Some(content) = &post.published_content {
content.clone() content.clone()
} else { } else {
let raw = std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?; let raw =
std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?;
crate::util::frontmatter::read_post_file(&raw) crate::util::frontmatter::read_post_file(&raw)
.map(|(_, body)| body) .map(|(_, body)| body)
.map_err(EngineError::Parse)? .map_err(EngineError::Parse)?
@@ -131,12 +143,18 @@ fn load_published_posts(
} }
fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> { fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string()); let main = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()]; let mut languages = vec![main.clone()];
for language in &metadata.blog_languages { for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) { if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone()); languages.push(language.clone());
} }
} }
languages languages
} }
@@ -63,7 +63,14 @@ pub fn validate_translations(
blog_languages: &[String], blog_languages: &[String],
main_language: &str, main_language: &str,
) -> EngineResult<TranslationValidationReport> { ) -> EngineResult<TranslationValidationReport> {
validate_translations_with_progress(conn, data_dir, project_id, blog_languages, main_language, None) validate_translations_with_progress(
conn,
data_dir,
project_id,
blog_languages,
main_language,
None,
)
} }
/// Like `validate_translations` but with optional per-item progress. /// Like `validate_translations` but with optional per-item progress.
@@ -193,11 +200,13 @@ pub fn validate_translations_with_progress(
Err(_) => continue, Err(_) => continue,
}; };
let Some((yaml_str, _body)) = crate::util::frontmatter::split_frontmatter(&content) else { let Some((yaml_str, _body)) = crate::util::frontmatter::split_frontmatter(&content)
else {
continue; continue;
}; };
let Ok(fm) = crate::util::frontmatter::TranslationFrontmatter::from_yaml(yaml_str) else { let Ok(fm) = crate::util::frontmatter::TranslationFrontmatter::from_yaml(yaml_str)
else {
continue; continue;
}; };
+12 -9
View File
@@ -133,10 +133,10 @@ pub fn translate(locale: UiLocale, key: &str) -> String {
if let Some(val) = catalog_for(locale).get(key) { if let Some(val) = catalog_for(locale).get(key) {
return val.clone(); return val.clone();
} }
if locale != UiLocale::En { if locale != UiLocale::En
if let Some(val) = CATALOG_EN.get(key) { && let Some(val) = CATALOG_EN.get(key)
return val.clone(); {
} return val.clone();
} }
key.to_string() key.to_string()
} }
@@ -163,10 +163,10 @@ pub fn translate_render(language: &str, key: &str) -> String {
if let Some(val) = catalog.get(key) { if let Some(val) = catalog.get(key) {
return val.clone(); return val.clone();
} }
if !language.starts_with("en") { if !language.starts_with("en")
if let Some(val) = RENDER_EN.get(key) { && let Some(val) = RENDER_EN.get(key)
return val.clone(); {
} return val.clone();
} }
key.to_string() key.to_string()
} }
@@ -302,7 +302,10 @@ mod tests {
#[test] #[test]
fn translate_render_missing_key_returns_key() { fn translate_render_missing_key_returns_key() {
assert_eq!(translate_render("en", "render.nonexistent"), "render.nonexistent"); assert_eq!(
translate_render("en", "render.nonexistent"),
"render.nonexistent"
);
} }
#[test] #[test]
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod db; pub mod db;
pub mod engine; pub mod engine;
pub mod i18n;
pub mod model; pub mod model;
pub mod render; pub mod render;
pub mod i18n;
pub mod scripting; pub mod scripting;
pub mod util; pub mod util;
+8 -3
View File
@@ -154,10 +154,15 @@ mod tests {
fn max_posts_per_page_validation() { fn max_posts_per_page_validation() {
let mut meta = ProjectMetadata { let mut meta = ProjectMetadata {
name: "Test".into(), name: "Test".into(),
description: None, public_url: None, main_language: None, description: None,
default_author: None, max_posts_per_page: 50, blogmark_category: None, public_url: None,
main_language: None,
default_author: None,
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None, pico_theme: None,
semantic_similarity_enabled: false, blog_languages: vec![], semantic_similarity_enabled: false,
blog_languages: vec![],
}; };
assert!(meta.validate().is_ok()); assert!(meta.validate().is_ok());
+12 -12
View File
@@ -1,20 +1,20 @@
mod post;
mod media;
mod tag;
mod project;
mod template;
mod script;
mod generation; mod generation;
mod media;
pub mod metadata; pub mod metadata;
mod post;
mod project;
mod script;
mod tag;
mod template;
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
pub use media::{Media, MediaTranslation};
pub use tag::Tag;
pub use project::{Project, Setting};
pub use template::{Template, TemplateKind, TemplateStatus};
pub use script::{Script, ScriptKind, ScriptStatus};
pub use generation::{ pub use generation::{
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity, DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
PublishingPreferences, SshMode, PublishingPreferences, SshMode,
}; };
pub use media::{Media, MediaTranslation};
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry}; pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
pub use project::{Project, Setting};
pub use script::{Script, ScriptKind, ScriptStatus};
pub use tag::Tag;
pub use template::{Template, TemplateKind, TemplateStatus};
+18 -16
View File
@@ -32,13 +32,12 @@ pub fn write_generated_file(
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes()); let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path); let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) { if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
if existing.content_hash == hash && existing.content_hash == hash
&& target_path.exists() && target_path.exists()
&& file_hash(&target_path)? == hash && file_hash(&target_path)? == hash
{ {
return Ok(GeneratedWriteOutcome::SkippedUnchanged); return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
} }
if let Some(parent) = target_path.parent() { if let Some(parent) = target_path.parent() {
@@ -68,13 +67,12 @@ pub fn write_generated_bytes(
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content); let hash = content_hash(content);
let target_path = output_dir.join(relative_path); let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) { if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
if existing.content_hash == hash && existing.content_hash == hash
&& target_path.exists() && target_path.exists()
&& file_hash(&target_path)? == hash && file_hash(&target_path)? == hash
{ {
return Ok(GeneratedWriteOutcome::SkippedUnchanged); return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
} }
if let Some(parent) = target_path.parent() { if let Some(parent) = target_path.parent() {
@@ -135,9 +133,13 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
*days.entry(day).or_insert(0) += 1; *days.entry(day).or_insert(0) += 1;
} }
CalendarArchiveData { years, months, days } CalendarArchiveData {
years,
months,
days,
}
} }
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> { pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
serde_json::to_string_pretty(&build_calendar_archive_data(posts)) serde_json::to_string_pretty(&build_calendar_archive_data(posts))
} }
+53 -24
View File
@@ -52,17 +52,10 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
"vimeo" => Some(render_vimeo(&args)), "vimeo" => Some(render_vimeo(&args)),
"photo_archive" => Some(render_photo_archive(&args, context)), "photo_archive" => Some(render_photo_archive(&args, context)),
"tag_cloud" => Some(render_tag_cloud(&args, context)), "tag_cloud" => Some(render_tag_cloud(&args, context)),
_ => Some(unsupported_macro(name)), _ => None,
} }
} }
fn unsupported_macro(name: &str) -> String {
format!(
"<section class=\"macro-unsupported\"><p>Unsupported macro: <code>{}</code></p></section>",
escape_html_attr(name),
)
}
fn tokenize_invocation(invocation: &str) -> Vec<String> { fn tokenize_invocation(invocation: &str) -> Vec<String> {
let mut tokens = Vec::new(); let mut tokens = Vec::new();
let mut current = String::new(); let mut current = String::new();
@@ -100,12 +93,11 @@ fn tokenize_invocation(invocation: &str) -> Vec<String> {
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue { fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.len() >= 2 { if trimmed.len() >= 2
if (trimmed.starts_with('"') && trimmed.ends_with('"')) && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')) || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
{ {
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string()); return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
}
} }
match trimmed { match trimmed {
@@ -197,7 +189,11 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
fn render_youtube(args: &HashMap<String, JsonValue>) -> String { fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() { if video_id.is_empty() {
return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id."); return empty_block(
"macro-youtube",
"gallery-empty",
"Missing YouTube video id.",
);
} }
format!( format!(
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>", "<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
@@ -224,10 +220,18 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
.or_else(|| linked_media(context)); .or_else(|| linked_media(context));
let Some(media) = media else { let Some(media) = media else {
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available."); return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
}; };
if media.is_empty() { if media.is_empty() {
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available."); return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
} }
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new(); let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
@@ -243,7 +247,11 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
let single_month = grouped.len() == 1; let single_month = grouped.len() == 1;
let mut html = format!( let mut html = format!(
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">", "<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
if single_month { " photo-archive-single-month" } else { "" } if single_month {
" photo-archive-single-month"
} else {
""
}
); );
for (bucket, items) in grouped.into_iter().rev() { for (bucket, items) in grouped.into_iter().rev() {
html.push_str("<section class=\"photo-archive-month\">"); html.push_str("<section class=\"photo-archive-month\">");
@@ -362,7 +370,10 @@ fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
} }
fn image_path(image: &JsonValue) -> Option<String> { fn image_path(image: &JsonValue) -> Option<String> {
image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned) image
.get("file_path")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
} }
fn image_title(image: &JsonValue) -> Option<String> { fn image_title(image: &JsonValue) -> Option<String> {
@@ -391,7 +402,9 @@ fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
} }
fn value_as_u64(value: &JsonValue) -> Option<u64> { fn value_as_u64(value: &JsonValue) -> Option<u64> {
value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64)) value
.as_u64()
.or_else(|| value.as_i64().map(|number| number.max(0) as u64))
} }
fn stringify_scalar(value: &JsonValue) -> String { fn stringify_scalar(value: &JsonValue) -> String {
@@ -407,7 +420,9 @@ fn stringify_scalar(value: &JsonValue) -> String {
fn month_bucket(path: &str) -> Option<String> { fn month_bucket(path: &str) -> Option<String> {
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>(); let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
match segments.as_slice() { match segments.as_slice() {
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => Some(format!("{year}-{month}")), ["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
Some(format!("{year}-{month}"))
}
_ => None, _ => None,
} }
} }
@@ -422,11 +437,16 @@ fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> Strin
} }
fn escape_html(value: &str) -> String { fn escape_html(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;") value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
} }
fn escape_html_attr(value: &str) -> String { fn escape_html_attr(value: &str) -> String {
escape_html(value).replace('"', "&quot;").replace('\'', "&#39;") escape_html(value)
.replace('"', "&quot;")
.replace('\'', "&#39;")
} }
#[cfg(test)] #[cfg(test)]
@@ -470,4 +490,13 @@ mod tests {
assert!(html.contains("macro-tag-cloud")); assert!(html.contains("macro-tag-cloud"));
assert!(html.contains("data-tag-cloud=\"true\"")); assert!(html.contains("data-tag-cloud=\"true\""));
} }
}
#[test]
fn leaves_unknown_macros_verbatim() {
let markdown = "Before [[future_macro value='x']] after";
assert_eq!(
expand_builtin_macros(markdown, &MacroRenderContext::default()),
markdown
);
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ mod tests {
assert!(rendered.contains("<h1>Title</h1>")); assert!(rendered.contains("<h1>Title</h1>"));
assert!(rendered.contains("<p>Some <em>text</em>.</p>")); assert!(rendered.contains("<p>Some <em>text</em>.</p>"));
} }
} }
+10 -11
View File
@@ -1,27 +1,26 @@
mod markdown;
mod macros;
mod generation; mod generation;
mod macros;
mod markdown;
mod page_renderer; mod page_renderer;
mod routes; mod routes;
mod site; mod site;
mod template_lookup; mod template_lookup;
pub use generation::{ pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
build_core_generation_paths, write_generated_bytes, write_generated_file, write_generated_bytes, write_generated_file,
}; };
pub use markdown::render_markdown_to_html; pub use markdown::render_markdown_to_html;
pub use page_renderer::{RenderError, render_liquid_template}; pub use page_renderer::{RenderError, render_liquid_template};
pub use routes::{ pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page, RenderedPage, build_canonical_post_path, render_starter_list_page,
render_starter_list_page_with_media_map, render_starter_single_post_page, render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map, render_starter_single_post_page_with_media_map,
}; };
pub use site::{ pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
build_preview_response, build_site_render_artifacts, build_site_render_artifacts,
}; };
pub use template_lookup::{ pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
resolve_post_template,
}; };
+67 -16
View File
@@ -2,11 +2,11 @@ use std::collections::HashMap;
use liquid::ParserBuilder; use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource}; use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
};
use liquid_core::model::ScalarCow; use liquid_core::model::ScalarCow;
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
ParseFilter, Runtime, Value, ValueView,
};
use serde::Serialize; use serde::Serialize;
use serde_json::{Map as JsonMap, Value as JsonValue}; use serde_json::{Map as JsonMap, Value as JsonValue};
use thiserror::Error; use thiserror::Error;
@@ -14,6 +14,7 @@ use thiserror::Error;
use crate::i18n::translate_render; use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros}; use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html; use crate::render::render_markdown_to_html;
use crate::util::slugify;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum RenderError { pub enum RenderError {
@@ -40,6 +41,7 @@ pub fn render_liquid_template<T: Serialize>(
let parser = ParserBuilder::with_stdlib() let parser = ParserBuilder::with_stdlib()
.filter(I18n) .filter(I18n)
.filter(Markdown) .filter(Markdown)
.filter(Slugify)
.partials(compiled_partials) .partials(compiled_partials)
.build()?; .build()?;
let template = parser.parse(template_source)?; let template = parser.parse(template_source)?;
@@ -47,6 +49,28 @@ pub fn render_liquid_template<T: Serialize>(
Ok(template.render(&globals)?) Ok(template.render(&globals)?)
} }
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "slugify",
description = "Convert text to the canonical post slug format.",
parsed(SlugifyFilter)
)]
struct Slugify;
#[derive(Debug, Default, Display_filter)]
#[name = "slugify"]
struct SlugifyFilter;
impl Filter for SlugifyFilter {
fn evaluate(
&self,
input: &dyn ValueView,
_runtime: &dyn Runtime,
) -> liquid_core::Result<Value> {
Ok(Value::scalar(slugify(input.to_kstr().as_str())))
}
}
#[derive(Debug, FilterParameters)] #[derive(Debug, FilterParameters)]
struct I18nArgs { struct I18nArgs {
#[parameter(description = "Render language", arg_type = "str")] #[parameter(description = "Render language", arg_type = "str")]
@@ -74,7 +98,10 @@ impl Filter for I18nFilter {
let args = self.args.evaluate(runtime)?; let args = self.args.evaluate(runtime)?;
let key = input.to_kstr(); let key = input.to_kstr();
let language = args.language.to_kstr(); let language = args.language.to_kstr();
Ok(Value::scalar(translate_render(language.as_str(), key.as_str()))) Ok(Value::scalar(translate_render(
language.as_str(),
key.as_str(),
)))
} }
} }
@@ -135,7 +162,10 @@ impl Filter for MarkdownFilter {
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context); let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
let rendered = render_markdown_to_html(&expanded); let rendered = render_markdown_to_html(&expanded);
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context))) Ok(Value::scalar(rewrite_rendered_html_urls(
&rendered,
&rewrite_context,
)))
} }
} }
@@ -148,10 +178,10 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
} }
} }
if !roots.contains_key("Tags") { if !roots.contains_key("Tags")
if let Some(tags) = roots.get("post_tags").cloned() { && let Some(tags) = roots.get("post_tags").cloned()
roots.insert("Tags".to_string(), tags); {
} roots.insert("Tags".to_string(), tags);
} }
roots roots
@@ -202,9 +232,16 @@ fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
.unwrap_or_default() .unwrap_or_default()
} }
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String { pub(crate) fn rewrite_rendered_html_urls(
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context)); html: &str,
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context)) rewrite_context: &impl RewriteContextView,
) -> String {
let rewritten = rewrite_attribute_urls(html, "href", |href| {
normalize_preview_href(href, rewrite_context)
});
rewrite_attribute_urls(&rewritten, "src", |src| {
normalize_preview_src(src, rewrite_context)
})
} }
pub(crate) trait RewriteContextView { pub(crate) trait RewriteContextView {
@@ -387,7 +424,9 @@ fn extract_post_slug(path: &str) -> Option<String> {
let segments: Vec<_> = trimmed.split('/').collect(); let segments: Vec<_> = trimmed.split('/').collect();
match segments.as_slice() { match segments.as_slice() {
["post" | "posts", slug] => Some(trim_html_suffix(slug)), ["post" | "posts", slug] => Some(trim_html_suffix(slug)),
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => { ["post" | "posts", year, month, slug]
if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) =>
{
Some(trim_html_suffix(slug)) Some(trim_html_suffix(slug))
} }
_ => None, _ => None,
@@ -422,7 +461,7 @@ fn trim_html_suffix(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{rewrite_rendered_html_urls, RewriteContextView}; use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
use std::collections::HashMap; use std::collections::HashMap;
struct TestRewriteContext { struct TestRewriteContext {
@@ -457,4 +496,16 @@ mod tests {
assert!(html.contains("src=\"/media/2026/04/media-1.png\"")); assert!(html.contains("src=\"/media/2026/04/media-1.png\""));
} }
}
#[test]
fn exposes_slugify_liquid_filter() {
let rendered = render_liquid_template(
"{{ title | slugify }}",
&HashMap::new(),
&serde_json::json!({"title": "Über die Brücke"}),
)
.unwrap();
assert_eq!(rendered, "ueber-die-bruecke");
}
}
+101 -35
View File
@@ -7,11 +7,16 @@ use crate::i18n::normalize_language;
use crate::model::{Post, ProjectMetadata}; use crate::model::{Post, ProjectMetadata};
use crate::render::{RenderError, render_liquid_template}; use crate::render::{RenderError, render_liquid_template};
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid"); const STARTER_SINGLE_POST_TEMPLATE: &str =
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid"); include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid"); const STARTER_POST_LIST_TEMPLATE: &str =
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid"); include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid"); const STARTER_HEAD_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage { pub struct RenderedPage {
@@ -136,7 +141,10 @@ pub fn render_starter_single_post_page_with_media_map(
language: &str, language: &str,
canonical_media_path_by_source_path: HashMap<String, String>, canonical_media_path_by_source_path: HashMap<String, String>,
) -> Result<RenderedPage, RenderError> { ) -> Result<RenderedPage, RenderError> {
let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')); let relative_path = format!(
"{}/index.html",
build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')
);
let canonical_path = build_canonical_post_path(post, language, main_language(metadata)); let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post); let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
let context = PostTemplateContext { let context = PostTemplateContext {
@@ -171,13 +179,12 @@ pub fn render_starter_single_post_page_with_media_map(
post_data_json_by_id: HashMap::new(), post_data_json_by_id: HashMap::new(),
}; };
let html = render_liquid_template( let html = render_liquid_template(STARTER_SINGLE_POST_TEMPLATE, &starter_partials(), &context)?;
STARTER_SINGLE_POST_TEMPLATE,
&starter_partials(),
&context,
)?;
Ok(RenderedPage { relative_path, html }) Ok(RenderedPage {
relative_path,
html,
})
} }
pub fn render_starter_list_page( pub fn render_starter_list_page(
@@ -246,26 +253,33 @@ pub fn render_starter_list_page_with_media_map(
post_data_json_by_id: HashMap::new(), post_data_json_by_id: HashMap::new(),
}; };
let html = render_liquid_template( let html =
&starter_post_list_template(), render_liquid_template(&starter_post_list_template(), &starter_partials(), &context)?;
&starter_partials(),
&context,
)?;
Ok(RenderedPage { relative_path, html }) Ok(RenderedPage {
relative_path,
html,
})
} }
fn starter_partials() -> HashMap<String, String> { fn starter_partials() -> HashMap<String, String> {
HashMap::from([ HashMap::from([
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()), (
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()), "partials/head".to_string(),
STARTER_HEAD_PARTIAL.to_string(),
),
(
"partials/menu".to_string(),
STARTER_MENU_PARTIAL.to_string(),
),
( (
"partials/language-switcher".to_string(), "partials/language-switcher".to_string(),
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(), STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
), ),
( (
"partials/menu-items".to_string(), "partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(), "{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
), ),
]) ])
} }
@@ -305,7 +319,11 @@ fn calendar_initial_parts(post: &Post) -> (i32, u32) {
.unwrap_or((1970, 1)) .unwrap_or((1970, 1))
} }
fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<AlternateLinkContext> { fn build_alternate_links(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<AlternateLinkContext> {
metadata metadata
.blog_languages .blog_languages
.iter() .iter()
@@ -320,7 +338,11 @@ fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_langua
.collect() .collect()
} }
fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> { fn build_blog_languages(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<BlogLanguageContext> {
metadata metadata
.blog_languages .blog_languages
.iter() .iter()
@@ -334,7 +356,10 @@ fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_languag
.collect() .collect()
} }
fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> { fn build_blog_languages_for_index(
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<BlogLanguageContext> {
metadata metadata
.blog_languages .blog_languages
.iter() .iter()
@@ -349,12 +374,23 @@ fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language:
} }
fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String { fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); let base_url = metadata
format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata))) .public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
format!(
"{base_url}{}",
build_canonical_post_path(post, language, main_language(metadata))
)
} }
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String { fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) { let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
"/".to_string() "/".to_string()
} else { } else {
@@ -377,7 +413,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
continue; continue;
}; };
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day()); let key = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
if current_key.as_deref() != Some(key.as_str()) { if current_key.as_deref() != Some(key.as_str()) {
if let Some(last) = blocks.last_mut() { if let Some(last) = blocks.last_mut() {
last.show_separator = true; last.show_separator = true;
@@ -385,7 +426,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
current_key = Some(key); current_key = Some(key);
blocks.push(DayBlockContext { blocks.push(DayBlockContext {
show_date_marker: true, show_date_marker: true,
date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()), date_label: format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
),
posts: Vec::new(), posts: Vec::new(),
show_separator: false, show_separator: false,
}); });
@@ -456,19 +502,39 @@ mod tests {
#[test] #[test]
fn canonical_post_paths_follow_language_prefix_rule() { fn canonical_post_paths_follow_language_prefix_rule() {
let post = make_post(); let post = make_post();
assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello"); assert_eq!(
assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello"); build_canonical_post_path(&post, "en", "en"),
"/2024/03/09/hello"
);
assert_eq!(
build_canonical_post_path(&post, "de", "en"),
"/de/2024/03/09/hello"
);
} }
#[test] #[test]
fn starter_single_post_renderer_uses_canonical_route_and_language_links() { fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
let post = make_post(); let post = make_post();
let metadata = make_metadata(); let metadata = make_metadata();
let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap(); let rendered = render_starter_single_post_page(
&post,
"Body with [link](/posts/hello)",
&metadata,
"en",
)
.unwrap();
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html"); assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
assert!(rendered.html.contains("https://example.com/2024/03/09/hello")); assert!(
assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello")); rendered
.html
.contains("https://example.com/2024/03/09/hello")
);
assert!(
rendered
.html
.contains("https://example.com/de/2024/03/09/hello")
);
assert!(rendered.html.contains("href=\"/2024/03/09/hello\"")); assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
} }
@@ -515,4 +581,4 @@ mod tests {
assert!(rendered.html.contains("10.03.2024")); assert!(rendered.html.contains("10.03.2024"));
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\"")); assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
} }
} }
+277 -93
View File
@@ -10,17 +10,28 @@ use serde_json::{Value, json};
use crate::db::queries; use crate::db::queries;
use crate::engine::menu::{self, MenuItemKind}; use crate::engine::menu::{self, MenuItemKind};
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus}; use crate::model::{
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template}; CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus,
};
use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
render_liquid_template, resolve_post_template,
};
use crate::util::frontmatter::{read_template_file, read_translation_file}; use crate::util::frontmatter::{read_template_file, read_translation_file};
use crate::util::slugify; use crate::util::slugify;
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid"); const STARTER_SINGLE_POST_TEMPLATE: &str =
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid"); include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_NOT_FOUND_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/not-found.liquid"); const STARTER_POST_LIST_TEMPLATE: &str =
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid"); include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid"); const STARTER_NOT_FOUND_TEMPLATE: &str =
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid"); include_str!("../../../../assets/starter-templates/not-found.liquid");
const STARTER_HEAD_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitePage { pub struct SitePage {
@@ -100,9 +111,16 @@ pub fn build_site_render_artifacts(
let mut artifacts = SiteRenderArtifacts::default(); let mut artifacts = SiteRenderArtifacts::default();
for language in languages { for language in languages {
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?; let localized_posts =
load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings); let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
let routes = build_language_routes(&localized_list_posts, metadata, &language, &tags, &category_settings); let routes = build_language_routes(
&localized_list_posts,
metadata,
&language,
&tags,
&category_settings,
);
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts); let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
let menu_items = build_menu_items(data_dir, &language, &main_language)?; let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes let rendered_list_pages = routes
@@ -138,7 +156,8 @@ pub fn build_site_render_artifacts(
artifacts.pages.push(page); artifacts.pages.push(page);
} }
let canonical_map = canonical_post_path_by_slug(&localized_posts, &language, &main_language); let canonical_map =
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
for record in &localized_posts { for record in &localized_posts {
let html = render_post_route( let html = render_post_route(
conn, conn,
@@ -183,9 +202,14 @@ pub fn build_preview_response(
published_posts: &[(Post, String)], published_posts: &[(Post, String)],
requested_path: &str, requested_path: &str,
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> { ) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?; let artifacts =
build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
let normalized = normalize_request_path(requested_path); let normalized = normalize_request_path(requested_path);
if let Some(page) = artifacts.pages.iter().find(|page| page.url_path == normalized) { if let Some(page) = artifacts
.pages
.iter()
.find(|page| page.url_path == normalized)
{
return Ok(PreviewRenderResult { return Ok(PreviewRenderResult {
status_code: 200, status_code: 200,
html: page.html.clone(), html: page.html.clone(),
@@ -207,7 +231,8 @@ fn load_template_bundle(
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
) -> Result<TemplateBundle, Box<dyn Error + Send + Sync>> { ) -> Result<TemplateBundle, Box<dyn Error + Send + Sync>> {
let templates = queries::template::list_templates_by_project(conn, project_id).unwrap_or_default(); let templates =
queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
let mut template_source_by_slug = HashMap::new(); let mut template_source_by_slug = HashMap::new();
let mut post_templates = Vec::new(); let mut post_templates = Vec::new();
let mut list_template_sources = HashMap::new(); let mut list_template_sources = HashMap::new();
@@ -237,7 +262,10 @@ fn load_template_bundle(
} }
} }
TemplateKind::NotFound => { TemplateKind::NotFound => {
if template.slug == "not-found" || template.slug == "not_found" || not_found_template == STARTER_NOT_FOUND_TEMPLATE { if template.slug == "not-found"
|| template.slug == "not_found"
|| not_found_template == STARTER_NOT_FOUND_TEMPLATE
{
not_found_template = source; not_found_template = source;
} }
} }
@@ -258,7 +286,10 @@ fn load_template_bundle(
.entry("list".to_string()) .entry("list".to_string())
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string()); .or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
if !post_templates.iter().any(|template| template.slug == "post") { if !post_templates
.iter()
.any(|template| template.slug == "post")
{
post_templates.push(Template { post_templates.push(Template {
id: "starter-post-template".to_string(), id: "starter-post-template".to_string(),
project_id: project_id.to_string(), project_id: project_id.to_string(),
@@ -273,7 +304,8 @@ fn load_template_bundle(
created_at: 0, created_at: 0,
updated_at: 0, updated_at: 0,
}); });
template_source_by_slug.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string()); template_source_by_slug
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
} }
Ok(TemplateBundle { Ok(TemplateBundle {
@@ -318,7 +350,11 @@ fn load_language_posts(
continue; continue;
} }
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &post.id, language) { if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
{
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?; let raw = fs::read_to_string(data_dir.join(&translation.file_path))?;
let (_, translated_body) = read_translation_file(&raw)?; let (_, translated_body) = read_translation_file(&raw)?;
let mut translated_post = post.clone(); let mut translated_post = post.clone();
@@ -336,7 +372,10 @@ fn load_language_posts(
} }
posts.sort_by(|left, right| { posts.sort_by(|left, right| {
right.post.published_at.unwrap_or(right.post.created_at) right
.post
.published_at
.unwrap_or(right.post.created_at)
.cmp(&left.post.published_at.unwrap_or(left.post.created_at)) .cmp(&left.post.published_at.unwrap_or(left.post.created_at))
}); });
Ok(posts) Ok(posts)
@@ -367,14 +406,29 @@ fn build_language_routes(
for record in posts { for record in posts {
for category in &record.post.categories { for category in &record.post.categories {
category_posts.entry(category.clone()).or_default().push(record.clone()); category_posts
.entry(category.clone())
.or_default()
.push(record.clone());
} }
for tag in &record.post.tags { for tag in &record.post.tags {
tag_posts.entry(tag.clone()).or_default().push(record.clone()); tag_posts
.entry(tag.clone())
.or_default()
.push(record.clone());
} }
if let Some(timestamp) = Utc.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at)).single() { if let Some(timestamp) = Utc
year_posts.entry(timestamp.year()).or_default().push(record.clone()); .timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at))
month_posts.entry((timestamp.year(), timestamp.month())).or_default().push(record.clone()); .single()
{
year_posts
.entry(timestamp.year())
.or_default()
.push(record.clone());
month_posts
.entry((timestamp.year(), timestamp.month()))
.or_default()
.push(record.clone());
} }
} }
@@ -383,7 +437,10 @@ fn build_language_routes(
routes.extend(paginated_route_specs( routes.extend(paginated_route_specs(
&records, &records,
per_page, per_page,
format!("{}/category/{slug}", language_root_prefix(language, metadata)), format!(
"{}/category/{slug}",
language_root_prefix(language, metadata)
),
category.clone(), category.clone(),
Some(json!({"kind": "category", "name": category})), Some(json!({"kind": "category", "name": category})),
category_settings category_settings
@@ -424,7 +481,10 @@ fn build_language_routes(
routes.extend(paginated_route_specs( routes.extend(paginated_route_specs(
&records, &records,
per_page, per_page,
format!("{}/{year}/{month:02}", language_root_prefix(language, metadata)), format!(
"{}/{year}/{month:02}",
language_root_prefix(language, metadata)
),
format!("{} {year}-{month:02}", metadata.name), format!("{} {year}-{month:02}", metadata.name),
Some(json!({"kind": "month", "year": year, "month": month})), Some(json!({"kind": "month", "year": year, "month": month})),
None, None,
@@ -449,7 +509,11 @@ fn paginated_route_specs(
let current_page = page_index + 1; let current_page = page_index + 1;
let start = page_index * per_page; let start = page_index * per_page;
let end = (start + per_page).min(total_items); let end = (start + per_page).min(total_items);
let slice = if start < end { posts[start..end].to_vec() } else { Vec::new() }; let slice = if start < end {
posts[start..end].to_vec()
} else {
Vec::new()
};
let relative_base = base_path.trim_matches('/'); let relative_base = base_path.trim_matches('/');
let relative_path = if current_page == 1 { let relative_path = if current_page == 1 {
if relative_base.is_empty() { if relative_base.is_empty() {
@@ -479,6 +543,10 @@ fn paginated_route_specs(
pages pages
} }
#[expect(
clippy::too_many_arguments,
reason = "render inputs are existing domain data with distinct lifetimes"
)]
fn render_list_route( fn render_list_route(
route: &RouteSpec, route: &RouteSpec,
metadata: &ProjectMetadata, metadata: &ProjectMetadata,
@@ -536,7 +604,11 @@ fn render_list_route(
"not_found_back_label": serde_json::Value::Null, "not_found_back_label": serde_json::Value::Null,
}); });
Ok(render_liquid_template(list_template, &bundle.partials, &context)?) Ok(render_liquid_template(
list_template,
&bundle.partials,
&context,
)?)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -558,9 +630,12 @@ fn render_post_route(
let render_categories = category_settings let render_categories = category_settings
.iter() .iter()
.map(|(name, settings)| { .map(|(name, settings)| {
(name.clone(), RenderCategorySettings { (
post_template_slug: settings.post_template_slug.clone(), name.clone(),
}) RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
},
)
}) })
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let resolved = resolve_post_template(RenderTemplateLookup { let resolved = resolve_post_template(RenderTemplateLookup {
@@ -583,12 +658,26 @@ fn render_post_route(
.filter_map(|link| media_by_id.get(&link.media_id)) .filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context) .map(media_context)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let outgoing_links = queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default(); let outgoing_links =
let incoming_links = queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default(); queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
let post_by_id = all_posts.iter().map(|item| (item.post.id.clone(), item)).collect::<HashMap<_, _>>(); let incoming_links =
let outgoing_link_context = outgoing_links.iter().map(|link| link_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>(); queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
let incoming_link_context = incoming_links.iter().map(|link| link_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>(); let post_by_id = all_posts
let backlinks = incoming_links.iter().map(|link| backlink_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>(); .iter()
.map(|item| (item.post.id.clone(), item))
.collect::<HashMap<_, _>>();
let outgoing_link_context = outgoing_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let incoming_link_context = incoming_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let backlinks = incoming_links
.iter()
.map(|link| backlink_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let taxonomy_counts = build_taxonomy_counts(all_posts, tags); let taxonomy_counts = build_taxonomy_counts(all_posts, tags);
let context = json!({ let context = json!({
@@ -626,7 +715,11 @@ fn render_post_route(
"not_found_back_label": serde_json::Value::Null, "not_found_back_label": serde_json::Value::Null,
}); });
Ok(render_liquid_template(&template_source, &bundle.partials, &context)?) Ok(render_liquid_template(
&template_source,
&bundle.partials,
&context,
)?)
} }
fn render_not_found_route( fn render_not_found_route(
@@ -670,7 +763,11 @@ fn render_not_found_route(
"canonical_media_path_by_source_path": HashMap::<String, String>::new(), "canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(), "post_data_json_by_id": HashMap::<String, Value>::new(),
}); });
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?) Ok(render_liquid_template(
&bundle.not_found_template,
&bundle.partials,
&context,
)?)
} }
fn build_menu_items( fn build_menu_items(
@@ -728,17 +825,17 @@ fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
} }
fn prefix_or_root(prefix: &str) -> &str { fn prefix_or_root(prefix: &str) -> &str {
if prefix.is_empty() { if prefix.is_empty() { "/" } else { prefix }
"/"
} else {
prefix
}
} }
fn route_href(route: &RouteSpec, page: usize) -> String { fn route_href(route: &RouteSpec, page: usize) -> String {
let base = route.url_path.trim_end_matches('/'); let base = route.url_path.trim_end_matches('/');
if page <= 1 { if page <= 1 {
if base.is_empty() { "/".to_string() } else { base.to_string() } if base.is_empty() {
"/".to_string()
} else {
base.to_string()
}
} else if base.is_empty() || base == "/" { } else if base.is_empty() || base == "/" {
format!("/page/{page}") format!("/page/{page}")
} else { } else {
@@ -760,7 +857,12 @@ fn build_day_blocks(
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else { let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
continue; continue;
}; };
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day()); let key = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
if !current_key.is_empty() && current_key != key { if !current_key.is_empty() && current_key != key {
blocks.push(json!({ blocks.push(json!({
"show_date_marker": true, "show_date_marker": true,
@@ -771,7 +873,12 @@ fn build_day_blocks(
current_posts = Vec::new(); current_posts = Vec::new();
} }
current_key = key; current_key = key;
current_label = format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()); current_label = format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
);
let show_title = should_show_list_title(&record.post, category_settings); let show_title = should_show_list_title(&record.post, category_settings);
current_posts.push(json!({ current_posts.push(json!({
"id": record.post.id, "id": record.post.id,
@@ -797,7 +904,8 @@ fn filter_posts_for_lists(
posts: &[RenderPostRecord], posts: &[RenderPostRecord],
category_settings: &HashMap<String, CategorySettings>, category_settings: &HashMap<String, CategorySettings>,
) -> Vec<RenderPostRecord> { ) -> Vec<RenderPostRecord> {
posts.iter() posts
.iter()
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings)) .filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
.cloned() .cloned()
.collect() .collect()
@@ -850,11 +958,14 @@ fn canonical_post_path_by_slug(
language: &str, language: &str,
main_language: &str, main_language: &str,
) -> HashMap<String, String> { ) -> HashMap<String, String> {
posts.iter() posts
.map(|record| ( .iter()
record.post.slug.clone(), .map(|record| {
build_canonical_post_path(&record.post, language, main_language), (
)) record.post.slug.clone(),
build_canonical_post_path(&record.post, language, main_language),
)
})
.collect() .collect()
} }
@@ -874,7 +985,9 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
let mut remainder = markdown; let mut remainder = markdown;
while let Some(index) = remainder.find("bds-media://") { while let Some(index) = remainder.find("bds-media://") {
let rest = &remainder[index..]; let rest = &remainder[index..];
let end = rest.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace()).unwrap_or(rest.len()); let end = rest
.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace())
.unwrap_or(rest.len());
refs.push(rest[..end].to_string()); refs.push(rest[..end].to_string());
remainder = &rest[end..]; remainder = &rest[end..];
} }
@@ -927,29 +1040,50 @@ fn build_taxonomy_counts(
} }
} }
let categories = category_counts.into_iter().map(|(name, count)| json!({ let categories = category_counts
"name": name, .into_iter()
"slug": slugify(&name), .map(|(name, count)| {
"post_count": count, json!({
})).collect::<Vec<_>>(); "name": name,
"slug": slugify(&name),
let tag_items = tag_counts.into_iter().map(|(name, count)| { "post_count": count,
let color = tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(&name)).and_then(|tag| tag.color.clone()); })
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
}) })
}).collect::<Vec<_>>(); .collect::<Vec<_>>();
let tag_items = tag_counts
.into_iter()
.map(|(name, count)| {
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(&name))
.and_then(|tag| tag.color.clone());
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
})
})
.collect::<Vec<_>>();
(categories, tag_items, tag_colors) (categories, tag_items, tag_colors)
} }
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> { fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
names.iter() names
.iter()
.map(|name| { .map(|name| {
let count = posts.iter().filter(|record| record.post.categories.iter().any(|category| category.eq_ignore_ascii_case(name))).count(); let count = posts
.iter()
.filter(|record| {
record
.post
.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(name))
})
.count();
json!({ json!({
"name": name, "name": name,
"slug": slugify(name), "slug": slugify(name),
@@ -959,11 +1093,28 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
.collect() .collect()
} }
fn taxonomy_items_for_tags(names: &[String], posts: &[RenderPostRecord], tags: &[Tag]) -> Vec<Value> { fn taxonomy_items_for_tags(
names.iter() names: &[String],
posts: &[RenderPostRecord],
tags: &[Tag],
) -> Vec<Value> {
names
.iter()
.map(|name| { .map(|name| {
let count = posts.iter().filter(|record| record.post.tags.iter().any(|tag| tag.eq_ignore_ascii_case(name))).count(); let count = posts
let color = tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(name)).and_then(|tag| tag.color.clone()); .iter()
.filter(|record| {
record
.post
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(name))
})
.count();
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(name))
.and_then(|tag| tag.color.clone());
json!({ json!({
"name": name, "name": name,
"slug": slugify(name), "slug": slugify(name),
@@ -1076,7 +1227,11 @@ fn build_alternate_post_links(post: &Post, metadata: &ProjectMetadata) -> Vec<Va
links links
} }
fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<Value> { fn build_post_blog_languages(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<Value> {
let main_language = main_language(metadata); let main_language = main_language(metadata);
render_languages(metadata) render_languages(metadata)
.into_iter() .into_iter()
@@ -1090,7 +1245,11 @@ fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_la
.collect() .collect()
} }
fn build_list_blog_languages(metadata: &ProjectMetadata, current_language: &str, current_url_path: &str) -> Vec<Value> { fn build_list_blog_languages(
metadata: &ProjectMetadata,
current_language: &str,
current_url_path: &str,
) -> Vec<Value> {
let main_language = main_language(metadata); let main_language = main_language(metadata);
render_languages(metadata) render_languages(metadata)
.into_iter() .into_iter()
@@ -1126,7 +1285,11 @@ fn build_alternate_list_links(metadata: &ProjectMetadata, current_url_path: &str
links links
} }
fn relocalize_url_path(current_url_path: &str, target_language: &str, main_language: &str) -> String { fn relocalize_url_path(
current_url_path: &str,
target_language: &str,
main_language: &str,
) -> String {
let stripped = strip_language_prefix(current_url_path, main_language); let stripped = strip_language_prefix(current_url_path, main_language);
if target_language.eq_ignore_ascii_case(main_language) { if target_language.eq_ignore_ascii_case(main_language) {
stripped stripped
@@ -1140,10 +1303,11 @@ fn relocalize_url_path(current_url_path: &str, target_language: &str, main_langu
fn strip_language_prefix(path: &str, main_language: &str) -> String { fn strip_language_prefix(path: &str, main_language: &str) -> String {
let normalized = normalize_request_path(path); let normalized = normalize_request_path(path);
let trimmed = normalized.trim_start_matches('/'); let trimmed = normalized.trim_start_matches('/');
if let Some((first, remainder)) = trimmed.split_once('/') { if let Some((first, remainder)) = trimmed.split_once('/')
if first.len() == 2 && !first.eq_ignore_ascii_case(main_language) { && first.len() == 2
return format!("/{}", remainder.trim_start_matches('/')); && !first.eq_ignore_ascii_case(main_language)
} {
return format!("/{}", remainder.trim_start_matches('/'));
} }
normalized normalized
} }
@@ -1161,16 +1325,20 @@ fn relative_to_url_path(relative_path: &str) -> String {
if relative_path == "index.html" { if relative_path == "index.html" {
return "/".to_string(); return "/".to_string();
} }
let trimmed = relative_path.trim_end_matches("index.html").trim_end_matches('/'); let trimmed = relative_path
.trim_end_matches("index.html")
.trim_end_matches('/');
format!("/{}", trimmed.trim_start_matches('/')) format!("/{}", trimmed.trim_start_matches('/'))
} }
fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String { fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String {
let trimmed = path.trim_start_matches('/'); let trimmed = path.trim_start_matches('/');
if let Some((candidate, _)) = trimmed.split_once('/') { if let Some((candidate, _)) = trimmed.split_once('/')
if render_languages(metadata).iter().any(|language| language == candidate) { && render_languages(metadata)
return candidate.to_string(); .iter()
} .any(|language| language == candidate)
{
return candidate.to_string();
} }
main_language(metadata).to_string() main_language(metadata).to_string()
} }
@@ -1179,7 +1347,10 @@ fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = main_language(metadata).to_string(); let main = main_language(metadata).to_string();
let mut languages = vec![main.clone()]; let mut languages = vec![main.clone()];
for language in &metadata.blog_languages { for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) { if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone()); languages.push(language.clone());
} }
} }
@@ -1213,18 +1384,31 @@ fn normalize_partial_slug(slug: &str) -> String {
fn starter_partials() -> HashMap<String, String> { fn starter_partials() -> HashMap<String, String> {
HashMap::from([ HashMap::from([
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()), (
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()), "partials/head".to_string(),
("partials/language-switcher".to_string(), STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string()), STARTER_HEAD_PARTIAL.to_string(),
),
(
"partials/menu".to_string(),
STARTER_MENU_PARTIAL.to_string(),
),
(
"partials/language-switcher".to_string(),
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
),
( (
"partials/menu-items".to_string(), "partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(), "{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
), ),
]) ])
} }
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> { fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
metadata.pico_theme.as_ref().map(|_| "/assets/pico.min.css".to_string()) metadata
.pico_theme
.as_ref()
.map(|_| "/assets/pico.min.css".to_string())
} }
fn calendar_initial_parts(post: &Post) -> (i32, u32) { fn calendar_initial_parts(post: &Post) -> (i32, u32) {
@@ -1247,4 +1431,4 @@ fn queries_category_settings(
data_dir: &Path, data_dir: &Path,
) -> Result<HashMap<String, CategorySettings>, Box<dyn Error + Send + Sync>> { ) -> Result<HashMap<String, CategorySettings>, Box<dyn Error + Send + Sync>> {
Ok(crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()) Ok(crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default())
} }
+13 -13
View File
@@ -21,13 +21,17 @@ pub enum TemplateLookupError {
MissingDefaultTemplate, MissingDefaultTemplate,
} }
pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a Template, TemplateLookupError> { pub fn resolve_post_template<'a>(
lookup: RenderTemplateLookup<'a>,
) -> Result<&'a Template, TemplateLookupError> {
if let Some(explicit_slug) = lookup.post.template_slug.as_deref() { if let Some(explicit_slug) = lookup.post.template_slug.as_deref() {
return lookup return lookup
.templates .templates
.iter() .iter()
.find(|template| is_enabled_post_template(template, explicit_slug)) .find(|template| is_enabled_post_template(template, explicit_slug))
.ok_or_else(|| TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string())); .ok_or_else(|| {
TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string())
});
} }
for post_tag in &lookup.post.tags { for post_tag in &lookup.post.tags {
@@ -36,14 +40,12 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
.iter() .iter()
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag)) .find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
.and_then(|tag| tag.post_template_slug.as_deref()) .and_then(|tag| tag.post_template_slug.as_deref())
{ && let Some(template) = lookup
if let Some(template) = lookup
.templates .templates
.iter() .iter()
.find(|template| is_enabled_post_template(template, template_slug)) .find(|template| is_enabled_post_template(template, template_slug))
{ {
return Ok(template); return Ok(template);
}
} }
} }
@@ -52,14 +54,12 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
.category_settings .category_settings
.get(category_name) .get(category_name)
.and_then(|settings| settings.post_template_slug.as_deref()) .and_then(|settings| settings.post_template_slug.as_deref())
{ && let Some(template) = lookup
if let Some(template) = lookup
.templates .templates
.iter() .iter()
.find(|template| is_enabled_post_template(template, template_slug)) .find(|template| is_enabled_post_template(template, template_slug))
{ {
return Ok(template); return Ok(template);
}
} }
} }
@@ -72,4 +72,4 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
fn is_enabled_post_template(template: &Template, slug: &str) -> bool { fn is_enabled_post_template(template: &Template, slug: &str) -> bool {
template.enabled && template.kind == TemplateKind::Post && template.slug == slug template.enabled && template.kind == TemplateKind::Post && template.slug == slug
} }
+125 -44
View File
@@ -1,5 +1,5 @@
use crate::model::{Post, PostTranslation}; use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms}; use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
/// Split content at `---` delimiters into (yaml, body). /// Split content at `---` delimiters into (yaml, body).
/// Returns `None` if the content does not start with `---`. /// Returns `None` if the content does not start with `---`.
@@ -118,28 +118,28 @@ impl PostFrontmatter {
} }
// Conditional fields (only when truthy) // Conditional fields (only when truthy)
if let Some(ref excerpt) = self.excerpt { if let Some(ref excerpt) = self.excerpt
if !excerpt.is_empty() { && !excerpt.is_empty()
lines.push(format!("excerpt: {}", yaml_string_value(excerpt))); {
} lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
} }
if let Some(ref author) = self.author { if let Some(ref author) = self.author
if !author.is_empty() { && !author.is_empty()
lines.push(format!("author: {}", yaml_string_value(author))); {
} lines.push(format!("author: {}", yaml_string_value(author)));
} }
if let Some(ref language) = self.language { if let Some(ref language) = self.language
if !language.is_empty() { && !language.is_empty()
lines.push(format!("language: {language}")); {
} lines.push(format!("language: {language}"));
} }
if self.do_not_translate { if self.do_not_translate {
lines.push("doNotTranslate: true".to_string()); lines.push("doNotTranslate: true".to_string());
} }
if let Some(ref template_slug) = self.template_slug { if let Some(ref template_slug) = self.template_slug
if !template_slug.is_empty() { && !template_slug.is_empty()
lines.push(format!("templateSlug: {template_slug}")); {
} lines.push(format!("templateSlug: {template_slug}"));
} }
if let Some(published_at) = self.published_at { if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at))); lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
@@ -157,7 +157,7 @@ impl PostFrontmatter {
.ok_or("frontmatter is not a YAML mapping")?; .ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> { let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string())) map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v { .and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()), serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()), serde_yaml::Value::Number(n) => Some(n.to_string()),
@@ -167,7 +167,7 @@ impl PostFrontmatter {
}; };
let get_string_list = |key: &str| -> Vec<String> { let get_string_list = |key: &str| -> Vec<String> {
map.get(&serde_yaml::Value::String(key.to_string())) map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_sequence()) .and_then(|v| v.as_sequence())
.map(|seq| { .map(|seq| {
seq.iter() seq.iter()
@@ -198,8 +198,7 @@ impl PostFrontmatter {
do_not_translate: get_str("doNotTranslate") do_not_translate: get_str("doNotTranslate")
.map(|s| s == "true") .map(|s| s == "true")
.unwrap_or(false), .unwrap_or(false),
published_at: get_str("publishedAt") published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
.and_then(|s| iso_to_unix_ms(&s).ok()),
}) })
} }
} }
@@ -212,8 +211,7 @@ pub fn write_post_file(post: &Post, body: &str) -> String {
/// Read a complete post file, returning frontmatter + body. /// Read a complete post file, returning frontmatter + body.
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> { pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content) let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
.ok_or("no frontmatter delimiters found")?;
let fm = PostFrontmatter::from_yaml(yaml)?; let fm = PostFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string())) Ok((fm, body.to_string()))
} }
@@ -223,31 +221,61 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
/// Parsed translation frontmatter. /// Parsed translation frontmatter.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TranslationFrontmatter { pub struct TranslationFrontmatter {
pub id: Option<String>,
pub translation_for: String, pub translation_for: String,
pub language: String, pub language: String,
pub title: String, pub title: String,
pub excerpt: Option<String>, pub excerpt: Option<String>,
pub status: Option<String>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
pub published_at: Option<i64>,
} }
impl TranslationFrontmatter { impl TranslationFrontmatter {
pub fn from_translation(t: &PostTranslation) -> Self { pub fn from_translation(t: &PostTranslation) -> Self {
Self { Self {
id: Some(t.id.clone()),
translation_for: t.translation_for.clone(), translation_for: t.translation_for.clone(),
language: t.language.clone(), language: t.language.clone(),
title: t.title.clone(), title: t.title.clone(),
excerpt: t.excerpt.clone(), excerpt: t.excerpt.clone(),
status: Some(
serde_json::to_string(&t.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
),
created_at: Some(t.created_at),
updated_at: Some(t.updated_at),
published_at: t.published_at,
} }
} }
pub fn to_yaml(&self) -> String { pub fn to_yaml(&self) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
if let Some(id) = &self.id {
lines.push(format!("id: {id}"));
}
lines.push(format!("translationFor: {}", self.translation_for)); lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language)); lines.push(format!("language: {}", self.language));
lines.push(format!("title: {}", yaml_string_value(&self.title))); lines.push(format!("title: {}", yaml_string_value(&self.title)));
if let Some(ref excerpt) = self.excerpt { if let Some(ref excerpt) = self.excerpt
if !excerpt.is_empty() { && !excerpt.is_empty()
lines.push(format!("excerpt: {}", yaml_string_value(excerpt))); {
} lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
if let Some(status) = &self.status {
lines.push(format!("status: {status}"));
}
if let Some(created_at) = self.created_at {
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(created_at)));
}
if let Some(updated_at) = self.updated_at {
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(updated_at)));
}
if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
} }
lines.join("\n") lines.join("\n")
} }
@@ -260,16 +288,21 @@ impl TranslationFrontmatter {
.ok_or("frontmatter is not a YAML mapping")?; .ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> { let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string())) map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
}; };
Ok(Self { Ok(Self {
id: get_str("id"),
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?, translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
language: get_str("language").ok_or("missing 'language'")?, language: get_str("language").ok_or("missing 'language'")?,
title: get_str("title").ok_or("missing 'title'")?, title: get_str("title").ok_or("missing 'title'")?,
excerpt: get_str("excerpt").filter(|s| !s.is_empty()), excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
status: get_str("status"),
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
}) })
} }
} }
@@ -282,8 +315,7 @@ pub fn write_translation_file(translation: &PostTranslation, body: &str) -> Stri
/// Read a complete translation file. /// Read a complete translation file.
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> { pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content) let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
.ok_or("no frontmatter delimiters found")?;
let fm = TranslationFrontmatter::from_yaml(yaml)?; let fm = TranslationFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string())) Ok((fm, body.to_string()))
} }
@@ -317,8 +349,14 @@ impl TemplateFrontmatter {
lines.push(format!("kind: \"{}\"", self.kind)); lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("enabled: {}", self.enabled)); lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version)); lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at))); lines.push(format!(
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at))); "createdAt: \"{}\"",
unix_ms_to_iso(self.created_at)
));
lines.push(format!(
"updatedAt: \"{}\"",
unix_ms_to_iso(self.updated_at)
));
lines.join("\n") lines.join("\n")
} }
@@ -328,7 +366,7 @@ impl TemplateFrontmatter {
let map = doc.as_mapping().ok_or("not a YAML mapping")?; let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> { let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string())) map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v { .and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()), serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()), serde_yaml::Value::Number(n) => Some(n.to_string()),
@@ -359,8 +397,7 @@ impl TemplateFrontmatter {
/// Read a template file (frontmatter + body). /// Read a template file (frontmatter + body).
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> { pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content) let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
.ok_or("no frontmatter delimiters found")?;
let fm = TemplateFrontmatter::from_yaml(yaml)?; let fm = TemplateFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string())) Ok((fm, body.to_string()))
} }
@@ -401,8 +438,14 @@ impl ScriptFrontmatter {
lines.push(format!("entrypoint: \"{}\"", self.entrypoint)); lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
lines.push(format!("enabled: {}", self.enabled)); lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version)); lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at))); lines.push(format!(
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at))); "createdAt: \"{}\"",
unix_ms_to_iso(self.created_at)
));
lines.push(format!(
"updatedAt: \"{}\"",
unix_ms_to_iso(self.updated_at)
));
lines.join("\n") lines.join("\n")
} }
@@ -412,7 +455,7 @@ impl ScriptFrontmatter {
let map = doc.as_mapping().ok_or("not a YAML mapping")?; let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> { let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string())) map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v { .and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()), serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()), serde_yaml::Value::Number(n) => Some(n.to_string()),
@@ -450,8 +493,7 @@ pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), St
return Ok((fm, body.to_string())); return Ok((fm, body.to_string()));
} }
// Fall back to standard --- format (Lua scripts) // Fall back to standard --- format (Lua scripts)
let (yaml, body) = split_frontmatter(content) let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
.ok_or("no frontmatter delimiters found")?;
let fm = ScriptFrontmatter::from_yaml(yaml)?; let fm = ScriptFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string())) Ok((fm, body.to_string()))
} }
@@ -513,6 +555,7 @@ fn yaml_string_value(s: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::PostStatus;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -551,7 +594,10 @@ mod tests {
assert_eq!(fm.title, "Esmeralda"); assert_eq!(fm.title, "Esmeralda");
assert_eq!(fm.slug, "esmeralda"); assert_eq!(fm.slug, "esmeralda");
assert_eq!(fm.status, "published"); assert_eq!(fm.status, "published");
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]); assert_eq!(
fm.tags,
vec!["fotografie", "makro", "natur", "spinne", "tiere"]
);
assert_eq!(fm.categories, vec!["picture"]); assert_eq!(fm.categories, vec!["picture"]);
assert_eq!(fm.language.as_deref(), Some("es")); assert_eq!(fm.language.as_deref(), Some("es"));
assert_eq!(fm.published_at, Some(1131883200000)); assert_eq!(fm.published_at, Some(1131883200000));
@@ -655,7 +701,36 @@ mod tests {
let yaml = fm.to_yaml(); let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body); let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md"); assert_eq!(
actual, expected,
"golden output mismatch for esmeralda.en.md"
);
}
#[test]
fn current_translation_output_carries_full_metadata() {
let translation = PostTranslation {
id: "translation-1".into(),
project_id: "project-1".into(),
translation_for: "post-1".into(),
language: "de".into(),
title: "Titel".into(),
excerpt: None,
content: None,
status: PostStatus::Published,
file_path: "posts/2026/07/post.de.md".into(),
checksum: None,
created_at: 1_751_328_000_000,
updated_at: 1_751_414_400_000,
published_at: Some(1_751_414_400_000),
};
let output = write_translation_file(&translation, "Inhalt");
assert!(output.contains("id: translation-1"));
assert!(output.contains("status: published"));
assert!(output.contains("createdAt:"));
assert!(output.contains("updatedAt:"));
assert!(output.contains("publishedAt:"));
} }
#[test] #[test]
@@ -723,7 +798,10 @@ mod tests {
let content = fs::read_to_string(path).unwrap(); let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_template_file(&content).unwrap(); let (fm, body) = read_template_file(&content).unwrap();
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6"); 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.project_id.as_deref(),
Some("1979237c-034d-41f6-99a0-f35eb57b3f6c")
);
assert_eq!(fm.slug, "testvorlage"); assert_eq!(fm.slug, "testvorlage");
assert_eq!(fm.title, "Testvorlage"); assert_eq!(fm.title, "Testvorlage");
assert_eq!(fm.kind, "post"); assert_eq!(fm.kind, "post");
@@ -738,7 +816,10 @@ mod tests {
let expected = fs::read_to_string(&path).unwrap(); let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_template_file(&expected).unwrap(); let (fm, body) = read_template_file(&expected).unwrap();
let actual = write_template_file(&fm, &body); let actual = write_template_file(&fm, &body);
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid"); assert_eq!(
actual, expected,
"golden output mismatch for testvorlage.liquid"
);
} }
// --- Script frontmatter tests --- // --- Script frontmatter tests ---
+10 -7
View File
@@ -1,14 +1,17 @@
mod slug;
mod checksum;
pub mod timestamp;
pub mod atomic_write; pub mod atomic_write;
pub mod paths; mod checksum;
pub mod frontmatter; pub mod frontmatter;
pub mod paths;
pub mod sidecar; pub mod sidecar;
mod slug;
pub mod thumbnail; pub mod thumbnail;
pub mod timestamp;
pub use slug::{slugify, ensure_unique};
pub use checksum::{content_hash, file_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 atomic_write::{atomic_write, atomic_write_str};
pub use checksum::{content_hash, file_hash};
pub use paths::*; pub use paths::*;
pub use slug::{ensure_unique, slugify};
pub use timestamp::{
calendar_range_unix_ms, iso_to_unix_ms, now_unix_ms, unix_ms_to_iso,
year_month_day_from_unix_ms, year_month_from_unix_ms,
};
+9 -2
View File
@@ -8,7 +8,11 @@ pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical /// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
/// post's `created_at`. /// post's `created_at`.
pub fn translation_file_path(canonical_created_at_ms: i64, canonical_slug: &str, language: &str) -> String { 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); let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
format!("posts/{y}/{m}/{canonical_slug}.{language}.md") format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
} }
@@ -101,7 +105,10 @@ mod tests {
#[test] #[test]
fn template_and_script_paths() { fn template_and_script_paths() {
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid"); assert_eq!(
template_file_path("testvorlage"),
"templates/testvorlage.liquid"
);
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua"); assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
} }
} }
+77 -46
View File
@@ -1,5 +1,7 @@
use std::fmt::{self, Display};
use crate::model::Media; use crate::model::Media;
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms}; use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
/// Parsed media sidecar fields. /// Parsed media sidecar fields.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -53,11 +55,14 @@ impl MediaSidecar {
} }
/// Serialize to the hand-built YAML-like format matching TypeScript output. /// Serialize to the hand-built YAML-like format matching TypeScript output.
pub fn to_string(&self) -> String { fn serialize(&self) -> String {
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
lines.push("---".into()); lines.push("---".into());
lines.push(format!("id: {}", self.id)); lines.push(format!("id: {}", self.id));
lines.push(format!("originalName: \"{}\"", escape_double_quotes(&self.original_name))); lines.push(format!(
"originalName: \"{}\"",
escape_double_quotes(&self.original_name)
));
lines.push(format!("mimeType: {}", self.mime_type)); lines.push(format!("mimeType: {}", self.mime_type));
lines.push(format!("size: {}", self.size)); lines.push(format!("size: {}", self.size));
if let Some(w) = self.width { if let Some(w) = self.width {
@@ -66,30 +71,30 @@ impl MediaSidecar {
if let Some(h) = self.height { if let Some(h) = self.height {
lines.push(format!("height: {h}")); lines.push(format!("height: {h}"));
} }
if let Some(ref title) = self.title { if let Some(ref title) = self.title
if !title.is_empty() { && !title.is_empty()
lines.push(format!("title: \"{}\"", escape_double_quotes(title))); {
} lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
} }
if let Some(ref alt) = self.alt { if let Some(ref alt) = self.alt
if !alt.is_empty() { && !alt.is_empty()
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt))); {
} lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
} }
if let Some(ref caption) = self.caption { if let Some(ref caption) = self.caption
if !caption.is_empty() { && !caption.is_empty()
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption))); {
} lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
} }
if let Some(ref author) = self.author { if let Some(ref author) = self.author
if !author.is_empty() { && !author.is_empty()
lines.push(format!("author: \"{}\"", escape_double_quotes(author))); {
} lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
} }
if let Some(ref language) = self.language { if let Some(ref language) = self.language
if !language.is_empty() { && !language.is_empty()
lines.push(format!("language: {language}")); {
} lines.push(format!("language: {language}"));
} }
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at))); lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at))); lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
@@ -98,13 +103,21 @@ impl MediaSidecar {
if self.tags.is_empty() { if self.tags.is_empty() {
lines.push("tags: []".into()); lines.push("tags: []".into());
} else { } else {
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect(); let quoted: Vec<String> = self
.tags
.iter()
.map(|t| format!("\"{}\"", escape_double_quotes(t)))
.collect();
lines.push(format!("tags: [{}]", quoted.join(", "))); lines.push(format!("tags: [{}]", quoted.join(", ")));
} }
// linkedPostIds: only if non-empty // linkedPostIds: only if non-empty
if !self.linked_post_ids.is_empty() { if !self.linked_post_ids.is_empty() {
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect(); let quoted: Vec<String> = self
.linked_post_ids
.iter()
.map(|id| format!("\"{id}\""))
.collect();
lines.push(format!("linkedPostIds: [{}]", quoted.join(", "))); lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
} }
@@ -113,33 +126,45 @@ impl MediaSidecar {
} }
} }
impl Display for MediaSidecar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.serialize())
}
}
impl MediaTranslationSidecar { impl MediaTranslationSidecar {
/// Serialize to the hand-built format. /// Serialize to the hand-built format.
pub fn to_string(&self) -> String { fn serialize(&self) -> String {
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
lines.push("---".into()); lines.push("---".into());
lines.push(format!("translationFor: {}", self.translation_for)); lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language)); lines.push(format!("language: {}", self.language));
if let Some(ref title) = self.title { if let Some(ref title) = self.title
if !title.is_empty() { && !title.is_empty()
lines.push(format!("title: \"{}\"", escape_double_quotes(title))); {
} lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
} }
if let Some(ref alt) = self.alt { if let Some(ref alt) = self.alt
if !alt.is_empty() { && !alt.is_empty()
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt))); {
} lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
} }
if let Some(ref caption) = self.caption { if let Some(ref caption) = self.caption
if !caption.is_empty() { && !caption.is_empty()
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption))); {
} lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
} }
lines.push("---".into()); lines.push("---".into());
lines.join("\n") lines.join("\n")
} }
} }
impl Display for MediaTranslationSidecar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.serialize())
}
}
/// Parse a canonical media sidecar. /// Parse a canonical media sidecar.
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> { pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
// Strip --- delimiters // Strip --- delimiters
@@ -301,7 +326,8 @@ mod tests {
#[test] #[test]
fn parse_fixture_sidecar() { fn parse_fixture_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta"); let path =
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let content = fs::read_to_string(path).unwrap(); let content = fs::read_to_string(path).unwrap();
let sc = read_sidecar(&content).unwrap(); let sc = read_sidecar(&content).unwrap();
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240"); assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
@@ -314,17 +340,25 @@ mod tests {
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau")); assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst")); assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
assert!(sc.tags.is_empty()); assert!(sc.tags.is_empty());
assert_eq!(sc.linked_post_ids, vec!["40a83ab1-423d-4310-aac4-642d84675007"]); assert_eq!(
sc.linked_post_ids,
vec!["40a83ab1-423d-4310-aac4-642d84675007"]
);
} }
#[test] #[test]
fn golden_output_sidecar() { fn golden_output_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta"); let path =
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap(); let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap(); let sc = read_sidecar(&expected).unwrap();
let actual = sc.to_string(); let actual = sc.to_string();
// Compare trimmed (fixture may or may not have trailing newline) // Compare trimmed (fixture may or may not have trailing newline)
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar"); assert_eq!(
actual.trim(),
expected.trim(),
"golden output mismatch for media sidecar"
);
} }
#[test] #[test]
@@ -378,10 +412,7 @@ mod tests {
#[test] #[test]
fn inline_json_array_parsing() { fn inline_json_array_parsing() {
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new()); assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
assert_eq!( assert_eq!(parse_inline_json_array("[\"a\", \"b\"]"), vec!["a", "b"]);
parse_inline_json_array("[\"a\", \"b\"]"),
vec!["a", "b"]
);
} }
#[test] #[test]
+15 -20
View File
@@ -1,5 +1,4 @@
use deunicode::deunicode; use deunicode::deunicode;
use std::time::{SystemTime, UNIX_EPOCH};
/// Pre-process German characters to match TypeScript `transliteration` npm output. /// Pre-process German characters to match TypeScript `transliteration` npm output.
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue. /// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
@@ -50,7 +49,7 @@ pub fn slugify(input: &str) -> String {
} }
/// Ensure a slug is unique within a project, using the spec's algorithm: /// Ensure a slug is unique within a project, using the spec's algorithm:
/// tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}. /// tries base, then unbounded numeric suffixes `{slug}-2`, `{slug}-3`, and so on.
/// ///
/// `exists` is a predicate that returns true if the candidate slug is already taken. /// `exists` is a predicate that returns true if the candidate slug is already taken.
pub fn ensure_unique<F>(base: &str, exists: F) -> String pub fn ensure_unique<F>(base: &str, exists: F) -> String
@@ -60,17 +59,13 @@ where
if !exists(base) { if !exists(base) {
return base.to_string(); return base.to_string();
} }
for n in 2..=999 { for n in 2_u64.. {
let candidate = format!("{base}-{n}"); let candidate = format!("{base}-{n}");
if !exists(&candidate) { if !exists(&candidate) {
return candidate; return candidate;
} }
} }
let ts = SystemTime::now() unreachable!()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{base}-{ts}")
} }
#[cfg(test)] #[cfg(test)]
@@ -121,7 +116,9 @@ mod tests {
#[test] #[test]
fn ensure_unique_sequential_taken() { fn ensure_unique_sequential_taken() {
let slug = ensure_unique("hello", |s| s == "hello" || s == "hello-2" || s == "hello-3"); let slug = ensure_unique("hello", |s| {
s == "hello" || s == "hello-2" || s == "hello-3"
});
assert_eq!(slug, "hello-4"); assert_eq!(slug, "hello-4");
} }
@@ -178,20 +175,18 @@ mod tests {
} }
#[test] #[test]
fn ensure_unique_all_999_taken() { fn ensure_unique_continues_after_999() {
let slug = ensure_unique("x", |s| { let slug = ensure_unique("x", |s| {
if s == "x" { return true; } if s == "x" {
if let Some(suffix) = s.strip_prefix("x-") { return true;
if let Ok(n) = suffix.parse::<u32>() { }
return n <= 999; if let Some(suffix) = s.strip_prefix("x-")
} && let Ok(n) = suffix.parse::<u32>()
{
return n <= 999;
} }
false false
}); });
// Should fall back to timestamp-based slug assert_eq!(slug, "x-1000");
assert!(slug.starts_with("x-"));
let suffix = slug.strip_prefix("x-").unwrap();
let ts: u64 = suffix.parse().expect("should be a timestamp");
assert!(ts > 1_000_000_000);
} }
} }
+67 -25
View File
@@ -32,10 +32,34 @@ pub enum ThumbnailFit {
/// Standard thumbnail sizes matching spec: small/medium/large are /// Standard thumbnail sizes matching spec: small/medium/large are
/// width-constrained aspect-preserving; AI is letterboxed on black. /// width-constrained aspect-preserving; AI is letterboxed on black.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[ pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, ThumbnailSize {
ThumbnailSize { name: "medium", width: 400, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, name: "small",
ThumbnailSize { name: "large", width: 800, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, width: 150,
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain }, height: u32::MAX,
format: ThumbnailFormat::Webp,
fit: ThumbnailFit::Inside,
},
ThumbnailSize {
name: "medium",
width: 400,
height: u32::MAX,
format: ThumbnailFormat::Webp,
fit: ThumbnailFit::Inside,
},
ThumbnailSize {
name: "large",
width: 800,
height: u32::MAX,
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. /// Generate a single thumbnail from a source image file.
@@ -57,9 +81,7 @@ pub fn generate_thumbnail(
img.resize(size.width, size.height, FilterType::Lanczos3) img.resize(size.width, size.height, FilterType::Lanczos3)
} }
} }
ThumbnailFit::Cover => { ThumbnailFit::Cover => img.resize_to_fill(size.width, size.height, FilterType::Lanczos3),
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
}
ThumbnailFit::Contain => { ThumbnailFit::Contain => {
// Resize to fit, then place on black background // Resize to fit, then place on black background
let resized = img.resize(size.width, size.height, FilterType::Lanczos3); let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
@@ -118,7 +140,11 @@ pub fn generate_all_thumbnails(
let dest = thumbnails_dir let dest = thumbnails_dir
.join(prefix) .join(prefix)
.join(format!("{media_id}-{}.{ext}", size.name)); .join(format!("{media_id}-{}.{ext}", size.name));
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 }; let quality = if size.format == ThumbnailFormat::Jpeg {
85
} else {
80
};
generate_thumbnail(source, &dest, size, quality)?; generate_thumbnail(source, &dest, size, quality)?;
paths.push(dest.to_string_lossy().to_string()); paths.push(dest.to_string_lossy().to_string());
} }
@@ -136,10 +162,10 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?; let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
// Try to read EXIF orientation from JPEG files // Try to read EXIF orientation from JPEG files
if let Ok(data) = fs::read(path) { if let Ok(data) = fs::read(path)
if let Some(orientation) = read_exif_orientation(&data) { && let Some(orientation) = read_exif_orientation(&data)
img = apply_orientation(img, orientation); {
} img = apply_orientation(img, orientation);
} }
Ok(img) Ok(img)
@@ -180,7 +206,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
} }
let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I'; let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I';
let read_u16 = |offset: usize| -> Option<u16> { let read_u16 = |offset: usize| -> Option<u16> {
if offset + 2 > data.len() { return None; } if offset + 2 > data.len() {
return None;
}
if is_le { if is_le {
Some(u16::from_le_bytes([data[offset], data[offset + 1]])) Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
} else { } else {
@@ -188,11 +216,23 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
} }
}; };
let read_u32 = |offset: usize| -> Option<u32> { let read_u32 = |offset: usize| -> Option<u32> {
if offset + 4 > data.len() { return None; } if offset + 4 > data.len() {
return None;
}
if is_le { if is_le {
Some(u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]])) Some(u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
} else { } else {
Some(u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]])) Some(u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
} }
}; };
@@ -202,7 +242,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
for i in 0..entry_count { for i in 0..entry_count {
let entry_pos = ifd_pos + 2 + i * 12; let entry_pos = ifd_pos + 2 + i * 12;
if entry_pos + 12 > data.len() { break; } if entry_pos + 12 > data.len() {
break;
}
let tag = read_u16(entry_pos)?; let tag = read_u16(entry_pos)?;
if tag == 0x0112 { if tag == 0x0112 {
// Orientation tag // Orientation tag
@@ -214,14 +256,14 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage { fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage {
match orientation { match orientation {
1 => img, // Normal 1 => img, // Normal
2 => img.fliph(), // Mirrored horizontal 2 => img.fliph(), // Mirrored horizontal
3 => img.rotate180(), // Rotated 180 3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Mirrored vertical 4 => img.flipv(), // Mirrored vertical
5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW 5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW
6 => img.rotate90(), // Rotated 90 CW 6 => img.rotate90(), // Rotated 90 CW
7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW 7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW
8 => img.rotate270(), // Rotated 270 CW 8 => img.rotate270(), // Rotated 270 CW
_ => img, _ => img,
} }
} }
+31 -1
View File
@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc, TimeZone}; use chrono::{DateTime, NaiveDate, TimeZone, Utc};
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`). /// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
pub fn unix_ms_to_iso(ms: i64) -> String { pub fn unix_ms_to_iso(ms: i64) -> String {
@@ -35,6 +35,24 @@ pub fn now_unix_ms() -> i64 {
Utc::now().timestamp_millis() Utc::now().timestamp_millis()
} }
/// Return the inclusive start and exclusive end of a calendar year or month.
pub fn calendar_range_unix_ms(year: i32, month: Option<u32>) -> Option<(i64, i64)> {
let start_month = month.unwrap_or(1);
let (end_year, end_month) = match month {
Some(12) | None => (year.checked_add(1)?, 1),
Some(month) => (year, month.checked_add(1)?),
};
let start = NaiveDate::from_ymd_opt(year, start_month, 1)?
.and_hms_opt(0, 0, 0)?
.and_utc()
.timestamp_millis();
let end = NaiveDate::from_ymd_opt(end_year, end_month, 1)?
.and_hms_opt(0, 0, 0)?
.and_utc()
.timestamp_millis();
Some((start, end))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -83,4 +101,16 @@ mod tests {
fn invalid_iso_returns_error() { fn invalid_iso_returns_error() {
assert!(iso_to_unix_ms("not a date").is_err()); assert!(iso_to_unix_ms("not a date").is_err());
} }
#[test]
fn calendar_range_rejects_invalid_month() {
assert_eq!(calendar_range_unix_ms(2026, Some(13)), None);
}
#[test]
fn calendar_range_covers_requested_month() {
let (start, end) = calendar_range_unix_ms(2026, Some(7)).unwrap();
assert_eq!(unix_ms_to_iso(start), "2026-07-01T00:00:00.000Z");
assert_eq!(unix_ms_to_iso(end), "2026-08-01T00:00:00.000Z");
}
} }
+86 -19
View File
@@ -25,7 +25,15 @@ fn read_project() {
.query_row( .query_row(
"SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1", "SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1",
[PROJECT_ID], [PROJECT_ID],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
) )
.unwrap(); .unwrap();
@@ -52,7 +60,10 @@ fn read_published_post_has_null_content() {
assert_eq!(title, "Esmeralda"); assert_eq!(title, "Esmeralda");
assert_eq!(slug, "esmeralda"); assert_eq!(slug, "esmeralda");
assert_eq!(status, "published"); assert_eq!(status, "published");
assert!(content.is_none(), "published posts must have NULL content in DB"); assert!(
content.is_none(),
"published posts must have NULL content in DB"
);
} }
#[test] #[test]
@@ -96,8 +107,14 @@ fn published_posts_have_file_paths() {
assert_eq!(rows.len(), 3); assert_eq!(rows.len(), 3);
for (slug, path) in &rows { for (slug, path) in &rows {
assert!(!path.is_empty(), "published post '{slug}' must have a file_path"); assert!(
assert!(path.ends_with(&format!("{slug}.md")), "file_path must end with {slug}.md"); !path.is_empty(),
"published post '{slug}' must have a file_path"
);
assert!(
path.ends_with(&format!("{slug}.md")),
"file_path must end with {slug}.md"
);
} }
} }
@@ -131,8 +148,14 @@ fn post_timestamps_are_unix_integers() {
.unwrap(); .unwrap();
// Sanity: timestamps should be in reasonable Unix range (year 2000+) // Sanity: timestamps should be in reasonable Unix range (year 2000+)
assert!(created_at > 946_684_800, "created_at should be after year 2000"); assert!(
assert!(updated_at > 946_684_800, "updated_at should be after year 2000"); created_at > 946_684_800,
"created_at should be after year 2000"
);
assert!(
updated_at > 946_684_800,
"updated_at should be after year 2000"
);
} }
#[test] #[test]
@@ -147,7 +170,10 @@ fn post_unique_constraint_on_project_slug() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(dupes.is_empty(), "found duplicate (project_id, slug) pairs: {dupes:?}"); assert!(
dupes.is_empty(),
"found duplicate (project_id, slug) pairs: {dupes:?}"
);
} }
// ── Post Translations ─────────────────────────────────────────────── // ── Post Translations ───────────────────────────────────────────────
@@ -156,7 +182,9 @@ fn post_unique_constraint_on_project_slug() {
fn read_post_translations() { fn read_post_translations() {
let conn = fixture_db(); let conn = fixture_db();
let count: i64 = conn let count: i64 = conn
.query_row("SELECT COUNT(*) FROM post_translations", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM post_translations", [], |row| {
row.get(0)
})
.unwrap(); .unwrap();
assert_eq!(count, 4); assert_eq!(count, 4);
} }
@@ -177,7 +205,10 @@ fn translation_references_valid_post() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(orphans.is_empty(), "orphan translations referencing missing posts: {orphans:?}"); assert!(
orphans.is_empty(),
"orphan translations referencing missing posts: {orphans:?}"
);
} }
#[test] #[test]
@@ -193,7 +224,10 @@ fn published_translations_have_null_content() {
.collect(); .collect();
for (id, content) in &rows { for (id, content) in &rows {
assert!(content.is_none(), "published translation {id} must have NULL content"); assert!(
content.is_none(),
"published translation {id} must have NULL content"
);
} }
} }
@@ -293,7 +327,16 @@ fn tag_names_are_expected() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert_eq!(names, vec!["fotografie", "mac-os-x", "natur", "programmierung", "sysadmin"]); assert_eq!(
names,
vec![
"fotografie",
"mac-os-x",
"natur",
"programmierung",
"sysadmin"
]
);
} }
#[test] #[test]
@@ -308,7 +351,10 @@ fn tag_unique_constraint_on_project_name() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(dupes.is_empty(), "found duplicate (project_id, name) tag pairs: {dupes:?}"); assert!(
dupes.is_empty(),
"found duplicate (project_id, name) tag pairs: {dupes:?}"
);
} }
// ── Templates ─────────────────────────────────────────────────────── // ── Templates ───────────────────────────────────────────────────────
@@ -345,7 +391,10 @@ fn read_template() {
assert_eq!(kind, "post"); assert_eq!(kind, "post");
assert!(enabled); assert!(enabled);
assert_eq!(status, "published"); assert_eq!(status, "published");
assert!(content.is_none(), "published template content should be NULL in DB"); assert!(
content.is_none(),
"published template content should be NULL in DB"
);
} }
// ── Scripts ───────────────────────────────────────────────────────── // ── Scripts ─────────────────────────────────────────────────────────
@@ -420,7 +469,10 @@ fn settings_are_key_value_pairs() {
assert!(!value.is_empty()); assert!(!value.is_empty());
} }
// All setting keys should contain the project ID (namespaced) // All setting keys should contain the project ID (namespaced)
let project_keys: Vec<_> = pairs.iter().filter(|(k, _)| k.contains(PROJECT_ID)).collect(); let project_keys: Vec<_> = pairs
.iter()
.filter(|(k, _)| k.contains(PROJECT_ID))
.collect();
assert_eq!(project_keys.len(), 5); assert_eq!(project_keys.len(), 5);
} }
@@ -479,7 +531,10 @@ fn all_posts_belong_to_existing_project() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(orphans.is_empty(), "posts referencing missing projects: {orphans:?}"); assert!(
orphans.is_empty(),
"posts referencing missing projects: {orphans:?}"
);
} }
#[test] #[test]
@@ -498,7 +553,10 @@ fn all_tags_belong_to_existing_project() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(orphans.is_empty(), "tags referencing missing projects: {orphans:?}"); assert!(
orphans.is_empty(),
"tags referencing missing projects: {orphans:?}"
);
} }
#[test] #[test]
@@ -517,7 +575,10 @@ fn all_media_belong_to_existing_project() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(orphans.is_empty(), "media referencing missing projects: {orphans:?}"); assert!(
orphans.is_empty(),
"media referencing missing projects: {orphans:?}"
);
} }
#[test] #[test]
@@ -537,7 +598,10 @@ fn post_links_reference_valid_posts() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(orphans.is_empty(), "post_links with invalid references: {orphans:?}"); assert!(
orphans.is_empty(),
"post_links with invalid references: {orphans:?}"
);
} }
#[test] #[test]
@@ -557,5 +621,8 @@ fn post_media_references_valid_entities() {
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(orphans.is_empty(), "post_media with invalid references: {orphans:?}"); assert!(
orphans.is_empty(),
"post_media with invalid references: {orphans:?}"
);
} }
+35 -63
View File
@@ -4,23 +4,22 @@
//! - Step 20: Golden-file comparisons against fixture files //! - Step 20: Golden-file comparisons against fixture files
//! - Step 21: Metadata diff coverage matrix (every diffable field covered) //! - Step 21: Metadata diff coverage matrix (every diffable field covered)
use bds_core::db::Database;
use bds_core::db::fts::ensure_fts_tables; use bds_core::db::fts::ensure_fts_tables;
use bds_core::db::queries::project::insert_project; use bds_core::db::queries::project::insert_project;
use bds_core::db::queries::script as qs; use bds_core::db::queries::script as qs;
use bds_core::db::queries::template as qtpl; use bds_core::db::queries::template as qtpl;
use bds_core::db::Database;
use bds_core::engine::media; use bds_core::engine::media;
use bds_core::engine::meta; use bds_core::engine::meta;
use bds_core::engine::metadata_diff; use bds_core::engine::metadata_diff;
use bds_core::engine::post; use bds_core::engine::post;
use bds_core::engine::tag; use bds_core::engine::tag;
use bds_core::model::{ use bds_core::model::{
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus,
TemplateStatus,
}; };
use bds_core::util::frontmatter::{ use bds_core::util::frontmatter::{
read_post_file, read_template_file, read_translation_file, ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_template_file,
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter, read_translation_file, write_script_file, write_template_file,
}; };
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar}; use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
use image::DynamicImage; use image::DynamicImage;
@@ -325,10 +324,7 @@ fn test_golden_post_esmeralda() {
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body); let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
// Compare byte-for-byte with fixture // Compare byte-for-byte with fixture
assert_eq!( assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
actual, expected,
"golden output mismatch for esmeralda.md"
);
} }
#[test] #[test]
@@ -353,8 +349,7 @@ fn test_golden_translation_esmeralda_en() {
#[test] #[test]
fn test_golden_sidecar() { fn test_golden_sidecar() {
let path = let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap(); let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap(); let sc = read_sidecar(&expected).unwrap();
@@ -401,7 +396,14 @@ fn test_golden_meta_files() {
assert_eq!( assert_eq!(
categories, categories,
vec![ vec![
"article", "aside", "kochbuch", "metaowl", "page", "picture", "spielelog", "wiki" "article",
"aside",
"kochbuch",
"metaowl",
"page",
"picture",
"spielelog",
"wiki"
] ]
); );
// Verify they are sorted // Verify they are sorted
@@ -416,7 +418,7 @@ fn test_golden_meta_files() {
// Verify sorted by name // Verify sorted by name
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
let mut sorted_names = names.clone(); let mut sorted_names = names.clone();
sorted_names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); sorted_names.sort_by_key(|a| a.to_lowercase());
assert_eq!(names, sorted_names, "tags should be sorted by name"); assert_eq!(names, sorted_names, "tags should be sorted by name");
// Spot-check a known tag // Spot-check a known tag
assert!(tags.iter().any(|t| t.name == "rust")); assert!(tags.iter().any(|t| t.name == "rust"));
@@ -457,9 +459,7 @@ fn test_golden_template() {
/// Helper: create a post, publish it, then modify a specific field in the /// Helper: create a post, publish it, then modify a specific field in the
/// file on disk. Run the diff and return all detected field names. /// file on disk. Run the diff and return all detected field names.
fn post_diff_for_field( fn post_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
let (db, dir) = setup(); let (db, dir) = setup();
let created = post::create_post( let created = post::create_post(
@@ -519,9 +519,8 @@ fn test_diff_detects_post_slug() {
#[test] #[test]
fn test_diff_detects_post_status() { fn test_diff_detects_post_status() {
let fields = post_diff_for_field(|content| { let fields =
content.replace("status: published", "status: draft") post_diff_for_field(|content| content.replace("status: published", "status: draft"));
});
assert!( assert!(
fields.contains(&"status".to_string()), fields.contains(&"status".to_string()),
"expected 'status' in diff fields, got: {fields:?}" "expected 'status' in diff fields, got: {fields:?}"
@@ -530,9 +529,7 @@ fn test_diff_detects_post_status() {
#[test] #[test]
fn test_diff_detects_post_tags() { fn test_diff_detects_post_tags() {
let fields = post_diff_for_field(|content| { let fields = post_diff_for_field(|content| content.replace(" - tag1", " - changed-tag"));
content.replace(" - tag1", " - changed-tag")
});
assert!( assert!(
fields.contains(&"tags".to_string()), fields.contains(&"tags".to_string()),
"expected 'tags' in diff fields, got: {fields:?}" "expected 'tags' in diff fields, got: {fields:?}"
@@ -541,9 +538,7 @@ fn test_diff_detects_post_tags() {
#[test] #[test]
fn test_diff_detects_post_categories() { fn test_diff_detects_post_categories() {
let fields = post_diff_for_field(|content| { let fields = post_diff_for_field(|content| content.replace(" - cat1", " - changed-cat"));
content.replace(" - cat1", " - changed-cat")
});
assert!( assert!(
fields.contains(&"categories".to_string()), fields.contains(&"categories".to_string()),
"expected 'categories' in diff fields, got: {fields:?}" "expected 'categories' in diff fields, got: {fields:?}"
@@ -578,9 +573,7 @@ fn test_diff_detects_post_author() {
#[test] #[test]
fn test_diff_detects_post_language() { fn test_diff_detects_post_language() {
let fields = post_diff_for_field(|content| { let fields = post_diff_for_field(|content| content.replace("language: en", "language: de"));
content.replace("language: en", "language: de")
});
assert!( assert!(
fields.contains(&"language".to_string()), fields.contains(&"language".to_string()),
"expected 'language' in diff fields, got: {fields:?}" "expected 'language' in diff fields, got: {fields:?}"
@@ -687,9 +680,7 @@ fn test_diff_detects_post_published_at() {
/// Helper: import media, then modify a field in the sidecar file. /// Helper: import media, then modify a field in the sidecar file.
/// Returns the list of diff field names detected. /// Returns the list of diff field names detected.
fn media_diff_for_field( fn media_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
let (db, dir) = setup(); let (db, dir) = setup();
let source_path = create_test_image(dir.path()); let source_path = create_test_image(dir.path());
@@ -764,10 +755,7 @@ fn test_diff_detects_media_caption() {
#[test] #[test]
fn test_diff_detects_media_author() { fn test_diff_detects_media_author() {
let fields = media_diff_for_field(|content| { let fields = media_diff_for_field(|content| {
content.replace( content.replace("author: \"Original Author\"", "author: \"Changed Author\"")
"author: \"Original Author\"",
"author: \"Changed Author\"",
)
}); });
assert!( assert!(
fields.contains(&"author".to_string()), fields.contains(&"author".to_string()),
@@ -778,10 +766,7 @@ fn test_diff_detects_media_author() {
#[test] #[test]
fn test_diff_detects_media_tags() { fn test_diff_detects_media_tags() {
let fields = media_diff_for_field(|content| { let fields = media_diff_for_field(|content| {
content.replace( content.replace("tags: [\"original-tag\"]", "tags: [\"changed-tag\"]")
"tags: [\"original-tag\"]",
"tags: [\"changed-tag\"]",
)
}); });
assert!( assert!(
fields.contains(&"tags".to_string()), fields.contains(&"tags".to_string()),
@@ -791,9 +776,7 @@ fn test_diff_detects_media_tags() {
#[test] #[test]
fn test_diff_detects_media_language() { fn test_diff_detects_media_language() {
let fields = media_diff_for_field(|content| { let fields = media_diff_for_field(|content| content.replace("language: en", "language: de"));
content.replace("language: en", "language: de")
});
assert!( assert!(
fields.contains(&"language".to_string()), fields.contains(&"language".to_string()),
"expected 'language' in media diff fields, got: {fields:?}" "expected 'language' in media diff fields, got: {fields:?}"
@@ -804,9 +787,7 @@ fn test_diff_detects_media_language() {
/// Helper: insert a template in DB + write file, then modify a field in /// Helper: insert a template in DB + write file, then modify a field in
/// the file. Returns the list of diff field names. /// the file. Returns the list of diff field names.
fn template_diff_for_field( fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
let (db, dir) = setup(); let (db, dir) = setup();
let tpl = Template { let tpl = Template {
@@ -870,9 +851,8 @@ fn test_diff_detects_template_title() {
#[test] #[test]
fn test_diff_detects_template_kind() { fn test_diff_detects_template_kind() {
let fields = template_diff_for_field(|content| { let fields =
content.replace("kind: \"post\"", "kind: \"list\"") template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
});
assert!( assert!(
fields.contains(&"kind".to_string()), fields.contains(&"kind".to_string()),
"expected 'kind' in template diff fields, got: {fields:?}" "expected 'kind' in template diff fields, got: {fields:?}"
@@ -881,9 +861,8 @@ fn test_diff_detects_template_kind() {
#[test] #[test]
fn test_diff_detects_template_enabled() { fn test_diff_detects_template_enabled() {
let fields = template_diff_for_field(|content| { let fields =
content.replace("enabled: true", "enabled: false") template_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
});
assert!( assert!(
fields.contains(&"enabled".to_string()), fields.contains(&"enabled".to_string()),
"expected 'enabled' in template diff fields, got: {fields:?}" "expected 'enabled' in template diff fields, got: {fields:?}"
@@ -892,9 +871,7 @@ fn test_diff_detects_template_enabled() {
#[test] #[test]
fn test_diff_detects_template_version() { fn test_diff_detects_template_version() {
let fields = template_diff_for_field(|content| { let fields = template_diff_for_field(|content| content.replace("version: 1", "version: 99"));
content.replace("version: 1", "version: 99")
});
assert!( assert!(
fields.contains(&"version".to_string()), fields.contains(&"version".to_string()),
"expected 'version' in template diff fields, got: {fields:?}" "expected 'version' in template diff fields, got: {fields:?}"
@@ -905,9 +882,7 @@ fn test_diff_detects_template_version() {
/// Helper: insert a script in DB + write file, then modify a field in the /// Helper: insert a script in DB + write file, then modify a field in the
/// file. Returns the list of diff field names. /// file. Returns the list of diff field names.
fn script_diff_for_field( fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
let (db, dir) = setup(); let (db, dir) = setup();
let script = Script { let script = Script {
@@ -995,9 +970,8 @@ fn test_diff_detects_script_entrypoint() {
#[test] #[test]
fn test_diff_detects_script_enabled() { fn test_diff_detects_script_enabled() {
let fields = script_diff_for_field(|content| { let fields =
content.replace("enabled: true", "enabled: false") script_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
});
assert!( assert!(
fields.contains(&"enabled".to_string()), fields.contains(&"enabled".to_string()),
"expected 'enabled' in script diff fields, got: {fields:?}" "expected 'enabled' in script diff fields, got: {fields:?}"
@@ -1006,9 +980,7 @@ fn test_diff_detects_script_enabled() {
#[test] #[test]
fn test_diff_detects_script_version() { fn test_diff_detects_script_version() {
let fields = script_diff_for_field(|content| { let fields = script_diff_for_field(|content| content.replace("version: 1", "version: 42"));
content.replace("version: 1", "version: 42")
});
assert!( assert!(
fields.contains(&"version".to_string()), fields.contains(&"version".to_string()),
"expected 'version' in script diff fields, got: {fields:?}" "expected 'version' in script diff fields, got: {fields:?}"
+96 -23
View File
@@ -1,15 +1,18 @@
use std::collections::HashMap; use std::collections::HashMap;
use bds_core::db::Database;
use bds_core::db::queries::project::insert_project; use bds_core::db::queries::project::insert_project;
use bds_core::db::queries::template::insert_template; use bds_core::db::queries::template::insert_template;
use bds_core::db::Database;
use bds_core::engine::generation::{ use bds_core::engine::generation::{
PublishedPostSource, apply_validation_sections, generate_starter_site, PublishedPostSource, apply_validation_sections, generate_starter_site,
sections_from_validation_report, load_published_post_source, sections_from_validation_report,
}; };
use bds_core::engine::meta::write_category_meta_json; use bds_core::engine::meta::write_category_meta_json;
use bds_core::engine::validate_site::validate_site; use bds_core::engine::validate_site::validate_site;
use bds_core::model::{CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind, TemplateStatus}; use bds_core::model::{
CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind,
TemplateStatus,
};
use tempfile::TempDir; use tempfile::TempDir;
fn make_project() -> Project { fn make_project() -> Project {
@@ -95,6 +98,24 @@ fn setup() -> (Database, TempDir) {
(db, dir) (db, dir)
} }
#[test]
fn reopened_draft_generation_uses_last_published_file() {
let (_db, dir) = setup();
let mut post = make_post("reopened", 1_710_000_000_000);
post.status = PostStatus::Draft;
post.content = Some("Unpublished draft body".into());
post.file_path = "posts/2024/03/reopened.md".into();
let frontmatter = bds_core::util::frontmatter::PostFrontmatter::from_post(&post).to_yaml();
let file = bds_core::util::frontmatter::format_frontmatter(&frontmatter, "Published body");
std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap();
std::fs::write(dir.path().join(&post.file_path), file).unwrap();
let source = load_published_post_source(dir.path(), post)
.unwrap()
.unwrap();
assert_eq!(source.body_markdown, "Published body");
}
#[test] #[test]
fn generation_engine_writes_core_and_single_post_artifacts() { fn generation_engine_writes_core_and_single_post_artifacts() {
let (db, dir) = setup(); let (db, dir) = setup();
@@ -110,7 +131,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
}, },
]; ];
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); let report =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(report.written_paths.contains(&"index.html".to_string())); assert!(report.written_paths.contains(&"index.html".to_string()));
assert!(report.written_paths.contains(&"calendar.json".to_string())); assert!(report.written_paths.contains(&"calendar.json".to_string()));
@@ -118,10 +140,26 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
assert!(report.written_paths.contains(&"feed.xml".to_string())); assert!(report.written_paths.contains(&"feed.xml".to_string()));
assert!(report.written_paths.contains(&"atom.xml".to_string())); assert!(report.written_paths.contains(&"atom.xml".to_string()));
assert!(report.written_paths.contains(&"sitemap.xml".to_string())); assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
assert!(report.written_paths.contains(&"assets/pico.min.css".to_string())); assert!(
assert!(report.written_paths.contains(&"assets/tag-cloud.js".to_string())); report
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string())); .written_paths
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string())); .contains(&"assets/pico.min.css".to_string())
);
assert!(
report
.written_paths
.contains(&"assets/tag-cloud.js".to_string())
);
assert!(
report
.written_paths
.contains(&"2024/03/09/hello/index.html".to_string())
);
assert!(
report
.written_paths
.contains(&"2024/03/10/next/index.html".to_string())
);
assert!(dir.path().join("index.html").exists()); assert!(dir.path().join("index.html").exists());
assert!(dir.path().join("rss.xml").exists()); assert!(dir.path().join("rss.xml").exists());
@@ -152,13 +190,17 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
body_markdown: "Hallo Welt".into(), body_markdown: "Hallo Welt".into(),
}]; }];
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap(); let report =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
assert!(report.written_paths.contains(&"en/atom.xml".to_string())); assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string())); assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap(); let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
assert!(atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />") || atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />")); assert!(
atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />")
|| atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />")
);
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />")); assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap(); let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
@@ -223,7 +265,8 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
}, },
]; ];
let report = generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap(); let report =
generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
for asset in [ for asset in [
"images/close.png", "images/close.png",
@@ -232,17 +275,29 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
"images/prev.png", "images/prev.png",
] { ] {
assert!(report.written_paths.contains(&asset.to_string())); assert!(report.written_paths.contains(&asset.to_string()));
assert!(dir.path().join(asset).exists(), "missing bundled image {asset}"); assert!(
dir.path().join(asset).exists(),
"missing bundled image {asset}"
);
} }
assert!(!report.written_paths.contains(&"category/hidden/index.html".to_string())); assert!(
assert!(report.written_paths.contains(&"category/featured/index.html".to_string())); !report
.written_paths
.contains(&"category/hidden/index.html".to_string())
);
assert!(
report
.written_paths
.contains(&"category/featured/index.html".to_string())
);
let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap(); let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
assert!(!index_html.contains("Hidden Post")); assert!(!index_html.contains("Hidden Post"));
assert!(index_html.contains("[Featured Post|false]")); assert!(index_html.contains("[Featured Post|false]"));
let featured_html = std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap(); let featured_html =
std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
assert!(featured_html.contains("FEATURED:[Featured Post|false]")); assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap(); let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
@@ -263,15 +318,21 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
body_markdown: "Hello **world**".into(), body_markdown: "Hello **world**".into(),
}]; }];
let first = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); let first =
let second = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let second =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(!first.written_paths.is_empty()); assert!(!first.written_paths.is_empty());
assert!(second.skipped_paths.contains(&"index.html".to_string())); assert!(second.skipped_paths.contains(&"index.html".to_string()));
assert!(second.skipped_paths.contains(&"calendar.json".to_string())); assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
assert!(second.skipped_paths.contains(&"rss.xml".to_string())); assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
assert!(second.skipped_paths.contains(&"feed.xml".to_string())); assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
assert!(second.skipped_paths.contains(&"assets/pico.min.css".to_string())); assert!(
second
.skipped_paths
.contains(&"assets/pico.min.css".to_string())
);
} }
#[test] #[test]
@@ -313,7 +374,9 @@ fn apply_validation_repairs_core_section_outputs() {
let report = validate_site(db.conn(), dir.path(), "p1").unwrap(); let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&report); let sections = sections_from_validation_report(&report);
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections).unwrap(); let apply_report =
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap(); let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty()); assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
@@ -339,12 +402,22 @@ fn apply_validation_removes_extra_section_outputs() {
std::fs::write(extra_dir.join("index.html"), "ghost").unwrap(); std::fs::write(extra_dir.join("index.html"), "ghost").unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap(); let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.extra_pages.contains(&"tag/ghost/index.html".to_string())); assert!(
report
.extra_pages
.contains(&"tag/ghost/index.html".to_string())
);
let sections = sections_from_validation_report(&report); let sections = sections_from_validation_report(&report);
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections).unwrap(); let apply_report =
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap(); let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(apply_report.deleted_paths.contains(&"tag/ghost/index.html".to_string())); assert!(
apply_report
.deleted_paths
.contains(&"tag/ghost/index.html".to_string())
);
assert!(repaired.extra_pages.is_empty()); assert!(repaired.extra_pages.is_empty());
} }
@@ -368,4 +441,4 @@ fn site_validation_uses_html_output_directory_when_present() {
assert!(report.missing_pages.is_empty()); assert!(report.missing_pages.is_empty());
assert!(report.extra_pages.is_empty()); assert!(report.extra_pages.is_empty());
assert!(report.stale_pages.is_empty()); assert!(report.stale_pages.is_empty());
} }
@@ -1,12 +1,11 @@
use std::fs; use std::fs;
use bds_core::db::Database;
use bds_core::db::queries::generated_file_hash::get_generated_file_hash; use bds_core::db::queries::generated_file_hash::get_generated_file_hash;
use bds_core::db::queries::project::insert_project; use bds_core::db::queries::project::insert_project;
use bds_core::db::Database;
use bds_core::model::{Post, PostStatus, Project}; use bds_core::model::{Post, PostStatus, Project};
use bds_core::render::{ use bds_core::render::{
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
write_generated_file,
}; };
use tempfile::TempDir; use tempfile::TempDir;
@@ -72,7 +71,11 @@ fn generated_write_skips_unchanged_content() {
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap(); let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
assert_eq!(stored.relative_path, "index.html"); assert_eq!(stored.relative_path, "index.html");
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed")); assert!(
fs::read_to_string(dir.path().join("index.html"))
.unwrap()
.contains("changed")
);
} }
#[test] #[test]
@@ -85,7 +88,10 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
assert_eq!(first, GeneratedWriteOutcome::Written); assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written); assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello"); assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello"
);
} }
#[test] #[test]
@@ -98,7 +104,10 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
assert_eq!(first, GeneratedWriteOutcome::Written); assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written); assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello"); assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello"
);
} }
#[test] #[test]
@@ -131,4 +140,4 @@ fn calendar_json_groups_posts_by_year_month_day() {
assert_eq!(parsed["months"]["2024-04"], 1); assert_eq!(parsed["months"]["2024-04"], 1);
assert_eq!(parsed["days"]["2024-03-09"], 2); assert_eq!(parsed["days"]["2024-03-09"], 2);
assert_eq!(parsed["days"]["2024-04-09"], 1); assert_eq!(parsed["days"]["2024-04-09"], 1);
} }
+8 -7
View File
@@ -92,7 +92,7 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
} }
#[test] #[test]
fn markdown_filter_marks_unsupported_macros_visibly() { fn markdown_filter_leaves_unknown_macros_verbatim() {
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}"; let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
let partials = HashMap::new(); let partials = HashMap::new();
let context = serde_json::json!({ let context = serde_json::json!({
@@ -104,9 +104,8 @@ fn markdown_filter_marks_unsupported_macros_visibly() {
}); });
let rendered = render_liquid_template(template, &partials, &context).unwrap(); let rendered = render_liquid_template(template, &partials, &context).unwrap();
assert!(rendered.contains("macro-unsupported")); assert!(rendered.contains("[[custom_block foo=bar]]"));
assert!(rendered.contains("Unsupported macro")); assert!(!rendered.contains("macro-unsupported"));
assert!(rendered.contains("custom_block"));
} }
#[test] #[test]
@@ -123,11 +122,13 @@ fn starter_single_post_template_renders_with_partials() {
), ),
( (
"partials/language-switcher".to_string(), "partials/language-switcher".to_string(),
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid").to_string(), include_str!("../../../assets/starter-templates/partials/language-switcher.liquid")
.to_string(),
), ),
( (
"partials/menu-items".to_string(), "partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(), "{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
), ),
]); ]);
@@ -167,4 +168,4 @@ fn starter_single_post_template_renders_with_partials() {
assert!(rendered.contains("<strong>world</strong>")); assert!(rendered.contains("<strong>world</strong>"));
assert!(rendered.contains("href=\"/2024/03/09/hello\"")); assert!(rendered.contains("href=\"/2024/03/09/hello\""));
assert!(rendered.contains("data-pagefind-body")); assert!(rendered.contains("data-pagefind-body"));
} }
+12 -5
View File
@@ -2,8 +2,8 @@ use std::collections::HashMap;
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus}; use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
use bds_core::render::{ use bds_core::render::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, render_markdown_to_html,
render_markdown_to_html, resolve_post_template, resolve_post_template,
}; };
fn make_post() -> Post { fn make_post() -> Post {
@@ -70,7 +70,11 @@ fn template_lookup_prefers_post_specific_template() {
post.tags = vec!["rust".into()]; post.tags = vec!["rust".into()];
post.categories = vec!["article".into()]; post.categories = vec!["article".into()];
let templates = vec![make_template("post"), make_template("tag-template"), make_template("custom")]; let templates = vec![
make_template("post"),
make_template("tag-template"),
make_template("custom"),
];
let tags = vec![make_tag("rust", Some("tag-template"))]; let tags = vec![make_tag("rust", Some("tag-template"))];
let mut categories = HashMap::new(); let mut categories = HashMap::new();
categories.insert( categories.insert(
@@ -163,7 +167,10 @@ fn template_lookup_errors_when_explicit_template_missing() {
}) })
.unwrap_err(); .unwrap_err();
assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into())); assert_eq!(
err,
TemplateLookupError::MissingExplicitTemplate("missing".into())
);
} }
#[test] #[test]
@@ -171,4 +178,4 @@ fn markdown_render_produces_html() {
let html = render_markdown_to_html("# Hello\n\nA paragraph with **bold** text."); let html = render_markdown_to_html("# Hello\n\nA paragraph with **bold** text.");
assert!(html.contains("<h1>Hello</h1>")); assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<strong>bold</strong>")); assert!(html.contains("<strong>bold</strong>"));
} }
+161 -46
View File
@@ -25,9 +25,18 @@ fn post_status_serde_matches_db_values() {
// spec: status: draft | published | archived // spec: status: draft | published | archived
use bds_core::model::PostStatus; use bds_core::model::PostStatus;
assert_eq!(serde_json::to_string(&PostStatus::Draft).unwrap(), "\"draft\""); assert_eq!(
assert_eq!(serde_json::to_string(&PostStatus::Published).unwrap(), "\"published\""); serde_json::to_string(&PostStatus::Draft).unwrap(),
assert_eq!(serde_json::to_string(&PostStatus::Archived).unwrap(), "\"archived\""); "\"draft\""
);
assert_eq!(
serde_json::to_string(&PostStatus::Published).unwrap(),
"\"published\""
);
assert_eq!(
serde_json::to_string(&PostStatus::Archived).unwrap(),
"\"archived\""
);
// round-trip // round-trip
let d: PostStatus = serde_json::from_str("\"draft\"").unwrap(); let d: PostStatus = serde_json::from_str("\"draft\"").unwrap();
@@ -44,10 +53,22 @@ fn post_status_serde_matches_db_values() {
fn template_kind_serde_matches_db_values() { fn template_kind_serde_matches_db_values() {
use bds_core::model::TemplateKind; use bds_core::model::TemplateKind;
assert_eq!(serde_json::to_string(&TemplateKind::Post).unwrap(), "\"post\""); assert_eq!(
assert_eq!(serde_json::to_string(&TemplateKind::List).unwrap(), "\"list\""); serde_json::to_string(&TemplateKind::Post).unwrap(),
assert_eq!(serde_json::to_string(&TemplateKind::NotFound).unwrap(), "\"not_found\""); "\"post\""
assert_eq!(serde_json::to_string(&TemplateKind::Partial).unwrap(), "\"partial\""); );
assert_eq!(
serde_json::to_string(&TemplateKind::List).unwrap(),
"\"list\""
);
assert_eq!(
serde_json::to_string(&TemplateKind::NotFound).unwrap(),
"\"not_found\""
);
assert_eq!(
serde_json::to_string(&TemplateKind::Partial).unwrap(),
"\"partial\""
);
let nf: TemplateKind = serde_json::from_str("\"not_found\"").unwrap(); let nf: TemplateKind = serde_json::from_str("\"not_found\"").unwrap();
assert_eq!(nf, TemplateKind::NotFound); assert_eq!(nf, TemplateKind::NotFound);
@@ -59,8 +80,14 @@ fn template_kind_serde_matches_db_values() {
fn template_status_serde_matches_db_values() { fn template_status_serde_matches_db_values() {
use bds_core::model::TemplateStatus; use bds_core::model::TemplateStatus;
assert_eq!(serde_json::to_string(&TemplateStatus::Draft).unwrap(), "\"draft\""); assert_eq!(
assert_eq!(serde_json::to_string(&TemplateStatus::Published).unwrap(), "\"published\""); serde_json::to_string(&TemplateStatus::Draft).unwrap(),
"\"draft\""
);
assert_eq!(
serde_json::to_string(&TemplateStatus::Published).unwrap(),
"\"published\""
);
} }
// ── spec: schema.allium — Script kind: macro | utility | transform ── // ── spec: schema.allium — Script kind: macro | utility | transform ──
@@ -69,9 +96,18 @@ fn template_status_serde_matches_db_values() {
fn script_kind_serde_matches_db_values() { fn script_kind_serde_matches_db_values() {
use bds_core::model::ScriptKind; use bds_core::model::ScriptKind;
assert_eq!(serde_json::to_string(&ScriptKind::Macro).unwrap(), "\"macro\""); assert_eq!(
assert_eq!(serde_json::to_string(&ScriptKind::Utility).unwrap(), "\"utility\""); serde_json::to_string(&ScriptKind::Macro).unwrap(),
assert_eq!(serde_json::to_string(&ScriptKind::Transform).unwrap(), "\"transform\""); "\"macro\""
);
assert_eq!(
serde_json::to_string(&ScriptKind::Utility).unwrap(),
"\"utility\""
);
assert_eq!(
serde_json::to_string(&ScriptKind::Transform).unwrap(),
"\"transform\""
);
let t: ScriptKind = serde_json::from_str("\"transform\"").unwrap(); let t: ScriptKind = serde_json::from_str("\"transform\"").unwrap();
assert_eq!(t, ScriptKind::Transform); assert_eq!(t, ScriptKind::Transform);
@@ -83,8 +119,14 @@ fn script_kind_serde_matches_db_values() {
fn script_status_serde_matches_db_values() { fn script_status_serde_matches_db_values() {
use bds_core::model::ScriptStatus; use bds_core::model::ScriptStatus;
assert_eq!(serde_json::to_string(&ScriptStatus::Draft).unwrap(), "\"draft\""); assert_eq!(
assert_eq!(serde_json::to_string(&ScriptStatus::Published).unwrap(), "\"published\""); serde_json::to_string(&ScriptStatus::Draft).unwrap(),
"\"draft\""
);
assert_eq!(
serde_json::to_string(&ScriptStatus::Published).unwrap(),
"\"published\""
);
} }
// ── spec: post.allium — content_location invariant ── // ── spec: post.allium — content_location invariant ──
@@ -105,7 +147,10 @@ fn content_location_published_posts_null_content_in_fixture() {
assert!(!rows.is_empty()); assert!(!rows.is_empty());
for (slug, content) in &rows { for (slug, content) in &rows {
assert!(content.is_none(), "spec: published post '{slug}' must have NULL content in DB"); assert!(
content.is_none(),
"spec: published post '{slug}' must have NULL content in DB"
);
} }
} }
@@ -123,7 +168,10 @@ fn content_location_draft_posts_have_content_in_fixture() {
assert!(!rows.is_empty()); assert!(!rows.is_empty());
for (slug, content) in &rows { for (slug, content) in &rows {
assert!(content.is_some(), "spec: draft post '{slug}' must have content in DB"); assert!(
content.is_some(),
"spec: draft post '{slug}' must have content in DB"
);
} }
} }
@@ -138,7 +186,8 @@ fn post_status_transitions_all_valid() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \ "INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)", VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \ "INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)", VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
@@ -157,11 +206,15 @@ fn post_status_transitions_all_valid() {
for (from, to) in transitions { for (from, to) in transitions {
// Set to 'from' state first // Set to 'from' state first
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from]).unwrap(); conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from])
.unwrap();
// Transition to 'to' state // Transition to 'to' state
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to]).unwrap(); conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to])
.unwrap();
let status: String = conn let status: String = conn
.query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| r.get(0)) .query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| {
r.get(0)
})
.unwrap(); .unwrap();
assert_eq!(status, to, "transition {from} -> {to} failed"); assert_eq!(status, to, "transition {from} -> {to} failed");
} }
@@ -177,7 +230,8 @@ fn slug_frozen_after_publish_semantics() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \ "INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)", VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at, published_at) \ "INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at, published_at) \
VALUES ('post1', 'p1', 'Test', 'test', 'published', 'posts/2024/01/test.md', 1000, 1000, 1000)", VALUES ('post1', 'p1', 'Test', 'test', 'published', 'posts/2024/01/test.md', 1000, 1000, 1000)",
@@ -186,9 +240,16 @@ fn slug_frozen_after_publish_semantics() {
// published_at is set — slug should be considered frozen // published_at is set — slug should be considered frozen
let published_at: Option<i64> = conn let published_at: Option<i64> = conn
.query_row("SELECT published_at FROM posts WHERE id = 'post1'", [], |r| r.get(0)) .query_row(
"SELECT published_at FROM posts WHERE id = 'post1'",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert!(published_at.is_some(), "spec: is_slug_frozen = published_at != null"); assert!(
published_at.is_some(),
"spec: is_slug_frozen = published_at != null"
);
// A never-published draft has no published_at — slug is mutable // A never-published draft has no published_at — slug is mutable
conn.execute( conn.execute(
@@ -197,9 +258,16 @@ fn slug_frozen_after_publish_semantics() {
[], [],
).unwrap(); ).unwrap();
let draft_published_at: Option<i64> = conn let draft_published_at: Option<i64> = conn
.query_row("SELECT published_at FROM posts WHERE id = 'post2'", [], |r| r.get(0)) .query_row(
"SELECT published_at FROM posts WHERE id = 'post2'",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert!(draft_published_at.is_none(), "spec: unpublished draft has no published_at"); assert!(
draft_published_at.is_none(),
"spec: unpublished draft has no published_at"
);
} }
// ── spec: project.allium — SingleActiveProject invariant ── // ── spec: project.allium — SingleActiveProject invariant ──
@@ -209,7 +277,11 @@ fn slug_frozen_after_publish_semantics() {
fn single_active_project_in_fixture() { fn single_active_project_in_fixture() {
let conn = fixture_db(); let conn = fixture_db();
let active_count: i64 = conn let active_count: i64 = conn
.query_row("SELECT COUNT(*) FROM projects WHERE is_active = 1", [], |r| r.get(0)) .query_row(
"SELECT COUNT(*) FROM projects WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert_eq!(active_count, 1, "spec: exactly one project is active"); assert_eq!(active_count, 1, "spec: exactly one project is active");
} }
@@ -247,7 +319,10 @@ fn unique_translation_per_post_language_in_fixture() {
.unwrap() .unwrap()
.map(|r| r.unwrap()) .map(|r| r.unwrap())
.collect(); .collect();
assert!(dupes.is_empty(), "spec: translation unique per (post, language)"); assert!(
dupes.is_empty(),
"spec: translation unique per (post, language)"
);
} }
// ── spec: schema.allium — Post defaults ── // ── spec: schema.allium — Post defaults ──
@@ -260,13 +335,15 @@ fn post_defaults_match_spec() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \ "INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)", VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[], [],
).unwrap(); )
.unwrap();
// Insert with minimal columns to test defaults // Insert with minimal columns to test defaults
conn.execute( conn.execute(
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \ "INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \
VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)", VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (status, do_not_translate, file_path): (String, bool, String) = conn let (status, do_not_translate, file_path): (String, bool, String) = conn
.query_row( .query_row(
@@ -291,12 +368,14 @@ fn script_defaults_match_spec() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \ "INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)", VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \ "INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)", VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn
.query_row( .query_row(
@@ -322,12 +401,14 @@ fn template_defaults_match_spec() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \ "INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)", VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \ "INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \
VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)", VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
let (kind, enabled, version, status): (String, bool, i32, String) = conn let (kind, enabled, version, status): (String, bool, i32, String) = conn
.query_row( .query_row(
@@ -351,12 +432,14 @@ fn tag_unique_name_per_project_enforced() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \ "INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)", VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[], [],
).unwrap(); )
.unwrap();
conn.execute( conn.execute(
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \ "INSERT INTO tags (id, project_id, name, created_at, updated_at) \
VALUES ('t1', 'p1', 'rust', 1000, 1000)", VALUES ('t1', 'p1', 'rust', 1000, 1000)",
[], [],
).unwrap(); )
.unwrap();
// Same name, same project — must fail // Same name, same project — must fail
let result = conn.execute( let result = conn.execute(
@@ -364,7 +447,10 @@ fn tag_unique_name_per_project_enforced() {
VALUES ('t2', 'p1', 'rust', 1000, 1000)", VALUES ('t2', 'p1', 'rust', 1000, 1000)",
[], [],
); );
assert!(result.is_err(), "spec: duplicate tag name in same project must fail"); assert!(
result.is_err(),
"spec: duplicate tag name in same project must fail"
);
} }
// ── spec: post.allium — Slug generation algorithm ── // ── spec: post.allium — Slug generation algorithm ──
@@ -447,16 +533,37 @@ fn published_post_file_paths_follow_date_layout() {
#[test] #[test]
fn notification_entity_serde_matches_db_values() { fn notification_entity_serde_matches_db_values() {
use bds_core::model::{NotificationEntity, NotificationAction}; use bds_core::model::{NotificationAction, NotificationEntity};
assert_eq!(serde_json::to_string(&NotificationEntity::Post).unwrap(), "\"post\""); assert_eq!(
assert_eq!(serde_json::to_string(&NotificationEntity::Media).unwrap(), "\"media\""); serde_json::to_string(&NotificationEntity::Post).unwrap(),
assert_eq!(serde_json::to_string(&NotificationEntity::Script).unwrap(), "\"script\""); "\"post\""
assert_eq!(serde_json::to_string(&NotificationEntity::Template).unwrap(), "\"template\""); );
assert_eq!(
serde_json::to_string(&NotificationEntity::Media).unwrap(),
"\"media\""
);
assert_eq!(
serde_json::to_string(&NotificationEntity::Script).unwrap(),
"\"script\""
);
assert_eq!(
serde_json::to_string(&NotificationEntity::Template).unwrap(),
"\"template\""
);
assert_eq!(serde_json::to_string(&NotificationAction::Created).unwrap(), "\"created\""); assert_eq!(
assert_eq!(serde_json::to_string(&NotificationAction::Updated).unwrap(), "\"updated\""); serde_json::to_string(&NotificationAction::Created).unwrap(),
assert_eq!(serde_json::to_string(&NotificationAction::Deleted).unwrap(), "\"deleted\""); "\"created\""
);
assert_eq!(
serde_json::to_string(&NotificationAction::Updated).unwrap(),
"\"updated\""
);
assert_eq!(
serde_json::to_string(&NotificationAction::Deleted).unwrap(),
"\"deleted\""
);
} }
// ── spec: generation.allium / publishing.allium — SshMode values ── // ── spec: generation.allium / publishing.allium — SshMode values ──
@@ -493,7 +600,11 @@ fn fts5_tables_exist_in_fixture() {
[], [],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
); );
assert_eq!(result.unwrap(), 1, "spec: posts_fts virtual table must exist"); assert_eq!(
result.unwrap(),
1,
"spec: posts_fts virtual table must exist"
);
// media_fts // media_fts
let result = conn.query_row( let result = conn.query_row(
@@ -501,5 +612,9 @@ fn fts5_tables_exist_in_fixture() {
[], [],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
); );
assert_eq!(result.unwrap(), 1, "spec: media_fts virtual table must exist"); assert_eq!(
result.unwrap(),
1,
"spec: media_fts virtual table must exist"
);
} }
+16 -5
View File
@@ -127,7 +127,13 @@ impl EditorBuffer {
} }
/// Set selection explicitly (for double-click word select, etc). /// Set selection explicitly (for double-click word select, etc).
pub fn set_selection(&mut self, anchor_line: usize, anchor_col: usize, head_line: usize, head_col: usize) { pub fn set_selection(
&mut self,
anchor_line: usize,
anchor_col: usize,
head_line: usize,
head_col: usize,
) {
self.selection = Some(Selection { self.selection = Some(Selection {
anchor_line, anchor_line,
anchor_col, anchor_col,
@@ -176,7 +182,7 @@ impl EditorBuffer {
/// Insert text at the current cursor position. Deletes selection first if any. /// Insert text at the current cursor position. Deletes selection first if any.
pub fn insert(&mut self, text: &str) { pub fn insert(&mut self, text: &str) {
if self.selection.is_some() && self.selection.as_ref().map_or(false, |s| !s.is_empty()) { if self.selection.is_some() && self.selection.as_ref().is_some_and(|s| !s.is_empty()) {
self.delete_selection(); self.delete_selection();
} }
let char_idx = self.cursor_char_idx(); let char_idx = self.cursor_char_idx();
@@ -198,7 +204,7 @@ impl EditorBuffer {
/// Delete one character before the cursor (backspace). /// Delete one character before the cursor (backspace).
pub fn backspace(&mut self) { pub fn backspace(&mut self) {
if self.selection.as_ref().map_or(false, |s| !s.is_empty()) { if self.selection.as_ref().is_some_and(|s| !s.is_empty()) {
self.delete_selection(); self.delete_selection();
return; return;
} }
@@ -229,7 +235,7 @@ impl EditorBuffer {
/// Delete one character after the cursor (delete key). /// Delete one character after the cursor (delete key).
pub fn delete_forward(&mut self) { pub fn delete_forward(&mut self) {
if self.selection.as_ref().map_or(false, |s| !s.is_empty()) { if self.selection.as_ref().is_some_and(|s| !s.is_empty()) {
self.delete_selection(); self.delete_selection();
return; return;
} }
@@ -497,7 +503,12 @@ impl EditorBuffer {
} }
/// Ensure cursor is visible using visual line index (for word-wrap aware scrolling). /// Ensure cursor is visible using visual line index (for word-wrap aware scrolling).
pub fn ensure_visual_line_visible(&mut self, visual_line: usize, visible_lines: usize, max_visual: usize) { pub fn ensure_visual_line_visible(
&mut self,
visual_line: usize,
visible_lines: usize,
max_visual: usize,
) {
if visible_lines == 0 { if visible_lines == 0 {
return; return;
} }
+6
View File
@@ -13,6 +13,12 @@ pub struct UndoHistory {
last_group_boundary: usize, last_group_boundary: usize,
} }
impl Default for UndoHistory {
fn default() -> Self {
Self::new()
}
}
impl UndoHistory { impl UndoHistory {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
+1 -1
View File
@@ -4,5 +4,5 @@ pub mod history;
mod widget; mod widget;
pub use buffer::{EditorBuffer, Selection}; pub use buffer::{EditorBuffer, Selection};
pub use highlight::{highlighter, Highlighter}; pub use highlight::{Highlighter, highlighter};
pub use widget::{CodeEditor, EditorMessage, mono_metrics}; pub use widget::{CodeEditor, EditorMessage, mono_metrics};
+176 -100
View File
@@ -62,12 +62,11 @@ pub fn mono_metrics() -> &'static MonoMetrics {
let mut char_width = FONT_SIZE * 0.6; // fallback let mut char_width = FONT_SIZE * 0.6; // fallback
let mut line_height = (FONT_SIZE * 1.43).ceil(); // fallback let mut line_height = (FONT_SIZE * 1.43).ceil(); // fallback
for run in buffer.layout_runs() { if let Some(run) = buffer.layout_runs().next() {
if let Some(glyph) = run.glyphs.first() { if let Some(glyph) = run.glyphs.first() {
char_width = glyph.w; char_width = glyph.w;
} }
line_height = run.line_height; line_height = run.line_height;
break;
} }
MonoMetrics { MonoMetrics {
@@ -92,7 +91,7 @@ const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7);
const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9); const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9);
const MIN_THUMB_HEIGHT: f32 = 20.0; const MIN_THUMB_HEIGHT: f32 = 20.0;
fn committed_text_input<'a>(text: Option<&'a str>, is_command_shortcut: bool) -> Option<&'a str> { fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option<&str> {
if is_command_shortcut { if is_command_shortcut {
return None; return None;
} }
@@ -156,7 +155,11 @@ fn line_text_for(buf: &EditorBuffer, line_idx: usize) -> String {
buf.line(line_idx) buf.line(line_idx)
.map(|l| { .map(|l| {
let s: String = l.chars().collect(); let s: String = l.chars().collect();
if s.ends_with('\n') { s[..s.len() - 1].to_string() } else { s } if s.ends_with('\n') {
s[..s.len() - 1].to_string()
} else {
s
}
}) })
.unwrap_or_default() .unwrap_or_default()
} }
@@ -186,7 +189,11 @@ fn logical_to_visual(buf: &EditorBuffer, line: usize, col: usize, max_chars: usi
if idx == line { if idx == line {
// Find which sub-line the column falls on // Find which sub-line the column falls on
for (i, &start) in breaks.iter().enumerate() { for (i, &start) in breaks.iter().enumerate() {
let end = if i + 1 < breaks.len() { breaks[i + 1] } else { usize::MAX }; let end = if i + 1 < breaks.len() {
breaks[i + 1]
} else {
usize::MAX
};
if (col >= start && col < end) || i + 1 == breaks.len() { if (col >= start && col < end) || i + 1 == breaks.len() {
return vis + i; return vis + i;
} }
@@ -209,7 +216,11 @@ fn visual_to_logical(
if max_chars == 0 { if max_chars == 0 {
// No wrapping — direct 1:1 mapping // No wrapping — direct 1:1 mapping
let line = scroll + visual_row; let line = scroll + visual_row;
return if line < buf.line_count() { Some((line, 0)) } else { None }; return if line < buf.line_count() {
Some((line, 0))
} else {
None
};
} }
let target = scroll + visual_row; let target = scroll + visual_row;
let mut vis_count = 0usize; let mut vis_count = 0usize;
@@ -357,7 +368,11 @@ where
let line_len = text.chars().count(); let line_len = text.chars().count();
for (w, &start) in breaks.iter().enumerate() { for (w, &start) in breaks.iter().enumerate() {
let end = if w + 1 < breaks.len() { breaks[w + 1] } else { line_len }; let end = if w + 1 < breaks.len() {
breaks[w + 1]
} else {
line_len
};
if vis_skip < scroll { if vis_skip < scroll {
vis_skip += 1; vis_skip += 1;
} else if visual_rows.len() < visible_lines { } else if visual_rows.len() < visible_lines {
@@ -370,43 +385,57 @@ where
let text_x = bounds.x + GUTTER_WIDTH + 8.0; let text_x = bounds.x + GUTTER_WIDTH + 8.0;
// Render visual rows // Render visual rows
for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate() { for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate()
{
let y = bounds.y + vis_idx as f32 * metrics.line_height; let y = bounds.y + vis_idx as f32 * metrics.line_height;
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height { if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
continue; continue;
} }
// Draw selection highlight for this visual line // Draw selection highlight for this visual line
if let Some(sel) = selection { if let Some(sel) = selection
if !sel.is_empty() { && !sel.is_empty()
let (start, end) = sel.ordered(); {
let line_len = buf let (start, end) = sel.ordered();
.line(line_idx) let line_len = buf
.map(|l| { .line(line_idx)
let len = l.len_chars(); .map(|l| {
if len > 0 && l.char(len - 1) == '\n' { len - 1 } else { len } let len = l.len_chars();
}) if len > 0 && l.char(len - 1) == '\n' {
.unwrap_or(0); len - 1
} else {
if line_idx >= start.0 && line_idx <= end.0 { len
let sel_start_col = if line_idx == start.0 { start.1 } else { 0 };
let sel_end_col = if line_idx == end.0 { end.1 } else { line_len + 1 };
// Clip selection to current visual line range
let s = sel_start_col.max(char_start);
let e = sel_end_col.min(char_end);
if s < e {
let sel_x = text_x + (s - char_start) as f32 * metrics.char_width;
let sel_w = (e - s) as f32 * metrics.char_width;
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle { x: sel_x, y, width: sel_w, height: metrics.line_height },
border: iced::Border::default(),
shadow: iced::Shadow::default(),
},
SELECTION_BG,
);
} }
})
.unwrap_or(0);
if line_idx >= start.0 && line_idx <= end.0 {
let sel_start_col = if line_idx == start.0 { start.1 } else { 0 };
let sel_end_col = if line_idx == end.0 {
end.1
} else {
line_len + 1
};
// Clip selection to current visual line range
let s = sel_start_col.max(char_start);
let e = sel_end_col.min(char_end);
if s < e {
let sel_x = text_x + (s - char_start) as f32 * metrics.char_width;
let sel_w = (e - s) as f32 * metrics.char_width;
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: sel_x,
y,
width: sel_w,
height: metrics.line_height,
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
},
SELECTION_BG,
);
} }
} }
} }
@@ -414,13 +443,19 @@ where
// Line number (only on first visual line of each logical line) // Line number (only on first visual line of each logical line)
if is_first { if is_first {
let line_num = format!("{:>4}", line_idx + 1); let line_num = format!("{:>4}", line_idx + 1);
let num_color = if line_idx == cursor_line { ACTIVE_LINE_NUM } else { GUTTER_TEXT }; let num_color = if line_idx == cursor_line {
ACTIVE_LINE_NUM
} else {
GUTTER_TEXT
};
renderer.fill_text( renderer.fill_text(
text::Text { text::Text {
content: line_num, content: line_num,
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height), bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
size: Pixels(FONT_SIZE), size: Pixels(FONT_SIZE),
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)), line_height: iced::widget::text::LineHeight::Absolute(Pixels(
metrics.line_height,
)),
font, font,
horizontal_alignment: iced::alignment::Horizontal::Right, horizontal_alignment: iced::alignment::Horizontal::Right,
vertical_alignment: iced::alignment::Vertical::Top, vertical_alignment: iced::alignment::Vertical::Top,
@@ -441,7 +476,9 @@ where
for (style, span_text) in spans { for (style, span_text) in spans {
let color = syntect_to_iced(style.foreground); let color = syntect_to_iced(style.foreground);
for ch in span_text.chars() { for ch in span_text.chars() {
if ch == '\n' { continue; } if ch == '\n' {
continue;
}
chars_with_color.push((ch, color)); chars_with_color.push((ch, color));
} }
} }
@@ -463,14 +500,19 @@ where
while span_end < slice.len() && slice[span_end].1 == color { while span_end < slice.len() && slice[span_end].1 == color {
span_end += 1; span_end += 1;
} }
let display_text: String = slice[span_start..span_end].iter().map(|(c, _)| *c).collect(); let display_text: String = slice[span_start..span_end]
.iter()
.map(|(c, _)| *c)
.collect();
let span_width = display_text.len() as f32 * metrics.char_width; let span_width = display_text.len() as f32 * metrics.char_width;
renderer.fill_text( renderer.fill_text(
text::Text { text::Text {
content: display_text, content: display_text,
bounds: Size::new(span_width + metrics.char_width, metrics.line_height), bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
size: Pixels(FONT_SIZE), size: Pixels(FONT_SIZE),
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)), line_height: iced::widget::text::LineHeight::Absolute(Pixels(
metrics.line_height,
)),
font, font,
horizontal_alignment: iced::alignment::Horizontal::Left, horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top, vertical_alignment: iced::alignment::Vertical::Top,
@@ -499,7 +541,9 @@ where
content: display_text.to_string(), content: display_text.to_string(),
bounds: Size::new(text_area_width, metrics.line_height), bounds: Size::new(text_area_width, metrics.line_height),
size: Pixels(FONT_SIZE), size: Pixels(FONT_SIZE),
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)), line_height: iced::widget::text::LineHeight::Absolute(Pixels(
metrics.line_height,
)),
font, font,
horizontal_alignment: iced::alignment::Horizontal::Left, horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top, vertical_alignment: iced::alignment::Vertical::Top,
@@ -520,7 +564,10 @@ where
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: Rectangle { bounds: Rectangle {
x: cursor_x, y, width: 2.0, height: metrics.line_height, x: cursor_x,
y,
width: 2.0,
height: metrics.line_height,
}, },
border: iced::Border::default(), border: iced::Border::default(),
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
@@ -529,12 +576,17 @@ where
); );
} }
// Also draw cursor at end of last visual line segment // Also draw cursor at end of last visual line segment
if cursor_col == char_end && char_end == line_text_for(&buf, line_idx).chars().count() { if cursor_col == char_end
&& char_end == line_text_for(&buf, line_idx).chars().count()
{
let cursor_x = text_x + (char_end - char_start) as f32 * metrics.char_width; let cursor_x = text_x + (char_end - char_start) as f32 * metrics.char_width;
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: Rectangle { bounds: Rectangle {
x: cursor_x, y, width: 2.0, height: metrics.line_height, x: cursor_x,
y,
width: 2.0,
height: metrics.line_height,
}, },
border: iced::Border::default(), border: iced::Border::default(),
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
@@ -553,14 +605,23 @@ where
let thumb_ratio = viewport_lines as f32 / total_vis as f32; let thumb_ratio = viewport_lines as f32 / total_vis as f32;
let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT); let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT);
let max_scroll = total_vis.saturating_sub(viewport_lines); let max_scroll = total_vis.saturating_sub(viewport_lines);
let scroll_ratio = if max_scroll > 0 { scroll as f32 / max_scroll as f32 } else { 0.0 }; let scroll_ratio = if max_scroll > 0 {
scroll as f32 / max_scroll as f32
} else {
0.0
};
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height); let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
let track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH; let track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH;
// Track // Track
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: Rectangle { x: track_x, y: bounds.y, width: SCROLLBAR_WIDTH, height: track_height }, bounds: Rectangle {
x: track_x,
y: bounds.y,
width: SCROLLBAR_WIDTH,
height: track_height,
},
border: iced::Border::default(), border: iced::Border::default(),
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
}, },
@@ -582,7 +643,11 @@ where
}, },
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
}, },
if is_hover { SCROLLBAR_THUMB_HOVER } else { SCROLLBAR_THUMB }, if is_hover {
SCROLLBAR_THUMB_HOVER
} else {
SCROLLBAR_THUMB
},
); );
} }
} }
@@ -629,7 +694,11 @@ where
if let Some(pos) = cursor.position() { if let Some(pos) = cursor.position() {
let local_y = pos.y - bounds.y - grab_offset; let local_y = pos.y - bounds.y - grab_offset;
let scroll_range = track_height - thumb_height; let scroll_range = track_height - thumb_height;
let ratio = if scroll_range > 0.0 { (local_y / scroll_range).clamp(0.0, 1.0) } else { 0.0 }; let ratio = if scroll_range > 0.0 {
(local_y / scroll_range).clamp(0.0, 1.0)
} else {
0.0
};
let new_scroll = (ratio * max_scroll as f32).round() as usize; let new_scroll = (ratio * max_scroll as f32).round() as usize;
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll); self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
} }
@@ -644,29 +713,33 @@ where
} }
// Start scrollbar drag // Start scrollbar drag
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event { if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event
if let Some(pos) = cursor.position() { && let Some(pos) = cursor.position()
if pos.x >= track_x && pos.x <= bounds.x + bounds.width && pos.x >= track_x
&& pos.y >= bounds.y && pos.y <= bounds.y + bounds.height && pos.x <= bounds.x + bounds.width
{ && pos.y >= bounds.y
let current_scroll = self.buffer.borrow().scroll_offset(); && pos.y <= bounds.y + bounds.height
let scroll_ratio = if max_scroll > 0 { current_scroll as f32 / max_scroll as f32 } else { 0.0 }; {
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height); let current_scroll = self.buffer.borrow().scroll_offset();
let scroll_ratio = if max_scroll > 0 {
current_scroll as f32 / max_scroll as f32
} else {
0.0
};
let thumb_y = bounds.y + scroll_ratio * (track_height - thumb_height);
if pos.y >= thumb_y && pos.y <= thumb_y + thumb_height { if pos.y >= thumb_y && pos.y <= thumb_y + thumb_height {
// Clicked on thumb — start dragging // Clicked on thumb — start dragging
state.scrollbar_drag = Some(pos.y - thumb_y); state.scrollbar_drag = Some(pos.y - thumb_y);
} else { } else {
// Clicked on track — jump to position // Clicked on track — jump to position
let ratio = ((pos.y - bounds.y) / track_height).clamp(0.0, 1.0); let ratio = ((pos.y - bounds.y) / track_height).clamp(0.0, 1.0);
let new_scroll = (ratio * max_scroll as f32).round() as usize; let new_scroll = (ratio * max_scroll as f32).round() as usize;
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll); self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
state.scrollbar_drag = Some(thumb_height / 2.0); state.scrollbar_drag = Some(thumb_height / 2.0);
}
state.is_focused = true;
return Status::Captured;
}
} }
state.is_focused = true;
return Status::Captured;
} }
} }
@@ -679,9 +752,11 @@ where
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let vis_row = (pos.y / metrics.line_height) as usize; let vis_row = (pos.y / metrics.line_height) as usize;
let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; let raw_col =
let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
.unwrap_or((buf.line_count().saturating_sub(1), 0)); let (line, char_off) =
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
.unwrap_or((buf.line_count().saturating_sub(1), 0));
let col = char_off + raw_col; let col = char_off + raw_col;
// Double-click detection // Double-click detection
@@ -717,16 +792,17 @@ where
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let vis_row = (pos.y / metrics.line_height) as usize; let vis_row = (pos.y / metrics.line_height) as usize;
let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; let raw_col =
let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
.unwrap_or((buf.line_count().saturating_sub(1), 0)); let (line, char_off) =
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
.unwrap_or((buf.line_count().saturating_sub(1), 0));
let col = char_off + raw_col; let col = char_off + raw_col;
// Extend selection by simulating shift+movement // Extend selection by simulating shift+movement
let clamped_line = line.min(buf.line_count().saturating_sub(1)); let clamped_line = line.min(buf.line_count().saturating_sub(1));
let clamped_col = if clamped_line < buf.line_count() { let clamped_col = if clamped_line < buf.line_count() {
col.min( col.min(
buf buf.line(clamped_line)
.line(clamped_line)
.map(|l| { .map(|l| {
let len = l.len_chars(); let len = l.len_chars();
if len > 0 && l.char(len - 1) == '\n' { if len > 0 && l.char(len - 1) == '\n' {
@@ -748,12 +824,7 @@ where
.selection() .selection()
.map(|s| s.anchor_col) .map(|s| s.anchor_col)
.unwrap_or(buf.cursor().1); .unwrap_or(buf.cursor().1);
buf.set_selection( buf.set_selection(anchor_line, anchor_col, clamped_line, clamped_col);
anchor_line,
anchor_col,
clamped_line,
clamped_col,
);
buf.set_cursor(clamped_line, clamped_col); buf.set_cursor(clamped_line, clamped_col);
} }
return Status::Captured; return Status::Captured;
@@ -762,9 +833,7 @@ where
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => { Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
let lines = match delta { let lines = match delta {
mouse::ScrollDelta::Lines { y, .. } => -(y * 3.0) as isize, mouse::ScrollDelta::Lines { y, .. } => -(y * 3.0) as isize,
mouse::ScrollDelta::Pixels { y, .. } => { mouse::ScrollDelta::Pixels { y, .. } => -(y / metrics.line_height) as isize,
-(y / metrics.line_height) as isize
}
}; };
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let vis = (bounds.height / metrics.line_height) as usize; let vis = (bounds.height / metrics.line_height) as usize;
@@ -774,7 +843,10 @@ where
return Status::Captured; return Status::Captured;
} }
Event::Keyboard(keyboard::Event::KeyPressed { Event::Keyboard(keyboard::Event::KeyPressed {
key, modifiers, text, .. key,
modifiers,
text,
..
}) if state.is_focused => { }) if state.is_focused => {
let vis = (bounds.height / metrics.line_height) as usize; let vis = (bounds.height / metrics.line_height) as usize;
let is_cmd = modifiers.command(); let is_cmd = modifiers.command();
@@ -799,7 +871,11 @@ where
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => { keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => {
{ {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
if is_shift { buf.redo(); } else { buf.undo(); } if is_shift {
buf.redo();
} else {
buf.undo();
}
ensure_vis!(buf); ensure_vis!(buf);
} }
self.emit_change(shell); self.emit_change(shell);
@@ -994,6 +1070,16 @@ impl<'a, Message> CodeEditor<'a, Message> {
} }
} }
impl<'a, Message, Renderer> From<CodeEditor<'a, Message>> for Element<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer + text::Renderer<Font = iced::Font> + 'a,
Message: 'a,
{
fn from(editor: CodeEditor<'a, Message>) -> Self {
Self::new(editor)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::committed_text_input; use super::committed_text_input;
@@ -1013,13 +1099,3 @@ mod tests {
assert_eq!(committed_text_input(None, false), None); assert_eq!(committed_text_input(None, false), None);
} }
} }
impl<'a, Message, Renderer> From<CodeEditor<'a, Message>> for Element<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer + text::Renderer<Font = iced::Font> + 'a,
Message: 'a,
{
fn from(editor: CodeEditor<'a, Message>) -> Self {
Self::new(editor)
}
}
+3089 -2131
View File
File diff suppressed because it is too large. Load diff
+17 -5
View File
@@ -1,5 +1,5 @@
use iced::widget::{button, checkbox, column, container, pick_list, row, text, text_input, Space};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{Space, button, checkbox, column, container, pick_list, row, text, text_input};
use iced::{Alignment, Background, Color, Element, Length, Theme}; use iced::{Alignment, Background, Color, Element, Length, Theme};
/// Standard form field label color. /// Standard form field label color.
@@ -19,7 +19,10 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
on_change: impl Fn(String) -> Message + 'a, on_change: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
column![ column![
text(label.to_string()).size(12).color(LABEL_COLOR).shaping(Shaping::Advanced), text(label.to_string())
.size(12)
.color(LABEL_COLOR)
.shaping(Shaping::Advanced),
text_input(placeholder, value).on_input(on_change).size(14), text_input(placeholder, value).on_input(on_change).size(14),
] ]
.spacing(4) .spacing(4)
@@ -39,7 +42,10 @@ where
{ {
let list: Vec<T> = options.to_vec(); let list: Vec<T> = options.to_vec();
column![ column![
text(label.to_string()).size(12).color(LABEL_COLOR).shaping(Shaping::Advanced), text(label.to_string())
.size(12)
.color(LABEL_COLOR)
.shaping(Shaping::Advanced),
pick_list(list, selected.cloned(), on_select), pick_list(list, selected.cloned(), on_select),
] ]
.spacing(4) .spacing(4)
@@ -157,8 +163,14 @@ pub fn toolbar<'a, Message: 'a>(
pub fn date_label<'a, Message: 'a>(label: &str, timestamp_ms: i64) -> Element<'a, Message> { pub fn date_label<'a, Message: 'a>(label: &str, timestamp_ms: i64) -> Element<'a, Message> {
let date_str = format_timestamp(timestamp_ms); let date_str = format_timestamp(timestamp_ms);
row![ row![
text(label.to_string()).size(12).color(LABEL_COLOR).shaping(Shaping::Advanced), text(label.to_string())
text(date_str).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).shaping(Shaping::Advanced), .size(12)
.color(LABEL_COLOR)
.shaping(Shaping::Advanced),
text(date_str)
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65))
.shaping(Shaping::Advanced),
] ]
.spacing(8) .spacing(8)
.into() .into()
+2 -2
View File
@@ -3,11 +3,11 @@ use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use iced::event;
use iced::advanced::layout::{self, Node}; use iced::advanced::layout::{self, Node};
use iced::advanced::renderer; use iced::advanced::renderer;
use iced::advanced::widget::Tree; use iced::advanced::widget::Tree;
use iced::advanced::{Clipboard, Layout, Shell, Widget}; use iced::advanced::{Clipboard, Layout, Shell, Widget};
use iced::event;
use iced::mouse; use iced::mouse;
use iced::{Element, Event, Length, Rectangle, Size, Task, window}; use iced::{Element, Event, Length, Rectangle, Size, Task, window};
use wry::dpi::{LogicalPosition, LogicalSize}; use wry::dpi::{LogicalPosition, LogicalSize};
@@ -314,4 +314,4 @@ where
pub fn webview<Message>(controller: &WebViewController) -> WebViewPlaceholder<Message> { pub fn webview<Message>(controller: &WebViewController) -> WebViewPlaceholder<Message> {
WebViewPlaceholder::new().bounds_sender(controller.bounds_sender()) WebViewPlaceholder::new().bounds_sender(controller.bounds_sender())
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use bds_core::i18n::{translate, translate_with, UiLocale}; use bds_core::i18n::{UiLocale, translate, translate_with};
/// Shorthand for translate in view code. /// Shorthand for translate in view code.
pub fn t(locale: UiLocale, key: &str) -> String { pub fn t(locale: UiLocale, key: &str) -> String {
+11 -5
View File
@@ -3,7 +3,7 @@ use std::sync::mpsc;
use objc2::rc::Retained; use objc2::rc::Retained;
use objc2::runtime::ProtocolObject; use objc2::runtime::ProtocolObject;
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly}; use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
use objc2_app_kit::{NSApplication, NSApplicationDelegate}; use objc2_app_kit::{NSApplication, NSApplicationDelegate};
use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL}; use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
@@ -30,8 +30,11 @@ define_class!(
#[ivars = DelegateIvars] #[ivars = DelegateIvars]
pub struct BdsAppDelegate; pub struct BdsAppDelegate;
// SAFETY: NSObjectProtocol declares no additional safety requirements.
unsafe impl NSObjectProtocol for BdsAppDelegate {} unsafe impl NSObjectProtocol for BdsAppDelegate {}
// SAFETY: Every exported selector below uses the exact AppKit delegate signature and the
// class is main-thread-only, as required by NSApplicationDelegate.
unsafe impl NSApplicationDelegate for BdsAppDelegate { unsafe impl NSApplicationDelegate for BdsAppDelegate {
#[unsafe(method(applicationDidFinishLaunching:))] #[unsafe(method(applicationDidFinishLaunching:))]
fn did_finish_launching(&self, _notification: &NSNotification) { fn did_finish_launching(&self, _notification: &NSNotification) {
@@ -49,7 +52,10 @@ define_class!(
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) { fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
for i in 0..urls.len() { for i in 0..urls.len() {
if let Some(url) = urls.objectAtIndex(i).absoluteString() { if let Some(url) = urls.objectAtIndex(i).absoluteString() {
let _ = self.ivars().tx.send(LifecycleEvent::UrlOpen(url.to_string())); let _ = self
.ivars()
.tx
.send(LifecycleEvent::UrlOpen(url.to_string()));
} }
} }
} }
@@ -60,6 +66,8 @@ impl BdsAppDelegate {
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> { fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
let this = Self::alloc(mtm); let this = Self::alloc(mtm);
let this = this.set_ivars(DelegateIvars { tx }); let this = this.set_ivars(DelegateIvars { tx });
// SAFETY: `this` has initialized ivars and NSObject's `init` accepts ownership of the
// allocated receiver, returning the retained initialized object.
unsafe { msg_send![super(this), init] } unsafe { msg_send![super(this), init] }
} }
} }
@@ -86,9 +94,7 @@ pub fn install_delegate(tx: mpsc::Sender<LifecycleEvent>) -> Option<Retained<Bds
} }
/// Poll the macOS lifecycle receiver for the next event and map to a Message. /// Poll the macOS lifecycle receiver for the next event and map to a Message.
pub fn poll_lifecycle( pub fn poll_lifecycle(receiver: &mpsc::Receiver<LifecycleEvent>) -> Option<Message> {
receiver: &mpsc::Receiver<LifecycleEvent>,
) -> Option<Message> {
match receiver.try_recv() { match receiver.try_recv() {
Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)), Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)),
Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)), Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)),
+124 -37
View File
@@ -1,11 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use iced::Subscription; use iced::Subscription;
use muda::accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL}; use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
use muda::{Menu, MenuEvent, MenuItem, MenuId, PredefinedMenuItem, Submenu}; use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
use bds_core::i18n::{translate, UiLocale};
use crate::app::Message; use crate::app::Message;
use bds_core::i18n::{UiLocale, translate};
/// Every custom menu item that the application handles. /// Every custom menu item that the application handles.
/// ///
@@ -210,12 +210,24 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
// -- File -- // -- File --
let file_menu = Submenu::new(translate(locale, "menu.group.file"), true); let file_menu = Submenu::new(translate(locale, "menu.group.file"), true);
let _ = file_menu.append(&item(&mut reg, MenuAction::NewPost, locale, let _ = file_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)))); &mut reg,
let _ = file_menu.append(&item(&mut reg, MenuAction::ImportMedia, locale, MenuAction::NewPost,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI)))); locale,
let _ = file_menu.append(&item(&mut reg, MenuAction::Save, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)),
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS)))); ));
let _ = file_menu.append(&item(
&mut reg,
MenuAction::ImportMedia,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI)),
));
let _ = file_menu.append(&item(
&mut reg,
MenuAction::Save,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS)),
));
let _ = file_menu.append(&PredefinedMenuItem::separator()); let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None)); let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None)); let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
@@ -232,34 +244,76 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = edit_menu.append(&PredefinedMenuItem::paste(None)); let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None)); let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
let _ = edit_menu.append(&PredefinedMenuItem::separator()); let _ = edit_menu.append(&PredefinedMenuItem::separator());
let _ = edit_menu.append(&item(&mut reg, MenuAction::Find, locale, let _ = edit_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF)))); &mut reg,
let _ = edit_menu.append(&item(&mut reg, MenuAction::Replace, locale, MenuAction::Find,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH)))); locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF)),
));
let _ = edit_menu.append(&item(
&mut reg,
MenuAction::Replace,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH)),
));
let _ = edit_menu.append(&PredefinedMenuItem::separator()); let _ = edit_menu.append(&PredefinedMenuItem::separator());
let _ = edit_menu.append(&item(&mut reg, MenuAction::EditPreferences, locale, let _ = edit_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)))); &mut reg,
MenuAction::EditPreferences,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)),
));
// -- View -- // -- View --
let view_menu = Submenu::new(translate(locale, "menu.group.view"), true); let view_menu = Submenu::new(translate(locale, "menu.group.view"), true);
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewPosts, locale, let _ = view_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1)))); &mut reg,
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewMedia, locale, MenuAction::ViewPosts,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2)))); locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1)),
));
let _ = view_menu.append(&item(
&mut reg,
MenuAction::ViewMedia,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2)),
));
let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&PredefinedMenuItem::separator());
let _ = view_menu.append(&item(&mut reg, MenuAction::ToggleSidebar, locale, let _ = view_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB)))); &mut reg,
let _ = view_menu.append(&item(&mut reg, MenuAction::TogglePanel, locale, MenuAction::ToggleSidebar,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ)))); locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB)),
));
let _ = view_menu.append(&item(
&mut reg,
MenuAction::TogglePanel,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ)),
));
let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&PredefinedMenuItem::separator());
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None)); let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
// -- Blog -- // -- Blog --
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true); let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale, let _ = blog_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP)))); &mut reg,
let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale, MenuAction::PublishSelected,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV)))); locale,
Some(Accelerator::new(
Some(CMD_OR_CTRL | Modifiers::SHIFT),
Code::KeyP,
)),
));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::PreviewPost,
locale,
Some(Accelerator::new(
Some(CMD_OR_CTRL | Modifiers::SHIFT),
Code::KeyV,
)),
));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&PredefinedMenuItem::separator());
@@ -267,16 +321,49 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None)); let _ = blog_menu.append(&item(
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None)); &mut reg,
let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None)); MenuAction::RegenerateCalendar,
locale,
None,
));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::ValidateTranslations,
locale,
None,
));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::FillMissingTranslations,
locale,
None,
));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale, let _ = blog_menu.append(&item(
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)))); &mut reg,
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale, MenuAction::GenerateSitemap,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL)))); locale,
let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)),
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU)))); ));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::ValidateSite,
locale,
Some(Accelerator::new(
Some(CMD_OR_CTRL | Modifiers::SHIFT),
Code::KeyL,
)),
));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::UploadSite,
locale,
Some(Accelerator::new(
Some(CMD_OR_CTRL | Modifiers::SHIFT),
Code::KeyU,
)),
));
// -- Help -- // -- Help --
let help_menu = Submenu::new(translate(locale, "menu.group.help"), true); let help_menu = Submenu::new(translate(locale, "menu.group.help"), true);
@@ -303,7 +390,7 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
/// init task or first update), not during `build_menu_bar`. /// init task or first update), not during `build_menu_bar`.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn init_menu_for_nsapp(menu: &Menu) { pub fn init_menu_for_nsapp(menu: &Menu) {
let _ = menu.init_for_nsapp(); menu.init_for_nsapp();
} }
/// Re-translate every registered menu item for a new locale. /// Re-translate every registered menu item for a new locale.
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod menu;
pub mod dialog; pub mod dialog;
pub mod menu;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod macos; pub mod macos;
+2 -2
View File
@@ -3,7 +3,7 @@ pub mod sidebar_filter;
pub mod tabs; pub mod tabs;
pub mod toast; pub mod toast;
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry}; pub use navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
pub use sidebar_filter::{PostFilter, MediaFilter, CalendarFilter, CalendarYear, CalendarMonth}; pub use sidebar_filter::{CalendarFilter, CalendarMonth, CalendarYear, MediaFilter, PostFilter};
pub use tabs::{Tab, TabType}; pub use tabs::{Tab, TabType};
pub use toast::{Toast, ToastLevel}; pub use toast::{Toast, ToastLevel};
+3 -6
View File
@@ -92,24 +92,21 @@ mod tests {
#[test] #[test]
fn toggle_off_when_same_and_visible() { fn toggle_off_when_same_and_visible() {
let (view, visible) = let (view, visible) = handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
assert_eq!(view, SidebarView::Posts); assert_eq!(view, SidebarView::Posts);
assert!(!visible); assert!(!visible);
} }
#[test] #[test]
fn toggle_on_when_same_and_hidden() { fn toggle_on_when_same_and_hidden() {
let (view, visible) = let (view, visible) = handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
assert_eq!(view, SidebarView::Posts); assert_eq!(view, SidebarView::Posts);
assert!(visible); assert!(visible);
} }
#[test] #[test]
fn switch_view_when_different() { fn switch_view_when_different() {
let (view, visible) = let (view, visible) = handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
assert_eq!(view, SidebarView::Media); assert_eq!(view, SidebarView::Media);
assert!(visible); assert!(visible);
} }
+6 -6
View File
@@ -1,9 +1,9 @@
/// Sidebar filter state per sidebar_views.allium PostsView / MediaView. //! Sidebar filter state per sidebar_views.allium PostsView / MediaView.
/// //!
/// Per ui_data_flow.allium SidebarFilterIsolation: //! Per ui_data_flow.allium SidebarFilterIsolation:
/// "Sidebar search/filter state is local to the sidebar component. //! "Sidebar search/filter state is local to the sidebar component.
/// Filtering never affects: active tab, editor content, selectedPostId. //! Filtering never affects: active tab, editor content, selectedPostId.
/// Only the visible list of items changes." //! Only the visible list of items changes."
/// Calendar year/month archive filter. /// Calendar year/month archive filter.
/// Per sidebar_views.allium CalendarFilter. /// Per sidebar_views.allium CalendarFilter.
+10 -11
View File
@@ -122,21 +122,20 @@ pub fn open_tab(tabs: &mut Vec<Tab>, new_tab: Tab) -> usize {
} }
// Singleton: only one instance allowed (catches id mismatch edge cases) // Singleton: only one instance allowed (catches id mismatch edge cases)
if new_tab.tab_type.is_singleton() { if new_tab.tab_type.is_singleton()
if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) { && let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type)
return idx; {
} return idx;
} }
// Transient replacement: replace existing transient of same type // Transient replacement: replace existing transient of same type
if new_tab.is_transient { if new_tab.is_transient
if let Some(idx) = tabs && let Some(idx) = tabs
.iter() .iter()
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient) .position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
{ {
tabs[idx] = new_tab; tabs[idx] = new_tab;
return idx; return idx;
}
} }
tabs.push(new_tab); tabs.push(new_tab);
@@ -159,7 +158,7 @@ pub fn close_tab(tabs: &mut Vec<Tab>, id: &str) -> Option<usize> {
} }
/// Mark a transient tab as permanent (non-transient). /// Mark a transient tab as permanent (non-transient).
pub fn pin_tab(tabs: &mut Vec<Tab>, id: &str) { pub fn pin_tab(tabs: &mut [Tab], id: &str) {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) { if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) {
tab.is_transient = false; tab.is_transient = false;
} }
-1
View File
@@ -3,7 +3,6 @@
/// Toasts are ephemeral, auto-dismissing messages shown at the top of /// Toasts are ephemeral, auto-dismissing messages shown at the top of
/// the workspace. Each toast has a severity level, a message, and a /// the workspace. Each toast has a severity level, a message, and a
/// monotonically increasing id used for targeted dismissal. /// monotonically increasing id used for targeted dismissal.
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1); static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
+23 -26
View File
@@ -1,5 +1,5 @@
use iced::widget::{button, column, container, svg, text, tooltip, Column, Space};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{Column, Space, button, column, container, svg, text, tooltip};
use iced::{Background, Border, Color, Element, Length, Theme}; use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -40,10 +40,7 @@ const TOP_ACTIVITIES: &[SidebarView] = &[
]; ];
/// Bottom group of activity items. /// Bottom group of activity items.
const BOTTOM_ACTIVITIES: &[SidebarView] = &[ const BOTTOM_ACTIVITIES: &[SidebarView] = &[SidebarView::Git, SidebarView::Settings];
SidebarView::Git,
SidebarView::Settings,
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles — matching bDS ActivityBar.css // Styles — matching bDS ActivityBar.css
@@ -108,7 +105,7 @@ pub fn view(
let icon = svg(handle) let icon = svg(handle)
.width(Length::Fixed(24.0)) .width(Length::Fixed(24.0))
.height(Length::Fixed(24.0)) .height(Length::Fixed(24.0))
.opacity(if is_active { 1.0 } else { 0.4 }); .opacity(if is_active { 1.0_f32 } else { 0.4_f32 });
let btn = button( let btn = button(
container(icon) container(icon)
@@ -119,7 +116,11 @@ pub fn view(
.height(Length::Fixed(48.0)) .height(Length::Fixed(48.0))
.padding(0) .padding(0)
.on_press(Message::SetActiveView(view)) .on_press(Message::SetActiveView(view))
.style(if is_active { active_button_style } else { inactive_button_style }); .style(if is_active {
active_button_style
} else {
inactive_button_style
});
// Active indicator: 2px left border (like bDS/VS Code) // Active indicator: 2px left border (like bDS/VS Code)
let btn_row: Element<'static, Message> = if is_active { let btn_row: Element<'static, Message> = if is_active {
@@ -138,33 +139,29 @@ pub fn view(
// Wrap in tooltip per layout.allium ActivityButton.label_key // Wrap in tooltip per layout.allium ActivityButton.label_key
let tip_text = t(locale, view.i18n_key()); let tip_text = t(locale, view.i18n_key());
tooltip(btn_row, text(tip_text).size(12).shaping(Shaping::Advanced), tooltip::Position::Right) tooltip(
.gap(4) btn_row,
.into() text(tip_text).size(12).shaping(Shaping::Advanced),
tooltip::Position::Right,
)
.gap(4)
.into()
}; };
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES let top_items: Vec<Element<'static, Message>> =
.iter() TOP_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
.map(|v| make_btn(*v))
.collect();
let bottom_items: Vec<Element<'static, Message>> = BOTTOM_ACTIVITIES let bottom_items: Vec<Element<'static, Message>> =
.iter() BOTTOM_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
.map(|v| make_btn(*v))
.collect();
let top = Column::with_children(top_items).spacing(0); let top = Column::with_children(top_items).spacing(0);
let bottom = Column::with_children(bottom_items).spacing(0); let bottom = Column::with_children(bottom_items).spacing(0);
container( container(
column![ column![top, Space::with_height(Length::Fill), bottom,]
top, .width(Length::Fixed(50.0))
Space::with_height(Length::Fill), .height(Length::Fill)
bottom, .padding([4, 0]),
]
.width(Length::Fixed(50.0))
.height(Length::Fill)
.padding([4, 0]),
) )
.width(Length::Fixed(50.0)) .width(Length::Fixed(50.0))
.height(Length::Fill) .height(Length::Fill)
+153 -83
View File
@@ -1,4 +1,4 @@
use iced::widget::{button, column, container, row, scrollable, text, tooltip, Space}; use iced::widget::{Space, button, column, container, row, scrollable, text, tooltip};
use iced::{Alignment, Background, Color, Element, Length, Theme}; use iced::{Alignment, Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -88,13 +88,8 @@ impl DashboardState {
} }
/// Render the dashboard overview. /// Render the dashboard overview.
pub fn view<'a>( pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Message> {
state: &'a DashboardState, let header = text(state.title.clone()).size(24).color(Color::WHITE);
locale: UiLocale,
) -> Element<'a, Message> {
let header = text(state.title.clone())
.size(24)
.color(Color::WHITE);
let project_label = text(state.subtitle.clone()) let project_label = text(state.subtitle.clone())
.size(14) .size(14)
@@ -106,11 +101,23 @@ pub fn view<'a>(
state.stats.total_posts.to_string(), state.stats.total_posts.to_string(),
{ {
let mut details = vec![ let mut details = vec![
format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")), format!(
format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")), "{} {}",
state.stats.published_count,
t(locale, "dashboard.published")
),
format!(
"{} {}",
state.stats.draft_count,
t(locale, "dashboard.drafts")
),
]; ];
if state.stats.archived_count > 0 { if state.stats.archived_count > 0 {
details.push(format!("{} {}", state.stats.archived_count, t(locale, "dashboard.archived"))); details.push(format!(
"{} {}",
state.stats.archived_count,
t(locale, "dashboard.archived")
));
} }
details details
}, },
@@ -119,14 +126,22 @@ pub fn view<'a>(
&t(locale, "dashboard.media"), &t(locale, "dashboard.media"),
state.stats.media_count.to_string(), state.stats.media_count.to_string(),
vec![ vec![
format!("{} {}", state.stats.image_count, t(locale, "dashboard.images")), format!(
"{} {}",
state.stats.image_count,
t(locale, "dashboard.images")
),
state.stats.total_media_size.clone(), state.stats.total_media_size.clone(),
], ],
), ),
stat_card( stat_card(
&t(locale, "dashboard.tags"), &t(locale, "dashboard.tags"),
state.stats.tag_count.to_string(), state.stats.tag_count.to_string(),
vec![format!("{} {}", state.stats.category_count, t(locale, "dashboard.categories"))], vec![format!(
"{} {}",
state.stats.category_count,
t(locale, "dashboard.categories")
)],
), ),
] ]
.spacing(16); .spacing(16);
@@ -166,7 +181,12 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
container( container(
column( column(
std::iter::once(text(value).size(28).color(Color::WHITE).into()) std::iter::once(text(value).size(28).color(Color::WHITE).into())
.chain(std::iter::once(text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).into())) .chain(std::iter::once(
text(label.to_string())
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65))
.into(),
))
.chain(details.into_iter().map(|detail| { .chain(details.into_iter().map(|detail| {
text(detail) text(detail)
.size(12) .size(12)
@@ -191,65 +211,84 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
.into() .into()
} }
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -> Element<'a, Message> { fn timeline_chart<'a>(
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1); months: &'a [DashboardTimelineMonth],
row( _locale: UiLocale,
months ) -> Element<'a, Message> {
.iter() let max_count = months
.map(|month| { .iter()
let height = if month.count == 0 { .map(|month| month.count)
8.0 .max()
} else { .unwrap_or(1)
24.0 + (month.count as f32 / max_count as f32) * 96.0 .max(1);
}; row(months
container( .iter()
column![ .map(|month| {
text(month.count.to_string()).size(12).color(Color::WHITE), let height = if month.count == 0 {
container(Space::with_height(height)) 8.0
.width(Length::Fill) } else {
.style(|_: &Theme| container::Style { 24.0 + (month.count as f32 / max_count as f32) * 96.0
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))), };
border: iced::Border { radius: 6.0.into(), ..iced::Border::default() }, container(
..container::Style::default() column![
}), text(month.count.to_string()).size(12).color(Color::WHITE),
text(format!("{} {}", month.label, month.year)).size(11).color(Color::from_rgb(0.7, 0.72, 0.78)), container(Space::with_height(height))
] .width(Length::Fill)
.spacing(6) .style(|_: &Theme| container::Style {
.align_x(Alignment::Center), background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
) border: iced::Border {
.width(Length::FillPortion(1)) radius: 6.0.into(),
.into() ..iced::Border::default()
}) },
.collect::<Vec<_>>(), ..container::Style::default()
) }),
text(format!("{} {}", month.label, month.year))
.size(11)
.color(Color::from_rgb(0.7, 0.72, 0.78)),
]
.spacing(6)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.into()
})
.collect::<Vec<_>>())
.spacing(10) .spacing(10)
.align_y(Alignment::End) .align_y(Alignment::End)
.into() .into()
} }
fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> { fn tag_cloud<'a>(
tags: &'a [DashboardTag],
overflow_count: usize,
locale: UiLocale,
) -> Element<'a, Message> {
let cloud = if tags.is_empty() { let cloud = if tags.is_empty() {
row![text(t(locale, "dashboard.noTags")).size(13).color(Color::from_rgb(0.6, 0.62, 0.68))] row![
.spacing(8) text(t(locale, "dashboard.noTags"))
.wrap() .size(13)
.color(Color::from_rgb(0.6, 0.62, 0.68))
]
.spacing(8)
.wrap()
} else { } else {
let words = tags let words = tags
.iter() .iter()
.map(|tag| { .map(|tag| {
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28)); let bg =
parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
let fg = contrast_color(bg); let fg = contrast_color(bg);
tooltip( tooltip(
container( container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg))
text(tag.name.clone()) .padding([6, 10])
.size(scale_font(tag.count)) .style(move |_: &Theme| container::Style {
.color(fg), background: Some(Background::Color(bg)),
) border: iced::Border {
.padding([6, 10]) radius: 999.0.into(),
.style(move |_: &Theme| container::Style { ..iced::Border::default()
background: Some(Background::Color(bg)), },
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() }, ..container::Style::default()
..container::Style::default() }),
}),
text(format!("{} {}", tag.name, tag.count)).size(12), text(format!("{} {}", tag.name, tag.count)).size(12),
tooltip::Position::Top, tooltip::Position::Top,
) )
@@ -269,30 +308,49 @@ fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLoca
] ]
.spacing(8) .spacing(8)
} else { } else {
column![inputs::section_header(&t(locale, "dashboard.tagCloud")), cloud].spacing(8) column![
inputs::section_header(&t(locale, "dashboard.tagCloud")),
cloud
]
.spacing(8)
}; };
container(content).width(Length::FillPortion(1)).into() container(content).width(Length::FillPortion(1)).into()
} }
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> { fn category_cloud<'a>(
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| { categories: &'a [DashboardCategory],
column.push( locale: UiLocale,
container( ) -> Element<'a, Message> {
row![ let items = categories.iter().fold(
text(category.name.clone()).size(13).color(Color::WHITE), column![inputs::section_header(&t(
text(category.count.to_string()).size(12).color(Color::from_rgb(0.72, 0.74, 0.80)), locale,
] "dashboard.categoryCloud"
.spacing(8), ))]
.spacing(8),
|column, category| {
column.push(
container(
row![
text(category.name.clone()).size(13).color(Color::WHITE),
text(category.count.to_string())
.size(12)
.color(Color::from_rgb(0.72, 0.74, 0.80)),
]
.spacing(8),
)
.padding([6, 10])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
) )
.padding([6, 10]) },
.style(|_: &Theme| container::Style { );
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
)
});
container(items).width(Length::FillPortion(1)).into() container(items).width(Length::FillPortion(1)).into()
} }
@@ -313,7 +371,9 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
button( button(
column![ column![
text(post.title.clone()).size(14).color(Color::WHITE), text(post.title.clone()).size(14).color(Color::WHITE),
text(post.date.clone()).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)), text(post.date.clone())
.size(12)
.color(Color::from_rgb(0.6, 0.62, 0.68)),
] ]
.spacing(4), .spacing(4),
) )
@@ -342,7 +402,10 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
.padding(12) .padding(12)
.style(|_: &Theme| container::Style { .style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))), background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
border: iced::Border { radius: 8.0.into(), ..iced::Border::default() }, border: iced::Border {
radius: 8.0.into(),
..iced::Border::default()
},
..container::Style::default() ..container::Style::default()
}) })
.into() .into()
@@ -363,7 +426,10 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> {
.padding([4, 8]) .padding([4, 8])
.style(move |_: &Theme| container::Style { .style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)), background: Some(Background::Color(bg)),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() }, border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default() ..container::Style::default()
}) })
.into() .into()
@@ -387,5 +453,9 @@ fn parse_color(value: Option<&str>) -> Option<Color> {
fn contrast_color(background: Color) -> Color { fn contrast_color(background: Color) -> Color {
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b; let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
if luma > 0.55 { Color::BLACK } else { Color::WHITE } if luma > 0.55 {
Color::BLACK
} else {
Color::WHITE
}
} }
+106 -79
View File
@@ -1,8 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, image, row, scrollable, text};
use iced::{Color, Element, Length}; use iced::{Color, Element, Length};
use bds_core::i18n::{self, UiLocale}; use bds_core::i18n::{self, UiLocale};
@@ -72,12 +72,15 @@ impl MediaEditorState {
let mut translation_drafts = HashMap::new(); let mut translation_drafts = HashMap::new();
for tr in translations { for tr in translations {
translation_drafts.insert(tr.language.clone(), MediaTranslationDraft { translation_drafts.insert(
title: tr.title.clone().unwrap_or_default(), tr.language.clone(),
alt: tr.alt.clone().unwrap_or_default(), MediaTranslationDraft {
caption: tr.caption.clone().unwrap_or_default(), title: tr.title.clone().unwrap_or_default(),
is_dirty: false, alt: tr.alt.clone().unwrap_or_default(),
}); caption: tr.caption.clone().unwrap_or_default(),
is_dirty: false,
},
);
} }
Self { Self {
@@ -125,12 +128,15 @@ impl MediaEditorState {
is_dirty: self.is_dirty, is_dirty: self.is_dirty,
}); });
} else { } else {
self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft { self.translation_drafts.insert(
title: self.title.clone(), self.active_language.clone(),
alt: self.alt.clone(), MediaTranslationDraft {
caption: self.caption.clone(), title: self.title.clone(),
is_dirty: self.is_dirty, alt: self.alt.clone(),
}); caption: self.caption.clone(),
is_dirty: self.is_dirty,
},
);
} }
// Load target fields // Load target fields
if target_lang == self.canonical_language { if target_lang == self.canonical_language {
@@ -225,24 +231,28 @@ pub fn view<'a>(
ai_enabled: bool, ai_enabled: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let header = inputs::toolbar( let header = inputs::toolbar(
vec![ vec![text(state.original_name.clone()).size(18).into()],
text(state.original_name.clone()).size(18).into(),
],
vec![ vec![
if state.mime_type.starts_with("image/") { if state.mime_type.starts_with("image/") {
button(text(t(locale, "editor.aiAnalyze")).size(13)) button(text(t(locale, "editor.aiAnalyze")).size(13))
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi))) .on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)),
)
.padding([6, 16]) .padding([6, 16])
.into() .into()
} else { } else {
Space::new(0, 0).into() Space::new(0, 0).into()
}, },
button(text(t(locale, "editor.detectLanguage")).size(13)) button(text(t(locale, "editor.detectLanguage")).size(13))
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage))) .on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)),
)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "editor.translate")).size(13)) button(text(t(locale, "editor.translate")).size(13))
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata))) .on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)),
)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "common.save")).size(13)) button(text(t(locale, "common.save")).size(13))
@@ -273,7 +283,9 @@ pub fn view<'a>(
Color::from_rgb(0.55, 0.58, 0.65) Color::from_rgb(0.55, 0.58, 0.65)
}; };
button(text(label).size(12).shaping(Shaping::Advanced).color(color)) button(text(label).size(12).shaping(Shaping::Advanced).color(color))
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(flag.language.clone()))) .on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(
flag.language.clone(),
)))
.padding([4, 8]) .padding([4, 8])
.style(|_: &iced::Theme, _| button::Style::default()) .style(|_: &iced::Theme, _| button::Style::default())
.into() .into()
@@ -295,13 +307,13 @@ pub fn view<'a>(
.width(Length::Fill) .width(Length::Fill)
.into() .into()
} else { } else {
no_preview() no_preview(locale)
} }
} else { } else {
no_preview() no_preview(locale)
} }
} else { } else {
no_preview() no_preview(locale)
}; };
// File info // File info
@@ -311,9 +323,12 @@ pub fn view<'a>(
}; };
let size_str = format_file_size(state.size); let size_str = format_file_size(state.size);
let info = row![ let info = row![
text(format!("{} • {} • {}", state.mime_type, size_str, dimensions)) text(format!(
.size(12) "{} • {} • {}",
.color(Color::from_rgb(0.55, 0.58, 0.65)), state.mime_type, size_str, dimensions
))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
] ]
.padding(8); .padding(8);
@@ -332,18 +347,13 @@ pub fn view<'a>(
); );
let meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill); let meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill);
let caption_input = inputs::labeled_input( let caption_input =
&t(locale, "editor.caption"), inputs::labeled_input(&t(locale, "editor.caption"), "", &state.caption, |s| {
"", Message::MediaEditor(MediaEditorMsg::CaptionChanged(s))
&state.caption, });
|s| Message::MediaEditor(MediaEditorMsg::CaptionChanged(s)), let author_input = inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| {
); Message::MediaEditor(MediaEditorMsg::AuthorChanged(s))
let author_input = inputs::labeled_input( });
&t(locale, "editor.author"),
"",
&state.author,
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
);
let tags_input = inputs::labeled_input( let tags_input = inputs::labeled_input(
&t(locale, "editor.tags"), &t(locale, "editor.tags"),
&t(locale, "editor.tagsPlaceholder"), &t(locale, "editor.tagsPlaceholder"),
@@ -361,8 +371,12 @@ pub fn view<'a>(
Some(&state.language), Some(&state.language),
|lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)), |lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)),
); );
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill); let meta_row2 = row![caption_input, author_input]
let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill); .spacing(16)
.width(Length::Fill);
let meta_row3 = row![tags_input, language_input]
.spacing(16)
.width(Length::Fill);
let linked_posts_header = row![ let linked_posts_header = row![
text(t(locale, "editor.linkedPosts")) text(t(locale, "editor.linkedPosts"))
@@ -396,24 +410,30 @@ pub fn view<'a>(
.iter() .iter()
.map(|post| { .map(|post| {
button(text(post.title.clone()).size(12)) button(text(post.title.clone()).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(post.post_id.clone()))) .on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(
post.post_id.clone(),
)))
.padding([4, 10]) .padding([4, 10])
.width(Length::Fill) .width(Length::Fill)
.into() .into()
}) })
.collect() .collect()
}; };
container(column![search, column(results).spacing(4)].spacing(8).padding(8)) container(
.style(|_: &iced::Theme| iced::widget::container::Style { column![search, column(results).spacing(4)]
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))), .spacing(8)
border: iced::Border { .padding(8),
color: Color::from_rgb(0.28, 0.28, 0.32), )
width: 1.0, .style(|_: &iced::Theme| iced::widget::container::Style {
radius: 6.0.into(), background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
}, border: iced::Border {
..iced::widget::container::Style::default() color: Color::from_rgb(0.28, 0.28, 0.32),
}) width: 1.0,
.into() radius: 6.0.into(),
},
..iced::widget::container::Style::default()
})
.into()
} else { } else {
Space::new(0, 0).into() Space::new(0, 0).into()
}; };
@@ -430,11 +450,15 @@ pub fn view<'a>(
.map(|post| { .map(|post| {
row![ row![
button(text(post.title.clone()).size(12)) button(text(post.title.clone()).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(post.post_id.clone()))) .on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(
post.post_id.clone()
)))
.padding([4, 0]), .padding([4, 0]),
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
button(text(t(locale, "editor.unlinkMedia")).size(11)) button(text(t(locale, "editor.unlinkMedia")).size(11))
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(post.post_id.clone()))) .on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(
post.post_id.clone()
)))
.padding([3, 8]) .padding([3, 8])
.style(inputs::danger_button), .style(inputs::danger_button),
] ]
@@ -471,7 +495,7 @@ pub fn view<'a>(
] ]
.spacing(12) .spacing(12)
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill),
); );
container(body) container(body)
@@ -480,6 +504,29 @@ pub fn view<'a>(
.into() .into()
} }
fn no_preview<'a>(locale: UiLocale) -> Element<'a, Message> {
container(
text(t(locale, "editor.noPreviewAvailable"))
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.center_x(Length::Fill)
.center_y(Length::Fixed(200.0))
.into()
}
fn format_file_size(bytes: i64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::MediaEditorState; use super::MediaEditorState;
@@ -519,30 +566,10 @@ mod tests {
); );
let flags = state.translation_flags(); let flags = state.translation_flags();
let languages = flags.into_iter().map(|flag| flag.language).collect::<Vec<_>>(); let languages = flags
.into_iter()
.map(|flag| flag.language)
.collect::<Vec<_>>();
assert_eq!(languages, vec!["en".to_string(), "de".to_string()]); assert_eq!(languages, vec!["en".to_string(), "de".to_string()]);
} }
} }
fn no_preview<'a>() -> Element<'a, Message> {
container(
text("No preview available")
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.center_x(Length::Fill)
.center_y(Length::Fixed(200.0))
.into()
}
fn format_file_size(bytes: i64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
+13 -13
View File
@@ -1,18 +1,18 @@
pub mod workspace;
pub mod activity_bar; pub mod activity_bar;
pub mod sidebar; pub mod dashboard;
pub mod tab_bar; pub mod media_editor;
pub mod status_bar; pub mod modal;
pub mod panel; pub mod panel;
pub mod post_editor;
pub mod project_selector; pub mod project_selector;
pub mod script_editor;
pub mod settings_view;
pub mod sidebar;
pub mod site_validation;
pub mod status_bar;
pub mod tab_bar;
pub mod tags_view;
pub mod template_editor;
pub mod toast; pub mod toast;
pub mod welcome; pub mod welcome;
pub mod modal; pub mod workspace;
pub mod post_editor;
pub mod media_editor;
pub mod template_editor;
pub mod script_editor;
pub mod tags_view;
pub mod settings_view;
pub mod dashboard;
pub mod site_validation;
+183 -125
View File
@@ -1,7 +1,9 @@
use std::path::Path; use std::path::Path;
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{button, checkbox, column, container, image, row, scrollable, text, text_input, Space}; use iced::widget::{
Space, button, checkbox, column, container, image, row, scrollable, text, text_input,
};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -112,7 +114,10 @@ pub enum ConfirmAction {
DeleteTemplate(String), DeleteTemplate(String),
ForceDeleteTemplate(String), ForceDeleteTemplate(String),
DeleteTag(String), DeleteTag(String),
MergeTags { sources: Vec<String>, target: String }, MergeTags {
sources: Vec<String>,
target: String,
},
} }
// ── Modal backdrop style ── // ── Modal backdrop style ──
@@ -231,7 +236,11 @@ fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
} }
/// Render the modal overlay. /// Render the modal overlay.
pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Element<'static, Message> { pub fn view(
state: ModalState,
locale: UiLocale,
data_dir: Option<&Path>,
) -> Element<'static, Message> {
let modal_content: Element<'static, Message> = match state { let modal_content: Element<'static, Message> = match state {
ModalState::ConfirmDelete { ModalState::ConfirmDelete {
entity_name, entity_name,
@@ -452,14 +461,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
}), }),
) )
.padding([3, 8]) .padding([3, 8])
.style(|_: &Theme| container::Style { .style(|_: &Theme| {
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))), container::Style {
border: Border { background: Some(Background::Color(Color::from_rgb(
color: Color::from_rgb(0.30, 0.30, 0.35), 0.18, 0.20, 0.25,
width: 1.0, ))),
radius: 999.0.into(), border: Border {
}, color: Color::from_rgb(0.30, 0.30, 0.35),
..container::Style::default() width: 1.0,
radius: 999.0.into(),
},
..container::Style::default()
}
}), }),
] ]
.spacing(12) .spacing(12)
@@ -505,7 +518,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
row![ row![
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
button(text(t(locale, "modal.postInsertLink.insert"))) button(text(t(locale, "modal.postInsertLink.insert")))
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert)) .on_press(Message::PostEditor(
PostEditorMsg::PostInsertLinkExternalInsert
))
.padding([6, 16]) .padding([6, 16])
.style(confirm_button_style), .style(confirm_button_style),
] ]
@@ -537,11 +552,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.size(13) .size(13)
.shaping(Shaping::Advanced); .shaping(Shaping::Advanced);
let trailing_button: Element<'static, Message> = if active_tab == PostInsertLinkTab::Internal { let trailing_button: Element<'static, Message> =
create_post_btn if active_tab == PostInsertLinkTab::Internal {
} else { create_post_btn
Space::with_width(0.0).into() } else {
}; Space::with_width(0.0).into()
};
let buttons = row![ let buttons = row![
button(cancel_text) button(cancel_text)
@@ -597,31 +613,27 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.iter() .iter()
.map(|m| { .map(|m| {
let is_image = m.mime_type.starts_with("image/"); let is_image = m.mime_type.starts_with("image/");
let title = m let title = m.title.as_deref().unwrap_or("").to_string();
.title
.as_ref()
.map(|s| s.as_str())
.unwrap_or("")
.to_string();
let original_name = m.original_name.clone(); let original_name = m.original_name.clone();
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) { let thumb: Element<'static, Message> =
image(path) if let Some(path) = resolve_thumbnail_file(data_dir, m) {
.width(Length::Fixed(120.0)) image(path)
.height(Length::Fixed(90.0)) .width(Length::Fixed(120.0))
.into() .height(Length::Fixed(90.0))
} else if is_image { .into()
text("\u{1F5BC}") } else if is_image {
.size(32) text("\u{1F5BC}")
.shaping(Shaping::Advanced) .size(32)
.color(Color::from_rgb(0.40, 0.40, 0.45)) .shaping(Shaping::Advanced)
.into() .color(Color::from_rgb(0.40, 0.40, 0.45))
} else { .into()
text("\u{1F4C4}") } else {
.size(32) text("\u{1F4C4}")
.shaping(Shaping::Advanced) .size(32)
.color(Color::from_rgb(0.40, 0.40, 0.45)) .shaping(Shaping::Advanced)
.into() .color(Color::from_rgb(0.40, 0.40, 0.45))
}; .into()
};
let media_title = text(title.clone()) let media_title = text(title.clone())
.size(12) .size(12)
@@ -716,10 +728,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.size(13) .size(13)
.shaping(Shaping::Advanced); .shaping(Shaping::Advanced);
let buttons = row![button(cancel_text) let buttons = row![
.on_press(Message::DismissModal) button(cancel_text)
.padding([6, 16]) .on_press(Message::DismissModal)
.style(cancel_button_style),]; .padding([6, 16])
.style(cancel_button_style),
];
let content = column![ let content = column![
title, title,
@@ -754,24 +768,20 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, m)| { .map(|(index, m)| {
let title = m let title = m.title.as_deref().unwrap_or("").to_string();
.title let thumb: Element<'static, Message> =
.as_ref() if let Some(path) = resolve_thumbnail_file(data_dir, m) {
.map(|s| s.as_str()) image(path)
.unwrap_or("") .width(Length::Fixed(180.0))
.to_string(); .height(Length::Fixed(120.0))
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) { .into()
image(path) } else {
.width(Length::Fixed(180.0)) text("\u{1F5BC}")
.height(Length::Fixed(120.0)) .size(32)
.into() .shaping(Shaping::Advanced)
} else { .color(Color::from_rgb(0.40, 0.40, 0.45))
text("\u{1F5BC}") .into()
.size(32) };
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
};
let media_title = text(title) let media_title = text(title)
.size(12) .size(12)
@@ -845,17 +855,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
let lightbox: Element<'static, Message> = if let Some(index) = selected_index { let lightbox: Element<'static, Message> = if let Some(index) = selected_index {
if let Some(selected) = image_media.get(index) { if let Some(selected) = image_media.get(index) {
let preview: Element<'static, Message> = if let Some(path) = resolve_media_file(data_dir, selected) { let preview: Element<'static, Message> =
image(path) if let Some(path) = resolve_media_file(data_dir, selected) {
.width(Length::Fill) image(path)
.height(Length::Fixed(420.0)) .width(Length::Fill)
.into() .height(Length::Fixed(420.0))
} else { .into()
text(t(locale, "modal.postGallery.unavailable")) } else {
.size(13) text(t(locale, "modal.postGallery.unavailable"))
.shaping(Shaping::Advanced) .size(13)
.into() .shaping(Shaping::Advanced)
}; .into()
};
column![ column![
container(preview) container(preview)
.width(Length::Fill) .width(Length::Fill)
@@ -876,7 +887,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.style(cancel_button_style), .style(cancel_button_style),
Space::with_width(12.0), Space::with_width(12.0),
button(text(t(locale, "modal.postGallery.backToGrid"))) button(text(t(locale, "modal.postGallery.backToGrid")))
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox)) .on_press(Message::PostEditor(
PostEditorMsg::PostGalleryCloseLightbox
))
.padding([6, 12]) .padding([6, 12])
.style(cancel_button_style), .style(cancel_button_style),
] ]
@@ -915,35 +928,53 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(Color::WHITE); .color(Color::WHITE);
let rows = fields.iter().enumerate().map(|(index, field)| { let rows = fields
let toggle = checkbox(field.label.clone(), field.accepted) .iter()
.on_toggle_maybe((!field.locked) .enumerate()
.then_some(move |value| Message::ToggleAiSuggestionField(index, value))) .map(|(index, field)| {
.size(16) let toggle =
.text_size(13); checkbox(field.label.clone(), field.accepted)
container(column![ .on_toggle_maybe((!field.locked).then_some(move |value| {
toggle, Message::ToggleAiSuggestionField(index, value)
row![ }))
container(text(field.current_value.clone()).size(12).color(Color::from_rgb(0.58, 0.58, 0.64))).width(Length::FillPortion(1)), .size(16)
text("→").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)), .text_size(13);
container(text(field.suggested_value.clone()).size(12).color(Color::WHITE)).width(Length::FillPortion(1)), container(
] column![
.spacing(10) toggle,
.align_y(Alignment::Center), row![
] container(
.spacing(8)) text(field.current_value.clone())
.padding(10) .size(12)
.style(|_: &Theme| container::Style { .color(Color::from_rgb(0.58, 0.58, 0.64))
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))), )
border: Border { .width(Length::FillPortion(1)),
color: Color::from_rgb(0.30, 0.30, 0.35), text("→").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
width: 1.0, container(
radius: 6.0.into(), text(field.suggested_value.clone())
}, .size(12)
..container::Style::default() .color(Color::WHITE)
)
.width(Length::FillPortion(1)),
]
.spacing(10)
.align_y(Alignment::Center),
]
.spacing(8),
)
.padding(10)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 6.0.into(),
},
..container::Style::default()
})
.into()
}) })
.into() .collect::<Vec<Element<'static, Message>>>();
}).collect::<Vec<Element<'static, Message>>>();
let buttons = row![ let buttons = row![
button(text(t(locale, "common.cancel")).size(13)) button(text(t(locale, "common.cancel")).size(13))
@@ -972,35 +1003,59 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.into() .into()
} }
ModalState::LanguagePicker { target, source_language, available_targets } => { ModalState::LanguagePicker {
target,
source_language,
available_targets,
} => {
let title = text(t(locale, "modal.languagePicker.title")) let title = text(t(locale, "modal.languagePicker.title"))
.size(16) .size(16)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(Color::WHITE); .color(Color::WHITE);
let subtitle = text(format!("{}: {}", t(locale, "editor.language"), source_language)) let subtitle = text(format!(
.size(12) "{}: {}",
.shaping(Shaping::Advanced) t(locale, "editor.language"),
.color(Color::from_rgb(0.60, 0.60, 0.68)); source_language
))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.60, 0.60, 0.68));
let rows = if available_targets.is_empty() { let rows = if available_targets.is_empty() {
vec![text(t(locale, "common.noResults")).size(12).into()] vec![text(t(locale, "common.noResults")).size(12).into()]
} else { } else {
available_targets.into_iter().map(|language| { available_targets
let message = match &target { .into_iter()
AiEntityTarget::Post(_) => Message::PostEditor(PostEditorMsg::TranslateTo(language.code.clone())), .map(|language| {
AiEntityTarget::Media(_) => Message::MediaEditor(crate::views::media_editor::MediaEditorMsg::TranslateTo(language.code.clone())), let message = match &target {
}; AiEntityTarget::Post(_) => Message::PostEditor(
let status = language.existing_status.clone().unwrap_or_default(); PostEditorMsg::TranslateTo(language.code.clone()),
button(row![ ),
text(format!("{} {}", language.flag_emoji, language.name)).size(13).color(Color::WHITE), AiEntityTarget::Media(_) => Message::MediaEditor(
Space::with_width(Length::Fill), crate::views::media_editor::MediaEditorMsg::TranslateTo(
text(status).size(11).color(Color::from_rgb(0.60, 0.60, 0.68)), language.code.clone(),
].align_y(Alignment::Center)) ),
.on_press(message) ),
.padding([8, 12]) };
.style(cancel_button_style) let status = language.existing_status.clone().unwrap_or_default();
.into() button(
}).collect::<Vec<Element<'static, Message>>>() row![
text(format!("{} {}", language.flag_emoji, language.name))
.size(13)
.color(Color::WHITE),
Space::with_width(Length::Fill),
text(status)
.size(11)
.color(Color::from_rgb(0.60, 0.60, 0.68)),
]
.align_y(Alignment::Center),
)
.on_press(message)
.padding([8, 12])
.style(cancel_button_style)
.into()
})
.collect::<Vec<Element<'static, Message>>>()
}; };
let content = column![ let content = column![
@@ -1042,7 +1097,10 @@ mod tests {
#[test] #[test]
fn external_link_markdown_requires_url() { fn external_link_markdown_requires_url() {
assert_eq!(external_link_markdown("", "text"), None); assert_eq!(external_link_markdown("", "text"), None);
assert_eq!(external_link_markdown("https://example.com", ""), Some("https://example.com".to_string())); assert_eq!(
external_link_markdown("https://example.com", ""),
Some("https://example.com".to_string())
);
assert_eq!( assert_eq!(
external_link_markdown("https://example.com", "Example"), external_link_markdown("https://example.com", "Example"),
Some("[Example](https://example.com)".to_string()) Some("[Example](https://example.com)".to_string())
+113 -49
View File
@@ -1,5 +1,5 @@
use iced::widget::{button, column, container, scrollable, text, Space};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, scrollable, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -64,6 +64,10 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "arguments are independent panel state slices"
)]
pub fn view( pub fn view(
panel_tab: PanelTab, panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot], task_snapshots: &[TaskSnapshot],
@@ -79,39 +83,68 @@ pub fn view(
// Tab header — per layout.allium: tasks, output, post_links (only when // Tab header — per layout.allium: tasks, output, post_links (only when
// active editor tab is a post), git_log (only when active tab is post or // active editor tab is a post), git_log (only when active tab is post or
// media). // media).
let tasks_btn = button(text(t(locale, "common.tasks")).size(12).shaping(Shaping::Advanced)) let tasks_btn = button(
.on_press(Message::SetPanelTab(PanelTab::Tasks)) text(t(locale, "common.tasks"))
.padding([4, 8]) .size(12)
.style(if panel_tab == PanelTab::Tasks { tab_active } else { tab_inactive }); .shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::Tasks))
.padding([4, 8])
.style(if panel_tab == PanelTab::Tasks {
tab_active
} else {
tab_inactive
});
let output_btn = button(text(t(locale, "panel.output")).size(12).shaping(Shaping::Advanced)) let output_btn = button(
.on_press(Message::SetPanelTab(PanelTab::Output)) text(t(locale, "panel.output"))
.padding([4, 8]) .size(12)
.style(if panel_tab == PanelTab::Output { tab_active } else { tab_inactive }); .shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::Output))
.padding([4, 8])
.style(if panel_tab == PanelTab::Output {
tab_active
} else {
tab_inactive
});
let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced)) let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced))
.on_press(Message::TogglePanel) .on_press(Message::TogglePanel)
.padding([4, 6]) .padding([4, 6])
.style(close_btn_style); .style(close_btn_style);
let mut tab_row: Vec<Element<'static, Message>> = vec![ let mut tab_row: Vec<Element<'static, Message>> = vec![tasks_btn.into(), output_btn.into()];
tasks_btn.into(),
output_btn.into(),
];
if active_tab_is_post { if active_tab_is_post {
let post_links_btn = button(text(t(locale, "panel.postLinks")).size(12).shaping(Shaping::Advanced)) let post_links_btn = button(
.on_press(Message::SetPanelTab(PanelTab::PostLinks)) text(t(locale, "panel.postLinks"))
.padding([4, 8]) .size(12)
.style(if panel_tab == PanelTab::PostLinks { tab_active } else { tab_inactive }); .shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
.padding([4, 8])
.style(if panel_tab == PanelTab::PostLinks {
tab_active
} else {
tab_inactive
});
tab_row.push(post_links_btn.into()); tab_row.push(post_links_btn.into());
} }
if active_tab_is_post_or_media { if active_tab_is_post_or_media {
let git_log_btn = button(text(t(locale, "panel.gitLog")).size(12).shaping(Shaping::Advanced)) let git_log_btn = button(
.on_press(Message::SetPanelTab(PanelTab::GitLog)) text(t(locale, "panel.gitLog"))
.padding([4, 8]) .size(12)
.style(if panel_tab == PanelTab::GitLog { tab_active } else { tab_inactive }); .shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::GitLog))
.padding([4, 8])
.style(if panel_tab == PanelTab::GitLog {
tab_active
} else {
tab_inactive
});
tab_row.push(git_log_btn.into()); tab_row.push(git_log_btn.into());
} }
@@ -127,9 +160,14 @@ pub fn view(
let content: Element<'static, Message> = match panel_tab { let content: Element<'static, Message> = match panel_tab {
PanelTab::Tasks => { PanelTab::Tasks => {
if task_snapshots.is_empty() { if task_snapshots.is_empty() {
container(text(t(locale, "tasks.noActive")).size(12).shaping(Shaping::Advanced).color(muted)) container(
.padding(8) text(t(locale, "tasks.noActive"))
.into() .size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else { } else {
// Per layout.allium: last 10 tasks, newest first // Per layout.allium: last 10 tasks, newest first
let items: Vec<Element<'static, Message>> = task_snapshots let items: Vec<Element<'static, Message>> = task_snapshots
@@ -137,21 +175,24 @@ pub fn view(
.rev() .rev()
.take(10) .take(10)
.map(|snap| { .map(|snap| {
let progress_str = snap.progress let progress_str = snap
.progress
.map(|p| format!(" ({:.0}%)", p * 100.0)) .map(|p| format!(" ({:.0}%)", p * 100.0))
.unwrap_or_default(); .unwrap_or_default();
let phase_str = snap.message let phase_str = snap
.message
.as_deref() .as_deref()
.map(|m| format!(" \u{2014} {m}")) .map(|m| format!(" \u{2014} {m}"))
.unwrap_or_default(); .unwrap_or_default();
let status_text = format!( let status_text = format!(
"{} \u{2014} {}{}{}", "{} \u{2014} {}{}{}",
snap.label, snap.label, snap.status, progress_str, phase_str,
snap.status,
progress_str,
phase_str,
); );
text(status_text).size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.70, 0.70, 0.75)).into() text(status_text)
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75))
.into()
}) })
.collect(); .collect();
scrollable( scrollable(
@@ -164,9 +205,14 @@ pub fn view(
} }
PanelTab::Output => { PanelTab::Output => {
if output_entries.is_empty() { if output_entries.is_empty() {
container(text(t(locale, "panel.noOutput")).size(12).shaping(Shaping::Advanced).color(muted)) container(
.padding(8) text(t(locale, "panel.noOutput"))
.into() .size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else { } else {
let items: Vec<Element<'static, Message>> = output_entries let items: Vec<Element<'static, Message>> = output_entries
.iter() .iter()
@@ -188,9 +234,14 @@ pub fn view(
} }
PanelTab::PostLinks => { PanelTab::PostLinks => {
if post_outlinks.is_empty() && post_backlinks.is_empty() { if post_outlinks.is_empty() && post_backlinks.is_empty() {
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) container(
.padding(8) text(t(locale, "panel.postLinksPlaceholder"))
.into() .size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else { } else {
let mut items: Vec<Element<'static, Message>> = vec![ let mut items: Vec<Element<'static, Message>> = vec![
text(t(locale, "editor.outlinks")) text(t(locale, "editor.outlinks"))
@@ -201,7 +252,12 @@ pub fn view(
]; ];
if post_outlinks.is_empty() { if post_outlinks.is_empty() {
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into()); items.push(
text(t(locale, "panel.postLinksPlaceholder"))
.size(11)
.color(muted)
.into(),
);
} else { } else {
for link in post_outlinks { for link in post_outlinks {
items.push(post_link_button(locale, link)); items.push(post_link_button(locale, link));
@@ -218,7 +274,12 @@ pub fn view(
); );
if post_backlinks.is_empty() { if post_backlinks.is_empty() {
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into()); items.push(
text(t(locale, "panel.postLinksPlaceholder"))
.size(11)
.color(muted)
.into(),
);
} else { } else {
for link in post_backlinks { for link in post_backlinks {
items.push(post_link_button(locale, link)); items.push(post_link_button(locale, link));
@@ -235,19 +296,22 @@ pub fn view(
} }
PanelTab::GitLog => { PanelTab::GitLog => {
// Git Log content populated in extension bucket A (git integration) // Git Log content populated in extension bucket A (git integration)
container(text(t(locale, "panel.gitLogPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) container(
.padding(8) text(t(locale, "panel.gitLogPlaceholder"))
.into() .size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} }
}; };
container( container(column![tab_header, content].spacing(0))
column![tab_header, content].spacing(0), .width(Length::Fill)
) .height(Length::Fixed(200.0))
.width(Length::Fill) .style(panel_style)
.height(Length::Fixed(200.0)) .into()
.style(panel_style)
.into()
} }
fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> { fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> {
+101 -35
View File
@@ -2,12 +2,12 @@ use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use iced::widget::text::{Shaping, Wrapping}; use iced::widget::text::{Shaping, Wrapping};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space}; use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input};
use iced::{Color, Element, Length, Theme}; use iced::{Color, Element, Length, Theme};
use bds_core::i18n::{self, UiLocale}; use bds_core::i18n::{self, UiLocale};
use bds_core::model::{Post, PostStatus, PostTranslation}; use bds_core::model::{Post, PostStatus, PostTranslation};
use bds_editor::{highlighter, CodeEditor, EditorBuffer, EditorMessage}; use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
use crate::app::Message; use crate::app::Message;
use crate::components::inputs; use crate::components::inputs;
@@ -297,7 +297,11 @@ impl PostEditorState {
langs.sort(); langs.sort();
for lang in langs { for lang in langs {
let locale = i18n::normalize_language(&lang); let locale = i18n::normalize_language(&lang);
let status = match self.translation_drafts.get(&lang).map(|draft| &draft.status) { let status = match self
.translation_drafts
.get(&lang)
.map(|draft| &draft.status)
{
Some(PostStatus::Published) => "published", Some(PostStatus::Published) => "published",
Some(_) => "draft", Some(_) => "draft",
None => "missing", None => "missing",
@@ -480,10 +484,30 @@ pub fn view<'a>(
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
container( container(
column![ column![
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled), quick_action_item(
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled), locale,
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled), t(locale, "editor.aiAnalyze"),
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled), PostEditorMsg::AnalyzeWithAi,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.suggestTaxonomy"),
PostEditorMsg::AnalyzeTaxonomy,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.translate"),
PostEditorMsg::Translate,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.detectLanguage"),
PostEditorMsg::DetectLanguage,
ai_enabled
),
] ]
.spacing(4) .spacing(4)
) )
@@ -525,7 +549,7 @@ pub fn view<'a>(
let mut flag_row = row![].spacing(2); let mut flag_row = row![].spacing(2);
for flag in &flags { for flag in &flags {
let lang = flag.language.clone(); let lang = flag.language.clone();
let label = format!("{}", flag.flag_emoji); let label = flag.flag_emoji.to_string();
let btn = button(text(label).size(14).shaping(Shaping::Advanced)) let btn = button(text(label).size(14).shaping(Shaping::Advanced))
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang))) .on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
.padding([2, 4]) .padding([2, 4])
@@ -574,9 +598,13 @@ pub fn view<'a>(
Some(&state.language), Some(&state.language),
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)), |lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
); );
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced)) let detect_language = button(
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage))) text(t(locale, "editor.detectLanguage"))
.padding([6, 12]); .size(12)
.shaping(Shaping::Advanced),
)
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
.padding([6, 12]);
let template_input = inputs::labeled_input( let template_input = inputs::labeled_input(
&t(locale, "editor.templateSlug"), &t(locale, "editor.templateSlug"),
"", "",
@@ -619,11 +647,13 @@ pub fn view<'a>(
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() { let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
Space::new(0, 0).into() Space::new(0, 0).into()
} else { } else {
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.outlinks")) let mut items: Vec<Element<'a, Message>> = vec![
.size(12) text(t(locale, "editor.outlinks"))
.color(inputs::LABEL_COLOR) .size(12)
.shaping(Shaping::Advanced) .color(inputs::LABEL_COLOR)
.into()]; .shaping(Shaping::Advanced)
.into(),
];
for link in &state.outlinks { for link in &state.outlinks {
items.push( items.push(
text(format!("\u{2192} {}", link.title)) text(format!("\u{2192} {}", link.title))
@@ -639,11 +669,13 @@ pub fn view<'a>(
let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() { let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() {
Space::new(0, 0).into() Space::new(0, 0).into()
} else { } else {
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.backlinks")) let mut items: Vec<Element<'a, Message>> = vec![
.size(12) text(t(locale, "editor.backlinks"))
.color(inputs::LABEL_COLOR) .size(12)
.shaping(Shaping::Advanced) .color(inputs::LABEL_COLOR)
.into()]; .shaping(Shaping::Advanced)
.into(),
];
for link in &state.backlinks { for link in &state.backlinks {
items.push( items.push(
text(format!("\u{2190} {}", link.title)) text(format!("\u{2190} {}", link.title))
@@ -712,20 +744,32 @@ pub fn view<'a>(
] ]
.spacing(2) .spacing(2)
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "common.open")).size(11).shaping(Shaping::Advanced)) button(
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id))) text(t(locale, "common.open"))
.padding([4, 10]), .size(11)
button(text(t(locale, "editor.unlinkMedia")).size(11).shaping(Shaping::Advanced)) .shaping(Shaping::Advanced)
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(media_id))) )
.padding([4, 10]) .on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
.style(inputs::danger_button), .padding([4, 10]),
button(
text(t(locale, "editor.unlinkMedia"))
.size(11)
.shaping(Shaping::Advanced)
)
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(
media_id
)))
.padding([4, 10])
.style(inputs::danger_button),
] ]
.spacing(8) .spacing(8)
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
) )
.padding(8) .padding(8)
.style(|_: &Theme| container::Style { .style(|_: &Theme| container::Style {
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))), background: Some(iced::Background::Color(Color::from_rgb(
0.16, 0.18, 0.22,
))),
border: iced::Border { border: iced::Border {
color: Color::from_rgb(0.28, 0.28, 0.32), color: Color::from_rgb(0.28, 0.28, 0.32),
width: 1.0, width: 1.0,
@@ -788,13 +832,31 @@ pub fn view<'a>(
// ── Content section (fills remaining space) ── // ── Content section (fills remaining space) ──
let content_label = inputs::section_header(&t(locale, "editor.content")); let content_label = inputs::section_header(&t(locale, "editor.content"));
let mode_toggle = row![ let mode_toggle = row![
mode_button(locale, &state.editor_mode, "markdown", Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))), mode_button(
mode_button(locale, &state.editor_mode, "preview", Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))), locale,
&state.editor_mode,
"markdown",
Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))
),
mode_button(
locale,
&state.editor_mode,
"preview",
Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))
),
] ]
.spacing(6) .spacing(6)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
let body_toolbar = inputs::toolbar( let body_toolbar = inputs::toolbar(
vec![row![content_label, Space::with_width(Length::Fixed(12.0)), mode_toggle].align_y(iced::Alignment::Center).into()], vec![
row![
content_label,
Space::with_width(Length::Fixed(12.0)),
mode_toggle
]
.align_y(iced::Alignment::Center)
.into(),
],
vec![ vec![
if state.editor_mode == "markdown" { if state.editor_mode == "markdown" {
button( button(
@@ -980,10 +1042,15 @@ fn mode_button<'a>(
.into() .into()
} }
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> { fn quick_action_item<'a>(
locale: UiLocale,
label: String,
msg: PostEditorMsg,
enabled: bool,
) -> Element<'a, Message> {
let _ = locale; let _ = locale;
button(text(label).size(12).shaping(Shaping::Advanced)) button(text(label).size(12).shaping(Shaping::Advanced))
.on_press_maybe(enabled.then_some(Message::PostEditor(msg))) .on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
.padding([6, 12]) .padding([6, 12])
.style(status_bar::dropdown_item) .style(status_bar::dropdown_item)
.width(Length::Fixed(220.0)) .width(Length::Fixed(220.0))
@@ -1138,5 +1205,4 @@ mod tests {
state.set_editor_mode("preview"); state.set_editor_mode("preview");
assert_eq!(state.editor_mode, "preview"); assert_eq!(state.editor_mode, "preview");
} }
} }
+25 -7
View File
@@ -1,5 +1,5 @@
use iced::widget::{button, container, row, svg, text, Column, Space};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{Column, Space, button, container, row, svg, text};
use iced::{Background, Border, Color, Element, Length, Theme}; use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -120,19 +120,32 @@ pub fn view(
let label = if is_active { let label = if is_active {
row![ row![
text("\u{2713}").size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.40, 0.80, 0.40)), text("\u{2713}")
text(name).size(12).shaping(Shaping::Advanced).color(Color::WHITE), .size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.80, 0.40)),
text(name)
.size(12)
.shaping(Shaping::Advanced)
.color(Color::WHITE),
] ]
.spacing(6) .spacing(6)
} else { } else {
row![ row![
Space::with_width(Length::Fixed(14.0)), Space::with_width(Length::Fixed(14.0)),
text(name).size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.80, 0.80, 0.85)), text(name)
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.80, 0.80, 0.85)),
] ]
.spacing(6) .spacing(6)
}; };
let style_fn = if is_active { project_item_active } else { project_item }; let style_fn = if is_active {
project_item_active
} else {
project_item
};
items.push( items.push(
button(label) button(label)
@@ -159,8 +172,13 @@ pub fn view(
items.push( items.push(
button( button(
row![ row![
text("+").size(14).shaping(Shaping::Advanced).color(Color::from_rgb(0.55, 0.75, 0.95)), text("+")
text(t(locale, "projectSelector.newProject")).size(12).shaping(Shaping::Advanced), .size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.55, 0.75, 0.95)),
text(t(locale, "projectSelector.newProject"))
.size(12)
.shaping(Shaping::Advanced),
] ]
.spacing(6), .spacing(6),
) )
+35 -40
View File
@@ -1,6 +1,6 @@
use std::cell::RefCell; use std::cell::RefCell;
use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Color, Element, Length, Theme}; use iced::{Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -118,10 +118,7 @@ pub enum ScriptEditorMsg {
} }
/// Render the script editor view. /// Render the script editor view.
pub fn view<'a>( pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, Message> {
state: &'a ScriptEditorState,
locale: UiLocale,
) -> Element<'a, Message> {
let header = inputs::toolbar( let header = inputs::toolbar(
vec![ vec![
text(state.title.clone()).size(18).into(), text(state.title.clone()).size(18).into(),
@@ -162,7 +159,9 @@ pub fn view<'a>(
&state.slug, &state.slug,
|s| Message::ScriptEditor(ScriptEditorMsg::SlugChanged(s)), |s| Message::ScriptEditor(ScriptEditorMsg::SlugChanged(s)),
); );
let meta_row1 = row![title_input, slug_input].spacing(16).width(Length::Fill); let meta_row1 = row![title_input, slug_input]
.spacing(16)
.width(Length::Fill);
// Metadata row 2: kind, entrypoint, enabled // Metadata row 2: kind, entrypoint, enabled
let kind_options = vec![ let kind_options = vec![
@@ -179,7 +178,10 @@ pub fn view<'a>(
); );
let mut entrypoint_options = state.discovered_entrypoints.clone(); let mut entrypoint_options = state.discovered_entrypoints.clone();
if !entrypoint_options.iter().any(|entrypoint| entrypoint == &state.entrypoint) { if !entrypoint_options
.iter()
.any(|entrypoint| entrypoint == &state.entrypoint)
{
entrypoint_options.push(state.entrypoint.clone()); entrypoint_options.push(state.entrypoint.clone());
} }
let selected_entrypoint = entrypoint_options let selected_entrypoint = entrypoint_options
@@ -192,28 +194,30 @@ pub fn view<'a>(
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)), |entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
); );
let enabled_check = inputs::labeled_checkbox( let enabled_check =
&t(locale, "editor.enabled"), inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
state.enabled, Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b))
|b| Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b)), });
);
let meta_row2 = row![kind_select, entrypoint_input, enabled_check] let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
.spacing(16) .spacing(16)
.width(Length::Fill); .width(Length::Fill);
// Content editor (CodeEditor with syntax highlighting based on file extension) // Content editor (CodeEditor with syntax highlighting based on file extension)
let syntax_ext = if state.file_path.ends_with(".py") { "py" } else { "lua" }; let syntax_ext = if state.file_path.ends_with(".py") {
"py"
} else {
"lua"
};
let content_section: Element<'a, Message> = column![ let content_section: Element<'a, Message> = column![
inputs::section_header(&t(locale, "editor.content")), inputs::section_header(&t(locale, "editor.content")),
Element::from( Element::from(
CodeEditor::new( CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| {
&state.editor_buffer, match msg {
highlighter(), EditorMessage::ContentChanged(s) => {
syntax_ext, Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))
) }
.on_change(|msg| match msg { EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
EditorMessage::ContentChanged(s) => Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)), }
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
}) })
), ),
] ]
@@ -245,29 +249,20 @@ pub fn view<'a>(
// Top pane: header + metadata (scrollable for overflow) // Top pane: header + metadata (scrollable for overflow)
let top_pane = scrollable( let top_pane = scrollable(
column![ column![header, meta_row1, meta_row2,]
header, .spacing(12)
meta_row1, .padding(16)
meta_row2, .width(Length::Fill),
]
.spacing(12)
.padding(16)
.width(Length::Fill)
) )
.height(Length::Shrink); .height(Length::Shrink);
// Full layout: top pane (shrink), content (fill), validation + footer (shrink) // Full layout: top pane (shrink), content (fill), validation + footer (shrink)
column![ column![top_pane, content_section, validation, footer,]
top_pane, .spacing(4)
content_section, .padding([0, 16])
validation, .width(Length::Fill)
footer, .height(Length::Fill)
] .into()
.spacing(4)
.padding([0, 16])
.width(Length::Fill)
.height(Length::Fill)
.into()
} }
fn status_badge<'a>(status: &ScriptStatus) -> Element<'a, Message> { fn status_badge<'a>(status: &ScriptStatus) -> Element<'a, Message> {
+208 -144
View File
@@ -1,5 +1,5 @@
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
use iced::{Alignment, Color, Element, Length, Theme}; use iced::{Alignment, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -209,7 +209,13 @@ impl Default for SettingsViewState {
public_url: String::new(), public_url: String::new(),
main_language: "en".to_string(), main_language: "en".to_string(),
blog_languages: vec!["en".to_string()], blog_languages: vec!["en".to_string()],
available_languages: vec!["en".to_string(), "de".to_string(), "fr".to_string(), "it".to_string(), "es".to_string()], available_languages: vec![
"en".to_string(),
"de".to_string(),
"fr".to_string(),
"it".to_string(),
"es".to_string(),
],
default_author: String::new(), default_author: String::new(),
max_posts_per_page: "50".to_string(), max_posts_per_page: "50".to_string(),
blogmark_category: String::new(), blogmark_category: String::new(),
@@ -255,11 +261,11 @@ impl SettingsViewState {
fn ordered_sections(&self) -> Vec<SettingsSection> { fn ordered_sections(&self) -> Vec<SettingsSection> {
let mut sections = SettingsSection::all().to_vec(); let mut sections = SettingsSection::all().to_vec();
if let Some(active) = &self.active_section { if let Some(active) = &self.active_section
if let Some(index) = sections.iter().position(|section| section == active) { && let Some(index) = sections.iter().position(|section| section == active)
let focused = sections.remove(index); {
sections.insert(0, focused); let focused = sections.remove(index);
} sections.insert(0, focused);
} }
sections sections
} }
@@ -337,10 +343,7 @@ pub enum SettingsMsg {
} }
/// Render the settings view. /// Render the settings view.
pub fn view<'a>( pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
state: &'a SettingsViewState,
locale: UiLocale,
) -> Element<'a, Message> {
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query) let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s))) .on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
.size(14); .size(14);
@@ -388,38 +391,6 @@ pub fn view<'a>(
.into() .into()
} }
#[cfg(test)]
mod tests {
use super::{SettingsSection, SettingsViewState};
#[test]
fn focus_section_collapses_other_sections_and_clears_search() {
let mut state = SettingsViewState {
search_query: "publish".to_string(),
..SettingsViewState::default()
};
state.focus_section(SettingsSection::Publishing);
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
assert!(state.search_query.is_empty());
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
assert!(state.collapsed.contains(&SettingsSection::Project));
assert!(state.collapsed.contains(&SettingsSection::MCP));
}
#[test]
fn focused_section_is_rendered_first() {
let mut state = SettingsViewState::default();
state.focus_section(SettingsSection::Technology);
let ordered = state.ordered_sections();
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
assert_eq!(ordered.len(), SettingsSection::all().len());
}
}
fn render_section<'a>( fn render_section<'a>(
state: &'a SettingsViewState, state: &'a SettingsViewState,
section: &SettingsSection, section: &SettingsSection,
@@ -436,7 +407,9 @@ fn render_section<'a>(
.spacing(8) .spacing(8)
.align_y(Alignment::Center), .align_y(Alignment::Center),
) )
.on_press(Message::Settings(SettingsMsg::ToggleSection(section.clone()))) .on_press(Message::Settings(SettingsMsg::ToggleSection(
section.clone(),
)))
.padding([6, 8]) .padding([6, 8])
.width(Length::Fill) .width(Length::Fill)
.style(|_: &Theme, _| button::Style::default()); .style(|_: &Theme, _| button::Style::default());
@@ -456,9 +429,7 @@ fn render_section<'a>(
SettingsSection::MCP => section_mcp(locale), SettingsSection::MCP => section_mcp(locale),
}; };
column![header, content] column![header, content].spacing(4).into()
.spacing(4)
.into()
} }
fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -469,7 +440,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|s| Message::Settings(SettingsMsg::ProjectNameChanged(s)), |s| Message::Settings(SettingsMsg::ProjectNameChanged(s)),
); );
let desc = column![ let desc = column![
text(t(locale, "settings.projectDescription")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced), text(t(locale, "settings.projectDescription"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced),
text_editor(&state.project_description) text_editor(&state.project_description)
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a))) .on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
.height(Length::Fixed(80.0)) .height(Length::Fixed(80.0))
@@ -477,12 +451,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
] ]
.spacing(4); .spacing(4);
let data_path = row![ let data_path = row![
inputs::labeled_input( inputs::labeled_input(&t(locale, "settings.dataPath"), "", &state.data_path, |s| {
&t(locale, "settings.dataPath"), Message::Settings(SettingsMsg::DataPathChanged(s))
"", },),
&state.data_path,
|s| Message::Settings(SettingsMsg::DataPathChanged(s)),
),
button(text(t(locale, "settings.browse")).size(12)) button(text(t(locale, "settings.browse")).size(12))
.on_press(Message::Settings(SettingsMsg::BrowseDataPath)) .on_press(Message::Settings(SettingsMsg::BrowseDataPath))
.padding([6, 12]), .padding([6, 12]),
@@ -512,14 +483,24 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.iter() .iter()
.map(|language| { .map(|language| {
let label = if *language == state.main_language { let label = if *language == state.main_language {
format!("{} ({})", language, t(locale, "settings.mainLanguageRequired")) format!(
"{} ({})",
language,
t(locale, "settings.mainLanguageRequired")
)
} else { } else {
language.clone() language.clone()
}; };
inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), { inputs::labeled_checkbox(
let language = language.clone(); &label,
move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone())) state.blog_languages.iter().any(|item| item == language),
}) {
let language = language.clone();
move |_| {
Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
}
},
)
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
@@ -539,7 +520,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
let blogmark_category = inputs::labeled_select( let blogmark_category = inputs::labeled_select(
&t(locale, "settings.blogmarkCategory"), &t(locale, "settings.blogmarkCategory"),
&state.categories, &state.categories,
state.categories.iter().find(|row| row.name == state.blogmark_category), state
.categories
.iter()
.find(|row| row.name == state.blogmark_category),
|row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)), |row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)),
); );
let save = button(text(t(locale, "common.save")).size(13)) let save = button(text(t(locale, "common.save")).size(13))
@@ -566,9 +550,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
blogmark_category, blogmark_category,
save, save,
] ]
.spacing(8) .spacing(8)
.padding([0, 16]) .padding([0, 16])
.into() .into()
} }
fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -616,79 +600,112 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.chain(state.template_options.iter().cloned()) .chain(state.template_options.iter().cloned())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| { let category_rows = state
let title = inputs::labeled_input( .categories
&tw(locale, "settings.categoryTitle", &[("category", &category.name)]), .iter()
"", .fold(column![].spacing(8), |column, category| {
&category.title, let title = inputs::labeled_input(
{ &tw(
let name = category.name.clone(); locale,
move |value| Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value)) "settings.categoryTitle",
}, &[("category", &category.name)],
); ),
let post_template = inputs::labeled_select( "",
&t(locale, "settings.categoryPostTemplate"), &category.title,
&template_options,
Some(&category.post_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryPostTemplateChanged(name.clone(), value))
},
);
let list_template = inputs::labeled_select(
&t(locale, "settings.categoryListTemplate"),
&template_options,
Some(&category.list_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryListTemplateChanged(name.clone(), value))
},
);
let toggles = column![
inputs::labeled_checkbox(
&t(locale, "settings.categoryRenderInLists"),
category.render_in_lists,
{ {
let name = category.name.clone(); let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value)) move |value| {
Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
}
}, },
), );
inputs::labeled_checkbox( let post_template = inputs::labeled_select(
&t(locale, "settings.categoryShowTitles"), &t(locale, "settings.categoryPostTemplate"),
category.show_title, &template_options,
Some(&category.post_template_slug),
{ {
let name = category.name.clone(); let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value)) move |value| {
Message::Settings(SettingsMsg::CategoryPostTemplateChanged(
name.clone(),
value,
))
}
}, },
), );
] let list_template = inputs::labeled_select(
.spacing(6); &t(locale, "settings.categoryListTemplate"),
let actions = row![ &template_options,
button(text(t(locale, "common.save")).size(13)) Some(&category.list_template_slug),
.on_press(Message::Settings(SettingsMsg::SaveCategory(category.name.clone()))) {
.style(inputs::primary_button) let name = category.name.clone();
.padding([6, 12]), move |value| {
button(text(t(locale, "common.remove")).size(13)) Message::Settings(SettingsMsg::CategoryListTemplateChanged(
.on_press_maybe((!category.is_protected).then(|| Message::Settings(SettingsMsg::RemoveCategory(category.name.clone())))) name.clone(),
.style(inputs::danger_button) value,
.padding([6, 12]), ))
] }
.spacing(8); },
);
let toggles = column![
inputs::labeled_checkbox(
&t(locale, "settings.categoryRenderInLists"),
category.render_in_lists,
{
let name = category.name.clone();
move |value| {
Message::Settings(SettingsMsg::CategoryRenderInListsChanged(
name.clone(),
value,
))
}
},
),
inputs::labeled_checkbox(
&t(locale, "settings.categoryShowTitles"),
category.show_title,
{
let name = category.name.clone();
move |value| {
Message::Settings(SettingsMsg::CategoryShowTitleChanged(
name.clone(),
value,
))
}
},
),
]
.spacing(6);
let actions = row![
button(text(t(locale, "common.save")).size(13))
.on_press(Message::Settings(SettingsMsg::SaveCategory(
category.name.clone()
)))
.style(inputs::primary_button)
.padding([6, 12]),
button(text(t(locale, "common.remove")).size(13))
.on_press_maybe((!category.is_protected).then(|| Message::Settings(
SettingsMsg::RemoveCategory(category.name.clone())
)))
.style(inputs::danger_button)
.padding([6, 12]),
]
.spacing(8);
column.push( column.push(
container( container(
column![ column![
text(category.name.clone()).size(15).color(Color::WHITE), text(category.name.clone()).size(15).color(Color::WHITE),
title, title,
toggles, toggles,
row![post_template, list_template].spacing(12), row![post_template, list_template].spacing(12),
actions, actions,
] ]
.spacing(8), .spacing(8),
)
.padding(12),
) )
.padding(12), });
)
});
let add_row = row![ let add_row = row![
inputs::labeled_input( inputs::labeled_input(
@@ -732,7 +749,12 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
label: t(locale, "tags.noTemplate"), label: t(locale, "tags.noTemplate"),
supports_vision: false, supports_vision: false,
}) })
.chain(active_model_options.iter().filter(|option| option.supports_vision).cloned()) .chain(
active_model_options
.iter()
.filter(|option| option.supports_vision)
.cloned(),
)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let offline = inputs::labeled_checkbox( let offline = inputs::labeled_checkbox(
@@ -786,23 +808,32 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
let default_model = inputs::labeled_select( let default_model = inputs::labeled_select(
&t(locale, "settings.defaultModel"), &t(locale, "settings.defaultModel"),
&model_options, &model_options,
model_options.iter().find(|option| option.id == state.default_model), model_options
.iter()
.find(|option| option.id == state.default_model),
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)), |option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
); );
let title_model = inputs::labeled_select( let title_model = inputs::labeled_select(
&t(locale, "settings.titleModel"), &t(locale, "settings.titleModel"),
&model_options, &model_options,
model_options.iter().find(|option| option.id == state.title_model), model_options
.iter()
.find(|option| option.id == state.title_model),
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)), |option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
); );
let image_model = inputs::labeled_select( let image_model = inputs::labeled_select(
&t(locale, "settings.imageAnalysisModel"), &t(locale, "settings.imageAnalysisModel"),
&image_model_options, &image_model_options,
image_model_options.iter().find(|option| option.id == state.image_model), image_model_options
.iter()
.find(|option| option.id == state.image_model),
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)), |option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
); );
let prompt = column![ let prompt = column![
text(t(locale, "settings.systemPrompt")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced), text(t(locale, "settings.systemPrompt"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced),
text_editor(&state.system_prompt) text_editor(&state.system_prompt)
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a))) .on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
.height(Length::Fixed(200.0)) .height(Length::Fixed(200.0))
@@ -822,11 +853,15 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
column![ column![
offline, offline,
text(t(locale, "settings.onlineEndpointSection")).size(12).color(inputs::LABEL_COLOR), text(t(locale, "settings.onlineEndpointSection"))
.size(12)
.color(inputs::LABEL_COLOR),
online_url, online_url,
row![online_model, online_api_key].spacing(12), row![online_model, online_api_key].spacing(12),
online_refresh, online_refresh,
text(t(locale, "settings.airplaneEndpointSection")).size(12).color(inputs::LABEL_COLOR), text(t(locale, "settings.airplaneEndpointSection"))
.size(12)
.color(inputs::LABEL_COLOR),
airplane_url, airplane_url,
row![airplane_model, airplane_refresh].spacing(12), row![airplane_model, airplane_refresh].spacing(12),
default_model, default_model,
@@ -835,9 +870,9 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
prompt, prompt,
btns, btns,
] ]
.spacing(8) .spacing(8)
.padding([0, 16]) .padding([0, 16])
.into() .into()
} }
fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -857,12 +892,9 @@ fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Ele
} }
fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let host = inputs::labeled_input( let host = inputs::labeled_input(&t(locale, "settings.sshHost"), "", &state.ssh_host, |s| {
&t(locale, "settings.sshHost"), Message::Settings(SettingsMsg::SshHostChanged(s))
"", });
&state.ssh_host,
|s| Message::Settings(SettingsMsg::SshHostChanged(s)),
);
let user = inputs::labeled_input( let user = inputs::labeled_input(
&t(locale, "settings.sshUsername"), &t(locale, "settings.sshUsername"),
"", "",
@@ -939,3 +971,35 @@ fn section_mcp<'a>(locale: UiLocale) -> Element<'a, Message> {
.color(Color::from_rgb(0.5, 0.5, 0.5)) .color(Color::from_rgb(0.5, 0.5, 0.5))
.into() .into()
} }
#[cfg(test)]
mod tests {
use super::{SettingsSection, SettingsViewState};
#[test]
fn focus_section_collapses_other_sections_and_clears_search() {
let mut state = SettingsViewState {
search_query: "publish".to_string(),
..SettingsViewState::default()
};
state.focus_section(SettingsSection::Publishing);
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
assert!(state.search_query.is_empty());
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
assert!(state.collapsed.contains(&SettingsSection::Project));
assert!(state.collapsed.contains(&SettingsSection::MCP));
}
#[test]
fn focused_section_is_rendered_first() {
let mut state = SettingsViewState::default();
state.focus_section(SettingsSection::Technology);
let ordered = state.ordered_sections();
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
assert_eq!(ordered.len(), SettingsSection::all().len());
}
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more