chore: source formatting and spec allignment
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -805,6 +805,7 @@ dependencies = [
|
||||
"liquid-core",
|
||||
"pagefind",
|
||||
"pulldown-cmark",
|
||||
"quick-xml 0.41.0",
|
||||
"rayon",
|
||||
"refinery",
|
||||
"reqwest",
|
||||
@@ -5638,6 +5639,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
|
||||
@@ -37,6 +37,7 @@ open = "5"
|
||||
pulldown-cmark = "0.13"
|
||||
liquid = "0.26"
|
||||
liquid-core = { version = "0.26", features = ["derive"] }
|
||||
quick-xml = "0.41"
|
||||
rayon = "1.10"
|
||||
pagefind = "1.5.2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
@@ -227,7 +227,7 @@ Rules:
|
||||
|
||||
### `bds-ui/views`
|
||||
|
||||
- preview controls
|
||||
- internal Markdown/Preview switch backed by Wry, plus external Open in Browser command
|
||||
- generation progress display
|
||||
- render errors and diagnostics
|
||||
- AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate)
|
||||
|
||||
14
RUST_PLAN.md
14
RUST_PLAN.md
@@ -25,7 +25,7 @@ This plan is split into multiple documents:
|
||||
## 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.
|
||||
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.
|
||||
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.
|
||||
@@ -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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
### 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).
|
||||
- **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.
|
||||
- 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-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.
|
||||
|
||||
## 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.
|
||||
- **Memory usage / startup:** no BEAM VM, no browser engine — a single native process.
|
||||
- **Zero install dependencies:** users download one binary. No Homebrew, no system packages.
|
||||
- **Memory usage / startup:** no BEAM VM or bundled browser engine — one native application process plus the operating system webview used while preview is visible.
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
5. Publish the generated output to a remote target.
|
||||
6. Rebuild the database from files and run metadata diff without losing information.
|
||||
|
||||
@@ -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 and generated HTML use local assets only
|
||||
- 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
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
||||
### Done when
|
||||
|
||||
- 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
|
||||
|
||||
## Wave 5: Publishing And Operational Integrity
|
||||
|
||||
@@ -22,6 +22,7 @@ sys-locale = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
liquid = { workspace = true }
|
||||
liquid-core = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
pagefind = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
5
crates/bds-core/migrations/V3__align_ai_schema.sql
Normal file
5
crates/bds-core/migrations/V3__align_ai_schema.sql
Normal file
@@ -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;
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::migrations;
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use crate::db::migrations;
|
||||
|
||||
/// Database wrapper managing a SQLite connection.
|
||||
pub struct Database {
|
||||
@@ -11,7 +11,9 @@ impl Database {
|
||||
/// Open an existing bDS project database.
|
||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -162,11 +162,9 @@ pub const POST_TRANSLATION_COLUMNS: &str = "\
|
||||
id, project_id, translation_for, language, title, excerpt, content, \
|
||||
status, file_path, checksum, created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_LINK_COLUMNS: &str =
|
||||
"id, source_post_id, target_post_id, link_text, created_at";
|
||||
pub const POST_LINK_COLUMNS: &str = "id, source_post_id, target_post_id, link_text, created_at";
|
||||
|
||||
pub const POST_MEDIA_COLUMNS: &str =
|
||||
"id, project_id, post_id, media_id, sort_order, created_at";
|
||||
pub const POST_MEDIA_COLUMNS: &str = "id, project_id, post_id, media_id, sort_order, created_at";
|
||||
|
||||
pub const MEDIA_COLUMNS: &str = "\
|
||||
id, project_id, filename, original_name, mime_type, size, \
|
||||
@@ -189,8 +187,7 @@ pub const SCRIPT_COLUMNS: &str = "\
|
||||
|
||||
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
|
||||
|
||||
pub const GENERATED_FILE_HASH_COLUMNS: &str =
|
||||
"project_id, relative_path, content_hash, updated_at";
|
||||
pub const GENERATED_FILE_HASH_COLUMNS: &str = "project_id, relative_path, content_hash, updated_at";
|
||||
|
||||
pub const DB_NOTIFICATION_COLUMNS: &str =
|
||||
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
|
||||
@@ -418,7 +415,10 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_post_status_valid() {
|
||||
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
|
||||
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
|
||||
assert_eq!(
|
||||
parse_post_status("published").unwrap(),
|
||||
PostStatus::Published
|
||||
);
|
||||
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
|
||||
}
|
||||
|
||||
@@ -431,8 +431,14 @@ mod tests {
|
||||
fn parse_template_kind_valid() {
|
||||
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
|
||||
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
|
||||
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
|
||||
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
|
||||
assert_eq!(
|
||||
parse_template_kind("not_found").unwrap(),
|
||||
TemplateKind::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
parse_template_kind("partial").unwrap(),
|
||||
TemplateKind::Partial
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -442,36 +448,69 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_template_status_valid() {
|
||||
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
|
||||
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
|
||||
assert_eq!(
|
||||
parse_template_status("draft").unwrap(),
|
||||
TemplateStatus::Draft
|
||||
);
|
||||
assert_eq!(
|
||||
parse_template_status("published").unwrap(),
|
||||
TemplateStatus::Published
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_kind_valid() {
|
||||
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
|
||||
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
|
||||
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
|
||||
assert_eq!(
|
||||
parse_script_kind("transform").unwrap(),
|
||||
ScriptKind::Transform
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_status_valid() {
|
||||
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
|
||||
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
|
||||
assert_eq!(
|
||||
parse_script_status("published").unwrap(),
|
||||
ScriptStatus::Published
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_entity_valid() {
|
||||
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
|
||||
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
|
||||
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
|
||||
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
|
||||
assert_eq!(
|
||||
parse_notification_entity("post").unwrap(),
|
||||
NotificationEntity::Post
|
||||
);
|
||||
assert_eq!(
|
||||
parse_notification_entity("media").unwrap(),
|
||||
NotificationEntity::Media
|
||||
);
|
||||
assert_eq!(
|
||||
parse_notification_entity("script").unwrap(),
|
||||
NotificationEntity::Script
|
||||
);
|
||||
assert_eq!(
|
||||
parse_notification_entity("template").unwrap(),
|
||||
NotificationEntity::Template
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_action_valid() {
|
||||
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
|
||||
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
|
||||
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
|
||||
assert_eq!(
|
||||
parse_notification_action("created").unwrap(),
|
||||
NotificationAction::Created
|
||||
);
|
||||
assert_eq!(
|
||||
parse_notification_action("updated").unwrap(),
|
||||
NotificationAction::Updated
|
||||
);
|
||||
assert_eq!(
|
||||
parse_notification_action("deleted").unwrap(),
|
||||
NotificationAction::Deleted
|
||||
);
|
||||
}
|
||||
|
||||
// ── JSON helpers ─────────────────────────────────────────────────
|
||||
@@ -504,11 +543,13 @@ mod tests {
|
||||
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
let p = c
|
||||
.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
|
||||
[],
|
||||
project_from_row,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.id, "p1");
|
||||
assert_eq!(p.name, "Blog");
|
||||
assert_eq!(p.slug, "blog");
|
||||
@@ -527,7 +568,8 @@ mod tests {
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
@@ -541,11 +583,13 @@ mod tests {
|
||||
1000, 2000, NULL)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
let p = c
|
||||
.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
|
||||
[],
|
||||
post_from_row,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.id, "x");
|
||||
assert_eq!(p.project_id, "p1");
|
||||
assert_eq!(p.title, "Hello");
|
||||
@@ -575,19 +619,23 @@ mod tests {
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at)
|
||||
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
|
||||
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let t = c.query_row(
|
||||
)
|
||||
.unwrap();
|
||||
let t = c
|
||||
.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
|
||||
[],
|
||||
template_from_row,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t.kind, TemplateKind::NotFound);
|
||||
assert!(!t.enabled);
|
||||
assert_eq!(t.version, 3);
|
||||
@@ -604,11 +652,15 @@ mod tests {
|
||||
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let n = c.query_row(
|
||||
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
|
||||
let n = c
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"
|
||||
),
|
||||
[],
|
||||
db_notification_from_row,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n.entity_type, NotificationEntity::Media);
|
||||
assert_eq!(n.entity_id, "m1");
|
||||
assert_eq!(n.action, NotificationAction::Deleted);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use rust_stemmers::{Algorithm, Stemmer};
|
||||
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).
|
||||
///
|
||||
@@ -22,7 +24,7 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
caption,
|
||||
original_name,
|
||||
tags
|
||||
);"
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -82,6 +84,10 @@ pub struct MediaTranslationFts {
|
||||
/// 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.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "FTS columns mirror the persisted post fields"
|
||||
)]
|
||||
pub fn index_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
@@ -137,6 +143,10 @@ pub fn index_post(
|
||||
/// 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.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "FTS columns mirror the persisted media fields"
|
||||
)]
|
||||
pub fn index_media(
|
||||
conn: &Connection,
|
||||
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.
|
||||
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 mut stmt = conn.prepare(
|
||||
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank")?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
@@ -255,16 +266,28 @@ pub fn search_posts_filtered(
|
||||
// Get FTS matches first
|
||||
let fts_ids = search_posts(conn, query, language)?;
|
||||
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
|
||||
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!(
|
||||
"SELECT id FROM posts WHERE id IN ({}) ",
|
||||
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;
|
||||
|
||||
if let Some(status) = filters.status {
|
||||
@@ -296,29 +319,21 @@ pub fn search_posts_filtered(
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
// Filter by year from created_at (unix ms)
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
|
||||
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!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
|
||||
let (start, end) =
|
||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||
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(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 {
|
||||
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()));
|
||||
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 total: usize = {
|
||||
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))?
|
||||
};
|
||||
|
||||
@@ -365,22 +381,26 @@ pub fn search_posts_filtered(
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
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| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
|
||||
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.
|
||||
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 mut stmt = conn.prepare(
|
||||
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank")?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
@@ -526,7 +546,8 @@ mod tests {
|
||||
language: "en".into(),
|
||||
}],
|
||||
"en",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_posts(db.conn(), "spider", "en").unwrap();
|
||||
assert_eq!(results, vec!["post-1"]);
|
||||
@@ -545,7 +566,8 @@ mod tests {
|
||||
&["nature".into()],
|
||||
&[],
|
||||
"en",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_media(db.conn(), "sunset", "en").unwrap();
|
||||
assert_eq!(results, vec!["media-1"]);
|
||||
@@ -561,10 +583,7 @@ mod tests {
|
||||
#[test]
|
||||
fn remove_from_index() {
|
||||
let db = setup();
|
||||
index_post(
|
||||
db.conn(), "p1", "Test", None, None,
|
||||
&[], &[], &[], "en",
|
||||
).unwrap();
|
||||
index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap();
|
||||
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
|
||||
|
||||
remove_post_from_index(db.conn(), "p1").unwrap();
|
||||
@@ -586,8 +605,13 @@ mod tests {
|
||||
let db = setup();
|
||||
// German post with English translation
|
||||
index_post(
|
||||
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
|
||||
&[], &[],
|
||||
db.conn(),
|
||||
"p1",
|
||||
"Programmierung",
|
||||
None,
|
||||
Some("Deutsche Entwicklung"),
|
||||
&[],
|
||||
&[],
|
||||
&[PostTranslationFts {
|
||||
title: "English development programming".into(),
|
||||
excerpt: None,
|
||||
@@ -595,7 +619,8 @@ mod tests {
|
||||
language: "en".into(),
|
||||
}],
|
||||
"de",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search with English stemming should find via English translation
|
||||
let results = search_posts(db.conn(), "develop", "en").unwrap();
|
||||
@@ -605,7 +630,18 @@ mod tests {
|
||||
#[test]
|
||||
fn search_by_title_field() {
|
||||
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
|
||||
let results = search_posts(db.conn(), "unique", "en").unwrap();
|
||||
@@ -615,7 +651,18 @@ mod tests {
|
||||
#[test]
|
||||
fn search_by_tags() {
|
||||
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();
|
||||
assert_eq!(results, vec!["p1"]);
|
||||
@@ -624,7 +671,18 @@ mod tests {
|
||||
#[test]
|
||||
fn search_by_categories() {
|
||||
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();
|
||||
assert_eq!(results, vec!["p1"]);
|
||||
@@ -647,8 +705,30 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"post1",
|
||||
"Test Post",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"post2",
|
||||
"Draft Post",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filters = PostSearchFilters {
|
||||
status: Some("published"),
|
||||
@@ -670,19 +750,45 @@ mod tests {
|
||||
// 2023-06-15 in unix ms
|
||||
let ts_2023: i64 = 1686873600000;
|
||||
|
||||
db.conn().execute(
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
|
||||
rusqlite::params![ts_2024],
|
||||
).unwrap();
|
||||
db.conn().execute(
|
||||
)
|
||||
.unwrap();
|
||||
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)",
|
||||
rusqlite::params![ts_2023],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p2024",
|
||||
"Year 2024",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p2023",
|
||||
"Year 2023",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filters = PostSearchFilters {
|
||||
year: Some(2024),
|
||||
@@ -707,7 +813,18 @@ mod tests {
|
||||
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
|
||||
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
|
||||
).unwrap();
|
||||
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
&id,
|
||||
"Searchable",
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let filters = PostSearchFilters {
|
||||
|
||||
@@ -2,14 +2,16 @@ use rusqlite::Connection;
|
||||
|
||||
mod embedded {
|
||||
use refinery::embed_migrations;
|
||||
embed_migrations!("migrations");
|
||||
embed_migrations!("./migrations");
|
||||
}
|
||||
|
||||
/// Run all embedded migrations against the given connection using refinery.
|
||||
///
|
||||
/// Creates the full bDS schema as specified in specs/schema.allium.
|
||||
/// 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)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -63,19 +65,34 @@ mod tests {
|
||||
fn all_tables_exist() {
|
||||
let conn = setup();
|
||||
let expected = [
|
||||
"projects", "posts", "post_translations", "media", "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",
|
||||
"projects",
|
||||
"posts",
|
||||
"post_translations",
|
||||
"media",
|
||||
"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 {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {table}"),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}"));
|
||||
assert_eq!(count, 0, "table '{table}' should start empty");
|
||||
}
|
||||
@@ -89,11 +106,9 @@ mod tests {
|
||||
fn refinery_schema_history_exists() {
|
||||
let conn = setup();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM refinery_schema_history",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.query_row("SELECT COUNT(*) FROM refinery_schema_history", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(count >= 1, "refinery should track at least one migration");
|
||||
}
|
||||
@@ -124,7 +139,10 @@ mod tests {
|
||||
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]
|
||||
@@ -156,7 +174,10 @@ mod tests {
|
||||
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]
|
||||
@@ -174,7 +195,10 @@ mod tests {
|
||||
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]
|
||||
@@ -185,13 +209,17 @@ mod tests {
|
||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
|
||||
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
let err = conn.execute(
|
||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
|
||||
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]
|
||||
@@ -208,7 +236,10 @@ mod tests {
|
||||
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]
|
||||
@@ -225,7 +256,10 @@ mod tests {
|
||||
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]
|
||||
@@ -238,13 +272,17 @@ mod tests {
|
||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||
VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
let err = conn.execute(
|
||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||
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]
|
||||
@@ -261,7 +299,10 @@ mod tests {
|
||||
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]
|
||||
@@ -278,7 +319,10 @@ mod tests {
|
||||
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();
|
||||
|
||||
let title: String = conn.query_row(
|
||||
"SELECT title FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let title: String = conn
|
||||
.query_row("SELECT title FROM posts WHERE id = 'post1'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(title, "Hello");
|
||||
|
||||
let tags: String = conn.query_row(
|
||||
"SELECT tags FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let tags: String = conn
|
||||
.query_row("SELECT tags FROM posts WHERE id = 'post1'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(tags, "[\"rust\",\"blog\"]");
|
||||
|
||||
let content: Option<String> = conn.query_row(
|
||||
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let content: Option<String> = conn
|
||||
.query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(content.as_deref(), Some("Body text"));
|
||||
}
|
||||
|
||||
@@ -349,10 +399,15 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let content: Option<String> = conn.query_row(
|
||||
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
assert!(content.is_none(), "published post content must be null in DB");
|
||||
let content: Option<String> = conn
|
||||
.query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
content.is_none(),
|
||||
"published post content must be null in DB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -366,9 +421,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let (lang, title): (String, String) = conn.query_row(
|
||||
"SELECT language, title FROM post_translations WHERE id = 't1'", [], |r| Ok((r.get(0)?, r.get(1)?))
|
||||
).unwrap();
|
||||
let (lang, title): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT language, title FROM post_translations WHERE id = 't1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lang, "de");
|
||||
assert_eq!(title, "Hallo");
|
||||
}
|
||||
@@ -386,10 +445,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn.query_row(
|
||||
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
).unwrap();
|
||||
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn
|
||||
.query_row(
|
||||
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(orig, "photo.jpg");
|
||||
assert_eq!(w, Some(1920));
|
||||
assert_eq!(h, Some(1080));
|
||||
@@ -407,10 +469,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let (title, alt): (Option<String>, Option<String>) = conn.query_row(
|
||||
"SELECT title, alt FROM media_translations WHERE id = 'mt1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?))
|
||||
).unwrap();
|
||||
let (title, alt): (Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT title, alt FROM media_translations WHERE id = 'mt1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(title.as_deref(), Some("Sonnenuntergang"));
|
||||
assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang"));
|
||||
}
|
||||
@@ -425,10 +490,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let (name, color, tpl): (String, Option<String>, Option<String>) = conn.query_row(
|
||||
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
).unwrap();
|
||||
let (name, color, tpl): (String, Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "rust");
|
||||
assert_eq!(color.as_deref(), Some("#ff5733"));
|
||||
assert_eq!(tpl.as_deref(), Some("tag-tpl"));
|
||||
@@ -444,14 +512,20 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn.query_row(
|
||||
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
).unwrap();
|
||||
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kind, "post");
|
||||
assert_eq!(ver, 3);
|
||||
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]
|
||||
@@ -464,10 +538,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let (kind, ep, content): (String, String, Option<String>) = conn.query_row(
|
||||
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
).unwrap();
|
||||
let (kind, ep, content): (String, String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kind, "macro");
|
||||
assert_eq!(ep, "render");
|
||||
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)
|
||||
VALUES ('pl1', 'post1', 'post2', 'see also', 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (src, tgt, txt): (String, String, Option<String>) = conn.query_row(
|
||||
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
).unwrap();
|
||||
let (src, tgt, txt): (String, String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(src, "post1");
|
||||
assert_eq!(tgt, "post2");
|
||||
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)
|
||||
VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let order: i32 = conn.query_row(
|
||||
"SELECT sort_order FROM post_media WHERE id = 'pm1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let order: i32 = conn
|
||||
.query_row(
|
||||
"SELECT sort_order FROM post_media WHERE id = 'pm1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(order, 5);
|
||||
}
|
||||
|
||||
@@ -518,11 +604,14 @@ mod tests {
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let val: String = conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'theme'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let val: String = conn
|
||||
.query_row("SELECT value FROM settings WHERE key = 'theme'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(val, "dark");
|
||||
}
|
||||
|
||||
@@ -550,11 +639,16 @@ mod tests {
|
||||
"INSERT INTO chat_conversations (id, title, model, created_at, updated_at)
|
||||
VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let title: String = conn.query_row(
|
||||
"SELECT title FROM chat_conversations WHERE id = 'c1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let title: String = conn
|
||||
.query_row(
|
||||
"SELECT title FROM chat_conversations WHERE id = 'c1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(title, "Test Chat");
|
||||
}
|
||||
|
||||
@@ -565,28 +659,59 @@ mod tests {
|
||||
"INSERT INTO chat_conversations (id, title, created_at, updated_at)
|
||||
VALUES ('c1', 'Chat', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO chat_messages (conversation_id, role, content, created_at)
|
||||
VALUES ('c1', 'user', 'Hello', 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (role, content): (String, Option<String>) = conn.query_row(
|
||||
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'", [],
|
||||
|r| Ok((r.get(0)?, r.get(1)?))
|
||||
).unwrap();
|
||||
let (role, content): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(role, "user");
|
||||
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]
|
||||
fn roundtrip_ai_provider_and_model() {
|
||||
let conn = setup();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"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)",
|
||||
@@ -596,12 +721,16 @@ mod tests {
|
||||
"INSERT INTO ai_model_modalities (provider, model_id, direction, modality)
|
||||
VALUES ('openai', 'gpt-4', 'input', 'text')",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let name: String = conn.query_row(
|
||||
let name: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'",
|
||||
[], |r| r.get(0)
|
||||
).unwrap();
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "GPT-4");
|
||||
|
||||
let modality: String = conn.query_row(
|
||||
@@ -617,11 +746,16 @@ mod tests {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let val: String = conn.query_row(
|
||||
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let val: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(val, "abc");
|
||||
}
|
||||
|
||||
@@ -632,11 +766,16 @@ mod tests {
|
||||
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector)
|
||||
VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let vec: String = conn.query_row(
|
||||
"SELECT vector FROM embedding_keys WHERE label = 1", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let vec: String = conn
|
||||
.query_row(
|
||||
"SELECT vector FROM embedding_keys WHERE label = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(vec, "base64vector");
|
||||
}
|
||||
|
||||
@@ -650,10 +789,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let count: i64 = conn.query_row(
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'",
|
||||
[], |r| r.get(0)
|
||||
).unwrap();
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
@@ -667,9 +809,13 @@ mod tests {
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let name: String = conn.query_row(
|
||||
"SELECT name FROM import_definitions WHERE id = 'i1'", [], |r| r.get(0)
|
||||
).unwrap();
|
||||
let name: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM import_definitions WHERE id = 'i1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "WP Import");
|
||||
}
|
||||
|
||||
@@ -680,7 +826,8 @@ mod tests {
|
||||
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at)
|
||||
VALUES ('post', 'post1', 'created', 1, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (etype, action, cli): (String, String, i64) = conn.query_row(
|
||||
"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)
|
||||
VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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'",
|
||||
@@ -736,12 +884,16 @@ mod tests {
|
||||
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at)
|
||||
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
|
||||
.query_row(
|
||||
"SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'",
|
||||
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
).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!(enabled, 1, "default enabled 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)
|
||||
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
|
||||
.query_row(
|
||||
"SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'",
|
||||
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
|
||||
).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!(ep, "render", "default entrypoint must be 'render'");
|
||||
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;
|
||||
|
||||
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(
|
||||
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(project_id, relative_path)
|
||||
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
||||
params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at],
|
||||
params![
|
||||
hash.project_id,
|
||||
hash.relative_path,
|
||||
hash.content_hash,
|
||||
hash.updated_at
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -54,8 +62,8 @@ pub fn list_generated_file_hashes_by_project(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -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::util::calendar_range_unix_ms;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
@@ -133,9 +134,7 @@ pub struct MediaFilterParams {
|
||||
|
||||
impl MediaFilterParams {
|
||||
pub fn has_active_filters(&self) -> bool {
|
||||
!self.search_query.is_empty()
|
||||
|| self.year.is_some()
|
||||
|| !self.tags.is_empty()
|
||||
!self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,50 +160,14 @@ pub fn list_media_filtered(
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
.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 (start, end) =
|
||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||
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 {
|
||||
let idx = param_values.len() + 1;
|
||||
@@ -244,7 +207,7 @@ pub fn media_calendar_counts(
|
||||
FROM media
|
||||
WHERE project_id = ?1
|
||||
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| {
|
||||
Ok((
|
||||
@@ -257,24 +220,18 @@ pub fn media_calendar_counts(
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across media for a project.
|
||||
pub fn distinct_media_tags(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
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)
|
||||
})?;
|
||||
pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||
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();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
if let Ok(json_str) = json_str
|
||||
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||
{
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_tags.into_iter().collect())
|
||||
}
|
||||
|
||||
@@ -307,8 +264,8 @@ pub fn make_test_media(id: &str, project_id: &str) -> Media {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
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;
|
||||
|
||||
pub fn insert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
pub fn insert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
@@ -54,10 +51,7 @@ pub fn list_media_translations_by_media(
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn upsert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
pub fn upsert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
@@ -98,9 +92,9 @@ pub fn delete_media_translation(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
@@ -164,8 +158,6 @@ mod tests {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
delete_media_translation(db.conn(), "m1", "de").unwrap();
|
||||
assert!(
|
||||
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
|
||||
);
|
||||
assert!(get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
pub mod project;
|
||||
pub mod post;
|
||||
pub mod post_translation;
|
||||
pub mod generated_file_hash;
|
||||
pub mod media;
|
||||
pub mod media_translation;
|
||||
pub mod tag;
|
||||
pub mod post;
|
||||
pub mod post_link;
|
||||
pub mod post_media;
|
||||
pub mod template;
|
||||
pub mod post_translation;
|
||||
pub mod project;
|
||||
pub mod script;
|
||||
pub mod setting;
|
||||
pub mod generated_file_hash;
|
||||
pub mod tag;
|
||||
pub mod template;
|
||||
|
||||
@@ -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::util::calendar_range_unix_ms;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
@@ -165,6 +166,10 @@ pub fn set_post_file_path(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments mirror the published snapshot columns"
|
||||
)]
|
||||
pub fn set_published_snapshot(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
@@ -182,7 +187,16 @@ pub fn set_published_snapshot(
|
||||
published_categories = ?4, published_excerpt = ?5,
|
||||
published_at = ?6, updated_at = ?7
|
||||
WHERE id = ?8",
|
||||
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
|
||||
params![
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
categories,
|
||||
excerpt,
|
||||
published_at,
|
||||
updated_at,
|
||||
id
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -295,51 +309,16 @@ pub fn list_posts_filtered(
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
// created_at is unix ms; compute year range
|
||||
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp() * 1000;
|
||||
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
.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 (start, end) =
|
||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||
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})"));
|
||||
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 {
|
||||
let idx = param_values.len() + 1;
|
||||
@@ -435,24 +414,18 @@ pub fn post_calendar_counts(
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across posts for a project.
|
||||
pub fn distinct_post_tags(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
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)
|
||||
})?;
|
||||
pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||
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();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
if let Ok(json_str) = json_str
|
||||
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||
{
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_tags.into_iter().collect())
|
||||
}
|
||||
|
||||
@@ -462,27 +435,25 @@ pub fn distinct_post_categories(
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
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| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||
let mut all_cats = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str {
|
||||
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
if let Ok(json_str) = json_str
|
||||
&& let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||
{
|
||||
all_cats.extend(cats);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_cats.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
@@ -603,9 +574,17 @@ mod tests {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_published_snapshot(
|
||||
db.conn(), "x1", "Pub Title", "Pub Body",
|
||||
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
|
||||
).unwrap();
|
||||
db.conn(),
|
||||
"x1",
|
||||
"Pub Title",
|
||||
"Pub Body",
|
||||
"[\"rust\"]",
|
||||
"[\"tech\"]",
|
||||
Some("Pub Excerpt"),
|
||||
3000,
|
||||
3000,
|
||||
)
|
||||
.unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
||||
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
||||
@@ -51,8 +51,8 @@ pub fn list_links_by_target(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
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)
|
||||
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
||||
@@ -65,9 +65,9 @@ pub fn update_sort_order(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let 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;
|
||||
|
||||
pub fn insert_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
||||
if !t.status.is_valid_for_translation() {
|
||||
return Err(rusqlite::Error::InvalidParameterName(
|
||||
"translation status must be draft or published".to_string(),
|
||||
@@ -74,10 +73,7 @@ pub fn list_post_translations_by_post(
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
||||
if !t.status.is_valid_for_translation() {
|
||||
return Err(rusqlite::Error::InvalidParameterName(
|
||||
"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<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
conn.execute("DELETE FROM post_translations WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -125,8 +118,8 @@ pub fn delete_all_translations_for_post(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
|
||||
@@ -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;
|
||||
|
||||
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
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;
|
||||
|
||||
@@ -44,9 +44,7 @@ pub fn get_script_by_slug(
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Script> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"),
|
||||
params![project_id, slug],
|
||||
script_from_row,
|
||||
)
|
||||
@@ -94,8 +92,8 @@ pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::{ScriptKind, ScriptStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
|
||||
@@ -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;
|
||||
|
||||
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
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;
|
||||
|
||||
@@ -43,9 +43,7 @@ pub fn get_template_by_slug(
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Template> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"),
|
||||
params![project_id, slug],
|
||||
template_from_row,
|
||||
)
|
||||
@@ -92,8 +90,8 @@ pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::{TemplateKind, TemplateStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
|
||||
@@ -4,7 +4,7 @@ use keyring::Entry;
|
||||
use reqwest::blocking::Client;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::queries::setting;
|
||||
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<()> {
|
||||
validate_endpoint_config(endpoint)?;
|
||||
let checked_at = now_unix_ms();
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "url"), endpoint.url.trim(), checked_at)?;
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "model"), endpoint.model.trim(), checked_at)?;
|
||||
set_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(endpoint.kind, "url"),
|
||||
endpoint.url.trim(),
|
||||
checked_at,
|
||||
)?;
|
||||
set_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(endpoint.kind, "model"),
|
||||
endpoint.model.trim(),
|
||||
checked_at,
|
||||
)?;
|
||||
if endpoint.kind == AiEndpointKind::Online {
|
||||
let entry = endpoint_keyring_entry(endpoint.kind)?;
|
||||
if let Some(api_key) = &endpoint.api_key {
|
||||
if api_key.trim().is_empty() {
|
||||
entry.delete_credential().ok();
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "false", checked_at)?;
|
||||
set_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
|
||||
"false",
|
||||
checked_at,
|
||||
)?;
|
||||
} else {
|
||||
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "true", checked_at)?;
|
||||
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> {
|
||||
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)?;
|
||||
if stored.url.trim().is_empty() {
|
||||
return Err(EngineError::Validation(format!(
|
||||
@@ -243,14 +267,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
|
||||
validate_endpoint_config(endpoint)?;
|
||||
let client = build_http_client()?;
|
||||
let request = client.get(models_url(&endpoint.url));
|
||||
let response = with_auth(request, endpoint)
|
||||
.send()?
|
||||
.error_for_status()?;
|
||||
let response = with_auth(request, endpoint).send()?.error_for_status()?;
|
||||
let body: Value = response.json()?;
|
||||
let models = body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| EngineError::Parse("model catalog response missing data array".to_string()))?;
|
||||
let models = body.get("data").and_then(Value::as_array).ok_or_else(|| {
|
||||
EngineError::Parse("model catalog response missing data array".to_string())
|
||||
})?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for model in models {
|
||||
@@ -275,7 +296,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
|
||||
let supports_vision = model
|
||||
.get("modalities")
|
||||
.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);
|
||||
result.push(AiModelInfo {
|
||||
id,
|
||||
@@ -326,7 +351,12 @@ pub fn run_one_shot(
|
||||
}
|
||||
});
|
||||
let client = build_http_client()?;
|
||||
let response = with_auth(client.post(chat_completions_url(&endpoint.url)).json(&payload), &endpoint)
|
||||
let response = with_auth(
|
||||
client
|
||||
.post(chat_completions_url(&endpoint.url))
|
||||
.json(&payload),
|
||||
&endpoint,
|
||||
)
|
||||
.send()?
|
||||
.error_for_status()?;
|
||||
let body: Value = response.json()?;
|
||||
@@ -337,7 +367,9 @@ pub fn run_one_shot(
|
||||
.and_then(|choice| choice.get("message"))
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| EngineError::Parse("chat completions response missing message content".to_string()))?;
|
||||
.ok_or_else(|| {
|
||||
EngineError::Parse("chat completions response missing message content".to_string())
|
||||
})?;
|
||||
parse_one_shot_response(request, content)
|
||||
}
|
||||
|
||||
@@ -347,8 +379,10 @@ fn build_http_client() -> EngineResult<Client> {
|
||||
|
||||
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
|
||||
let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default();
|
||||
let model = get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
|
||||
let api_key_configured = get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
|
||||
let model =
|
||||
get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
|
||||
let api_key_configured =
|
||||
get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
|
||||
.map(|value| value == "true")
|
||||
.unwrap_or(false);
|
||||
Ok(StoredAiEndpointConfig {
|
||||
@@ -361,48 +395,81 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
|
||||
|
||||
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
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() {
|
||||
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
|
||||
&& 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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
|
||||
OneShotOperation::AnalyzeTaxonomy
|
||||
| OneShotOperation::AnalyzePost
|
||||
| OneShotOperation::DetectLanguage
|
||||
| OneShotOperation::TranslatePost { .. }
|
||||
| OneShotOperation::TranslateMedia { .. } => settings.title_model.as_ref().or(settings.default_model.as_ref()),
|
||||
| OneShotOperation::TranslateMedia { .. } => settings
|
||||
.title_model
|
||||
.as_ref()
|
||||
.or(settings.default_model.as_ref()),
|
||||
}
|
||||
.filter(|model| !model.trim().is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| endpoint.model.clone());
|
||||
if selected.trim().is_empty() {
|
||||
return Err(EngineError::Validation("AI unavailable - configure model in Settings".to_string()));
|
||||
return Err(EngineError::Validation(
|
||||
"AI unavailable - configure model in Settings".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String {
|
||||
let operation_prompt = match operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => "Return only JSON with tags and categories for the post.",
|
||||
OneShotOperation::AnalyzePost => "Return only JSON with title, excerpt, and slug suggestions for the post.",
|
||||
OneShotOperation::AnalyzeTaxonomy => {
|
||||
"Return only JSON with tags and categories for the post."
|
||||
}
|
||||
OneShotOperation::AnalyzePost => {
|
||||
"Return only JSON with title, excerpt, and slug suggestions for the post."
|
||||
}
|
||||
OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.",
|
||||
OneShotOperation::TranslatePost { target_language } => {
|
||||
return format!("{} Translate the post into {} and return only JSON with title, excerpt, and content.", base_prompt.trim(), target_language);
|
||||
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 } => {
|
||||
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() {
|
||||
@@ -417,26 +484,31 @@ fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value>
|
||||
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
||||
"Suggest tags and categories for this post: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
).into()),
|
||||
)
|
||||
.into()),
|
||||
OneShotOperation::AnalyzePost => Ok(format!(
|
||||
"Analyze this post and suggest title, excerpt, and slug: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
).into()),
|
||||
)
|
||||
.into()),
|
||||
OneShotOperation::DetectLanguage => Ok(format!(
|
||||
"Detect the language of this text: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
).into()),
|
||||
)
|
||||
.into()),
|
||||
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
||||
"Translate this post to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
).into()),
|
||||
)
|
||||
.into()),
|
||||
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
|
||||
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
||||
"Translate this media metadata to {}: {}",
|
||||
target_language,
|
||||
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 {
|
||||
OneShotOperation::AnalyzeTaxonomy => OneShotResponse::Taxonomy(serde_json::from_str(content)?),
|
||||
OneShotOperation::AnalyzePost => OneShotResponse::PostAnalysis(serde_json::from_str(content)?),
|
||||
OneShotOperation::DetectLanguage => OneShotResponse::LanguageDetection(serde_json::from_str(content)?),
|
||||
OneShotOperation::TranslatePost { .. } => OneShotResponse::Translation(serde_json::from_str(content)?),
|
||||
OneShotOperation::AnalyzeImage => OneShotResponse::ImageAnalysis(serde_json::from_str(content)?),
|
||||
OneShotOperation::TranslateMedia { .. } => OneShotResponse::MediaTranslation(serde_json::from_str(content)?),
|
||||
OneShotOperation::AnalyzeTaxonomy => {
|
||||
OneShotResponse::Taxonomy(serde_json::from_str(content)?)
|
||||
}
|
||||
OneShotOperation::AnalyzePost => {
|
||||
OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
|
||||
}
|
||||
OneShotOperation::DetectLanguage => {
|
||||
OneShotResponse::LanguageDetection(serde_json::from_str(content)?)
|
||||
}
|
||||
OneShotOperation::TranslatePost { .. } => {
|
||||
OneShotResponse::Translation(serde_json::from_str(content)?)
|
||||
}
|
||||
OneShotOperation::AnalyzeImage => {
|
||||
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?)
|
||||
}
|
||||
OneShotOperation::TranslateMedia { .. } => {
|
||||
OneShotResponse::MediaTranslation(serde_json::from_str(content)?)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -565,7 +652,11 @@ fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -577,7 +668,12 @@ fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> En
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -691,12 +787,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn refresh_model_catalog_parses_openai_models_shape() {
|
||||
let server = spawn_test_server(|request| {
|
||||
let server = spawn_test_server(
|
||||
|request| {
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||
http_ok(
|
||||
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
|
||||
)
|
||||
}, 1);
|
||||
},
|
||||
1,
|
||||
);
|
||||
let models = refresh_model_catalog(&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: server,
|
||||
@@ -713,16 +812,22 @@ mod tests {
|
||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let server = spawn_test_server(|request| {
|
||||
let server = spawn_test_server(
|
||||
|request| {
|
||||
if request.starts_with("GET /v1/models HTTP/1.1") {
|
||||
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
|
||||
}
|
||||
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
|
||||
assert!(request.contains("authorization: Bearer secret-token") || request.contains("Authorization: Bearer secret-token"));
|
||||
assert!(
|
||||
request.contains("authorization: Bearer secret-token")
|
||||
|| request.contains("Authorization: Bearer secret-token")
|
||||
);
|
||||
http_ok(
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
|
||||
)
|
||||
}, 1);
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
@@ -769,9 +874,17 @@ mod tests {
|
||||
let parts = content.as_array().unwrap();
|
||||
assert_eq!(parts.len(), 2);
|
||||
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]["image_url"]["url"], "data:image/jpeg;base64,abc123");
|
||||
assert_eq!(
|
||||
parts[1]["image_url"]["url"],
|
||||
"data:image/jpeg;base64,abc123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -940,7 +1053,10 @@ mod tests {
|
||||
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 addr = listener.local_addr().unwrap();
|
||||
thread::spawn(move || {
|
||||
|
||||
@@ -69,18 +69,17 @@ mod tests {
|
||||
let _ = db.migrate();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = engine::project::create_project(
|
||||
db.conn(), "Test", Some(tmp.path().to_str().unwrap()),
|
||||
).unwrap();
|
||||
let p =
|
||||
engine::project::create_project(db.conn(), "Test", Some(tmp.path().to_str().unwrap()))
|
||||
.unwrap();
|
||||
|
||||
regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap();
|
||||
|
||||
let cal_path = tmp.path().join("html").join("calendar.json");
|
||||
assert!(cal_path.exists());
|
||||
|
||||
let data: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(&cal_path).unwrap()
|
||||
).unwrap();
|
||||
let data: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
|
||||
assert!(data["years"].as_object().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +72,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn display_variants() {
|
||||
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error"));
|
||||
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found"));
|
||||
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict"));
|
||||
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation"));
|
||||
assert!(
|
||||
EngineError::Parse("bad yaml".into())
|
||||
.to_string()
|
||||
.contains("parse error")
|
||||
);
|
||||
assert!(
|
||||
EngineError::NotFound("post 123".into())
|
||||
.to_string()
|
||||
.contains("not found")
|
||||
);
|
||||
assert!(
|
||||
EngineError::Conflict("slug taken".into())
|
||||
.to_string()
|
||||
.contains("conflict")
|
||||
);
|
||||
assert!(
|
||||
EngineError::Validation("title empty".into())
|
||||
.to_string()
|
||||
.contains("validation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,6 +24,31 @@ pub struct PublishedPostSource {
|
||||
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)]
|
||||
pub struct GenerationReport {
|
||||
pub written_paths: Vec<String>,
|
||||
@@ -56,11 +81,19 @@ pub fn generate_starter_site(
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
||||
let artifacts =
|
||||
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 {
|
||||
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)?;
|
||||
@@ -70,13 +103,24 @@ pub fn generate_starter_site(
|
||||
output_dir,
|
||||
project_id,
|
||||
"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,
|
||||
)?;
|
||||
|
||||
for render_language in render_languages(metadata) {
|
||||
let localized_posts = 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()) {
|
||||
let localized_posts =
|
||||
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()
|
||||
} else {
|
||||
format!("{}/", render_language)
|
||||
@@ -85,19 +129,44 @@ pub fn generate_starter_site(
|
||||
if prefix.is_empty() {
|
||||
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}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &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}atom.xml"),
|
||||
&build_atom_xml(metadata, &localized_posts, &render_language),
|
||||
&mut report,
|
||||
)?;
|
||||
write_out(
|
||||
conn,
|
||||
output_dir,
|
||||
project_id,
|
||||
&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,
|
||||
)?;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -151,20 +220,22 @@ pub fn apply_validation_sections(
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let artifacts = build_site_render_artifacts(
|
||||
conn,
|
||||
&data_dir,
|
||||
project_id,
|
||||
metadata,
|
||||
&input_posts,
|
||||
)
|
||||
let artifacts =
|
||||
build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let mut report = GenerationReport::default();
|
||||
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, §ion_set);
|
||||
|
||||
for page in &artifacts.pages {
|
||||
if path_matches_sections(&page.relative_path, §ion_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,
|
||||
project_id,
|
||||
"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,
|
||||
)?;
|
||||
|
||||
for render_language in render_languages(metadata) {
|
||||
let localized_posts = 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()) {
|
||||
let localized_posts =
|
||||
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()
|
||||
} else {
|
||||
format!("{}/", render_language)
|
||||
@@ -196,7 +272,14 @@ pub fn apply_validation_sections(
|
||||
if prefix.is_empty() {
|
||||
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(
|
||||
conn,
|
||||
output_dir,
|
||||
@@ -210,40 +293,29 @@ pub fn apply_validation_sections(
|
||||
output_dir,
|
||||
project_id,
|
||||
&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,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
remove_extra_section_paths(output_dir, &expected_paths, §ion_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)
|
||||
}
|
||||
|
||||
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(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
@@ -256,7 +328,9 @@ fn write_out(
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||
{
|
||||
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative_path.to_string()),
|
||||
GeneratedWriteOutcome::SkippedUnchanged => {
|
||||
report.skipped_paths.push(relative_path.to_string())
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -273,10 +347,13 @@ fn write_pagefind_indexes(
|
||||
.build()
|
||||
.map_err(EngineError::Io)?;
|
||||
|
||||
let grouped = documents.iter().fold(HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(), |mut acc, doc| {
|
||||
let grouped = documents.iter().fold(
|
||||
HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(),
|
||||
|mut acc, doc| {
|
||||
acc.entry(doc.language.clone()).or_default().push(doc);
|
||||
acc
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
for (language, docs) in grouped {
|
||||
let config = PagefindServiceConfig::builder()
|
||||
@@ -292,9 +369,16 @@ fn write_pagefind_indexes(
|
||||
.await
|
||||
.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 {
|
||||
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)
|
||||
.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("rss.xml".to_string());
|
||||
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()
|
||||
} else {
|
||||
format!("{language}/")
|
||||
@@ -376,7 +465,10 @@ fn remove_extra_section_paths(
|
||||
{
|
||||
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;
|
||||
}
|
||||
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),
|
||||
["tag", ..] => Some(GenerationSection::Tag),
|
||||
[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"]
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -425,7 +522,10 @@ fn has_language_prefix(parts: &[&str]) -> bool {
|
||||
!is_year_segment(first)
|
||||
&& *first != "category"
|
||||
&& *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,
|
||||
}
|
||||
@@ -468,14 +568,22 @@ fn section_sort_key(section: &GenerationSection) -> u8 {
|
||||
}
|
||||
|
||||
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> {
|
||||
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()];
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -496,8 +604,16 @@ fn localized_sources(
|
||||
localized.push(source.clone());
|
||||
continue;
|
||||
}
|
||||
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &source.post.id, language) {
|
||||
let raw = std::fs::read_to_string(data_dir.join(translation.file_path.trim_start_matches('/')))
|
||||
if let Ok(translation) =
|
||||
queries::post_translation::get_post_translation_by_post_and_language(
|
||||
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)
|
||||
.map_err(EngineError::Parse)?;
|
||||
@@ -524,7 +640,8 @@ fn filter_posts_for_lists(
|
||||
posts: &[PublishedPostSource],
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
) -> Vec<PublishedPostSource> {
|
||||
posts.iter()
|
||||
posts
|
||||
.iter()
|
||||
.filter(|source| {
|
||||
!source.post.categories.iter().any(|category| {
|
||||
category_settings
|
||||
@@ -537,8 +654,16 @@ fn filter_posts_for_lists(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
fn build_rss_xml(
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
language: &str,
|
||||
) -> String {
|
||||
let base_url = metadata
|
||||
.public_url
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/');
|
||||
let last_build = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
@@ -557,22 +682,46 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
||||
];
|
||||
|
||||
for source in posts {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
|
||||
let url = format!(
|
||||
"{base_url}{}",
|
||||
build_canonical_post_path(
|
||||
&source.post,
|
||||
language,
|
||||
metadata.main_language.as_deref().unwrap_or("en")
|
||||
)
|
||||
);
|
||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||
.unwrap_or_else(Utc::now);
|
||||
xml.push(" <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!(" <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 {
|
||||
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);
|
||||
xml.push(format!(" <description><![CDATA[{html}]]></description>"));
|
||||
xml.push(format!(" <content:encoded><![CDATA[{html}]]></content:encoded>"));
|
||||
xml.push(format!(
|
||||
" <description><![CDATA[{html}]]></description>"
|
||||
));
|
||||
xml.push(format!(
|
||||
" <content:encoded><![CDATA[{html}]]></content:encoded>"
|
||||
));
|
||||
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 {
|
||||
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
|
||||
@@ -585,8 +734,16 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
||||
xml.join("\n")
|
||||
}
|
||||
|
||||
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
fn build_atom_xml(
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
language: &str,
|
||||
) -> String {
|
||||
let base_url = metadata
|
||||
.public_url
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/');
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let feed_prefix = language_prefix(language, main_language);
|
||||
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(),
|
||||
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
|
||||
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!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
|
||||
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 {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
|
||||
let url = format!(
|
||||
"{base_url}{}",
|
||||
build_canonical_post_path(
|
||||
&source.post,
|
||||
language,
|
||||
metadata.main_language.as_deref().unwrap_or("en")
|
||||
)
|
||||
);
|
||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let html = render_markdown_to_html(&source.body_markdown);
|
||||
xml.push(format!(" <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!(" <link href=\"{url}\" />"));
|
||||
xml.push(format!(" <updated>{}</updated>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
|
||||
xml.push(format!(" <published>{}</published>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
|
||||
xml.push(format!(
|
||||
" <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 {
|
||||
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!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
|
||||
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 {
|
||||
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
|
||||
@@ -639,7 +825,11 @@ fn build_sitemap_xml(
|
||||
posts: &[PublishedPostSource],
|
||||
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 main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let languages = render_languages(metadata);
|
||||
let index_lastmod = posts
|
||||
@@ -694,7 +884,10 @@ fn build_sitemap_xml(
|
||||
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!(
|
||||
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
|
||||
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) {
|
||||
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("/");
|
||||
}
|
||||
relative_path.to_string()
|
||||
|
||||
@@ -12,11 +12,11 @@ use crate::db::queries::post_media as qpm;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Media, MediaTranslation};
|
||||
use crate::util::sidecar::{
|
||||
read_sidecar, read_translation_sidecar, MediaSidecar, MediaTranslationSidecar,
|
||||
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
|
||||
};
|
||||
use crate::util::thumbnail::{
|
||||
generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat,
|
||||
THUMBNAIL_SIZES,
|
||||
THUMBNAIL_SIZES, ThumbnailFormat, generate_all_thumbnails, image_dimensions,
|
||||
mime_from_extension,
|
||||
};
|
||||
use crate::util::{
|
||||
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.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments are the user-supplied media metadata fields"
|
||||
)]
|
||||
pub fn import_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
@@ -142,6 +146,10 @@ pub fn import_media(
|
||||
}
|
||||
|
||||
/// Update a media item's metadata fields.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "optional arguments represent independent media field changes"
|
||||
)]
|
||||
pub fn update_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
@@ -655,9 +663,9 @@ fn rebuild_translation_sidecar(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use image::DynamicImage;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::fs;
|
||||
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::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)?;
|
||||
Ok(parse_opml(&content))
|
||||
parse_opml(&content)
|
||||
}
|
||||
|
||||
/// Write the navigation menu to meta/menu.opml.
|
||||
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
|
||||
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
|
||||
let normalized = normalize_menu(items);
|
||||
let opml = serialize_opml(&normalized);
|
||||
let opml = serialize_opml(&normalized)?;
|
||||
let path = data_dir.join("meta").join("menu.opml");
|
||||
atomic_write_str(&path, &opml)?;
|
||||
Ok(())
|
||||
@@ -74,7 +78,7 @@ pub fn default_menu_opml() -> String {
|
||||
slug: None,
|
||||
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.
|
||||
@@ -95,211 +99,136 @@ fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
|
||||
}
|
||||
|
||||
/// Parse OPML 2.0 XML into menu items.
|
||||
fn parse_opml(content: &str) -> Vec<MenuItem> {
|
||||
// Simple XML parsing using quick-xml-style manual parsing.
|
||||
// OPML structure: <opml><body><outline .../></body></opml>
|
||||
fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
|
||||
let mut reader = Reader::from_str(content);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut items = Vec::new();
|
||||
parse_outlines(content, &mut items);
|
||||
let mut parents = Vec::new();
|
||||
let mut in_body = false;
|
||||
|
||||
// Ensure HomeAlwaysPresent
|
||||
if items.is_empty() || items[0].kind != MenuItemKind::Home {
|
||||
let without_home: Vec<MenuItem> = items
|
||||
.into_iter()
|
||||
.filter(|i| i.kind != MenuItemKind::Home)
|
||||
.collect();
|
||||
let mut normalized = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
normalized.extend(without_home);
|
||||
return normalized;
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(event)) if event.name().as_ref() == b"body" => in_body = true,
|
||||
Ok(Event::End(event)) if event.name().as_ref() == b"body" => in_body = false,
|
||||
Ok(Event::Start(event)) if in_body && event.name().as_ref() == b"outline" => {
|
||||
parents.push(parse_outline(&event)?);
|
||||
}
|
||||
items
|
||||
Ok(Event::Empty(event)) if in_body && event.name().as_ref() == b"outline" => {
|
||||
attach_outline(parse_outline(&event)?, &mut parents, &mut 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;
|
||||
Ok(Event::End(event)) if event.name().as_ref() == b"outline" => {
|
||||
let item = parents
|
||||
.pop()
|
||||
.ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
|
||||
attach_outline(item, &mut parents, &mut items);
|
||||
}
|
||||
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('>') {
|
||||
is_self_closing = false;
|
||||
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::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(error) => return Err(EngineError::Parse(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
items.push(MenuItem {
|
||||
if !parents.is_empty() {
|
||||
return Err(EngineError::Parse("unclosed <outline>".to_string()));
|
||||
}
|
||||
Ok(normalize_menu(&items))
|
||||
}
|
||||
|
||||
fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
|
||||
let mut label = "Untitled".to_string();
|
||||
let mut kind = MenuItemKind::Page;
|
||||
let mut slug = None;
|
||||
|
||||
for attribute in event.attributes() {
|
||||
let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let value = attribute
|
||||
.decoded_and_normalized_value(XmlVersion::Implicit1_0, event.decoder())
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||
.into_owned();
|
||||
match attribute.key.as_ref() {
|
||||
b"text" => label = value,
|
||||
b"type" => kind = MenuItemKind::from_str(&value),
|
||||
b"htmlUrl" => slug = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MenuItem {
|
||||
kind,
|
||||
label,
|
||||
slug,
|
||||
children,
|
||||
});
|
||||
pos = after_tag;
|
||||
}
|
||||
children: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
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;
|
||||
pos += 8; // skip past "<outline"
|
||||
} else if content[pos..].starts_with("</outline>") {
|
||||
if depth == 0 {
|
||||
return Some(pos);
|
||||
}
|
||||
depth -= 1;
|
||||
pos += 10;
|
||||
fn attach_outline(item: MenuItem, parents: &mut [MenuItem], items: &mut Vec<MenuItem>) {
|
||||
if let Some(parent) = parents.last_mut() {
|
||||
parent.children.push(item);
|
||||
} else {
|
||||
pos += 1;
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_attr(tag: &str, name: &str) -> Option<String> {
|
||||
let pattern = format!("{name}=\"");
|
||||
let start = tag.find(&pattern)?;
|
||||
let value_start = start + pattern.len();
|
||||
let rest = &tag[value_start..];
|
||||
let end = rest.find('"')?;
|
||||
let value = &rest[..end];
|
||||
// Unescape XML entities
|
||||
Some(
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serialize menu items to OPML 2.0 format.
|
||||
fn serialize_opml(items: &[MenuItem]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
out.push_str("<opml version=\"2.0\">\n");
|
||||
out.push_str(" <head><title>Blog Menu</title></head>\n");
|
||||
out.push_str(" <body>\n");
|
||||
fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
|
||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
|
||||
writer
|
||||
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
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 {
|
||||
serialize_outline(&mut out, item, 2);
|
||||
write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
}
|
||||
out.push_str(" </body>\n");
|
||||
out.push_str("</opml>\n");
|
||||
out
|
||||
writer
|
||||
.write_event(Event::End(BytesEnd::new("body")))
|
||||
.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) {
|
||||
let pad = " ".repeat(indent);
|
||||
out.push_str(&pad);
|
||||
out.push_str("<outline");
|
||||
out.push_str(&format!(
|
||||
" text=\"{}\"",
|
||||
xml_escape(&item.label)
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" type=\"{}\"",
|
||||
item.kind.as_str()
|
||||
));
|
||||
if let Some(ref slug) = item.slug {
|
||||
out.push_str(&format!(
|
||||
" htmlUrl=\"{}\"",
|
||||
xml_escape(slug)
|
||||
));
|
||||
fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
|
||||
let label = escape(&item.label);
|
||||
let slug = item.slug.as_deref().map(escape);
|
||||
let mut outline = BytesStart::new("outline");
|
||||
outline.push_attribute(("text", label.as_ref()));
|
||||
outline.push_attribute(("type", item.kind.as_str()));
|
||||
if let Some(slug) = &slug {
|
||||
outline.push_attribute(("htmlUrl", slug.as_ref()));
|
||||
}
|
||||
if item.children.is_empty() {
|
||||
out.push_str("/>\n");
|
||||
writer.write_event(Event::Empty(outline))?;
|
||||
} else {
|
||||
out.push_str(">\n");
|
||||
writer.write_event(Event::Start(outline))?;
|
||||
for child in &item.children {
|
||||
serialize_outline(out, child, indent + 1);
|
||||
write_outline(writer, child)?;
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</outline>\n");
|
||||
writer.write_event(Event::End(BytesEnd::new("outline")))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -332,18 +261,16 @@ mod tests {
|
||||
kind: MenuItemKind::Submenu,
|
||||
label: "Categories".into(),
|
||||
slug: None,
|
||||
children: vec![
|
||||
MenuItem {
|
||||
children: vec![MenuItem {
|
||||
kind: MenuItemKind::CategoryArchive,
|
||||
label: "Tech".into(),
|
||||
slug: Some("/category/tech/".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
];
|
||||
let opml = serialize_opml(&items);
|
||||
let parsed = parse_opml(&opml);
|
||||
let opml = serialize_opml(&items).unwrap();
|
||||
let parsed = parse_opml(&opml).unwrap();
|
||||
assert_eq!(parsed.len(), 3);
|
||||
assert_eq!(parsed[0].kind, MenuItemKind::Home);
|
||||
assert_eq!(parsed[1].label, "About");
|
||||
@@ -354,14 +281,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn normalize_prepends_home() {
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "About".into(),
|
||||
slug: Some("/about".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
];
|
||||
}];
|
||||
let normalized = normalize_menu(&items);
|
||||
assert_eq!(normalized.len(), 2);
|
||||
assert_eq!(normalized[0].kind, MenuItemKind::Home);
|
||||
@@ -381,14 +306,12 @@ mod tests {
|
||||
fn write_and_read_menu() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "Blog".into(),
|
||||
slug: Some("/blog".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
];
|
||||
}];
|
||||
write_menu(dir.path(), &items).unwrap();
|
||||
let read = read_menu(dir.path()).unwrap();
|
||||
// Home is always prepended
|
||||
@@ -398,8 +321,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_special_chars() {
|
||||
let escaped = xml_escape("Tom & Jerry <3 \"quotes\"");
|
||||
assert_eq!(escaped, "Tom & Jerry <3 "quotes"");
|
||||
fn reads_single_quoted_xml_attributes() {
|
||||
let parsed = parse_opml(
|
||||
"<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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::engine::EngineResult;
|
||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
use crate::model::PublishingPreferences;
|
||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
use crate::util::atomic_write_str;
|
||||
|
||||
// ── 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.
|
||||
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
|
||||
let mut sorted = categories.to_vec();
|
||||
sorted.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||
sorted.sort_by_key(|a| a.to_lowercase());
|
||||
let path = data_dir.join("meta").join("categories.json");
|
||||
let json = serde_json::to_string_pretty(&sorted)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
@@ -48,9 +48,7 @@ pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineRe
|
||||
// ── category-meta.json ──────────────────────────────────────────────
|
||||
|
||||
/// Read meta/category-meta.json.
|
||||
pub fn read_category_meta_json(
|
||||
data_dir: &Path,
|
||||
) -> EngineResult<HashMap<String, CategorySettings>> {
|
||||
pub fn read_category_meta_json(data_dir: &Path) -> EngineResult<HashMap<String, CategorySettings>> {
|
||||
let path = data_dir.join("meta").join("category-meta.json");
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
|
||||
@@ -79,10 +77,7 @@ pub fn read_publishing_json(data_dir: &Path) -> EngineResult<PublishingPreferenc
|
||||
}
|
||||
|
||||
/// Atomic write to meta/publishing.json.
|
||||
pub fn write_publishing_json(
|
||||
data_dir: &Path,
|
||||
prefs: &PublishingPreferences,
|
||||
) -> EngineResult<()> {
|
||||
pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> EngineResult<()> {
|
||||
let path = data_dir.join("meta").join("publishing.json");
|
||||
let json = serde_json::to_string_pretty(prefs)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
@@ -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.
|
||||
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
|
||||
let mut sorted = tags.to_vec();
|
||||
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
sorted.sort_by_key(|a| a.name.to_lowercase());
|
||||
let path = data_dir.join("meta").join("tags.json");
|
||||
let json = serde_json::to_string_pretty(&sorted)?;
|
||||
atomic_write_str(&path, &json)?;
|
||||
@@ -162,10 +157,7 @@ pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineR
|
||||
|
||||
/// Update arbitrary fields of project metadata.
|
||||
/// Per metadata.allium UpdateProjectMetadata rule.
|
||||
pub fn update_project_metadata(
|
||||
data_dir: &Path,
|
||||
changes: &serde_json::Value,
|
||||
) -> EngineResult<()> {
|
||||
pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> EngineResult<()> {
|
||||
let mut meta = read_project_json(data_dir)?;
|
||||
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
|
||||
meta.name = name.to_string();
|
||||
@@ -191,7 +183,10 @@ pub fn update_project_metadata(
|
||||
if let Some(theme) = changes.get("picoTheme") {
|
||||
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;
|
||||
}
|
||||
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()))
|
||||
.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)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -380,7 +376,7 @@ mod tests {
|
||||
fn add_category_creates_entries() {
|
||||
let dir = setup();
|
||||
// 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();
|
||||
|
||||
add_category(dir.path(), "page").unwrap();
|
||||
@@ -395,7 +391,7 @@ mod tests {
|
||||
#[test]
|
||||
fn add_category_idempotent() {
|
||||
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();
|
||||
|
||||
add_category(dir.path(), "article").unwrap();
|
||||
@@ -406,7 +402,7 @@ mod tests {
|
||||
#[test]
|
||||
fn remove_category_deletes_entries() {
|
||||
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();
|
||||
meta.insert(
|
||||
"article".to_string(),
|
||||
|
||||
@@ -13,7 +13,9 @@ use crate::db::queries::script as qs;
|
||||
use crate::db::queries::template as qtpl;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template};
|
||||
use crate::util::frontmatter::{read_post_file, read_script_file, read_template_file, read_translation_file};
|
||||
use crate::util::frontmatter::{
|
||||
read_post_file, read_script_file, read_template_file, read_translation_file,
|
||||
};
|
||||
use crate::util::sidecar::read_sidecar;
|
||||
|
||||
/// A single field difference.
|
||||
@@ -139,7 +141,11 @@ fn opt_to_str(opt: &Option<String>) -> 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 {
|
||||
@@ -172,7 +178,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -257,21 +263,23 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_translation(
|
||||
data_dir: &Path,
|
||||
t: &PostTranslation,
|
||||
) -> EngineResult<Option<EntityDiff>> {
|
||||
fn diff_translation(data_dir: &Path, t: &PostTranslation) -> EngineResult<Option<EntityDiff>> {
|
||||
let abs_path = data_dir.join(&t.file_path);
|
||||
if !abs_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&abs_path)?;
|
||||
let (fm, _body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
let (fm, _body) = read_translation_file(&content).map_err(EngineError::Parse)?;
|
||||
|
||||
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, "title", &t.title, &fm.title);
|
||||
compare_field(
|
||||
@@ -285,7 +293,7 @@ fn diff_translation(
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(EntityDiff {
|
||||
entity_type: "translation".to_string(),
|
||||
entity_type: "post_translation".to_string(),
|
||||
entity_id: t.id.clone(),
|
||||
file_path: t.file_path.clone(),
|
||||
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 sc = read_sidecar(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
|
||||
|
||||
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 (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();
|
||||
|
||||
@@ -403,7 +411,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -414,7 +422,12 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
|
||||
script_kind_to_str(&script.kind),
|
||||
&fm.kind,
|
||||
);
|
||||
compare_field(&mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"entrypoint",
|
||||
&script.entrypoint,
|
||||
&fm.entrypoint,
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"enabled",
|
||||
@@ -494,10 +507,7 @@ fn detect_orphan_files(
|
||||
if !dir_path.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in WalkDir::new(&dir_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
@@ -600,20 +610,20 @@ fn detect_orphan_files(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
use crate::db::queries::media::insert_media;
|
||||
use crate::db::queries::post::insert_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::queries::script::insert_script;
|
||||
use crate::db::queries::template::insert_template;
|
||||
use crate::db::Database;
|
||||
use crate::engine::post::{create_post, publish_post};
|
||||
use crate::model::{
|
||||
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
|
||||
TemplateStatus,
|
||||
};
|
||||
use crate::util::frontmatter::{
|
||||
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
|
||||
ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
|
||||
};
|
||||
use crate::util::sidecar::MediaSidecar;
|
||||
use tempfile::TempDir;
|
||||
@@ -658,11 +668,7 @@ mod tests {
|
||||
let non_time_diffs: Vec<_> = report
|
||||
.diffs
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.fields
|
||||
.iter()
|
||||
.any(|f| f.field_name != "updatedAt")
|
||||
})
|
||||
.filter(|d| d.fields.iter().any(|f| f.field_name != "updatedAt"))
|
||||
.collect();
|
||||
assert!(
|
||||
non_time_diffs.is_empty(),
|
||||
@@ -786,7 +792,11 @@ mod tests {
|
||||
// Create a .md file in posts/ that is not in the DB
|
||||
let posts_dir = dir.path().join("posts/2024/01");
|
||||
fs::create_dir_all(&posts_dir).unwrap();
|
||||
fs::write(posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n").unwrap();
|
||||
fs::write(
|
||||
posts_dir.join("orphan.md"),
|
||||
"---\ntitle: Orphan\n---\nBody\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
@@ -801,7 +811,9 @@ mod tests {
|
||||
"expected at least one orphan file"
|
||||
);
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
pub mod error;
|
||||
pub mod ai;
|
||||
pub mod calendar;
|
||||
pub mod context;
|
||||
pub mod project;
|
||||
pub mod meta;
|
||||
pub mod tag;
|
||||
pub mod post;
|
||||
pub mod error;
|
||||
pub mod generation;
|
||||
pub mod media;
|
||||
pub mod menu;
|
||||
pub mod meta;
|
||||
pub mod metadata_diff;
|
||||
pub mod post;
|
||||
pub mod post_media;
|
||||
pub mod template;
|
||||
pub mod template_rebuild;
|
||||
pub mod preview;
|
||||
pub mod project;
|
||||
pub mod rebuild;
|
||||
pub mod script;
|
||||
pub mod script_rebuild;
|
||||
pub mod task;
|
||||
pub mod metadata_diff;
|
||||
pub mod site_assets;
|
||||
pub mod rebuild;
|
||||
pub mod search;
|
||||
pub mod calendar;
|
||||
pub mod generation;
|
||||
pub mod preview;
|
||||
pub mod site_assets;
|
||||
pub mod tag;
|
||||
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_translations;
|
||||
pub mod validate_media;
|
||||
pub mod validate_content;
|
||||
pub mod menu;
|
||||
|
||||
pub use error::{EngineError, EngineResult};
|
||||
pub use context::EngineContext;
|
||||
pub use error::{EngineError, EngineResult};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -30,6 +30,10 @@ pub struct RebuildReport {
|
||||
}
|
||||
|
||||
/// Create a new draft post.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments are the user-supplied post fields"
|
||||
)]
|
||||
pub fn create_post(
|
||||
conn: &Connection,
|
||||
_data_dir: &Path,
|
||||
@@ -90,6 +94,10 @@ pub fn create_post(
|
||||
}
|
||||
|
||||
/// Update a post's fields.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "optional arguments represent independent post field changes"
|
||||
)]
|
||||
pub fn update_post(
|
||||
conn: &Connection,
|
||||
_data_dir: &Path,
|
||||
@@ -115,15 +123,14 @@ pub fn update_post(
|
||||
}
|
||||
|
||||
// Slug uniqueness check
|
||||
if let Some(new_slug) = slug {
|
||||
if new_slug != post.slug {
|
||||
if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() {
|
||||
if let Some(new_slug) = slug
|
||||
&& new_slug != post.slug
|
||||
&& 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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(t) = title {
|
||||
post.title = t.to_string();
|
||||
@@ -161,14 +168,13 @@ pub fn update_post(
|
||||
// Reload content from filesystem if content field is NULL (published state)
|
||||
if post.content.is_none() && !post.file_path.is_empty() {
|
||||
let abs_path = _data_dir.join(&post.file_path);
|
||||
if abs_path.exists() {
|
||||
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
||||
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
||||
if abs_path.exists()
|
||||
&& let Ok(file_content) = fs::read_to_string(&abs_path)
|
||||
&& let Ok((_fm, body)) = read_post_file(&file_content)
|
||||
{
|
||||
post.content = Some(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post.status = PostStatus::Draft;
|
||||
}
|
||||
|
||||
@@ -207,7 +213,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
||||
c.clone()
|
||||
} else if abs_path.exists() {
|
||||
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
|
||||
} else {
|
||||
String::new()
|
||||
@@ -310,26 +316,21 @@ 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()
|
||||
{
|
||||
let abs_path = data_dir.join(&post.file_path);
|
||||
if abs_path.exists() {
|
||||
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
||||
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
||||
if abs_path.exists()
|
||||
&& let Ok(file_content) = fs::read_to_string(&abs_path)
|
||||
&& let Ok((_fm, body)) = read_post_file(&file_content)
|
||||
{
|
||||
post.content = Some(body);
|
||||
qp::update_post(conn, &post)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = now_unix_ms();
|
||||
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discard unpublished draft changes and restore the published version.
|
||||
pub fn discard_post_draft(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post_id: &str,
|
||||
) -> EngineResult<Post> {
|
||||
pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||
let mut post = qp::get_post_by_id(conn, post_id)?;
|
||||
if post.published_at.is_none() {
|
||||
return Err(EngineError::Conflict(
|
||||
@@ -337,8 +338,18 @@ pub fn discard_post_draft(
|
||||
));
|
||||
}
|
||||
|
||||
let (title, slug, excerpt, author, language, template_slug, do_not_translate, tags, categories, checksum) =
|
||||
if !post.file_path.is_empty() {
|
||||
let (
|
||||
title,
|
||||
slug,
|
||||
excerpt,
|
||||
author,
|
||||
language,
|
||||
template_slug,
|
||||
do_not_translate,
|
||||
tags,
|
||||
categories,
|
||||
checksum,
|
||||
) = if !post.file_path.is_empty() {
|
||||
let abs_path = data_dir.join(&post.file_path);
|
||||
if abs_path.exists() {
|
||||
let raw = fs::read_to_string(&abs_path)?;
|
||||
@@ -357,9 +368,13 @@ pub fn discard_post_draft(
|
||||
)
|
||||
} 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.published_excerpt.clone().or_else(|| post.excerpt.clone()),
|
||||
post.published_excerpt
|
||||
.clone()
|
||||
.or_else(|| post.excerpt.clone()),
|
||||
post.author.clone(),
|
||||
post.language.clone(),
|
||||
post.template_slug.clone(),
|
||||
@@ -377,9 +392,13 @@ pub fn discard_post_draft(
|
||||
}
|
||||
} 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.published_excerpt.clone().or_else(|| post.excerpt.clone()),
|
||||
post.published_excerpt
|
||||
.clone()
|
||||
.or_else(|| post.excerpt.clone()),
|
||||
post.author.clone(),
|
||||
post.language.clone(),
|
||||
post.template_slug.clone(),
|
||||
@@ -415,11 +434,7 @@ pub fn discard_post_draft(
|
||||
}
|
||||
|
||||
/// Create a new draft post by duplicating an existing post.
|
||||
pub fn duplicate_post(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post_id: &str,
|
||||
) -> EngineResult<Post> {
|
||||
pub fn duplicate_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||
let source = qp::get_post_by_id(conn, post_id)?;
|
||||
let body = if let Some(ref content) = source.content {
|
||||
content.clone()
|
||||
@@ -529,7 +544,7 @@ pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
|
||||
/// Upsert a translation for a post.
|
||||
pub fn upsert_translation(
|
||||
conn: &Connection,
|
||||
_data_dir: &Path,
|
||||
data_dir: &Path,
|
||||
post_id: &str,
|
||||
language: &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);
|
||||
match existing {
|
||||
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.excerpt = excerpt.map(|s| s.to_string());
|
||||
if let Some(c) = content {
|
||||
@@ -834,8 +862,7 @@ fn publish_translation(
|
||||
c.clone()
|
||||
} else if abs_path.exists() {
|
||||
let file_content = fs::read_to_string(&abs_path)?;
|
||||
let (_fm, body) =
|
||||
read_translation_file(&file_content).map_err(|e| EngineError::Parse(e))?;
|
||||
let (_fm, body) = read_translation_file(&file_content).map_err(EngineError::Parse)?;
|
||||
body
|
||||
} else {
|
||||
String::new()
|
||||
@@ -912,7 +939,7 @@ fn rebuild_canonical_post(
|
||||
path: &Path,
|
||||
) -> EngineResult<bool> {
|
||||
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
|
||||
.strip_prefix(data_dir)
|
||||
@@ -1002,7 +1029,7 @@ fn rebuild_translation(
|
||||
path: &Path,
|
||||
) -> EngineResult<bool> {
|
||||
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
|
||||
.strip_prefix(data_dir)
|
||||
@@ -1011,7 +1038,6 @@ fn rebuild_translation(
|
||||
.to_string();
|
||||
|
||||
let hash = content_hash(content.as_bytes());
|
||||
let now = now_unix_ms();
|
||||
|
||||
// Check if parent post exists
|
||||
let parent = qp::get_post_by_id(conn, &fm.translation_for);
|
||||
@@ -1023,12 +1049,17 @@ fn rebuild_translation(
|
||||
}
|
||||
let parent = parent.unwrap();
|
||||
|
||||
// Determine status from parent
|
||||
let status = if parent.status == PostStatus::Published {
|
||||
PostStatus::Published
|
||||
} else {
|
||||
PostStatus::Draft
|
||||
// Current files carry independent translation metadata. Legacy files fall back to the
|
||||
// canonical post values for compatibility.
|
||||
let status = match fm.status.as_deref() {
|
||||
Some("published") => PostStatus::Published,
|
||||
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
|
||||
let existing =
|
||||
@@ -1045,13 +1076,15 @@ fn rebuild_translation(
|
||||
t.status = status;
|
||||
t.file_path = rel_path;
|
||||
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)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
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(),
|
||||
translation_for: fm.translation_for,
|
||||
language: fm.language,
|
||||
@@ -1065,13 +1098,9 @@ fn rebuild_translation(
|
||||
status,
|
||||
file_path: rel_path,
|
||||
checksum: Some(hash),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: if parent.published_at.is_some() {
|
||||
Some(now)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
};
|
||||
qt::insert_post_translation(conn, &t)?;
|
||||
Ok(true)
|
||||
@@ -1093,15 +1122,9 @@ pub fn post_insert_link(slug: &str) -> String {
|
||||
/// Returns the Markdown syntax:  or [name](bds-media://id)
|
||||
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
|
||||
if is_image {
|
||||
format!(
|
||||
"",
|
||||
bds_media_url = format!("bds-media://{media_id}")
|
||||
)
|
||||
format!("")
|
||||
} else {
|
||||
format!(
|
||||
"[{original_name}]({bds_media_url})",
|
||||
bds_media_url = format!("bds-media://{media_id}")
|
||||
)
|
||||
format!("[{original_name}](bds-media://{media_id})")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1120,9 +1143,9 @@ pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
@@ -1584,6 +1607,49 @@ mod tests {
|
||||
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]
|
||||
fn delete_translation_removes_file_and_db() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
@@ -107,10 +107,10 @@ mod tests {
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post_media::list_post_media_by_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::util::sidecar::read_sidecar;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
@@ -182,12 +182,7 @@ mod tests {
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
|
||||
|
||||
// Reverse the order: m2 first, m1 second
|
||||
reorder_post_media(
|
||||
db.conn(),
|
||||
"post1",
|
||||
&["m2".to_string(), "m1".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
reorder_post_media(db.conn(), "post1", &["m2".to_string(), "m1".to_string()]).unwrap();
|
||||
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::{StatusCode, Uri, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::Deserialize;
|
||||
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| {
|
||||
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 {
|
||||
EngineError::Io(error)
|
||||
}
|
||||
@@ -135,23 +137,23 @@ async fn handle_draft_preview(
|
||||
theme: query.theme.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,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_style_preview(
|
||||
Query(query): Query<StylePreviewQuery>,
|
||||
) -> Response {
|
||||
async fn handle_style_preview(Query(query): Query<StylePreviewQuery>) -> Response {
|
||||
let theme = query.theme.unwrap_or_else(|| "default".to_string());
|
||||
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
|
||||
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>",
|
||||
theme,
|
||||
mode,
|
||||
theme,
|
||||
mode,
|
||||
theme, mode, theme, mode,
|
||||
);
|
||||
Html(html).into_response()
|
||||
}
|
||||
@@ -163,7 +165,10 @@ fn render_preview_response(
|
||||
style: Option<&StylePreviewQuery>,
|
||||
) -> EngineResult<Response> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -178,7 +183,14 @@ fn render_preview_response(
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
|
||||
let response = build_preview_response(
|
||||
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);
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -238,15 +252,20 @@ fn render_draft_preview(
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||
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);
|
||||
|
||||
if target_language != canonical_language {
|
||||
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(
|
||||
if target_language != canonical_language
|
||||
&& let Ok(translation) =
|
||||
queries::post_translation::get_post_translation_by_post_and_language(
|
||||
db.conn(),
|
||||
post_id,
|
||||
target_language,
|
||||
) {
|
||||
)
|
||||
{
|
||||
let mut translated_post = post.clone();
|
||||
translated_post.title = translation.title.clone();
|
||||
translated_post.excerpt = translation.excerpt.clone();
|
||||
@@ -261,12 +280,15 @@ fn render_draft_preview(
|
||||
&state.project_id,
|
||||
&metadata,
|
||||
&[(translated_post, body)],
|
||||
&crate::render::build_canonical_post_path(&post, target_language, metadata.main_language.as_deref().unwrap_or("en")),
|
||||
&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 response = build_preview_response(
|
||||
@@ -275,7 +297,11 @@ fn render_draft_preview(
|
||||
&state.project_id,
|
||||
&metadata,
|
||||
&[(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()))?;
|
||||
Ok(response.html)
|
||||
@@ -288,7 +314,10 @@ fn collect_published_posts(
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
||||
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 {
|
||||
body_markdown: load_post_body(&state.data_dir, &post)?,
|
||||
post,
|
||||
@@ -318,7 +347,11 @@ fn load_translation_body(
|
||||
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 body = if translation {
|
||||
read_translation_file(&raw).map(|(_, body)| body)
|
||||
@@ -351,25 +384,32 @@ fn serve_scoped_file(
|
||||
let scope_root = data_dir.join(scope_dir);
|
||||
let candidate = scope_root.join(relative);
|
||||
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_scope_root = scope_root.canonicalize().unwrap_or(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 mime = guess_content_type(&canonical_candidate);
|
||||
Ok(Some((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, mime)],
|
||||
bytes,
|
||||
)
|
||||
.into_response()))
|
||||
Ok(Some(
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
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",
|
||||
"js" => "application/javascript; 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 {
|
||||
(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)]
|
||||
@@ -391,7 +436,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries;
|
||||
use crate::engine::meta;
|
||||
use crate::model::{Post, Project, ProjectMetadata, PostStatus};
|
||||
use crate::model::{Post, PostStatus, Project, ProjectMetadata};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn preview_port_guard() -> &'static Mutex<()> {
|
||||
@@ -504,7 +549,14 @@ mod tests {
|
||||
#[test]
|
||||
fn root_preview_renders_index_page() {
|
||||
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(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(make_post().post, make_post().body_markdown)],
|
||||
"/",
|
||||
)
|
||||
.unwrap()
|
||||
.html;
|
||||
assert!(html.contains("post-list"));
|
||||
@@ -568,12 +620,16 @@ mod tests {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut body = None;
|
||||
for _ in 0..20 {
|
||||
if let Ok(response) = client.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1")).send() {
|
||||
if response.status().is_success() {
|
||||
if let Ok(response) = client
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1"
|
||||
))
|
||||
.send()
|
||||
&& response.status().is_success()
|
||||
{
|
||||
body = Some(response.text().unwrap());
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
server.stop().unwrap();
|
||||
@@ -599,7 +655,9 @@ mod tests {
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"))
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
server.stop().unwrap();
|
||||
@@ -628,7 +686,9 @@ mod tests {
|
||||
.send()
|
||||
.unwrap();
|
||||
let asset = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"))
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let media_body = media.text().unwrap();
|
||||
@@ -739,7 +799,10 @@ mod tests {
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&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",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -750,7 +813,10 @@ mod tests {
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown)],
|
||||
&[
|
||||
(hidden.post, hidden.body_markdown),
|
||||
(featured.post, featured.body_markdown),
|
||||
],
|
||||
"/category/featured",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -893,6 +959,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::engine::site_assets::copy_bundled_site_assets;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::Project;
|
||||
use crate::model::metadata::ProjectMetadata;
|
||||
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique};
|
||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||
|
||||
/// The well-known ID of the default project (spec: DefaultProjectExists).
|
||||
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).
|
||||
/// Rejects deletion of the default project and the currently active project.
|
||||
/// 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
|
||||
if project_id == DEFAULT_PROJECT_ID {
|
||||
return Err(EngineError::Validation(
|
||||
@@ -122,13 +126,13 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
||||
}
|
||||
|
||||
// Check if this is the active project (don't delete active)
|
||||
if let Ok(active) = q::get_active_project(conn) {
|
||||
if active.id == project_id {
|
||||
if let Ok(active) = q::get_active_project(conn)
|
||||
&& active.id == project_id
|
||||
{
|
||||
return Err(EngineError::Validation(
|
||||
"cannot delete the active project".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Only delete internal data directory, never external custom data_path.
|
||||
// The caller must pass the internal path only.
|
||||
@@ -139,13 +143,12 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
||||
q::delete_project(conn, project_id)?;
|
||||
|
||||
// Clean up internal filesystem only (not custom external paths per spec)
|
||||
if !is_custom_path {
|
||||
if let Some(dir) = internal_data_dir {
|
||||
if dir.exists() {
|
||||
if !is_custom_path
|
||||
&& let Some(dir) = internal_data_dir
|
||||
&& dir.exists()
|
||||
{
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -153,7 +156,15 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
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/
|
||||
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")),
|
||||
("not-found.liquid", include_str!("../../../../assets/starter-templates/not-found.liquid")),
|
||||
(
|
||||
"single-post.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)] = &[
|
||||
("head.liquid", include_str!("../../../../assets/starter-templates/partials/head.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")),
|
||||
(
|
||||
"head.liquid",
|
||||
include_str!("../../../../assets/starter-templates/partials/head.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 {
|
||||
@@ -258,12 +290,8 @@ mod tests {
|
||||
fn create_project_inserts_and_creates_dirs() {
|
||||
let (db, dir) = setup();
|
||||
let data_path = dir.path().join("my-blog");
|
||||
let project = create_project(
|
||||
db.conn(),
|
||||
"My Blog",
|
||||
Some(data_path.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let project =
|
||||
create_project(db.conn(), "My Blog", Some(data_path.to_str().unwrap())).unwrap();
|
||||
|
||||
assert_eq!(project.name, "My Blog");
|
||||
assert_eq!(project.slug, "my-blog");
|
||||
@@ -287,13 +315,15 @@ mod tests {
|
||||
assert_eq!(project_json["name"], "My Blog");
|
||||
assert_eq!(project_json["maxPostsPerPage"], 50);
|
||||
|
||||
let cats: Vec<String> =
|
||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/categories.json")).unwrap())
|
||||
let cats: Vec<String> = serde_json::from_str(
|
||||
&fs::read_to_string(data_path.join("meta/categories.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
|
||||
|
||||
let cat_meta: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap())
|
||||
let cat_meta: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cat_meta.as_object().unwrap().is_empty());
|
||||
|
||||
@@ -364,7 +394,10 @@ mod tests {
|
||||
assert!(internal_dir.join("posts").is_dir());
|
||||
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
|
||||
assert!(list_projects(db.conn()).unwrap().is_empty());
|
||||
assert!(!internal_dir.exists(), "internal directory should be cleaned up");
|
||||
assert!(
|
||||
!internal_dir.exists(),
|
||||
"internal directory should be cleaned up"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,11 +4,11 @@ use std::sync::Arc;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::fts;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::engine::media;
|
||||
use crate::engine::post;
|
||||
use crate::engine::script_rebuild;
|
||||
use crate::engine::template_rebuild;
|
||||
use crate::engine::EngineResult;
|
||||
|
||||
/// Report from a full rebuild operation.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -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 cb = Arc::clone(cb);
|
||||
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 msg = format!("Posts: {current}/{total} \u{2014} {name}");
|
||||
cb(global_pct, &msg);
|
||||
@@ -76,7 +80,10 @@ pub fn rebuild_from_filesystem_with_progress(
|
||||
f
|
||||
});
|
||||
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_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 cb = Arc::clone(cb);
|
||||
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 msg = format!("Media: {current}/{total} \u{2014} {name}");
|
||||
cb(global_pct, &msg);
|
||||
@@ -97,7 +108,10 @@ pub fn rebuild_from_filesystem_with_progress(
|
||||
f
|
||||
});
|
||||
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_updated = media_report.media_updated;
|
||||
@@ -133,12 +147,12 @@ pub fn rebuild_from_filesystem_with_progress(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media as qm;
|
||||
use crate::db::queries::post as qp;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::db::queries::template as qtpl;
|
||||
use crate::db::Database;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -276,11 +290,7 @@ updatedAt: 2024-01-15T12:00:00.000Z
|
||||
tags: []
|
||||
---
|
||||
";
|
||||
fs::write(
|
||||
media_dir.join("test-media-1.jpg.meta"),
|
||||
sidecar_content,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();
|
||||
|
||||
// Template
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
|
||||
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
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};
|
||||
|
||||
/// 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.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "optional arguments represent independent script field changes"
|
||||
)]
|
||||
pub fn update_script(
|
||||
conn: &Connection,
|
||||
script_id: &str,
|
||||
@@ -67,15 +71,14 @@ pub fn update_script(
|
||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||
|
||||
// Slug uniqueness
|
||||
if let Some(new_slug) = slug {
|
||||
if new_slug != script.slug {
|
||||
if qs::get_script_by_slug(conn, project_id, new_slug).is_ok() {
|
||||
if let Some(new_slug) = slug
|
||||
&& new_slug != script.slug
|
||||
&& qs::get_script_by_slug(conn, project_id, new_slug).is_ok()
|
||||
{
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"script slug '{new_slug}' already exists"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(t) = title {
|
||||
script.title = t.to_string();
|
||||
@@ -108,11 +111,7 @@ pub fn update_script(
|
||||
}
|
||||
|
||||
/// Save script content (editor save). Updates DB, bumps version.
|
||||
pub fn save_script(
|
||||
conn: &Connection,
|
||||
script_id: &str,
|
||||
content: &str,
|
||||
) -> EngineResult<Script> {
|
||||
pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineResult<Script> {
|
||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||
script.content = Some(content.to_string());
|
||||
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.
|
||||
pub fn publish_script(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
script_id: &str,
|
||||
) -> EngineResult<Script> {
|
||||
pub fn publish_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<Script> {
|
||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||
|
||||
if script.status == ScriptStatus::Published {
|
||||
@@ -221,9 +216,7 @@ pub fn unpublish_script(
|
||||
let mut script = qs::get_script_by_id(conn, script_id)?;
|
||||
|
||||
if script.status != ScriptStatus::Published {
|
||||
return Err(EngineError::Conflict(
|
||||
"script is not published".to_string(),
|
||||
));
|
||||
return Err(EngineError::Conflict("script is not published".to_string()));
|
||||
}
|
||||
|
||||
if !script.file_path.is_empty() {
|
||||
@@ -244,11 +237,7 @@ pub fn unpublish_script(
|
||||
}
|
||||
|
||||
/// Delete a script and its file.
|
||||
pub fn delete_script(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
script_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
pub fn delete_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<()> {
|
||||
let script = qs::get_script_by_id(conn, script_id)?;
|
||||
|
||||
// Delete file if exists
|
||||
@@ -311,8 +300,8 @@ fn validate_lua_blocks(content: &str) -> Result<(), String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
@@ -326,7 +315,15 @@ mod tests {
|
||||
#[test]
|
||||
fn create_draft_script() {
|
||||
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.slug, "my-script");
|
||||
assert_eq!(s.kind, ScriptKind::Macro);
|
||||
@@ -340,7 +337,15 @@ mod tests {
|
||||
#[test]
|
||||
fn create_with_custom_entrypoint() {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -357,7 +362,18 @@ mod tests {
|
||||
fn update_script_bumps_version() {
|
||||
let (db, _dir) = setup();
|
||||
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.content, Some("new".to_string()));
|
||||
assert_eq!(updated.version, 2);
|
||||
@@ -375,7 +391,15 @@ mod tests {
|
||||
#[test]
|
||||
fn publish_and_unpublish_script() {
|
||||
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();
|
||||
assert_eq!(published.status, ScriptStatus::Published);
|
||||
@@ -391,7 +415,15 @@ mod tests {
|
||||
#[test]
|
||||
fn delete_script_removes_file() {
|
||||
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();
|
||||
assert!(dir.path().join("scripts/del.lua").exists());
|
||||
|
||||
|
||||
@@ -140,8 +140,8 @@ fn rebuild_single_script(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
@@ -176,8 +176,7 @@ end
|
||||
";
|
||||
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 1);
|
||||
assert_eq!(report.updated, 0);
|
||||
@@ -191,7 +190,10 @@ end
|
||||
assert!(script.enabled);
|
||||
assert_eq!(script.version, 1);
|
||||
assert_eq!(script.status, ScriptStatus::Published);
|
||||
assert!(script.content.is_none(), "published script should have content=None in DB");
|
||||
assert!(
|
||||
script.content.is_none(),
|
||||
"published script should have content=None in DB"
|
||||
);
|
||||
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();
|
||||
|
||||
let report =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 1);
|
||||
assert_eq!(report.updated, 0);
|
||||
@@ -280,8 +281,7 @@ end
|
||||
";
|
||||
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 0);
|
||||
assert_eq!(report.updated, 1);
|
||||
@@ -321,14 +321,12 @@ function run() end
|
||||
fs::write(scripts_dir.join("idem.lua"), content).unwrap();
|
||||
|
||||
// First run
|
||||
let r1 =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let r1 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r1.created, 1);
|
||||
assert_eq!(r1.updated, 0);
|
||||
|
||||
// Second run - should update, not create
|
||||
let r2 =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let r2 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r2.created, 0);
|
||||
assert_eq!(r2.updated, 1);
|
||||
|
||||
|
||||
@@ -13,42 +13,222 @@ pub(crate) struct 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 { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") },
|
||||
BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") },
|
||||
BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") },
|
||||
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 { 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") },
|
||||
BundledSiteAsset {
|
||||
relative_path: "assets/bds.css",
|
||||
bytes: include_bytes!(
|
||||
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css"
|
||||
),
|
||||
},
|
||||
BundledSiteAsset {
|
||||
relative_path: "assets/calendar-runtime.js",
|
||||
bytes: include_bytes!(
|
||||
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js"
|
||||
),
|
||||
},
|
||||
BundledSiteAsset {
|
||||
relative_path: "assets/code-enhancements.js",
|
||||
bytes: include_bytes!(
|
||||
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js"
|
||||
),
|
||||
},
|
||||
BundledSiteAsset {
|
||||
relative_path: "assets/d3.layout.cloud.js",
|
||||
bytes: include_bytes!(
|
||||
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js"
|
||||
),
|
||||
},
|
||||
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 {
|
||||
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<()> {
|
||||
@@ -72,11 +252,21 @@ pub(crate) fn write_bundled_site_assets(
|
||||
report: &mut crate::engine::generation::GenerationReport,
|
||||
) -> EngineResult<()> {
|
||||
for asset in BUNDLED_SITE_ASSETS {
|
||||
match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes)
|
||||
match write_generated_bytes(
|
||||
conn,
|
||||
output_dir,
|
||||
project_id,
|
||||
asset.relative_path,
|
||||
asset.bytes,
|
||||
)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||
{
|
||||
GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()),
|
||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()),
|
||||
GeneratedWriteOutcome::Written => {
|
||||
report.written_paths.push(asset.relative_path.to_string())
|
||||
}
|
||||
GeneratedWriteOutcome::SkippedUnchanged => {
|
||||
report.skipped_paths.push(asset.relative_path.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::db::queries::post as post_q;
|
||||
use crate::db::queries::tag as tag_q;
|
||||
use crate::engine::meta;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::metadata::TagEntry;
|
||||
use crate::model::Tag;
|
||||
use crate::model::metadata::TagEntry;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
/// 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.
|
||||
/// This is additive only — it does NOT rewrite tags.json.
|
||||
pub fn sync_tags_from_posts(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Vec<Tag>> {
|
||||
pub fn sync_tags_from_posts(conn: &Connection, project_id: &str) -> EngineResult<Vec<Tag>> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
|
||||
// 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.
|
||||
pub fn rewrite_tags_json(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
pub fn rewrite_tags_json(conn: &Connection, data_dir: &Path, project_id: &str) -> EngineResult<()> {
|
||||
let tags = tag_q::list_tags_by_project(conn, project_id)?;
|
||||
let entries: Vec<TagEntry> = tags
|
||||
.into_iter()
|
||||
@@ -317,8 +310,8 @@ fn flush_post_frontmatter(
|
||||
data_dir: &Path,
|
||||
post_ids: &[String],
|
||||
) -> EngineResult<()> {
|
||||
use crate::util::frontmatter::write_post_file;
|
||||
use crate::util::atomic_write_str;
|
||||
use crate::util::frontmatter::write_post_file;
|
||||
|
||||
for post_id in post_ids {
|
||||
let post = post_q::get_post_by_id(conn, post_id)?;
|
||||
@@ -328,7 +321,7 @@ fn flush_post_frontmatter(
|
||||
// Read existing body from file
|
||||
let content = std::fs::read_to_string(&abs_path)?;
|
||||
let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
|
||||
.map_err(|e| EngineError::Parse(e))?;
|
||||
.map_err(EngineError::Parse)?;
|
||||
// Rewrite with updated frontmatter
|
||||
let file_content = write_post_file(&post, &body);
|
||||
atomic_write_str(&abs_path, &file_content)?;
|
||||
@@ -348,8 +341,7 @@ fn remove_tag_name_from_posts(
|
||||
let mut modified = Vec::new();
|
||||
for mut post in posts {
|
||||
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
|
||||
post.tags
|
||||
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
|
||||
post.tags.retain(|t| !t.eq_ignore_ascii_case(tag_name));
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
modified.push(post.id.clone());
|
||||
@@ -361,9 +353,9 @@ fn remove_tag_name_from_posts(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::post::insert_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::{Post, PostStatus};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -470,11 +462,7 @@ mod tests {
|
||||
fn rename_tag_updates_posts() {
|
||||
let (db, dir) = setup();
|
||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x1", "hello", vec!["rust".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(db.conn(), &make_post("x1", "hello", vec!["rust".into()])).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 t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
|
||||
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x1", "a", vec!["rs".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(db.conn(), &make_post("x1", "a", vec!["rs".into()])).unwrap();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x2", "b", vec!["rust".into(), "target".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_tags(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&[&t1.id, &t2.id],
|
||||
&t3.id,
|
||||
)
|
||||
.unwrap();
|
||||
merge_tags(db.conn(), dir.path(), "p1", &[&t1.id, &t2.id], &t3.id).unwrap();
|
||||
|
||||
// Post x1 should now have "target"
|
||||
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
|
||||
@@ -563,7 +540,10 @@ mod tests {
|
||||
assert_eq!(tags.len(), 2);
|
||||
|
||||
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()]);
|
||||
}
|
||||
|
||||
@@ -572,8 +552,16 @@ mod tests {
|
||||
let (db, dir) = setup();
|
||||
// Write tags.json with colors
|
||||
let entries = vec![
|
||||
TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None },
|
||||
TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) },
|
||||
TagEntry {
|
||||
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();
|
||||
|
||||
@@ -605,9 +593,18 @@ mod tests {
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
let post = crate::engine::post::create_post(
|
||||
db.conn(), dir.path(), "p1", "Tagged Post", Some("body content"),
|
||||
vec!["rust".into()], vec![], None, None, None,
|
||||
).unwrap();
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"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();
|
||||
|
||||
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
|
||||
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();
|
||||
assert!(file_content.contains("golang"), "frontmatter should contain renamed tag");
|
||||
assert!(!file_content.contains("rust"), "frontmatter should not contain old tag name");
|
||||
assert!(
|
||||
file_content.contains("golang"),
|
||||
"frontmatter should contain renamed tag"
|
||||
);
|
||||
assert!(
|
||||
!file_content.contains("rust"),
|
||||
"frontmatter should not contain old tag name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Unique task identifier.
|
||||
pub type TaskId = u64;
|
||||
@@ -23,6 +23,19 @@ pub struct TaskProgress {
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
struct TaskEntry {
|
||||
@@ -35,6 +48,7 @@ struct TaskEntry {
|
||||
progress: Option<f32>,
|
||||
message: Option<String>,
|
||||
created_at: Instant,
|
||||
finished_at: Option<Instant>,
|
||||
last_progress_report: Option<Instant>,
|
||||
}
|
||||
|
||||
@@ -71,18 +85,24 @@ impl TaskManager {
|
||||
progress: None,
|
||||
message: None,
|
||||
created_at: Instant::now(),
|
||||
finished_at: None,
|
||||
last_progress_report: None,
|
||||
};
|
||||
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
tasks.push(entry);
|
||||
// Auto-start if under capacity
|
||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||
if running < self.max_concurrent {
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) {
|
||||
let running = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count();
|
||||
if running < self.max_concurrent
|
||||
&& let Some(t) = tasks
|
||||
.iter_mut()
|
||||
.find(|t| t.id == id && t.status == TaskStatus::Pending)
|
||||
{
|
||||
t.status = TaskStatus::Running;
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
@@ -101,27 +121,31 @@ impl TaskManager {
|
||||
/// Running, false if concurrency is at capacity or the task is not Queued.
|
||||
pub fn try_start(&self, task_id: TaskId) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||
let running = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count();
|
||||
if running >= self.max_concurrent {
|
||||
return false;
|
||||
}
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if entry.status == TaskStatus::Pending {
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& entry.status == TaskStatus::Pending
|
||||
{
|
||||
entry.status = TaskStatus::Running;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Mark a task as completed.
|
||||
pub fn complete(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if matches!(entry.status, TaskStatus::Running) {
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& matches!(entry.status, TaskStatus::Running)
|
||||
{
|
||||
entry.status = TaskStatus::Completed;
|
||||
entry.progress = Some(1.0);
|
||||
}
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
}
|
||||
@@ -129,11 +153,12 @@ impl TaskManager {
|
||||
/// Mark a task as failed with an error message.
|
||||
pub fn fail(&self, task_id: TaskId, error: String) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if matches!(entry.status, TaskStatus::Running) {
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& matches!(entry.status, TaskStatus::Running)
|
||||
{
|
||||
entry.message = Some(error.clone());
|
||||
entry.status = TaskStatus::Failed(error);
|
||||
}
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
}
|
||||
@@ -141,11 +166,12 @@ impl TaskManager {
|
||||
/// Cancel a task by setting its cancel flag and status.
|
||||
pub fn cancel(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) {
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -163,44 +189,64 @@ impl TaskManager {
|
||||
/// Return the current status of a task.
|
||||
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).map(|t| t.status.clone())
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
.map(|t| t.status.clone())
|
||||
}
|
||||
|
||||
/// Count tasks that are still queued.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().filter(|t| t.status == TaskStatus::Pending).count()
|
||||
tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Pending)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count tasks that are currently running.
|
||||
pub fn running_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().filter(|t| t.status == TaskStatus::Running).count()
|
||||
tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Remove all completed, failed, and cancelled tasks.
|
||||
pub fn drain_completed(&self) {
|
||||
/// Remove finished tasks older than the configured retention period.
|
||||
pub fn evict_expired(&self) {
|
||||
let cutoff = Instant::now() - FINISHED_TASK_TTL;
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
tasks.retain(|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.
|
||||
pub fn label(&self, task_id: TaskId) -> Option<String> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).map(|t| t.label.clone())
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
.map(|t| t.label.clone())
|
||||
}
|
||||
|
||||
/// Return the id of the first queued task (FIFO order).
|
||||
pub fn next_queued(&self) -> Option<TaskId> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.status == TaskStatus::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.
|
||||
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if entry.status == TaskStatus::Running {
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& entry.status == TaskStatus::Running
|
||||
{
|
||||
let now = Instant::now();
|
||||
let should_report = match entry.last_progress_report {
|
||||
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
|
||||
@@ -213,39 +259,66 @@ impl TaskManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current progress of a task.
|
||||
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.progress)
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
.and_then(|t| t.progress)
|
||||
}
|
||||
|
||||
/// Return the current message of a task.
|
||||
pub fn message(&self, task_id: TaskId) -> Option<String> {
|
||||
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.
|
||||
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 mut snapshots = tasks
|
||||
.iter()
|
||||
.filter(|task| task.finished_at.is_none())
|
||||
.chain(
|
||||
tasks
|
||||
.iter()
|
||||
.map(|t| (t.id, t.label.clone(), t.status.clone(), t.progress, t.message.clone()))
|
||||
.collect()
|
||||
.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.
|
||||
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
|
||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||
if running < max_concurrent {
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) {
|
||||
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
|
||||
let running = tasks
|
||||
.iter()
|
||||
.filter(|t| 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskManager {
|
||||
fn default() -> Self {
|
||||
@@ -255,6 +328,8 @@ impl Default for TaskManager {
|
||||
|
||||
/// Default progress throttle interval (250ms per spec).
|
||||
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.
|
||||
pub struct ProgressThrottle {
|
||||
@@ -344,13 +419,16 @@ mod tests {
|
||||
mgr.fail(bad, "disk full".into());
|
||||
|
||||
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
|
||||
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
|
||||
assert_eq!(
|
||||
mgr.status(bad),
|
||||
Some(TaskStatus::Failed("disk full".into()))
|
||||
);
|
||||
// Progress should be 1.0 on completed
|
||||
assert_eq!(mgr.progress(ok), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_removes_finished() {
|
||||
fn eviction_removes_only_expired_finished_tasks() {
|
||||
let mgr = TaskManager::new(3);
|
||||
let a = mgr.submit("done"); // auto-starts
|
||||
let b = mgr.submit("broken"); // auto-starts
|
||||
@@ -364,7 +442,14 @@ mod tests {
|
||||
// After a completes: c 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(b), None);
|
||||
@@ -372,6 +457,17 @@ mod tests {
|
||||
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]
|
||||
fn completing_task_starts_next_queued() {
|
||||
let mgr = TaskManager::new(1);
|
||||
|
||||
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
||||
use crate::db::queries::template as qt;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
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};
|
||||
|
||||
/// 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.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "optional arguments represent independent template field changes"
|
||||
)]
|
||||
pub fn update_template(
|
||||
conn: &Connection,
|
||||
template_id: &str,
|
||||
@@ -64,15 +68,14 @@ pub fn update_template(
|
||||
let mut tpl = qt::get_template_by_id(conn, template_id)?;
|
||||
|
||||
// Slug uniqueness check
|
||||
if let Some(new_slug) = slug {
|
||||
if new_slug != tpl.slug {
|
||||
if qt::get_template_by_slug(conn, project_id, new_slug).is_ok() {
|
||||
if let Some(new_slug) = slug
|
||||
&& new_slug != tpl.slug
|
||||
&& qt::get_template_by_slug(conn, project_id, new_slug).is_ok()
|
||||
{
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"template slug '{new_slug}' already exists"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(t) = title {
|
||||
tpl.title = t.to_string();
|
||||
@@ -149,10 +152,7 @@ pub fn publish_template(
|
||||
));
|
||||
}
|
||||
|
||||
let body = tpl
|
||||
.content
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let body = tpl.content.clone().unwrap_or_default();
|
||||
|
||||
// Validate before publishing
|
||||
validate_template(&body).map_err(EngineError::Validation)?;
|
||||
@@ -210,8 +210,7 @@ pub fn unpublish_template(
|
||||
let abs_path = data_dir.join(&tpl.file_path);
|
||||
if abs_path.exists() {
|
||||
let file_content = fs::read_to_string(&abs_path)?;
|
||||
let (_fm, body) =
|
||||
crate::util::frontmatter::read_template_file(&file_content)
|
||||
let (_fm, body) = crate::util::frontmatter::read_template_file(&file_content)
|
||||
.map_err(EngineError::Parse)?;
|
||||
tpl.content = Some(body);
|
||||
}
|
||||
@@ -340,25 +339,29 @@ fn validate_liquid_blocks(content: &str) -> Result<(), String> {
|
||||
let start = i + 2;
|
||||
if let Some(end_offset) = content[start..].find("%}") {
|
||||
let tag_content = content[start..start + end_offset].trim();
|
||||
let tag_content = tag_content.trim_start_matches('-').trim_end_matches('-').trim();
|
||||
let first_word = tag_content
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
let tag_content = tag_content
|
||||
.trim_start_matches('-')
|
||||
.trim_end_matches('-')
|
||||
.trim();
|
||||
let first_word = tag_content.split_whitespace().next().unwrap_or("");
|
||||
|
||||
match first_word {
|
||||
"if" => if_depth += 1,
|
||||
"endif" => {
|
||||
if_depth -= 1;
|
||||
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,
|
||||
"endfor" => {
|
||||
for_depth -= 1;
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
@@ -431,7 +434,14 @@ mod tests {
|
||||
#[test]
|
||||
fn create_draft_template() {
|
||||
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.slug, "my-template");
|
||||
assert_eq!(tpl.kind, TemplateKind::Post);
|
||||
@@ -456,9 +466,16 @@ mod tests {
|
||||
let (db, _dir) = setup();
|
||||
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
|
||||
let updated = update_template(
|
||||
db.conn(), &tpl.id, "p1",
|
||||
Some("New Title"), None, None, None, Some("new content"),
|
||||
).unwrap();
|
||||
db.conn(),
|
||||
&tpl.id,
|
||||
"p1",
|
||||
Some("New Title"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("new content"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.title, "New Title");
|
||||
assert_eq!(updated.content, Some("new content".to_string()));
|
||||
assert_eq!(updated.version, 2);
|
||||
@@ -469,7 +486,16 @@ mod tests {
|
||||
let (db, _dir) = setup();
|
||||
create_template(db.conn(), "p1", "Alpha", 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());
|
||||
}
|
||||
|
||||
@@ -485,7 +511,14 @@ mod tests {
|
||||
#[test]
|
||||
fn publish_and_unpublish_template() {
|
||||
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
|
||||
let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
|
||||
|
||||
@@ -139,8 +139,8 @@ fn rebuild_single_template(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use tempfile::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();
|
||||
|
||||
let report =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 1);
|
||||
assert_eq!(report.updated, 0);
|
||||
@@ -186,7 +185,10 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
assert!(tpl.enabled);
|
||||
assert_eq!(tpl.version, 1);
|
||||
assert_eq!(tpl.status, TemplateStatus::Published);
|
||||
assert!(tpl.content.is_none(), "published template should have content=None in DB");
|
||||
assert!(
|
||||
tpl.content.is_none(),
|
||||
"published template should have content=None in DB"
|
||||
);
|
||||
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();
|
||||
|
||||
let report =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 0);
|
||||
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("styles.css"), "body {}").unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 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();
|
||||
|
||||
// First run
|
||||
let r1 =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let r1 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r1.created, 1);
|
||||
assert_eq!(r1.updated, 0);
|
||||
|
||||
// Second run - should update, not create
|
||||
let r2 =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
let r2 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r2.created, 0);
|
||||
assert_eq!(r2.updated, 1);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// Validation functions for template (Liquid) and script (Lua/Python) content.
|
||||
/// Per template.allium and script.allium, these are pre-publish gates.
|
||||
///
|
||||
/// Current implementation: basic structural checks.
|
||||
/// When a full Liquid parser crate is added, upgrade `validate_liquid` to
|
||||
/// attempt a real parse. Similarly for `validate_script` with a Lua parser.
|
||||
//! Validation functions for template (Liquid) and script (Lua/Python) content.
|
||||
//! Per template.allium and script.allium, these are pre-publish gates.
|
||||
//!
|
||||
//! Current implementation: basic structural checks.
|
||||
//! When a full Liquid parser crate is added, upgrade `validate_liquid` to
|
||||
//! attempt a real parse. Similarly for `validate_script` with a Lua parser.
|
||||
|
||||
/// Result of a validation check.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -35,8 +35,10 @@ pub fn validate_liquid(content: &str) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Check for unmatched Liquid block tags
|
||||
let block_tags = ["if", "unless", "for", "case", "capture", "comment",
|
||||
"raw", "paginate", "tablerow", "block", "schema"];
|
||||
let block_tags = [
|
||||
"if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow",
|
||||
"block", "schema",
|
||||
];
|
||||
|
||||
for tag in &block_tags {
|
||||
let open_pattern = format!("{{% {tag}");
|
||||
|
||||
@@ -25,7 +25,8 @@ pub fn validate_site(
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||
let output_dir = generated_output_dir(data_dir);
|
||||
let published_posts = load_published_posts(data_dir, conn, project_id)?;
|
||||
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
|
||||
let artifacts =
|
||||
build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
|
||||
let mut expected = artifacts
|
||||
@@ -36,7 +37,12 @@ pub fn validate_site(
|
||||
expected.insert("calendar.json".to_string());
|
||||
expected.insert("rss.xml".to_string());
|
||||
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()
|
||||
} else {
|
||||
format!("{language}/")
|
||||
@@ -79,7 +85,9 @@ pub fn validate_site(
|
||||
|
||||
let mut stale_pages = Vec::new();
|
||||
for rel in expected.intersection(&actual) {
|
||||
if let Ok(stored) = queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel) {
|
||||
if let Ok(stored) =
|
||||
queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel)
|
||||
{
|
||||
let actual_hash = file_hash(&output_dir.join(rel))?;
|
||||
if actual_hash != stored.content_hash {
|
||||
stale_pages.push(rel.clone());
|
||||
@@ -114,13 +122,17 @@ fn load_published_posts(
|
||||
) -> EngineResult<Vec<(Post, String)>> {
|
||||
let posts = queries::post::list_posts_by_project(conn, project_id)?;
|
||||
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 {
|
||||
content.clone()
|
||||
} else if let Some(content) = &post.published_content {
|
||||
content.clone()
|
||||
} 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)
|
||||
.map(|(_, body)| body)
|
||||
.map_err(EngineError::Parse)?
|
||||
@@ -131,10 +143,16 @@ fn load_published_posts(
|
||||
}
|
||||
|
||||
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()];
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,14 @@ pub fn validate_translations(
|
||||
blog_languages: &[String],
|
||||
main_language: &str,
|
||||
) -> 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.
|
||||
@@ -193,11 +200,13 @@ pub fn validate_translations_with_progress(
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -133,11 +133,11 @@ pub fn translate(locale: UiLocale, key: &str) -> String {
|
||||
if let Some(val) = catalog_for(locale).get(key) {
|
||||
return val.clone();
|
||||
}
|
||||
if locale != UiLocale::En {
|
||||
if let Some(val) = CATALOG_EN.get(key) {
|
||||
if locale != UiLocale::En
|
||||
&& let Some(val) = CATALOG_EN.get(key)
|
||||
{
|
||||
return val.clone();
|
||||
}
|
||||
}
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
@@ -163,11 +163,11 @@ pub fn translate_render(language: &str, key: &str) -> String {
|
||||
if let Some(val) = catalog.get(key) {
|
||||
return val.clone();
|
||||
}
|
||||
if !language.starts_with("en") {
|
||||
if let Some(val) = RENDER_EN.get(key) {
|
||||
if !language.starts_with("en")
|
||||
&& let Some(val) = RENDER_EN.get(key)
|
||||
{
|
||||
return val.clone();
|
||||
}
|
||||
}
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
@@ -302,7 +302,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod db;
|
||||
pub mod engine;
|
||||
pub mod i18n;
|
||||
pub mod model;
|
||||
pub mod render;
|
||||
pub mod i18n;
|
||||
pub mod scripting;
|
||||
pub mod util;
|
||||
|
||||
@@ -154,10 +154,15 @@ mod tests {
|
||||
fn max_posts_per_page_validation() {
|
||||
let mut meta = ProjectMetadata {
|
||||
name: "Test".into(),
|
||||
description: None, public_url: None, main_language: None,
|
||||
default_author: None, max_posts_per_page: 50, blogmark_category: None,
|
||||
description: None,
|
||||
public_url: None,
|
||||
main_language: None,
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false, blog_languages: vec![],
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec![],
|
||||
};
|
||||
assert!(meta.validate().is_ok());
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
mod post;
|
||||
mod media;
|
||||
mod tag;
|
||||
mod project;
|
||||
mod template;
|
||||
mod script;
|
||||
mod generation;
|
||||
mod media;
|
||||
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::{
|
||||
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
|
||||
PublishingPreferences, SshMode,
|
||||
};
|
||||
pub use media::{Media, MediaTranslation};
|
||||
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};
|
||||
|
||||
@@ -32,14 +32,13 @@ pub fn write_generated_file(
|
||||
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let hash = content_hash(content.as_bytes());
|
||||
let target_path = output_dir.join(relative_path);
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
||||
if existing.content_hash == hash
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
|
||||
&& existing.content_hash == hash
|
||||
&& target_path.exists()
|
||||
&& file_hash(&target_path)? == hash
|
||||
{
|
||||
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -68,14 +67,13 @@ pub fn write_generated_bytes(
|
||||
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let hash = content_hash(content);
|
||||
let target_path = output_dir.join(relative_path);
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
||||
if existing.content_hash == hash
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
|
||||
&& existing.content_hash == hash
|
||||
&& target_path.exists()
|
||||
&& file_hash(&target_path)? == hash
|
||||
{
|
||||
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -135,7 +133,11 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
|
||||
*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> {
|
||||
|
||||
@@ -52,17 +52,10 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
|
||||
"vimeo" => Some(render_vimeo(&args)),
|
||||
"photo_archive" => Some(render_photo_archive(&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> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
@@ -100,13 +93,12 @@ fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
||||
|
||||
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.len() >= 2 {
|
||||
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
|
||||
if trimmed.len() >= 2
|
||||
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||
{
|
||||
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match trimmed {
|
||||
"true" => return JsonValue::Bool(true),
|
||||
@@ -197,7 +189,11 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
|
||||
fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
|
||||
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
|
||||
if video_id.is_empty() {
|
||||
return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id.");
|
||||
return empty_block(
|
||||
"macro-youtube",
|
||||
"gallery-empty",
|
||||
"Missing YouTube video id.",
|
||||
);
|
||||
}
|
||||
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>",
|
||||
@@ -224,10 +220,18 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
|
||||
.or_else(|| linked_media(context));
|
||||
|
||||
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() {
|
||||
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();
|
||||
@@ -243,7 +247,11 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
|
||||
let single_month = grouped.len() == 1;
|
||||
let mut html = format!(
|
||||
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
|
||||
if single_month { " photo-archive-single-month" } else { "" }
|
||||
if single_month {
|
||||
" photo-archive-single-month"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
for (bucket, items) in grouped.into_iter().rev() {
|
||||
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> {
|
||||
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> {
|
||||
@@ -391,7 +402,9 @@ fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -407,7 +420,9 @@ fn stringify_scalar(value: &JsonValue) -> String {
|
||||
fn month_bucket(path: &str) -> Option<String> {
|
||||
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -422,11 +437,16 @@ fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> Strin
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
escape_html(value).replace('"', """).replace('\'', "'")
|
||||
escape_html(value)
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -470,4 +490,13 @@ mod tests {
|
||||
assert!(html.contains("macro-tag-cloud"));
|
||||
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,14 +1,14 @@
|
||||
mod markdown;
|
||||
mod macros;
|
||||
mod generation;
|
||||
mod macros;
|
||||
mod markdown;
|
||||
mod page_renderer;
|
||||
mod routes;
|
||||
mod site;
|
||||
mod template_lookup;
|
||||
|
||||
pub use generation::{
|
||||
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
|
||||
build_core_generation_paths, write_generated_bytes, write_generated_file,
|
||||
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
||||
write_generated_bytes, write_generated_file,
|
||||
};
|
||||
pub use markdown::render_markdown_to_html;
|
||||
pub use page_renderer::{RenderError, render_liquid_template};
|
||||
@@ -18,10 +18,9 @@ pub use routes::{
|
||||
render_starter_single_post_page_with_media_map,
|
||||
};
|
||||
pub use site::{
|
||||
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts,
|
||||
build_preview_response, build_site_render_artifacts,
|
||||
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
|
||||
build_site_render_artifacts,
|
||||
};
|
||||
pub use template_lookup::{
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||
resolve_post_template,
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::collections::HashMap;
|
||||
|
||||
use liquid::ParserBuilder;
|
||||
use liquid::partials::{EagerCompiler, InMemorySource};
|
||||
use liquid_core::{
|
||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
|
||||
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
|
||||
};
|
||||
use liquid_core::model::ScalarCow;
|
||||
use liquid_core::{
|
||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
|
||||
ParseFilter, Runtime, Value, ValueView,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||
use thiserror::Error;
|
||||
@@ -14,6 +14,7 @@ use thiserror::Error;
|
||||
use crate::i18n::translate_render;
|
||||
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
|
||||
use crate::render::render_markdown_to_html;
|
||||
use crate::util::slugify;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RenderError {
|
||||
@@ -40,6 +41,7 @@ pub fn render_liquid_template<T: Serialize>(
|
||||
let parser = ParserBuilder::with_stdlib()
|
||||
.filter(I18n)
|
||||
.filter(Markdown)
|
||||
.filter(Slugify)
|
||||
.partials(compiled_partials)
|
||||
.build()?;
|
||||
let template = parser.parse(template_source)?;
|
||||
@@ -47,6 +49,28 @@ pub fn render_liquid_template<T: Serialize>(
|
||||
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)]
|
||||
struct I18nArgs {
|
||||
#[parameter(description = "Render language", arg_type = "str")]
|
||||
@@ -74,7 +98,10 @@ impl Filter for I18nFilter {
|
||||
let args = self.args.evaluate(runtime)?;
|
||||
let key = input.to_kstr();
|
||||
let language = args.language.to_kstr();
|
||||
Ok(Value::scalar(translate_render(language.as_str(), key.as_str())))
|
||||
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(), ¯o_context);
|
||||
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,11 +178,11 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
||||
}
|
||||
}
|
||||
|
||||
if !roots.contains_key("Tags") {
|
||||
if let Some(tags) = roots.get("post_tags").cloned() {
|
||||
if !roots.contains_key("Tags")
|
||||
&& let Some(tags) = roots.get("post_tags").cloned()
|
||||
{
|
||||
roots.insert("Tags".to_string(), tags);
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
@@ -202,9 +232,16 @@ fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String {
|
||||
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context));
|
||||
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context))
|
||||
pub(crate) fn rewrite_rendered_html_urls(
|
||||
html: &str,
|
||||
rewrite_context: &impl RewriteContextView,
|
||||
) -> String {
|
||||
let rewritten = rewrite_attribute_urls(html, "href", |href| {
|
||||
normalize_preview_href(href, rewrite_context)
|
||||
});
|
||||
rewrite_attribute_urls(&rewritten, "src", |src| {
|
||||
normalize_preview_src(src, rewrite_context)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) trait RewriteContextView {
|
||||
@@ -387,7 +424,9 @@ fn extract_post_slug(path: &str) -> Option<String> {
|
||||
let segments: Vec<_> = trimmed.split('/').collect();
|
||||
match segments.as_slice() {
|
||||
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
|
||||
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => {
|
||||
["post" | "posts", year, month, slug]
|
||||
if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) =>
|
||||
{
|
||||
Some(trim_html_suffix(slug))
|
||||
}
|
||||
_ => None,
|
||||
@@ -422,7 +461,7 @@ fn trim_html_suffix(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{rewrite_rendered_html_urls, RewriteContextView};
|
||||
use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct TestRewriteContext {
|
||||
@@ -457,4 +496,16 @@ mod tests {
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,16 @@ use crate::i18n::normalize_language;
|
||||
use crate::model::{Post, ProjectMetadata};
|
||||
use crate::render::{RenderError, render_liquid_template};
|
||||
|
||||
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
||||
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||
const STARTER_SINGLE_POST_TEMPLATE: &str =
|
||||
include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||
const STARTER_POST_LIST_TEMPLATE: &str =
|
||||
include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||
const STARTER_HEAD_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
||||
const STARTER_MENU_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderedPage {
|
||||
@@ -136,7 +141,10 @@ pub fn render_starter_single_post_page_with_media_map(
|
||||
language: &str,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
) -> 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 (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
|
||||
let context = PostTemplateContext {
|
||||
@@ -171,13 +179,12 @@ pub fn render_starter_single_post_page_with_media_map(
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
let html = render_liquid_template(
|
||||
STARTER_SINGLE_POST_TEMPLATE,
|
||||
&starter_partials(),
|
||||
&context,
|
||||
)?;
|
||||
let html = render_liquid_template(STARTER_SINGLE_POST_TEMPLATE, &starter_partials(), &context)?;
|
||||
|
||||
Ok(RenderedPage { relative_path, html })
|
||||
Ok(RenderedPage {
|
||||
relative_path,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
|
||||
let html = render_liquid_template(
|
||||
&starter_post_list_template(),
|
||||
&starter_partials(),
|
||||
&context,
|
||||
)?;
|
||||
let html =
|
||||
render_liquid_template(&starter_post_list_template(), &starter_partials(), &context)?;
|
||||
|
||||
Ok(RenderedPage { relative_path, html })
|
||||
Ok(RenderedPage {
|
||||
relative_path,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
fn starter_partials() -> HashMap<String, String> {
|
||||
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(),
|
||||
STARTER_LANGUAGE_SWITCHER_PARTIAL.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))
|
||||
}
|
||||
|
||||
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
|
||||
.blog_languages
|
||||
.iter()
|
||||
@@ -320,7 +338,11 @@ fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_langua
|
||||
.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
|
||||
.blog_languages
|
||||
.iter()
|
||||
@@ -334,7 +356,10 @@ fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_languag
|
||||
.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
|
||||
.blog_languages
|
||||
.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 {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata)))
|
||||
let base_url = metadata
|
||||
.public_url
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/');
|
||||
format!(
|
||||
"{base_url}{}",
|
||||
build_canonical_post_path(post, language, main_language(metadata))
|
||||
)
|
||||
}
|
||||
|
||||
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let base_url = metadata
|
||||
.public_url
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/');
|
||||
let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
|
||||
"/".to_string()
|
||||
} else {
|
||||
@@ -377,7 +413,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
||||
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 let Some(last) = blocks.last_mut() {
|
||||
last.show_separator = true;
|
||||
@@ -385,7 +426,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
||||
current_key = Some(key);
|
||||
blocks.push(DayBlockContext {
|
||||
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(),
|
||||
show_separator: false,
|
||||
});
|
||||
@@ -456,19 +502,39 @@ mod tests {
|
||||
#[test]
|
||||
fn canonical_post_paths_follow_language_prefix_rule() {
|
||||
let post = make_post();
|
||||
assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello");
|
||||
assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello");
|
||||
assert_eq!(
|
||||
build_canonical_post_path(&post, "en", "en"),
|
||||
"/2024/03/09/hello"
|
||||
);
|
||||
assert_eq!(
|
||||
build_canonical_post_path(&post, "de", "en"),
|
||||
"/de/2024/03/09/hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
|
||||
let post = make_post();
|
||||
let metadata = make_metadata();
|
||||
let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap();
|
||||
let rendered = render_starter_single_post_page(
|
||||
&post,
|
||||
"Body with [link](/posts/hello)",
|
||||
&metadata,
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
|
||||
assert!(rendered.html.contains("https://example.com/2024/03/09/hello"));
|
||||
assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello"));
|
||||
assert!(
|
||||
rendered
|
||||
.html
|
||||
.contains("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\""));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,28 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::db::queries;
|
||||
use crate::engine::menu::{self, MenuItemKind};
|
||||
use crate::model::{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::model::{
|
||||
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::slugify;
|
||||
|
||||
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||
const STARTER_NOT_FOUND_TEMPLATE: &str = 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");
|
||||
const STARTER_SINGLE_POST_TEMPLATE: &str =
|
||||
include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||
const STARTER_POST_LIST_TEMPLATE: &str =
|
||||
include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||
const STARTER_NOT_FOUND_TEMPLATE: &str =
|
||||
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)]
|
||||
pub struct SitePage {
|
||||
@@ -100,9 +111,16 @@ pub fn build_site_render_artifacts(
|
||||
|
||||
let mut artifacts = SiteRenderArtifacts::default();
|
||||
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 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 menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
||||
let rendered_list_pages = routes
|
||||
@@ -138,7 +156,8 @@ pub fn build_site_render_artifacts(
|
||||
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 {
|
||||
let html = render_post_route(
|
||||
conn,
|
||||
@@ -183,9 +202,14 @@ pub fn build_preview_response(
|
||||
published_posts: &[(Post, String)],
|
||||
requested_path: &str,
|
||||
) -> 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);
|
||||
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 {
|
||||
status_code: 200,
|
||||
html: page.html.clone(),
|
||||
@@ -207,7 +231,8 @@ fn load_template_bundle(
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> 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 post_templates = Vec::new();
|
||||
let mut list_template_sources = HashMap::new();
|
||||
@@ -237,7 +262,10 @@ fn load_template_bundle(
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -258,7 +286,10 @@ fn load_template_bundle(
|
||||
.entry("list".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 {
|
||||
id: "starter-post-template".to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
@@ -273,7 +304,8 @@ fn load_template_bundle(
|
||||
created_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 {
|
||||
@@ -318,7 +350,11 @@ fn load_language_posts(
|
||||
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 (_, translated_body) = read_translation_file(&raw)?;
|
||||
let mut translated_post = post.clone();
|
||||
@@ -336,7 +372,10 @@ fn load_language_posts(
|
||||
}
|
||||
|
||||
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))
|
||||
});
|
||||
Ok(posts)
|
||||
@@ -367,14 +406,29 @@ fn build_language_routes(
|
||||
|
||||
for record in posts {
|
||||
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 {
|
||||
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() {
|
||||
year_posts.entry(timestamp.year()).or_default().push(record.clone());
|
||||
month_posts.entry((timestamp.year(), timestamp.month())).or_default().push(record.clone());
|
||||
if let Some(timestamp) = Utc
|
||||
.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at))
|
||||
.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(
|
||||
&records,
|
||||
per_page,
|
||||
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
|
||||
format!(
|
||||
"{}/category/{slug}",
|
||||
language_root_prefix(language, metadata)
|
||||
),
|
||||
category.clone(),
|
||||
Some(json!({"kind": "category", "name": category})),
|
||||
category_settings
|
||||
@@ -424,7 +481,10 @@ fn build_language_routes(
|
||||
routes.extend(paginated_route_specs(
|
||||
&records,
|
||||
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),
|
||||
Some(json!({"kind": "month", "year": year, "month": month})),
|
||||
None,
|
||||
@@ -449,7 +509,11 @@ fn paginated_route_specs(
|
||||
let current_page = page_index + 1;
|
||||
let start = page_index * per_page;
|
||||
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_path = if current_page == 1 {
|
||||
if relative_base.is_empty() {
|
||||
@@ -479,6 +543,10 @@ fn paginated_route_specs(
|
||||
pages
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "render inputs are existing domain data with distinct lifetimes"
|
||||
)]
|
||||
fn render_list_route(
|
||||
route: &RouteSpec,
|
||||
metadata: &ProjectMetadata,
|
||||
@@ -536,7 +604,11 @@ fn render_list_route(
|
||||
"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)]
|
||||
@@ -558,9 +630,12 @@ fn render_post_route(
|
||||
let render_categories = category_settings
|
||||
.iter()
|
||||
.map(|(name, settings)| {
|
||||
(name.clone(), RenderCategorySettings {
|
||||
(
|
||||
name.clone(),
|
||||
RenderCategorySettings {
|
||||
post_template_slug: settings.post_template_slug.clone(),
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
@@ -583,12 +658,26 @@ fn render_post_route(
|
||||
.filter_map(|link| media_by_id.get(&link.media_id))
|
||||
.map(media_context)
|
||||
.collect::<Vec<_>>();
|
||||
let outgoing_links = queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
|
||||
let incoming_links = queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
|
||||
let post_by_id = all_posts.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 outgoing_links =
|
||||
queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
|
||||
let incoming_links =
|
||||
queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
|
||||
let post_by_id = all_posts
|
||||
.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 context = json!({
|
||||
@@ -626,7 +715,11 @@ fn render_post_route(
|
||||
"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(
|
||||
@@ -670,7 +763,11 @@ fn render_not_found_route(
|
||||
"canonical_media_path_by_source_path": HashMap::<String, String>::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(
|
||||
@@ -728,17 +825,17 @@ fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
|
||||
}
|
||||
|
||||
fn prefix_or_root(prefix: &str) -> &str {
|
||||
if prefix.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
if prefix.is_empty() { "/" } else { prefix }
|
||||
}
|
||||
|
||||
fn route_href(route: &RouteSpec, page: usize) -> String {
|
||||
let base = route.url_path.trim_end_matches('/');
|
||||
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 == "/" {
|
||||
format!("/page/{page}")
|
||||
} else {
|
||||
@@ -760,7 +857,12 @@ fn build_day_blocks(
|
||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
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 {
|
||||
blocks.push(json!({
|
||||
"show_date_marker": true,
|
||||
@@ -771,7 +873,12 @@ fn build_day_blocks(
|
||||
current_posts = Vec::new();
|
||||
}
|
||||
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);
|
||||
current_posts.push(json!({
|
||||
"id": record.post.id,
|
||||
@@ -797,7 +904,8 @@ fn filter_posts_for_lists(
|
||||
posts: &[RenderPostRecord],
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
) -> Vec<RenderPostRecord> {
|
||||
posts.iter()
|
||||
posts
|
||||
.iter()
|
||||
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
|
||||
.cloned()
|
||||
.collect()
|
||||
@@ -850,11 +958,14 @@ fn canonical_post_path_by_slug(
|
||||
language: &str,
|
||||
main_language: &str,
|
||||
) -> HashMap<String, String> {
|
||||
posts.iter()
|
||||
.map(|record| (
|
||||
posts
|
||||
.iter()
|
||||
.map(|record| {
|
||||
(
|
||||
record.post.slug.clone(),
|
||||
build_canonical_post_path(&record.post, language, main_language),
|
||||
))
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -874,7 +985,9 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
|
||||
let mut remainder = markdown;
|
||||
while let Some(index) = remainder.find("bds-media://") {
|
||||
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());
|
||||
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
|
||||
.into_iter()
|
||||
.map(|(name, count)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"slug": slugify(&name),
|
||||
"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());
|
||||
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<_>>();
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(categories, tag_items, tag_colors)
|
||||
}
|
||||
|
||||
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
|
||||
names.iter()
|
||||
names
|
||||
.iter()
|
||||
.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!({
|
||||
"name": name,
|
||||
"slug": slugify(name),
|
||||
@@ -959,11 +1093,28 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn taxonomy_items_for_tags(names: &[String], posts: &[RenderPostRecord], tags: &[Tag]) -> Vec<Value> {
|
||||
names.iter()
|
||||
fn taxonomy_items_for_tags(
|
||||
names: &[String],
|
||||
posts: &[RenderPostRecord],
|
||||
tags: &[Tag],
|
||||
) -> Vec<Value> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let count = posts.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());
|
||||
let count = posts
|
||||
.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!({
|
||||
"name": name,
|
||||
"slug": slugify(name),
|
||||
@@ -1076,7 +1227,11 @@ fn build_alternate_post_links(post: &Post, metadata: &ProjectMetadata) -> Vec<Va
|
||||
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);
|
||||
render_languages(metadata)
|
||||
.into_iter()
|
||||
@@ -1090,7 +1245,11 @@ fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_la
|
||||
.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);
|
||||
render_languages(metadata)
|
||||
.into_iter()
|
||||
@@ -1126,7 +1285,11 @@ fn build_alternate_list_links(metadata: &ProjectMetadata, current_url_path: &str
|
||||
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);
|
||||
if target_language.eq_ignore_ascii_case(main_language) {
|
||||
stripped
|
||||
@@ -1140,11 +1303,12 @@ fn relocalize_url_path(current_url_path: &str, target_language: &str, main_langu
|
||||
fn strip_language_prefix(path: &str, main_language: &str) -> String {
|
||||
let normalized = normalize_request_path(path);
|
||||
let trimmed = normalized.trim_start_matches('/');
|
||||
if let Some((first, remainder)) = trimmed.split_once('/') {
|
||||
if first.len() == 2 && !first.eq_ignore_ascii_case(main_language) {
|
||||
if let Some((first, remainder)) = trimmed.split_once('/')
|
||||
&& first.len() == 2
|
||||
&& !first.eq_ignore_ascii_case(main_language)
|
||||
{
|
||||
return format!("/{}", remainder.trim_start_matches('/'));
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -1161,17 +1325,21 @@ fn relative_to_url_path(relative_path: &str) -> String {
|
||||
if relative_path == "index.html" {
|
||||
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('/'))
|
||||
}
|
||||
|
||||
fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
if let Some((candidate, _)) = trimmed.split_once('/') {
|
||||
if render_languages(metadata).iter().any(|language| language == candidate) {
|
||||
if let Some((candidate, _)) = trimmed.split_once('/')
|
||||
&& render_languages(metadata)
|
||||
.iter()
|
||||
.any(|language| language == candidate)
|
||||
{
|
||||
return candidate.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 mut languages = vec![main.clone()];
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1213,18 +1384,31 @@ fn normalize_partial_slug(slug: &str) -> String {
|
||||
|
||||
fn starter_partials() -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()),
|
||||
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()),
|
||||
("partials/language-switcher".to_string(), STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string()),
|
||||
(
|
||||
"partials/head".to_string(),
|
||||
STARTER_HEAD_PARTIAL.to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu".to_string(),
|
||||
STARTER_MENU_PARTIAL.to_string(),
|
||||
),
|
||||
(
|
||||
"partials/language-switcher".to_string(),
|
||||
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu-items".to_string(),
|
||||
"{% for item in items %}<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> {
|
||||
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) {
|
||||
|
||||
@@ -21,13 +21,17 @@ pub enum TemplateLookupError {
|
||||
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() {
|
||||
return lookup
|
||||
.templates
|
||||
.iter()
|
||||
.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 {
|
||||
@@ -36,8 +40,7 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
|
||||
.iter()
|
||||
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
|
||||
.and_then(|tag| tag.post_template_slug.as_deref())
|
||||
{
|
||||
if let Some(template) = lookup
|
||||
&& let Some(template) = lookup
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| is_enabled_post_template(template, template_slug))
|
||||
@@ -45,15 +48,13 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
|
||||
return Ok(template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for category_name in &lookup.post.categories {
|
||||
if let Some(template_slug) = lookup
|
||||
.category_settings
|
||||
.get(category_name)
|
||||
.and_then(|settings| settings.post_template_slug.as_deref())
|
||||
{
|
||||
if let Some(template) = lookup
|
||||
&& let Some(template) = lookup
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| is_enabled_post_template(template, template_slug))
|
||||
@@ -61,7 +62,6 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
|
||||
return Ok(template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lookup
|
||||
.templates
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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).
|
||||
/// Returns `None` if the content does not start with `---`.
|
||||
@@ -118,29 +118,29 @@ impl PostFrontmatter {
|
||||
}
|
||||
|
||||
// Conditional fields (only when truthy)
|
||||
if let Some(ref excerpt) = self.excerpt {
|
||||
if !excerpt.is_empty() {
|
||||
if let Some(ref excerpt) = self.excerpt
|
||||
&& !excerpt.is_empty()
|
||||
{
|
||||
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = self.author {
|
||||
if !author.is_empty() {
|
||||
if let Some(ref author) = self.author
|
||||
&& !author.is_empty()
|
||||
{
|
||||
lines.push(format!("author: {}", yaml_string_value(author)));
|
||||
}
|
||||
}
|
||||
if let Some(ref language) = self.language {
|
||||
if !language.is_empty() {
|
||||
if let Some(ref language) = self.language
|
||||
&& !language.is_empty()
|
||||
{
|
||||
lines.push(format!("language: {language}"));
|
||||
}
|
||||
}
|
||||
if self.do_not_translate {
|
||||
lines.push("doNotTranslate: true".to_string());
|
||||
}
|
||||
if let Some(ref template_slug) = self.template_slug {
|
||||
if !template_slug.is_empty() {
|
||||
if let Some(ref template_slug) = self.template_slug
|
||||
&& !template_slug.is_empty()
|
||||
{
|
||||
lines.push(format!("templateSlug: {template_slug}"));
|
||||
}
|
||||
}
|
||||
if let Some(published_at) = self.published_at {
|
||||
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
|
||||
}
|
||||
@@ -157,7 +157,7 @@ impl PostFrontmatter {
|
||||
.ok_or("frontmatter is not a YAML mapping")?;
|
||||
|
||||
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 {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
@@ -167,7 +167,7 @@ impl PostFrontmatter {
|
||||
};
|
||||
|
||||
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())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
@@ -198,8 +198,7 @@ impl PostFrontmatter {
|
||||
do_not_translate: get_str("doNotTranslate")
|
||||
.map(|s| s == "true")
|
||||
.unwrap_or(false),
|
||||
published_at: get_str("publishedAt")
|
||||
.and_then(|s| iso_to_unix_ms(&s).ok()),
|
||||
published_at: get_str("publishedAt").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.
|
||||
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = PostFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -223,31 +221,61 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
|
||||
/// Parsed translation frontmatter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranslationFrontmatter {
|
||||
pub id: Option<String>,
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
pub title: 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 {
|
||||
pub fn from_translation(t: &PostTranslation) -> Self {
|
||||
Self {
|
||||
id: Some(t.id.clone()),
|
||||
translation_for: t.translation_for.clone(),
|
||||
language: t.language.clone(),
|
||||
title: t.title.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 {
|
||||
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!("language: {}", self.language));
|
||||
lines.push(format!("title: {}", yaml_string_value(&self.title)));
|
||||
if let Some(ref excerpt) = self.excerpt {
|
||||
if !excerpt.is_empty() {
|
||||
if let Some(ref excerpt) = self.excerpt
|
||||
&& !excerpt.is_empty()
|
||||
{
|
||||
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")
|
||||
}
|
||||
@@ -260,16 +288,21 @@ impl TranslationFrontmatter {
|
||||
.ok_or("frontmatter is not a YAML mapping")?;
|
||||
|
||||
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())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: get_str("id"),
|
||||
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
|
||||
language: get_str("language").ok_or("missing 'language'")?,
|
||||
title: get_str("title").ok_or("missing 'title'")?,
|
||||
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
||||
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.
|
||||
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = TranslationFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -317,8 +349,14 @@ impl TemplateFrontmatter {
|
||||
lines.push(format!("kind: \"{}\"", self.kind));
|
||||
lines.push(format!("enabled: {}", self.enabled));
|
||||
lines.push(format!("version: {}", self.version));
|
||||
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
|
||||
lines.push(format!(
|
||||
"createdAt: \"{}\"",
|
||||
unix_ms_to_iso(self.created_at)
|
||||
));
|
||||
lines.push(format!(
|
||||
"updatedAt: \"{}\"",
|
||||
unix_ms_to_iso(self.updated_at)
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -328,7 +366,7 @@ impl TemplateFrontmatter {
|
||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
map.get(serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
@@ -359,8 +397,7 @@ impl TemplateFrontmatter {
|
||||
|
||||
/// Read a template file (frontmatter + body).
|
||||
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = TemplateFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -401,8 +438,14 @@ impl ScriptFrontmatter {
|
||||
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
|
||||
lines.push(format!("enabled: {}", self.enabled));
|
||||
lines.push(format!("version: {}", self.version));
|
||||
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
|
||||
lines.push(format!(
|
||||
"createdAt: \"{}\"",
|
||||
unix_ms_to_iso(self.created_at)
|
||||
));
|
||||
lines.push(format!(
|
||||
"updatedAt: \"{}\"",
|
||||
unix_ms_to_iso(self.updated_at)
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -412,7 +455,7 @@ impl ScriptFrontmatter {
|
||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
map.get(serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
@@ -450,8 +493,7 @@ pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), St
|
||||
return Ok((fm, body.to_string()));
|
||||
}
|
||||
// Fall back to standard --- format (Lua scripts)
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = ScriptFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -513,6 +555,7 @@ fn yaml_string_value(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::PostStatus;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -551,7 +594,10 @@ mod tests {
|
||||
assert_eq!(fm.title, "Esmeralda");
|
||||
assert_eq!(fm.slug, "esmeralda");
|
||||
assert_eq!(fm.status, "published");
|
||||
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]);
|
||||
assert_eq!(
|
||||
fm.tags,
|
||||
vec!["fotografie", "makro", "natur", "spinne", "tiere"]
|
||||
);
|
||||
assert_eq!(fm.categories, vec!["picture"]);
|
||||
assert_eq!(fm.language.as_deref(), Some("es"));
|
||||
assert_eq!(fm.published_at, Some(1131883200000));
|
||||
@@ -655,7 +701,36 @@ mod tests {
|
||||
|
||||
let yaml = fm.to_yaml();
|
||||
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]
|
||||
@@ -723,7 +798,10 @@ mod tests {
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_template_file(&content).unwrap();
|
||||
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
|
||||
assert_eq!(fm.project_id.as_deref(), Some("1979237c-034d-41f6-99a0-f35eb57b3f6c"));
|
||||
assert_eq!(
|
||||
fm.project_id.as_deref(),
|
||||
Some("1979237c-034d-41f6-99a0-f35eb57b3f6c")
|
||||
);
|
||||
assert_eq!(fm.slug, "testvorlage");
|
||||
assert_eq!(fm.title, "Testvorlage");
|
||||
assert_eq!(fm.kind, "post");
|
||||
@@ -738,7 +816,10 @@ mod tests {
|
||||
let expected = fs::read_to_string(&path).unwrap();
|
||||
let (fm, body) = read_template_file(&expected).unwrap();
|
||||
let actual = write_template_file(&fm, &body);
|
||||
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"golden output mismatch for testvorlage.liquid"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Script frontmatter tests ---
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
mod slug;
|
||||
mod checksum;
|
||||
pub mod timestamp;
|
||||
pub mod atomic_write;
|
||||
pub mod paths;
|
||||
mod checksum;
|
||||
pub mod frontmatter;
|
||||
pub mod paths;
|
||||
pub mod sidecar;
|
||||
mod slug;
|
||||
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 checksum::{content_hash, file_hash};
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
/// 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);
|
||||
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
|
||||
}
|
||||
@@ -101,7 +105,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -53,11 +55,14 @@ impl MediaSidecar {
|
||||
}
|
||||
|
||||
/// 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();
|
||||
lines.push("---".into());
|
||||
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!("size: {}", self.size));
|
||||
if let Some(w) = self.width {
|
||||
@@ -66,31 +71,31 @@ impl MediaSidecar {
|
||||
if let Some(h) = self.height {
|
||||
lines.push(format!("height: {h}"));
|
||||
}
|
||||
if let Some(ref title) = self.title {
|
||||
if !title.is_empty() {
|
||||
if let Some(ref title) = self.title
|
||||
&& !title.is_empty()
|
||||
{
|
||||
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
||||
}
|
||||
}
|
||||
if let Some(ref alt) = self.alt {
|
||||
if !alt.is_empty() {
|
||||
if let Some(ref alt) = self.alt
|
||||
&& !alt.is_empty()
|
||||
{
|
||||
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
||||
}
|
||||
}
|
||||
if let Some(ref caption) = self.caption {
|
||||
if !caption.is_empty() {
|
||||
if let Some(ref caption) = self.caption
|
||||
&& !caption.is_empty()
|
||||
{
|
||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = self.author {
|
||||
if !author.is_empty() {
|
||||
if let Some(ref author) = self.author
|
||||
&& !author.is_empty()
|
||||
{
|
||||
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
|
||||
}
|
||||
}
|
||||
if let Some(ref language) = self.language {
|
||||
if !language.is_empty() {
|
||||
if let Some(ref language) = self.language
|
||||
&& !language.is_empty()
|
||||
{
|
||||
lines.push(format!("language: {language}"));
|
||||
}
|
||||
}
|
||||
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
|
||||
|
||||
@@ -98,13 +103,21 @@ impl MediaSidecar {
|
||||
if self.tags.is_empty() {
|
||||
lines.push("tags: []".into());
|
||||
} else {
|
||||
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect();
|
||||
let quoted: Vec<String> = self
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| format!("\"{}\"", escape_double_quotes(t)))
|
||||
.collect();
|
||||
lines.push(format!("tags: [{}]", quoted.join(", ")));
|
||||
}
|
||||
|
||||
// linkedPostIds: only if non-empty
|
||||
if !self.linked_post_ids.is_empty() {
|
||||
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect();
|
||||
let quoted: Vec<String> = self
|
||||
.linked_post_ids
|
||||
.iter()
|
||||
.map(|id| format!("\"{id}\""))
|
||||
.collect();
|
||||
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 {
|
||||
/// Serialize to the hand-built format.
|
||||
pub fn to_string(&self) -> String {
|
||||
fn serialize(&self) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("---".into());
|
||||
lines.push(format!("translationFor: {}", self.translation_for));
|
||||
lines.push(format!("language: {}", self.language));
|
||||
if let Some(ref title) = self.title {
|
||||
if !title.is_empty() {
|
||||
if let Some(ref title) = self.title
|
||||
&& !title.is_empty()
|
||||
{
|
||||
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
||||
}
|
||||
}
|
||||
if let Some(ref alt) = self.alt {
|
||||
if !alt.is_empty() {
|
||||
if let Some(ref alt) = self.alt
|
||||
&& !alt.is_empty()
|
||||
{
|
||||
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
||||
}
|
||||
}
|
||||
if let Some(ref caption) = self.caption {
|
||||
if !caption.is_empty() {
|
||||
if let Some(ref caption) = self.caption
|
||||
&& !caption.is_empty()
|
||||
{
|
||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||
}
|
||||
}
|
||||
lines.push("---".into());
|
||||
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.
|
||||
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
|
||||
// Strip --- delimiters
|
||||
@@ -301,7 +326,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 sc = read_sidecar(&content).unwrap();
|
||||
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.caption.as_ref().unwrap().contains("Handwerkskunst"));
|
||||
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]
|
||||
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 sc = read_sidecar(&expected).unwrap();
|
||||
let actual = sc.to_string();
|
||||
// Compare trimmed (fixture may or may not have trailing newline)
|
||||
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar");
|
||||
assert_eq!(
|
||||
actual.trim(),
|
||||
expected.trim(),
|
||||
"golden output mismatch for media sidecar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -378,10 +412,7 @@ mod tests {
|
||||
#[test]
|
||||
fn inline_json_array_parsing() {
|
||||
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
|
||||
assert_eq!(
|
||||
parse_inline_json_array("[\"a\", \"b\"]"),
|
||||
vec!["a", "b"]
|
||||
);
|
||||
assert_eq!(parse_inline_json_array("[\"a\", \"b\"]"), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use deunicode::deunicode;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Pre-process German characters to match TypeScript `transliteration` npm output.
|
||||
/// 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:
|
||||
/// 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.
|
||||
pub fn ensure_unique<F>(base: &str, exists: F) -> String
|
||||
@@ -60,17 +59,13 @@ where
|
||||
if !exists(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
for n in 2..=999 {
|
||||
for n in 2_u64.. {
|
||||
let candidate = format!("{base}-{n}");
|
||||
if !exists(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
format!("{base}-{ts}")
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -121,7 +116,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -178,20 +175,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_unique_all_999_taken() {
|
||||
fn ensure_unique_continues_after_999() {
|
||||
let slug = ensure_unique("x", |s| {
|
||||
if s == "x" { return true; }
|
||||
if let Some(suffix) = s.strip_prefix("x-") {
|
||||
if let Ok(n) = suffix.parse::<u32>() {
|
||||
return n <= 999;
|
||||
if s == "x" {
|
||||
return true;
|
||||
}
|
||||
if let Some(suffix) = s.strip_prefix("x-")
|
||||
&& let Ok(n) = suffix.parse::<u32>()
|
||||
{
|
||||
return n <= 999;
|
||||
}
|
||||
false
|
||||
});
|
||||
// Should fall back to timestamp-based slug
|
||||
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);
|
||||
assert_eq!(slug, "x-1000");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,34 @@ pub enum ThumbnailFit {
|
||||
/// Standard thumbnail sizes matching spec: small/medium/large are
|
||||
/// width-constrained aspect-preserving; AI is letterboxed on black.
|
||||
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
||||
ThumbnailSize { name: "small", width: 150, 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 },
|
||||
ThumbnailSize {
|
||||
name: "small",
|
||||
width: 150,
|
||||
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.
|
||||
@@ -57,9 +81,7 @@ pub fn generate_thumbnail(
|
||||
img.resize(size.width, size.height, FilterType::Lanczos3)
|
||||
}
|
||||
}
|
||||
ThumbnailFit::Cover => {
|
||||
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
|
||||
}
|
||||
ThumbnailFit::Cover => img.resize_to_fill(size.width, size.height, FilterType::Lanczos3),
|
||||
ThumbnailFit::Contain => {
|
||||
// Resize to fit, then place on black background
|
||||
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
|
||||
@@ -118,7 +140,11 @@ pub fn generate_all_thumbnails(
|
||||
let dest = thumbnails_dir
|
||||
.join(prefix)
|
||||
.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)?;
|
||||
paths.push(dest.to_string_lossy().to_string());
|
||||
}
|
||||
@@ -136,11 +162,11 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
|
||||
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
|
||||
|
||||
// Try to read EXIF orientation from JPEG files
|
||||
if let Ok(data) = fs::read(path) {
|
||||
if let Some(orientation) = read_exif_orientation(&data) {
|
||||
if let Ok(data) = fs::read(path)
|
||||
&& let Some(orientation) = read_exif_orientation(&data)
|
||||
{
|
||||
img = apply_orientation(img, orientation);
|
||||
}
|
||||
}
|
||||
|
||||
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 read_u16 = |offset: usize| -> Option<u16> {
|
||||
if offset + 2 > data.len() { return None; }
|
||||
if offset + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
if is_le {
|
||||
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
|
||||
} else {
|
||||
@@ -188,11 +216,23 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
||||
}
|
||||
};
|
||||
let read_u32 = |offset: usize| -> Option<u32> {
|
||||
if offset + 4 > data.len() { return None; }
|
||||
if offset + 4 > data.len() {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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)?;
|
||||
if tag == 0x0112 {
|
||||
// Orientation tag
|
||||
|
||||
@@ -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`).
|
||||
pub fn unix_ms_to_iso(ms: i64) -> String {
|
||||
@@ -35,6 +35,24 @@ pub fn now_unix_ms() -> i64 {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -83,4 +101,16 @@ mod tests {
|
||||
fn invalid_iso_returns_error() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,15 @@ fn read_project() {
|
||||
.query_row(
|
||||
"SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1",
|
||||
[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();
|
||||
|
||||
@@ -52,7 +60,10 @@ fn read_published_post_has_null_content() {
|
||||
assert_eq!(title, "Esmeralda");
|
||||
assert_eq!(slug, "esmeralda");
|
||||
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]
|
||||
@@ -96,8 +107,14 @@ fn published_posts_have_file_paths() {
|
||||
|
||||
assert_eq!(rows.len(), 3);
|
||||
for (slug, path) in &rows {
|
||||
assert!(!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");
|
||||
assert!(
|
||||
!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();
|
||||
|
||||
// Sanity: timestamps should be in reasonable Unix range (year 2000+)
|
||||
assert!(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");
|
||||
assert!(
|
||||
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]
|
||||
@@ -147,7 +170,10 @@ fn post_unique_constraint_on_project_slug() {
|
||||
.map(|r| r.unwrap())
|
||||
.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 ───────────────────────────────────────────────
|
||||
@@ -156,7 +182,9 @@ fn post_unique_constraint_on_project_slug() {
|
||||
fn read_post_translations() {
|
||||
let conn = fixture_db();
|
||||
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();
|
||||
assert_eq!(count, 4);
|
||||
}
|
||||
@@ -177,7 +205,10 @@ fn translation_references_valid_post() {
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(orphans.is_empty(), "orphan translations referencing missing posts: {orphans:?}");
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"orphan translations referencing missing posts: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,7 +224,10 @@ fn published_translations_have_null_content() {
|
||||
.collect();
|
||||
|
||||
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())
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, vec!["fotografie", "mac-os-x", "natur", "programmierung", "sysadmin"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"fotografie",
|
||||
"mac-os-x",
|
||||
"natur",
|
||||
"programmierung",
|
||||
"sysadmin"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -308,7 +351,10 @@ fn tag_unique_constraint_on_project_name() {
|
||||
.map(|r| r.unwrap())
|
||||
.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 ───────────────────────────────────────────────────────
|
||||
@@ -345,7 +391,10 @@ fn read_template() {
|
||||
assert_eq!(kind, "post");
|
||||
assert!(enabled);
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -420,7 +469,10 @@ fn settings_are_key_value_pairs() {
|
||||
assert!(!value.is_empty());
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -479,7 +531,10 @@ fn all_posts_belong_to_existing_project() {
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(orphans.is_empty(), "posts referencing missing projects: {orphans:?}");
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"posts referencing missing projects: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -498,7 +553,10 @@ fn all_tags_belong_to_existing_project() {
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(orphans.is_empty(), "tags referencing missing projects: {orphans:?}");
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"tags referencing missing projects: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,7 +575,10 @@ fn all_media_belong_to_existing_project() {
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(orphans.is_empty(), "media referencing missing projects: {orphans:?}");
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"media referencing missing projects: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -537,7 +598,10 @@ fn post_links_reference_valid_posts() {
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(orphans.is_empty(), "post_links with invalid references: {orphans:?}");
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"post_links with invalid references: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -557,5 +621,8 @@ fn post_media_references_valid_entities() {
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(orphans.is_empty(), "post_media with invalid references: {orphans:?}");
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"post_media with invalid references: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,22 @@
|
||||
//! - Step 20: Golden-file comparisons against fixture files
|
||||
//! - 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::queries::project::insert_project;
|
||||
use bds_core::db::queries::script as qs;
|
||||
use bds_core::db::queries::template as qtpl;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::media;
|
||||
use bds_core::engine::meta;
|
||||
use bds_core::engine::metadata_diff;
|
||||
use bds_core::engine::post;
|
||||
use bds_core::engine::tag;
|
||||
use bds_core::model::{
|
||||
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
|
||||
TemplateStatus,
|
||||
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus,
|
||||
};
|
||||
use bds_core::util::frontmatter::{
|
||||
read_post_file, read_template_file, read_translation_file,
|
||||
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
|
||||
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_template_file,
|
||||
read_translation_file, write_script_file, write_template_file,
|
||||
};
|
||||
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
|
||||
use image::DynamicImage;
|
||||
@@ -325,10 +324,7 @@ fn test_golden_post_esmeralda() {
|
||||
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
|
||||
|
||||
// Compare byte-for-byte with fixture
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"golden output mismatch for esmeralda.md"
|
||||
);
|
||||
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,8 +349,7 @@ fn test_golden_translation_esmeralda_en() {
|
||||
|
||||
#[test]
|
||||
fn test_golden_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 sc = read_sidecar(&expected).unwrap();
|
||||
|
||||
@@ -401,7 +396,14 @@ fn test_golden_meta_files() {
|
||||
assert_eq!(
|
||||
categories,
|
||||
vec![
|
||||
"article", "aside", "kochbuch", "metaowl", "page", "picture", "spielelog", "wiki"
|
||||
"article",
|
||||
"aside",
|
||||
"kochbuch",
|
||||
"metaowl",
|
||||
"page",
|
||||
"picture",
|
||||
"spielelog",
|
||||
"wiki"
|
||||
]
|
||||
);
|
||||
// Verify they are sorted
|
||||
@@ -416,7 +418,7 @@ fn test_golden_meta_files() {
|
||||
// Verify sorted by name
|
||||
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||
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");
|
||||
// Spot-check a known tag
|
||||
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
|
||||
/// file on disk. Run the diff and return all detected field names.
|
||||
fn post_diff_for_field(
|
||||
modify_fn: impl FnOnce(&str) -> String,
|
||||
) -> Vec<String> {
|
||||
fn post_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let created = post::create_post(
|
||||
@@ -519,9 +519,8 @@ fn test_diff_detects_post_slug() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_post_status() {
|
||||
let fields = post_diff_for_field(|content| {
|
||||
content.replace("status: published", "status: draft")
|
||||
});
|
||||
let fields =
|
||||
post_diff_for_field(|content| content.replace("status: published", "status: draft"));
|
||||
assert!(
|
||||
fields.contains(&"status".to_string()),
|
||||
"expected 'status' in diff fields, got: {fields:?}"
|
||||
@@ -530,9 +529,7 @@ fn test_diff_detects_post_status() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_post_tags() {
|
||||
let fields = post_diff_for_field(|content| {
|
||||
content.replace(" - tag1", " - changed-tag")
|
||||
});
|
||||
let fields = post_diff_for_field(|content| content.replace(" - tag1", " - changed-tag"));
|
||||
assert!(
|
||||
fields.contains(&"tags".to_string()),
|
||||
"expected 'tags' in diff fields, got: {fields:?}"
|
||||
@@ -541,9 +538,7 @@ fn test_diff_detects_post_tags() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_post_categories() {
|
||||
let fields = post_diff_for_field(|content| {
|
||||
content.replace(" - cat1", " - changed-cat")
|
||||
});
|
||||
let fields = post_diff_for_field(|content| content.replace(" - cat1", " - changed-cat"));
|
||||
assert!(
|
||||
fields.contains(&"categories".to_string()),
|
||||
"expected 'categories' in diff fields, got: {fields:?}"
|
||||
@@ -578,9 +573,7 @@ fn test_diff_detects_post_author() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_post_language() {
|
||||
let fields = post_diff_for_field(|content| {
|
||||
content.replace("language: en", "language: de")
|
||||
});
|
||||
let fields = post_diff_for_field(|content| content.replace("language: en", "language: de"));
|
||||
assert!(
|
||||
fields.contains(&"language".to_string()),
|
||||
"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.
|
||||
/// Returns the list of diff field names detected.
|
||||
fn media_diff_for_field(
|
||||
modify_fn: impl FnOnce(&str) -> String,
|
||||
) -> Vec<String> {
|
||||
fn media_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let source_path = create_test_image(dir.path());
|
||||
@@ -764,10 +755,7 @@ fn test_diff_detects_media_caption() {
|
||||
#[test]
|
||||
fn test_diff_detects_media_author() {
|
||||
let fields = media_diff_for_field(|content| {
|
||||
content.replace(
|
||||
"author: \"Original Author\"",
|
||||
"author: \"Changed Author\"",
|
||||
)
|
||||
content.replace("author: \"Original Author\"", "author: \"Changed Author\"")
|
||||
});
|
||||
assert!(
|
||||
fields.contains(&"author".to_string()),
|
||||
@@ -778,10 +766,7 @@ fn test_diff_detects_media_author() {
|
||||
#[test]
|
||||
fn test_diff_detects_media_tags() {
|
||||
let fields = media_diff_for_field(|content| {
|
||||
content.replace(
|
||||
"tags: [\"original-tag\"]",
|
||||
"tags: [\"changed-tag\"]",
|
||||
)
|
||||
content.replace("tags: [\"original-tag\"]", "tags: [\"changed-tag\"]")
|
||||
});
|
||||
assert!(
|
||||
fields.contains(&"tags".to_string()),
|
||||
@@ -791,9 +776,7 @@ fn test_diff_detects_media_tags() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_media_language() {
|
||||
let fields = media_diff_for_field(|content| {
|
||||
content.replace("language: en", "language: de")
|
||||
});
|
||||
let fields = media_diff_for_field(|content| content.replace("language: en", "language: de"));
|
||||
assert!(
|
||||
fields.contains(&"language".to_string()),
|
||||
"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
|
||||
/// the file. Returns the list of diff field names.
|
||||
fn template_diff_for_field(
|
||||
modify_fn: impl FnOnce(&str) -> String,
|
||||
) -> Vec<String> {
|
||||
fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let tpl = Template {
|
||||
@@ -870,9 +851,8 @@ fn test_diff_detects_template_title() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_template_kind() {
|
||||
let fields = template_diff_for_field(|content| {
|
||||
content.replace("kind: \"post\"", "kind: \"list\"")
|
||||
});
|
||||
let fields =
|
||||
template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
|
||||
assert!(
|
||||
fields.contains(&"kind".to_string()),
|
||||
"expected 'kind' in template diff fields, got: {fields:?}"
|
||||
@@ -881,9 +861,8 @@ fn test_diff_detects_template_kind() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_template_enabled() {
|
||||
let fields = template_diff_for_field(|content| {
|
||||
content.replace("enabled: true", "enabled: false")
|
||||
});
|
||||
let fields =
|
||||
template_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
|
||||
assert!(
|
||||
fields.contains(&"enabled".to_string()),
|
||||
"expected 'enabled' in template diff fields, got: {fields:?}"
|
||||
@@ -892,9 +871,7 @@ fn test_diff_detects_template_enabled() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_template_version() {
|
||||
let fields = template_diff_for_field(|content| {
|
||||
content.replace("version: 1", "version: 99")
|
||||
});
|
||||
let fields = template_diff_for_field(|content| content.replace("version: 1", "version: 99"));
|
||||
assert!(
|
||||
fields.contains(&"version".to_string()),
|
||||
"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
|
||||
/// file. Returns the list of diff field names.
|
||||
fn script_diff_for_field(
|
||||
modify_fn: impl FnOnce(&str) -> String,
|
||||
) -> Vec<String> {
|
||||
fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let script = Script {
|
||||
@@ -995,9 +970,8 @@ fn test_diff_detects_script_entrypoint() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_script_enabled() {
|
||||
let fields = script_diff_for_field(|content| {
|
||||
content.replace("enabled: true", "enabled: false")
|
||||
});
|
||||
let fields =
|
||||
script_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
|
||||
assert!(
|
||||
fields.contains(&"enabled".to_string()),
|
||||
"expected 'enabled' in script diff fields, got: {fields:?}"
|
||||
@@ -1006,9 +980,7 @@ fn test_diff_detects_script_enabled() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_script_version() {
|
||||
let fields = script_diff_for_field(|content| {
|
||||
content.replace("version: 1", "version: 42")
|
||||
});
|
||||
let fields = script_diff_for_field(|content| content.replace("version: 1", "version: 42"));
|
||||
assert!(
|
||||
fields.contains(&"version".to_string()),
|
||||
"expected 'version' in script diff fields, got: {fields:?}"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bds_core::db::Database;
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::queries::template::insert_template;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::generation::{
|
||||
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::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;
|
||||
|
||||
fn make_project() -> Project {
|
||||
@@ -95,6 +98,24 @@ fn setup() -> (Database, TempDir) {
|
||||
(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]
|
||||
fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
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(&"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(&"atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(report.written_paths.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!(
|
||||
report
|
||||
.written_paths
|
||||
.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("rss.xml").exists());
|
||||
@@ -152,13 +190,17 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
||||
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/sitemap.xml".to_string()));
|
||||
|
||||
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\" />"));
|
||||
|
||||
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 [
|
||||
"images/close.png",
|
||||
@@ -232,17 +275,29 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
||||
"images/prev.png",
|
||||
] {
|
||||
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!(report.written_paths.contains(&"category/featured/index.html".to_string()));
|
||||
assert!(
|
||||
!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();
|
||||
assert!(!index_html.contains("Hidden Post"));
|
||||
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]"));
|
||||
|
||||
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(),
|
||||
}];
|
||||
|
||||
let first = 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();
|
||||
let first =
|
||||
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!(second.skipped_paths.contains(&"index.html".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(&"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]
|
||||
@@ -313,7 +374,9 @@ fn apply_validation_repairs_core_section_outputs() {
|
||||
|
||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
let sections = sections_from_validation_report(&report);
|
||||
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions).unwrap();
|
||||
let apply_report =
|
||||
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions)
|
||||
.unwrap();
|
||||
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
|
||||
@@ -339,12 +402,22 @@ fn apply_validation_removes_extra_section_outputs() {
|
||||
std::fs::write(extra_dir.join("index.html"), "ghost").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 apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions).unwrap();
|
||||
let apply_report =
|
||||
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions)
|
||||
.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());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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::project::insert_project;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::model::{Post, PostStatus, Project};
|
||||
use bds_core::render::{
|
||||
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
||||
write_generated_file,
|
||||
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
|
||||
};
|
||||
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();
|
||||
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]
|
||||
@@ -85,7 +88,10 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
|
||||
|
||||
assert_eq!(first, 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]
|
||||
@@ -98,7 +104,10 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
|
||||
|
||||
assert_eq!(first, 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]
|
||||
|
||||
@@ -92,7 +92,7 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
|
||||
}
|
||||
|
||||
#[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 partials = HashMap::new();
|
||||
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();
|
||||
assert!(rendered.contains("macro-unsupported"));
|
||||
assert!(rendered.contains("Unsupported macro"));
|
||||
assert!(rendered.contains("custom_block"));
|
||||
assert!(rendered.contains("[[custom_block foo=bar]]"));
|
||||
assert!(!rendered.contains("macro-unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -123,11 +122,13 @@ fn starter_single_post_template_renders_with_partials() {
|
||||
),
|
||||
(
|
||||
"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(),
|
||||
"{% 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(),
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::collections::HashMap;
|
||||
|
||||
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
|
||||
use bds_core::render::{
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||
render_markdown_to_html, resolve_post_template,
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, render_markdown_to_html,
|
||||
resolve_post_template,
|
||||
};
|
||||
|
||||
fn make_post() -> Post {
|
||||
@@ -70,7 +70,11 @@ fn template_lookup_prefers_post_specific_template() {
|
||||
post.tags = vec!["rust".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 mut categories = HashMap::new();
|
||||
categories.insert(
|
||||
@@ -163,7 +167,10 @@ fn template_lookup_errors_when_explicit_template_missing() {
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into()));
|
||||
assert_eq!(
|
||||
err,
|
||||
TemplateLookupError::MissingExplicitTemplate("missing".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,9 +25,18 @@ fn post_status_serde_matches_db_values() {
|
||||
// spec: status: draft | published | archived
|
||||
use bds_core::model::PostStatus;
|
||||
|
||||
assert_eq!(serde_json::to_string(&PostStatus::Draft).unwrap(), "\"draft\"");
|
||||
assert_eq!(serde_json::to_string(&PostStatus::Published).unwrap(), "\"published\"");
|
||||
assert_eq!(serde_json::to_string(&PostStatus::Archived).unwrap(), "\"archived\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PostStatus::Draft).unwrap(),
|
||||
"\"draft\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PostStatus::Published).unwrap(),
|
||||
"\"published\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PostStatus::Archived).unwrap(),
|
||||
"\"archived\""
|
||||
);
|
||||
|
||||
// round-trip
|
||||
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() {
|
||||
use bds_core::model::TemplateKind;
|
||||
|
||||
assert_eq!(serde_json::to_string(&TemplateKind::Post).unwrap(), "\"post\"");
|
||||
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\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TemplateKind::Post).unwrap(),
|
||||
"\"post\""
|
||||
);
|
||||
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();
|
||||
assert_eq!(nf, TemplateKind::NotFound);
|
||||
@@ -59,8 +80,14 @@ fn template_kind_serde_matches_db_values() {
|
||||
fn template_status_serde_matches_db_values() {
|
||||
use bds_core::model::TemplateStatus;
|
||||
|
||||
assert_eq!(serde_json::to_string(&TemplateStatus::Draft).unwrap(), "\"draft\"");
|
||||
assert_eq!(serde_json::to_string(&TemplateStatus::Published).unwrap(), "\"published\"");
|
||||
assert_eq!(
|
||||
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 ──
|
||||
@@ -69,9 +96,18 @@ fn template_status_serde_matches_db_values() {
|
||||
fn script_kind_serde_matches_db_values() {
|
||||
use bds_core::model::ScriptKind;
|
||||
|
||||
assert_eq!(serde_json::to_string(&ScriptKind::Macro).unwrap(), "\"macro\"");
|
||||
assert_eq!(serde_json::to_string(&ScriptKind::Utility).unwrap(), "\"utility\"");
|
||||
assert_eq!(serde_json::to_string(&ScriptKind::Transform).unwrap(), "\"transform\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ScriptKind::Macro).unwrap(),
|
||||
"\"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();
|
||||
assert_eq!(t, ScriptKind::Transform);
|
||||
@@ -83,8 +119,14 @@ fn script_kind_serde_matches_db_values() {
|
||||
fn script_status_serde_matches_db_values() {
|
||||
use bds_core::model::ScriptStatus;
|
||||
|
||||
assert_eq!(serde_json::to_string(&ScriptStatus::Draft).unwrap(), "\"draft\"");
|
||||
assert_eq!(serde_json::to_string(&ScriptStatus::Published).unwrap(), "\"published\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ScriptStatus::Draft).unwrap(),
|
||||
"\"draft\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ScriptStatus::Published).unwrap(),
|
||||
"\"published\""
|
||||
);
|
||||
}
|
||||
|
||||
// ── spec: post.allium — content_location invariant ──
|
||||
@@ -105,7 +147,10 @@ fn content_location_published_posts_null_content_in_fixture() {
|
||||
|
||||
assert!(!rows.is_empty());
|
||||
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());
|
||||
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) \
|
||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
|
||||
VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
||||
@@ -157,11 +206,15 @@ fn post_status_transitions_all_valid() {
|
||||
|
||||
for (from, to) in transitions {
|
||||
// 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
|
||||
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
|
||||
.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();
|
||||
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) \
|
||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"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)",
|
||||
@@ -186,9 +240,16 @@ fn slug_frozen_after_publish_semantics() {
|
||||
|
||||
// published_at is set — slug should be considered frozen
|
||||
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();
|
||||
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
|
||||
conn.execute(
|
||||
@@ -197,9 +258,16 @@ fn slug_frozen_after_publish_semantics() {
|
||||
[],
|
||||
).unwrap();
|
||||
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();
|
||||
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 ──
|
||||
@@ -209,7 +277,11 @@ fn slug_frozen_after_publish_semantics() {
|
||||
fn single_active_project_in_fixture() {
|
||||
let conn = fixture_db();
|
||||
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();
|
||||
assert_eq!(active_count, 1, "spec: exactly one project is active");
|
||||
}
|
||||
@@ -247,7 +319,10 @@ fn unique_translation_per_post_language_in_fixture() {
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.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 ──
|
||||
@@ -260,13 +335,15 @@ fn post_defaults_match_spec() {
|
||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
// Insert with minimal columns to test defaults
|
||||
conn.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \
|
||||
VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (status, do_not_translate, file_path): (String, bool, String) = conn
|
||||
.query_row(
|
||||
@@ -291,12 +368,14 @@ fn script_defaults_match_spec() {
|
||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \
|
||||
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn
|
||||
.query_row(
|
||||
@@ -322,12 +401,14 @@ fn template_defaults_match_spec() {
|
||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \
|
||||
VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (kind, enabled, version, status): (String, bool, i32, String) = conn
|
||||
.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) \
|
||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \
|
||||
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Same name, same project — must fail
|
||||
let result = conn.execute(
|
||||
@@ -364,7 +447,10 @@ fn tag_unique_name_per_project_enforced() {
|
||||
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 ──
|
||||
@@ -447,16 +533,37 @@ fn published_post_file_paths_follow_date_layout() {
|
||||
|
||||
#[test]
|
||||
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!(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(&NotificationEntity::Post).unwrap(),
|
||||
"\"post\""
|
||||
);
|
||||
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!(serde_json::to_string(&NotificationAction::Updated).unwrap(), "\"updated\"");
|
||||
assert_eq!(serde_json::to_string(&NotificationAction::Deleted).unwrap(), "\"deleted\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NotificationAction::Created).unwrap(),
|
||||
"\"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 ──
|
||||
@@ -493,7 +600,11 @@ fn fts5_tables_exist_in_fixture() {
|
||||
[],
|
||||
|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
|
||||
let result = conn.query_row(
|
||||
@@ -501,5 +612,9 @@ fn fts5_tables_exist_in_fixture() {
|
||||
[],
|
||||
|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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,13 @@ impl EditorBuffer {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
anchor_line,
|
||||
anchor_col,
|
||||
@@ -176,7 +182,7 @@ impl EditorBuffer {
|
||||
|
||||
/// Insert text at the current cursor position. Deletes selection first if any.
|
||||
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();
|
||||
}
|
||||
let char_idx = self.cursor_char_idx();
|
||||
@@ -198,7 +204,7 @@ impl EditorBuffer {
|
||||
|
||||
/// Delete one character before the cursor (backspace).
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +235,7 @@ impl EditorBuffer {
|
||||
|
||||
/// Delete one character after the cursor (delete key).
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -497,7 +503,12 @@ impl EditorBuffer {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ pub struct UndoHistory {
|
||||
last_group_boundary: usize,
|
||||
}
|
||||
|
||||
impl Default for UndoHistory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UndoHistory {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -4,5 +4,5 @@ pub mod history;
|
||||
mod widget;
|
||||
|
||||
pub use buffer::{EditorBuffer, Selection};
|
||||
pub use highlight::{highlighter, Highlighter};
|
||||
pub use highlight::{Highlighter, highlighter};
|
||||
pub use widget::{CodeEditor, EditorMessage, mono_metrics};
|
||||
|
||||
@@ -62,12 +62,11 @@ pub fn mono_metrics() -> &'static MonoMetrics {
|
||||
let mut char_width = FONT_SIZE * 0.6; // 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() {
|
||||
char_width = glyph.w;
|
||||
}
|
||||
line_height = run.line_height;
|
||||
break;
|
||||
}
|
||||
|
||||
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 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 {
|
||||
return None;
|
||||
}
|
||||
@@ -156,7 +155,11 @@ fn line_text_for(buf: &EditorBuffer, line_idx: usize) -> String {
|
||||
buf.line(line_idx)
|
||||
.map(|l| {
|
||||
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()
|
||||
}
|
||||
@@ -186,7 +189,11 @@ fn logical_to_visual(buf: &EditorBuffer, line: usize, col: usize, max_chars: usi
|
||||
if idx == line {
|
||||
// Find which sub-line the column falls on
|
||||
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() {
|
||||
return vis + i;
|
||||
}
|
||||
@@ -209,7 +216,11 @@ fn visual_to_logical(
|
||||
if max_chars == 0 {
|
||||
// No wrapping — direct 1:1 mapping
|
||||
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 mut vis_count = 0usize;
|
||||
@@ -357,7 +368,11 @@ where
|
||||
let line_len = text.chars().count();
|
||||
|
||||
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 {
|
||||
vis_skip += 1;
|
||||
} else if visual_rows.len() < visible_lines {
|
||||
@@ -370,27 +385,37 @@ where
|
||||
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
|
||||
|
||||
// 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;
|
||||
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Draw selection highlight for this visual line
|
||||
if let Some(sel) = selection {
|
||||
if !sel.is_empty() {
|
||||
if let Some(sel) = selection
|
||||
&& !sel.is_empty()
|
||||
{
|
||||
let (start, end) = sel.ordered();
|
||||
let line_len = buf
|
||||
.line(line_idx)
|
||||
.map(|l| {
|
||||
let len = l.len_chars();
|
||||
if len > 0 && l.char(len - 1) == '\n' { len - 1 } else { len }
|
||||
if len > 0 && l.char(len - 1) == '\n' {
|
||||
len - 1
|
||||
} else {
|
||||
len
|
||||
}
|
||||
})
|
||||
.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 };
|
||||
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);
|
||||
@@ -400,7 +425,12 @@ where
|
||||
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 },
|
||||
bounds: Rectangle {
|
||||
x: sel_x,
|
||||
y,
|
||||
width: sel_w,
|
||||
height: metrics.line_height,
|
||||
},
|
||||
border: iced::Border::default(),
|
||||
shadow: iced::Shadow::default(),
|
||||
},
|
||||
@@ -409,18 +439,23 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Line number (only on first visual line of each logical line)
|
||||
if is_first {
|
||||
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(
|
||||
text::Text {
|
||||
content: line_num,
|
||||
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
|
||||
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,
|
||||
horizontal_alignment: iced::alignment::Horizontal::Right,
|
||||
vertical_alignment: iced::alignment::Vertical::Top,
|
||||
@@ -441,7 +476,9 @@ where
|
||||
for (style, span_text) in spans {
|
||||
let color = syntect_to_iced(style.foreground);
|
||||
for ch in span_text.chars() {
|
||||
if ch == '\n' { continue; }
|
||||
if ch == '\n' {
|
||||
continue;
|
||||
}
|
||||
chars_with_color.push((ch, color));
|
||||
}
|
||||
}
|
||||
@@ -463,14 +500,19 @@ where
|
||||
while span_end < slice.len() && slice[span_end].1 == color {
|
||||
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;
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content: display_text,
|
||||
bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
|
||||
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,
|
||||
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||
vertical_alignment: iced::alignment::Vertical::Top,
|
||||
@@ -499,7 +541,9 @@ where
|
||||
content: display_text.to_string(),
|
||||
bounds: Size::new(text_area_width, metrics.line_height),
|
||||
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,
|
||||
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||
vertical_alignment: iced::alignment::Vertical::Top,
|
||||
@@ -520,7 +564,10 @@ where
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
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(),
|
||||
shadow: iced::Shadow::default(),
|
||||
@@ -529,12 +576,17 @@ where
|
||||
);
|
||||
}
|
||||
// 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;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
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(),
|
||||
shadow: iced::Shadow::default(),
|
||||
@@ -553,14 +605,23 @@ where
|
||||
let thumb_ratio = viewport_lines as f32 / total_vis as f32;
|
||||
let thumb_height = (track_height * thumb_ratio).max(MIN_THUMB_HEIGHT);
|
||||
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 track_x = bounds.x + bounds.width - SCROLLBAR_WIDTH;
|
||||
|
||||
// Track
|
||||
renderer.fill_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(),
|
||||
shadow: iced::Shadow::default(),
|
||||
},
|
||||
@@ -582,7 +643,11 @@ where
|
||||
},
|
||||
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() {
|
||||
let local_y = pos.y - bounds.y - grab_offset;
|
||||
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;
|
||||
self.buffer.borrow_mut().set_scroll(new_scroll, max_scroll);
|
||||
}
|
||||
@@ -644,13 +713,19 @@ where
|
||||
}
|
||||
|
||||
// Start scrollbar drag
|
||||
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event {
|
||||
if let Some(pos) = cursor.position() {
|
||||
if pos.x >= track_x && pos.x <= bounds.x + bounds.width
|
||||
&& pos.y >= bounds.y && pos.y <= bounds.y + bounds.height
|
||||
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event
|
||||
&& let Some(pos) = cursor.position()
|
||||
&& pos.x >= track_x
|
||||
&& pos.x <= bounds.x + bounds.width
|
||||
&& pos.y >= bounds.y
|
||||
&& pos.y <= bounds.y + bounds.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 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 {
|
||||
@@ -667,8 +742,6 @@ where
|
||||
return Status::Captured;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
@@ -679,8 +752,10 @@ where
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
let mut buf = self.buffer.borrow_mut();
|
||||
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 (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
||||
let raw_col =
|
||||
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
||||
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;
|
||||
|
||||
@@ -717,16 +792,17 @@ where
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
let mut buf = self.buffer.borrow_mut();
|
||||
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 (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
||||
let raw_col =
|
||||
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
||||
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;
|
||||
// Extend selection by simulating shift+movement
|
||||
let clamped_line = line.min(buf.line_count().saturating_sub(1));
|
||||
let clamped_col = if clamped_line < buf.line_count() {
|
||||
col.min(
|
||||
buf
|
||||
.line(clamped_line)
|
||||
buf.line(clamped_line)
|
||||
.map(|l| {
|
||||
let len = l.len_chars();
|
||||
if len > 0 && l.char(len - 1) == '\n' {
|
||||
@@ -748,12 +824,7 @@ where
|
||||
.selection()
|
||||
.map(|s| s.anchor_col)
|
||||
.unwrap_or(buf.cursor().1);
|
||||
buf.set_selection(
|
||||
anchor_line,
|
||||
anchor_col,
|
||||
clamped_line,
|
||||
clamped_col,
|
||||
);
|
||||
buf.set_selection(anchor_line, anchor_col, clamped_line, clamped_col);
|
||||
buf.set_cursor(clamped_line, clamped_col);
|
||||
}
|
||||
return Status::Captured;
|
||||
@@ -762,9 +833,7 @@ where
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
|
||||
let lines = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } => -(y * 3.0) as isize,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => {
|
||||
-(y / metrics.line_height) as isize
|
||||
}
|
||||
mouse::ScrollDelta::Pixels { y, .. } => -(y / metrics.line_height) as isize,
|
||||
};
|
||||
let mut buf = self.buffer.borrow_mut();
|
||||
let vis = (bounds.height / metrics.line_height) as usize;
|
||||
@@ -774,7 +843,10 @@ where
|
||||
return Status::Captured;
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key, modifiers, text, ..
|
||||
key,
|
||||
modifiers,
|
||||
text,
|
||||
..
|
||||
}) if state.is_focused => {
|
||||
let vis = (bounds.height / metrics.line_height) as usize;
|
||||
let is_cmd = modifiers.command();
|
||||
@@ -799,7 +871,11 @@ where
|
||||
keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => {
|
||||
{
|
||||
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);
|
||||
}
|
||||
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)]
|
||||
mod tests {
|
||||
use super::committed_text_input;
|
||||
@@ -1013,13 +1099,3 @@ mod tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::{Space, button, checkbox, column, container, pick_list, row, text, text_input};
|
||||
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||
|
||||
/// Standard form field label color.
|
||||
@@ -19,7 +19,10 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
|
||||
on_change: impl Fn(String) -> Message + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
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),
|
||||
]
|
||||
.spacing(4)
|
||||
@@ -39,7 +42,10 @@ where
|
||||
{
|
||||
let list: Vec<T> = options.to_vec();
|
||||
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),
|
||||
]
|
||||
.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> {
|
||||
let date_str = format_timestamp(timestamp_ms);
|
||||
row![
|
||||
text(label.to_string()).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),
|
||||
text(label.to_string())
|
||||
.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)
|
||||
.into()
|
||||
|
||||
@@ -3,11 +3,11 @@ use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use iced::event;
|
||||
use iced::advanced::layout::{self, Node};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::Tree;
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget};
|
||||
use iced::event;
|
||||
use iced::mouse;
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, window};
|
||||
use wry::dpi::{LogicalPosition, LogicalSize};
|
||||
|
||||
@@ -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.
|
||||
pub fn t(locale: UiLocale, key: &str) -> String {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::mpsc;
|
||||
|
||||
use objc2::rc::Retained;
|
||||
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_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
|
||||
|
||||
@@ -30,8 +30,11 @@ define_class!(
|
||||
#[ivars = DelegateIvars]
|
||||
pub struct BdsAppDelegate;
|
||||
|
||||
// SAFETY: NSObjectProtocol declares no additional safety requirements.
|
||||
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(method(applicationDidFinishLaunching:))]
|
||||
fn did_finish_launching(&self, _notification: &NSNotification) {
|
||||
@@ -49,7 +52,10 @@ define_class!(
|
||||
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
|
||||
for i in 0..urls.len() {
|
||||
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> {
|
||||
let this = Self::alloc(mtm);
|
||||
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] }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
pub fn poll_lifecycle(
|
||||
receiver: &mpsc::Receiver<LifecycleEvent>,
|
||||
) -> Option<Message> {
|
||||
pub fn poll_lifecycle(receiver: &mpsc::Receiver<LifecycleEvent>) -> Option<Message> {
|
||||
match receiver.try_recv() {
|
||||
Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)),
|
||||
Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::Subscription;
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL};
|
||||
use muda::{Menu, MenuEvent, MenuItem, MenuId, PredefinedMenuItem, Submenu};
|
||||
use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
|
||||
use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use bds_core::i18n::{translate, UiLocale};
|
||||
use crate::app::Message;
|
||||
use bds_core::i18n::{UiLocale, translate};
|
||||
|
||||
/// Every custom menu item that the application handles.
|
||||
///
|
||||
@@ -210,12 +210,24 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
||||
|
||||
// -- File --
|
||||
let file_menu = Submenu::new(translate(locale, "menu.group.file"), true);
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::NewPost, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN))));
|
||||
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(&item(
|
||||
&mut reg,
|
||||
MenuAction::NewPost,
|
||||
locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)),
|
||||
));
|
||||
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(&item(&mut reg, MenuAction::OpenInBrowser, 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::select_all(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::Find, 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(&item(
|
||||
&mut reg,
|
||||
MenuAction::Find,
|
||||
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(&item(&mut reg, MenuAction::EditPreferences, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma))));
|
||||
let _ = edit_menu.append(&item(
|
||||
&mut reg,
|
||||
MenuAction::EditPreferences,
|
||||
locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)),
|
||||
));
|
||||
|
||||
// -- View --
|
||||
let view_menu = Submenu::new(translate(locale, "menu.group.view"), true);
|
||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewPosts, 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(&item(
|
||||
&mut reg,
|
||||
MenuAction::ViewPosts,
|
||||
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(&item(&mut reg, MenuAction::ToggleSidebar, 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(&item(
|
||||
&mut reg,
|
||||
MenuAction::ToggleSidebar,
|
||||
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::fullscreen(None));
|
||||
|
||||
// -- Blog --
|
||||
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, 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(&item(
|
||||
&mut reg,
|
||||
MenuAction::PublishSelected,
|
||||
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(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
||||
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::MetadataDiff, locale, None));
|
||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = blog_menu.append(&item(&mut reg, 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(&item(
|
||||
&mut reg,
|
||||
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(&item(&mut reg, MenuAction::GenerateSitemap, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
|
||||
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))));
|
||||
let _ = blog_menu.append(&item(
|
||||
&mut reg,
|
||||
MenuAction::GenerateSitemap,
|
||||
locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)),
|
||||
));
|
||||
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 --
|
||||
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`.
|
||||
#[cfg(target_os = "macos")]
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod menu;
|
||||
pub mod dialog;
|
||||
pub mod menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
@@ -3,7 +3,7 @@ pub mod sidebar_filter;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
|
||||
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
|
||||
pub use sidebar_filter::{PostFilter, MediaFilter, CalendarFilter, CalendarYear, CalendarMonth};
|
||||
pub use navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||
pub use sidebar_filter::{CalendarFilter, CalendarMonth, CalendarYear, MediaFilter, PostFilter};
|
||||
pub use tabs::{Tab, TabType};
|
||||
pub use toast::{Toast, ToastLevel};
|
||||
|
||||
@@ -92,24 +92,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn toggle_off_when_same_and_visible() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
|
||||
let (view, visible) = handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
|
||||
assert_eq!(view, SidebarView::Posts);
|
||||
assert!(!visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_on_when_same_and_hidden() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
|
||||
let (view, visible) = handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
|
||||
assert_eq!(view, SidebarView::Posts);
|
||||
assert!(visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_view_when_different() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
|
||||
let (view, visible) = handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
|
||||
assert_eq!(view, SidebarView::Media);
|
||||
assert!(visible);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// Sidebar filter state per sidebar_views.allium PostsView / MediaView.
|
||||
///
|
||||
/// Per ui_data_flow.allium SidebarFilterIsolation:
|
||||
/// "Sidebar search/filter state is local to the sidebar component.
|
||||
/// Filtering never affects: active tab, editor content, selectedPostId.
|
||||
/// Only the visible list of items changes."
|
||||
//! Sidebar filter state per sidebar_views.allium PostsView / MediaView.
|
||||
//!
|
||||
//! Per ui_data_flow.allium SidebarFilterIsolation:
|
||||
//! "Sidebar search/filter state is local to the sidebar component.
|
||||
//! Filtering never affects: active tab, editor content, selectedPostId.
|
||||
//! Only the visible list of items changes."
|
||||
|
||||
/// Calendar year/month archive filter.
|
||||
/// Per sidebar_views.allium CalendarFilter.
|
||||
|
||||
@@ -122,22 +122,21 @@ pub fn open_tab(tabs: &mut Vec<Tab>, new_tab: Tab) -> usize {
|
||||
}
|
||||
|
||||
// Singleton: only one instance allowed (catches id mismatch edge cases)
|
||||
if new_tab.tab_type.is_singleton() {
|
||||
if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) {
|
||||
if new_tab.tab_type.is_singleton()
|
||||
&& let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type)
|
||||
{
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Transient replacement: replace existing transient of same type
|
||||
if new_tab.is_transient {
|
||||
if let Some(idx) = tabs
|
||||
if new_tab.is_transient
|
||||
&& let Some(idx) = tabs
|
||||
.iter()
|
||||
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
|
||||
{
|
||||
tabs[idx] = new_tab;
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
tabs.push(new_tab);
|
||||
tabs.len() - 1
|
||||
@@ -159,7 +158,7 @@ pub fn close_tab(tabs: &mut Vec<Tab>, id: &str) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
tab.is_transient = false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
/// Toasts are ephemeral, auto-dismissing messages shown at the top of
|
||||
/// the workspace. Each toast has a severity level, a message, and a
|
||||
/// monotonically increasing id used for targeted dismissal.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, column, container, svg, text, tooltip, Column, Space};
|
||||
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 bds_core::i18n::UiLocale;
|
||||
@@ -40,10 +40,7 @@ const TOP_ACTIVITIES: &[SidebarView] = &[
|
||||
];
|
||||
|
||||
/// Bottom group of activity items.
|
||||
const BOTTOM_ACTIVITIES: &[SidebarView] = &[
|
||||
SidebarView::Git,
|
||||
SidebarView::Settings,
|
||||
];
|
||||
const BOTTOM_ACTIVITIES: &[SidebarView] = &[SidebarView::Git, SidebarView::Settings];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — matching bDS ActivityBar.css
|
||||
@@ -108,7 +105,7 @@ pub fn view(
|
||||
let icon = svg(handle)
|
||||
.width(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(
|
||||
container(icon)
|
||||
@@ -119,7 +116,11 @@ pub fn view(
|
||||
.height(Length::Fixed(48.0))
|
||||
.padding(0)
|
||||
.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)
|
||||
let btn_row: Element<'static, Message> = if is_active {
|
||||
@@ -138,30 +139,26 @@ pub fn view(
|
||||
|
||||
// Wrap in tooltip per layout.allium ActivityButton.label_key
|
||||
let tip_text = t(locale, view.i18n_key());
|
||||
tooltip(btn_row, text(tip_text).size(12).shaping(Shaping::Advanced), tooltip::Position::Right)
|
||||
tooltip(
|
||||
btn_row,
|
||||
text(tip_text).size(12).shaping(Shaping::Advanced),
|
||||
tooltip::Position::Right,
|
||||
)
|
||||
.gap(4)
|
||||
.into()
|
||||
};
|
||||
|
||||
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES
|
||||
.iter()
|
||||
.map(|v| make_btn(*v))
|
||||
.collect();
|
||||
let top_items: Vec<Element<'static, Message>> =
|
||||
TOP_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
|
||||
|
||||
let bottom_items: Vec<Element<'static, Message>> = BOTTOM_ACTIVITIES
|
||||
.iter()
|
||||
.map(|v| make_btn(*v))
|
||||
.collect();
|
||||
let bottom_items: Vec<Element<'static, Message>> =
|
||||
BOTTOM_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
|
||||
|
||||
let top = Column::with_children(top_items).spacing(0);
|
||||
let bottom = Column::with_children(bottom_items).spacing(0);
|
||||
|
||||
container(
|
||||
column![
|
||||
top,
|
||||
Space::with_height(Length::Fill),
|
||||
bottom,
|
||||
]
|
||||
column![top, Space::with_height(Length::Fill), bottom,]
|
||||
.width(Length::Fixed(50.0))
|
||||
.height(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
|
||||
@@ -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 bds_core::i18n::UiLocale;
|
||||
@@ -88,13 +88,8 @@ impl DashboardState {
|
||||
}
|
||||
|
||||
/// Render the dashboard overview.
|
||||
pub fn view<'a>(
|
||||
state: &'a DashboardState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let header = text(state.title.clone())
|
||||
.size(24)
|
||||
.color(Color::WHITE);
|
||||
pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let header = text(state.title.clone()).size(24).color(Color::WHITE);
|
||||
|
||||
let project_label = text(state.subtitle.clone())
|
||||
.size(14)
|
||||
@@ -106,11 +101,23 @@ pub fn view<'a>(
|
||||
state.stats.total_posts.to_string(),
|
||||
{
|
||||
let mut details = vec![
|
||||
format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")),
|
||||
format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")),
|
||||
format!(
|
||||
"{} {}",
|
||||
state.stats.published_count,
|
||||
t(locale, "dashboard.published")
|
||||
),
|
||||
format!(
|
||||
"{} {}",
|
||||
state.stats.draft_count,
|
||||
t(locale, "dashboard.drafts")
|
||||
),
|
||||
];
|
||||
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
|
||||
},
|
||||
@@ -119,14 +126,22 @@ pub fn view<'a>(
|
||||
&t(locale, "dashboard.media"),
|
||||
state.stats.media_count.to_string(),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
stat_card(
|
||||
&t(locale, "dashboard.tags"),
|
||||
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);
|
||||
@@ -166,7 +181,12 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
||||
container(
|
||||
column(
|
||||
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| {
|
||||
text(detail)
|
||||
.size(12)
|
||||
@@ -191,10 +211,17 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
||||
.into()
|
||||
}
|
||||
|
||||
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -> Element<'a, Message> {
|
||||
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1);
|
||||
row(
|
||||
months
|
||||
fn timeline_chart<'a>(
|
||||
months: &'a [DashboardTimelineMonth],
|
||||
_locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let max_count = months
|
||||
.iter()
|
||||
.map(|month| month.count)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
row(months
|
||||
.iter()
|
||||
.map(|month| {
|
||||
let height = if month.count == 0 {
|
||||
@@ -209,10 +236,15 @@ fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
|
||||
border: iced::Border { radius: 6.0.into(), ..iced::Border::default() },
|
||||
border: iced::Border {
|
||||
radius: 6.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
}),
|
||||
text(format!("{} {}", month.label, month.year)).size(11).color(Color::from_rgb(0.7, 0.72, 0.78)),
|
||||
text(format!("{} {}", month.label, month.year))
|
||||
.size(11)
|
||||
.color(Color::from_rgb(0.7, 0.72, 0.78)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
@@ -220,34 +252,41 @@ fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -
|
||||
.width(Length::FillPortion(1))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(10)
|
||||
.align_y(Alignment::End)
|
||||
.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() {
|
||||
row![text(t(locale, "dashboard.noTags")).size(13).color(Color::from_rgb(0.6, 0.62, 0.68))]
|
||||
row![
|
||||
text(t(locale, "dashboard.noTags"))
|
||||
.size(13)
|
||||
.color(Color::from_rgb(0.6, 0.62, 0.68))
|
||||
]
|
||||
.spacing(8)
|
||||
.wrap()
|
||||
} else {
|
||||
let words = tags
|
||||
.iter()
|
||||
.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);
|
||||
tooltip(
|
||||
container(
|
||||
text(tag.name.clone())
|
||||
.size(scale_font(tag.count))
|
||||
.color(fg),
|
||||
)
|
||||
container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg))
|
||||
.padding([6, 10])
|
||||
.style(move |_: &Theme| container::Style {
|
||||
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()
|
||||
}),
|
||||
text(format!("{} {}", tag.name, tag.count)).size(12),
|
||||
@@ -269,30 +308,49 @@ fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLoca
|
||||
]
|
||||
.spacing(8)
|
||||
} 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()
|
||||
}
|
||||
|
||||
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> {
|
||||
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| {
|
||||
fn category_cloud<'a>(
|
||||
categories: &'a [DashboardCategory],
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let items = categories.iter().fold(
|
||||
column![inputs::section_header(&t(
|
||||
locale,
|
||||
"dashboard.categoryCloud"
|
||||
))]
|
||||
.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)),
|
||||
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() },
|
||||
border: iced::Border {
|
||||
radius: 999.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
});
|
||||
},
|
||||
);
|
||||
container(items).width(Length::FillPortion(1)).into()
|
||||
}
|
||||
|
||||
@@ -313,7 +371,9 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
||||
button(
|
||||
column![
|
||||
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),
|
||||
)
|
||||
@@ -342,7 +402,10 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
||||
.padding(12)
|
||||
.style(|_: &Theme| container::Style {
|
||||
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()
|
||||
})
|
||||
.into()
|
||||
@@ -363,7 +426,10 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> {
|
||||
.padding([4, 8])
|
||||
.style(move |_: &Theme| container::Style {
|
||||
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()
|
||||
})
|
||||
.into()
|
||||
@@ -387,5 +453,9 @@ fn parse_color(value: Option<&str>) -> Option<Color> {
|
||||
|
||||
fn contrast_color(background: Color) -> Color {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, column, container, image, row, scrollable, text};
|
||||
use iced::{Color, Element, Length};
|
||||
|
||||
use bds_core::i18n::{self, UiLocale};
|
||||
@@ -72,12 +72,15 @@ impl MediaEditorState {
|
||||
|
||||
let mut translation_drafts = HashMap::new();
|
||||
for tr in translations {
|
||||
translation_drafts.insert(tr.language.clone(), MediaTranslationDraft {
|
||||
translation_drafts.insert(
|
||||
tr.language.clone(),
|
||||
MediaTranslationDraft {
|
||||
title: tr.title.clone().unwrap_or_default(),
|
||||
alt: tr.alt.clone().unwrap_or_default(),
|
||||
caption: tr.caption.clone().unwrap_or_default(),
|
||||
is_dirty: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
@@ -125,12 +128,15 @@ impl MediaEditorState {
|
||||
is_dirty: self.is_dirty,
|
||||
});
|
||||
} else {
|
||||
self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft {
|
||||
self.translation_drafts.insert(
|
||||
self.active_language.clone(),
|
||||
MediaTranslationDraft {
|
||||
title: self.title.clone(),
|
||||
alt: self.alt.clone(),
|
||||
caption: self.caption.clone(),
|
||||
is_dirty: self.is_dirty,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
// Load target fields
|
||||
if target_lang == self.canonical_language {
|
||||
@@ -225,24 +231,28 @@ pub fn view<'a>(
|
||||
ai_enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(state.original_name.clone()).size(18).into(),
|
||||
],
|
||||
vec![text(state.original_name.clone()).size(18).into()],
|
||||
vec![
|
||||
if state.mime_type.starts_with("image/") {
|
||||
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])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
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])
|
||||
.into(),
|
||||
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])
|
||||
.into(),
|
||||
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)
|
||||
};
|
||||
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])
|
||||
.style(|_: &iced::Theme, _| button::Style::default())
|
||||
.into()
|
||||
@@ -295,13 +307,13 @@ pub fn view<'a>(
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
no_preview()
|
||||
no_preview(locale)
|
||||
}
|
||||
} else {
|
||||
no_preview()
|
||||
no_preview(locale)
|
||||
}
|
||||
} else {
|
||||
no_preview()
|
||||
no_preview(locale)
|
||||
};
|
||||
|
||||
// File info
|
||||
@@ -311,7 +323,10 @@ pub fn view<'a>(
|
||||
};
|
||||
let size_str = format_file_size(state.size);
|
||||
let info = row![
|
||||
text(format!("{} • {} • {}", state.mime_type, size_str, dimensions))
|
||||
text(format!(
|
||||
"{} • {} • {}",
|
||||
state.mime_type, size_str, dimensions
|
||||
))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
]
|
||||
@@ -332,18 +347,13 @@ pub fn view<'a>(
|
||||
);
|
||||
let meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill);
|
||||
|
||||
let caption_input = inputs::labeled_input(
|
||||
&t(locale, "editor.caption"),
|
||||
"",
|
||||
&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 caption_input =
|
||||
inputs::labeled_input(&t(locale, "editor.caption"), "", &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 tags_input = inputs::labeled_input(
|
||||
&t(locale, "editor.tags"),
|
||||
&t(locale, "editor.tagsPlaceholder"),
|
||||
@@ -361,8 +371,12 @@ pub fn view<'a>(
|
||||
Some(&state.language),
|
||||
|lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill);
|
||||
let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill);
|
||||
let meta_row2 = row![caption_input, author_input]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
let meta_row3 = row![tags_input, language_input]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
let linked_posts_header = row![
|
||||
text(t(locale, "editor.linkedPosts"))
|
||||
@@ -396,14 +410,20 @@ pub fn view<'a>(
|
||||
.iter()
|
||||
.map(|post| {
|
||||
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])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
container(column![search, column(results).spacing(4)].spacing(8).padding(8))
|
||||
container(
|
||||
column![search, column(results).spacing(4)]
|
||||
.spacing(8)
|
||||
.padding(8),
|
||||
)
|
||||
.style(|_: &iced::Theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
border: iced::Border {
|
||||
@@ -430,11 +450,15 @@ pub fn view<'a>(
|
||||
.map(|post| {
|
||||
row![
|
||||
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]),
|
||||
Space::with_width(Length::Fill),
|
||||
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])
|
||||
.style(inputs::danger_button),
|
||||
]
|
||||
@@ -471,7 +495,7 @@ pub fn view<'a>(
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
|
||||
container(body)
|
||||
@@ -480,6 +504,29 @@ pub fn view<'a>(
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::MediaEditorState;
|
||||
@@ -519,30 +566,10 @@ mod tests {
|
||||
);
|
||||
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
pub mod workspace;
|
||||
pub mod activity_bar;
|
||||
pub mod sidebar;
|
||||
pub mod tab_bar;
|
||||
pub mod status_bar;
|
||||
pub mod dashboard;
|
||||
pub mod media_editor;
|
||||
pub mod modal;
|
||||
pub mod panel;
|
||||
pub mod post_editor;
|
||||
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 welcome;
|
||||
pub mod modal;
|
||||
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;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::path::Path;
|
||||
|
||||
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 bds_core::i18n::UiLocale;
|
||||
@@ -112,7 +114,10 @@ pub enum ConfirmAction {
|
||||
DeleteTemplate(String),
|
||||
ForceDeleteTemplate(String),
|
||||
DeleteTag(String),
|
||||
MergeTags { sources: Vec<String>, target: String },
|
||||
MergeTags {
|
||||
sources: Vec<String>,
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Modal backdrop style ──
|
||||
@@ -231,7 +236,11 @@ fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
ModalState::ConfirmDelete {
|
||||
entity_name,
|
||||
@@ -452,14 +461,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
}),
|
||||
)
|
||||
.padding([3, 8])
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
.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: 999.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}),
|
||||
]
|
||||
.spacing(12)
|
||||
@@ -505,7 +518,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "modal.postInsertLink.insert")))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert))
|
||||
.on_press(Message::PostEditor(
|
||||
PostEditorMsg::PostInsertLinkExternalInsert
|
||||
))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
]
|
||||
@@ -537,7 +552,8 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced);
|
||||
|
||||
let trailing_button: Element<'static, Message> = if active_tab == PostInsertLinkTab::Internal {
|
||||
let trailing_button: Element<'static, Message> =
|
||||
if active_tab == PostInsertLinkTab::Internal {
|
||||
create_post_btn
|
||||
} else {
|
||||
Space::with_width(0.0).into()
|
||||
@@ -597,14 +613,10 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let is_image = m.mime_type.starts_with("image/");
|
||||
let title = m
|
||||
.title
|
||||
.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let title = m.title.as_deref().unwrap_or("").to_string();
|
||||
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> =
|
||||
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
image(path)
|
||||
.width(Length::Fixed(120.0))
|
||||
.height(Length::Fixed(90.0))
|
||||
@@ -716,10 +728,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced);
|
||||
|
||||
let buttons = row![button(cancel_text)
|
||||
let buttons = row![
|
||||
button(cancel_text)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),];
|
||||
.style(cancel_button_style),
|
||||
];
|
||||
|
||||
let content = column![
|
||||
title,
|
||||
@@ -754,13 +768,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, m)| {
|
||||
let title = m
|
||||
.title
|
||||
.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
let title = m.title.as_deref().unwrap_or("").to_string();
|
||||
let thumb: Element<'static, Message> =
|
||||
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
image(path)
|
||||
.width(Length::Fixed(180.0))
|
||||
.height(Length::Fixed(120.0))
|
||||
@@ -845,7 +855,8 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
|
||||
let lightbox: Element<'static, Message> = if let Some(index) = selected_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> =
|
||||
if let Some(path) = resolve_media_file(data_dir, selected) {
|
||||
image(path)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(420.0))
|
||||
@@ -876,7 +887,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(12.0),
|
||||
button(text(t(locale, "modal.postGallery.backToGrid")))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox))
|
||||
.on_press(Message::PostEditor(
|
||||
PostEditorMsg::PostGalleryCloseLightbox
|
||||
))
|
||||
.padding([6, 12])
|
||||
.style(cancel_button_style),
|
||||
]
|
||||
@@ -915,23 +928,40 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let rows = fields.iter().enumerate().map(|(index, field)| {
|
||||
let toggle = checkbox(field.label.clone(), field.accepted)
|
||||
.on_toggle_maybe((!field.locked)
|
||||
.then_some(move |value| Message::ToggleAiSuggestionField(index, value)))
|
||||
let rows = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
let toggle =
|
||||
checkbox(field.label.clone(), field.accepted)
|
||||
.on_toggle_maybe((!field.locked).then_some(move |value| {
|
||||
Message::ToggleAiSuggestionField(index, value)
|
||||
}))
|
||||
.size(16)
|
||||
.text_size(13);
|
||||
container(column![
|
||||
container(
|
||||
column![
|
||||
toggle,
|
||||
row![
|
||||
container(text(field.current_value.clone()).size(12).color(Color::from_rgb(0.58, 0.58, 0.64))).width(Length::FillPortion(1)),
|
||||
container(
|
||||
text(field.current_value.clone())
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.58, 0.58, 0.64))
|
||||
)
|
||||
.width(Length::FillPortion(1)),
|
||||
text("→").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
|
||||
container(text(field.suggested_value.clone()).size(12).color(Color::WHITE)).width(Length::FillPortion(1)),
|
||||
container(
|
||||
text(field.suggested_value.clone())
|
||||
.size(12)
|
||||
.color(Color::WHITE)
|
||||
)
|
||||
.width(Length::FillPortion(1)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8))
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(10)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
@@ -943,7 +973,8 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}).collect::<Vec<Element<'static, Message>>>();
|
||||
})
|
||||
.collect::<Vec<Element<'static, Message>>>();
|
||||
|
||||
let buttons = row![
|
||||
button(text(t(locale, "common.cancel")).size(13))
|
||||
@@ -972,12 +1003,20 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::LanguagePicker { target, source_language, available_targets } => {
|
||||
ModalState::LanguagePicker {
|
||||
target,
|
||||
source_language,
|
||||
available_targets,
|
||||
} => {
|
||||
let title = text(t(locale, "modal.languagePicker.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
let subtitle = text(format!("{}: {}", t(locale, "editor.language"), source_language))
|
||||
let subtitle = text(format!(
|
||||
"{}: {}",
|
||||
t(locale, "editor.language"),
|
||||
source_language
|
||||
))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.68));
|
||||
@@ -985,22 +1024,38 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
let rows = if available_targets.is_empty() {
|
||||
vec![text(t(locale, "common.noResults")).size(12).into()]
|
||||
} else {
|
||||
available_targets.into_iter().map(|language| {
|
||||
available_targets
|
||||
.into_iter()
|
||||
.map(|language| {
|
||||
let message = match &target {
|
||||
AiEntityTarget::Post(_) => Message::PostEditor(PostEditorMsg::TranslateTo(language.code.clone())),
|
||||
AiEntityTarget::Media(_) => Message::MediaEditor(crate::views::media_editor::MediaEditorMsg::TranslateTo(language.code.clone())),
|
||||
AiEntityTarget::Post(_) => Message::PostEditor(
|
||||
PostEditorMsg::TranslateTo(language.code.clone()),
|
||||
),
|
||||
AiEntityTarget::Media(_) => Message::MediaEditor(
|
||||
crate::views::media_editor::MediaEditorMsg::TranslateTo(
|
||||
language.code.clone(),
|
||||
),
|
||||
),
|
||||
};
|
||||
let status = language.existing_status.clone().unwrap_or_default();
|
||||
button(row![
|
||||
text(format!("{} {}", language.flag_emoji, language.name)).size(13).color(Color::WHITE),
|
||||
button(
|
||||
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))
|
||||
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>>>()
|
||||
})
|
||||
.collect::<Vec<Element<'static, Message>>>()
|
||||
};
|
||||
|
||||
let content = column![
|
||||
@@ -1042,7 +1097,10 @@ mod tests {
|
||||
#[test]
|
||||
fn external_link_markdown_requires_url() {
|
||||
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!(
|
||||
external_link_markdown("https://example.com", "Example"),
|
||||
Some("[Example](https://example.com)".to_string())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, column, container, scrollable, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, column, container, scrollable, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
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(
|
||||
panel_tab: PanelTab,
|
||||
task_snapshots: &[TaskSnapshot],
|
||||
@@ -79,39 +83,68 @@ pub fn view(
|
||||
// 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
|
||||
// media).
|
||||
let tasks_btn = button(text(t(locale, "common.tasks")).size(12).shaping(Shaping::Advanced))
|
||||
let tasks_btn = button(
|
||||
text(t(locale, "common.tasks"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::Tasks))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::Tasks { tab_active } else { tab_inactive });
|
||||
.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(
|
||||
text(t(locale, "panel.output"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::Output))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::Output { tab_active } else { tab_inactive });
|
||||
.style(if panel_tab == PanelTab::Output {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
|
||||
let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::TogglePanel)
|
||||
.padding([4, 6])
|
||||
.style(close_btn_style);
|
||||
|
||||
let mut tab_row: Vec<Element<'static, Message>> = vec![
|
||||
tasks_btn.into(),
|
||||
output_btn.into(),
|
||||
];
|
||||
let mut tab_row: Vec<Element<'static, Message>> = vec![tasks_btn.into(), output_btn.into()];
|
||||
|
||||
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(
|
||||
text(t(locale, "panel.postLinks"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::PostLinks { tab_active } else { tab_inactive });
|
||||
.style(if panel_tab == PanelTab::PostLinks {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
tab_row.push(post_links_btn.into());
|
||||
}
|
||||
|
||||
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(
|
||||
text(t(locale, "panel.gitLog"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::GitLog))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::GitLog { tab_active } else { tab_inactive });
|
||||
.style(if panel_tab == PanelTab::GitLog {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
tab_row.push(git_log_btn.into());
|
||||
}
|
||||
|
||||
@@ -127,7 +160,12 @@ pub fn view(
|
||||
let content: Element<'static, Message> = match panel_tab {
|
||||
PanelTab::Tasks => {
|
||||
if task_snapshots.is_empty() {
|
||||
container(text(t(locale, "tasks.noActive")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
container(
|
||||
text(t(locale, "tasks.noActive"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
@@ -137,21 +175,24 @@ pub fn view(
|
||||
.rev()
|
||||
.take(10)
|
||||
.map(|snap| {
|
||||
let progress_str = snap.progress
|
||||
let progress_str = snap
|
||||
.progress
|
||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||
.unwrap_or_default();
|
||||
let phase_str = snap.message
|
||||
let phase_str = snap
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|m| format!(" \u{2014} {m}"))
|
||||
.unwrap_or_default();
|
||||
let status_text = format!(
|
||||
"{} \u{2014} {}{}{}",
|
||||
snap.label,
|
||||
snap.status,
|
||||
progress_str,
|
||||
phase_str,
|
||||
snap.label, 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();
|
||||
scrollable(
|
||||
@@ -164,7 +205,12 @@ pub fn view(
|
||||
}
|
||||
PanelTab::Output => {
|
||||
if output_entries.is_empty() {
|
||||
container(text(t(locale, "panel.noOutput")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
container(
|
||||
text(t(locale, "panel.noOutput"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
@@ -188,7 +234,12 @@ pub fn view(
|
||||
}
|
||||
PanelTab::PostLinks => {
|
||||
if post_outlinks.is_empty() && post_backlinks.is_empty() {
|
||||
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
container(
|
||||
text(t(locale, "panel.postLinksPlaceholder"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
@@ -201,7 +252,12 @@ pub fn view(
|
||||
];
|
||||
|
||||
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 {
|
||||
for link in post_outlinks {
|
||||
items.push(post_link_button(locale, link));
|
||||
@@ -218,7 +274,12 @@ pub fn view(
|
||||
);
|
||||
|
||||
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 {
|
||||
for link in post_backlinks {
|
||||
items.push(post_link_button(locale, link));
|
||||
@@ -235,15 +296,18 @@ pub fn view(
|
||||
}
|
||||
PanelTab::GitLog => {
|
||||
// Git Log content populated in extension bucket A (git integration)
|
||||
container(text(t(locale, "panel.gitLogPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
container(
|
||||
text(t(locale, "panel.gitLogPlaceholder"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
container(
|
||||
column![tab_header, content].spacing(0),
|
||||
)
|
||||
container(column![tab_header, content].spacing(0))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(200.0))
|
||||
.style(panel_style)
|
||||
|
||||
@@ -2,12 +2,12 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
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 bds_core::i18n::{self, UiLocale};
|
||||
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::components::inputs;
|
||||
@@ -297,7 +297,11 @@ impl PostEditorState {
|
||||
langs.sort();
|
||||
for lang in langs {
|
||||
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(_) => "draft",
|
||||
None => "missing",
|
||||
@@ -480,10 +484,30 @@ pub fn view<'a>(
|
||||
Space::with_width(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), 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),
|
||||
quick_action_item(
|
||||
locale,
|
||||
t(locale, "editor.aiAnalyze"),
|
||||
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)
|
||||
)
|
||||
@@ -525,7 +549,7 @@ pub fn view<'a>(
|
||||
let mut flag_row = row![].spacing(2);
|
||||
for flag in &flags {
|
||||
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))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
||||
.padding([2, 4])
|
||||
@@ -574,7 +598,11 @@ pub fn view<'a>(
|
||||
Some(&state.language),
|
||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
||||
let detect_language = button(
|
||||
text(t(locale, "editor.detectLanguage"))
|
||||
.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(
|
||||
@@ -619,11 +647,13 @@ pub fn view<'a>(
|
||||
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
||||
Space::new(0, 0).into()
|
||||
} else {
|
||||
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.outlinks"))
|
||||
let mut items: Vec<Element<'a, Message>> = vec![
|
||||
text(t(locale, "editor.outlinks"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()];
|
||||
.into(),
|
||||
];
|
||||
for link in &state.outlinks {
|
||||
items.push(
|
||||
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() {
|
||||
Space::new(0, 0).into()
|
||||
} else {
|
||||
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.backlinks"))
|
||||
let mut items: Vec<Element<'a, Message>> = vec![
|
||||
text(t(locale, "editor.backlinks"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()];
|
||||
.into(),
|
||||
];
|
||||
for link in &state.backlinks {
|
||||
items.push(
|
||||
text(format!("\u{2190} {}", link.title))
|
||||
@@ -712,11 +744,21 @@ pub fn view<'a>(
|
||||
]
|
||||
.spacing(2)
|
||||
.width(Length::Fill),
|
||||
button(text(t(locale, "common.open")).size(11).shaping(Shaping::Advanced))
|
||||
button(
|
||||
text(t(locale, "common.open"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
|
||||
.padding([4, 10]),
|
||||
button(text(t(locale, "editor.unlinkMedia")).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(media_id)))
|
||||
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),
|
||||
]
|
||||
@@ -725,7 +767,9 @@ pub fn view<'a>(
|
||||
)
|
||||
.padding(8)
|
||||
.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 {
|
||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||
width: 1.0,
|
||||
@@ -788,13 +832,31 @@ pub fn view<'a>(
|
||||
// ── Content section (fills remaining space) ──
|
||||
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
||||
let mode_toggle = row![
|
||||
mode_button(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()))),
|
||||
mode_button(
|
||||
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)
|
||||
.align_y(iced::Alignment::Center);
|
||||
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![
|
||||
if state.editor_mode == "markdown" {
|
||||
button(
|
||||
@@ -980,7 +1042,12 @@ fn mode_button<'a>(
|
||||
.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;
|
||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||
@@ -1138,5 +1205,4 @@ mod tests {
|
||||
state.set_editor_mode("preview");
|
||||
assert_eq!(state.editor_mode, "preview");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, container, row, svg, text, Column, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Column, Space, button, container, row, svg, text};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -120,19 +120,32 @@ pub fn view(
|
||||
|
||||
let label = if is_active {
|
||||
row![
|
||||
text("\u{2713}").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),
|
||||
text("\u{2713}")
|
||||
.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)
|
||||
} else {
|
||||
row![
|
||||
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)
|
||||
};
|
||||
|
||||
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(
|
||||
button(label)
|
||||
@@ -159,8 +172,13 @@ pub fn view(
|
||||
items.push(
|
||||
button(
|
||||
row![
|
||||
text("+").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),
|
||||
text("+")
|
||||
.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),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 bds_core::i18n::UiLocale;
|
||||
@@ -118,10 +118,7 @@ pub enum ScriptEditorMsg {
|
||||
}
|
||||
|
||||
/// Render the script editor view.
|
||||
pub fn view<'a>(
|
||||
state: &'a ScriptEditorState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(state.title.clone()).size(18).into(),
|
||||
@@ -162,7 +159,9 @@ pub fn view<'a>(
|
||||
&state.slug,
|
||||
|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
|
||||
let kind_options = vec![
|
||||
@@ -179,7 +178,10 @@ pub fn view<'a>(
|
||||
);
|
||||
|
||||
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());
|
||||
}
|
||||
let selected_entrypoint = entrypoint_options
|
||||
@@ -192,28 +194,30 @@ pub fn view<'a>(
|
||||
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
|
||||
);
|
||||
|
||||
let enabled_check = inputs::labeled_checkbox(
|
||||
&t(locale, "editor.enabled"),
|
||||
state.enabled,
|
||||
|b| Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b)),
|
||||
);
|
||||
let enabled_check =
|
||||
inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
|
||||
Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b))
|
||||
});
|
||||
let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// 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![
|
||||
inputs::section_header(&t(locale, "editor.content")),
|
||||
Element::from(
|
||||
CodeEditor::new(
|
||||
&state.editor_buffer,
|
||||
highlighter(),
|
||||
syntax_ext,
|
||||
)
|
||||
.on_change(|msg| match msg {
|
||||
EditorMessage::ContentChanged(s) => Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)),
|
||||
CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| {
|
||||
match msg {
|
||||
EditorMessage::ContentChanged(s) => {
|
||||
Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))
|
||||
}
|
||||
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
|
||||
}
|
||||
})
|
||||
),
|
||||
]
|
||||
@@ -245,24 +249,15 @@ pub fn view<'a>(
|
||||
|
||||
// Top pane: header + metadata (scrollable for overflow)
|
||||
let top_pane = scrollable(
|
||||
column![
|
||||
header,
|
||||
meta_row1,
|
||||
meta_row2,
|
||||
]
|
||||
column![header, meta_row1, meta_row2,]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.height(Length::Shrink);
|
||||
|
||||
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
|
||||
column![
|
||||
top_pane,
|
||||
content_section,
|
||||
validation,
|
||||
footer,
|
||||
]
|
||||
column![top_pane, content_section, validation, footer,]
|
||||
.spacing(4)
|
||||
.padding([0, 16])
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -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::{button, column, container, row, scrollable, text, text_editor, text_input};
|
||||
use iced::{Alignment, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -209,7 +209,13 @@ impl Default for SettingsViewState {
|
||||
public_url: String::new(),
|
||||
main_language: "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(),
|
||||
max_posts_per_page: "50".to_string(),
|
||||
blogmark_category: String::new(),
|
||||
@@ -255,12 +261,12 @@ impl SettingsViewState {
|
||||
|
||||
fn ordered_sections(&self) -> Vec<SettingsSection> {
|
||||
let mut sections = SettingsSection::all().to_vec();
|
||||
if let Some(active) = &self.active_section {
|
||||
if let Some(index) = sections.iter().position(|section| section == active) {
|
||||
if let Some(active) = &self.active_section
|
||||
&& let Some(index) = sections.iter().position(|section| section == active)
|
||||
{
|
||||
let focused = sections.remove(index);
|
||||
sections.insert(0, focused);
|
||||
}
|
||||
}
|
||||
sections
|
||||
}
|
||||
}
|
||||
@@ -337,10 +343,7 @@ pub enum SettingsMsg {
|
||||
}
|
||||
|
||||
/// Render the settings view.
|
||||
pub fn view<'a>(
|
||||
state: &'a SettingsViewState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
|
||||
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
|
||||
.size(14);
|
||||
@@ -388,38 +391,6 @@ pub fn view<'a>(
|
||||
.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>(
|
||||
state: &'a SettingsViewState,
|
||||
section: &SettingsSection,
|
||||
@@ -436,7 +407,9 @@ fn render_section<'a>(
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.on_press(Message::Settings(SettingsMsg::ToggleSection(section.clone())))
|
||||
.on_press(Message::Settings(SettingsMsg::ToggleSection(
|
||||
section.clone(),
|
||||
)))
|
||||
.padding([6, 8])
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme, _| button::Style::default());
|
||||
@@ -456,9 +429,7 @@ fn render_section<'a>(
|
||||
SettingsSection::MCP => section_mcp(locale),
|
||||
};
|
||||
|
||||
column![header, content]
|
||||
.spacing(4)
|
||||
.into()
|
||||
column![header, content].spacing(4).into()
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
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)
|
||||
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
|
||||
.height(Length::Fixed(80.0))
|
||||
@@ -477,12 +451,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
]
|
||||
.spacing(4);
|
||||
let data_path = row![
|
||||
inputs::labeled_input(
|
||||
&t(locale, "settings.dataPath"),
|
||||
"",
|
||||
&state.data_path,
|
||||
|s| Message::Settings(SettingsMsg::DataPathChanged(s)),
|
||||
),
|
||||
inputs::labeled_input(&t(locale, "settings.dataPath"), "", &state.data_path, |s| {
|
||||
Message::Settings(SettingsMsg::DataPathChanged(s))
|
||||
},),
|
||||
button(text(t(locale, "settings.browse")).size(12))
|
||||
.on_press(Message::Settings(SettingsMsg::BrowseDataPath))
|
||||
.padding([6, 12]),
|
||||
@@ -512,14 +483,24 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
.iter()
|
||||
.map(|language| {
|
||||
let label = if *language == state.main_language {
|
||||
format!("{} ({})", language, t(locale, "settings.mainLanguageRequired"))
|
||||
format!(
|
||||
"{} ({})",
|
||||
language,
|
||||
t(locale, "settings.mainLanguageRequired")
|
||||
)
|
||||
} else {
|
||||
language.clone()
|
||||
};
|
||||
inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), {
|
||||
inputs::labeled_checkbox(
|
||||
&label,
|
||||
state.blog_languages.iter().any(|item| item == language),
|
||||
{
|
||||
let language = language.clone();
|
||||
move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
|
||||
})
|
||||
move |_| {
|
||||
Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
@@ -539,7 +520,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
let blogmark_category = inputs::labeled_select(
|
||||
&t(locale, "settings.blogmarkCategory"),
|
||||
&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)),
|
||||
);
|
||||
let save = button(text(t(locale, "common.save")).size(13))
|
||||
@@ -616,14 +600,23 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
.chain(state.template_options.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| {
|
||||
let category_rows = state
|
||||
.categories
|
||||
.iter()
|
||||
.fold(column![].spacing(8), |column, category| {
|
||||
let title = inputs::labeled_input(
|
||||
&tw(locale, "settings.categoryTitle", &[("category", &category.name)]),
|
||||
&tw(
|
||||
locale,
|
||||
"settings.categoryTitle",
|
||||
&[("category", &category.name)],
|
||||
),
|
||||
"",
|
||||
&category.title,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
|
||||
}
|
||||
},
|
||||
);
|
||||
let post_template = inputs::labeled_select(
|
||||
@@ -632,7 +625,12 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
Some(&category.post_template_slug),
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryPostTemplateChanged(name.clone(), value))
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryPostTemplateChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
);
|
||||
let list_template = inputs::labeled_select(
|
||||
@@ -641,7 +639,12 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
Some(&category.list_template_slug),
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryListTemplateChanged(name.clone(), value))
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryListTemplateChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
);
|
||||
let toggles = column![
|
||||
@@ -650,7 +653,12 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
category.render_in_lists,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value))
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryRenderInListsChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
),
|
||||
inputs::labeled_checkbox(
|
||||
@@ -658,18 +666,27 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
category.show_title,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value))
|
||||
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())))
|
||||
.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()))))
|
||||
.on_press_maybe((!category.is_protected).then(|| Message::Settings(
|
||||
SettingsMsg::RemoveCategory(category.name.clone())
|
||||
)))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 12]),
|
||||
]
|
||||
@@ -732,7 +749,12 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
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<_>>();
|
||||
|
||||
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(
|
||||
&t(locale, "settings.defaultModel"),
|
||||
&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)),
|
||||
);
|
||||
let title_model = inputs::labeled_select(
|
||||
&t(locale, "settings.titleModel"),
|
||||
&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)),
|
||||
);
|
||||
let image_model = inputs::labeled_select(
|
||||
&t(locale, "settings.imageAnalysisModel"),
|
||||
&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)),
|
||||
);
|
||||
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)
|
||||
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
|
||||
.height(Length::Fixed(200.0))
|
||||
@@ -822,11 +853,15 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
|
||||
column![
|
||||
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,
|
||||
row![online_model, online_api_key].spacing(12),
|
||||
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,
|
||||
row![airplane_model, airplane_refresh].spacing(12),
|
||||
default_model,
|
||||
@@ -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> {
|
||||
let host = inputs::labeled_input(
|
||||
&t(locale, "settings.sshHost"),
|
||||
"",
|
||||
&state.ssh_host,
|
||||
|s| Message::Settings(SettingsMsg::SshHostChanged(s)),
|
||||
);
|
||||
let host = inputs::labeled_input(&t(locale, "settings.sshHost"), "", &state.ssh_host, |s| {
|
||||
Message::Settings(SettingsMsg::SshHostChanged(s))
|
||||
});
|
||||
let user = inputs::labeled_input(
|
||||
&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))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, column, container, image, row, scrollable, text, text_input};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -269,9 +269,18 @@ fn clear_filters_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
/// Month name abbreviation for calendar display.
|
||||
fn month_abbr(month: u32) -> &'static str {
|
||||
match month {
|
||||
1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
|
||||
5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
|
||||
9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
|
||||
1 => "Jan",
|
||||
2 => "Feb",
|
||||
3 => "Mar",
|
||||
4 => "Apr",
|
||||
5 => "May",
|
||||
6 => "Jun",
|
||||
7 => "Jul",
|
||||
8 => "Aug",
|
||||
9 => "Sep",
|
||||
10 => "Oct",
|
||||
11 => "Nov",
|
||||
12 => "Dec",
|
||||
_ => "???",
|
||||
}
|
||||
}
|
||||
@@ -300,10 +309,12 @@ fn calendar_widget(
|
||||
let label = format!("{} ({})", cy.year, total);
|
||||
let year_val = cy.year;
|
||||
let on_year_clone = on_year.clone();
|
||||
let style_fn = if year_selected { calendar_selected_style } else { calendar_style };
|
||||
let year_btn = button(
|
||||
text(label).size(11).shaping(Shaping::Advanced)
|
||||
)
|
||||
let style_fn = if year_selected {
|
||||
calendar_selected_style
|
||||
} else {
|
||||
calendar_style
|
||||
};
|
||||
let year_btn = button(text(label).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(if year_selected {
|
||||
on_year_clone(None)
|
||||
} else {
|
||||
@@ -320,10 +331,12 @@ fn calendar_widget(
|
||||
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
|
||||
let month_val = cm.month;
|
||||
let on_month_clone = on_month.clone();
|
||||
let style_fn = if month_selected { calendar_selected_style } else { calendar_style };
|
||||
let month_btn = button(
|
||||
text(label).size(10).shaping(Shaping::Advanced)
|
||||
)
|
||||
let style_fn = if month_selected {
|
||||
calendar_selected_style
|
||||
} else {
|
||||
calendar_style
|
||||
};
|
||||
let month_btn = button(text(label).size(10).shaping(Shaping::Advanced))
|
||||
.on_press(if month_selected {
|
||||
on_month_clone(None)
|
||||
} else {
|
||||
@@ -362,10 +375,12 @@ fn chip_selector(
|
||||
let is_selected = selected.contains(tag);
|
||||
let tag_clone = tag.clone();
|
||||
let on_toggle_clone = on_toggle.clone();
|
||||
let style_fn = if is_selected { chip_selected_style } else { chip_style };
|
||||
let chip = button(
|
||||
text(tag.clone()).size(10).shaping(Shaping::Advanced)
|
||||
)
|
||||
let style_fn = if is_selected {
|
||||
chip_selected_style
|
||||
} else {
|
||||
chip_style
|
||||
};
|
||||
let chip = button(text(tag.clone()).size(10).shaping(Shaping::Advanced))
|
||||
.on_press(on_toggle_clone(tag_clone))
|
||||
.padding([2, 6])
|
||||
.style(style_fn);
|
||||
@@ -377,9 +392,7 @@ fn chip_selector(
|
||||
while !collected.is_empty() {
|
||||
let chunk_size = 3.min(collected.len());
|
||||
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
|
||||
items.push(
|
||||
iced::widget::Row::with_children(chunk).spacing(4).into()
|
||||
);
|
||||
items.push(iced::widget::Row::with_children(chunk).spacing(4).into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +421,11 @@ fn single_select_chip_selector(
|
||||
.iter()
|
||||
.map(|(value, display)| {
|
||||
let is_selected = selected == Some(value.as_str());
|
||||
let style_fn = if is_selected { chip_selected_style } else { chip_style };
|
||||
let style_fn = if is_selected {
|
||||
chip_selected_style
|
||||
} else {
|
||||
chip_style
|
||||
};
|
||||
let value = value.clone();
|
||||
let on_toggle = on_toggle.clone();
|
||||
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
|
||||
@@ -473,8 +490,8 @@ fn post_filter_panel(
|
||||
&filter.calendar_years,
|
||||
filter.calendar.selected_year,
|
||||
filter.calendar.selected_month,
|
||||
|y| Message::SetPostCalendarYear(y),
|
||||
|m| Message::SetPostCalendarMonth(m),
|
||||
Message::SetPostCalendarYear,
|
||||
Message::SetPostCalendarMonth,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -486,7 +503,7 @@ fn post_filter_panel(
|
||||
"sidebar.filter.tags",
|
||||
&filter.available_tags,
|
||||
&filter.tag_filter,
|
||||
|tag| Message::TogglePostTagFilter(tag),
|
||||
Message::TogglePostTagFilter,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -498,7 +515,7 @@ fn post_filter_panel(
|
||||
"sidebar.filter.categories",
|
||||
&filter.available_categories,
|
||||
&filter.category_filter,
|
||||
|cat| Message::TogglePostCategoryFilter(cat),
|
||||
Message::TogglePostCategoryFilter,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -537,23 +554,22 @@ fn post_filter_panel(
|
||||
button(
|
||||
text(t(locale, "sidebar.filter.clearAll"))
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::ClearPostFilters)
|
||||
.padding([2, 4])
|
||||
.style(clear_filters_style)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
iced::widget::Column::with_children(sections).spacing(2).into()
|
||||
iced::widget::Column::with_children(sections)
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the filter panel for Media view.
|
||||
fn media_filter_panel(
|
||||
filter: &MediaFilter,
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
fn media_filter_panel(filter: &MediaFilter, locale: UiLocale) -> Element<'static, Message> {
|
||||
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
if !filter.calendar_years.is_empty() {
|
||||
@@ -561,8 +577,8 @@ fn media_filter_panel(
|
||||
&filter.calendar_years,
|
||||
filter.calendar.selected_year,
|
||||
filter.calendar.selected_month,
|
||||
|y| Message::SetMediaCalendarYear(y),
|
||||
|m| Message::SetMediaCalendarMonth(m),
|
||||
Message::SetMediaCalendarYear,
|
||||
Message::SetMediaCalendarMonth,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -573,7 +589,7 @@ fn media_filter_panel(
|
||||
"sidebar.filter.tags",
|
||||
&filter.available_tags,
|
||||
&filter.tag_filter,
|
||||
|tag| Message::ToggleMediaTagFilter(tag),
|
||||
Message::ToggleMediaTagFilter,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -584,18 +600,24 @@ fn media_filter_panel(
|
||||
button(
|
||||
text(t(locale, "sidebar.filter.clearAll"))
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::ClearMediaFilters)
|
||||
.padding([2, 4])
|
||||
.style(clear_filters_style)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
iced::widget::Column::with_children(sections).spacing(2).into()
|
||||
iced::widget::Column::with_children(sections)
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments are independent sidebar state slices"
|
||||
)]
|
||||
pub fn view(
|
||||
sidebar_view: SidebarView,
|
||||
posts: &[Post],
|
||||
@@ -633,7 +655,11 @@ pub fn view(
|
||||
row![
|
||||
header,
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "common.add")).size(11).shaping(Shaping::Advanced))
|
||||
button(
|
||||
text(t(locale, "common.add"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(action)
|
||||
.padding([3, 8])
|
||||
.style(filter_button_style),
|
||||
@@ -652,10 +678,7 @@ pub fn view(
|
||||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
// Search input + filter toggle row
|
||||
let search = text_input(
|
||||
&t(locale, "sidebar.filter.search"),
|
||||
&filter.search_query,
|
||||
)
|
||||
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||||
.on_input(Message::PostSearchChanged)
|
||||
.size(11)
|
||||
.padding([4, 6])
|
||||
@@ -673,16 +696,12 @@ pub fn view(
|
||||
} else {
|
||||
"\u{25BC}" // ▼ toggle icon
|
||||
};
|
||||
let filter_toggle = button(
|
||||
text(toggle_label).size(11).shaping(Shaping::Advanced)
|
||||
)
|
||||
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::TogglePostFilterPanel)
|
||||
.padding([4, 6])
|
||||
.style(toggle_style);
|
||||
|
||||
top_items.push(
|
||||
row![search, filter_toggle].spacing(4).into()
|
||||
);
|
||||
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||||
|
||||
// Filter panel (collapsible)
|
||||
if filter.filter_panel_visible {
|
||||
@@ -703,7 +722,7 @@ pub fn view(
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let section_header = |label: &str| -> Element<'static, Message> {
|
||||
@@ -724,7 +743,8 @@ pub fn view(
|
||||
};
|
||||
let date = format_post_date(p.created_at);
|
||||
let prefix_chars: usize = 5;
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
|
||||
let text_px = width
|
||||
- SIDEBAR_TEXT_OVERHEAD_PX
|
||||
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
|
||||
let display_title = truncate_to_fit(&p.title, text_px);
|
||||
let label = format!("{icon} {status_indicator} {display_title}");
|
||||
@@ -736,11 +756,15 @@ pub fn view(
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.50, 0.50, 0.55));
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
let style_fn = if is_active {
|
||||
item_active_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
button(
|
||||
container(column![label_text, date_text].spacing(1))
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
.clip(true),
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: p.id.clone(),
|
||||
@@ -756,7 +780,10 @@ pub fn view(
|
||||
};
|
||||
|
||||
// Draft section
|
||||
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
|
||||
let drafts: Vec<&Post> = posts
|
||||
.iter()
|
||||
.filter(|p| p.status == bds_core::model::PostStatus::Draft)
|
||||
.collect();
|
||||
if !drafts.is_empty() {
|
||||
top_items.push(section_header(&t(locale, "sidebar.drafts")));
|
||||
for p in &drafts {
|
||||
@@ -766,7 +793,10 @@ pub fn view(
|
||||
}
|
||||
|
||||
// Published section
|
||||
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
|
||||
let published: Vec<&Post> = posts
|
||||
.iter()
|
||||
.filter(|p| p.status == bds_core::model::PostStatus::Published)
|
||||
.collect();
|
||||
if !published.is_empty() {
|
||||
top_items.push(section_header(&t(locale, "sidebar.published")));
|
||||
for p in &published {
|
||||
@@ -776,7 +806,10 @@ pub fn view(
|
||||
}
|
||||
|
||||
// Archived section
|
||||
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
|
||||
let archived: Vec<&Post> = posts
|
||||
.iter()
|
||||
.filter(|p| p.status == bds_core::model::PostStatus::Archived)
|
||||
.collect();
|
||||
if !archived.is_empty() {
|
||||
top_items.push(section_header(&t(locale, "sidebar.archived")));
|
||||
for p in &archived {
|
||||
@@ -791,13 +824,13 @@ pub fn view(
|
||||
button(
|
||||
text(t(locale, "sidebar.loadMore"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::LoadMorePosts)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(filter_button_style)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -811,10 +844,7 @@ pub fn view(
|
||||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
// Search input + filter toggle row
|
||||
let search = text_input(
|
||||
&t(locale, "sidebar.filter.search"),
|
||||
&filter.search_query,
|
||||
)
|
||||
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||||
.on_input(Message::MediaSearchChanged)
|
||||
.size(11)
|
||||
.padding([4, 6])
|
||||
@@ -832,16 +862,12 @@ pub fn view(
|
||||
} else {
|
||||
"\u{25BC}" // ▼ toggle icon
|
||||
};
|
||||
let filter_toggle = button(
|
||||
text(toggle_label).size(11).shaping(Shaping::Advanced)
|
||||
)
|
||||
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::ToggleMediaFilterPanel)
|
||||
.padding([4, 6])
|
||||
.style(toggle_style);
|
||||
|
||||
top_items.push(
|
||||
row![search, filter_toggle].spacing(4).into()
|
||||
);
|
||||
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||||
|
||||
// Filter panel (collapsible)
|
||||
if filter.filter_panel_visible {
|
||||
@@ -861,7 +887,7 @@ pub fn view(
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = media
|
||||
@@ -874,18 +900,20 @@ pub fn view(
|
||||
};
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
|
||||
let display_name = truncate_to_fit(&display_name, text_px);
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
let style_fn = if is_active {
|
||||
item_active_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
|
||||
// Left: 40x40 thumbnail or file icon (pre-resolved, no filesystem I/O)
|
||||
let thumb_path = media_thumbs
|
||||
.get(&m.id)
|
||||
.and_then(|opt| opt.as_ref());
|
||||
let thumb_path = media_thumbs.get(&m.id).and_then(|opt| opt.as_ref());
|
||||
let left: Element<'static, Message> = if let Some(path) = thumb_path {
|
||||
container(
|
||||
image(path.to_string_lossy().to_string())
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
.content_fit(iced::ContentFit::Cover)
|
||||
.content_fit(iced::ContentFit::Cover),
|
||||
)
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
@@ -896,7 +924,7 @@ pub fn view(
|
||||
container(
|
||||
text("\u{1F4C4}") // 📄 generic file icon
|
||||
.size(20)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
@@ -924,13 +952,11 @@ pub fn view(
|
||||
|
||||
let right = column![name_text, meta_text].spacing(1);
|
||||
|
||||
let content = row![left, right].spacing(8).align_y(iced::Alignment::Center);
|
||||
let content = row![left, right]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
button(
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
button(container(content).width(Length::Fill).clip(true))
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: m.id.clone(),
|
||||
tab_type: TabType::Media,
|
||||
@@ -953,13 +979,13 @@ pub fn view(
|
||||
button(
|
||||
text(t(locale, "sidebar.loadMore"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::LoadMoreMedia)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(filter_button_style)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -986,12 +1012,12 @@ pub fn view(
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None);
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
let style_fn = if is_active {
|
||||
item_active_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
button(container(label_text).width(Length::Fill).clip(true))
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: s.id.clone(),
|
||||
tab_type: TabType::Scripts,
|
||||
@@ -1005,9 +1031,7 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
}
|
||||
SidebarView::Templates => {
|
||||
@@ -1028,12 +1052,12 @@ pub fn view(
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None);
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
let style_fn = if is_active {
|
||||
item_active_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
button(container(label_text).width(Length::Fill).clip(true))
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: tmpl.id.clone(),
|
||||
tab_type: TabType::Templates,
|
||||
@@ -1047,9 +1071,7 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
}
|
||||
SidebarView::Settings => {
|
||||
@@ -1070,9 +1092,7 @@ pub fn view(
|
||||
.iter()
|
||||
.map(|(key, section_opt)| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||||
let msg = if let Some(section) = section_opt {
|
||||
Message::OpenSettingsSection(section.clone())
|
||||
} else {
|
||||
@@ -1084,10 +1104,7 @@ pub fn view(
|
||||
is_dirty: false,
|
||||
})
|
||||
};
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
button(container(label_text).width(Length::Fill))
|
||||
.on_press(msg)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
@@ -1095,28 +1112,17 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
SidebarView::Tags => {
|
||||
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
|
||||
let sections = [
|
||||
"tags.nav.cloud",
|
||||
"tags.nav.manage",
|
||||
"tags.nav.merge",
|
||||
];
|
||||
let sections = ["tags.nav.cloud", "tags.nav.manage", "tags.nav.merge"];
|
||||
let items: Vec<Element<'static, Message>> = sections
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||||
button(container(label_text).width(Length::Fill))
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: "tags".to_string(),
|
||||
tab_type: TabType::Tags,
|
||||
@@ -1130,24 +1136,16 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
_ => {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
_ => text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
}
|
||||
.into(),
|
||||
};
|
||||
|
||||
let content = column![
|
||||
header_row,
|
||||
Space::with_height(8.0),
|
||||
body,
|
||||
]
|
||||
let content = column![header_row, Space::with_height(8.0), body,]
|
||||
.spacing(4)
|
||||
.padding(12);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user